From 1eae04fc601613e31acd6fbe7329f9aa731d0c95 Mon Sep 17 00:00:00 2001 From: ysandler Date: Thu, 22 Jan 2026 23:03:49 -0600 Subject: [PATCH] init commit --- .gitignore | 0 Makefile | 13 +++ README.md | 120 +++++++++++++++++++++ VERSION | 1 + cmd/add.go | 159 ++++++++++++++++++++++++++++ cmd/edit.go | 59 +++++++++++ cmd/list.go | 93 ++++++++++++++++ cmd/remove.go | 71 +++++++++++++ cmd/root.go | 185 ++++++++++++++++++++++++++++++++ go.mod | 43 ++++++++ go.sum | 93 ++++++++++++++++ install.sh | 16 +++ internal/config/config.go | 144 +++++++++++++++++++++++++ internal/registry/entry.go | 14 +++ internal/registry/registry.go | 137 ++++++++++++++++++++++++ internal/search/search.go | 27 +++++ internal/search/text_search.go | 168 +++++++++++++++++++++++++++++ internal/storage/storage.go | 100 ++++++++++++++++++ internal/tui/styles.go | 51 +++++++++ internal/tui/tui.go | 187 +++++++++++++++++++++++++++++++++ main.go | 7 ++ 21 files changed, 1688 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 VERSION create mode 100644 cmd/add.go create mode 100644 cmd/edit.go create mode 100644 cmd/list.go create mode 100644 cmd/remove.go create mode 100644 cmd/root.go create mode 100644 go.mod create mode 100644 go.sum create mode 100755 install.sh create mode 100644 internal/config/config.go create mode 100644 internal/registry/entry.go create mode 100644 internal/registry/registry.go create mode 100644 internal/search/search.go create mode 100644 internal/search/text_search.go create mode 100644 internal/storage/storage.go create mode 100644 internal/tui/styles.go create mode 100644 internal/tui/tui.go create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..85a18cd --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..2d3ebaa --- /dev/null +++ b/README.md @@ -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 diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/cmd/add.go b/cmd/add.go new file mode 100644 index 0000000..b711ef5 --- /dev/null +++ b/cmd/add.go @@ -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 ", + 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 := ®istry.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] +} diff --git a/cmd/edit.go b/cmd/edit.go new file mode 100644 index 0000000..dfb05b1 --- /dev/null +++ b/cmd/edit.go @@ -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 ", + 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) +} diff --git a/cmd/list.go b/cmd/list.go new file mode 100644 index 0000000..1e4cc4d --- /dev/null +++ b/cmd/list.go @@ -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 +} diff --git a/cmd/remove.go b/cmd/remove.go new file mode 100644 index 0000000..a573aad --- /dev/null +++ b/cmd/remove.go @@ -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 ", + 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") +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..3d86ce5 --- /dev/null +++ b/cmd/root.go @@ -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)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8ed3df2 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8582496 --- /dev/null +++ b/go.sum @@ -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= diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..5f9d9ce --- /dev/null +++ b/install.sh @@ -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" diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..46ad21e --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/registry/entry.go b/internal/registry/entry.go new file mode 100644 index 0000000..07553ba --- /dev/null +++ b/internal/registry/entry.go @@ -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"` +} diff --git a/internal/registry/registry.go b/internal/registry/registry.go new file mode 100644 index 0000000..2558ebf --- /dev/null +++ b/internal/registry/registry.go @@ -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 +} diff --git a/internal/search/search.go b/internal/search/search.go new file mode 100644 index 0000000..6339177 --- /dev/null +++ b/internal/search/search.go @@ -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) +} diff --git a/internal/search/text_search.go b/internal/search/text_search.go new file mode 100644 index 0000000..4fa35e9 --- /dev/null +++ b/internal/search/text_search.go @@ -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 "" +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..19ba7cb --- /dev/null +++ b/internal/storage/storage.go @@ -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 +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 0000000..592b5e9 --- /dev/null +++ b/internal/tui/styles.go @@ -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) +) diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 0000000..0ca07d0 --- /dev/null +++ b/internal/tui/tui.go @@ -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 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..1ad64b4 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/yeho/doks/cmd" + +func main() { + cmd.Execute() +}