detached e38876b

feat(daemon): add per-tab floating state machine

commit
e38876b340e37fe2f953234af5adcd605d8ec143
author
brice · · 2026-07-09 23:50 UTC
committer
brice · · 2026-07-10 05:40 UTC
parent
1611c3c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +251 -0
A internal/usecase/daemon/floating.go +134 -0
@@ -0,0 +1,134 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/domain"
4+
5+// floatingState describes the lifecycle of a tab's independent floating pane.
6+// A floating slot is always guarded by its owning tab.mu.
7+type floatingState uint8
8+
9+const (
10+ floatingUninitialized floatingState = iota
11+ floatingWarming
12+ floatingHidden
13+ floatingVisible
14+)
15+
16+// floatingSlot holds the runtime which is deliberately outside tab.tree and
17+// tab.panes. generation identifies an asynchronous launch and its installed
18+// pane, preventing stale launch completions and exits from changing new state.
19+type floatingSlot struct {
20+ state floatingState
21+ pane *pane
22+ desiredVisible bool
23+ generation uint64
24+ launch domain.FloatingConfig
25+}
26+
27+// beginFloatingWarmLocked starts a single background launch. The caller must
28+// hold tb.mu and must only call it for an uninitialized slot.
29+func (tb *tab) beginFloatingWarmLocked(launch domain.FloatingConfig, desiredVisible bool) uint64 {
30+ if tb == nil || tb.floating.state != floatingUninitialized {
31+ return 0
32+ }
33+ tb.floating.generation++
34+ tb.floating.state = floatingWarming
35+ tb.floating.desiredVisible = desiredVisible
36+ tb.floating.launch = launch
37+ return tb.floating.generation
38+}
39+
40+// toggleFloatingLocked applies the user action. Its result says whether a new
41+// PTY launch is required and returns the generation associated with that launch
42+// or current slot.
43+func (tb *tab) toggleFloatingLocked(launch domain.FloatingConfig) (bool, uint64) {
44+ if tb == nil {
45+ return false, 0
46+ }
47+ switch tb.floating.state {
48+ case floatingUninitialized:
49+ generation := tb.beginFloatingWarmLocked(launch, true)
50+ return generation != 0, generation
51+ case floatingWarming:
52+ tb.floating.desiredVisible = !tb.floating.desiredVisible
53+ case floatingHidden:
54+ tb.floating.state = floatingVisible
55+ case floatingVisible:
56+ tb.floating.state = floatingHidden
57+ }
58+ return false, tb.floating.generation
59+}
60+
61+// installFloatingLocked installs a completed launch only when it is still the
62+// current warming generation. Stale completions must close their PTY elsewhere.
63+func (tb *tab) installFloatingLocked(p *pane, generation uint64) bool {
64+ if tb == nil || p == nil || tb.floating.state != floatingWarming || tb.floating.generation != generation {
65+ return false
66+ }
67+ tb.floating.pane = p
68+ if tb.floating.desiredVisible {
69+ tb.floating.state = floatingVisible
70+ } else {
71+ tb.floating.state = floatingHidden
72+ }
73+ return true
74+}
75+
76+// failFloatingLocked clears only the current warming launch. It deliberately
77+// leaves generation intact; the next launch advances it.
78+func (tb *tab) failFloatingLocked(generation uint64) bool {
79+ if tb == nil || tb.floating.state != floatingWarming || tb.floating.generation != generation {
80+ return false
81+ }
82+ tb.floating.state = floatingUninitialized
83+ tb.floating.pane = nil
84+ tb.floating.desiredVisible = false
85+ tb.floating.launch = domain.FloatingConfig{}
86+ return true
87+}
88+
89+// clearFloatingLocked clears a matching installed runtime. Matching both pane
90+// identity and generation prevents an old reader from reaping a newer pane.
91+func (tb *tab) clearFloatingLocked(p *pane, generation uint64) bool {
92+ if tb == nil || p == nil || tb.floating.pane != p || tb.floating.generation != generation ||
93+ (tb.floating.state != floatingHidden && tb.floating.state != floatingVisible) {
94+ return false
95+ }
96+ tb.floating.generation++
97+ tb.floating.state = floatingUninitialized
98+ tb.floating.pane = nil
99+ tb.floating.desiredVisible = false
100+ tb.floating.launch = domain.FloatingConfig{}
101+ return true
102+}
103+
104+// terminalTargetLocked chooses the visible floating terminal ahead of the
105+// normal layout target. The caller must hold tb.mu.
106+func (tb *tab) terminalTargetLocked() *pane {
107+ if tb != nil && tb.floating.state == floatingVisible && tb.floating.pane != nil {
108+ return tb.floating.pane
109+ }
110+ return tb.focusedPane()
111+}
112+
113+// floatingInnerSize returns the terminal area inside a percentage-sized popup.
114+// For each axis, a popup with at least three cells spends one cell on each
115+// border; smaller popups omit that axis's border. A valid tab always yields a
116+// valid PTY size.
117+func floatingInnerSize(tabSize domain.Size, cfg domain.FloatingConfig) domain.Size {
118+ if !tabSize.Valid() {
119+ return domain.Size{}
120+ }
121+ return domain.Size{
122+ Cols: floatingInnerAxis(tabSize.Cols, cfg.Width),
123+ Rows: floatingInnerAxis(tabSize.Rows, cfg.Height),
124+ }
125+}
126+
127+func floatingInnerAxis(available, percent int) int {
128+ percent = min(max(percent, 1), 100)
129+ bounds := min(max(available*percent/100, 1), available)
130+ if bounds >= 3 {
131+ return bounds - 2
132+ }
133+ return bounds
134+}
@@ -0,0 +1,134 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/domain"
4+
5+// floatingState describes the lifecycle of a tab's independent floating pane.
6+// A floating slot is always guarded by its owning tab.mu.
7+type floatingState uint8
8+
9+const (
10+ floatingUninitialized floatingState = iota
11+ floatingWarming
12+ floatingHidden
13+ floatingVisible
14+)
15+
16+// floatingSlot holds the runtime which is deliberately outside tab.tree and
17+// tab.panes. generation identifies an asynchronous launch and its installed
18+// pane, preventing stale launch completions and exits from changing new state.
19+type floatingSlot struct {
20+ state floatingState
21+ pane *pane
22+ desiredVisible bool
23+ generation uint64
24+ launch domain.FloatingConfig
25+}
26+
27+// beginFloatingWarmLocked starts a single background launch. The caller must
28+// hold tb.mu and must only call it for an uninitialized slot.
29+func (tb *tab) beginFloatingWarmLocked(launch domain.FloatingConfig, desiredVisible bool) uint64 {
30+ if tb == nil || tb.floating.state != floatingUninitialized {
31+ return 0
32+ }
33+ tb.floating.generation++
34+ tb.floating.state = floatingWarming
35+ tb.floating.desiredVisible = desiredVisible
36+ tb.floating.launch = launch
37+ return tb.floating.generation
38+}
39+
40+// toggleFloatingLocked applies the user action. Its result says whether a new
41+// PTY launch is required and returns the generation associated with that launch
42+// or current slot.
43+func (tb *tab) toggleFloatingLocked(launch domain.FloatingConfig) (bool, uint64) {
44+ if tb == nil {
45+ return false, 0
46+ }
47+ switch tb.floating.state {
48+ case floatingUninitialized:
49+ generation := tb.beginFloatingWarmLocked(launch, true)
50+ return generation != 0, generation
51+ case floatingWarming:
52+ tb.floating.desiredVisible = !tb.floating.desiredVisible
53+ case floatingHidden:
54+ tb.floating.state = floatingVisible
55+ case floatingVisible:
56+ tb.floating.state = floatingHidden
57+ }
58+ return false, tb.floating.generation
59+}
60+
61+// installFloatingLocked installs a completed launch only when it is still the
62+// current warming generation. Stale completions must close their PTY elsewhere.
63+func (tb *tab) installFloatingLocked(p *pane, generation uint64) bool {
64+ if tb == nil || p == nil || tb.floating.state != floatingWarming || tb.floating.generation != generation {
65+ return false
66+ }
67+ tb.floating.pane = p
68+ if tb.floating.desiredVisible {
69+ tb.floating.state = floatingVisible
70+ } else {
71+ tb.floating.state = floatingHidden
72+ }
73+ return true
74+}
75+
76+// failFloatingLocked clears only the current warming launch. It deliberately
77+// leaves generation intact; the next launch advances it.
78+func (tb *tab) failFloatingLocked(generation uint64) bool {
79+ if tb == nil || tb.floating.state != floatingWarming || tb.floating.generation != generation {
80+ return false
81+ }
82+ tb.floating.state = floatingUninitialized
83+ tb.floating.pane = nil
84+ tb.floating.desiredVisible = false
85+ tb.floating.launch = domain.FloatingConfig{}
86+ return true
87+}
88+
89+// clearFloatingLocked clears a matching installed runtime. Matching both pane
90+// identity and generation prevents an old reader from reaping a newer pane.
91+func (tb *tab) clearFloatingLocked(p *pane, generation uint64) bool {
92+ if tb == nil || p == nil || tb.floating.pane != p || tb.floating.generation != generation ||
93+ (tb.floating.state != floatingHidden && tb.floating.state != floatingVisible) {
94+ return false
95+ }
96+ tb.floating.generation++
97+ tb.floating.state = floatingUninitialized
98+ tb.floating.pane = nil
99+ tb.floating.desiredVisible = false
100+ tb.floating.launch = domain.FloatingConfig{}
101+ return true
102+}
103+
104+// terminalTargetLocked chooses the visible floating terminal ahead of the
105+// normal layout target. The caller must hold tb.mu.
106+func (tb *tab) terminalTargetLocked() *pane {
107+ if tb != nil && tb.floating.state == floatingVisible && tb.floating.pane != nil {
108+ return tb.floating.pane
109+ }
110+ return tb.focusedPane()
111+}
112+
113+// floatingInnerSize returns the terminal area inside a percentage-sized popup.
114+// For each axis, a popup with at least three cells spends one cell on each
115+// border; smaller popups omit that axis's border. A valid tab always yields a
116+// valid PTY size.
117+func floatingInnerSize(tabSize domain.Size, cfg domain.FloatingConfig) domain.Size {
118+ if !tabSize.Valid() {
119+ return domain.Size{}
120+ }
121+ return domain.Size{
122+ Cols: floatingInnerAxis(tabSize.Cols, cfg.Width),
123+ Rows: floatingInnerAxis(tabSize.Rows, cfg.Height),
124+ }
125+}
126+
127+func floatingInnerAxis(available, percent int) int {
128+ percent = min(max(percent, 1), 100)
129+ bounds := min(max(available*percent/100, 1), available)
130+ if bounds >= 3 {
131+ return bounds - 2
132+ }
133+ return bounds
134+}
A internal/usecase/daemon/floating_test.go +115 -0
@@ -0,0 +1,115 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+func TestFloatingSlotTransitions(t *testing.T) {
10+ first := &pane{}
11+ second := &pane{}
12+ launch := domain.FloatingConfig{Command: "top", Width: 80, Height: 80}
13+
14+ tests := []struct {
15+ name string
16+ run func(t *testing.T, tb *tab)
17+ }{
18+ {
19+ name: "warming double toggle retains hidden completion",
20+ run: func(t *testing.T, tb *tab) {
21+ tb.mu.Lock()
22+ generation := tb.beginFloatingWarmLocked(launch, false)
23+ start, gotGeneration := tb.toggleFloatingLocked(launch)
24+ if start || gotGeneration != generation || !tb.floating.desiredVisible {
25+ t.Fatalf("first warming toggle = start %v, generation %d, desired %v", start, gotGeneration, tb.floating.desiredVisible)
26+ }
27+ start, gotGeneration = tb.toggleFloatingLocked(launch)
28+ if start || gotGeneration != generation || tb.floating.desiredVisible {
29+ t.Fatalf("second warming toggle = start %v, generation %d, desired %v", start, gotGeneration, tb.floating.desiredVisible)
30+ }
31+ if !tb.installFloatingLocked(first, generation) || tb.floating.state != floatingHidden || tb.floating.pane != first {
32+ t.Fatal("warming completion did not retain a hidden pane")
33+ }
34+ tb.mu.Unlock()
35+ },
36+ },
37+ {
38+ name: "open and hide retain same pane",
39+ run: func(t *testing.T, tb *tab) {
40+ tb.mu.Lock()
41+ generation := tb.beginFloatingWarmLocked(launch, false)
42+ if !tb.installFloatingLocked(first, generation) {
43+ t.Fatal("install failed")
44+ }
45+ start, _ := tb.toggleFloatingLocked(launch)
46+ if start || tb.floating.state != floatingVisible || tb.floating.pane != first {
47+ t.Fatal("hidden open did not retain pane")
48+ }
49+ tb.toggleFloatingLocked(launch)
50+ if tb.floating.state != floatingHidden || tb.floating.pane != first {
51+ t.Fatal("visible hide did not retain pane")
52+ }
53+ tb.mu.Unlock()
54+ },
55+ },
56+ {
57+ name: "current launch failure clears desired visibility",
58+ run: func(t *testing.T, tb *tab) {
59+ tb.mu.Lock()
60+ generation := tb.beginFloatingWarmLocked(launch, true)
61+ if !tb.failFloatingLocked(generation) || tb.floating.state != floatingUninitialized || tb.floating.desiredVisible || tb.floating.pane != nil {
62+ t.Fatal("current launch failure did not clear slot")
63+ }
64+ tb.mu.Unlock()
65+ },
66+ },
67+ {
68+ name: "current exit clears and stale completion is ignored",
69+ run: func(t *testing.T, tb *tab) {
70+ tb.mu.Lock()
71+ generation := tb.beginFloatingWarmLocked(launch, true)
72+ if !tb.installFloatingLocked(first, generation) {
73+ t.Fatal("install failed")
74+ }
75+ if !tb.clearFloatingLocked(first, generation) || tb.floating.state != floatingUninitialized || tb.floating.pane != nil {
76+ t.Fatal("current exit did not clear slot")
77+ }
78+ newGeneration := tb.beginFloatingWarmLocked(launch, true)
79+ if tb.installFloatingLocked(second, generation) {
80+ t.Fatal("stale completion installed")
81+ }
82+ if tb.floating.state != floatingWarming || tb.floating.generation != newGeneration || tb.floating.pane != nil {
83+ t.Fatal("stale completion changed newer warming state")
84+ }
85+ tb.mu.Unlock()
86+ },
87+ },
88+ }
89+
90+ for _, tt := range tests {
91+ t.Run(tt.name, func(t *testing.T) { tt.run(t, &tab{}) })
92+ }
93+}
94+
95+func TestFloatingInnerSize(t *testing.T) {
96+ tests := []struct {
97+ name string
98+ tab domain.Size
99+ cfg domain.FloatingConfig
100+ want domain.Size
101+ }{
102+ {name: "one percent", tab: domain.Size{Cols: 100, Rows: 100}, cfg: domain.FloatingConfig{Width: 1, Height: 1}, want: domain.Size{Cols: 1, Rows: 1}},
103+ {name: "full size reserves borders", tab: domain.Size{Cols: 100, Rows: 80}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, want: domain.Size{Cols: 98, Rows: 78}},
104+ {name: "tiny tab omits borders", tab: domain.Size{Cols: 2, Rows: 1}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, want: domain.Size{Cols: 2, Rows: 1}},
105+ {name: "three cells leaves one inner cell", tab: domain.Size{Cols: 3, Rows: 3}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, want: domain.Size{Cols: 1, Rows: 1}},
106+ {name: "invalid tab has no pty size", tab: domain.Size{}, cfg: domain.FloatingConfig{Width: 80, Height: 80}, want: domain.Size{}},
107+ }
108+ for _, tt := range tests {
109+ t.Run(tt.name, func(t *testing.T) {
110+ if got := floatingInnerSize(tt.tab, tt.cfg); got != tt.want {
111+ t.Fatalf("floatingInnerSize(%+v, %+v) = %+v, want %+v", tt.tab, tt.cfg, got, tt.want)
112+ }
113+ })
114+ }
115+}
@@ -0,0 +1,115 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+func TestFloatingSlotTransitions(t *testing.T) {
10+ first := &pane{}
11+ second := &pane{}
12+ launch := domain.FloatingConfig{Command: "top", Width: 80, Height: 80}
13+
14+ tests := []struct {
15+ name string
16+ run func(t *testing.T, tb *tab)
17+ }{
18+ {
19+ name: "warming double toggle retains hidden completion",
20+ run: func(t *testing.T, tb *tab) {
21+ tb.mu.Lock()
22+ generation := tb.beginFloatingWarmLocked(launch, false)
23+ start, gotGeneration := tb.toggleFloatingLocked(launch)
24+ if start || gotGeneration != generation || !tb.floating.desiredVisible {
25+ t.Fatalf("first warming toggle = start %v, generation %d, desired %v", start, gotGeneration, tb.floating.desiredVisible)
26+ }
27+ start, gotGeneration = tb.toggleFloatingLocked(launch)
28+ if start || gotGeneration != generation || tb.floating.desiredVisible {
29+ t.Fatalf("second warming toggle = start %v, generation %d, desired %v", start, gotGeneration, tb.floating.desiredVisible)
30+ }
31+ if !tb.installFloatingLocked(first, generation) || tb.floating.state != floatingHidden || tb.floating.pane != first {
32+ t.Fatal("warming completion did not retain a hidden pane")
33+ }
34+ tb.mu.Unlock()
35+ },
36+ },
37+ {
38+ name: "open and hide retain same pane",
39+ run: func(t *testing.T, tb *tab) {
40+ tb.mu.Lock()
41+ generation := tb.beginFloatingWarmLocked(launch, false)
42+ if !tb.installFloatingLocked(first, generation) {
43+ t.Fatal("install failed")
44+ }
45+ start, _ := tb.toggleFloatingLocked(launch)
46+ if start || tb.floating.state != floatingVisible || tb.floating.pane != first {
47+ t.Fatal("hidden open did not retain pane")
48+ }
49+ tb.toggleFloatingLocked(launch)
50+ if tb.floating.state != floatingHidden || tb.floating.pane != first {
51+ t.Fatal("visible hide did not retain pane")
52+ }
53+ tb.mu.Unlock()
54+ },
55+ },
56+ {
57+ name: "current launch failure clears desired visibility",
58+ run: func(t *testing.T, tb *tab) {
59+ tb.mu.Lock()
60+ generation := tb.beginFloatingWarmLocked(launch, true)
61+ if !tb.failFloatingLocked(generation) || tb.floating.state != floatingUninitialized || tb.floating.desiredVisible || tb.floating.pane != nil {
62+ t.Fatal("current launch failure did not clear slot")
63+ }
64+ tb.mu.Unlock()
65+ },
66+ },
67+ {
68+ name: "current exit clears and stale completion is ignored",
69+ run: func(t *testing.T, tb *tab) {
70+ tb.mu.Lock()
71+ generation := tb.beginFloatingWarmLocked(launch, true)
72+ if !tb.installFloatingLocked(first, generation) {
73+ t.Fatal("install failed")
74+ }
75+ if !tb.clearFloatingLocked(first, generation) || tb.floating.state != floatingUninitialized || tb.floating.pane != nil {
76+ t.Fatal("current exit did not clear slot")
77+ }
78+ newGeneration := tb.beginFloatingWarmLocked(launch, true)
79+ if tb.installFloatingLocked(second, generation) {
80+ t.Fatal("stale completion installed")
81+ }
82+ if tb.floating.state != floatingWarming || tb.floating.generation != newGeneration || tb.floating.pane != nil {
83+ t.Fatal("stale completion changed newer warming state")
84+ }
85+ tb.mu.Unlock()
86+ },
87+ },
88+ }
89+
90+ for _, tt := range tests {
91+ t.Run(tt.name, func(t *testing.T) { tt.run(t, &tab{}) })
92+ }
93+}
94+
95+func TestFloatingInnerSize(t *testing.T) {
96+ tests := []struct {
97+ name string
98+ tab domain.Size
99+ cfg domain.FloatingConfig
100+ want domain.Size
101+ }{
102+ {name: "one percent", tab: domain.Size{Cols: 100, Rows: 100}, cfg: domain.FloatingConfig{Width: 1, Height: 1}, want: domain.Size{Cols: 1, Rows: 1}},
103+ {name: "full size reserves borders", tab: domain.Size{Cols: 100, Rows: 80}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, want: domain.Size{Cols: 98, Rows: 78}},
104+ {name: "tiny tab omits borders", tab: domain.Size{Cols: 2, Rows: 1}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, want: domain.Size{Cols: 2, Rows: 1}},
105+ {name: "three cells leaves one inner cell", tab: domain.Size{Cols: 3, Rows: 3}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, want: domain.Size{Cols: 1, Rows: 1}},
106+ {name: "invalid tab has no pty size", tab: domain.Size{}, cfg: domain.FloatingConfig{Width: 80, Height: 80}, want: domain.Size{}},
107+ }
108+ for _, tt := range tests {
109+ t.Run(tt.name, func(t *testing.T) {
110+ if got := floatingInnerSize(tt.tab, tt.cfg); got != tt.want {
111+ t.Fatalf("floatingInnerSize(%+v, %+v) = %+v, want %+v", tt.tab, tt.cfg, got, tt.want)
112+ }
113+ })
114+ }
115+}
M internal/usecase/daemon/session.go +2 -0
@@ -84,6 +84,8 @@ type tab struct {
8484 size domain.Size
8585 ctx context.Context
8686 cancel context.CancelFunc
87+ // floating is independent from the normal layout tree and pane map.
88+ floating floatingSlot
8789
8890 // previewClient tracks the one client currently previewing this tab in the picker.
8991 // v1 is last-writer-wins: multiple clients previewing the same tab are not supported.
@@ -84,6 +84,8 @@ type tab struct {
84 size domain.Size84 size domain.Size
85 ctx context.Context85 ctx context.Context
86 cancel context.CancelFunc86 cancel context.CancelFunc
87+ // floating is independent from the normal layout tree and pane map.
88+ floating floatingSlot
87 89
88 // previewClient tracks the one client currently previewing this tab in the picker.90 // previewClient tracks the one client currently previewing this tab in the picker.
89 // v1 is last-writer-wins: multiple clients previewing the same tab are not supported.91 // v1 is last-writer-wins: multiple clients previewing the same tab are not supported.

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