init commit

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

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

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

View File

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

View File

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

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

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

View File

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

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

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

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

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

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

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