init commit
This commit is contained in:
0
.gitignore
vendored
Normal file
0
.gitignore
vendored
Normal file
13
Makefile
Normal file
13
Makefile
Normal 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
120
README.md
Normal 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
|
||||||
159
cmd/add.go
Normal file
159
cmd/add.go
Normal 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 := ®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]
|
||||||
|
}
|
||||||
59
cmd/edit.go
Normal file
59
cmd/edit.go
Normal 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
93
cmd/list.go
Normal 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
71
cmd/remove.go
Normal 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
185
cmd/root.go
Normal 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
43
go.mod
Normal 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
93
go.sum
Normal 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
16
install.sh
Executable 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
144
internal/config/config.go
Normal 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
|
||||||
|
}
|
||||||
14
internal/registry/entry.go
Normal file
14
internal/registry/entry.go
Normal 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"`
|
||||||
|
}
|
||||||
137
internal/registry/registry.go
Normal file
137
internal/registry/registry.go
Normal 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
27
internal/search/search.go
Normal 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)
|
||||||
|
}
|
||||||
168
internal/search/text_search.go
Normal file
168
internal/search/text_search.go
Normal 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
100
internal/storage/storage.go
Normal 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
51
internal/tui/styles.go
Normal 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
187
internal/tui/tui.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user