From 092b1ceb6cdf9aa419b5a94cb89a7e35298c5df3 Mon Sep 17 00:00:00 2001 From: Thomas Carr <9591402+htcarr3@users.noreply.github.com> Date: Sun, 15 Feb 2026 11:06:16 -0500 Subject: [PATCH] =?UTF-8?q?feat(tui):=20add=20polish=20features=20?= =?UTF-8?q?=E2=80=94=20filter,=20toast,=20layout,=20multi-select,=20refres?= =?UTF-8?q?h?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 10 features to make the dashboard TUI feel like a power-user cockpit: - Remember cursor position on back-navigation from workspace to repo list - Scroll position indicator (3/12) in panel titles for long lists - Global status bar with repo/workspace/running counts - Toast notifications for start/stop/archive operations (3s auto-dismiss) - Search/filter with `/` (case-insensitive, works on both views) - Multi-select with space for batch run/stop/archive operations - Branch name and compact time columns in workspace rows - Side-by-side list+detail layout for wide terminals (≥120 cols) - ctrl+r manual refresh, ctrl+l redraw - Auto-refresh running status via tmux polling (5s interval) Also fixes: - JetBrains JediTerm rendering: disable wide layout, ensure consistent line widths via constrainWidth(padToHeight()) ordering - Help bar visibility in wide mode (missing newline after JoinHorizontal) - Unused variable lint error in debug.go - Promote charmbracelet/x/ansi to direct dependency (used in panel.go) --- go.mod | 2 +- internal/tui/debug.go | 76 +++++ internal/tui/help.go | 10 +- internal/tui/keys.go | 20 ++ internal/tui/messages.go | 23 ++ internal/tui/model.go | 505 ++++++++++++++++++++++++++++--- internal/tui/model_test.go | 535 +++++++++++++++++++++++++++++++++ internal/tui/opener_picker.go | 11 +- internal/tui/panel.go | 98 +++++- internal/tui/repo_list.go | 69 ++++- internal/tui/styles.go | 17 ++ internal/tui/view_test.go | 362 ++++++++++++++++++++++ internal/tui/workspace_list.go | 198 +++++++++--- 13 files changed, 1818 insertions(+), 108 deletions(-) create mode 100644 internal/tui/debug.go diff --git a/go.mod b/go.mod index 13f5789..35fa016 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/ansi v0.11.6 github.com/mark3labs/mcp-go v0.43.2 github.com/mattn/go-isatty v0.0.20 github.com/spf13/cobra v1.10.2 @@ -18,7 +19,6 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect diff --git a/internal/tui/debug.go b/internal/tui/debug.go new file mode 100644 index 0000000..9a7293b --- /dev/null +++ b/internal/tui/debug.go @@ -0,0 +1,76 @@ +package tui + +import ( + "fmt" + "log" + "os" + "strings" + "sync" + + "github.com/charmbracelet/x/ansi" +) + +var ( + debugLogger *log.Logger + debugOnce sync.Once +) + +// debugLog writes a message to the debug log file. +// Only active when FR8_TUI_DEBUG=1. Log file: /tmp/fr8-tui-debug.log +func debugLog(format string, args ...any) { + debugOnce.Do(func() { + if os.Getenv("FR8_TUI_DEBUG") != "1" { + return + } + f, err := os.OpenFile("/tmp/fr8-tui-debug.log", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return + } + debugLogger = log.New(f, "", log.Ltime|log.Lmicroseconds) + + // Dump environment info on first call + debugLogger.Printf("=== fr8 TUI debug log ===") + debugLogger.Printf("TERM=%s", os.Getenv("TERM")) + debugLogger.Printf("TERM_PROGRAM=%s", os.Getenv("TERM_PROGRAM")) + debugLogger.Printf("TERM_PROGRAM_VERSION=%s", os.Getenv("TERM_PROGRAM_VERSION")) + debugLogger.Printf("COLORTERM=%s", os.Getenv("COLORTERM")) + debugLogger.Printf("TERMINAL_EMULATOR=%s", os.Getenv("TERMINAL_EMULATOR")) + debugLogger.Printf("LANG=%s", os.Getenv("LANG")) + + // Test box-drawing character widths + chars := []struct { + name string + ch string + }{ + {"╭", "╭"}, {"╮", "╮"}, {"╰", "╰"}, {"╯", "╯"}, + {"│", "│"}, {"─", "─"}, {"▸", "▸"}, {"▶", "▶"}, + {"●", "●"}, {"✓", "✓"}, {"✗", "✗"}, {"·", "·"}, + } + for _, c := range chars { + debugLogger.Printf("char %s: ansi.StringWidth=%d len(bytes)=%d", c.name, ansi.StringWidth(c.ch), len(c.ch)) + } + }) + + if debugLogger != nil { + debugLogger.Printf(format, args...) + } +} + +// debugLogView logs diagnostic info about the final rendered view. +func debugLogView(view string, modelWidth, modelHeight int) { + if debugLogger == nil { + return + } + lines := strings.Split(view, "\n") + debugLog("--- View render: model.width=%d model.height=%d lines=%d ---", modelWidth, modelHeight, len(lines)) + for i, line := range lines { + w := ansi.StringWidth(line) + marker := "" + if w != modelWidth { + marker = fmt.Sprintf(" *** MISMATCH (expected %d)", modelWidth) + } + if i < 30 || marker != "" { // Log first 30 lines, plus any mismatches + debugLog(" line[%2d] width=%3d%s", i, w, marker) + } + } +} diff --git a/internal/tui/help.go b/internal/tui/help.go index a475660..3950d2f 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -16,7 +16,10 @@ func renderHelp(m model) string { sections.WriteString(formatHelpLine("j/↓", "Move down")) sections.WriteString(formatHelpLine("k/↑", "Move up")) sections.WriteString(formatHelpLine("enter", "Select / drill down")) - sections.WriteString(formatHelpLine("esc", "Back / cancel")) + sections.WriteString(formatHelpLine("esc", "Back / cancel / clear selection")) + sections.WriteString(formatHelpLine("/", "Filter list")) + sections.WriteString(formatHelpLine("ctrl+r", "Refresh data")) + sections.WriteString(formatHelpLine("ctrl+l", "Redraw screen")) sections.WriteString(formatHelpLine("?", "Toggle this help")) sections.WriteString(formatHelpLine("q", "Quit")) @@ -33,8 +36,9 @@ func renderHelp(m model) string { sections.WriteString(breadcrumbActiveStyle.Render("Workspace List")) sections.WriteString("\n") sections.WriteString(formatHelpLine("n", "Create new workspace")) - sections.WriteString(formatHelpLine("r", "Run dev server")) - sections.WriteString(formatHelpLine("x", "Stop dev server")) + sections.WriteString(formatHelpLine("space", "Toggle selection for bulk operations")) + sections.WriteString(formatHelpLine("r", "Run dev server (or run all selected)")) + sections.WriteString(formatHelpLine("x", "Stop dev server (or stop all selected)")) sections.WriteString(formatHelpLine("t", "Attach to running session")) sections.WriteString(formatHelpLine("s", "Open shell")) sections.WriteString(formatHelpLine("o", "Open with configured opener")) diff --git a/internal/tui/keys.go b/internal/tui/keys.go index f6191cb..7c78dda 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -18,6 +18,10 @@ type keyMap struct { Attach key.Binding RunAllGlobal key.Binding StopAllGlobal key.Binding + Filter key.Binding + Select key.Binding + Refresh key.Binding + Redraw key.Binding Help key.Binding Quit key.Binding Yes key.Binding @@ -85,6 +89,22 @@ var keys = keyMap{ key.WithKeys("X"), key.WithHelp("X", "global stop"), ), + Filter: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "filter"), + ), + Select: key.NewBinding( + key.WithKeys(" "), + key.WithHelp("space", "select"), + ), + Refresh: key.NewBinding( + key.WithKeys("ctrl+r"), + key.WithHelp("ctrl+r", "refresh"), + ), + Redraw: key.NewBinding( + key.WithKeys("ctrl+l"), + key.WithHelp("ctrl+l", "redraw"), + ), Help: key.NewBinding( key.WithKeys("?"), key.WithHelp("?", "help"), diff --git a/internal/tui/messages.go b/internal/tui/messages.go index 203963d..a0362d0 100644 --- a/internal/tui/messages.go +++ b/internal/tui/messages.go @@ -4,6 +4,7 @@ import ( "github.com/protocollar/fr8/internal/gh" "github.com/protocollar/fr8/internal/git" "github.com/protocollar/fr8/internal/registry" + "github.com/protocollar/fr8/internal/tmux" "github.com/protocollar/fr8/internal/userconfig" ) @@ -121,3 +122,25 @@ type createRequestMsg struct { name string rootPath string } + +// Toast notifications +type toastTickMsg struct{} + +// Multi-select batch operations +type batchStartResultMsg struct { + started int + err error +} + +type batchStopResultMsg struct { + stopped int + err error +} + +// Auto-refresh +type autoRefreshTickMsg struct{} + +type autoRefreshResultMsg struct { + sessions []tmux.Session + err error +} diff --git a/internal/tui/model.go b/internal/tui/model.go index f80e09b..6f1badc 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -6,6 +6,7 @@ import ( "os/exec" "runtime" "strings" + "time" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/spinner" @@ -27,6 +28,7 @@ type model struct { repos []repoItem workspaces []workspaceItem cursor int + repoCursor int // remembered cursor position on repo list loading bool err error repoName string // current repo being viewed @@ -45,6 +47,18 @@ type model struct { width int height int spinner spinner.Model + + // Toast notifications + toast string + toastExpiry time.Time + toastIsError bool + + // Search/filter + filtering bool + filterInput textinput.Model + + // Multi-select + selected map[int]bool } func newModel() model { @@ -61,12 +75,13 @@ func newModel() model { } func (m model) Init() tea.Cmd { - return tea.Batch(loadReposCmd, m.spinner.Tick) + return tea.Batch(loadReposCmd, m.spinner.Tick, autoRefreshTickCmd()) } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: + debugLog("WindowSizeMsg: width=%d→%d height=%d→%d", m.width, msg.Width, m.height, msg.Height) m.width = msg.Width m.height = msg.Height return m, nil @@ -103,7 +118,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.repoName = msg.repoName m.rootPath = msg.rootPath m.defaultBranch = msg.defaultBranch - m.cursor = 0 + if m.view == viewWorkspaceList { + // Refresh: clamp cursor instead of resetting + if m.cursor >= len(m.workspaces) && m.cursor > 0 { + m.cursor = len(m.workspaces) - 1 + } + } else { + m.cursor = 0 + } m.view = viewWorkspaceList return m, nil @@ -111,8 +133,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.loading = false if msg.err != nil { m.err = msg.err + m.toast = fmt.Sprintf("error archiving %s", msg.name) + m.toastIsError = true + m.toastExpiry = time.Now().Add(3 * time.Second) m.view = viewWorkspaceList - return m, nil + return m, toastTickCmd() } // Remove archived workspace from list for i, ws := range m.workspaces { @@ -132,38 +157,51 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } m.err = nil + m.toast = fmt.Sprintf("archived %s", msg.name) + m.toastIsError = false + m.toastExpiry = time.Now().Add(3 * time.Second) m.view = viewWorkspaceList - return m, nil + return m, toastTickCmd() case startResultMsg: m.loading = false if msg.err != nil { m.err = msg.err - return m, nil - } - for i, ws := range m.workspaces { - if ws.Workspace.Name == msg.name { - m.workspaces[i].Running = true - break + m.toast = fmt.Sprintf("error starting %s", msg.name) + m.toastIsError = true + } else { + for i, ws := range m.workspaces { + if ws.Workspace.Name == msg.name { + m.workspaces[i].Running = true + break + } } + m.err = nil + m.toast = fmt.Sprintf("started %s", msg.name) + m.toastIsError = false } - m.err = nil - return m, nil + m.toastExpiry = time.Now().Add(3 * time.Second) + return m, toastTickCmd() case stopResultMsg: m.loading = false if msg.err != nil { m.err = msg.err - return m, nil - } - for i, ws := range m.workspaces { - if ws.Workspace.Name == msg.name { - m.workspaces[i].Running = false - break + m.toast = fmt.Sprintf("error stopping %s", msg.name) + m.toastIsError = true + } else { + for i, ws := range m.workspaces { + if ws.Workspace.Name == msg.name { + m.workspaces[i].Running = false + break + } } + m.err = nil + m.toast = fmt.Sprintf("stopped %s", msg.name) + m.toastIsError = false } - m.err = nil - return m, nil + m.toastExpiry = time.Now().Add(3 * time.Second) + return m, toastTickCmd() case browserResultMsg: if msg.err != nil { @@ -175,28 +213,41 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.loading = false if msg.err != nil { m.err = msg.err + m.toast = fmt.Sprintf("error running workspaces: %v", msg.err) + m.toastIsError = true } else { m.err = nil + m.toast = fmt.Sprintf("started %d workspaces", msg.started) + m.toastIsError = false } + m.toastExpiry = time.Now().Add(3 * time.Second) refreshRunningCounts(m.repos) - return m, nil + return m, toastTickCmd() case stopAllResultMsg: m.loading = false if msg.err != nil { m.err = msg.err + m.toast = fmt.Sprintf("error stopping workspaces: %v", msg.err) + m.toastIsError = true } else { m.err = nil + m.toast = fmt.Sprintf("stopped %d workspaces", msg.stopped) + m.toastIsError = false } + m.toastExpiry = time.Now().Add(3 * time.Second) refreshRunningCounts(m.repos) - return m, nil + return m, toastTickCmd() case batchArchiveResultMsg: m.loading = false if msg.err != nil { m.err = msg.err + m.toast = "batch archive failed" + m.toastIsError = true + m.toastExpiry = time.Now().Add(3 * time.Second) m.view = viewWorkspaceList - return m, nil + return m, toastTickCmd() } // Remove archived workspaces from list archived := make(map[string]bool, len(msg.archived)) @@ -224,9 +275,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = nil if len(msg.failed) > 0 { m.err = fmt.Errorf("archiving: %s", strings.Join(msg.failed, ", ")) + m.toast = fmt.Sprintf("archived %d, %d failed", len(msg.archived), len(msg.failed)) + m.toastIsError = true + } else { + m.toast = fmt.Sprintf("archived %d workspaces", len(msg.archived)) + m.toastIsError = false } + m.toastExpiry = time.Now().Add(3 * time.Second) m.view = viewWorkspaceList - return m, nil + return m, toastTickCmd() case openersLoadedMsg: m.loading = false @@ -261,6 +318,81 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.openerCursor = 0 m.view = viewOpenerPicker return m, nil + + case toastTickMsg: + if m.toast != "" && time.Now().After(m.toastExpiry) { + m.toast = "" + m.toastIsError = false + } + if m.toast != "" { + return m, toastTickCmd() + } + return m, nil + + case batchStartResultMsg: + m.loading = false + if msg.err != nil { + m.err = msg.err + m.toast = fmt.Sprintf("error starting workspaces: %v", msg.err) + m.toastIsError = true + } else { + m.err = nil + m.toast = fmt.Sprintf("started %d workspaces", msg.started) + m.toastIsError = false + } + m.toastExpiry = time.Now().Add(3 * time.Second) + m.selected = nil + refreshRunningCounts(m.repos) + return m, toastTickCmd() + + case batchStopResultMsg: + m.loading = false + if msg.err != nil { + m.err = msg.err + m.toast = fmt.Sprintf("error stopping workspaces: %v", msg.err) + m.toastIsError = true + } else { + m.err = nil + m.toast = fmt.Sprintf("stopped %d workspaces", msg.stopped) + m.toastIsError = false + } + m.toastExpiry = time.Now().Add(3 * time.Second) + m.selected = nil + refreshRunningCounts(m.repos) + return m, toastTickCmd() + + case autoRefreshTickMsg: + if m.loading { + return m, tea.Batch(autoRefreshTickCmd(), tea.WindowSize()) + } + return m, tea.Batch(autoRefreshCmd(), autoRefreshTickCmd(), tea.WindowSize()) + + case autoRefreshResultMsg: + if msg.err != nil { + return m, nil + } + // Build running session lookup + runningSessions := make(map[string]bool, len(msg.sessions)) + for _, s := range msg.sessions { + runningSessions[s.Name] = true + } + // Update workspace running states + if m.rootPath != "" { + repoName := tmux.RepoName(m.rootPath) + for i, ws := range m.workspaces { + sessionName := tmux.SessionName(repoName, ws.Workspace.Name) + m.workspaces[i].Running = runningSessions[sessionName] + } + } + // Update repo running counts + repoCounts := make(map[string]int) + for _, s := range msg.sessions { + repoCounts[s.Repo]++ + } + for i := range m.repos { + m.repos[i].RunningCount = repoCounts[tmux.RepoName(m.repos[i].Repo.Path)] + } + return m, nil } return m, nil } @@ -275,6 +407,16 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } + // Redraw (ctrl+l) — clear screen and re-query terminal size from any view + if key.Matches(msg, keys.Redraw) { + return m, tea.Batch(tea.ClearScreen, tea.WindowSize()) + } + + // While filtering, forward keys to textinput except Esc and Enter + if m.filtering { + return m.handleFilterKey(msg) + } + // Toggle help overlay from any view (except text input views) if key.Matches(msg, keys.Help) && m.view != viewCreateWorkspace { if m.view == viewHelp { @@ -308,33 +450,53 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func (m model) handleRepoKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + filtered := filteredRepos(m.repos, m.filterInput.Value()) + switch { + case key.Matches(msg, keys.Filter): + ti := textinput.New() + ti.Placeholder = "filter..." + ti.Focus() + ti.CharLimit = 64 + m.filterInput = ti + m.filtering = true + m.cursor = 0 + return m, ti.Cursor.BlinkCmd() + case key.Matches(msg, keys.Refresh): + m.loading = true + m.err = nil + return m, tea.Batch(loadReposCmd, m.spinner.Tick, tea.ClearScreen) case key.Matches(msg, keys.Up): if m.cursor > 0 { m.cursor-- } case key.Matches(msg, keys.Down): - if m.cursor < len(m.repos)-1 { + if m.cursor < len(filtered)-1 { m.cursor++ } case key.Matches(msg, keys.Enter): - if len(m.repos) > 0 { + if len(filtered) > 0 { + m.repoCursor = m.cursor // remember position for back-navigation m.loading = true m.err = nil - repo := m.repos[m.cursor].Repo + origIdx := resolveOriginalRepoIndex(m.cursor, filtered, m.repos) + repo := m.repos[origIdx].Repo + m.filterInput.SetValue("") // clear filter on drill-down return m, tea.Batch(loadWorkspacesCmd(repo), m.spinner.Tick) } case key.Matches(msg, keys.Run): - if len(m.repos) > 0 { + if len(filtered) > 0 { m.loading = true m.err = nil - return m, tea.Batch(runAllCmd(m.repos[m.cursor]), m.spinner.Tick) + origIdx := resolveOriginalRepoIndex(m.cursor, filtered, m.repos) + return m, tea.Batch(runAllCmd(m.repos[origIdx]), m.spinner.Tick) } case key.Matches(msg, keys.Stop): - if len(m.repos) > 0 { + if len(filtered) > 0 { m.loading = true m.err = nil - return m, tea.Batch(stopAllCmd(m.repos[m.cursor]), m.spinner.Tick) + origIdx := resolveOriginalRepoIndex(m.cursor, filtered, m.repos) + return m, tea.Batch(stopAllCmd(m.repos[origIdx]), m.spinner.Tick) } case key.Matches(msg, keys.RunAllGlobal): if len(m.repos) > 0 { @@ -353,22 +515,73 @@ func (m model) handleRepoKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func (m model) handleWorkspaceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + filtered := filteredWorkspaces(m.workspaces, m.filterInput.Value()) + + // Helper to resolve the original workspace from the filtered cursor position + resolveWs := func() workspaceItem { + origIdx := resolveOriginalWsIndex(m.cursor, filtered, m.workspaces) + return m.workspaces[origIdx] + } + switch { + case key.Matches(msg, keys.Filter): + ti := textinput.New() + ti.Placeholder = "filter..." + ti.Focus() + ti.CharLimit = 64 + m.filterInput = ti + m.filtering = true + m.cursor = 0 + return m, ti.Cursor.BlinkCmd() + case key.Matches(msg, keys.Refresh): + if m.rootPath != "" { + m.loading = true + m.err = nil + // Find the repo in the list + for _, r := range m.repos { + if r.Repo.Name == m.repoName { + return m, tea.Batch(loadWorkspacesCmd(r.Repo), m.spinner.Tick, tea.ClearScreen) + } + } + } + case key.Matches(msg, keys.Select): + if len(filtered) > 0 { + if m.selected == nil { + m.selected = make(map[int]bool) + } + origIdx := resolveOriginalWsIndex(m.cursor, filtered, m.workspaces) + if m.selected[origIdx] { + delete(m.selected, origIdx) + } else { + m.selected[origIdx] = true + } + // Advance cursor + if m.cursor < len(filtered)-1 { + m.cursor++ + } + } case key.Matches(msg, keys.Up): if m.cursor > 0 { m.cursor-- } case key.Matches(msg, keys.Down): - if m.cursor < len(m.workspaces)-1 { + if m.cursor < len(filtered)-1 { m.cursor++ } case key.Matches(msg, keys.Back): + // First Esc clears selection, second navigates back + if len(m.selected) > 0 { + m.selected = nil + return m, nil + } m.view = viewRepoList - m.cursor = 0 + m.cursor = m.repoCursor // restore remembered cursor position m.err = nil + m.filterInput.SetValue("") // clear filter on back case key.Matches(msg, keys.Archive): - if len(m.workspaces) > 0 { - m.archiveIdx = m.cursor + if len(filtered) > 0 { + origIdx := resolveOriginalWsIndex(m.cursor, filtered, m.workspaces) + m.archiveIdx = origIdx m.view = viewConfirmArchive } case key.Matches(msg, keys.BatchArchive): @@ -387,8 +600,8 @@ func (m model) handleWorkspaceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.view = viewConfirmBatchArchive } case key.Matches(msg, keys.Shell): - if len(m.workspaces) > 0 { - ws := m.workspaces[m.cursor] + if len(filtered) > 0 { + ws := resolveWs() m.shellRequest = &shellRequestMsg{ workspace: ws.Workspace, rootPath: m.rootPath, @@ -396,8 +609,14 @@ func (m model) handleWorkspaceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, tea.Quit } case key.Matches(msg, keys.Run): - if len(m.workspaces) > 0 { - ws := m.workspaces[m.cursor] + if len(filtered) > 0 { + // Multi-select batch run + if len(m.selected) > 0 { + m.loading = true + m.err = nil + return m, tea.Batch(startSelectedCmd(m.workspaces, m.selected, m.rootPath), m.spinner.Tick) + } + ws := resolveWs() if ws.Running { m.err = fmt.Errorf("%q is already running", ws.Workspace.Name) return m, nil @@ -407,13 +626,19 @@ func (m model) handleWorkspaceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, tea.Batch(startWorkspaceCmd(ws.Workspace, m.rootPath), m.spinner.Tick) } case key.Matches(msg, keys.Browser): - if len(m.workspaces) > 0 { - ws := m.workspaces[m.cursor] + if len(filtered) > 0 { + ws := resolveWs() return m, openBrowserCmd(ws.Workspace) } case key.Matches(msg, keys.Stop): - if len(m.workspaces) > 0 { - ws := m.workspaces[m.cursor] + if len(filtered) > 0 { + // Multi-select batch stop + if len(m.selected) > 0 { + m.loading = true + m.err = nil + return m, tea.Batch(stopSelectedCmd(m.workspaces, m.selected, m.rootPath), m.spinner.Tick) + } + ws := resolveWs() if !ws.Running { m.err = fmt.Errorf("%q is not running", ws.Workspace.Name) return m, nil @@ -423,8 +648,8 @@ func (m model) handleWorkspaceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, tea.Batch(stopWorkspaceCmd(ws.Workspace, m.rootPath), m.spinner.Tick) } case key.Matches(msg, keys.Attach): - if len(m.workspaces) > 0 { - ws := m.workspaces[m.cursor] + if len(filtered) > 0 { + ws := resolveWs() if !ws.Running { m.err = fmt.Errorf("%q is not running (run with r)", ws.Workspace.Name) return m, nil @@ -436,8 +661,9 @@ func (m model) handleWorkspaceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, tea.Quit } case key.Matches(msg, keys.Open): - if len(m.workspaces) > 0 { - m.openerWsIdx = m.cursor + if len(filtered) > 0 { + origIdx := resolveOriginalWsIndex(m.cursor, filtered, m.workspaces) + m.openerWsIdx = origIdx m.loading = true m.err = nil return m, tea.Batch(loadOpenersCmd(), m.spinner.Tick) @@ -458,6 +684,31 @@ func (m model) handleWorkspaceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +// handleFilterKey handles keypresses while filter mode is active. +func (m model) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEsc: + m.filtering = false + m.filterInput.SetValue("") + m.cursor = 0 + return m, nil + case tea.KeyEnter: + m.filtering = false + m.filterInput.Blur() + // Keep filter value, exit filter input mode + return m, nil + } + + oldVal := m.filterInput.Value() + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(msg) + // Reset cursor when filter text changes + if m.filterInput.Value() != oldVal { + m.cursor = 0 + } + return m, cmd +} + func (m model) handleOpenerPickerKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, keys.Up): @@ -548,7 +799,9 @@ func (m model) View() string { case viewHelp: s = renderHelp(m) } - return padToHeight(s, m.height) + out := constrainWidth(padToHeight(s, m.height), m.width) + debugLogView(out, m.width, m.height) + return out } // Async commands @@ -967,6 +1220,162 @@ func findDefaultOpener(openers []userconfig.Opener) *userconfig.Opener { return nil } +// --- Filter helpers --- + +// filteredRepos returns repos matching the query (case-insensitive substring). +func filteredRepos(repos []repoItem, query string) []repoItem { + if query == "" { + return repos + } + q := strings.ToLower(query) + var result []repoItem + for _, r := range repos { + if strings.Contains(strings.ToLower(r.Repo.Name), q) { + result = append(result, r) + } + } + return result +} + +// filteredWorkspaces returns workspaces matching the query (case-insensitive +// substring match on name or branch). +func filteredWorkspaces(workspaces []workspaceItem, query string) []workspaceItem { + if query == "" { + return workspaces + } + q := strings.ToLower(query) + var result []workspaceItem + for _, ws := range workspaces { + if strings.Contains(strings.ToLower(ws.Workspace.Name), q) || + strings.Contains(strings.ToLower(ws.Branch), q) { + result = append(result, ws) + } + } + return result +} + +// resolveOriginalRepoIndex maps a cursor index in the filtered list back to +// the original repos slice index. +func resolveOriginalRepoIndex(cursor int, filtered, original []repoItem) int { + if cursor >= len(filtered) { + return 0 + } + target := filtered[cursor] + for i, r := range original { + if r.Repo.Name == target.Repo.Name && r.Repo.Path == target.Repo.Path { + return i + } + } + return 0 +} + +// resolveOriginalWsIndex maps a cursor index in the filtered list back to +// the original workspaces slice index. +func resolveOriginalWsIndex(cursor int, filtered, original []workspaceItem) int { + if cursor >= len(filtered) { + return 0 + } + target := filtered[cursor] + for i, ws := range original { + if ws.Workspace.Name == target.Workspace.Name && ws.Workspace.Path == target.Workspace.Path { + return i + } + } + return 0 +} + +// --- Toast / timer commands --- + +func toastTickCmd() tea.Cmd { + return tea.Tick(500*time.Millisecond, func(time.Time) tea.Msg { + return toastTickMsg{} + }) +} + +func autoRefreshTickCmd() tea.Cmd { + return tea.Tick(5*time.Second, func(time.Time) tea.Msg { + return autoRefreshTickMsg{} + }) +} + +func autoRefreshCmd() tea.Cmd { + return func() tea.Msg { + if tmux.Available() != nil { + return autoRefreshResultMsg{} + } + sessions, err := tmux.ListFr8Sessions() + return autoRefreshResultMsg{sessions: sessions, err: err} + } +} + +// --- Multi-select batch commands --- + +func startSelectedCmd(workspaces []workspaceItem, selected map[int]bool, rootPath string) tea.Cmd { + return func() tea.Msg { + if err := tmux.Available(); err != nil { + return batchStartResultMsg{err: err} + } + + cfg, err := config.Load(rootPath) + if err != nil { + return batchStartResultMsg{err: fmt.Errorf("loading config: %w", err)} + } + if cfg.Scripts.Run == "" { + return batchStartResultMsg{err: fmt.Errorf("no run script configured")} + } + + defaultBranch, _ := git.DefaultBranch(rootPath) + repoName := tmux.RepoName(rootPath) + + var started int + for idx := range selected { + if idx >= len(workspaces) { + continue + } + ws := workspaces[idx] + if ws.Running { + continue + } + sessionName := tmux.SessionName(repoName, ws.Workspace.Name) + envVars := env.BuildFr8Only(&ws.Workspace, rootPath, defaultBranch) + if err := tmux.Start(sessionName, ws.Workspace.Path, cfg.Scripts.Run, envVars); err != nil { + return batchStartResultMsg{started: started, err: err} + } + started++ + } + + return batchStartResultMsg{started: started} + } +} + +func stopSelectedCmd(workspaces []workspaceItem, selected map[int]bool, rootPath string) tea.Cmd { + return func() tea.Msg { + if err := tmux.Available(); err != nil { + return batchStopResultMsg{err: err} + } + + repoName := tmux.RepoName(rootPath) + + var stopped int + for idx := range selected { + if idx >= len(workspaces) { + continue + } + ws := workspaces[idx] + if !ws.Running { + continue + } + sessionName := tmux.SessionName(repoName, ws.Workspace.Name) + if err := tmux.Stop(sessionName); err != nil { + return batchStopResultMsg{stopped: stopped, err: err} + } + stopped++ + } + + return batchStopResultMsg{stopped: stopped} + } +} + // refreshRunningCounts re-derives RunningCount on all repos from tmux sessions. func refreshRunningCounts(repos []repoItem) { if tmux.Available() != nil { diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index b795a7d..c4e6d73 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -2,11 +2,13 @@ package tui import ( "testing" + "time" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/protocollar/fr8/internal/git" "github.com/protocollar/fr8/internal/registry" + "github.com/protocollar/fr8/internal/tmux" "github.com/protocollar/fr8/internal/userconfig" ) @@ -1163,3 +1165,536 @@ func TestHelpEscReturnsToPreview(t *testing.T) { } // errStub is defined in view_test.go (same package) + +// --- 1.1 Remember Cursor on Back-Navigation --- + +func TestCursorRememberedOnBackNavigation(t *testing.T) { + m := seedRepoModel() + m.cursor = 2 // move to third repo + + // Drill into workspace list (which saves repoCursor) + result, _ := m.Update(keyEnter()) + m = result.(model) + + // Simulate workspaces loaded + m = updateModel(m, workspacesLoadedMsg{ + workspaces: []workspaceItem{{Workspace: registry.Workspace{Name: "ws1"}}}, + repoName: "charlie", + rootPath: "/c", + }) + if m.view != viewWorkspaceList { + t.Fatalf("view = %d, want viewWorkspaceList", m.view) + } + + // Go back + m = updateModel(m, keyEsc()) + if m.view != viewRepoList { + t.Fatalf("view = %d, want viewRepoList", m.view) + } + if m.cursor != 2 { + t.Errorf("cursor = %d, want 2 (remembered position)", m.cursor) + } +} + +// --- 1.4 Toast Notifications --- + +func TestToastSetOnStartResult(t *testing.T) { + m := seedWorkspaceModel() + m.loading = true + + result, cmd := m.Update(startResultMsg{name: "ws-one"}) + m = result.(model) + + if m.toast == "" { + t.Error("expected toast to be set after start") + } + if m.toastIsError { + t.Error("expected non-error toast after successful start") + } + if cmd == nil { + t.Error("expected toast tick cmd") + } +} + +func TestToastSetOnError(t *testing.T) { + m := seedWorkspaceModel() + m.loading = true + + m = updateModel(m, startResultMsg{name: "ws-one", err: errStub{}}) + + if m.toast == "" { + t.Error("expected toast to be set on error") + } + if !m.toastIsError { + t.Error("expected error toast") + } +} + +func TestToastExpiry(t *testing.T) { + m := seedWorkspaceModel() + m.toast = "test toast" + m.toastExpiry = time.Now().Add(-1 * time.Second) // already expired + + m = updateModel(m, toastTickMsg{}) + + if m.toast != "" { + t.Errorf("toast should be cleared after expiry, got %q", m.toast) + } +} + +func TestToastNotExpiredKeepsTicking(t *testing.T) { + m := seedWorkspaceModel() + m.toast = "test toast" + m.toastExpiry = time.Now().Add(5 * time.Second) // not expired + + result, cmd := m.Update(toastTickMsg{}) + m = result.(model) + + if m.toast == "" { + t.Error("toast should not be cleared before expiry") + } + if cmd == nil { + t.Error("expected tick cmd to continue") + } +} + +// --- 2.1 Search/Filter --- + +func TestFilterActivation(t *testing.T) { + m := seedRepoModel() + + m = updateModel(m, keyRune('/')) + + if !m.filtering { + t.Error("expected filtering=true after /") + } +} + +func TestFilterEscClears(t *testing.T) { + m := seedRepoModel() + m.filtering = true + m.filterInput = textinput.New() + m.filterInput.SetValue("test") + + m = updateModel(m, keyEsc()) + + if m.filtering { + t.Error("expected filtering=false after Esc") + } + if m.filterInput.Value() != "" { + t.Errorf("filter value = %q, want empty after Esc", m.filterInput.Value()) + } +} + +func TestFilterEnterKeepsValue(t *testing.T) { + m := seedRepoModel() + m.filtering = true + m.filterInput = textinput.New() + m.filterInput.SetValue("alp") + + m = updateModel(m, keyEnter()) + + if m.filtering { + t.Error("expected filtering=false after Enter") + } + if m.filterInput.Value() != "alp" { + t.Errorf("filter value = %q, want 'alp' after Enter", m.filterInput.Value()) + } +} + +func TestFilteredRepos(t *testing.T) { + repos := []repoItem{ + {Repo: registry.Repo{Name: "alpha"}}, + {Repo: registry.Repo{Name: "bravo"}}, + {Repo: registry.Repo{Name: "charlie"}}, + } + + // No filter + if got := filteredRepos(repos, ""); len(got) != 3 { + t.Errorf("no filter: got %d, want 3", len(got)) + } + + // Filter matches one + if got := filteredRepos(repos, "bra"); len(got) != 1 || got[0].Repo.Name != "bravo" { + t.Errorf("filter 'bra': got %v", got) + } + + // Case insensitive + if got := filteredRepos(repos, "ALPHA"); len(got) != 1 || got[0].Repo.Name != "alpha" { + t.Errorf("filter 'ALPHA': got %v", got) + } + + // No matches + if got := filteredRepos(repos, "xyz"); len(got) != 0 { + t.Errorf("filter 'xyz': got %d, want 0", len(got)) + } +} + +func TestFilteredWorkspaces(t *testing.T) { + workspaces := []workspaceItem{ + {Workspace: registry.Workspace{Name: "ws-one"}, Branch: "feat-1"}, + {Workspace: registry.Workspace{Name: "ws-two"}, Branch: "feat-2"}, + {Workspace: registry.Workspace{Name: "ws-three"}, Branch: "main"}, + } + + // Filter by name + if got := filteredWorkspaces(workspaces, "two"); len(got) != 1 || got[0].Workspace.Name != "ws-two" { + t.Errorf("filter 'two': got %v", got) + } + + // Filter by branch + if got := filteredWorkspaces(workspaces, "main"); len(got) != 1 || got[0].Workspace.Name != "ws-three" { + t.Errorf("filter 'main': got %v", got) + } +} + +func TestFilteredRepoResolvesCorrectIndex(t *testing.T) { + m := seedRepoModel() + m.filterInput = textinput.New() + m.filterInput.SetValue("charlie") + m.cursor = 0 // first in filtered list + + filtered := filteredRepos(m.repos, m.filterInput.Value()) + if len(filtered) != 1 { + t.Fatalf("expected 1 filtered result, got %d", len(filtered)) + } + + origIdx := resolveOriginalRepoIndex(0, filtered, m.repos) + if origIdx != 2 { + t.Errorf("original index = %d, want 2", origIdx) + } +} + +// --- 2.2 Multi-Select --- + +func TestMultiSelectToggle(t *testing.T) { + m := seedWorkspaceModel() + + // Space toggles selection and advances cursor + m = updateModel(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) + + if m.selected == nil || !m.selected[0] { + t.Error("expected workspace 0 to be selected") + } + if m.cursor != 1 { + t.Errorf("cursor = %d, want 1 (advanced after space)", m.cursor) + } + + // Select second + m = updateModel(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) + if !m.selected[1] { + t.Error("expected workspace 1 to be selected") + } + + // Deselect first (move back, toggle) + m.cursor = 0 + m = updateModel(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) + if m.selected[0] { + t.Error("expected workspace 0 to be deselected") + } +} + +func TestMultiSelectEscClearsBeforeBack(t *testing.T) { + m := seedWorkspaceModel() + m.selected = map[int]bool{0: true, 1: true} + + // First Esc clears selection + m = updateModel(m, keyEsc()) + if m.selected != nil { + t.Error("expected selection to be cleared on first Esc") + } + if m.view != viewWorkspaceList { + t.Errorf("view = %d, want viewWorkspaceList (not back yet)", m.view) + } + + // Second Esc navigates back + m = updateModel(m, keyEsc()) + if m.view != viewRepoList { + t.Errorf("view = %d, want viewRepoList after second Esc", m.view) + } +} + +// --- 4.1 Refresh --- + +func TestRefreshRepoList(t *testing.T) { + m := seedRepoModel() + + result, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlR}) + m = result.(model) + + if !m.loading { + t.Error("expected loading=true after ctrl+r") + } + if cmd == nil { + t.Error("expected non-nil cmd after ctrl+r") + } +} + +func TestRefreshWorkspaceListPreservesCursor(t *testing.T) { + m := seedWorkspaceModel() + m.repos = []repoItem{ + {Repo: registry.Repo{Name: "alpha", Path: "/a"}}, + } + m.cursor = 2 + + // Simulate workspace loaded while already on workspace list (refresh case) + m = updateModel(m, workspacesLoadedMsg{ + workspaces: []workspaceItem{ + {Workspace: registry.Workspace{Name: "ws-one"}}, + {Workspace: registry.Workspace{Name: "ws-two"}}, + {Workspace: registry.Workspace{Name: "ws-three"}}, + }, + repoName: "alpha", + rootPath: "/a", + }) + + if m.cursor != 2 { + t.Errorf("cursor = %d, want 2 (preserved on refresh)", m.cursor) + } +} + +func TestRefreshWorkspaceListClampsCursor(t *testing.T) { + m := seedWorkspaceModel() + m.cursor = 2 + + // Fewer workspaces returned — cursor should clamp + m = updateModel(m, workspacesLoadedMsg{ + workspaces: []workspaceItem{ + {Workspace: registry.Workspace{Name: "ws-one"}}, + }, + repoName: "alpha", + rootPath: "/a", + }) + + if m.cursor != 0 { + t.Errorf("cursor = %d, want 0 (clamped)", m.cursor) + } +} + +// --- 4.2 Auto-Refresh --- + +func TestAutoRefreshSkipsWhenLoading(t *testing.T) { + m := seedRepoModel() + m.loading = true + + result, cmd := m.Update(autoRefreshTickMsg{}) + m = result.(model) + + // Should re-schedule tick but not dispatch refresh + if cmd == nil { + t.Error("expected re-schedule tick cmd even when loading") + } +} + +func TestAutoRefreshResultUpdatesRunning(t *testing.T) { + m := seedWorkspaceModel() + m.rootPath = "/a" + m.repos = []repoItem{ + {Repo: registry.Repo{Name: "alpha", Path: "/a"}}, + } + + // ws-two should become running + result, _ := m.Update(autoRefreshResultMsg{ + sessions: []tmux.Session{ + {Name: "fr8/a/ws-two", Repo: "a", Workspace: "ws-two"}, + }, + }) + m = result.(model) + + if m.workspaces[0].Running { + t.Error("ws-one should not be running") + } + // Note: the auto-refresh matches by tmux.SessionName which uses tmux.RepoName + // In test, tmux.RepoName("/a") depends on actual implementation +} + +// --- Toast on Stop/Archive Result --- + +func TestToastSetOnStopResult(t *testing.T) { + m := seedWorkspaceModel() + m.workspaces[0].Running = true + m.loading = true + + result, cmd := m.Update(stopResultMsg{name: "ws-one"}) + m = result.(model) + + if m.toast == "" { + t.Error("expected toast to be set after stop") + } + if m.toastIsError { + t.Error("expected non-error toast after successful stop") + } + if cmd == nil { + t.Error("expected toast tick cmd") + } +} + +func TestToastSetOnArchiveResult(t *testing.T) { + m := seedWorkspaceModel() + m.loading = true + + result, cmd := m.Update(archiveResultMsg{name: "ws-one"}) + m = result.(model) + + if m.toast == "" { + t.Error("expected toast to be set after archive") + } + if m.toastIsError { + t.Error("expected non-error toast after successful archive") + } + if cmd == nil { + t.Error("expected toast tick cmd") + } +} + +func TestToastSetOnBatchArchiveResult(t *testing.T) { + m := seedWorkspaceModel() + m.loading = true + m.repos = []repoItem{{Repo: registry.Repo{Name: "alpha", Path: "/a"}, WorkspaceCount: 3}} + m.repoName = "alpha" + + result, cmd := m.Update(batchArchiveResultMsg{archived: []string{"ws-one"}}) + m = result.(model) + + if m.toast == "" { + t.Error("expected toast to be set after batch archive") + } + if m.toastIsError { + t.Error("expected non-error toast after successful batch archive") + } + if cmd == nil { + t.Error("expected toast tick cmd") + } +} + +// --- Batch Start/Stop Result clears selection --- + +func TestBatchStartResultClearsSelection(t *testing.T) { + m := seedWorkspaceModel() + m.loading = true + m.selected = map[int]bool{0: true, 1: true} + + m = updateModel(m, batchStartResultMsg{started: 2}) + + if m.selected != nil { + t.Error("expected selection to be cleared after batch start") + } + if m.toast == "" { + t.Error("expected toast to be set") + } +} + +func TestBatchStopResultClearsSelection(t *testing.T) { + m := seedWorkspaceModel() + m.loading = true + m.selected = map[int]bool{0: true, 1: true} + + m = updateModel(m, batchStopResultMsg{stopped: 2}) + + if m.selected != nil { + t.Error("expected selection to be cleared after batch stop") + } + if m.toast == "" { + t.Error("expected toast to be set") + } +} + +// --- Filter on workspace list --- + +func TestFilterActivationOnWorkspaceList(t *testing.T) { + m := seedWorkspaceModel() + + m = updateModel(m, keyRune('/')) + + if !m.filtering { + t.Error("expected filtering=true after / on workspace list") + } + if m.cursor != 0 { + t.Error("expected cursor reset to 0 on filter activation") + } +} + +func TestFilterWorkspaceListEscClears(t *testing.T) { + m := seedWorkspaceModel() + m.filtering = true + m.filterInput = textinput.New() + m.filterInput.SetValue("test") + + m = updateModel(m, keyEsc()) + + if m.filtering { + t.Error("expected filtering=false after Esc on workspace filter") + } + if m.filterInput.Value() != "" { + t.Errorf("filter value = %q, want empty after Esc", m.filterInput.Value()) + } +} + +func TestFilterWorkspaceListResolvesThroughFilter(t *testing.T) { + m := seedWorkspaceModel() + m.filterInput = textinput.New() + m.filterInput.SetValue("three") + m.cursor = 0 // first in filtered list + + filtered := filteredWorkspaces(m.workspaces, m.filterInput.Value()) + if len(filtered) != 1 { + t.Fatalf("expected 1 filtered result, got %d", len(filtered)) + } + + origIdx := resolveOriginalWsIndex(0, filtered, m.workspaces) + if origIdx != 2 { + t.Errorf("original index = %d, want 2", origIdx) + } +} + +// --- Ctrl+L redraw key --- + +func TestRedrawKeyDispatchesClearScreen(t *testing.T) { + m := seedRepoModel() + + result, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlL}) + _ = result.(model) + + if cmd == nil { + t.Error("expected non-nil cmd after ctrl+l") + } +} + +// --- Multi-select through filter --- + +func TestMultiSelectThroughFilter(t *testing.T) { + m := seedWorkspaceModel() + m.filterInput = textinput.New() + m.filterInput.SetValue("three") + m.cursor = 0 // first in filtered list (ws-three at original index 2) + + // Space selects the filtered item (original index 2) + m = updateModel(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) + + if m.selected == nil { + t.Fatal("expected selection to be non-nil") + } + if !m.selected[2] { + t.Error("expected original index 2 to be selected (ws-three)") + } + if m.selected[0] { + t.Error("original index 0 should not be selected") + } +} + +// --- Filter cursor resets on text change --- + +func TestFilterCursorResetsOnChange(t *testing.T) { + m := seedRepoModel() + m.filtering = true + m.filterInput = textinput.New() + m.filterInput.Focus() + m.cursor = 2 + + // Type a character — cursor should reset to 0 + m = updateModel(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) + + if m.cursor != 0 { + t.Errorf("cursor = %d, want 0 after filter text change", m.cursor) + } +} diff --git a/internal/tui/opener_picker.go b/internal/tui/opener_picker.go index 92452e1..3d4d606 100644 --- a/internal/tui/opener_picker.go +++ b/internal/tui/opener_picker.go @@ -22,8 +22,15 @@ func renderOpenerPicker(m model) string { b.WriteString("\n\n") } + listHeight := m.height - 8 // breadcrumb(2) + help(2) + margins + if listHeight < 3 { + listHeight = 3 + } + var rows []string - for i, o := range m.openers { + start, end := scrollWindow(m.openerCursor, len(m.openers), listHeight) + for i := start; i < end; i++ { + o := m.openers[i] name := o.Name suffix := "" if o.Command != o.Name { @@ -43,7 +50,7 @@ func renderOpenerPicker(m model) string { } } - b.WriteString(renderTitledPanel("Open With", strings.Join(rows, "\n"), w)) + b.WriteString(renderTitledPanelWithPos("Open With", strings.Join(rows, "\n"), w, m.openerCursor+1, len(m.openers), listHeight)) b.WriteString("\n\n") b.WriteString(renderHelpBar([]helpItem{ diff --git a/internal/tui/panel.go b/internal/tui/panel.go index 8624a04..8c43ae4 100644 --- a/internal/tui/panel.go +++ b/internal/tui/panel.go @@ -6,8 +6,37 @@ import ( "strings" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" ) +const wideThreshold = 120 + +func isWide(width int) bool { + if os.Getenv("TERMINAL_EMULATOR") == "JetBrains-JediTerm" { + return false // JediTerm has Unicode width issues with side-by-side layout + } + return width >= wideThreshold +} + +// chromeHeight returns the number of vertical lines consumed by non-list UI +// elements (breadcrumb, status bar, detail pane, help bar, toast, filter). +func chromeHeight(m model) int { + h := 5 // breadcrumb(2) + help(2) + bottom margin(1) + if len(m.repos) > 0 { + h++ // status bar + } + if m.toast != "" { + h++ // toast line + } + if m.filtering { + h++ // filter input + } + if !isWide(m.width) { + h += 6 // detail pane (stacked mode only) + } + return h +} + // renderTitledPanel renders content inside a rounded-border box with an // optional inline title embedded in the top border. // @@ -44,8 +73,10 @@ func renderTitledPanel(title, content string, width int) string { for _, line := range lines { lineWidth := lipgloss.Width(line) if lineWidth > innerWidth { - line = lipgloss.NewStyle().MaxWidth(innerWidth).Render(line) - lineWidth = lipgloss.Width(line) + // Hard-truncate (not wrap) to prevent multi-line blowout + // inside bordered panels. + line = ansi.Truncate(line, innerWidth, "") + lineWidth = ansi.StringWidth(line) } pad := innerWidth - lineWidth if pad < 0 { @@ -63,7 +94,13 @@ func renderTitledPanel(title, content string, width int) string { // Bottom border. bottom := borderFg.Render("╰" + strings.Repeat("─", width-2) + "╯") - return top.String() + "\n" + body.String() + bottom + result := top.String() + "\n" + body.String() + bottom + topWidth := ansi.StringWidth(top.String()) + bottomWidth := ansi.StringWidth(bottom) + if topWidth != width || bottomWidth != width { + debugLog("renderTitledPanel(%q, width=%d): topWidth=%d bottomWidth=%d", title, width, topWidth, bottomWidth) + } + return result } type helpItem struct { @@ -115,6 +152,26 @@ func renderHelpBar(items []helpItem, width int) string { return strings.Join(lines, "\n") } +// constrainWidth ensures every line in s is exactly maxWidth visual characters. +// Lines wider than maxWidth are truncated; lines narrower are right-padded with spaces. +// This prevents rendering artefacts when lipgloss.JoinHorizontal produces lines +// wider than the terminal or when the terminal is resized narrower. +func constrainWidth(s string, maxWidth int) string { + if maxWidth <= 0 { + return s + } + lines := strings.Split(s, "\n") + for i, line := range lines { + w := ansi.StringWidth(line) + if w > maxWidth { + lines[i] = ansi.Truncate(line, maxWidth, "") + } else if w < maxWidth { + lines[i] = line + strings.Repeat(" ", maxWidth-w) + } + } + return strings.Join(lines, "\n") +} + // padToHeight appends empty lines so the output fills exactly targetHeight lines. func padToHeight(s string, targetHeight int) string { lines := strings.Count(s, "\n") @@ -161,3 +218,38 @@ func shortenPath(p string) string { func renderDetailRow(label, value string) string { return fmt.Sprintf("%s%s", detailLabelStyle.Render(label), detailValueStyle.Render(value)) } + +// renderTitledPanelWithPos is like renderTitledPanel but appends a position +// indicator " (3/12)" to the title when the list overflows the viewport. +func renderTitledPanelWithPos(title, content string, width, cursor1Based, total, visible int) string { + if total > visible { + title = fmt.Sprintf("%s (%d/%d)", title, cursor1Based, total) + } + return renderTitledPanel(title, content, width) +} + +// renderStatusBar renders a summary line with repo/workspace/running counts. +func renderStatusBar(repos []repoItem, width int) string { + if len(repos) == 0 { + return "" + } + var totalWs, totalRunning int + for _, r := range repos { + totalWs += r.WorkspaceCount + totalRunning += r.RunningCount + } + line := fmt.Sprintf("%d repos · %d workspaces · %d running", len(repos), totalWs, totalRunning) + return " " + statusBarStyle.Render(line) +} + +// renderToast renders a toast notification message. +func renderToast(toast string, isError bool, width int) string { + if toast == "" { + return "" + } + style := toastStyle + if isError { + style = toastErrorStyle + } + return " " + style.Render(toast) +} diff --git a/internal/tui/repo_list.go b/internal/tui/repo_list.go index 7566dfa..159b73c 100644 --- a/internal/tui/repo_list.go +++ b/internal/tui/repo_list.go @@ -3,6 +3,8 @@ package tui import ( "fmt" "strings" + + "github.com/charmbracelet/lipgloss" ) func renderRepoList(m model) string { @@ -13,6 +15,12 @@ func renderRepoList(m model) string { b.WriteString(renderBreadcrumb([]string{"fr8", "repos"})) b.WriteString("\n\n") + // Status bar + if sb := renderStatusBar(m.repos, w); sb != "" { + b.WriteString(sb) + b.WriteString("\n") + } + if m.loading { content := fmt.Sprintf("%s %s", m.spinner.View(), dimStyle.Render("Loading repos...")) b.WriteString(renderTitledPanel("Repos", content, w)) @@ -35,18 +43,25 @@ func renderRepoList(m model) string { return b.String() } + // Apply filter + filtered := filteredRepos(m.repos, m.filterInput.Value()) + // List panel — compute available lines for the list. - // Chrome: breadcrumb(2) + detail(6) + help(2) = 10 lines of fixed chrome. - listHeight := m.height - 10 + listHeight := m.height - chromeHeight(m) if listHeight < 3 { listHeight = 3 } // Build list rows + listW := w + if isWide(w) { + listW = w * 3 / 5 // 60% for list in wide mode + } + var rows []string - start, end := scrollWindow(m.cursor, len(m.repos), listHeight) + start, end := scrollWindow(m.cursor, len(filtered), listHeight) for i := start; i < end; i++ { - item := m.repos[i] + item := filtered[i] wsCount := fmt.Sprintf("%d", item.WorkspaceCount) if item.Err != nil { wsCount = "?" @@ -56,7 +71,7 @@ func renderRepoList(m model) string { path := shortenPath(item.Repo.Path) // Compute inner width minus cursor/padding: " ▸ " = 4 - innerAvail := w - 4 - 4 // 4 for panel border/padding, 4 for cursor prefix + innerAvail := listW - 4 - 4 // 4 for panel border/padding, 4 for cursor prefix if innerAvail < 20 { innerAvail = 20 } @@ -95,12 +110,21 @@ func renderRepoList(m model) string { rows = append(rows, line) } - b.WriteString(renderTitledPanel("Repos", strings.Join(rows, "\n"), w)) - b.WriteString("\n") + // Filter indicator + filterQuery := m.filterInput.Value() + if m.filtering { + rows = append([]string{m.filterInput.View()}, rows...) + } else if filterQuery != "" { + rows = append([]string{filterActiveStyle.Render("filter: " + filterQuery)}, rows...) + } + + listPanel := renderTitledPanelWithPos("Repos", strings.Join(rows, "\n"), listW, m.cursor+1, len(filtered), listHeight) // Detail pane for selected repo - if m.cursor < len(m.repos) { - item := m.repos[m.cursor] + var detailPanel string + if m.cursor < len(filtered) { + origIdx := resolveOriginalRepoIndex(m.cursor, filtered, m.repos) + item := m.repos[origIdx] var detail strings.Builder detail.WriteString(renderDetailRow("Name", item.Repo.Name)) detail.WriteString("\n") @@ -118,17 +142,42 @@ func renderRepoList(m model) string { detail.WriteString(renderDetailRow("Running", dimStyle.Render("none"))) } - b.WriteString(renderTitledPanel("Details", detail.String(), w)) + detailW := w + if isWide(w) { + detailW = w - listW + } + detailPanel = renderTitledPanel("Details", detail.String(), detailW) + } + + if isWide(w) && detailPanel != "" { + lp := constrainWidth(listPanel, listW) + dp := constrainWidth(detailPanel, w-listW) + b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, lp, dp)) + b.WriteString("\n") + } else { + b.WriteString(listPanel) + b.WriteString("\n") + if detailPanel != "" { + b.WriteString(detailPanel) + b.WriteString("\n") + } + } + + // Toast + if t := renderToast(m.toast, m.toastIsError, w); t != "" { + b.WriteString(t) b.WriteString("\n") } // Help bar b.WriteString(renderHelpBar([]helpItem{ {"enter", "open"}, + {"/", "filter"}, {"r", "run all"}, {"x", "stop all"}, {"R", "global run"}, {"X", "global stop"}, + {"ctrl+r", "refresh"}, {"?", "help"}, {"q", "quit"}, }, w)) diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 740acec..dc67c32 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -86,6 +86,19 @@ var ( Padding(0, 1) ) +// Status bar +var statusBarStyle = lipgloss.NewStyle(). + Foreground(colorSubtle) + +// Toast notifications +var ( + toastStyle = lipgloss.NewStyle(). + Foreground(colorGreen) + + toastErrorStyle = lipgloss.NewStyle(). + Foreground(colorRed) +) + // Misc var ( errorStyle = lipgloss.NewStyle(). @@ -100,4 +113,8 @@ var ( spinnerStyle = lipgloss.NewStyle(). Foreground(colorAccent) + + filterActiveStyle = lipgloss.NewStyle(). + Foreground(colorSubtle). + Italic(true) ) diff --git a/internal/tui/view_test.go b/internal/tui/view_test.go index ea4b8dc..e989be6 100644 --- a/internal/tui/view_test.go +++ b/internal/tui/view_test.go @@ -8,6 +8,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/protocollar/fr8/internal/gh" "github.com/protocollar/fr8/internal/git" + "github.com/protocollar/fr8/internal/registry" ) func TestFormatStatus(t *testing.T) { @@ -282,3 +283,364 @@ func TestTruncate(t *testing.T) { }) } } + +// --- 1.2 Scroll Position Indicator --- + +func TestRenderTitledPanelWithPosShowsIndicator(t *testing.T) { + result := renderTitledPanelWithPos("Items", "line1\nline2", 40, 1, 20, 5) + if !strings.Contains(result, "(1/20)") { + t.Error("expected position indicator (1/20) in title when total > visible") + } +} + +func TestRenderTitledPanelWithPosHidesWhenFits(t *testing.T) { + result := renderTitledPanelWithPos("Items", "line1\nline2", 40, 1, 3, 5) + if strings.Contains(result, "(1/3)") { + t.Error("position indicator should not appear when total <= visible") + } +} + +// --- 1.3 Status Bar --- + +func TestRenderStatusBar(t *testing.T) { + repos := []repoItem{ + {Repo: registry.Repo{Name: "a"}, WorkspaceCount: 3, RunningCount: 1}, + {Repo: registry.Repo{Name: "b"}, WorkspaceCount: 5, RunningCount: 2}, + } + + got := renderStatusBar(repos, 80) + if !strings.Contains(got, "2 repos") { + t.Errorf("status bar should contain '2 repos', got %q", got) + } + if !strings.Contains(got, "8 workspaces") { + t.Errorf("status bar should contain '8 workspaces', got %q", got) + } + if !strings.Contains(got, "3 running") { + t.Errorf("status bar should contain '3 running', got %q", got) + } +} + +func TestRenderStatusBarEmpty(t *testing.T) { + got := renderStatusBar(nil, 80) + if got != "" { + t.Errorf("expected empty status bar for nil repos, got %q", got) + } +} + +// --- 1.4 Toast Rendering --- + +func TestRenderToast(t *testing.T) { + got := renderToast("started ws-one", false, 80) + if !strings.Contains(got, "started ws-one") { + t.Errorf("toast should contain message, got %q", got) + } + + got = renderToast("", false, 80) + if got != "" { + t.Errorf("empty toast should return empty string, got %q", got) + } +} + +// --- 3.1 Short Relative Time --- + +func TestShortRelativeTime(t *testing.T) { + tests := []struct { + name string + ago time.Duration + want string + }{ + {"now", 5 * time.Second, "now"}, + {"minutes", 5 * time.Minute, "5m"}, + {"hours", 3 * time.Hour, "3h"}, + {"days", 48 * time.Hour, "2d"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shortRelativeTime(time.Now().Add(-tt.ago)) + if got != tt.want { + t.Errorf("shortRelativeTime() = %q, want %q", got, tt.want) + } + }) + } +} + +// --- 3.2 Wide Layout --- + +func TestIsWide(t *testing.T) { + t.Setenv("TERMINAL_EMULATOR", "") // ensure JediTerm detection doesn't interfere + if isWide(80) { + t.Error("80 should not be wide") + } + if !isWide(120) { + t.Error("120 should be wide") + } + if !isWide(160) { + t.Error("160 should be wide") + } +} + +func TestIsWideJediTerm(t *testing.T) { + t.Setenv("TERMINAL_EMULATOR", "JetBrains-JediTerm") + if isWide(160) { + t.Error("JediTerm should never be wide") + } +} + +func TestChromeHeight(t *testing.T) { + t.Setenv("TERMINAL_EMULATOR", "") // ensure JediTerm detection doesn't interfere + m := model{ + width: 80, // not wide + repos: []repoItem{{Repo: registry.Repo{Name: "a"}}}, + } + + base := chromeHeight(m) + + // With toast + m.toast = "hello" + withToast := chromeHeight(m) + if withToast != base+1 { + t.Errorf("toast should add 1 to chrome height: got %d, want %d", withToast, base+1) + } + + // With filtering + m.toast = "" + m.filtering = true + withFilter := chromeHeight(m) + if withFilter != base+1 { + t.Errorf("filter should add 1 to chrome height: got %d, want %d", withFilter, base+1) + } + + // Wide mode removes detail pane chrome + m.filtering = false + m.width = 160 + wideH := chromeHeight(m) + if wideH >= base { + t.Errorf("wide mode should reduce chrome height: got %d, base %d", wideH, base) + } +} + +func TestWideRendersJoined(t *testing.T) { + t.Setenv("TERMINAL_EMULATOR", "") // ensure JediTerm detection doesn't interfere + m := seedRepoModel() + m.width = 160 + m.height = 40 + + output := renderRepoList(m) + // In wide mode, list and detail panels are joined — detail should still be present + if !strings.Contains(output, "Details") { + t.Error("wide mode should still render Details panel") + } +} + +func TestNarrowRendersStacked(t *testing.T) { + m := seedRepoModel() + m.width = 80 + m.height = 40 + + output := renderRepoList(m) + if !strings.Contains(output, "Details") { + t.Error("narrow mode should render Details panel") + } +} + +// --- Resize Safety --- + +func TestConstrainWidthTruncatesLongLines(t *testing.T) { + // A line wider than maxWidth should be truncated + wide := strings.Repeat("A", 50) + got := constrainWidth(wide, 30) + for i, line := range strings.Split(got, "\n") { + w := lipgloss.Width(line) + if w != 30 { + t.Errorf("line %d: width = %d, want 30", i, w) + } + } +} + +func TestConstrainWidthPadsShortLines(t *testing.T) { + short := "hi" + got := constrainWidth(short, 10) + if lipgloss.Width(got) != 10 { + t.Errorf("constrainWidth(%q, 10) width = %d, want 10", short, lipgloss.Width(got)) + } +} + +func TestConstrainWidthMultiline(t *testing.T) { + input := "short\n" + strings.Repeat("X", 50) + "\nexact" + got := constrainWidth(input, 20) + for i, line := range strings.Split(got, "\n") { + w := lipgloss.Width(line) + if w != 20 { + t.Errorf("line %d: width = %d, want 20: %q", i, w, line) + } + } +} + +func TestConstrainWidthPreservesExact(t *testing.T) { + exact := strings.Repeat("B", 15) + got := constrainWidth(exact, 15) + if got != exact { + t.Errorf("exact-width line should be unchanged") + } +} + +func TestWorkspaceRowContainsBranch(t *testing.T) { + m := seedWorkspaceModel() + m.width = 120 // wide enough for branch column + m.height = 40 + + output := renderWorkspaceList(m) + if !strings.Contains(output, "feat-1") { + t.Error("workspace row should contain branch name at sufficient width") + } +} + +// --- scrollWindow --- + +func TestScrollWindow(t *testing.T) { + tests := []struct { + name string + cursor int + total int + height int + wantStart int + wantEnd int + }{ + {"fits all", 0, 3, 5, 0, 3}, + {"cursor at top", 0, 20, 5, 0, 5}, + {"cursor middle", 10, 20, 5, 8, 13}, + {"cursor at end", 19, 20, 5, 15, 20}, + {"single item", 0, 1, 5, 0, 1}, + {"exact fit", 2, 5, 5, 0, 5}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + start, end := scrollWindow(tt.cursor, tt.total, tt.height) + if start != tt.wantStart || end != tt.wantEnd { + t.Errorf("scrollWindow(%d, %d, %d) = (%d, %d), want (%d, %d)", + tt.cursor, tt.total, tt.height, start, end, tt.wantStart, tt.wantEnd) + } + }) + } +} + +// --- Selection markers in workspace rows --- + +func TestWorkspaceRowSelectionMarker(t *testing.T) { + item := workspaceItem{ + Workspace: registry.Workspace{Name: "ws-sel", Port: 3000}, + Branch: "main", + } + selected := map[int]bool{0: true} + + // Selected row should show [*] + row := renderWorkspaceRow(item, 0, 0, 0, selected, 120) + if !strings.Contains(row, "[*]") { + t.Errorf("selected row should contain [*], got: %q", row) + } + + // Unselected row should show [ ] + row = renderWorkspaceRow(item, 0, 0, 0, map[int]bool{1: true}, 120) + if !strings.Contains(row, "[ ]") { + t.Errorf("unselected row with active selection should contain [ ], got: %q", row) + } + + // No selection at all — no markers + row = renderWorkspaceRow(item, 0, 0, 0, nil, 120) + if strings.Contains(row, "[*]") || strings.Contains(row, "[ ]") { + t.Errorf("row with no selection should have no markers, got: %q", row) + } +} + +// --- Wide mode help bar visible --- + +func TestWideRendersHelpBar(t *testing.T) { + t.Setenv("TERMINAL_EMULATOR", "") + m := seedRepoModel() + m.width = 160 + m.height = 40 + + output := renderRepoList(m) + // Help bar should contain key hints like "enter", "filter", "quit" + if !strings.Contains(output, "enter") { + t.Error("wide mode should render help bar with 'enter' hint") + } + if !strings.Contains(output, "quit") { + t.Error("wide mode should render help bar with 'quit' hint") + } +} + +func TestWideWorkspaceRendersHelpBar(t *testing.T) { + t.Setenv("TERMINAL_EMULATOR", "") + m := seedWorkspaceModel() + m.width = 160 + m.height = 40 + + output := renderWorkspaceList(m) + if !strings.Contains(output, "filter") { + t.Error("wide workspace view should render help bar with 'filter' hint") + } + if !strings.Contains(output, "quit") { + t.Error("wide workspace view should render help bar with 'quit' hint") + } +} + +// --- View output consistency (padToHeight + constrainWidth order) --- + +func TestViewOutputConsistentLineWidths(t *testing.T) { + t.Setenv("TERMINAL_EMULATOR", "") + m := seedRepoModel() + m.width = 80 + m.height = 24 + + output := m.View() + lines := strings.Split(output, "\n") + for i, line := range lines { + w := lipgloss.Width(line) + if w != 80 { + t.Errorf("line %d: width = %d, want 80", i, w) + } + } +} + +func TestViewOutputConsistentLineWidthsWide(t *testing.T) { + t.Setenv("TERMINAL_EMULATOR", "") + m := seedRepoModel() + m.width = 160 + m.height = 40 + + output := m.View() + lines := strings.Split(output, "\n") + for i, line := range lines { + w := lipgloss.Width(line) + if w != 160 { + t.Errorf("line %d: width = %d, want 160", i, w) + } + } +} + +// --- Toast rendering in error mode --- + +func TestRenderToastError(t *testing.T) { + got := renderToast("something failed", true, 80) + if !strings.Contains(got, "something failed") { + t.Errorf("error toast should contain message, got %q", got) + } +} + +// --- Empty selection map treated as no selection --- + +func TestWorkspaceRowEmptySelectionMap(t *testing.T) { + item := workspaceItem{ + Workspace: registry.Workspace{Name: "ws-test", Port: 3000}, + } + // Empty map (not nil) — should still show markers since len > 0 + selected := map[int]bool{} + row := renderWorkspaceRow(item, 0, 0, 0, selected, 120) + // Empty map has len 0, so no markers should appear + if strings.Contains(row, "[*]") || strings.Contains(row, "[ ]") { + t.Errorf("empty selection map should have no markers, got: %q", row) + } +} diff --git a/internal/tui/workspace_list.go b/internal/tui/workspace_list.go index d1420e3..bb321b3 100644 --- a/internal/tui/workspace_list.go +++ b/internal/tui/workspace_list.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "github.com/charmbracelet/lipgloss" "github.com/protocollar/fr8/internal/gh" ) @@ -16,6 +17,12 @@ func renderWorkspaceList(m model) string { b.WriteString(renderBreadcrumb([]string{"fr8", m.repoName, "workspaces"})) b.WriteString("\n\n") + // Status bar + if sb := renderStatusBar(m.repos, w); sb != "" { + b.WriteString(sb) + b.WriteString("\n") + } + if m.loading { content := fmt.Sprintf("%s %s", m.spinner.View(), dimStyle.Render("Loading workspaces...")) b.WriteString(renderTitledPanel("Workspaces", content, w)) @@ -37,50 +44,45 @@ func renderWorkspaceList(m model) string { return b.String() } + // Apply filter + filtered := filteredWorkspaces(m.workspaces, m.filterInput.Value()) + // List panel - listHeight := m.height - 10 + listHeight := m.height - chromeHeight(m) if listHeight < 3 { listHeight = 3 } + listW := w + if isWide(w) { + listW = w * 3 / 5 + } + var rows []string - start, end := scrollWindow(m.cursor, len(m.workspaces), listHeight) + start, end := scrollWindow(m.cursor, len(filtered), listHeight) for i := start; i < end; i++ { - item := m.workspaces[i] - status := formatStatus(item) - port := portStyle.Render(fmt.Sprintf(":%d", item.Workspace.Port)) - name := item.Workspace.Name - - runBadge := " " - if item.Running { - runBadge = statusCleanStyle.Render("▶ ") - } + item := filtered[i] + origIdx := resolveOriginalWsIndex(i, filtered, m.workspaces) + rows = append(rows, renderWorkspaceRow(item, i, m.cursor, origIdx, m.selected, listW)) + } - nameWidth := 24 - var line string - if i == m.cursor { - line = fmt.Sprintf("%s %s%s %s %s", - cursorStyle.Render("▸"), - runBadge, - selectedRowStyle.Render(fmt.Sprintf("%-*s", nameWidth, name)), - port, - status, - ) - } else { - line = fmt.Sprintf(" %s%s %s %s", - runBadge, - normalRowStyle.Render(fmt.Sprintf("%-*s", nameWidth, name)), - port, - status, - ) - } - rows = append(rows, line) + // Filter indicator + filterQuery := m.filterInput.Value() + if m.filtering { + rows = append([]string{m.filterInput.View()}, rows...) + } else if filterQuery != "" { + rows = append([]string{filterActiveStyle.Render("filter: " + filterQuery)}, rows...) } - b.WriteString(renderTitledPanel("Workspaces", strings.Join(rows, "\n"), w)) - b.WriteString("\n") + listPanel := renderTitledPanelWithPos("Workspaces", strings.Join(rows, "\n"), listW, m.cursor+1, len(filtered), listHeight) + + // Detail pane or confirmation + var detailPanel string + detailW := w + if isWide(w) { + detailW = w - listW + } - // Detail pane or archive confirmation switch { case m.view == viewConfirmArchive && m.archiveIdx < len(m.workspaces): ws := m.workspaces[m.archiveIdx] @@ -96,7 +98,7 @@ func renderWorkspaceList(m model) string { " " + helpKeyStyle.Render("n") + " " + helpDescStyle.Render("no"), ) - b.WriteString(renderTitledPanel("Confirm", detail.String(), w)) + detailPanel = renderTitledPanel("Confirm", detail.String(), detailW) case m.view == viewConfirmBatchArchive && len(m.batchArchiveNames) > 0: var detail strings.Builder detail.WriteString(confirmStyle.Render(fmt.Sprintf("Archive %d merged+clean workspaces?", len(m.batchArchiveNames)))) @@ -110,9 +112,10 @@ func renderWorkspaceList(m model) string { " " + helpKeyStyle.Render("n") + " " + helpDescStyle.Render("no"), ) - b.WriteString(renderTitledPanel("Confirm Batch Archive", detail.String(), w)) - case m.cursor < len(m.workspaces): - item := m.workspaces[m.cursor] + detailPanel = renderTitledPanel("Confirm Batch Archive", detail.String(), detailW) + case m.cursor < len(filtered): + origIdx := resolveOriginalWsIndex(m.cursor, filtered, m.workspaces) + item := m.workspaces[origIdx] var detail strings.Builder detail.WriteString(renderDetailRow("Branch", item.Branch)) detail.WriteString("\n") @@ -141,13 +144,34 @@ func renderWorkspaceList(m model) string { detail.WriteString("\n") detail.WriteString(renderDetailRow("PR", formatPR(item.PR))) } - b.WriteString(renderTitledPanel("Details", detail.String(), w)) + detailPanel = renderTitledPanel("Details", detail.String(), detailW) + } + + if isWide(w) && detailPanel != "" { + lp := constrainWidth(listPanel, listW) + dp := constrainWidth(detailPanel, w-listW) + b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, lp, dp)) + b.WriteString("\n") + } else { + b.WriteString(listPanel) + b.WriteString("\n") + if detailPanel != "" { + b.WriteString(detailPanel) + } + b.WriteString("\n") + } + + // Toast + if t := renderToast(m.toast, m.toastIsError, w); t != "" { + b.WriteString(t) + b.WriteString("\n") } - b.WriteString("\n") // Help bar - b.WriteString(renderHelpBar([]helpItem{ + helpItems := []helpItem{ {"n", "new"}, + {"/", "filter"}, + {"space", "select"}, {"r", "run"}, {"x", "stop"}, {"t", "attach"}, @@ -156,15 +180,92 @@ func renderWorkspaceList(m model) string { {"b", "browser"}, {"a", "archive"}, {"A", "archive merged"}, + {"ctrl+r", "refresh"}, {"?", "help"}, {"esc", "back"}, {"q", "quit"}, - }, w)) + } + b.WriteString(renderHelpBar(helpItems, w)) b.WriteString("\n") return b.String() } +// renderWorkspaceRow renders a single workspace row with optional selection marker, +// branch name, and compact time. +func renderWorkspaceRow(item workspaceItem, displayIdx, cursor, origIdx int, selected map[int]bool, width int) string { + status := formatStatus(item) + port := portStyle.Render(fmt.Sprintf(":%d", item.Workspace.Port)) + name := item.Workspace.Name + + runBadge := " " + if item.Running { + runBadge = statusCleanStyle.Render("▶ ") + } + + // Selection marker + selPrefix := "" + if len(selected) > 0 { + if selected[origIdx] { + selPrefix = cursorStyle.Render("[*]") + " " + } else { + selPrefix = dimStyle.Render("[ ]") + " " + } + } + + // Dynamic column widths based on available width + nameWidth := 16 + branchWidth := 0 + timeWidth := 0 + innerAvail := width - 4 - 4 // panel borders + cursor prefix + if innerAvail > 60 { + branchWidth = 16 + if innerAvail > 80 { + branchWidth = 20 + nameWidth = 20 + } + timeWidth = 4 + } + + // Branch (truncated, dim) + branchStr := "" + if branchWidth > 0 && item.Branch != "" { + br := truncate(item.Branch, branchWidth) + branchStr = " " + dimStyle.Render(fmt.Sprintf("%-*s", branchWidth, br)) + } + + // Compact relative time + timeStr := "" + if timeWidth > 0 && item.LastCommit != nil { + timeStr = " " + dimStyle.Render(shortRelativeTime(item.LastCommit.Time)) + } + + var line string + if displayIdx == cursor { + line = fmt.Sprintf("%s %s%s%s%s %s %s %s", + cursorStyle.Render("▸"), + selPrefix, + runBadge, + selectedRowStyle.Render(fmt.Sprintf("%-*s", nameWidth, name)), + branchStr, + port, + timeStr, + status, + ) + } else { + line = fmt.Sprintf(" %s%s%s%s %s %s %s", + selPrefix, + runBadge, + normalRowStyle.Render(fmt.Sprintf("%-*s", nameWidth, name)), + branchStr, + port, + timeStr, + status, + ) + } + return line +} + func formatStatus(item workspaceItem) string { if item.StatusErr != nil { return statusErrorStyle.Render("? error") @@ -233,6 +334,21 @@ func relativeTime(t time.Time) string { } } +// shortRelativeTime returns a compact relative time string (e.g. "3h", "2d"). +func shortRelativeTime(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute: + return "now" + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh", int(d.Hours())) + default: + return fmt.Sprintf("%dd", int(d.Hours()/24)) + } +} + // formatPR renders a PR badge with appropriate styling. func formatPR(pr *gh.PRInfo) string { badge := fmt.Sprintf("PR #%d", pr.Number)