detached 8ccec9f

feat(daemon): manage floating pane lifecycle

commit
8ccec9f939fa6468ed87095090da07ee82a03590
author
brice · · 2026-07-09 23:52 UTC
committer
brice · · 2026-07-10 05:40 UTC
parent
e38876b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +218 -2
M internal/usecase/daemon/floating.go +199 -1
@@ -1,6 +1,14 @@
11 package daemon
22
3-import "github.com/bnema/vev/internal/domain"
3+import (
4+ "context"
5+ "fmt"
6+ "path/filepath"
7+ "strings"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/usecase/layout"
11+)
412
513 // floatingState describes the lifecycle of a tab's independent floating pane.
614 // A floating slot is always guarded by its owning tab.mu.
@@ -132,3 +140,193 @@ func floatingInnerAxis(available, percent int) int {
132140 }
133141 return bounds
134142 }
143+
144+// ensureFloatingWarm starts the background prewarm exactly once for a tab.
145+func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) {
146+ if d == nil || sess == nil || tb == nil || d.ptys == nil {
147+ return
148+ }
149+ d.startFloating(sess, tb, false)
150+}
151+
152+// toggleFloating changes only this tab's slot. Opening an uninitialized slot
153+// launches asynchronously; installed slots are retained when hidden.
154+func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error {
155+ if d == nil || sess == nil {
156+ return fmt.Errorf("floating pane: session required")
157+ }
158+ tb := sess.activeTab()
159+ if tb == nil {
160+ return layout.ErrNotFound
161+ }
162+ cfg := d.currentFloatingConfig()
163+ tb.mu.Lock()
164+ start, generation := tb.toggleFloatingLocked(cfg)
165+ visible := tb.floating.desiredVisible
166+ tb.mu.Unlock()
167+ if start {
168+ d.launchFloating(sess, tb, cfg, generation, visible)
169+ return nil
170+ }
171+ if ac != nil {
172+ d.paint(sess, ac, true)
173+ }
174+ return nil
175+}
176+
177+// startFloating starts a prewarm if its slot remains uninitialized.
178+func (d *Daemon) startFloating(sess *session, tb *tab, visible bool) {
179+ cfg := d.currentFloatingConfig()
180+ tb.mu.Lock()
181+ generation := tb.beginFloatingWarmLocked(cfg, visible)
182+ tb.mu.Unlock()
183+ if generation != 0 {
184+ d.launchFloating(sess, tb, cfg, generation, visible)
185+ }
186+}
187+
188+// launchFloating snapshots all launch inputs before starting the potentially
189+// blocking factory call. In particular, no PTY operation occurs while tb.mu is
190+// held, and config reloads cannot affect this launch.
191+func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfig, generation uint64, userOpen bool) {
192+ sess.mu.Lock()
193+ name, cwd, term := sess.name, sess.cwd, sess.terminal
194+ sess.mu.Unlock()
195+ tb.mu.Lock()
196+ sz := floatingInnerSize(tb.size, cfg)
197+ focused := tb.focusedPane()
198+ tabStableID := tb.stableID
199+ tabCtx := tb.ctx
200+ tb.mu.Unlock()
201+ if !sz.Valid() {
202+ sz = domain.Size{Cols: 1, Rows: 1}
203+ }
204+ if focused != nil && d.procCwd != nil && focused.pty != nil {
205+ if live, err := d.procCwd(focused.pty.Pid()); err == nil && live != "" {
206+ cwd = live
207+ }
208+ }
209+ if cwd == "" && d.dirOrHome != nil {
210+ cwd = d.dirOrHome("")
211+ }
212+ paneStableID, err := newStableID("p")
213+ if err != nil {
214+ d.failFloatingLaunch(sess, tb, generation, userOpen, err)
215+ return
216+ }
217+ env := d.childEnv(name, tabStableID, paneStableID, term)
218+ command, args := d.shell, append([]string(nil), d.shellArgs...)
219+ if cfg.Command != "" {
220+ args = []string{"-lc", cfg.Command}
221+ }
222+ fallback := floatingCommandFallback(cfg.Command, d.shell)
223+ go func() {
224+ pty, openErr := d.ptys.Open(command, args, env, cwd, sz)
225+ if openErr != nil {
226+ d.failFloatingLaunch(sess, tb, generation, userOpen, openErr)
227+ return
228+ }
229+ p := newPaneWithStableID(layout.PaneID("floating"), paneStableID, pty, sz)
230+ p.title.displayFallback = fallback
231+ if tabCtx == nil {
232+ tabCtx = sess.ctx
233+ }
234+ if tabCtx == nil {
235+ tabCtx = context.Background()
236+ }
237+ p.ctx, p.cancel = context.WithCancel(tabCtx)
238+ d.installFloating(sess, tb, p, generation)
239+ }()
240+}
241+
242+func (d *Daemon) failFloatingLaunch(sess *session, tb *tab, generation uint64, userOpen bool, err error) {
243+ tb.mu.Lock()
244+ current := tb.failFloatingLocked(generation)
245+ tb.mu.Unlock()
246+ if current {
247+ kind := "background-prewarm"
248+ if userOpen {
249+ kind = "user-open"
250+ }
251+ d.log.Warn("floating pty spawn failed", "err", err, "session", sess.name, "kind", kind)
252+ }
253+}
254+
255+// installFloating installs only the current generation. A stale successful
256+// launch is cancelled and closed outside the tab lock.
257+func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uint64) {
258+ tb.mu.Lock()
259+ installed := tb.installFloatingLocked(p, generation)
260+ visible := installed && tb.floating.state == floatingVisible
261+ tb.mu.Unlock()
262+ if !installed {
263+ if p.cancel != nil {
264+ p.cancel()
265+ }
266+ _ = p.pty.Close()
267+ return
268+ }
269+ p.onExit = func() { d.reapFloating(sess, tb, p, generation) }
270+ d.startPaneGoroutines(sess, tb, p)
271+ if visible {
272+ sess.mu.Lock()
273+ ac := sess.client
274+ sess.mu.Unlock()
275+ if ac != nil {
276+ d.paint(sess, ac, true)
277+ }
278+ }
279+}
280+
281+// reapFloating ignores old readers and restores the background only when the
282+// exiting popup was visible.
283+func (d *Daemon) reapFloating(sess *session, tb *tab, p *pane, generation uint64) {
284+ tb.mu.Lock()
285+ visible := tb.floating.state == floatingVisible && tb.floating.pane == p && tb.floating.generation == generation
286+ cleared := tb.clearFloatingLocked(p, generation)
287+ tb.mu.Unlock()
288+ if !cleared {
289+ return
290+ }
291+ if p.cancel != nil {
292+ p.cancel()
293+ }
294+ if visible {
295+ sess.mu.Lock()
296+ ac := sess.client
297+ sess.mu.Unlock()
298+ if ac != nil {
299+ d.paint(sess, ac, true)
300+ }
301+ }
302+}
303+
304+// teardownFloating invalidates first, then releases resources outside tb.mu.
305+func (d *Daemon) teardownFloating(tb *tab) {
306+ if tb == nil {
307+ return
308+ }
309+ tb.mu.Lock()
310+ p := tb.floating.pane
311+ tb.floating.generation++
312+ tb.floating.state = floatingUninitialized
313+ tb.floating.pane = nil
314+ tb.floating.desiredVisible = false
315+ tb.floating.launch = domain.FloatingConfig{}
316+ tb.mu.Unlock()
317+ if p != nil {
318+ if p.cancel != nil {
319+ p.cancel()
320+ }
321+ if p.pty != nil {
322+ _ = p.pty.Close()
323+ }
324+ }
325+}
326+
327+func floatingCommandFallback(command, shell string) string {
328+ if fields := strings.Fields(command); len(fields) > 0 {
329+ return filepath.Base(fields[0])
330+ }
331+ return filepath.Base(shell)
332+}
@@ -1,6 +1,14 @@
1 package daemon1 package daemon
2 2
3-import "github.com/bnema/vev/internal/domain"3+import (
4+ "context"
5+ "fmt"
6+ "path/filepath"
7+ "strings"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/usecase/layout"
11+)
4 12
5 // floatingState describes the lifecycle of a tab's independent floating pane.13 // floatingState describes the lifecycle of a tab's independent floating pane.
6 // A floating slot is always guarded by its owning tab.mu.14 // A floating slot is always guarded by its owning tab.mu.
@@ -132,3 +140,193 @@ func floatingInnerAxis(available, percent int) int {
132 }140 }
133 return bounds141 return bounds
134 }142 }
143+
144+// ensureFloatingWarm starts the background prewarm exactly once for a tab.
145+func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) {
146+ if d == nil || sess == nil || tb == nil || d.ptys == nil {
147+ return
148+ }
149+ d.startFloating(sess, tb, false)
150+}
151+
152+// toggleFloating changes only this tab's slot. Opening an uninitialized slot
153+// launches asynchronously; installed slots are retained when hidden.
154+func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error {
155+ if d == nil || sess == nil {
156+ return fmt.Errorf("floating pane: session required")
157+ }
158+ tb := sess.activeTab()
159+ if tb == nil {
160+ return layout.ErrNotFound
161+ }
162+ cfg := d.currentFloatingConfig()
163+ tb.mu.Lock()
164+ start, generation := tb.toggleFloatingLocked(cfg)
165+ visible := tb.floating.desiredVisible
166+ tb.mu.Unlock()
167+ if start {
168+ d.launchFloating(sess, tb, cfg, generation, visible)
169+ return nil
170+ }
171+ if ac != nil {
172+ d.paint(sess, ac, true)
173+ }
174+ return nil
175+}
176+
177+// startFloating starts a prewarm if its slot remains uninitialized.
178+func (d *Daemon) startFloating(sess *session, tb *tab, visible bool) {
179+ cfg := d.currentFloatingConfig()
180+ tb.mu.Lock()
181+ generation := tb.beginFloatingWarmLocked(cfg, visible)
182+ tb.mu.Unlock()
183+ if generation != 0 {
184+ d.launchFloating(sess, tb, cfg, generation, visible)
185+ }
186+}
187+
188+// launchFloating snapshots all launch inputs before starting the potentially
189+// blocking factory call. In particular, no PTY operation occurs while tb.mu is
190+// held, and config reloads cannot affect this launch.
191+func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfig, generation uint64, userOpen bool) {
192+ sess.mu.Lock()
193+ name, cwd, term := sess.name, sess.cwd, sess.terminal
194+ sess.mu.Unlock()
195+ tb.mu.Lock()
196+ sz := floatingInnerSize(tb.size, cfg)
197+ focused := tb.focusedPane()
198+ tabStableID := tb.stableID
199+ tabCtx := tb.ctx
200+ tb.mu.Unlock()
201+ if !sz.Valid() {
202+ sz = domain.Size{Cols: 1, Rows: 1}
203+ }
204+ if focused != nil && d.procCwd != nil && focused.pty != nil {
205+ if live, err := d.procCwd(focused.pty.Pid()); err == nil && live != "" {
206+ cwd = live
207+ }
208+ }
209+ if cwd == "" && d.dirOrHome != nil {
210+ cwd = d.dirOrHome("")
211+ }
212+ paneStableID, err := newStableID("p")
213+ if err != nil {
214+ d.failFloatingLaunch(sess, tb, generation, userOpen, err)
215+ return
216+ }
217+ env := d.childEnv(name, tabStableID, paneStableID, term)
218+ command, args := d.shell, append([]string(nil), d.shellArgs...)
219+ if cfg.Command != "" {
220+ args = []string{"-lc", cfg.Command}
221+ }
222+ fallback := floatingCommandFallback(cfg.Command, d.shell)
223+ go func() {
224+ pty, openErr := d.ptys.Open(command, args, env, cwd, sz)
225+ if openErr != nil {
226+ d.failFloatingLaunch(sess, tb, generation, userOpen, openErr)
227+ return
228+ }
229+ p := newPaneWithStableID(layout.PaneID("floating"), paneStableID, pty, sz)
230+ p.title.displayFallback = fallback
231+ if tabCtx == nil {
232+ tabCtx = sess.ctx
233+ }
234+ if tabCtx == nil {
235+ tabCtx = context.Background()
236+ }
237+ p.ctx, p.cancel = context.WithCancel(tabCtx)
238+ d.installFloating(sess, tb, p, generation)
239+ }()
240+}
241+
242+func (d *Daemon) failFloatingLaunch(sess *session, tb *tab, generation uint64, userOpen bool, err error) {
243+ tb.mu.Lock()
244+ current := tb.failFloatingLocked(generation)
245+ tb.mu.Unlock()
246+ if current {
247+ kind := "background-prewarm"
248+ if userOpen {
249+ kind = "user-open"
250+ }
251+ d.log.Warn("floating pty spawn failed", "err", err, "session", sess.name, "kind", kind)
252+ }
253+}
254+
255+// installFloating installs only the current generation. A stale successful
256+// launch is cancelled and closed outside the tab lock.
257+func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uint64) {
258+ tb.mu.Lock()
259+ installed := tb.installFloatingLocked(p, generation)
260+ visible := installed && tb.floating.state == floatingVisible
261+ tb.mu.Unlock()
262+ if !installed {
263+ if p.cancel != nil {
264+ p.cancel()
265+ }
266+ _ = p.pty.Close()
267+ return
268+ }
269+ p.onExit = func() { d.reapFloating(sess, tb, p, generation) }
270+ d.startPaneGoroutines(sess, tb, p)
271+ if visible {
272+ sess.mu.Lock()
273+ ac := sess.client
274+ sess.mu.Unlock()
275+ if ac != nil {
276+ d.paint(sess, ac, true)
277+ }
278+ }
279+}
280+
281+// reapFloating ignores old readers and restores the background only when the
282+// exiting popup was visible.
283+func (d *Daemon) reapFloating(sess *session, tb *tab, p *pane, generation uint64) {
284+ tb.mu.Lock()
285+ visible := tb.floating.state == floatingVisible && tb.floating.pane == p && tb.floating.generation == generation
286+ cleared := tb.clearFloatingLocked(p, generation)
287+ tb.mu.Unlock()
288+ if !cleared {
289+ return
290+ }
291+ if p.cancel != nil {
292+ p.cancel()
293+ }
294+ if visible {
295+ sess.mu.Lock()
296+ ac := sess.client
297+ sess.mu.Unlock()
298+ if ac != nil {
299+ d.paint(sess, ac, true)
300+ }
301+ }
302+}
303+
304+// teardownFloating invalidates first, then releases resources outside tb.mu.
305+func (d *Daemon) teardownFloating(tb *tab) {
306+ if tb == nil {
307+ return
308+ }
309+ tb.mu.Lock()
310+ p := tb.floating.pane
311+ tb.floating.generation++
312+ tb.floating.state = floatingUninitialized
313+ tb.floating.pane = nil
314+ tb.floating.desiredVisible = false
315+ tb.floating.launch = domain.FloatingConfig{}
316+ tb.mu.Unlock()
317+ if p != nil {
318+ if p.cancel != nil {
319+ p.cancel()
320+ }
321+ if p.pty != nil {
322+ _ = p.pty.Close()
323+ }
324+ }
325+}
326+
327+func floatingCommandFallback(command, shell string) string {
328+ if fields := strings.Fields(command); len(fields) > 0 {
329+ return filepath.Base(fields[0])
330+ }
331+ return filepath.Base(shell)
332+}
M internal/usecase/daemon/pane.go +3 -0
@@ -29,6 +29,9 @@ type pane struct {
2929 title paneTitleState
3030 ctx context.Context
3131 cancel context.CancelFunc
32+ // onExit is set before the reader starts and never changed. Floating panes
33+ // use it to reap their independent slot without touching the layout tree.
34+ onExit func()
3235 }
3336
3437 func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {
@@ -29,6 +29,9 @@ type pane struct {
29 title paneTitleState29 title paneTitleState
30 ctx context.Context30 ctx context.Context
31 cancel context.CancelFunc31 cancel context.CancelFunc
32+ // onExit is set before the reader starts and never changed. Floating panes
33+ // use it to reap their independent slot without touching the layout tree.
34+ onExit func()
32 }35 }
33 36
34 func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {37 func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {
M internal/usecase/daemon/render.go +5 -1
@@ -109,7 +109,11 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
109109 signal(p.dirty)
110110 }
111111 if err != nil {
112- d.reapPane(sess, tb, p)
112+ if p.onExit != nil {
113+ p.onExit()
114+ } else {
115+ d.reapPane(sess, tb, p)
116+ }
113117 return
114118 }
115119 }
@@ -109,7 +109,11 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
109 signal(p.dirty)109 signal(p.dirty)
110 }110 }
111 if err != nil {111 if err != nil {
112- d.reapPane(sess, tb, p)112+ if p.onExit != nil {
113+ p.onExit()
114+ } else {
115+ d.reapPane(sess, tb, p)
116+ }
113 return117 return
114 }118 }
115 }119 }
M internal/usecase/daemon/session.go +11 -0
@@ -373,7 +373,18 @@ func (tb *tab) panesSnapshot() []*pane {
373373 func (tb *tab) closeAllPanes() {
374374 tb.mu.Lock()
375375 panes := tb.panesSnapshot()
376+ floating := tb.floating.pane
377+ // Invalidate before cancellation so a concurrently unblocked floating
378+ // reader cannot reap a subsequently reused slot.
379+ tb.floating.generation++
380+ tb.floating.state = floatingUninitialized
381+ tb.floating.pane = nil
382+ tb.floating.desiredVisible = false
383+ tb.floating.launch = domain.FloatingConfig{}
376384 tb.mu.Unlock()
385+ if floating != nil {
386+ panes = append(panes, floating)
387+ }
377388 for _, p := range panes {
378389 if p.cancel != nil {
379390 p.cancel()
@@ -373,7 +373,18 @@ func (tb *tab) panesSnapshot() []*pane {
373 func (tb *tab) closeAllPanes() {373 func (tb *tab) closeAllPanes() {
374 tb.mu.Lock()374 tb.mu.Lock()
375 panes := tb.panesSnapshot()375 panes := tb.panesSnapshot()
376+ floating := tb.floating.pane
377+ // Invalidate before cancellation so a concurrently unblocked floating
378+ // reader cannot reap a subsequently reused slot.
379+ tb.floating.generation++
380+ tb.floating.state = floatingUninitialized
381+ tb.floating.pane = nil
382+ tb.floating.desiredVisible = false
383+ tb.floating.launch = domain.FloatingConfig{}
376 tb.mu.Unlock()384 tb.mu.Unlock()
385+ if floating != nil {
386+ panes = append(panes, floating)
387+ }
377 for _, p := range panes {388 for _, p := range panes {
378 if p.cancel != nil {389 if p.cancel != nil {
379 p.cancel()390 p.cancel()

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