detached 884ab90

feat(daemon): emit stable vev pane identity

commit
884ab90b5ce0dd25442f0afd307cf004ed9f5008
author
brice · · 2026-07-06 07:40 UTC
parent
5ebf319
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +84 -11
A internal/usecase/daemon/identity.go +26 -0
@@ -0,0 +1,26 @@
1+package daemon
2+
3+import (
4+ "crypto/rand"
5+ "encoding/base32"
6+ "io"
7+ "strings"
8+)
9+
10+var stableIDRand io.Reader = rand.Reader
11+
12+func newStableID() (string, error) {
13+ var b [16]byte
14+ if _, err := io.ReadFull(stableIDRand, b[:]); err != nil {
15+ return "", err
16+ }
17+ return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b[:])), nil
18+}
19+
20+func mustTestStableID(prefix string) string {
21+ id, err := newStableID()
22+ if err != nil {
23+ return prefix + "-unknown"
24+ }
25+ return id
26+}
@@ -0,0 +1,26 @@
1+package daemon
2+
3+import (
4+ "crypto/rand"
5+ "encoding/base32"
6+ "io"
7+ "strings"
8+)
9+
10+var stableIDRand io.Reader = rand.Reader
11+
12+func newStableID() (string, error) {
13+ var b [16]byte
14+ if _, err := io.ReadFull(stableIDRand, b[:]); err != nil {
15+ return "", err
16+ }
17+ return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b[:])), nil
18+}
19+
20+func mustTestStableID(prefix string) string {
21+ id, err := newStableID()
22+ if err != nil {
23+ return prefix + "-unknown"
24+ }
25+ return id
26+}
M internal/usecase/daemon/pane.go +6 -0
@@ -18,6 +18,7 @@ import (
1818 // The PTY reader takes only pane.mu, so child output never waits on client IO.
1919 type pane struct {
2020 id layout.PaneID
21+ stableID string
2122 pty ports.PTY
2223 mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title
2324 screen *vt.Screen
@@ -34,11 +35,16 @@ type pane struct {
3435 }
3536
3637 func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {
38+ return newPaneWithStableID(id, mustTestStableID("pane"), pty, sz)
39+}
40+
41+func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {
3742 sb := scopy.NewScrollback(defaultScrollbackRows)
3843 screen := vt.NewScreen(sz.Cols, sz.Rows)
3944 screen.OnLineEvicted = sb.Append
4045 return &pane{
4146 id: id,
47+ stableID: stableID,
4248 pty: pty,
4349 screen: screen,
4450 scrollback: sb,
@@ -18,6 +18,7 @@ import (
18 // The PTY reader takes only pane.mu, so child output never waits on client IO.18 // The PTY reader takes only pane.mu, so child output never waits on client IO.
19 type pane struct {19 type pane struct {
20 id layout.PaneID20 id layout.PaneID
21+ stableID string
21 pty ports.PTY22 pty ports.PTY
22 mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title23 mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title
23 screen *vt.Screen24 screen *vt.Screen
@@ -34,11 +35,16 @@ type pane struct {
34 }35 }
35 36
36 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 {
38+ return newPaneWithStableID(id, mustTestStableID("pane"), pty, sz)
39+}
40+
41+func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {
37 sb := scopy.NewScrollback(defaultScrollbackRows)42 sb := scopy.NewScrollback(defaultScrollbackRows)
38 screen := vt.NewScreen(sz.Cols, sz.Rows)43 screen := vt.NewScreen(sz.Cols, sz.Rows)
39 screen.OnLineEvicted = sb.Append44 screen.OnLineEvicted = sb.Append
40 return &pane{45 return &pane{
41 id: id,46 id: id,
47+ stableID: stableID,
42 pty: pty,48 pty: pty,
43 screen: screen,49 screen: screen,
44 scrollback: sb,50 scrollback: sb,
M internal/usecase/daemon/pane_actions.go +11 -2
@@ -57,9 +57,18 @@ func (d *Daemon) spawnPaneOp(
5757 return layout.ErrTooSmall
5858 }
5959 newRect := placementContent(placements, newID)
60+ tabStableID := tb.stableID
6061 tb.mu.Unlock()
6162
62- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, rectSize(newRect))
63+ paneStableID, err := newStableID()
64+ if err != nil {
65+ tb.mu.Lock()
66+ _ = tb.tree.Close(newID)
67+ tb.tree.Focus = oldFocus
68+ tb.mu.Unlock()
69+ return fmt.Errorf("daemon: generating pane identity: %w", err)
70+ }
71+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, rectSize(newRect))
6372 if err != nil {
6473 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")
6574 tb.mu.Lock()
@@ -70,7 +79,7 @@ func (d *Daemon) spawnPaneOp(
7079 }
7180
7281 pctx, cancel := context.WithCancel(tb.ctx)
73- p := newPane(newID, pty, rectSize(newRect))
82+ p := newPaneWithStableID(newID, paneStableID, pty, rectSize(newRect))
7483 p.ctx, p.cancel = pctx, cancel
7584 p.rect = newRect
7685
@@ -57,9 +57,18 @@ func (d *Daemon) spawnPaneOp(
57 return layout.ErrTooSmall57 return layout.ErrTooSmall
58 }58 }
59 newRect := placementContent(placements, newID)59 newRect := placementContent(placements, newID)
60+ tabStableID := tb.stableID
60 tb.mu.Unlock()61 tb.mu.Unlock()
61 62
62- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, rectSize(newRect))63+ paneStableID, err := newStableID()
64+ if err != nil {
65+ tb.mu.Lock()
66+ _ = tb.tree.Close(newID)
67+ tb.tree.Focus = oldFocus
68+ tb.mu.Unlock()
69+ return fmt.Errorf("daemon: generating pane identity: %w", err)
70+ }
71+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, rectSize(newRect))
63 if err != nil {72 if err != nil {
64 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")73 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")
65 tb.mu.Lock()74 tb.mu.Lock()
@@ -70,7 +79,7 @@ func (d *Daemon) spawnPaneOp(
70 }79 }
71 80
72 pctx, cancel := context.WithCancel(tb.ctx)81 pctx, cancel := context.WithCancel(tb.ctx)
73- p := newPane(newID, pty, rectSize(newRect))82+ p := newPaneWithStableID(newID, paneStableID, pty, rectSize(newRect))
74 p.ctx, p.cancel = pctx, cancel83 p.ctx, p.cancel = pctx, cancel
75 p.rect = newRect84 p.rect = newRect
76 85
M internal/usecase/daemon/session.go +28 -6
@@ -70,6 +70,7 @@ type session struct {
7070 type tab struct {
7171 mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership
7272
73+ stableID string
7374 tree *layout.Tree
7475 panes map[layout.PaneID]*pane
7576 nextPaneID int
@@ -122,7 +123,15 @@ func (d *Daemon) touchMRU(sess *session) {
122123
123124 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) {
124125 tbSize := tabSize(sz)
125- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)
126+ tabStableID, err := newStableID()
127+ if err != nil {
128+ return nil, fmt.Errorf("daemon: generating tab identity: %w", err)
129+ }
130+ paneStableID, err := newStableID()
131+ if err != nil {
132+ return nil, fmt.Errorf("daemon: generating pane identity: %w", err)
133+ }
134+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, tbSize)
126135 if err != nil {
127136 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
128137 return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
@@ -234,12 +243,20 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
234243 cwd := sess.cwd
235244 client := sess.client
236245 sess.mu.Unlock()
237- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)
246+ tabStableID, err := newStableID()
247+ if err != nil {
248+ return fmt.Errorf("daemon: generating tab identity: %w", err)
249+ }
250+ paneStableID, err := newStableID()
251+ if err != nil {
252+ return fmt.Errorf("daemon: generating pane identity: %w", err)
253+ }
254+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, tbSize)
238255 if err != nil {
239256 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")
240257 return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)
241258 }
242- tb := newTab(pty, tbSize)
259+ tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
243260 if client != nil {
244261 t := d.effectiveTheme(client.getClientTheme())
245262 tb.mu.Lock()
@@ -274,9 +291,14 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
274291 }
275292
276293 func newTab(pty ports.PTY, sz domain.Size) *tab {
294+ return newTabWithStableID(mustTestStableID("tab"), mustTestStableID("pane"), pty, sz)
295+}
296+
297+func newTabWithStableID(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size) *tab {
277298 id := layout.PaneID("pane-1")
278- p := newPane(id, pty, sz)
299+ p := newPaneWithStableID(id, paneStableID, pty, sz)
279300 return &tab{
301+ stableID: tabStableID,
280302 tree: layout.NewTree(id),
281303 panes: map[layout.PaneID]*pane{id: p},
282304 nextPaneID: 2,
@@ -707,7 +729,7 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
707729
708730 // childEnv builds the session child's environment: the daemon's own, with TERM
709731 // and VEV forced to well-known values.
710-func (d *Daemon) childEnv(name string) []string {
732+func (d *Daemon) childEnv(name, tabStableID, paneStableID string) []string {
711733 out := make([]string, 0, len(d.baseEnv)+2)
712734 for _, e := range d.baseEnv {
713735 if strings.HasPrefix(e, "TERM=") || strings.HasPrefix(e, "VEV=") {
@@ -715,5 +737,5 @@ func (d *Daemon) childEnv(name string) []string {
715737 }
716738 out = append(out, e)
717739 }
718- return append(out, "TERM=xterm-256color", "VEV="+name)
740+ return append(out, "TERM=xterm-direct", "VEV=session="+name+",tab="+tabStableID+",pane="+paneStableID)
719741 }
@@ -70,6 +70,7 @@ type session struct {
70 type tab struct {70 type tab struct {
71 mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership71 mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership
72 72
73+ stableID string
73 tree *layout.Tree74 tree *layout.Tree
74 panes map[layout.PaneID]*pane75 panes map[layout.PaneID]*pane
75 nextPaneID int76 nextPaneID int
@@ -122,7 +123,15 @@ func (d *Daemon) touchMRU(sess *session) {
122 123
123 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) {124 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) {
124 tbSize := tabSize(sz)125 tbSize := tabSize(sz)
125- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)126+ tabStableID, err := newStableID()
127+ if err != nil {
128+ return nil, fmt.Errorf("daemon: generating tab identity: %w", err)
129+ }
130+ paneStableID, err := newStableID()
131+ if err != nil {
132+ return nil, fmt.Errorf("daemon: generating pane identity: %w", err)
133+ }
134+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, tbSize)
126 if err != nil {135 if err != nil {
127 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")136 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
128 return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)137 return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
@@ -234,12 +243,20 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
234 cwd := sess.cwd243 cwd := sess.cwd
235 client := sess.client244 client := sess.client
236 sess.mu.Unlock()245 sess.mu.Unlock()
237- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)246+ tabStableID, err := newStableID()
247+ if err != nil {
248+ return fmt.Errorf("daemon: generating tab identity: %w", err)
249+ }
250+ paneStableID, err := newStableID()
251+ if err != nil {
252+ return fmt.Errorf("daemon: generating pane identity: %w", err)
253+ }
254+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, tbSize)
238 if err != nil {255 if err != nil {
239 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")256 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")
240 return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)257 return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)
241 }258 }
242- tb := newTab(pty, tbSize)259+ tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
243 if client != nil {260 if client != nil {
244 t := d.effectiveTheme(client.getClientTheme())261 t := d.effectiveTheme(client.getClientTheme())
245 tb.mu.Lock()262 tb.mu.Lock()
@@ -274,9 +291,14 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
274 }291 }
275 292
276 func newTab(pty ports.PTY, sz domain.Size) *tab {293 func newTab(pty ports.PTY, sz domain.Size) *tab {
294+ return newTabWithStableID(mustTestStableID("tab"), mustTestStableID("pane"), pty, sz)
295+}
296+
297+func newTabWithStableID(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size) *tab {
277 id := layout.PaneID("pane-1")298 id := layout.PaneID("pane-1")
278- p := newPane(id, pty, sz)299+ p := newPaneWithStableID(id, paneStableID, pty, sz)
279 return &tab{300 return &tab{
301+ stableID: tabStableID,
280 tree: layout.NewTree(id),302 tree: layout.NewTree(id),
281 panes: map[layout.PaneID]*pane{id: p},303 panes: map[layout.PaneID]*pane{id: p},
282 nextPaneID: 2,304 nextPaneID: 2,
@@ -707,7 +729,7 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
707 729
708 // childEnv builds the session child's environment: the daemon's own, with TERM730 // childEnv builds the session child's environment: the daemon's own, with TERM
709 // and VEV forced to well-known values.731 // and VEV forced to well-known values.
710-func (d *Daemon) childEnv(name string) []string {732+func (d *Daemon) childEnv(name, tabStableID, paneStableID string) []string {
711 out := make([]string, 0, len(d.baseEnv)+2)733 out := make([]string, 0, len(d.baseEnv)+2)
712 for _, e := range d.baseEnv {734 for _, e := range d.baseEnv {
713 if strings.HasPrefix(e, "TERM=") || strings.HasPrefix(e, "VEV=") {735 if strings.HasPrefix(e, "TERM=") || strings.HasPrefix(e, "VEV=") {
@@ -715,5 +737,5 @@ func (d *Daemon) childEnv(name string) []string {
715 }737 }
716 out = append(out, e)738 out = append(out, e)
717 }739 }
718- return append(out, "TERM=xterm-256color", "VEV="+name)740+ return append(out, "TERM=xterm-direct", "VEV=session="+name+",tab="+tabStableID+",pane="+paneStableID)
719 }741 }
M internal/usecase/daemon/snapshot.go +13 -3
@@ -92,7 +92,12 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
9292 for _, pl := range placements {
9393 placementByPane[pl.ID] = pl.Content
9494 }
95- tb := &tab{tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize}
95+ tabStableID, err := newStableID()
96+ if err != nil {
97+ closeOpened()
98+ return fmt.Errorf("snapshot: generating tab identity: %w", err)
99+ }
100+ tb := &tab{stableID: tabStableID, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize}
96101 if tb.nextPaneID <= 0 {
97102 tb.nextPaneID = 1
98103 }
@@ -109,12 +114,17 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
109114 return fmt.Errorf("snapshot: missing pane placement")
110115 }
111116 contentSize := restorePTYSize(contentRect, tbSize)
112- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name), paneSnap.Cwd, contentSize)
117+ paneStableID, err := newStableID()
118+ if err != nil {
119+ closeOpened()
120+ return fmt.Errorf("snapshot: generating pane identity: %w", err)
121+ }
122+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID), paneSnap.Cwd, contentSize)
113123 if err != nil {
114124 closeOpened()
115125 return err
116126 }
117- p := newPane(paneSnap.ID, pty, contentSize)
127+ p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize)
118128 p.ctx, p.cancel = context.WithCancel(tb.ctx)
119129 p.rect = contentRect
120130 seedPaneRows(p, paneSnap.Scrollback, paneSnap.Visible)
@@ -92,7 +92,12 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
92 for _, pl := range placements {92 for _, pl := range placements {
93 placementByPane[pl.ID] = pl.Content93 placementByPane[pl.ID] = pl.Content
94 }94 }
95- tb := &tab{tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize}95+ tabStableID, err := newStableID()
96+ if err != nil {
97+ closeOpened()
98+ return fmt.Errorf("snapshot: generating tab identity: %w", err)
99+ }
100+ tb := &tab{stableID: tabStableID, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize}
96 if tb.nextPaneID <= 0 {101 if tb.nextPaneID <= 0 {
97 tb.nextPaneID = 1102 tb.nextPaneID = 1
98 }103 }
@@ -109,12 +114,17 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
109 return fmt.Errorf("snapshot: missing pane placement")114 return fmt.Errorf("snapshot: missing pane placement")
110 }115 }
111 contentSize := restorePTYSize(contentRect, tbSize)116 contentSize := restorePTYSize(contentRect, tbSize)
112- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name), paneSnap.Cwd, contentSize)117+ paneStableID, err := newStableID()
118+ if err != nil {
119+ closeOpened()
120+ return fmt.Errorf("snapshot: generating pane identity: %w", err)
121+ }
122+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID), paneSnap.Cwd, contentSize)
113 if err != nil {123 if err != nil {
114 closeOpened()124 closeOpened()
115 return err125 return err
116 }126 }
117- p := newPane(paneSnap.ID, pty, contentSize)127+ p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize)
118 p.ctx, p.cancel = context.WithCancel(tb.ctx)128 p.ctx, p.cancel = context.WithCancel(tb.ctx)
119 p.rect = contentRect129 p.rect = contentRect
120 seedPaneRows(p, paneSnap.Scrollback, paneSnap.Visible)130 seedPaneRows(p, paneSnap.Scrollback, paneSnap.Visible)

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 2

Tags 13

↑↓ moveenter openesc close