init commit
This commit is contained in:
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