detached 8be4601

refactor(daemon): name tab chrome rows and derive headless viewports

commit
8be46014f75c7fc067330700174a0717a93c1c8b
author
brice · · 2026-07-23 21:31 UTC
parent
cb1999a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +71 -1
M internal/usecase/daemon/session.go +28 -1
@@ -457,14 +457,41 @@ func (tb *tab) closeAllPanes() {
457457 }
458458 }
459459
460+// tabChromeRows is the vertical chrome (tab bar + status bar) between a
461+// client viewport and a tab's content area.
462+const tabChromeRows = 2
463+
460464 func tabSize(clientSize domain.Size) domain.Size {
461465 if !clientSize.Valid() {
462466 clientSize = defaultSize
463467 }
464- rows := max(clientSize.Rows-2, 1)
468+ rows := max(clientSize.Rows-tabChromeRows, 1)
465469 return domain.Size{Cols: clientSize.Cols, Rows: rows}
466470 }
467471
472+// fullViewportSize derives a full client-equivalent viewport from the active
473+// tab's retained content size, for actions that need a viewport while no
474+// client is attached. It falls back to defaultSize when no valid size exists.
475+func (s *session) fullViewportSize() domain.Size {
476+ s.mu.Lock()
477+ var tb *tab
478+ if s.active >= 0 && s.active < len(s.tabs) {
479+ tb = s.tabs[s.active]
480+ }
481+ s.mu.Unlock()
482+
483+ var content domain.Size
484+ if tb != nil {
485+ tb.mu.Lock()
486+ content = tb.size
487+ tb.mu.Unlock()
488+ }
489+ if !content.Valid() {
490+ return defaultSize
491+ }
492+ return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows}
493+}
494+
468495 func (d *Daemon) startTabGoroutines(sess *session, tb *tab) {
469496 tb.mu.Lock()
470497 panes := tb.panesSnapshot()
@@ -457,14 +457,41 @@ func (tb *tab) closeAllPanes() {
457 }457 }
458 }458 }
459 459
460+// tabChromeRows is the vertical chrome (tab bar + status bar) between a
461+// client viewport and a tab's content area.
462+const tabChromeRows = 2
463+
460 func tabSize(clientSize domain.Size) domain.Size {464 func tabSize(clientSize domain.Size) domain.Size {
461 if !clientSize.Valid() {465 if !clientSize.Valid() {
462 clientSize = defaultSize466 clientSize = defaultSize
463 }467 }
464- rows := max(clientSize.Rows-2, 1)468+ rows := max(clientSize.Rows-tabChromeRows, 1)
465 return domain.Size{Cols: clientSize.Cols, Rows: rows}469 return domain.Size{Cols: clientSize.Cols, Rows: rows}
466 }470 }
467 471
472+// fullViewportSize derives a full client-equivalent viewport from the active
473+// tab's retained content size, for actions that need a viewport while no
474+// client is attached. It falls back to defaultSize when no valid size exists.
475+func (s *session) fullViewportSize() domain.Size {
476+ s.mu.Lock()
477+ var tb *tab
478+ if s.active >= 0 && s.active < len(s.tabs) {
479+ tb = s.tabs[s.active]
480+ }
481+ s.mu.Unlock()
482+
483+ var content domain.Size
484+ if tb != nil {
485+ tb.mu.Lock()
486+ content = tb.size
487+ tb.mu.Unlock()
488+ }
489+ if !content.Valid() {
490+ return defaultSize
491+ }
492+ return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows}
493+}
494+
468 func (d *Daemon) startTabGoroutines(sess *session, tb *tab) {495 func (d *Daemon) startTabGoroutines(sess *session, tb *tab) {
469 tb.mu.Lock()496 tb.mu.Lock()
470 panes := tb.panesSnapshot()497 panes := tb.panesSnapshot()
A internal/usecase/daemon/session_size_test.go +43 -0
@@ -0,0 +1,43 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+func TestFullViewportSize(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ sess *session
13+ want domain.Size
14+ }{
15+ {
16+ name: "derives from active tab content size",
17+ sess: &session{tabs: []*tab{{size: domain.Size{Cols: 120, Rows: 38}}}, active: 0},
18+ want: domain.Size{Cols: 120, Rows: 40},
19+ },
20+ {
21+ name: "round-trips through tabSize",
22+ sess: &session{tabs: []*tab{{size: tabSize(domain.Size{Cols: 80, Rows: 24})}}, active: 0},
23+ want: domain.Size{Cols: 80, Rows: 24},
24+ },
25+ {
26+ name: "falls back to defaultSize with no tabs",
27+ sess: &session{},
28+ want: defaultSize,
29+ },
30+ {
31+ name: "falls back to defaultSize with invalid tab size",
32+ sess: &session{tabs: []*tab{{}}, active: 0},
33+ want: defaultSize,
34+ },
35+ }
36+ for _, tt := range tests {
37+ t.Run(tt.name, func(t *testing.T) {
38+ if got := tt.sess.fullViewportSize(); got != tt.want {
39+ t.Fatalf("fullViewportSize() = %+v, want %+v", got, tt.want)
40+ }
41+ })
42+ }
43+}
@@ -0,0 +1,43 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+func TestFullViewportSize(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ sess *session
13+ want domain.Size
14+ }{
15+ {
16+ name: "derives from active tab content size",
17+ sess: &session{tabs: []*tab{{size: domain.Size{Cols: 120, Rows: 38}}}, active: 0},
18+ want: domain.Size{Cols: 120, Rows: 40},
19+ },
20+ {
21+ name: "round-trips through tabSize",
22+ sess: &session{tabs: []*tab{{size: tabSize(domain.Size{Cols: 80, Rows: 24})}}, active: 0},
23+ want: domain.Size{Cols: 80, Rows: 24},
24+ },
25+ {
26+ name: "falls back to defaultSize with no tabs",
27+ sess: &session{},
28+ want: defaultSize,
29+ },
30+ {
31+ name: "falls back to defaultSize with invalid tab size",
32+ sess: &session{tabs: []*tab{{}}, active: 0},
33+ want: defaultSize,
34+ },
35+ }
36+ for _, tt := range tests {
37+ t.Run(tt.name, func(t *testing.T) {
38+ if got := tt.sess.fullViewportSize(); got != tt.want {
39+ t.Fatalf("fullViewportSize() = %+v, want %+v", got, tt.want)
40+ }
41+ })
42+ }
43+}

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 12

Tags 11

↑↓ moveenter openesc close