Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions cmd/contributors/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
6 changes: 6 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
87 changes: 87 additions & 0 deletions internal/config/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
7 changes: 7 additions & 0 deletions internal/data/attention.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 10 additions & 4 deletions internal/tui/components/attentionpicker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down
94 changes: 94 additions & 0 deletions internal/tui/components/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
8 changes: 6 additions & 2 deletions internal/tui/components/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
3 changes: 1 addition & 2 deletions internal/tui/components/preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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))

Expand Down
55 changes: 55 additions & 0 deletions internal/tui/components/sessionlist.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
Loading
Loading