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