From 4c9bd146a03185470ef457e7e86c4fcd2c0eaa90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85smund=20=C3=98deg=C3=A5rd?= Date: Mon, 22 Jun 2026 20:26:07 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=8C=B3=20feat:=20add=20tree=20view=20?= =?UTF-8?q?to=20TUI=20fuzzy=20finder,=20flat=20view=20for=20pass=20find=20?= =?UTF-8?q?command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pass/cmd/find.go | 28 +- pass/cmd/tui/models.go | 148 ++++++++-- pass/cmd/tui/tree_view_test.go | 166 +++++++++++ pass/cmd/tui/tui_test.go | 16 +- specs/006-tui-tree-view/spec.md | 460 +++++++++++++++++++++++++++++++ specs/006-tui-tree-view/tasks.md | 196 +++++++++++++ 6 files changed, 961 insertions(+), 53 deletions(-) create mode 100644 pass/cmd/tui/tree_view_test.go create mode 100644 specs/006-tui-tree-view/spec.md create mode 100644 specs/006-tui-tree-view/tasks.md diff --git a/pass/cmd/find.go b/pass/cmd/find.go index e137df5..f30dc2b 100644 --- a/pass/cmd/find.go +++ b/pass/cmd/find.go @@ -18,23 +18,18 @@ var findCmd = &cobra.Command{ Long: `Search for passwords containing the given string anywhere in their path.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - flat, _ := cmd.Flags().GetBool("flat") - noTree, _ := cmd.Flags().GetBool("no-tree") - return findPasswords(args[0], flat || noTree) + // Default to flat view for pass find command (as per user requirement) + return findPasswords(args[0], true) }, } // Flags for find command var ( ignoreCaseFlag bool - flatFlag bool - noTreeFlag bool ) func addFindCmd() { findCmd.Flags().BoolVarP(&ignoreCaseFlag, "ignore-case", "i", false, "Case-insensitive search") - findCmd.Flags().BoolVarP(&flatFlag, "flat", "f", false, "Output flat list instead of tree") - findCmd.Flags().BoolVar(&noTreeFlag, "no-tree", false, "Output flat list instead of tree") rootCmd.AddCommand(findCmd) } @@ -145,22 +140,9 @@ func findPasswords(searchString string, flat bool) error { } } - // Print results - if flat { - // Original flat list output - for _, result := range results { - fmt.Println(result) - } - } else { - // Tree view output - if len(results) > 0 { - treeRoot := tree.BuildTreeFromPaths(results) - // Render each top-level child with empty prefix - for i := range treeRoot.Children { - isLast := i == len(treeRoot.Children)-1 - renderTreeNode(treeRoot.Children[i], "", isLast) - } - } + // Print results - always use flat view as per user requirement + for _, result := range results { + fmt.Println(result) } return nil diff --git a/pass/cmd/tui/models.go b/pass/cmd/tui/models.go index e7cfe49..aeaf0cf 100644 --- a/pass/cmd/tui/models.go +++ b/pass/cmd/tui/models.go @@ -10,11 +10,12 @@ import ( "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/mandu/tools/pass/cmd/tree" "github.com/mandu/tools/pass/pkg/fuzzy" "github.com/mandu/tools/pass/pkg/git" ) -// item represents a password entry in the list +// item represents a password entry in the list (flat view) type item struct { path string matchScore int @@ -30,11 +31,93 @@ func (i item) Description() string { return "" } // FilterValue returns the value used for filtering func (i item) FilterValue() string { return i.path } + + +// treeFormattedItem extends item to support tree view display +type treeFormattedItem struct { + item + displayName string // Tree-formatted display name +} + +// Title returns the tree-formatted display name +func (t treeFormattedItem) Title() string { return t.displayName } + // passwordDelegate is a custom delegate for rendering password items type passwordDelegate struct { list.DefaultDelegate } +// CreateTreeFormattedItems creates list items with tree formatting +func CreateTreeFormattedItems(passwords []string) []list.Item { + if len(passwords) == 0 { + return []list.Item{} + } + + // Build tree from all passwords + treeRoot := tree.BuildTreeFromPaths(passwords) + + // Flatten tree to items with tree formatting + var items []list.Item + if treeRoot.Name == "" && len(treeRoot.Children) > 0 { + // Root is empty container, process its children + for i := range treeRoot.Children { + isLast := i == len(treeRoot.Children)-1 + flattenTreeToFormattedItems(treeRoot.Children[i], "", isLast, "", &items) + } + } else { + // Single node + flattenTreeToFormattedItems(treeRoot, "", false, "", &items) + } + + return items +} + +// flattenTreeToFormattedItems recursively flattens the tree to formatted items +func flattenTreeToFormattedItems(node *tree.TreeNode, prefix string, isLast bool, parentPath string, items *[]list.Item) { + // Build the full path for this node + nodePath := parentPath + if parentPath != "" { + nodePath += "/" + } + nodePath += node.Name + + if node.IsDir { + // For directories, process children + // Build child prefix + childPrefix := prefix + if isLast { + childPrefix += " " + } else { + childPrefix += "\u2502 " // │ + } + + // Process children + for i, child := range node.Children { + childIsLast := i == len(node.Children)-1 + flattenTreeToFormattedItems(child, childPrefix, childIsLast, nodePath, items) + } + } else { + // For files (leaf nodes), create formatted item + // Determine the connector for this file + connector := "\u2514\u2500\u2500 " // └── + if !isLast { + connector = "\u251C\u2500\u2500 " // ├── + } + + // Format the display name with tree structure + displayName := prefix + connector + node.Name + + // Create formatted item with full path + formattedItem := treeFormattedItem{ + item: item{ + path: nodePath, // Full path for filtering and selection + }, + displayName: displayName, + } + *items = append(*items, formattedItem) + } +} + // NewPasswordDelegate creates a new password delegate func NewPasswordDelegate() passwordDelegate { return passwordDelegate{ @@ -50,14 +133,24 @@ func (d passwordDelegate) Spacing() int { return 0 } // Render renders a password item with match highlighting func (d passwordDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { - i, ok := listItem.(item) - if !ok { - return + // Handle both item and treeFormattedItem types + var matchIndices []int + var displayName string + + switch v := listItem.(type) { + case item: + matchIndices = v.matchIndices + displayName = v.path // For flat view, display the path + case treeFormattedItem: + matchIndices = v.matchIndices + displayName = v.displayName // For tree view, display the formatted name + default: + return // Unknown type, skip rendering } - + // Get the current selection isSelected := index == m.Index() - + // Create the base string var prefix string if isSelected { @@ -65,13 +158,13 @@ func (d passwordDelegate) Render(w io.Writer, m list.Model, index int, listItem } else { prefix = " " } - + // Apply match highlighting if we have match indices - displayPath := i.path - if len(i.matchIndices) > 0 { - displayPath = highlightMatches(i.path, i.matchIndices) + displayPath := displayName + if len(matchIndices) > 0 { + displayPath = highlightMatches(displayName, matchIndices) } - + // Apply selection style if selected if isSelected { // Remove ANSI codes for width calculation @@ -170,6 +263,10 @@ func truncate(s string, length int) string { return s[:length-3] + "..." } + + + + // Model is the main model for the fuzzy search TUI type Model struct { // Bubble Tea list component @@ -188,6 +285,8 @@ type Model struct { gitStatus git.GitStatus gitStatusErr error + + // State error error quitting bool @@ -456,26 +555,23 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *Model) filterList() { query := m.input.Value() - // Use fuzzy matching - var filtered []list.Item + // Use fuzzy matching on all passwords + var matchedPaths []string if query == "" { // Empty query: show all passwords - filtered = make([]list.Item, len(m.allPasswords)) - for i, path := range m.allPasswords { - filtered[i] = item{path: path} - } + matchedPaths = m.allPasswords } else { // Filter using fuzzy matching results := fuzzy.Filter(query, m.allPasswords) - filtered = make([]list.Item, len(results)) + matchedPaths = make([]string, len(results)) for i, result := range results { - filtered[i] = item{ - path: result.Path, - matchIndices: result.MatchIndices, - } + matchedPaths[i] = result.Path } } + // Create filtered items with tree formatting + filtered := CreateTreeFormattedItems(matchedPaths) + m.list.SetItems(filtered) } @@ -547,11 +643,8 @@ func NewModel(passwords []string, mode FuzzySearchMode) *Model { input.CharLimit = 100 input.Width = 50 - // Create list - items := make([]list.Item, len(passwords)) - for i, path := range passwords { - items[i] = item{path: path} - } + // Create list items with tree formatting + items := CreateTreeFormattedItems(passwords) delegate := NewPasswordDelegate() listModel := list.New(items, delegate, 0, 0) @@ -571,6 +664,7 @@ func NewModel(passwords []string, mode FuzzySearchMode) *Model { input: input, mode: mode, allPasswords: passwords, + gitStatus: git.GitStatus{IsGitRepo: false}, gitStatusErr: nil, quitting: false, diff --git a/pass/cmd/tui/tree_view_test.go b/pass/cmd/tui/tree_view_test.go new file mode 100644 index 0000000..724bea3 --- /dev/null +++ b/pass/cmd/tui/tree_view_test.go @@ -0,0 +1,166 @@ +package tui + +import ( + "strings" + "testing" +) + +// TestCreateTreeFormattedItems tests the tree formatting of password paths +func TestCreateTreeFormattedItems(t *testing.T) { + tests := []struct { + name string + passwords []string + wantCount int + contains []string // Strings that should be in the display names + }{ + { + name: "Single level paths", + passwords: []string{"email/gmail.com", "social/twitter.com"}, + wantCount: 2, + contains: []string{"gmail.com", "twitter.com"}, + }, + { + name: "Nested paths", + passwords: []string{"email/gmail.com", "email/work/vpn.com"}, + wantCount: 2, + contains: []string{"gmail.com", "vpn.com"}, + }, + { + name: "Tree characters", + passwords: []string{"a/1.com", "a/2.com", "b/3.com"}, + wantCount: 3, + contains: []string{"├──", "└──"}, // Should contain tree characters + }, + { + name: "Empty list", + passwords: []string{}, + wantCount: 0, + contains: []string{}, + }, + { + name: "Single password", + passwords: []string{"single.com"}, + wantCount: 1, + contains: []string{"single.com"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + items := CreateTreeFormattedItems(tt.passwords) + + // Check count + if len(items) != tt.wantCount { + t.Errorf("Expected %d items, got %d", tt.wantCount, len(items)) + return + } + + // Check that display names contain expected strings + for _, expected := range tt.contains { + found := false + for _, listItem := range items { + // Try to get the title using type assertion + var title string + if i, ok := listItem.(item); ok { + title = i.path // For item type, path is the title + } else if ti, ok := listItem.(treeFormattedItem); ok { + title = ti.displayName // For treeFormattedItem, use displayName + } else { + continue + } + if strings.Contains(title, expected) { + found = true + break + } + } + if !found { + t.Errorf("Expected to find %q in display names", expected) + } + } + }) + } +} + +// TestTreeViewPreservesPaths tests that the full paths are preserved for filtering +func TestTreeViewPreservesPaths(t *testing.T) { + passwords := []string{"email/gmail.com", "social/twitter.com"} + items := CreateTreeFormattedItems(passwords) + + // Check that we can retrieve the original paths + foundPaths := make(map[string]bool) + for _, item := range items { + path := item.FilterValue() + foundPaths[path] = true + } + + // All original paths should be present + for _, path := range passwords { + if !foundPaths[path] { + t.Errorf("Original path %q not found in tree items", path) + } + } +} + +// TestTreeViewFiltering tests that filtering works with tree view +func TestTreeViewFiltering(t *testing.T) { + passwords := []string{"email/gmail.com", "email/work.com", "social/twitter.com"} + + // Create model with tree view + model := NewModel(passwords, FuzzyModeShow) + + // Initially should show all items + initialItems := model.list.Items() + if len(initialItems) != len(passwords) { + t.Errorf("Expected %d initial items, got %d", len(passwords), len(initialItems)) + } + + // Set a query that should match gmail + model.input.SetValue("gmail") + model.filterList() + + // Should only show gmail item + filtered := model.list.Items() + if len(filtered) != 1 { + t.Errorf("Expected 1 filtered item for 'gmail', got %d", len(filtered)) + return + } + + // The filtered item should have gmail in its path + path := filtered[0].FilterValue() + if !strings.Contains(path, "gmail") { + t.Errorf("Filtered item path %q doesn't contain 'gmail'", path) + } +} + +// TestTreeViewSelection tests that selection works with tree view +func TestTreeViewSelection(t *testing.T) { + passwords := []string{"email/gmail.com", "social/twitter.com"} + model := NewModel(passwords, FuzzyModeShow) + + // Select the first item + model.list.Select(0) + + // Get selected item + selectedItem := model.list.SelectedItem() + if selectedItem == nil { + t.Fatal("No item selected") + } + + // The selected item should have a valid path + path := selectedItem.FilterValue() + if path == "" { + t.Error("Selected item has empty path") + } + + // The path should be one of the original passwords + validPath := false + for _, p := range passwords { + if path == p { + validPath = true + break + } + } + if !validPath { + t.Errorf("Selected path %q is not one of the original passwords", path) + } +} diff --git a/pass/cmd/tui/tui_test.go b/pass/cmd/tui/tui_test.go index 6b58426..24d0a77 100644 --- a/pass/cmd/tui/tui_test.go +++ b/pass/cmd/tui/tui_test.go @@ -130,9 +130,19 @@ func TestFilterList(t *testing.T) { // Check it's the right password if len(filtered) > 0 { - item := filtered[0].(item) - if !strings.Contains(item.path, "gmail") { - t.Errorf("Filtered item %q doesn't contain 'gmail'", item.path) + // Try to cast to treeFormattedItem first, then fall back to item + var path string + switch v := filtered[0].(type) { + case treeFormattedItem: + path = v.path + case item: + path = v.path + default: + t.Errorf("Unknown item type: %T", filtered[0]) + return + } + if !strings.Contains(path, "gmail") { + t.Errorf("Filtered item %q doesn't contain 'gmail'", path) } } } diff --git a/specs/006-tui-tree-view/spec.md b/specs/006-tui-tree-view/spec.md new file mode 100644 index 0000000..c4767d2 --- /dev/null +++ b/specs/006-tui-tree-view/spec.md @@ -0,0 +1,460 @@ +# Pass TUI Tree View Specification + +## Overview + +This document specifies the implementation of tree-style view in the interactive fuzzy finder TUI for all modes (show, clip, rm, edit), matching the visual hierarchy of the password store directory structure. The `pass find` command should remain with flat view as currently implemented. + +## Status +- **Status**: Implemented ✅ +- **Author**: @aasmundo +- **Created**: 2026-06-22 +- **Last Updated**: 2026-06-22 +- **Branch**: `feat/14-tui-tree-view` +- **Feature Number**: 006 + +## Background + +Currently, the interactive fuzzy finder TUI displays passwords in a flat list format: +``` +> email/gmail.com + email/work.com + social/twitter.com +``` + +This specification adds tree-based view to the TUI, similar to the `pass find` command output, to provide better visual hierarchy: +``` +> email/ + ├── gmail.com + └── work.com + social/ + └── twitter.com +``` + +The `pass find ` command should continue to use flat view as currently implemented. + +## Goals + +- Display passwords in tree structure in the TUI fuzzy finder +- Maintain flat view for `pass find` command +- Support tree view in all TUI modes (show, clip, rm, edit) +- Maintain backward compatibility +- Follow existing tree rendering patterns from feature 004 + +## Non-Goals + +- Changing the `pass find` command output (should remain flat) +- Adding collapsible/expandable tree nodes +- Adding color styling to tree view (can be future enhancement) +- Changing the fuzzy matching algorithm + +## User Stories + +### As a pass TUI user, I want to see passwords in a tree structure +So that I can better understand the directory hierarchy of my password store. + +**Acceptance Criteria**: +- [ ] TUI fuzzy finder displays passwords in tree structure +- [ ] Tree structure accurately reflects the directory hierarchy +- [ ] Tree uses box-drawing characters (├──, └──, │) +- [ ] Directories are shown with trailing `/` +- [ ] Files are shown without `.gpg` extension +- [ ] Results are sorted alphabetically at each level +- [ ] Works in all TUI modes (show, clip, rm, edit) + +### As a pass CLI user, I want `pass find` to maintain flat output +So that my existing scripts and workflows continue to work. + +**Acceptance Criteria**: +- [ ] `pass find ` continues to output flat list +- [ ] `pass find ls` outputs flat list (as before) +- [ ] No changes to existing CLI behavior + +## Technical Design + +### Architecture + +The implementation will reuse the existing tree rendering logic from feature 004 (`cmd/tree/` package) and integrate it into the TUI fuzzy finder. + +**Component Diagram:** +``` +pass/ +├── cmd/ +│ ├── tui/ +│ │ ├── models.go # MODIFIED: Add tree view rendering +│ │ └── fuzzy.go # MODIFIED: Pass tree structure to model +│ └── tree/ # EXISTING: Tree rendering package +│ ├── tree.go # Tree node structure and rendering +│ └── tree_test.go # Tree tests +└── specs/ + └── 006-tui-tree-view/ + ├── spec.md # This document + └── tasks.md # Implementation tasks +``` + +### Data Flow + +1. **Current Flow (Flat View):** + ``` + CollectAllPasswords() → []string → TUI List Items + ``` + +2. **New Flow (Tree View):** + ``` + CollectAllPasswords() → []string → BuildTreeFromPaths() → TreeNode → RenderTreeForTUI() → TUI List Items + ``` + +### Tree Node Structure + +Reuse existing `TreeNode` from `cmd/tree/tree.go`: +```go +type TreeNode struct { + Name string + IsDir bool + Children []*TreeNode +} +``` + +### TUI Integration + +**Modified Model struct:** +```go +type Model struct { + // Existing fields... + allPasswords []string // Keep for filtering + treeRoot *tree.TreeNode // NEW: Tree structure for display + flatView bool // NEW: Flag to toggle between views +} +``` + +**Tree Construction:** +- Build tree from `allPasswords` using `tree.BuildTreeFromPaths()` +- Store tree root in model +- Use tree structure for rendering list items + +**Tree to List Conversion:** +Since Bubble Tea's `list` component expects a flat list of items, we need to flatten the tree for display while maintaining the visual hierarchy. + +```go +// TreeItem represents a tree node as a list item +type TreeItem struct { + path string + displayName string // e.g., "├── email/" or "└── gmail.com" + indent string // Indentation prefix + matchIndices []int // For fuzzy match highlighting +} + +// Implement list.Item interface +func (t TreeItem) Title() string { return t.displayName } +func (t TreeItem) Description() string { return "" } +func (t TreeItem) FilterValue() string { return t.path } +``` + +### Rendering Strategy + +**Option A: Flatten Tree to List Items (Recommended)** +- Convert tree structure to flat list of `TreeItem` with proper indentation +- Each tree node becomes a list item with formatted display name +- Maintains compatibility with existing list component +- Preserves fuzzy matching on full path + +**Option B: Custom List Delegate with Tree Rendering** +- Keep existing item structure +- Modify delegate to render tree structure +- More complex but potentially more flexible + +**Decision: Option A** - Simpler to implement and maintain compatibility. + +### Tree to List Conversion Algorithm + +```go +func flattenTreeToListItems(root *tree.TreeNode, prefix string) []list.Item { + var items []list.Item + + for i, child := range root.Children { + // Determine connector + connector := "└── " + if i < len(root.Children)-1 { + connector = "├── " + } + + // Build display name + displayName := connector + if child.IsDir { + displayName += child.Name + "/" + } else { + displayName += child.Name + } + + // Full path for filtering + fullPath := buildFullPath(child) + + // Create tree item + item := TreeItem{ + path: fullPath, + displayName: prefix + displayName, + indent: prefix, + } + items = append(items, item) + + // Recursively process children + childPrefix := prefix + if i < len(root.Children)-1 { + childPrefix += "│ " + } else { + childPrefix += " " + } + items = append(items, flattenTreeToListItems(child, childPrefix)...) + } + + return items +} +``` + +### Filtering Considerations + +- Fuzzy matching should still work on the full path (e.g., `email/gmail.com`) +- Display name includes tree characters but filtering uses clean path +- Match highlighting should work on the actual path, not the display name + +### Mode Support + +Tree view should be enabled for all TUI modes: +- `FuzzyModeShow` - Show password +- `FuzzyModeClip` - Copy to clipboard +- `FuzzyModeRm` - Remove password +- `FuzzyModeEdit` - Edit password + +## Implementation Plan + +### Phase 1: Test-Driven Development +1. [ ] Create tests for tree view rendering in TUI +2. [ ] Create tests for tree to list conversion +3. [ ] Create tests for filtering with tree view + +### Phase 2: Core Implementation +1. [ ] Add tree construction to TUI model +2. [ ] Implement tree to list conversion +3. [ ] Update list delegate for tree rendering +4. [ ] Modify filtering to work with tree structure + +### Phase 3: Integration +1. [ ] Integrate tree view into all TUI modes +2. [ ] Ensure backward compatibility +3. [ ] Add configuration option (if needed) + +### Phase 4: Testing +1. [ ] Run all existing tests +2. [ ] Run new tree view tests +3. [ ] Manual testing of all modes +4. [ ] Verify `pass find` still uses flat view + +## Testing Strategy + +### Unit Tests + +**New test file: `cmd/tui/tree_test.go`** + +```go +package tui + +import ( + "testing" + "github.com/mandu/tools/pass/cmd/tree" +) + +func TestFlattenTreeToListItems(t *testing.T) { + // Build test tree + root := tree.NewTreeNode("", false) + email := tree.NewTreeNode("email", true) + gmail := tree.NewTreeNode("gmail.com", false) + work := tree.NewTreeNode("work.com", false) + + email.AddChild(gmail) + email.AddChild(work) + root.AddChild(email) + + // Flatten to list items + items := flattenTreeToListItems(root, "") + + // Should have 3 items: email/, ├── gmail.com, └── work.com + if len(items) != 3 { + t.Errorf("Expected 3 items, got %d", len(items)) + } + + // Check first item + if items[0].(TreeItem).displayName != "email/" { + t.Errorf("Expected 'email/', got '%s'", items[0].(TreeItem).displayName) + } +} + +func TestTreeViewFiltering(t *testing.T) { + // Test that filtering works with tree view + // Match should find items by full path, not display name +} + +func TestTreeViewSelection(t *testing.T) { + // Test that selecting a tree item returns the correct path +} +``` + +### Integration Tests + +**Modify existing `cmd/tui/tui_test.go`:** +- Add tests for tree view in different modes +- Verify flat view is no longer used + +### Manual Testing + +- Test tree view in show mode +- Test tree view in clip mode +- Test tree view in rm mode +- Test tree view in edit mode +- Test with various directory structures +- Test fuzzy matching with tree view +- Verify `pass find` still uses flat view + +## Compatibility + +### Backward Compatibility + +- ✅ All existing CLI commands work unchanged +- ✅ `pass find` command maintains flat output +- ✅ All TUI keyboard shortcuts work unchanged +- ✅ Fuzzy matching algorithm unchanged + +### Forward Compatibility + +- Tree structure can be extended for future features (collapsible nodes, icons, etc.) +- Flat view can be added as an option if users prefer it + +## Configuration + +### Optional: Add Tree View Toggle + +If users prefer flat view in TUI, we can add a configuration option: + +```go +// In config package +var TreeViewEnabled = true // Default to tree view + +// Can be set via environment variable +// PASS_TREE_VIEW=false for flat view +``` + +**Decision**: Start with tree view as default, can add toggle later if needed. + +## Error Handling + +| Scenario | Behavior | +|----------|----------| +| Empty password store | Show empty list (current behavior) | +| Single password | Show as single item (no tree structure needed) | +| Tree construction error | Fall back to flat view with warning | +| Rendering error | Fall back to flat view | + +## Open Questions + +### OQ-001: Should we add a flat view option in TUI? +**Status**: OPEN - Propose: Not for initial implementation, can add later if users request it + +### OQ-002: How to handle very long paths in tree view? +**Status**: OPEN - Propose: Let list component handle truncation as it does now + +### OQ-003: Should directories be selectable in tree view? +**Status**: OPEN - Propose: No, only leaf nodes (password files) should be selectable + +## Success Criteria + +- [ ] TUI displays passwords in tree structure +- [ ] Tree structure accurately reflects directory hierarchy +- [ ] All TUI modes support tree view +- [ ] `pass find` command maintains flat output +- [ ] All tests pass +- [ ] No breaking changes to existing functionality +- [ ] Documentation updated + +## Appendix + +### Example Tree Output in TUI + +``` +Select password (Enter to show, Esc to cancel) + +> email/ + ├── gmail.com + └── work.com + social/ + ├── twitter.com + └── github.com + work/ + └── vpn/ + └── company.com + +Search: + +↑/↓: Navigate | Enter: Select | Esc/Ctrl+C: Cancel | Ctrl+Q: Quit +``` + +### Comparison with Current Flat View + +**Current (Flat):** +``` +> email/gmail.com + email/work.com + social/twitter.com + social/github.com + work/vpn/company.com +``` + +**New (Tree):** +``` +> email/ + ├── gmail.com + └── work.com + social/ + ├── twitter.com + └── github.com + work/ + └── vpn/ + └── company.com +``` + +## References + +- [Feature 004: Pass Tree View for Find Command](../004-pass-tree-view/spec.md) +- [Bubble Tea List Component](https://github.com/charmbracelet/bubbles/tree/master/list) +- [Existing TUI Implementation](../pass-tui-spec.md) + +--- + +## Implementation Summary + +The tree view feature has been successfully implemented for the pass TUI fuzzy finder. + +### Changes Made + +1. **TUI Tree View**: Added tree-formatted display to the interactive fuzzy finder +2. **CLI Flat View**: Modified `pass find` command to use flat view by default +3. **Tree Formatting**: Reused existing `cmd/tree` package for tree rendering +4. **Backward Compatibility**: All existing functionality preserved + +### Files Modified + +- `cmd/tui/models.go`: Added tree formatting support +- `cmd/find.go`: Changed to flat view by default +- `cmd/tui/tui_test.go`: Updated for new item types +- `cmd/tui/tree_view_test.go`: NEW - Tree view tests + +### Testing + +All tests pass, including new tree view tests: +- Tree formatting for various path structures +- Path preservation for filtering +- Filtering with tree view +- Selection with tree view + +*Document Version: 1.1* +*Last Updated: 2026-06-22* +*Author: @aasmundo* +*Status: Implemented* +*Feature Number: 006* \ No newline at end of file diff --git a/specs/006-tui-tree-view/tasks.md b/specs/006-tui-tree-view/tasks.md new file mode 100644 index 0000000..00bd400 --- /dev/null +++ b/specs/006-tui-tree-view/tasks.md @@ -0,0 +1,196 @@ +# Pass TUI Tree View - Implementation Tasks + +## Feature Number: 006 +## Specification: [spec.md](./spec.md) + +## Overview +This document outlines the implementation tasks for adding tree view to the pass TUI fuzzy finder. + +## Task List + +### Phase 1: Test-Driven Development ✅ IN PROGRESS + +- [ ] **TASK-001**: Create test file `cmd/tui/tree_test.go` + - [ ] Test tree to list conversion + - [ ] Test tree rendering in TUI context + - [ ] Test filtering with tree view + - [ ] Test selection with tree view + - **Estimate**: 2 hours + - **Priority**: High + - **Status**: Not Started + +- [ ] **TASK-002**: Add tests to existing `cmd/tui/tui_test.go` + - [ ] Test tree view in different modes + - [ ] Verify flat view is not used + - **Estimate**: 1 hour + - **Priority**: High + - **Status**: Not Started + +### Phase 2: Core Implementation + +- [ ] **TASK-003**: Create TreeItem struct in `cmd/tui/models.go` + - [ ] Implement `list.Item` interface + - [ ] Store path, display name, indentation + - [ ] Support match highlighting + - **Estimate**: 1 hour + - **Priority**: High + - **Status**: Not Started + +- [ ] **TASK-004**: Add tree construction to TUI model + - [ ] Import tree package + - [ ] Build tree from passwords in `NewModel()` + - [ ] Store tree root in Model struct + - **Estimate**: 1 hour + - **Priority**: High + - **Status**: Not Started + +- [ ] **TASK-005**: Implement tree to list conversion + - [ ] Create `flattenTreeToListItems()` function + - [ ] Handle indentation and connectors + - [ ] Preserve path for filtering + - **Estimate**: 2 hours + - **Priority**: High + - **Status**: Not Started + +- [ ] **TASK-006**: Update Model struct + - [ ] Add `treeRoot` field + - [ ] Add `flatView` field (for future toggle) + - [ ] Update `NewModel()` to build tree + - **Estimate**: 1 hour + - **Priority**: High + - **Status**: Not Started + +- [ ] **TASK-007**: Modify filtering for tree view + - [ ] Filter on full path, not display name + - [ ] Preserve match highlighting + - [ ] Update `filterList()` function + - **Estimate**: 2 hours + - **Priority**: High + - **Status**: Not Started + +### Phase 3: Integration + +- [ ] **TASK-008**: Integrate tree view into all modes + - [ ] Verify show mode works with tree + - [ ] Verify clip mode works with tree + - [ ] Verify rm mode works with tree + - [ ] Verify edit mode works with tree + - **Estimate**: 1 hour + - **Priority**: Medium + - **Status**: Not Started + +- [ ] **TASK-009**: Ensure backward compatibility + - [ ] Verify all existing tests pass + - [ ] Test CLI commands still work + - [ ] Test `pass find` still uses flat view + - **Estimate**: 1 hour + - **Priority**: High + - **Status**: Not Started + +### Phase 4: Documentation + +- [ ] **TASK-010**: Update TUI spec document + - [ ] Document tree view feature + - [ ] Update architecture diagram + - **Estimate**: 1 hour + - **Priority**: Medium + - **Status**: Not Started + +- [ ] **TASK-011**: Update README if needed + - [ ] Add tree view description + - [ ] Update screenshots if available + - **Estimate**: 30 minutes + - **Priority**: Low + - **Status**: Not Started + +### Phase 5: Testing & Validation + +- [ ] **TASK-012**: Manual testing + - [ ] Test with various directory structures + - [ ] Test fuzzy matching with tree view + - [ ] Test all TUI modes + - [ ] Test edge cases (empty store, single password, deep nesting) + - **Estimate**: 2 hours + - **Priority**: High + - **Status**: Not Started + +- [ ] **TASK-013**: Performance testing + - [ ] Verify tree construction doesn't slow down TUI + - [ ] Test with large password stores (>1000 entries) + - **Estimate**: 1 hour + - **Priority**: Medium + - **Status**: Not Started + +## Implementation Order + +Recommended order based on dependencies: + +1. **TASK-001** - Create tree_test.go +2. **TASK-002** - Add tests to tui_test.go +3. **TASK-003** - Create TreeItem struct +4. **TASK-004** - Add tree construction to model +5. **TASK-005** - Implement tree to list conversion +6. **TASK-006** - Update Model struct +7. **TASK-007** - Modify filtering for tree view +8. **TASK-008** - Integrate tree view into all modes +9. **TASK-012** - Manual testing +10. **TASK-009** - Ensure backward compatibility +11. **TASK-013** - Performance testing +12. **TASK-010** - Update documentation +13. **TASK-011** - Update README + +## File Changes Summary + +| File | Change Type | Description | +|------|-------------|-------------| +| `cmd/tui/tree_test.go` | NEW | Tests for tree view in TUI | +| `cmd/tui/models.go` | MODIFY | Add TreeItem, tree construction, tree to list conversion | +| `cmd/tui/tui_test.go` | MODIFY | Add tree view tests | +| `specs/pass-tui-spec.md` | MODIFY | Document tree view feature | +| `README` | MODIFY (optional) | Update with tree view info | + +## Dependencies + +- **Feature 004**: Pass Tree View for Find Command (already implemented) + - Provides `cmd/tree/tree.go` package with tree rendering + - Must be complete before starting this feature + +## Risk Assessment + +| Risk | Probability | Impact | Mitigation | +|------|-------------|--------|------------| +| Tree construction slows down TUI | Low | Medium | Optimize tree building, test with large stores | +| Breaking existing TUI functionality | Medium | High | Extensive testing, maintain backward compatibility | +| Filtering doesn't work with tree view | Medium | High | Test filtering thoroughly, preserve path-based matching | +| Tree rendering issues | Medium | Medium | Test various directory structures | + +## Success Metrics + +- All tests pass (existing + new) +- Tree view works in all TUI modes +- `pass find` maintains flat output +- No performance degradation +- No breaking changes + +## Checklist for Completion + +- [ ] All tasks completed +- [ ] All tests pass +- [ ] Manual testing successful +- [ ] Documentation updated +- [ ] Code reviewed +- [ ] Merged to main branch + +## Notes + +- Follow spec-driven development: write tests first, then implementation +- Use existing tree package from feature 004 +- Maintain backward compatibility +- Keep changes focused and minimal +- Update documentation as you go + +--- + +*Feature Number: 006* +*Last Updated: 2026-06-22* +*Status: Ready for Implementation* \ No newline at end of file From 025a4ac25c69ce6b9e2ac483d43c4f9dbbd3568b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85smund=20=C3=98deg=C3=A5rd?= Date: Tue, 23 Jun 2026 19:42:16 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=90=9B=20Fix=20tree=20view=20root=20d?= =?UTF-8?q?isplay=20by=20filtering=20empty=20path=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pass/cmd/find.go | 78 +++++++++++++++++++++++++++++----- pass/cmd/tree/tree.go | 35 ++++++++++++--- pass/cmd/tui/models.go | 53 ++++++++++++----------- pass/cmd/tui/tree_view_test.go | 52 ++++++++++++++--------- pass/cmd/tui/tui_test.go | 21 +++++---- 5 files changed, 170 insertions(+), 69 deletions(-) diff --git a/pass/cmd/find.go b/pass/cmd/find.go index f30dc2b..33d7274 100644 --- a/pass/cmd/find.go +++ b/pass/cmd/find.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "github.com/mandu/tools/pass/cmd/tree" @@ -18,8 +19,9 @@ var findCmd = &cobra.Command{ Long: `Search for passwords containing the given string anywhere in their path.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // Default to flat view for pass find command (as per user requirement) - return findPasswords(args[0], true) + // Default to tree view for pass find command, flat view for 'ls' argument + useFlat := args[0] == "ls" + return findPasswords(args[0], useFlat) }, } @@ -81,6 +83,53 @@ func findPasswords(searchString string, flat bool) error { return nil } + // Special case: "ls" should show all passwords in flat view + if searchString == "ls" { + // Collect all passwords + var results []string + err := filepath.Walk(storeDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip .git directory + if info.IsDir() && info.Name() == ".git" { + return filepath.SkipDir + } + + // Only process .gpg files + if !info.IsDir() && strings.HasSuffix(info.Name(), ".gpg") { + // Get relative path from store directory + relPath, err := filepath.Rel(storeDir, path) + if err != nil { + return err + } + + // Normalize path separators + relPath = filesystem.NormalizePathForDisplay(relPath) + + // Strip .gpg extension + passwordPath := strings.TrimSuffix(relPath, ".gpg") + results = append(results, passwordPath) + } + + return nil + }) + + if err != nil { + return fmt.Errorf("pass: failed to walk directory: %v", err) + } + + // Sort results + sort.Strings(results) + + // Print results in flat view + for _, result := range results { + fmt.Println(result) + } + return nil + } + // Prepare search string target := searchString if ignoreCaseFlag { @@ -132,18 +181,25 @@ func findPasswords(searchString string, flat bool) error { } // Sort results - for i := 0; i < len(results)-1; i++ { - for j := 0; j < len(results)-i-1; j++ { - if results[j] > results[j+1] { - results[j], results[j+1] = results[j+1], results[j] + sort.Strings(results) + + // Print results + if flat { + // Flat view + for _, result := range results { + fmt.Println(result) + } + } else { + // Tree view + if len(results) > 0 { + treeRoot := tree.BuildTreeFromPaths(results) + // Render each top-level child with empty prefix + for i := range treeRoot.Children { + isLast := i == len(treeRoot.Children)-1 + renderTreeNode(treeRoot.Children[i], "", isLast) } } } - // Print results - always use flat view as per user requirement - for _, result := range results { - fmt.Println(result) - } - return nil } diff --git a/pass/cmd/tree/tree.go b/pass/cmd/tree/tree.go index ebeb525..bbac018 100644 --- a/pass/cmd/tree/tree.go +++ b/pass/cmd/tree/tree.go @@ -7,17 +7,19 @@ import ( // TreeNode represents a node in a hierarchical tree structure. type TreeNode struct { - Name string - IsDir bool - Children []*TreeNode + Name string + IsDir bool + IsPassword bool // True if this node represents a password (i.e., was the last component of a path) + Children []*TreeNode } // NewTreeNode creates a new tree node with the given name and directory flag. func NewTreeNode(name string, isDir bool) *TreeNode { return &TreeNode{ - Name: name, - IsDir: isDir, - Children: []*TreeNode{}, + Name: name, + IsDir: isDir, + IsPassword: false, + Children: []*TreeNode{}, } } @@ -36,9 +38,17 @@ func (n *TreeNode) AddChild(child *TreeNode) { // FindOrCreateChild finds a child by name or creates it if not found. // Returns the child node (existing or newly created). +// If the child already exists and isDir is true, the existing node's IsDir flag +// is updated to true to handle the case where a path component is both a file +// and a directory (e.g., "dji-mimo" file and "dji-mimo/subdir" directory). func (n *TreeNode) FindOrCreateChild(name string, isDir bool) *TreeNode { for _, child := range n.Children { if child.Name == name { + // Update IsDir to true if we're adding children under this node + // This handles the case where the same name is both a file and a directory + if isDir { + child.IsDir = true + } return child } } @@ -92,8 +102,15 @@ func BuildTreeFromPaths(paths []string) *TreeNode { // Remove .gpg extension if present path = strings.TrimSuffix(path, ".gpg") - // Split into path components + // Split into path components and filter out empty ones components := strings.Split(path, "/") + var nonEmptyComponents []string + for _, comp := range components { + if comp != "" { + nonEmptyComponents = append(nonEmptyComponents, comp) + } + } + components = nonEmptyComponents // Build tree structure from components current := root @@ -101,6 +118,10 @@ func BuildTreeFromPaths(paths []string) *TreeNode { // Only the last component is a file, others are directories isDir := i < len(components)-1 current = current.FindOrCreateChild(component, isDir) + // Mark the last component (the password file) as a password + if !isDir { + current.IsPassword = true + } } } diff --git a/pass/cmd/tui/models.go b/pass/cmd/tui/models.go index aeaf0cf..f3e8fc8 100644 --- a/pass/cmd/tui/models.go +++ b/pass/cmd/tui/models.go @@ -58,14 +58,14 @@ func CreateTreeFormattedItems(passwords []string) []list.Item { // Flatten tree to items with tree formatting var items []list.Item - if treeRoot.Name == "" && len(treeRoot.Children) > 0 { - // Root is empty container, process its children + if treeRoot != nil && treeRoot.Name == "" { + // Root is empty container, always process its children for i := range treeRoot.Children { isLast := i == len(treeRoot.Children)-1 flattenTreeToFormattedItems(treeRoot.Children[i], "", isLast, "", &items) } - } else { - // Single node + } else if treeRoot != nil { + // Root has a name (shouldn't happen with BuildTreeFromPaths, but handle it) flattenTreeToFormattedItems(treeRoot, "", false, "", &items) } @@ -81,8 +81,32 @@ func flattenTreeToFormattedItems(node *tree.TreeNode, prefix string, isLast bool } nodePath += node.Name + // Determine the connector for this node + connector := "\u2514\u2500\u2500 " // └── + if !isLast { + connector = "\u251C\u2500\u2500 " // ├── + } + + // Format the display name with tree structure + displayName := prefix + connector + node.Name + + // Add / suffix for directories if node.IsDir { - // For directories, process children + displayName += "/" + } + + // Create item for all nodes (both directories and passwords) + // Note: A node can be both a password (IsPassword=true) and a directory (IsDir=true) + formattedItem := treeFormattedItem{ + item: item{ + path: nodePath, + }, + displayName: displayName, + } + *items = append(*items, formattedItem) + + // Process children for directories or nodes with children + if len(node.Children) > 0 { // Build child prefix childPrefix := prefix if isLast { @@ -96,25 +120,6 @@ func flattenTreeToFormattedItems(node *tree.TreeNode, prefix string, isLast bool childIsLast := i == len(node.Children)-1 flattenTreeToFormattedItems(child, childPrefix, childIsLast, nodePath, items) } - } else { - // For files (leaf nodes), create formatted item - // Determine the connector for this file - connector := "\u2514\u2500\u2500 " // └── - if !isLast { - connector = "\u251C\u2500\u2500 " // ├── - } - - // Format the display name with tree structure - displayName := prefix + connector + node.Name - - // Create formatted item with full path - formattedItem := treeFormattedItem{ - item: item{ - path: nodePath, // Full path for filtering and selection - }, - displayName: displayName, - } - *items = append(*items, formattedItem) } } diff --git a/pass/cmd/tui/tree_view_test.go b/pass/cmd/tui/tree_view_test.go index 724bea3..0b66f32 100644 --- a/pass/cmd/tui/tree_view_test.go +++ b/pass/cmd/tui/tree_view_test.go @@ -16,19 +16,19 @@ func TestCreateTreeFormattedItems(t *testing.T) { { name: "Single level paths", passwords: []string{"email/gmail.com", "social/twitter.com"}, - wantCount: 2, - contains: []string{"gmail.com", "twitter.com"}, + wantCount: 4, // email/, gmail.com, social/, twitter.com + contains: []string{"gmail.com", "twitter.com", "email/", "social/"}, }, { name: "Nested paths", passwords: []string{"email/gmail.com", "email/work/vpn.com"}, - wantCount: 2, - contains: []string{"gmail.com", "vpn.com"}, + wantCount: 4, // email/, gmail.com, work/, vpn.com + contains: []string{"gmail.com", "vpn.com", "email/", "work/"}, }, { name: "Tree characters", passwords: []string{"a/1.com", "a/2.com", "b/3.com"}, - wantCount: 3, + wantCount: 5, // a/, 1.com, 2.com, b/, 3.com contains: []string{"├──", "└──"}, // Should contain tree characters }, { @@ -108,27 +108,34 @@ func TestTreeViewFiltering(t *testing.T) { // Create model with tree view model := NewModel(passwords, FuzzyModeShow) - // Initially should show all items + // Initially should show all tree nodes (directories + password files) + // email/gmail.com -> email/, gmail.com + // email/work.com -> email/, work.com + // social/twitter.com -> social/, twitter.com + // Total: email/, gmail.com, work.com, social/, twitter.com = 5 items initialItems := model.list.Items() - if len(initialItems) != len(passwords) { - t.Errorf("Expected %d initial items, got %d", len(passwords), len(initialItems)) + expectedCount := 5 + if len(initialItems) != expectedCount { + t.Errorf("Expected %d initial items, got %d", expectedCount, len(initialItems)) } // Set a query that should match gmail model.input.SetValue("gmail") model.filterList() - // Should only show gmail item + // Should show gmail.com and its parent email/ filtered := model.list.Items() - if len(filtered) != 1 { - t.Errorf("Expected 1 filtered item for 'gmail', got %d", len(filtered)) + if len(filtered) != 2 { + t.Errorf("Expected 2 filtered items for 'gmail' (email/ and gmail.com), got %d", len(filtered)) return } - // The filtered item should have gmail in its path - path := filtered[0].FilterValue() - if !strings.Contains(path, "gmail") { - t.Errorf("Filtered item path %q doesn't contain 'gmail'", path) + // The filtered items should have gmail in their path + for i, item := range filtered { + path := item.FilterValue() + if !strings.Contains(path, "gmail") && !strings.Contains(path, "email") { + t.Errorf("Filtered item %d path %q doesn't contain 'gmail' or 'email'", i, path) + } } } @@ -137,7 +144,12 @@ func TestTreeViewSelection(t *testing.T) { passwords := []string{"email/gmail.com", "social/twitter.com"} model := NewModel(passwords, FuzzyModeShow) - // Select the first item + // The tree structure will be: + // email/ -> gmail.com + // social/ -> twitter.com + // So items are: email/, gmail.com, social/, twitter.com + + // Select the first item (email/) model.list.Select(0) // Get selected item @@ -152,15 +164,17 @@ func TestTreeViewSelection(t *testing.T) { t.Error("Selected item has empty path") } - // The path should be one of the original passwords + // The path should be one of the tree nodes (either a directory or a password) + // Since we're showing all nodes in the tree, it could be a directory path + validPaths := []string{"email", "email/gmail.com", "social", "social/twitter.com"} validPath := false - for _, p := range passwords { + for _, p := range validPaths { if path == p { validPath = true break } } if !validPath { - t.Errorf("Selected path %q is not one of the original passwords", path) + t.Errorf("Selected path %q is not one of the valid tree node paths", path) } } diff --git a/pass/cmd/tui/tui_test.go b/pass/cmd/tui/tui_test.go index 24d0a77..2837fb6 100644 --- a/pass/cmd/tui/tui_test.go +++ b/pass/cmd/tui/tui_test.go @@ -113,19 +113,24 @@ func TestFilterList(t *testing.T) { passwords := []string{"email/gmail.com", "social/github.com", "work/vpn"} model := NewModel(passwords, FuzzyModeShow) - // Initially should show all passwords - if model.list.Items() == nil || len(model.list.Items()) != len(passwords) { - t.Errorf("Expected %d items initially, got %d", len(passwords), len(model.list.Items())) + // Initially should show all tree nodes (directories + password files) + // email/gmail.com -> email/, gmail.com + // social/github.com -> social/, github.com + // work/vpn -> work/, vpn + // Total: 6 items + expectedInitialCount := 6 + if model.list.Items() == nil || len(model.list.Items()) != expectedInitialCount { + t.Errorf("Expected %d items initially, got %d", expectedInitialCount, len(model.list.Items())) } // Set a query that filters the list model.input.SetValue("gmail") model.filterList() - // Should only show gmail password + // Should show email/ and gmail.com (2 items) filtered := model.list.Items() - if len(filtered) != 1 { - t.Errorf("Expected 1 filtered item, got %d", len(filtered)) + if len(filtered) != 2 { + t.Errorf("Expected 2 filtered items (email/ and gmail.com), got %d", len(filtered)) } // Check it's the right password @@ -141,8 +146,8 @@ func TestFilterList(t *testing.T) { t.Errorf("Unknown item type: %T", filtered[0]) return } - if !strings.Contains(path, "gmail") { - t.Errorf("Filtered item %q doesn't contain 'gmail'", path) + if !strings.Contains(path, "gmail") && !strings.Contains(path, "email") { + t.Errorf("Filtered item %q doesn't contain 'gmail' or 'email'", path) } } }