detached 88024fa

cli: preserve styled errors and keep selected session visible

commit
88024fa6996e66e4fa9b4f21ab28abff06fa03cd
author
brice · · 2026-02-10 09:49 UTC
parent
a86fc63
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +103 -20
M internal/cli/cmd/config.go +4 -4
@@ -117,20 +117,20 @@ func runConfigOpen(_ *cobra.Command, _ []string) error {
117117 configFile, err := config.GetConfigFile()
118118 if err != nil {
119119 renderer := styles.NewConfigRenderer(app.Theme)
120- return fmt.Errorf("%s", strings.TrimSpace(renderer.RenderError(fmt.Errorf("get config file: %w", err))))
120+ return fmt.Errorf("%s", strings.TrimRight(renderer.RenderError(fmt.Errorf("get config file: %w", err)), "\n"))
121121 }
122122
123123 // Check if config file exists
124124 if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
125125 renderer := styles.NewConfigRenderer(app.Theme)
126- return fmt.Errorf("%s", strings.TrimSpace(renderer.RenderError(fmt.Errorf("config file does not exist: %s", configFile))))
126+ return fmt.Errorf("%s", strings.TrimRight(renderer.RenderError(fmt.Errorf("config file does not exist: %s", configFile)), "\n"))
127127 }
128128
129129 editor := getEditor()
130130 if editor == "" {
131131 renderer := styles.NewConfigRenderer(app.Theme)
132132 const msg = "no editor found: set $EDITOR or $VISUAL environment variable"
133- return fmt.Errorf("%s", strings.TrimSpace(renderer.RenderError(fmt.Errorf("%s", msg))))
133+ return fmt.Errorf("%s", strings.TrimRight(renderer.RenderError(fmt.Errorf("%s", msg)), "\n"))
134134 }
135135
136136 // Keep this a simple, non-interactive message; the actual editor takes over the terminal.
@@ -143,7 +143,7 @@ func runConfigOpen(_ *cobra.Command, _ []string) error {
143143
144144 if err := cmd.Run(); err != nil {
145145 renderer := styles.NewConfigRenderer(app.Theme)
146- return fmt.Errorf("%s", strings.TrimSpace(renderer.RenderError(fmt.Errorf("open config: %w", err))))
146+ return fmt.Errorf("%s", strings.TrimRight(renderer.RenderError(fmt.Errorf("open config: %w", err)), "\n"))
147147 }
148148 return nil
149149 }
@@ -117,20 +117,20 @@ func runConfigOpen(_ *cobra.Command, _ []string) error {
117 configFile, err := config.GetConfigFile()117 configFile, err := config.GetConfigFile()
118 if err != nil {118 if err != nil {
119 renderer := styles.NewConfigRenderer(app.Theme)119 renderer := styles.NewConfigRenderer(app.Theme)
120- return fmt.Errorf("%s", strings.TrimSpace(renderer.RenderError(fmt.Errorf("get config file: %w", err))))120+ return fmt.Errorf("%s", strings.TrimRight(renderer.RenderError(fmt.Errorf("get config file: %w", err)), "\n"))
121 }121 }
122 122
123 // Check if config file exists123 // Check if config file exists
124 if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {124 if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
125 renderer := styles.NewConfigRenderer(app.Theme)125 renderer := styles.NewConfigRenderer(app.Theme)
126- return fmt.Errorf("%s", strings.TrimSpace(renderer.RenderError(fmt.Errorf("config file does not exist: %s", configFile))))126+ return fmt.Errorf("%s", strings.TrimRight(renderer.RenderError(fmt.Errorf("config file does not exist: %s", configFile)), "\n"))
127 }127 }
128 128
129 editor := getEditor()129 editor := getEditor()
130 if editor == "" {130 if editor == "" {
131 renderer := styles.NewConfigRenderer(app.Theme)131 renderer := styles.NewConfigRenderer(app.Theme)
132 const msg = "no editor found: set $EDITOR or $VISUAL environment variable"132 const msg = "no editor found: set $EDITOR or $VISUAL environment variable"
133- return fmt.Errorf("%s", strings.TrimSpace(renderer.RenderError(fmt.Errorf("%s", msg))))133+ return fmt.Errorf("%s", strings.TrimRight(renderer.RenderError(fmt.Errorf("%s", msg)), "\n"))
134 }134 }
135 135
136 // Keep this a simple, non-interactive message; the actual editor takes over the terminal.136 // Keep this a simple, non-interactive message; the actual editor takes over the terminal.
@@ -143,7 +143,7 @@ func runConfigOpen(_ *cobra.Command, _ []string) error {
143 143
144 if err := cmd.Run(); err != nil {144 if err := cmd.Run(); err != nil {
145 renderer := styles.NewConfigRenderer(app.Theme)145 renderer := styles.NewConfigRenderer(app.Theme)
146- return fmt.Errorf("%s", strings.TrimSpace(renderer.RenderError(fmt.Errorf("open config: %w", err))))146+ return fmt.Errorf("%s", strings.TrimRight(renderer.RenderError(fmt.Errorf("open config: %w", err)), "\n"))
147 }147 }
148 return nil148 return nil
149 }149 }
M internal/cli/cmd/crashes.go +8 -4
@@ -100,12 +100,14 @@ func runCrashesShow(_ *cobra.Command, args []string) error {
100100
101101 report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0])
102102 if err != nil {
103- return fmt.Errorf("%s\n%s", renderer.RenderError(err), renderer.RenderHintList())
103+ _, _ = fmt.Fprintf(os.Stderr, "%s\n%s\n", renderer.RenderError(err), renderer.RenderHintList())
104+ return nil
104105 }
105106
106107 body, err := os.ReadFile(report.MarkdownPath)
107108 if err != nil {
108- return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("read crash report markdown: %w", err)))
109+ _, _ = fmt.Fprintf(os.Stderr, "%s\n", renderer.RenderError(fmt.Errorf("read crash report markdown: %w", err)))
110+ return nil
109111 }
110112 fmt.Print(string(body))
111113 return nil
@@ -120,12 +122,14 @@ func runCrashesIssue(_ *cobra.Command, args []string) error {
120122
121123 report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0])
122124 if err != nil {
123- return fmt.Errorf("%s\n%s", renderer.RenderError(err), renderer.RenderHintList())
125+ _, _ = fmt.Fprintf(os.Stderr, "%s\n%s\n", renderer.RenderError(err), renderer.RenderHintList())
126+ return nil
124127 }
125128
126129 body, err := os.ReadFile(report.MarkdownPath)
127130 if err != nil {
128- return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("read crash report markdown: %w", err)))
131+ _, _ = fmt.Fprintf(os.Stderr, "%s\n", renderer.RenderError(fmt.Errorf("read crash report markdown: %w", err)))
132+ return nil
129133 }
130134 all := string(body)
131135 start := strings.Index(all, "## GitHub Issue Template")
@@ -100,12 +100,14 @@ func runCrashesShow(_ *cobra.Command, args []string) error {
100 100
101 report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0])101 report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0])
102 if err != nil {102 if err != nil {
103- return fmt.Errorf("%s\n%s", renderer.RenderError(err), renderer.RenderHintList())103+ _, _ = fmt.Fprintf(os.Stderr, "%s\n%s\n", renderer.RenderError(err), renderer.RenderHintList())
104+ return nil
104 }105 }
105 106
106 body, err := os.ReadFile(report.MarkdownPath)107 body, err := os.ReadFile(report.MarkdownPath)
107 if err != nil {108 if err != nil {
108- return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("read crash report markdown: %w", err)))109+ _, _ = fmt.Fprintf(os.Stderr, "%s\n", renderer.RenderError(fmt.Errorf("read crash report markdown: %w", err)))
110+ return nil
109 }111 }
110 fmt.Print(string(body))112 fmt.Print(string(body))
111 return nil113 return nil
@@ -120,12 +122,14 @@ func runCrashesIssue(_ *cobra.Command, args []string) error {
120 122
121 report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0])123 report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0])
122 if err != nil {124 if err != nil {
123- return fmt.Errorf("%s\n%s", renderer.RenderError(err), renderer.RenderHintList())125+ _, _ = fmt.Fprintf(os.Stderr, "%s\n%s\n", renderer.RenderError(err), renderer.RenderHintList())
126+ return nil
124 }127 }
125 128
126 body, err := os.ReadFile(report.MarkdownPath)129 body, err := os.ReadFile(report.MarkdownPath)
127 if err != nil {130 if err != nil {
128- return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("read crash report markdown: %w", err)))131+ _, _ = fmt.Fprintf(os.Stderr, "%s\n", renderer.RenderError(fmt.Errorf("read crash report markdown: %w", err)))
132+ return nil
129 }133 }
130 all := string(body)134 all := string(body)
131 start := strings.Index(all, "## GitHub Issue Template")135 start := strings.Index(all, "## GitHub Issue Template")
M internal/cli/cmd/sessions.go +1 -1
@@ -107,7 +107,7 @@ func runSessionsList(_ *cobra.Command, _ []string) error {
107107
108108 output, err := app.ListSessionsUC.Execute(app.Ctx(), currentSessionID, sessionsLimit)
109109 if err != nil {
110- return fmt.Errorf("list sessions: %w", err)
110+ return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("list sessions: %w", err)))
111111 }
112112
113113 if sessionsJSON {
@@ -107,7 +107,7 @@ func runSessionsList(_ *cobra.Command, _ []string) error {
107 107
108 output, err := app.ListSessionsUC.Execute(app.Ctx(), currentSessionID, sessionsLimit)108 output, err := app.ListSessionsUC.Execute(app.Ctx(), currentSessionID, sessionsLimit)
109 if err != nil {109 if err != nil {
110- return fmt.Errorf("list sessions: %w", err)110+ return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("list sessions: %w", err)))
111 }111 }
112 112
113 if sessionsJSON {113 if sessionsJSON {
M internal/cli/model/sessions.go +39 -11
@@ -429,18 +429,23 @@ func (m SessionsModel) renderSessionsList(maxHeight int) string {
429429 return t.Subtle.Render(" No saved sessions found.")
430430 }
431431
432- // Determine starting index so the selected row stays visible.
433- start := 0
434- if len(m.sessions) > maxHeight {
435- // Keep selection roughly centered, but always visible.
436- start = m.selectedIdx - (maxHeight / 2)
437- if start < 0 {
438- start = 0
439- }
440- maxStart := len(m.sessions) - maxHeight
441- if start > maxStart {
442- start = maxStart
432+ // Determine starting index so the selected row stays visible even when
433+ // rows above it are expanded and consume multiple lines.
434+ start := m.selectedIdx
435+ if start < 0 {
436+ start = 0
437+ }
438+ if start >= len(m.sessions) {
439+ start = len(m.sessions) - 1
440+ }
441+ used := m.rowHeight(start)
442+ for start > 0 {
443+ prevHeight := m.rowHeight(start - 1)
444+ if used+prevHeight > maxHeight {
445+ break
443446 }
447+ start--
448+ used += prevHeight
444449 }
445450
446451 var b strings.Builder
@@ -476,6 +481,29 @@ func (m SessionsModel) renderSessionsList(maxHeight int) string {
476481 return strings.TrimRight(b.String(), "\n")
477482 }
478483
484+func (m SessionsModel) rowHeight(idx int) int {
485+ if idx < 0 || idx >= len(m.sessions) {
486+ return 0
487+ }
488+
489+ height := 1 // always render one row line
490+ if idx != m.expandedIdx {
491+ return height
492+ }
493+
494+ info := m.sessions[idx]
495+ if info.State == nil {
496+ return height
497+ }
498+
499+ details := m.renderSessionDetails(info)
500+ if details == "" {
501+ return height
502+ }
503+
504+ return height + lipgloss.Height(details)
505+}
506+
479507 func (m SessionsModel) renderSessionRow(info entity.SessionInfo, isSelected, isExpanded bool) string {
480508 t := m.theme
481509
@@ -429,18 +429,23 @@ func (m SessionsModel) renderSessionsList(maxHeight int) string {
429 return t.Subtle.Render(" No saved sessions found.")429 return t.Subtle.Render(" No saved sessions found.")
430 }430 }
431 431
432- // Determine starting index so the selected row stays visible.432+ // Determine starting index so the selected row stays visible even when
433- start := 0433+ // rows above it are expanded and consume multiple lines.
434- if len(m.sessions) > maxHeight {434+ start := m.selectedIdx
435- // Keep selection roughly centered, but always visible.435+ if start < 0 {
436- start = m.selectedIdx - (maxHeight / 2)436+ start = 0
437- if start < 0 {437+ }
438- start = 0438+ if start >= len(m.sessions) {
439- }439+ start = len(m.sessions) - 1
440- maxStart := len(m.sessions) - maxHeight440+ }
441- if start > maxStart {441+ used := m.rowHeight(start)
442- start = maxStart442+ for start > 0 {
443+ prevHeight := m.rowHeight(start - 1)
444+ if used+prevHeight > maxHeight {
445+ break
443 }446 }
447+ start--
448+ used += prevHeight
444 }449 }
445 450
446 var b strings.Builder451 var b strings.Builder
@@ -476,6 +481,29 @@ func (m SessionsModel) renderSessionsList(maxHeight int) string {
476 return strings.TrimRight(b.String(), "\n")481 return strings.TrimRight(b.String(), "\n")
477 }482 }
478 483
484+func (m SessionsModel) rowHeight(idx int) int {
485+ if idx < 0 || idx >= len(m.sessions) {
486+ return 0
487+ }
488+
489+ height := 1 // always render one row line
490+ if idx != m.expandedIdx {
491+ return height
492+ }
493+
494+ info := m.sessions[idx]
495+ if info.State == nil {
496+ return height
497+ }
498+
499+ details := m.renderSessionDetails(info)
500+ if details == "" {
501+ return height
502+ }
503+
504+ return height + lipgloss.Height(details)
505+}
506+
479 func (m SessionsModel) renderSessionRow(info entity.SessionInfo, isSelected, isExpanded bool) string {507 func (m SessionsModel) renderSessionRow(info entity.SessionInfo, isSelected, isExpanded bool) string {
480 t := m.theme508 t := m.theme
481 509
A internal/cli/model/sessions_test.go +51 -0
@@ -0,0 +1,51 @@
1+package model
2+
3+import (
4+ "context"
5+ "fmt"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/dumber/internal/cli/styles"
12+ "github.com/bnema/dumber/internal/domain/entity"
13+ "github.com/bnema/dumber/internal/infrastructure/config"
14+)
15+
16+func TestRenderSessionsList_KeepsSelectedVisibleWhenExpandedRowAbove(t *testing.T) {
17+ theme := styles.NewTheme(config.DefaultConfig())
18+ m := NewSessionsModel(context.Background(), theme, SessionsModelConfig{})
19+
20+ m.sessions = make([]entity.SessionInfo, 0, 6)
21+ for i := 0; i < 6; i++ {
22+ m.sessions = append(m.sessions, testSessionInfo(fmt.Sprintf("session-%02d", i), 1))
23+ }
24+
25+ // Expand a row above the selected row with many detail lines.
26+ m.sessions[2] = testSessionInfo("session-02", 6)
27+ m.selectedIdx = 4
28+ m.expandedIdx = 2
29+
30+ view := m.renderSessionsList(5)
31+ require.Contains(t, view, "session-04", "selected row should remain visible")
32+}
33+
34+func testSessionInfo(id string, tabCount int) entity.SessionInfo {
35+ tabs := make([]entity.TabSnapshot, 0, tabCount)
36+ for i := 0; i < tabCount; i++ {
37+ tabs = append(tabs, entity.TabSnapshot{Name: fmt.Sprintf("Tab %d", i+1)})
38+ }
39+
40+ return entity.SessionInfo{
41+ Session: &entity.Session{
42+ ID: entity.SessionID(id),
43+ Type: entity.SessionTypeBrowser,
44+ StartedAt: time.Now().Add(-time.Hour),
45+ },
46+ State: &entity.SessionState{Tabs: tabs},
47+ TabCount: tabCount,
48+ PaneCount: 0,
49+ UpdatedAt: time.Now(),
50+ }
51+}
@@ -0,0 +1,51 @@
1+package model
2+
3+import (
4+ "context"
5+ "fmt"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/dumber/internal/cli/styles"
12+ "github.com/bnema/dumber/internal/domain/entity"
13+ "github.com/bnema/dumber/internal/infrastructure/config"
14+)
15+
16+func TestRenderSessionsList_KeepsSelectedVisibleWhenExpandedRowAbove(t *testing.T) {
17+ theme := styles.NewTheme(config.DefaultConfig())
18+ m := NewSessionsModel(context.Background(), theme, SessionsModelConfig{})
19+
20+ m.sessions = make([]entity.SessionInfo, 0, 6)
21+ for i := 0; i < 6; i++ {
22+ m.sessions = append(m.sessions, testSessionInfo(fmt.Sprintf("session-%02d", i), 1))
23+ }
24+
25+ // Expand a row above the selected row with many detail lines.
26+ m.sessions[2] = testSessionInfo("session-02", 6)
27+ m.selectedIdx = 4
28+ m.expandedIdx = 2
29+
30+ view := m.renderSessionsList(5)
31+ require.Contains(t, view, "session-04", "selected row should remain visible")
32+}
33+
34+func testSessionInfo(id string, tabCount int) entity.SessionInfo {
35+ tabs := make([]entity.TabSnapshot, 0, tabCount)
36+ for i := 0; i < tabCount; i++ {
37+ tabs = append(tabs, entity.TabSnapshot{Name: fmt.Sprintf("Tab %d", i+1)})
38+ }
39+
40+ return entity.SessionInfo{
41+ Session: &entity.Session{
42+ ID: entity.SessionID(id),
43+ Type: entity.SessionTypeBrowser,
44+ StartedAt: time.Now().Add(-time.Hour),
45+ },
46+ State: &entity.SessionState{Tabs: tabs},
47+ TabCount: tabCount,
48+ PaneCount: 0,
49+ UpdatedAt: time.Now(),
50+ }
51+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close