diff --git a/VERSION b/VERSION index 17e51c3..d917d3e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.1 +0.1.2 diff --git a/cmd/root.go b/cmd/root.go index 8674ede..56f0c21 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "os/exec" + "sort" "github.com/spf13/cobra" "github.com/yeho/doks/internal/config" @@ -39,7 +40,7 @@ and manuals. Access documents by key or search through content.`, searchDocuments(searchFlag) return } - cmd.Help() + showInteractiveList() }, } @@ -203,3 +204,33 @@ func displayFile(filePath string, highlightLine int) { } fmt.Print(string(content)) } + +func showInteractiveList() { + 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(tui.NoResultsStyle.Render("No documents registered. Use 'doks add ' to add documents.")) + return + } + + // Sort by key + sort.Slice(entries, func(i, j int) bool { + return entries[i].Key < entries[j].Key + }) + + selected, err := tui.RunList(entries) + if err != nil { + fmt.Fprintf(os.Stderr, "Error running TUI: %v\n", err) + os.Exit(1) + } + + if selected != nil { + filePath := cfg.FilePath(selected.Filename) + displayFile(filePath, 0) + } +} diff --git a/internal/tui/list.go b/internal/tui/list.go new file mode 100644 index 0000000..43a6e23 --- /dev/null +++ b/internal/tui/list.go @@ -0,0 +1,193 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/yeho/doks/internal/registry" +) + +type ListModel struct { + entries []*registry.Entry + cursor int + selected *registry.Entry + quitting bool + width int + height int +} + +func NewListModel(entries []*registry.Entry) ListModel { + return ListModel{ + entries: entries, + cursor: 0, + } +} + +func (m ListModel) Init() tea.Cmd { + return nil +} + +func (m ListModel) 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.entries) > 0 && m.cursor < len(m.entries) { + m.selected = m.entries[m.cursor] + } + return m, tea.Quit + + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + + case "down", "j": + if m.cursor < len(m.entries)-1 { + m.cursor++ + } + + case "home", "g": + m.cursor = 0 + + case "end", "G": + m.cursor = len(m.entries) - 1 + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + } + + return m, nil +} + +func (m ListModel) View() string { + if len(m.entries) == 0 { + return NoResultsStyle.Render("No documents registered") + } + + var b strings.Builder + + // Title + title := fmt.Sprintf("📚 Registered Documents (%d total)", len(m.entries)) + b.WriteString(TitleStyle.Render(title)) + b.WriteString("\n\n") + + // Entries with pagination + visibleEntries := m.entries + maxVisible := 10 + startIdx := 0 + if len(visibleEntries) > maxVisible { + // Show entries around cursor + startIdx = m.cursor - maxVisible/2 + if startIdx < 0 { + startIdx = 0 + } + endIdx := startIdx + maxVisible + if endIdx > len(visibleEntries) { + endIdx = len(visibleEntries) + startIdx = endIdx - maxVisible + if startIdx < 0 { + startIdx = 0 + } + } + visibleEntries = m.entries[startIdx:endIdx] + } + + for i, entry := range visibleEntries { + actualIdx := startIdx + i + isSelected := actualIdx == m.cursor + + // Cursor indicator + cursor := " " + if isSelected { + cursor = CursorStyle.Render("> ") + } + + // Key - use different styles for selected + var key string + if isSelected { + key = EntryKeySelected.Render(entry.Key) + } else { + key = EntryKeyStyle.Render(entry.Key) + } + + // Tags + var tagsStr string + if len(entry.Tags) > 0 { + tags := make([]string, len(entry.Tags)) + for i, tag := range entry.Tags { + tags[i] = "#" + tag + } + tagsStr = " " + TagsStyle.Render(strings.Join(tags, " ")) + } + + // Symlink indicator + var symlinkStr string + if entry.HasSymlink { + symlinkStr = " 🔗" + } + + // Description (truncated) + var descStr string + if entry.Description != "" { + desc := entry.Description + maxDescLen := 40 + if len(desc) > maxDescLen { + desc = desc[:maxDescLen-3] + "..." + } + if isSelected { + descStr = " " + MatchTextStyleSelected.Render("- "+desc) + } else { + descStr = " " + MatchTextStyle.Render("- "+desc) + } + } + + line := fmt.Sprintf("%s%s%s%s%s", cursor, key, tagsStr, symlinkStr, descStr) + b.WriteString(line) + b.WriteString("\n") + } + + // Scroll indicator + if len(m.entries) > maxVisible { + scrollInfo := fmt.Sprintf("\n Showing %d-%d of %d", + startIdx+1, + min(startIdx+maxVisible, len(m.entries)), + len(m.entries)) + b.WriteString(CountStyle.Render(scrollInfo)) + b.WriteString("\n") + } + + // Help + help := "↑/k up • ↓/j down • g/G first/last • enter select • q/esc quit" + b.WriteString(HelpStyle.Render(help)) + + return b.String() +} + +func (m ListModel) Selected() *registry.Entry { + return m.selected +} + +func (m ListModel) Quitting() bool { + return m.quitting +} + +func RunList(entries []*registry.Entry) (*registry.Entry, error) { + model := NewListModel(entries) + p := tea.NewProgram(model) + + finalModel, err := p.Run() + if err != nil { + return nil, err + } + + m := finalModel.(ListModel) + return m.Selected(), nil +}