diff --git a/cmd/contributors/main_test.go b/cmd/contributors/main_test.go index 0258dea..0f26db2 100644 --- a/cmd/contributors/main_test.go +++ b/cmd/contributors/main_test.go @@ -32,6 +32,24 @@ func TestRunAll_NonexistentDir(t *testing.T) { } } +func TestRunAll_WriteError(t *testing.T) { + t.Parallel() + repoDir := createTempGitRepo(t) + + // Create CONTRIBUTORS.md as a directory so os.WriteFile fails. + if err := os.Mkdir(filepath.Join(repoDir, "CONTRIBUTORS.md"), 0o755); err != nil { + t.Fatalf("creating blocking directory: %v", err) + } + + err := runAll(repoDir) + if err == nil { + t.Fatal("expected error when CONTRIBUTORS.md path is a directory") + } + if !strings.Contains(err.Error(), "writing CONTRIBUTORS.md") { + t.Errorf("error should mention writing, got: %v", err) + } +} + // --------------------------------------------------------------------------- // runAll — success path with temp git repo // --------------------------------------------------------------------------- diff --git a/internal/config/config.go b/internal/config/config.go index c75b18a..397a54a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -136,6 +136,12 @@ type Config struct { // Default is "right" when unset. PreviewPosition string `json:"preview_position,omitempty"` + // DefaultCollapsed controls whether session header rows start in + // collapsed (single-line) or expanded (multi-line) state. When false + // (default), sessions are expanded showing full details. When true, + // sessions start collapsed showing only the compact indicator row. + DefaultCollapsed bool `json:"default_collapsed,omitempty"` + // ConversationNewestFirst controls the turn display order in the // preview panel's Conversation section. When false (default), turns // are shown oldest-first (ascending by TurnIndex). When true, turns diff --git a/internal/config/coverage_test.go b/internal/config/coverage_test.go index e26ea0f..276b261 100644 --- a/internal/config/coverage_test.go +++ b/internal/config/coverage_test.go @@ -320,3 +320,90 @@ func TestReset_NoFile_NoError(t *testing.T) { t.Errorf("Reset with no file should not error: %v", err) } } + +// --------------------------------------------------------------------------- +// DefaultCollapsed — default value, JSON round-trip, and Load from disk +// --------------------------------------------------------------------------- + +func TestDefaultCollapsed_DefaultIsFalse(t *testing.T) { + t.Parallel() + cfg := Default() + if cfg.DefaultCollapsed { + t.Error("DefaultCollapsed should default to false") + } +} + +func TestDefaultCollapsed_JSONRoundTrip(t *testing.T) { + t.Parallel() + tests := []struct { + name string + val bool + }{ + {"true", true}, + {"false", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + original := &Config{DefaultCollapsed: tt.val} + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + var restored Config + if err := json.Unmarshal(data, &restored); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if restored.DefaultCollapsed != tt.val { + t.Errorf("DefaultCollapsed = %v, want %v", restored.DefaultCollapsed, tt.val) + } + }) + } +} + +func TestDefaultCollapsed_LoadFromJSON(t *testing.T) { + tmp := setupTempConfig(t) + + // Write a config file with default_collapsed set to true. + dir := filepath.Join(tmp, "dispatch") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + cfgData := `{"default_collapsed": true}` + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(cfgData), 0o600); err != nil { + t.Fatal(err) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if !cfg.DefaultCollapsed { + t.Error("DefaultCollapsed should be true after loading from JSON") + } + // Other defaults should remain. + if !cfg.ShowPreview { + t.Error("ShowPreview should keep default true") + } + if cfg.MaxSessions != 100 { + t.Errorf("MaxSessions = %d, want 100 (default)", cfg.MaxSessions) + } +} + +func TestDefaultCollapsed_SaveAndLoad(t *testing.T) { + setupTempConfig(t) + + cfg := Default() + cfg.DefaultCollapsed = true + if err := Save(cfg); err != nil { + t.Fatalf("Save: %v", err) + } + + loaded, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if !loaded.DefaultCollapsed { + t.Error("DefaultCollapsed should be true after Save/Load round-trip") + } +} diff --git a/internal/data/attention.go b/internal/data/attention.go index b9577ed..5e0d6e7 100644 --- a/internal/data/attention.go +++ b/internal/data/attention.go @@ -16,6 +16,7 @@ import ( "time" "github.com/jongio/dispatch/internal/platform" + "github.com/jongio/dispatch/internal/validate" ) // sessionStateRel is the relative path from the user home directory to @@ -80,6 +81,9 @@ func ScanAttention(threshold time.Duration, workspaceRecovery bool) map[string]A continue } sessionID := e.Name() + if !validate.SessionID(sessionID) { + continue + } dir := filepath.Join(stateDir, sessionID) status := classifySession(dir, threshold, workspaceRecovery) @@ -111,6 +115,9 @@ func ScanAttentionQuick(threshold time.Duration, workspaceRecovery bool) map[str continue } sessionID := e.Name() + if !validate.SessionID(sessionID) { + continue + } dir := filepath.Join(stateDir, sessionID) pidRes := findSessionPID(dir) diff --git a/internal/tui/components/attentionpicker.go b/internal/tui/components/attentionpicker.go index 3851a3b..ca00724 100644 --- a/internal/tui/components/attentionpicker.go +++ b/internal/tui/components/attentionpicker.go @@ -19,6 +19,12 @@ type attentionEntry struct { dot func() string // icon renderer } +// Checkbox glyphs used in the picker UI. +const ( + checkboxOff = "[ ]" + checkboxOn = "[✓]" +) + // attentionEntries is the fixed list of statuses presented in the picker. var attentionEntries = []attentionEntry{ {data.AttentionWaiting, "Needs input", styles.IconAttentionWaiting}, @@ -160,9 +166,9 @@ func (p AttentionPicker) View() string { for i, entry := range attentionEntries { // Checkbox. - check := "[ ]" + check := checkboxOff if _, ok := p.selected[entry.status]; ok { - check = "[✓]" + check = checkboxOn } // Coloured dot — resolve style dynamically for theme changes. @@ -181,9 +187,9 @@ func (p AttentionPicker) View() string { // "Has plan" row. { - check := "[ ]" + check := checkboxOff if p.filterPlans { - check = "[✓]" + check = checkboxOn } dot := styles.PlanIndicatorStyle.Render(styles.IconPlan()) line := fmt.Sprintf(" %s %s %-16s (%d)", check, dot, "Has plan", p.planCount) diff --git a/internal/tui/components/coverage_test.go b/internal/tui/components/coverage_test.go index ad5b123..ce7d88e 100644 --- a/internal/tui/components/coverage_test.go +++ b/internal/tui/components/coverage_test.go @@ -288,6 +288,100 @@ func TestSessionList_SelectedFolderPath_OnSession(t *testing.T) { } } +func TestSessionList_SelectedFolderCwd_FolderPivot(t *testing.T) { + t.Parallel() + sl := NewSessionList() + sl.SetGroups(makeGroups(1, 2)) + sl.SetPivotField("folder") + sl.SetSize(80, 10) + + cwd := sl.SelectedFolderCwd() + if cwd == "" { + t.Error("SelectedFolderCwd with folder pivot should return the folder path") + } + if cwd != sl.SelectedFolderPath() { + t.Errorf("SelectedFolderCwd = %q, want %q (same as SelectedFolderPath)", cwd, sl.SelectedFolderPath()) + } +} + +func TestSessionList_SelectedFolderCwd_RepoPivot(t *testing.T) { + t.Parallel() + groups := []data.SessionGroup{ + { + Label: "jongio/dispatch", + Sessions: []data.Session{ + {ID: "s1", Cwd: `D:\code\dispatch`}, + {ID: "s2", Cwd: `D:\code\dispatch`}, + }, + Count: 2, + }, + } + sl := NewSessionList() + sl.SetGroups(groups) + sl.SetPivotField("repo") + sl.SetSize(80, 10) + + cwd := sl.SelectedFolderCwd() + if cwd != `D:\code\dispatch` { + t.Errorf("SelectedFolderCwd with repo pivot = %q, want %q", cwd, `D:\code\dispatch`) + } +} + +func TestSessionList_SelectedFolderCwd_BranchPivot(t *testing.T) { + t.Parallel() + sl := NewSessionList() + sl.SetGroups(makeGroups(1, 2)) + sl.SetPivotField("branch") + sl.SetSize(80, 10) + + cwd := sl.SelectedFolderCwd() + if cwd != "" { + t.Errorf("SelectedFolderCwd with branch pivot = %q, want empty", cwd) + } +} + +func TestSessionList_SelectedFolderCwd_DatePivot(t *testing.T) { + t.Parallel() + sl := NewSessionList() + sl.SetGroups(makeGroups(1, 2)) + sl.SetPivotField("date") + sl.SetSize(80, 10) + + cwd := sl.SelectedFolderCwd() + if cwd != "" { + t.Errorf("SelectedFolderCwd with date pivot = %q, want empty", cwd) + } +} + +func TestSessionList_SelectedFolderCwd_OnSession(t *testing.T) { + t.Parallel() + sl := NewSessionList() + sl.SetSessions(makeSessions(3)) + sl.SetPivotField("folder") + sl.SetSize(80, 10) + + cwd := sl.SelectedFolderCwd() + if cwd != "" { + t.Errorf("SelectedFolderCwd on session item = %q, want empty", cwd) + } +} + +func TestSessionList_SelectedFolderCwd_RepoNoSessions(t *testing.T) { + t.Parallel() + groups := []data.SessionGroup{ + {Label: "jongio/empty", Sessions: nil, Count: 0}, + } + sl := NewSessionList() + sl.SetGroups(groups) + sl.SetPivotField("repo") + sl.SetSize(80, 10) + + cwd := sl.SelectedFolderCwd() + if cwd != "" { + t.Errorf("SelectedFolderCwd with repo pivot and no sessions = %q, want empty", cwd) + } +} + func TestSessionList_SessionCount(t *testing.T) { t.Parallel() sl := NewSessionList() diff --git a/internal/tui/components/help.go b/internal/tui/components/help.go index 44e39f8..a4cb967 100644 --- a/internal/tui/components/help.go +++ b/internal/tui/components/help.go @@ -104,9 +104,13 @@ func (h HelpOverlay) View() string { sb.WriteByte('\n') sb.WriteString(catStyle.Render("View")) sb.WriteByte('\n') - sb.WriteString(shortcutRow("s", "Cycle sort", "S", "Reverse")) + sb.WriteString(shortcutRow("s", "Cycle sort", "S", "Reverse sort")) sb.WriteByte('\n') - sb.WriteString(shortcutRow("p", "Preview", ",", "Settings")) + sb.WriteString(shortcutRow("Tab", "Cycle pivot", "S-Tab", "Reverse pivot")) + sb.WriteByte('\n') + sb.WriteString(shortcutRow("x", "Expand/collapse", "p", "Preview")) + sb.WriteByte('\n') + sb.WriteString(shortcutRow("P", "Preview position", ",", "Settings")) sb.WriteByte('\n') sb.WriteString(shortcutRow("h", "Hide session", "H", "Show hidden")) sb.WriteByte('\n') diff --git a/internal/tui/components/preview.go b/internal/tui/components/preview.go index 4d88752..a9ef03c 100644 --- a/internal/tui/components/preview.go +++ b/internal/tui/components/preview.go @@ -278,7 +278,6 @@ func (p PreviewPanel) renderContent() (string, int, int) { var b strings.Builder convLine := -1 - idLine := -1 // ── Title ── b.WriteString(styles.PreviewTitleStyle.Render(styles.IconSession()+"Session Detail") + "\n") @@ -298,7 +297,7 @@ func (p PreviewPanel) renderContent() (string, int, int) { } // Record the content line where "ID: ..." is rendered for click-to-copy. - idLine = strings.Count(b.String(), "\n") + idLine := strings.Count(b.String(), "\n") field("ID", s.ID) field("Folder", AbbrevPath(s.Cwd)) diff --git a/internal/tui/components/sessionlist.go b/internal/tui/components/sessionlist.go index 067a0e5..2b05fa8 100644 --- a/internal/tui/components/sessionlist.go +++ b/internal/tui/components/sessionlist.go @@ -306,6 +306,28 @@ func (s *SessionList) ExpandFolder() { } } +// ExpandAll expands every folder row and rebuilds the visible list. +func (s *SessionList) ExpandAll() { + for _, item := range s.allItems { + if item.isFolder { + s.expanded[item.folderPath] = struct{}{} + } + } + s.rebuildVisible() +} + +// AllExpanded returns true when every folder in allItems is currently expanded. +func (s *SessionList) AllExpanded() bool { + for _, item := range s.allItems { + if item.isFolder { + if _, ok := s.expanded[item.folderPath]; !ok { + return false + } + } + } + return true +} + // IsFolderSelected returns true when the cursor is on a folder node. func (s *SessionList) IsFolderSelected() bool { if s.cursor < 0 || s.cursor >= len(s.visItems) { @@ -326,6 +348,39 @@ func (s *SessionList) SelectedFolderPath() string { return item.folderPath } +// SelectedFolderCwd returns the working directory to use when launching a +// new session from the currently selected folder node: +// - folder pivot: the folder path itself (it IS a directory) +// - repo pivot: the Cwd of the first child session in the group +// - branch/date: "" (no meaningful directory to launch into) +func (s *SessionList) SelectedFolderCwd() string { + if s.cursor < 0 || s.cursor >= len(s.visItems) { + return "" + } + idx := s.visItems[s.cursor] + item := s.allItems[idx] + if !item.isFolder { + return "" + } + switch s.pivotField { + case "folder": + return item.folderPath + case "repo": + // Walk forward to find the first child session's Cwd. + for i := idx + 1; i < len(s.allItems); i++ { + if s.allItems[i].isFolder { + break + } + if s.allItems[i].session.Cwd != "" { + return s.allItems[i].session.Cwd + } + } + return "" + default: + return "" + } +} + // Selected returns the currently highlighted session. func (s *SessionList) Selected() (data.Session, bool) { if s.cursor < 0 || s.cursor >= len(s.visItems) { diff --git a/internal/tui/components/sessionlist_test.go b/internal/tui/components/sessionlist_test.go index ad1f07d..e8edf41 100644 --- a/internal/tui/components/sessionlist_test.go +++ b/internal/tui/components/sessionlist_test.go @@ -504,3 +504,143 @@ func TestAttentionDotNilMap(t *testing.T) { t.Fatalf("View() with nil attentionMap has %d lines, want 10", len(lines)) } } + +// --------------------------------------------------------------------------- +// ExpandAll / CollapseAll / AllExpanded tests +// --------------------------------------------------------------------------- + +func TestExpandAll(t *testing.T) { + t.Parallel() + sl := NewSessionList() + sl.SetGroups(makeGroups(3, 4)) // 3 folders × 4 sessions = 15 items total + + // Collapse all folders first. + sl.CollapseAll() + // Only folder rows should be visible (3 folders). + if len(sl.visItems) != 3 { + t.Fatalf("after CollapseAll: visItems = %d, want 3", len(sl.visItems)) + } + + // ExpandAll should make everything visible: 3 folders + 12 sessions = 15. + sl.ExpandAll() + if len(sl.visItems) != 15 { + t.Fatalf("after ExpandAll: visItems = %d, want 15", len(sl.visItems)) + } +} + +func TestCollapseAll(t *testing.T) { + t.Parallel() + sl := NewSessionList() + sl.SetGroups(makeGroups(3, 4)) + + // SetGroups defaults to all expanded, so 15 visible items. + if len(sl.visItems) != 15 { + t.Fatalf("precondition: visItems = %d, want 15", len(sl.visItems)) + } + + sl.CollapseAll() + // Only folder rows visible. + if len(sl.visItems) != 3 { + t.Fatalf("after CollapseAll: visItems = %d, want 3", len(sl.visItems)) + } + // Every visible item should be a folder. + for _, vi := range sl.visItems { + if !sl.allItems[vi].isFolder { + t.Fatal("expected only folder items after CollapseAll") + } + } +} + +func TestCollapseAllClampsCursor(t *testing.T) { + t.Parallel() + sl := NewSessionList() + sl.SetGroups(makeGroups(2, 5)) // 2 folders × 5 sessions = 12 visible + + // Move cursor to the last visible item. + sl.MoveTo(len(sl.visItems) - 1) + if sl.cursor != 11 { + t.Fatalf("precondition: cursor = %d, want 11", sl.cursor) + } + + sl.CollapseAll() + // Now only 2 folder rows; cursor should be clamped. + if sl.cursor >= len(sl.visItems) { + t.Fatalf("cursor %d out of bounds for %d visible items", sl.cursor, len(sl.visItems)) + } +} + +func TestAllExpanded(t *testing.T) { + t.Parallel() + sl := NewSessionList() + sl.SetGroups(makeGroups(3, 2)) + + // SetGroups defaults to all expanded. + if !sl.AllExpanded() { + t.Fatal("AllExpanded should be true after SetGroups") + } + + // Collapse one folder. + sl.MoveTo(0) + sl.CollapseFolder() + if sl.AllExpanded() { + t.Fatal("AllExpanded should be false after collapsing one folder") + } + + // Expand it back. + sl.MoveTo(0) + sl.ExpandFolder() + if !sl.AllExpanded() { + t.Fatal("AllExpanded should be true after re-expanding") + } +} + +func TestAllExpandedEmptyList(t *testing.T) { + t.Parallel() + sl := NewSessionList() + // No items at all → vacuously true. + if !sl.AllExpanded() { + t.Fatal("AllExpanded on empty list should return true") + } +} + +func TestAllExpandedFlatMode(t *testing.T) { + t.Parallel() + sl := NewSessionList() + sl.SetSessions(makeSessions(5)) + // Flat mode has no folders → vacuously true. + if !sl.AllExpanded() { + t.Fatal("AllExpanded in flat mode should return true") + } +} + +func TestExpandAllAfterPartialCollapse(t *testing.T) { + t.Parallel() + sl := NewSessionList() + sl.SetGroups(makeGroups(4, 3)) // 4 folders × 3 sessions = 16 items + + // Collapse two of the four folders. + sl.MoveTo(0) // first folder + sl.CollapseFolder() + // Find and collapse the third folder. + for i, vi := range sl.visItems { + item := sl.allItems[vi] + if item.isFolder && item.folderPath == `D:\code\folderC` { + sl.MoveTo(i) + sl.CollapseFolder() + break + } + } + + if sl.AllExpanded() { + t.Fatal("AllExpanded should be false after partial collapse") + } + + sl.ExpandAll() + if !sl.AllExpanded() { + t.Fatal("AllExpanded should be true after ExpandAll") + } + // All 16 items should be visible. + if len(sl.visItems) != 16 { + t.Fatalf("after ExpandAll: visItems = %d, want 16", len(sl.visItems)) + } +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 2b03c90..8a7ab96 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -48,11 +48,12 @@ type keyMap struct { ViewPlan key.Binding FilterPlans key.Binding CopyID key.Binding + ExpandCollapseAll key.Binding } // ShortHelp returns a compact set of key bindings for the mini help bar. func (k keyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Enter, k.LaunchWindow, k.LaunchTab, k.LaunchPane, k.LaunchAll, k.Search, k.Filter, k.Sort, k.Preview, k.ViewPlan, k.FilterPlans, k.Hide, k.Star, k.CopyID, k.JumpNextAttention, k.FilterAttention, k.ResumeInterrupted, k.Config, k.Help, k.Quit} + return []key.Binding{k.Enter, k.LaunchWindow, k.LaunchTab, k.LaunchPane, k.LaunchAll, k.Search, k.Filter, k.Sort, k.Preview, k.ViewPlan, k.FilterPlans, k.Hide, k.Star, k.CopyID, k.JumpNextAttention, k.FilterAttention, k.ResumeInterrupted, k.ExpandCollapseAll, k.Config, k.Help, k.Quit} } // FullHelp returns grouped key bindings for the expanded help view. @@ -61,7 +62,7 @@ func (k keyMap) FullHelp() [][]key.Binding { {k.Up, k.Down, k.Left, k.Right, k.Enter, k.LaunchWindow, k.LaunchTab, k.LaunchPane}, {k.Space, k.LaunchAll, k.SelectAll, k.DeselectAll}, {k.Search, k.Escape, k.Filter}, - {k.Sort, k.SortOrder, k.Pivot, k.PivotOrder}, + {k.Sort, k.SortOrder, k.Pivot, k.PivotOrder, k.ExpandCollapseAll}, {k.Preview, k.PreviewPosition, k.PreviewScrollUp, k.PreviewScrollDown, k.ConversationSort, k.ViewPlan, k.CopyID, k.Reindex, k.Config}, {k.Hide, k.ToggleHidden, k.Star, k.FilterFavorites, k.JumpNextAttention, k.FilterAttention, k.FilterPlans, k.ResumeInterrupted}, {k.TimeRange1, k.TimeRange2, k.TimeRange3, k.TimeRange4}, @@ -113,4 +114,5 @@ var keys = keyMap{ ViewPlan: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "view plan")), FilterPlans: key.NewBinding(key.WithKeys("M"), key.WithHelp("M", "filter plans")), CopyID: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy session ID")), + ExpandCollapseAll: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "expand/collapse all")), } diff --git a/internal/tui/model.go b/internal/tui/model.go index fd39434..98372ae 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -52,6 +52,10 @@ const ( // completes successfully. statusReindexDone = "Reindexed ✓" + // statusCopiedID is the status message shown when a session ID is + // copied to the clipboard. + statusCopiedID = "Copied session ID ✓" + // statusReindexCancelled is the status message shown when the user // cancels an in-flight reindex operation. statusReindexCancelled = "Reindex cancelled" @@ -463,6 +467,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Only transition from loading to session-list; never clobber an // active modal/overlay state with an async data load. if m.state == stateLoading { + if m.cfg.DefaultCollapsed { + m.sessionList.CollapseAll() + } m.state = stateSessionList } m.searchBar.SetResultCount(m.sessionList.SessionCount()) @@ -485,6 +492,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.sessionList.SetPivotField(m.pivot) m.sessionList.SetGroups(m.groups) if m.state == stateLoading { + if m.cfg.DefaultCollapsed { + m.sessionList.CollapseAll() + } m.state = stateSessionList } m.searchBar.SetResultCount(m.sessionList.SessionCount()) @@ -931,17 +941,10 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.filter.DeepSearch = true } return m, nil - case key.Matches(msg, keys.Up): - m.searchBar.Blur() - m.sessionList.MoveUp() - m.detailVersion++ - return m, m.loadSelectedDetailCmd() - case key.Matches(msg, keys.Down): - m.searchBar.Blur() - m.sessionList.MoveDown() - m.detailVersion++ - return m, m.loadSelectedDetailCmd() default: + // All other keys (including j/k which alias Up/Down) are + // forwarded to the search text input so they appear as typed + // characters instead of triggering shortcuts. var cmd tea.Cmd sb := m.searchBar sb, cmd = sb.Update(msg) @@ -1094,6 +1097,14 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } return m, nil + case key.Matches(msg, keys.ExpandCollapseAll): + if m.sessionList.AllExpanded() { + m.sessionList.CollapseAll() + } else { + m.sessionList.ExpandAll() + } + return m, nil + case key.Matches(msg, keys.Sort): m.cycleSort() return m, m.loadSessionsCmd() @@ -1330,7 +1341,7 @@ func (m Model) handleCopyID() (tea.Model, tea.Cmd) { m.statusErr = "clipboard: " + err.Error() return m, clearStatusAfter(2 * time.Second) } - m.statusInfo = "Copied session ID ✓" + m.statusInfo = statusCopiedID return m, clearStatusAfter(2 * time.Second) } @@ -1517,7 +1528,7 @@ func (m Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { m.statusErr = "clipboard: " + err.Error() return m, clearStatusAfter(2 * time.Second) } - m.statusInfo = "Copied session ID ✓" + m.statusInfo = statusCopiedID return m, clearStatusAfter(2 * time.Second) } } @@ -1668,6 +1679,13 @@ func (m Model) handleHeaderClick(x, y int) (tea.Model, tea.Cmd) { case "pivotorder": m.togglePivotOrder() return m, m.loadSessionsCmd() + case "expandall": + if m.sessionList.AllExpanded() { + m.sessionList.CollapseAll() + } else { + m.sessionList.ExpandAll() + } + return m, nil default: if rest, ok := strings.CutPrefix(action, "time:"); ok { cmd := m.setTimeRange(rest) @@ -1721,11 +1739,11 @@ func (m Model) badgeClickAction(x int) string { sortLabel := arrow + " " + sortDisplayLabel(m.sort.Field) sortKeyRendered := styles.KeyStyle.Render("s") sortKeyW := lipgloss.Width(sortKeyRendered) - sortPrefix := styles.DimmedStyle.Render(":Sort: ") + sortPrefix := styles.DimmedStyle.Render(": ") sortPrefixW := lipgloss.Width(sortPrefix) sortArrowRendered := styles.DimmedStyle.Render(arrow) sortArrowW := lipgloss.Width(sortArrowRendered) - sortFullRendered := sortKeyRendered + styles.DimmedStyle.Render(":Sort: "+sortLabel) + sortFullRendered := sortKeyRendered + styles.DimmedStyle.Render(": "+sortLabel) w := lipgloss.Width(sortFullRendered) if x >= cursor && x < cursor+w { // Click on the arrow portion toggles order; elsewhere cycles sort field. @@ -1742,31 +1760,40 @@ func (m Model) badgeClickAction(x int) string { if pivotLabel == pivotNone { pivotLabel = "list" } - hasPivotArrow := m.pivot != pivotNone - if hasPivotArrow { - pivotArrow := styles.IconSortDown() - if m.pivotOrder == data.Ascending { - pivotArrow = styles.IconSortUp() - } - pivotLabel = pivotArrow + " " + pivotLabel + pivotArrow := styles.IconSortDown() + if m.pivotOrder == data.Ascending { + pivotArrow = styles.IconSortUp() } + pivotLabel = pivotArrow + " " + pivotLabel pivotKeyRendered := styles.KeyStyle.Render("tab") pivotKeyW := lipgloss.Width(pivotKeyRendered) - pivotPrefix := styles.DimmedStyle.Render(":Pivot: ") + pivotPrefix := styles.DimmedStyle.Render(": ") pivotPrefixW := lipgloss.Width(pivotPrefix) - pivotRendered := pivotKeyRendered + styles.DimmedStyle.Render(":Pivot: "+pivotLabel) + pivotRendered := pivotKeyRendered + styles.DimmedStyle.Render(": "+pivotLabel) pw := lipgloss.Width(pivotRendered) if x >= cursor && x < cursor+pw { - if hasPivotArrow { - pivotArrowRendered := styles.DimmedStyle.Render(styles.IconSortDown()) - pivotArrowW := lipgloss.Width(pivotArrowRendered) - arrowStart := cursor + pivotKeyW + pivotPrefixW - if x >= arrowStart && x < arrowStart+pivotArrowW { - return "pivotorder" - } + pivotArrowRendered := styles.DimmedStyle.Render(styles.IconSortDown()) + pivotArrowW := lipgloss.Width(pivotArrowRendered) + arrowStart := cursor + pivotKeyW + pivotPrefixW + if x >= arrowStart && x < arrowStart+pivotArrowW { + return "pivotorder" } return "pivot" } + cursor += pw + 2 + + // Expand/collapse all indicator — only in tree mode. + if m.pivot != pivotNone { + expandIcon := styles.IconSortUp() + styles.IconSortUp() + if m.sessionList.AllExpanded() { + expandIcon = styles.IconSortDown() + styles.IconSortDown() + } + expandRendered := styles.KeyStyle.Render("x") + styles.DimmedStyle.Render(": "+expandIcon) + ew := lipgloss.Width(expandRendered) + if x >= cursor && x < cursor+ew { + return "expandall" + } + } return "" } @@ -1911,21 +1938,28 @@ func (m Model) renderBadges() string { arrow = styles.IconSortUp() } sortLabel := arrow + " " + sortDisplayLabel(m.sort.Field) - parts = append(parts, styles.KeyStyle.Render("s")+styles.DimmedStyle.Render(":Sort: "+sortLabel)) + parts = append(parts, styles.KeyStyle.Render("s")+styles.DimmedStyle.Render(": "+sortLabel)) // Pivot indicator with shortcut (always shown). pivotLabel := m.pivot if pivotLabel == pivotNone { pivotLabel = "list" } + pivotArrow := styles.IconSortDown() + if m.pivotOrder == data.Ascending { + pivotArrow = styles.IconSortUp() + } + pivotLabel = pivotArrow + " " + pivotLabel + parts = append(parts, styles.KeyStyle.Render("tab")+styles.DimmedStyle.Render(": "+pivotLabel)) + + // Expand/collapse all indicator — only shown in tree mode. if m.pivot != pivotNone { - pivotArrow := styles.IconSortDown() - if m.pivotOrder == data.Ascending { - pivotArrow = styles.IconSortUp() + expandIcon := styles.IconSortUp() + styles.IconSortUp() + if m.sessionList.AllExpanded() { + expandIcon = styles.IconSortDown() + styles.IconSortDown() } - pivotLabel = pivotArrow + " " + pivotLabel + parts = append(parts, styles.KeyStyle.Render("x")+styles.DimmedStyle.Render(": "+expandIcon)) } - parts = append(parts, styles.KeyStyle.Render("tab")+styles.DimmedStyle.Render(":Pivot: "+pivotLabel)) // Favorites filter indicator. if m.showFavorited { @@ -2526,9 +2560,9 @@ func (m *Model) launchWithMode(mode string) tea.Cmd { } // launchNewInFolder starts a fresh Copilot session (no session ID) with the -// working directory set to the selected folder's path. +// working directory set based on the selected folder's pivot type. func (m *Model) launchNewInFolder(mode string) tea.Cmd { - cwd := m.sessionList.SelectedFolderPath() + cwd := m.sessionList.SelectedFolderCwd() if cwd == "" { return nil } @@ -2731,7 +2765,7 @@ func (m Model) loadSessionsCmd() tea.Cmd { } if pivot != pivotNone { pf := pivotFieldFromString(pivot) - groups, err := store.GroupSessions(pf, filter, sortOpts, 0) + groups, err := store.GroupSessions(pf, filter, sortOpts, limit) if err != nil { return dataErrorMsg{err: err} } @@ -2941,7 +2975,7 @@ func (m Model) deepSearchCmd(version int) tea.Cmd { } if pivot != pivotNone { pf := pivotFieldFromString(pivot) - groups, err := store.GroupSessions(pf, filter, sortOpts, 0) + groups, err := store.GroupSessions(pf, filter, sortOpts, limit) if err != nil { return dataErrorMsg{err: err} } diff --git a/internal/tui/model_update_test.go b/internal/tui/model_update_test.go index 0a7e54d..05fdac1 100644 --- a/internal/tui/model_update_test.go +++ b/internal/tui/model_update_test.go @@ -1123,17 +1123,17 @@ func TestRenderMainView_BadgesVisibleDuringSearch(t *testing.T) { } } - // Badges row must be present: look for "Sort:" which always - // appears in the badges row. + // Badges row must be present: look for ":1h" which always + // appears in the time-range portion of the badges row. badgesFound := false for _, line := range lines { - if strings.Contains(line, "Sort:") { + if strings.Contains(line, ":1h") { badgesFound = true break } } if !badgesFound { - t.Errorf("badges row (containing 'Sort:') not found in output") + t.Errorf("badges row (containing ':1h') not found in output") for i, line := range lines[:min(5, len(lines))] { t.Logf(" line %d (w=%d): %q", i, lipgloss.Width(line), line) } @@ -2023,8 +2023,8 @@ func TestHandleKey_CopyID_Success(t *testing.T) { if copied != "abc-123" { t.Errorf("clipboard text = %q, want %q", copied, "abc-123") } - if rm.statusInfo != "Copied session ID ✓" { - t.Errorf("statusInfo = %q, want %q", rm.statusInfo, "Copied session ID ✓") + if rm.statusInfo != statusCopiedID { + t.Errorf("statusInfo = %q, want %q", rm.statusInfo, statusCopiedID) } if rm.statusErr != "" { t.Errorf("statusErr = %q, want empty", rm.statusErr) @@ -2271,11 +2271,13 @@ func TestHandleKey_SearchFocused_UpDown(t *testing.T) { m.searchBar.Focus() m.sessionList.SetSessions([]data.Session{{ID: "s1"}, {ID: "s2"}}) - // Up should blur search and move selection. + // Up arrow should be forwarded to the search text input (cursor + // movement), NOT blur the search bar. This prevents the "k" alias + // from leaking as a navigation shortcut while typing a query. result, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyUp}) rm := result.(Model) - if rm.searchBar.Focused() { - t.Error("search bar should be blurred after Up") + if !rm.searchBar.Focused() { + t.Error("search bar should remain focused after Up — keys must not leak") } } @@ -2284,10 +2286,31 @@ func TestHandleKey_SearchFocused_Down(t *testing.T) { m.searchBar.Focus() m.sessionList.SetSessions([]data.Session{{ID: "s1"}, {ID: "s2"}}) + // Down arrow should be forwarded to the search text input, NOT blur + // the search bar. This prevents the "j" alias from leaking. result, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) rm := result.(Model) - if rm.searchBar.Focused() { - t.Error("search bar should be blurred after Down") + if !rm.searchBar.Focused() { + t.Error("search bar should remain focused after Down — keys must not leak") + } +} + +func TestHandleKey_SearchFocused_CharKeysDoNotLeak(t *testing.T) { + // Regression test: character keys that alias navigation shortcuts + // (j→Down, k→Up) must be consumed by the search text input, not + // trigger session-list movement. + for _, ch := range []rune{'j', 'k', 's', 'f', 'x', 'q', 'p', 'r', 'h', 'a', 'd'} { + t.Run(string(ch), func(t *testing.T) { + m := newTestModel() + m.searchBar.Focus() + m.sessionList.SetSessions([]data.Session{{ID: "s1"}, {ID: "s2"}}) + + result, _ := m.Update(tea.KeyPressMsg{Code: ch, Text: string(ch)}) + rm := result.(Model) + if !rm.searchBar.Focused() { + t.Errorf("search bar lost focus after pressing %q — shortcut leaked", ch) + } + }) } } @@ -2970,6 +2993,7 @@ func TestHandleKey_Enter_FolderSelected(t *testing.T) { {Label: "folder1", Sessions: []data.Session{{ID: "s1"}}}, } m.sessionList.SetGroups(groups) + m.sessionList.SetPivotField(pivotFolder) // When a folder is selected, Enter launches a new session in that folder. result, cmd := m.Update(enterKeyMsg()) _ = result.(Model) @@ -2979,6 +3003,22 @@ func TestHandleKey_Enter_FolderSelected(t *testing.T) { } } +func TestHandleKey_Enter_FolderSelected_BranchPivot(t *testing.T) { + m := newTestModel() + m.pivot = pivotBranch + groups := []data.SessionGroup{ + {Label: "main", Sessions: []data.Session{{ID: "s1"}}}, + } + m.sessionList.SetGroups(groups) + m.sessionList.SetPivotField(pivotBranch) + // Branch pivot folders have no meaningful cwd; Enter should be a no-op. + result, cmd := m.Update(enterKeyMsg()) + _ = result.(Model) + if cmd != nil { + t.Error("Enter on branch-pivot folder should return nil cmd") + } +} + // --------------------------------------------------------------------------- // Cmd closure execution — boost inner coverage // --------------------------------------------------------------------------- diff --git a/magefile.go b/magefile.go index 1e95521..3c03cbe 100644 --- a/magefile.go +++ b/magefile.go @@ -295,11 +295,12 @@ func fmtSources() error { unformatted = append(unformatted, f) } } - fmt.Printf(" Formatting %d file(s):\n", len(unformatted)) + fmt.Printf(" %d file(s) need formatting:\n", len(unformatted)) for _, f := range unformatted { fmt.Printf(" %s\n", f) } - return run("gofmt", "-w", ".") + fmt.Println("\n Run 'gofmt -w .' to fix, then commit the changes.") + return fmt.Errorf("gofmt: %d file(s) not formatted", len(unformatted)) } func binaryName() string { @@ -342,13 +343,42 @@ func ensurePath() error { return nil } - // Windows: ensure bin/ is in persistent PATH + // Windows: scrub stale dispatch worktree bins from persistent PATH, + // then ensure the current project's bin dir is registered. machinePath, _ := cmdOutput("powershell", "-NoProfile", "-Command", `[Environment]::GetEnvironmentVariable('Path','Machine')`) machinePath = strings.TrimSpace(machinePath) - if containsPath(machinePath, binDir) { - // Already registered; just make sure the current session has it + userPath, _ := cmdOutput("powershell", "-NoProfile", "-Command", + `[Environment]::GetEnvironmentVariable('Path','User')`) + userPath = strings.TrimSpace(userPath) + + cleanedMachine, removedMachine := scrubDispatchBins(machinePath, binDir) + if len(removedMachine) > 0 { + fmt.Println("\n=== Scrubbing stale dispatch bin dirs from Machine PATH ===") + for _, r := range removedMachine { + fmt.Printf(" Removed: %s\n", r) + } + if err := exec.Command("powershell", "-NoProfile", "-Command", + fmt.Sprintf(`[Environment]::SetEnvironmentVariable('Path','%s','Machine')`, cleanedMachine)).Run(); err != nil { + fmt.Println(" Machine PATH update failed (need admin)") + } + } + machinePath = cleanedMachine + + cleanedUser, removedUser := scrubDispatchBins(userPath, binDir) + if len(removedUser) > 0 { + fmt.Println("\n=== Scrubbing stale dispatch bin dirs from User PATH ===") + for _, r := range removedUser { + fmt.Printf(" Removed: %s\n", r) + } + exec.Command("powershell", "-NoProfile", "-Command", + fmt.Sprintf(`[Environment]::SetEnvironmentVariable('Path','%s','User')`, cleanedUser)).Run() + } + userPath = cleanedUser + + if containsPath(machinePath, binDir) || containsPath(userPath, binDir) { + // Already registered; just make sure the current session has it. ensureSessionPath(binDir) return nil } @@ -359,9 +389,6 @@ func ensurePath() error { fmt.Sprintf(`[Environment]::SetEnvironmentVariable('Path','%s','Machine')`, newPath)).Run() if err != nil { fmt.Println(" Machine PATH failed (need admin), trying User PATH...") - userPath, _ := cmdOutput("powershell", "-NoProfile", "-Command", - `[Environment]::GetEnvironmentVariable('Path','User')`) - userPath = strings.TrimSpace(userPath) if !containsPath(userPath, binDir) { exec.Command("powershell", "-NoProfile", "-Command", fmt.Sprintf(`[Environment]::SetEnvironmentVariable('Path','%s;%s','User')`, binDir, userPath)).Run() @@ -375,11 +402,59 @@ func containsPath(pathList, dir string) bool { return strings.Contains(strings.ToLower(pathList), strings.ToLower(dir)) } +// isDispatchBinDir reports whether a PATH entry looks like a dispatch +// project or worktree bin directory: the path contains "dispatch" +// (case-insensitive) and the final path component is "bin". This +// preserves the production install path (e.g. +// C:\Users\jong\AppData\Local\Programs\dispatch) which does not end +// with \bin. +func isDispatchBinDir(entry string) bool { + clean := filepath.Clean(entry) + if !strings.Contains(strings.ToLower(clean), "dispatch") { + return false + } + return strings.EqualFold(filepath.Base(clean), "bin") +} + +// scrubDispatchBins removes dispatch worktree/project bin directories +// from pathList, keeping only the entry matching keep (case-insensitive +// comparison). If keep is empty, all dispatch bin dirs are removed. +// Non-dispatch entries are always preserved. Returns the cleaned path +// and the list of removed entries. +func scrubDispatchBins(pathList, keep string) (string, []string) { + sep := string(os.PathListSeparator) + entries := strings.Split(pathList, sep) + keepClean := "" + if keep != "" { + keepClean = filepath.Clean(keep) + } + var out []string + var removed []string + for _, e := range entries { + e = strings.TrimSpace(e) + if e == "" { + continue + } + clean := filepath.Clean(e) + if keepClean != "" && strings.EqualFold(clean, keepClean) { + out = append(out, e) + continue + } + if isDispatchBinDir(clean) { + removed = append(removed, e) + continue + } + out = append(out, e) + } + return strings.Join(out, sep), removed +} + func ensureSessionPath(binDir string) { current := os.Getenv("Path") - if !containsPath(current, binDir) { - os.Setenv("Path", binDir+";"+current) - } + // Remove all dispatch bin dirs (including current if present), then + // prepend current so it takes priority over any other entry. + cleaned, _ := scrubDispatchBins(current, "") + os.Setenv("Path", binDir+string(os.PathListSeparator)+cleaned) } func verify() error {