init commit

This commit is contained in:
ysandler
2026-01-22 23:03:49 -06:00
commit 1eae04fc60
21 changed files with 1688 additions and 0 deletions

0
.gitignore vendored Normal file
View File

13
Makefile Normal file
View File

@@ -0,0 +1,13 @@
VERSION := $(shell cat VERSION)
LDFLAGS := -ldflags "-X github.com/yeho/doks/cmd.Version=$(VERSION)"
.PHONY: build install clean
build:
go build $(LDFLAGS) -o doks
install:
go install $(LDFLAGS)
clean:
rm -f doks

120
README.md Normal file
View File

@@ -0,0 +1,120 @@
# doks
A terminal application for quickly accessing personal notes and documents.
## Features
- **Key-based access** - Retrieve documents instantly by registered key
- **Full-text search** - Search across all documents with ripgrep (or pure-Go fallback)
- **Interactive TUI** - Navigate multiple search results with keyboard
- **Symlink support** - Keep files in original location while storing in doks
- **Shell completions** - Tab completion for bash, zsh, and fish
## Installation
```bash
# Clone and install
git clone https://github.com/yeho/doks.git
cd doks
./install.sh
```
Or build manually:
```bash
make build
sudo mv doks /usr/local/bin/
```
## Usage
### Add a document
```bash
# Basic add
doks add ./notes.md -k mynotes
# Add with symlink preservation (keeps file accessible at original path)
doks add ./cheatsheet.md -k git -p
# Add with tags and description
doks add ./docker.md -k docker -t "containers,devops" -d "Docker commands"
```
### Retrieve a document
```bash
# By key
doks -k git
# Search
doks -s "commit"
```
### Other commands
```bash
# List all documents
doks list
# Filter by tag
doks list -t devops
# Edit a document
doks edit git
# Remove a document (keeps file in storage)
doks remove git
# Remove and delete file
doks remove git --delete
# Show version
doks -v
```
## Configuration
Config file: `~/.config/doks/config.json`
```json
{
"doksStorageDir": "/home/user/doks",
"defaultEditor": "vim",
"search": {
"engine": "text",
"vectorEnabled": false
},
"display": {
"contextLines": 3,
"maxResults": 20
}
}
```
## Storage
Documents are stored in `~/doks/` by default:
```
~/doks/
├── doksRegistry.db # Document registry (NDJSON)
└── doksFiles/ # Stored documents
```
## Shell Completions
```bash
# Bash
doks completion bash > /etc/bash_completion.d/doks
# Zsh
doks completion zsh > "${fpath[1]}/_doks"
# Fish
doks completion fish > ~/.config/fish/completions/doks.fish
```
## License
MIT

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.1.0

159
cmd/add.go Normal file
View File

@@ -0,0 +1,159 @@
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/yeho/doks/internal/registry"
"github.com/yeho/doks/internal/storage"
)
var (
addKey string
addPreserve bool
addTags string
addDescription string
)
var addCmd = &cobra.Command{
Use: "add <path>",
Short: "Add a document to doks",
Long: `Add a document to doks by copying it to the storage directory.
Use -p to create a symlink at the original location pointing to the stored file.`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
filePath := args[0]
if addKey == "" {
fmt.Fprintln(os.Stderr, "Error: -k (key) flag is required")
os.Exit(1)
}
absPath, err := storage.GetAbsolutePath(filePath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving path: %v\n", err)
os.Exit(1)
}
// Check if file exists
if _, err := os.Stat(absPath); os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "Error: file not found: %s\n", absPath)
os.Exit(1)
}
// Load registry
reg, err := registry.Load(cfg.RegistryPath())
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading registry: %v\n", err)
os.Exit(1)
}
// Check if key already exists
if reg.Exists(addKey) {
fmt.Fprintf(os.Stderr, "Error: key '%s' already exists\n", addKey)
os.Exit(1)
}
// Generate unique filename
filename := storage.GenerateFilename(absPath)
// Create storage and copy file
store := storage.New(cfg.FilesDir())
if err := store.CopyFile(absPath, filename); err != nil {
fmt.Fprintf(os.Stderr, "Error copying file: %v\n", err)
os.Exit(1)
}
// Remove original and create symlink if preserve flag is set
if addPreserve {
if err := os.Remove(absPath); err != nil {
fmt.Fprintf(os.Stderr, "Error removing original file: %v\n", err)
// Clean up copied file
store.RemoveFile(filename)
os.Exit(1)
}
if err := store.CreateSymlink(absPath, filename); err != nil {
fmt.Fprintf(os.Stderr, "Error creating symlink: %v\n", err)
os.Exit(1)
}
}
// Parse tags
var tags []string
if addTags != "" {
tags = splitTags(addTags)
}
// Create registry entry
entry := &registry.Entry{
Key: addKey,
Filename: filename,
OriginalPath: absPath,
HasSymlink: addPreserve,
Tags: tags,
Description: addDescription,
}
if err := reg.Add(entry); err != nil {
fmt.Fprintf(os.Stderr, "Error adding to registry: %v\n", err)
// Clean up
store.RemoveFile(filename)
if addPreserve {
store.RemoveSymlink(absPath)
}
os.Exit(1)
}
fmt.Printf("Added '%s' with key '%s'\n", filePath, addKey)
},
}
func init() {
rootCmd.AddCommand(addCmd)
addCmd.Flags().StringVarP(&addKey, "key", "k", "", "Unique key for the document (required)")
addCmd.Flags().BoolVarP(&addPreserve, "preserve", "p", false, "Create symlink at original location")
addCmd.Flags().StringVarP(&addTags, "tags", "t", "", "Comma-separated tags")
addCmd.Flags().StringVarP(&addDescription, "description", "d", "", "Description of the document")
addCmd.MarkFlagRequired("key")
}
func splitTags(tags string) []string {
if tags == "" {
return nil
}
result := []string{}
for _, tag := range splitString(tags, ",") {
tag = trimSpace(tag)
if tag != "" {
result = append(result, tag)
}
}
return result
}
func splitString(s, sep string) []string {
var result []string
start := 0
for i := 0; i < len(s); i++ {
if i+len(sep) <= len(s) && s[i:i+len(sep)] == sep {
result = append(result, s[start:i])
start = i + len(sep)
}
}
result = append(result, s[start:])
return result
}
func trimSpace(s string) string {
start := 0
end := len(s)
for start < end && (s[start] == ' ' || s[start] == '\t') {
start++
}
for end > start && (s[end-1] == ' ' || s[end-1] == '\t') {
end--
}
return s[start:end]
}

59
cmd/edit.go Normal file
View File

@@ -0,0 +1,59 @@
package cmd
import (
"fmt"
"os"
"os/exec"
"github.com/spf13/cobra"
"github.com/yeho/doks/internal/registry"
)
var editCmd = &cobra.Command{
Use: "edit <key>",
Short: "Edit a document in your configured editor",
Long: `Open a document in your configured editor (default: vim). Set defaultEditor in config to change.`,
Args: cobra.ExactArgs(1),
ValidArgsFunction: completeKeys,
Run: func(cmd *cobra.Command, args []string) {
key := args[0]
reg, err := registry.Load(cfg.RegistryPath())
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading registry: %v\n", err)
os.Exit(1)
}
entry, err := reg.Get(key)
if err != nil {
fmt.Fprintf(os.Stderr, "Document not found: %s\n", key)
os.Exit(1)
}
filePath := cfg.FilePath(entry.Filename)
// Get editor from config, fallback to EDITOR env, then vim
editor := cfg.DefaultEditor
if editor == "" {
editor = os.Getenv("EDITOR")
}
if editor == "" {
editor = "vim"
}
// Run editor
editorCmd := exec.Command(editor, filePath)
editorCmd.Stdin = os.Stdin
editorCmd.Stdout = os.Stdout
editorCmd.Stderr = os.Stderr
if err := editorCmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error running editor: %v\n", err)
os.Exit(1)
}
},
}
func init() {
rootCmd.AddCommand(editCmd)
}

93
cmd/list.go Normal file
View File

@@ -0,0 +1,93 @@
package cmd
import (
"fmt"
"os"
"sort"
"github.com/spf13/cobra"
"github.com/yeho/doks/internal/registry"
)
var listTagFilter string
var listCmd = &cobra.Command{
Use: "list",
Short: "List all registered documents",
Long: `Display all documents registered in doks, optionally filtered by tag.`,
Run: func(cmd *cobra.Command, args []string) {
reg, err := registry.Load(cfg.RegistryPath())
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading registry: %v\n", err)
os.Exit(1)
}
entries := reg.List()
if len(entries) == 0 {
fmt.Println("No documents registered")
return
}
// Sort by key
sort.Slice(entries, func(i, j int) bool {
return entries[i].Key < entries[j].Key
})
// Filter by tag if specified
if listTagFilter != "" {
filtered := make([]*registry.Entry, 0)
for _, entry := range entries {
if hasTag(entry.Tags, listTagFilter) {
filtered = append(filtered, entry)
}
}
entries = filtered
}
if len(entries) == 0 {
fmt.Printf("No documents found with tag '%s'\n", listTagFilter)
return
}
// Print entries
for _, entry := range entries {
symlink := ""
if entry.HasSymlink {
symlink = " [symlinked]"
}
tags := ""
if len(entry.Tags) > 0 {
tags = fmt.Sprintf(" [%s]", joinStrings(entry.Tags, ", "))
}
fmt.Printf(" %s%s%s\n", entry.Key, tags, symlink)
if entry.Description != "" {
fmt.Printf(" %s\n", entry.Description)
}
}
},
}
func init() {
rootCmd.AddCommand(listCmd)
listCmd.Flags().StringVarP(&listTagFilter, "tag", "t", "", "Filter by tag")
}
func hasTag(tags []string, target string) bool {
for _, tag := range tags {
if tag == target {
return true
}
}
return false
}
func joinStrings(strs []string, sep string) string {
if len(strs) == 0 {
return ""
}
result := strs[0]
for i := 1; i < len(strs); i++ {
result += sep + strs[i]
}
return result
}

71
cmd/remove.go Normal file
View File

@@ -0,0 +1,71 @@
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/yeho/doks/internal/registry"
"github.com/yeho/doks/internal/storage"
)
var removeDelete bool
var removeCmd = &cobra.Command{
Use: "remove <key>",
Short: "Remove a document from doks",
Long: `Remove a document from doks by its key.
By default, only the registry entry is removed. Use --delete to also delete the stored file.`,
Args: cobra.ExactArgs(1),
ValidArgsFunction: completeKeys,
Run: func(cmd *cobra.Command, args []string) {
key := args[0]
// Load registry
reg, err := registry.Load(cfg.RegistryPath())
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading registry: %v\n", err)
os.Exit(1)
}
// Get entry
entry, err := reg.Get(key)
if err != nil {
fmt.Fprintf(os.Stderr, "Document not found: %s\n", key)
os.Exit(1)
}
store := storage.New(cfg.FilesDir())
// Remove symlink if exists
if entry.HasSymlink {
if err := store.RemoveSymlink(entry.OriginalPath); err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not remove symlink: %v\n", err)
}
}
// Delete file if requested
if removeDelete {
if err := store.RemoveFile(entry.Filename); err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not delete file: %v\n", err)
}
}
// Remove from registry
if err := reg.Remove(key); err != nil {
fmt.Fprintf(os.Stderr, "Error removing from registry: %v\n", err)
os.Exit(1)
}
if removeDelete {
fmt.Printf("Removed '%s' and deleted file\n", key)
} else {
fmt.Printf("Removed '%s' from registry (file kept in storage)\n", key)
}
},
}
func init() {
rootCmd.AddCommand(removeCmd)
removeCmd.Flags().BoolVar(&removeDelete, "delete", false, "Also delete the stored file")
}

185
cmd/root.go Normal file
View File

@@ -0,0 +1,185 @@
package cmd
import (
"fmt"
"os"
"os/exec"
"github.com/spf13/cobra"
"github.com/yeho/doks/internal/config"
"github.com/yeho/doks/internal/registry"
"github.com/yeho/doks/internal/search"
"github.com/yeho/doks/internal/tui"
)
var (
Version = "dev" // Set at build time via ldflags
keyFlag string
searchFlag string
versionFlag bool
cfg *config.Config
)
var rootCmd = &cobra.Command{
Use: "doks",
Short: "A terminal app for quick access to personal notes",
Long: `doks is a terminal application for quickly accessing short notes
and manuals. Access documents by key or search through content.`,
Run: func(cmd *cobra.Command, args []string) {
if versionFlag {
fmt.Println(Version)
return
}
if keyFlag != "" {
retrieveByKey(keyFlag)
return
}
if searchFlag != "" {
searchDocuments(searchFlag)
return
}
cmd.Help()
},
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func init() {
cobra.OnInitialize(initConfig)
rootCmd.Flags().StringVarP(&keyFlag, "key", "k", "", "Retrieve document by key")
rootCmd.Flags().StringVarP(&searchFlag, "search", "s", "", "Search documents by text")
rootCmd.Flags().BoolVarP(&versionFlag, "version", "v", false, "Print version")
// Register key completion
rootCmd.RegisterFlagCompletionFunc("key", completeKeys)
}
func completeKeys(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// Need to initialize config for completion
if cfg == nil {
var err error
cfg, err = config.Load()
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
}
reg, err := registry.Load(cfg.RegistryPath())
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
keys := []string{}
for _, entry := range reg.List() {
keys = append(keys, entry.Key)
}
return keys, cobra.ShellCompDirectiveNoFileComp
}
func initConfig() {
var err error
cfg, err = config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
}
func retrieveByKey(key string) {
reg, err := registry.Load(cfg.RegistryPath())
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading registry: %v\n", err)
os.Exit(1)
}
entry, err := reg.Get(key)
if err != nil {
fmt.Fprintf(os.Stderr, "Document not found: %s\n", key)
os.Exit(1)
}
filePath := cfg.FilePath(entry.Filename)
displayFile(filePath, 0)
}
func searchDocuments(query string) {
reg, err := registry.Load(cfg.RegistryPath())
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading registry: %v\n", err)
os.Exit(1)
}
searcher := search.NewTextSearcher(cfg.FilesDir(), reg)
opts := search.Options{
MaxResults: cfg.Display.MaxResults,
ContextLines: cfg.Display.ContextLines,
}
results, err := searcher.Search(query, opts)
if err != nil {
fmt.Fprintf(os.Stderr, "Error searching: %v\n", err)
os.Exit(1)
}
if len(results) == 0 {
fmt.Printf("No results found for '%s'\n", query)
return
}
// If only one result, show it directly
if len(results) == 1 {
showResult(&results[0])
return
}
// Multiple results - show TUI
selected, err := tui.Run(results, query)
if err != nil {
fmt.Fprintf(os.Stderr, "Error running TUI: %v\n", err)
os.Exit(1)
}
if selected != nil {
showResult(selected)
}
}
func showResult(result *search.Result) {
fmt.Printf("--- %s (line %d) ---\n", result.Key, result.LineNumber)
displayFile(result.FilePath, result.LineNumber)
}
func displayFile(filePath string, highlightLine int) {
// Try bat first for syntax highlighting
if batPath, err := exec.LookPath("bat"); err == nil {
args := []string{
"--paging=never",
"--color=always",
}
if highlightLine > 0 {
args = append(args, fmt.Sprintf("--highlight-line=%d", highlightLine))
}
args = append(args, filePath)
cmd := exec.Command(batPath, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err == nil {
return
}
}
// Fallback to plain output
content, err := os.ReadFile(filePath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading file: %v\n", err)
os.Exit(1)
}
fmt.Print(string(content))
}

43
go.mod Normal file
View File

@@ -0,0 +1,43 @@
module github.com/yeho/doks
go 1.24.0
toolchain go1.24.12
require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.28.0 // indirect
)

93
go.sum Normal file
View File

@@ -0,0 +1,93 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

16
install.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/bash
set -e
INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}"
echo "Building doks..."
make build
echo "Installing to $INSTALL_DIR..."
if [ -w "$INSTALL_DIR" ]; then
mv doks "$INSTALL_DIR/"
else
sudo mv doks "$INSTALL_DIR/"
fi
echo "Done! doks $(doks -v) installed to $INSTALL_DIR/doks"

144
internal/config/config.go Normal file
View File

@@ -0,0 +1,144 @@
package config
import (
"os"
"path/filepath"
"github.com/spf13/viper"
)
type Config struct {
DoksStorageDir string `json:"doksStorageDir" mapstructure:"doksStorageDir"`
DefaultEditor string `json:"defaultEditor" mapstructure:"defaultEditor"`
Search SearchConfig `json:"search" mapstructure:"search"`
Embeddings EmbedConfig `json:"embeddings" mapstructure:"embeddings"`
Display DisplayConfig `json:"display" mapstructure:"display"`
}
type SearchConfig struct {
Engine string `json:"engine" mapstructure:"engine"`
VectorEnabled bool `json:"vectorEnabled" mapstructure:"vectorEnabled"`
}
type EmbedConfig struct {
Provider string `json:"provider" mapstructure:"provider"`
BaseURL string `json:"baseUrl" mapstructure:"baseUrl"`
APIKey string `json:"apiKey" mapstructure:"apiKey"`
Model string `json:"model" mapstructure:"model"`
}
type DisplayConfig struct {
ContextLines int `json:"contextLines" mapstructure:"contextLines"`
MaxResults int `json:"maxResults" mapstructure:"maxResults"`
}
func DefaultConfig() *Config {
homeDir, _ := os.UserHomeDir()
return &Config{
DoksStorageDir: filepath.Join(homeDir, "doks"),
DefaultEditor: "vim",
Search: SearchConfig{
Engine: "text",
VectorEnabled: false,
},
Embeddings: EmbedConfig{
Provider: "openai",
BaseURL: "https://api.openai.com/v1",
APIKey: "",
Model: "text-embedding-3-small",
},
Display: DisplayConfig{
ContextLines: 3,
MaxResults: 20,
},
}
}
func Load() (*Config, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, err
}
configDir := filepath.Join(homeDir, ".config", "doks")
if err := os.MkdirAll(configDir, 0755); err != nil {
return nil, err
}
viper.SetConfigName("config")
viper.SetConfigType("json")
viper.AddConfigPath(configDir)
// Set defaults
defaults := DefaultConfig()
viper.SetDefault("doksStorageDir", defaults.DoksStorageDir)
viper.SetDefault("defaultEditor", defaults.DefaultEditor)
viper.SetDefault("search.engine", defaults.Search.Engine)
viper.SetDefault("search.vectorEnabled", defaults.Search.VectorEnabled)
viper.SetDefault("embeddings.provider", defaults.Embeddings.Provider)
viper.SetDefault("embeddings.baseUrl", defaults.Embeddings.BaseURL)
viper.SetDefault("embeddings.apiKey", defaults.Embeddings.APIKey)
viper.SetDefault("embeddings.model", defaults.Embeddings.Model)
viper.SetDefault("display.contextLines", defaults.Display.ContextLines)
viper.SetDefault("display.maxResults", defaults.Display.MaxResults)
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
// Config file not found, create with defaults
configPath := filepath.Join(configDir, "config.json")
if err := viper.SafeWriteConfigAs(configPath); err != nil {
return nil, err
}
} else {
return nil, err
}
}
var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
return nil, err
}
// Expand ~ in storage dir
cfg.DoksStorageDir = expandPath(cfg.DoksStorageDir)
// Ensure storage directory exists
if err := os.MkdirAll(cfg.DoksStorageDir, 0755); err != nil {
return nil, err
}
// Ensure doksFiles subdirectory exists
filesDir := filepath.Join(cfg.DoksStorageDir, "doksFiles")
if err := os.MkdirAll(filesDir, 0755); err != nil {
return nil, err
}
return &cfg, nil
}
func (c *Config) RegistryPath() string {
return filepath.Join(c.DoksStorageDir, "doksRegistry.db")
}
func (c *Config) FilesDir() string {
return filepath.Join(c.DoksStorageDir, "doksFiles")
}
func (c *Config) FilePath(filename string) string {
return filepath.Join(c.FilesDir(), filename)
}
func (c *Config) VectorsDir() string {
return filepath.Join(c.DoksStorageDir, "vectors")
}
func expandPath(path string) string {
if len(path) > 0 && path[0] == '~' {
homeDir, err := os.UserHomeDir()
if err != nil {
return path
}
return filepath.Join(homeDir, path[1:])
}
return path
}

View File

@@ -0,0 +1,14 @@
package registry
import "time"
type Entry struct {
Key string `json:"key"`
Filename string `json:"filename"`
OriginalPath string `json:"originalPath"`
HasSymlink bool `json:"hasSymlink"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Tags []string `json:"tags,omitempty"`
Description string `json:"description,omitempty"`
}

View File

@@ -0,0 +1,137 @@
package registry
import (
"bufio"
"encoding/json"
"errors"
"os"
"time"
)
var (
ErrNotFound = errors.New("entry not found")
ErrKeyExists = errors.New("key already exists")
ErrInvalidEntry = errors.New("invalid entry")
)
type Registry struct {
path string
entries map[string]*Entry
}
func Load(path string) (*Registry, error) {
r := &Registry{
path: path,
entries: make(map[string]*Entry),
}
file, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return r, nil
}
return nil, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
var entry Entry
if err := json.Unmarshal([]byte(line), &entry); err != nil {
continue
}
r.entries[entry.Key] = &entry
}
if err := scanner.Err(); err != nil {
return nil, err
}
return r, nil
}
func (r *Registry) Save() error {
file, err := os.Create(r.path)
if err != nil {
return err
}
defer file.Close()
for _, entry := range r.entries {
data, err := json.Marshal(entry)
if err != nil {
return err
}
if _, err := file.Write(data); err != nil {
return err
}
if _, err := file.WriteString("\n"); err != nil {
return err
}
}
return nil
}
func (r *Registry) Add(entry *Entry) error {
if entry.Key == "" {
return ErrInvalidEntry
}
if _, exists := r.entries[entry.Key]; exists {
return ErrKeyExists
}
now := time.Now()
entry.CreatedAt = now
entry.UpdatedAt = now
r.entries[entry.Key] = entry
return r.Save()
}
func (r *Registry) Get(key string) (*Entry, error) {
entry, exists := r.entries[key]
if !exists {
return nil, ErrNotFound
}
return entry, nil
}
func (r *Registry) Update(entry *Entry) error {
if _, exists := r.entries[entry.Key]; !exists {
return ErrNotFound
}
entry.UpdatedAt = time.Now()
r.entries[entry.Key] = entry
return r.Save()
}
func (r *Registry) Remove(key string) error {
if _, exists := r.entries[key]; !exists {
return ErrNotFound
}
delete(r.entries, key)
return r.Save()
}
func (r *Registry) List() []*Entry {
entries := make([]*Entry, 0, len(r.entries))
for _, entry := range r.entries {
entries = append(entries, entry)
}
return entries
}
func (r *Registry) Exists(key string) bool {
_, exists := r.entries[key]
return exists
}

27
internal/search/search.go Normal file
View File

@@ -0,0 +1,27 @@
package search
type Result struct {
Key string
Filename string
FilePath string
LineNumber int
LineText string
Context []string // Lines before/after for display
Score float64 // For vector search relevance
}
type Options struct {
MaxResults int
ContextLines int
}
func DefaultOptions() Options {
return Options{
MaxResults: 20,
ContextLines: 2,
}
}
type Searcher interface {
Search(query string, opts Options) ([]Result, error)
}

View File

@@ -0,0 +1,168 @@
package search
import (
"bufio"
"bytes"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"github.com/yeho/doks/internal/registry"
)
type TextSearcher struct {
filesDir string
registry *registry.Registry
hasRipgrep bool
contextLines int
}
func NewTextSearcher(filesDir string, reg *registry.Registry) *TextSearcher {
_, err := exec.LookPath("rg")
return &TextSearcher{
filesDir: filesDir,
registry: reg,
hasRipgrep: err == nil,
}
}
func (t *TextSearcher) Search(query string, opts Options) ([]Result, error) {
if t.hasRipgrep {
return t.searchWithRipgrep(query, opts)
}
return t.searchPureGo(query, opts)
}
func (t *TextSearcher) searchWithRipgrep(query string, opts Options) ([]Result, error) {
contextArg := strconv.Itoa(opts.ContextLines)
cmd := exec.Command("rg",
"--line-number",
"--with-filename",
"--context", contextArg,
"--max-count", strconv.Itoa(opts.MaxResults*2), // Get more than needed, will dedupe
"-i", // Case insensitive
query,
t.filesDir,
)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
// Exit code 1 means no matches, which is fine
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return []Result{}, nil
}
// Other errors might be okay too (e.g., some files couldn't be read)
if stdout.Len() == 0 {
return []Result{}, nil
}
}
return t.parseRipgrepOutput(stdout.String(), opts)
}
func (t *TextSearcher) parseRipgrepOutput(output string, opts Options) ([]Result, error) {
results := []Result{}
seen := make(map[string]bool) // Track unique file:line combinations
lines := strings.Split(output, "\n")
for _, line := range lines {
if line == "" || line == "--" {
continue
}
// ripgrep output format: filename:linenum:text or filename-linenum-text (context)
// We want the matching lines (with :)
parts := strings.SplitN(line, ":", 3)
if len(parts) < 3 {
continue
}
filename := filepath.Base(parts[0])
lineNum, err := strconv.Atoi(parts[1])
if err != nil {
continue
}
lineText := parts[2]
// Find the key for this file
key := t.findKeyByFilename(filename)
if key == "" {
continue
}
// Deduplicate
dedupKey := filename + ":" + strconv.Itoa(lineNum)
if seen[dedupKey] {
continue
}
seen[dedupKey] = true
results = append(results, Result{
Key: key,
Filename: filename,
FilePath: parts[0],
LineNumber: lineNum,
LineText: strings.TrimSpace(lineText),
})
if len(results) >= opts.MaxResults {
break
}
}
return results, nil
}
func (t *TextSearcher) searchPureGo(query string, opts Options) ([]Result, error) {
results := []Result{}
queryLower := strings.ToLower(query)
entries := t.registry.List()
for _, entry := range entries {
filePath := filepath.Join(t.filesDir, entry.Filename)
file, err := os.Open(filePath)
if err != nil {
continue
}
scanner := bufio.NewScanner(file)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
if strings.Contains(strings.ToLower(line), queryLower) {
results = append(results, Result{
Key: entry.Key,
Filename: entry.Filename,
FilePath: filePath,
LineNumber: lineNum,
LineText: strings.TrimSpace(line),
})
if len(results) >= opts.MaxResults {
file.Close()
return results, nil
}
}
}
file.Close()
}
return results, nil
}
func (t *TextSearcher) findKeyByFilename(filename string) string {
entries := t.registry.List()
for _, entry := range entries {
if entry.Filename == filename {
return entry.Key
}
}
return ""
}

100
internal/storage/storage.go Normal file
View File

@@ -0,0 +1,100 @@
package storage
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
type Storage struct {
filesDir string
}
func New(filesDir string) *Storage {
return &Storage{filesDir: filesDir}
}
func (s *Storage) CopyFile(srcPath string, destFilename string) error {
src, err := os.Open(srcPath)
if err != nil {
return fmt.Errorf("failed to open source file: %w", err)
}
defer src.Close()
destPath := filepath.Join(s.filesDir, destFilename)
dest, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("failed to create destination file: %w", err)
}
defer dest.Close()
if _, err := io.Copy(dest, src); err != nil {
return fmt.Errorf("failed to copy file: %w", err)
}
return nil
}
func (s *Storage) RemoveFile(filename string) error {
path := filepath.Join(s.filesDir, filename)
return os.Remove(path)
}
func (s *Storage) CreateSymlink(originalPath, storedFilename string) error {
storedPath := filepath.Join(s.filesDir, storedFilename)
return os.Symlink(storedPath, originalPath)
}
func (s *Storage) RemoveSymlink(originalPath string) error {
info, err := os.Lstat(originalPath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return os.Remove(originalPath)
}
return nil
}
func (s *Storage) FilePath(filename string) string {
return filepath.Join(s.filesDir, filename)
}
func (s *Storage) FileExists(filename string) bool {
path := filepath.Join(s.filesDir, filename)
_, err := os.Stat(path)
return err == nil
}
func GenerateFilename(originalPath string) string {
base := filepath.Base(originalPath)
ext := filepath.Ext(base)
name := strings.TrimSuffix(base, ext)
hash := sha256.Sum256([]byte(originalPath))
shortHash := hex.EncodeToString(hash[:])[:8]
return fmt.Sprintf("%s_%s%s", name, shortHash, ext)
}
func GetAbsolutePath(path string) (string, error) {
if filepath.IsAbs(path) {
return path, nil
}
cwd, err := os.Getwd()
if err != nil {
return "", err
}
return filepath.Join(cwd, path), nil
}

51
internal/tui/styles.go Normal file
View File

@@ -0,0 +1,51 @@
package tui
import "github.com/charmbracelet/lipgloss"
var (
// Colors
secondaryColor = lipgloss.Color("241") // Gray
dimColor = lipgloss.Color("245") // Dimmer gray for matched text
// Styles
TitleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("213")). // Magenta/pink for title
MarginBottom(1)
// Key styles - bold and bright to stand out
KeyStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("82")) // Bright green
KeyStyleSelected = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("154")) // Even brighter green for selected
LineNumStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("244")) // Medium gray
LineNumStyleSelected = lipgloss.NewStyle().
Foreground(lipgloss.Color("250")) // Lighter for selected
// Match text - dimmer to not compete with key
MatchTextStyle = lipgloss.NewStyle().
Foreground(dimColor)
MatchTextStyleSelected = lipgloss.NewStyle().
Foreground(lipgloss.Color("252")) // Slightly brighter when selected
// Cursor
CursorStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("212"))
HelpStyle = lipgloss.NewStyle().
Foreground(secondaryColor).
MarginTop(1)
NoResultsStyle = lipgloss.NewStyle().
Foreground(secondaryColor).
Italic(true).
MarginTop(1)
)

187
internal/tui/tui.go Normal file
View File

@@ -0,0 +1,187 @@
package tui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/yeho/doks/internal/search"
)
type Model struct {
results []search.Result
cursor int
selected *search.Result
query string
quitting bool
width int
height int
}
func New(results []search.Result, query string) Model {
return Model{
results: results,
cursor: 0,
query: query,
}
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q", "esc":
m.quitting = true
return m, tea.Quit
case "enter":
if len(m.results) > 0 && m.cursor < len(m.results) {
m.selected = &m.results[m.cursor]
}
return m, tea.Quit
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.results)-1 {
m.cursor++
}
case "home", "g":
m.cursor = 0
case "end", "G":
m.cursor = len(m.results) - 1
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
}
return m, nil
}
func (m Model) View() string {
if len(m.results) == 0 {
return NoResultsStyle.Render(fmt.Sprintf("No results found for '%s'", m.query))
}
var b strings.Builder
// Title
title := fmt.Sprintf("Search results for '%s' (%d found)", m.query, len(m.results))
b.WriteString(TitleStyle.Render(title))
b.WriteString("\n\n")
// Results
visibleResults := m.results
maxVisible := 10
if len(visibleResults) > maxVisible {
// Show results around cursor
start := m.cursor - maxVisible/2
if start < 0 {
start = 0
}
end := start + maxVisible
if end > len(visibleResults) {
end = len(visibleResults)
start = end - maxVisible
if start < 0 {
start = 0
}
}
visibleResults = m.results[start:end]
}
for i, result := range m.results {
// Check if this result is visible
visible := false
for _, vr := range visibleResults {
if vr.Key == result.Key && vr.LineNumber == result.LineNumber {
visible = true
break
}
}
if !visible {
continue
}
isSelected := i == m.cursor
// Cursor indicator
cursor := " "
if isSelected {
cursor = CursorStyle.Render("> ")
}
// Key - bold and bright (even brighter when selected)
var key string
if isSelected {
key = KeyStyleSelected.Render(result.Key)
} else {
key = KeyStyle.Render(result.Key)
}
// Line number - dimmer
var lineNum string
if isSelected {
lineNum = LineNumStyleSelected.Render(fmt.Sprintf(":%d", result.LineNumber))
} else {
lineNum = LineNumStyle.Render(fmt.Sprintf(":%d", result.LineNumber))
}
// Truncate line text if too long
lineText := result.LineText
maxLineLen := 50
if len(lineText) > maxLineLen {
lineText = lineText[:maxLineLen-3] + "..."
}
// Match text - dimmer than key
var matchText string
if isSelected {
matchText = MatchTextStyleSelected.Render(lineText)
} else {
matchText = MatchTextStyle.Render(lineText)
}
line := fmt.Sprintf("%s%s%s %s", cursor, key, lineNum, matchText)
b.WriteString(line)
b.WriteString("\n")
}
// Help
help := "↑/k up • ↓/j down • enter select • q/esc quit"
b.WriteString(HelpStyle.Render(help))
return b.String()
}
func (m Model) Selected() *search.Result {
return m.selected
}
func (m Model) Quitting() bool {
return m.quitting
}
func Run(results []search.Result, query string) (*search.Result, error) {
model := New(results, query)
p := tea.NewProgram(model)
finalModel, err := p.Run()
if err != nil {
return nil, err
}
m := finalModel.(Model)
return m.Selected(), nil
}

7
main.go Normal file
View File

@@ -0,0 +1,7 @@
package main
import "github.com/yeho/doks/cmd"
func main() {
cmd.Execute()
}