detached 01179be

feat(snapshot): persist restore identity metadata

commit
01179be933ad0d5180763e0dc64a585a3cca99da
author
brice · · 2026-07-06 07:47 UTC
parent
5d3577f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +203 -26
M internal/usecase/daemon/snapshot.go +18 -8
@@ -92,10 +92,14 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
9292 for _, pl := range placements {
9393 placementByPane[pl.ID] = pl.Content
9494 }
95- tabStableID, err := newStableID("t")
96- if err != nil {
97- closeOpened()
98- return fmt.Errorf("snapshot: generating tab identity: %w", err)
95+ tabStableID := tabSnap.StableID
96+ if tabStableID == "" {
97+ var err error
98+ tabStableID, err = newStableID("t")
99+ if err != nil {
100+ closeOpened()
101+ return fmt.Errorf("snapshot: generating tab identity: %w", err)
102+ }
99103 }
100104 tb := &tab{stableID: tabStableID, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize}
101105 if tb.nextPaneID <= 0 {
@@ -114,10 +118,14 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
114118 return fmt.Errorf("snapshot: missing pane placement")
115119 }
116120 contentSize := restorePTYSize(contentRect, tbSize)
117- paneStableID, err := newStableID("p")
118- if err != nil {
119- closeOpened()
120- return fmt.Errorf("snapshot: generating pane identity: %w", err)
121+ paneStableID := paneSnap.StableID
122+ if paneStableID == "" {
123+ var err error
124+ paneStableID, err = newStableID("p")
125+ if err != nil {
126+ closeOpened()
127+ return fmt.Errorf("snapshot: generating pane identity: %w", err)
128+ }
121129 }
122130 pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID), paneSnap.Cwd, contentSize)
123131 if err != nil {
@@ -245,6 +253,7 @@ func (d *Daemon) captureSession(sess *session) bool {
245253 for _, tb := range tabs {
246254 tb.mu.Lock()
247255 tabSnap := snapcodec.Tab{
256+ StableID: tb.stableID,
248257 Cols: uint16(max(tb.size.Cols, 0)),
249258 Rows: uint16(max(tb.size.Rows, 0)),
250259 NextPaneID: uint64(max(tb.nextPaneID, 0)),
@@ -269,6 +278,7 @@ func (d *Daemon) captureSession(sess *session) bool {
269278 }
270279 paneSnap := snapcodec.Pane{
271280 ID: p.id,
281+ StableID: p.stableID,
272282 Scrollback: cloneRows(p.scrollback.Snapshot()),
273283 Visible: cloneRows(p.screen.PrimaryVisibleRows()),
274284 }
@@ -92,10 +92,14 @@ 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- tabStableID, err := newStableID("t")95+ tabStableID := tabSnap.StableID
96- if err != nil {96+ if tabStableID == "" {
97- closeOpened()97+ var err error
98- return fmt.Errorf("snapshot: generating tab identity: %w", err)98+ tabStableID, err = newStableID("t")
99+ if err != nil {
100+ closeOpened()
101+ return fmt.Errorf("snapshot: generating tab identity: %w", err)
102+ }
99 }103 }
100 tb := &tab{stableID: tabStableID, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize}104 tb := &tab{stableID: tabStableID, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize}
101 if tb.nextPaneID <= 0 {105 if tb.nextPaneID <= 0 {
@@ -114,10 +118,14 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
114 return fmt.Errorf("snapshot: missing pane placement")118 return fmt.Errorf("snapshot: missing pane placement")
115 }119 }
116 contentSize := restorePTYSize(contentRect, tbSize)120 contentSize := restorePTYSize(contentRect, tbSize)
117- paneStableID, err := newStableID("p")121+ paneStableID := paneSnap.StableID
118- if err != nil {122+ if paneStableID == "" {
119- closeOpened()123+ var err error
120- return fmt.Errorf("snapshot: generating pane identity: %w", err)124+ paneStableID, err = newStableID("p")
125+ if err != nil {
126+ closeOpened()
127+ return fmt.Errorf("snapshot: generating pane identity: %w", err)
128+ }
121 }129 }
122 pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID), paneSnap.Cwd, contentSize)130 pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID), paneSnap.Cwd, contentSize)
123 if err != nil {131 if err != nil {
@@ -245,6 +253,7 @@ func (d *Daemon) captureSession(sess *session) bool {
245 for _, tb := range tabs {253 for _, tb := range tabs {
246 tb.mu.Lock()254 tb.mu.Lock()
247 tabSnap := snapcodec.Tab{255 tabSnap := snapcodec.Tab{
256+ StableID: tb.stableID,
248 Cols: uint16(max(tb.size.Cols, 0)),257 Cols: uint16(max(tb.size.Cols, 0)),
249 Rows: uint16(max(tb.size.Rows, 0)),258 Rows: uint16(max(tb.size.Rows, 0)),
250 NextPaneID: uint64(max(tb.nextPaneID, 0)),259 NextPaneID: uint64(max(tb.nextPaneID, 0)),
@@ -269,6 +278,7 @@ func (d *Daemon) captureSession(sess *session) bool {
269 }278 }
270 paneSnap := snapcodec.Pane{279 paneSnap := snapcodec.Pane{
271 ID: p.id,280 ID: p.id,
281+ StableID: p.stableID,
272 Scrollback: cloneRows(p.scrollback.Snapshot()),282 Scrollback: cloneRows(p.scrollback.Snapshot()),
273 Visible: cloneRows(p.screen.PrimaryVisibleRows()),283 Visible: cloneRows(p.screen.PrimaryVisibleRows()),
274 }284 }
M internal/usecase/daemon/snapshot_restore_test.go +10 -6
@@ -2,9 +2,9 @@ package daemon
22
33 import (
44 "context"
5+ "encoding/binary"
56 "io"
67 "math"
7- "strings"
88 "sync"
99 "testing"
1010 "time"
@@ -26,6 +26,7 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
2626 CreatedAt: 99,
2727 Active: 0,
2828 Tabs: []snapcodec.Tab{{
29+ StableID: "t_saved",
2930 Cols: 80,
3031 Rows: 24,
3132 NextPaneID: 3,
@@ -35,8 +36,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
3536 layout.NewLeaf("pane-2"),
3637 }}},
3738 Panes: []snapcodec.Pane{
38- {ID: "pane-1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}},
39- {ID: "pane-2", Cwd: "/two", Scrollback: [][]renderer.Cell{cells("old2")}, Visible: [][]renderer.Cell{cells("vis2")}},
39+ {ID: "pane-1", StableID: "p_saved_1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}},
40+ {ID: "pane-2", StableID: "p_saved_2", Cwd: "/two", Scrollback: [][]renderer.Cell{cells("old2")}, Visible: [][]renderer.Cell{cells("vis2")}},
4041 },
4142 }},
4243 })}}
@@ -83,9 +84,9 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
8384 pane1StableID := tb.panes["pane-1"].stableID
8485 pane2StableID := tb.panes["pane-2"].stableID
8586 tb.mu.Unlock()
86- require.True(t, strings.HasPrefix(tabStableID, "t_"), tabStableID)
87- require.True(t, strings.HasPrefix(pane1StableID, "p_"), pane1StableID)
88- require.True(t, strings.HasPrefix(pane2StableID, "p_"), pane2StableID)
87+ require.Equal(t, "t_saved", tabStableID)
88+ require.Equal(t, "p_saved_1", pane1StableID)
89+ require.Equal(t, "p_saved_2", pane2StableID)
8990 require.Contains(t, factory.opens[0].env, "TERM=xterm-direct")
9091 require.Contains(t, factory.opens[0].env, "VEV=session=work,tab="+tabStableID+",pane="+pane1StableID)
9192 require.Contains(t, factory.opens[1].env, "TERM=xterm-direct")
@@ -132,6 +133,8 @@ func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T)
132133
133134 func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) {
134135 valid := mustSnapshotBytes(t, snapcodec.Session{Name: "live", CreatedAt: 1, Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/ok"}}}}})
136+ badVersion := append([]byte(nil), valid...)
137+ binary.BigEndian.PutUint16(badVersion[4:6], 1)
135138 tests := []struct {
136139 name string
137140 blobs []ports.SnapshotBlob
@@ -140,6 +143,7 @@ func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) {
140143 }{
141144 {name: "empty store"},
142145 {name: "corrupt blob", blobs: []ports.SnapshotBlob{{Name: "bad", Data: []byte("not a snapshot")}}},
146+ {name: "old v1 blob", blobs: []ports.SnapshotBlob{{Name: "old", Data: badVersion}}},
143147 {name: "live name", blobs: []ports.SnapshotBlob{{Name: "live", Data: valid}}, liveName: "live"},
144148 }
145149 for _, tt := range tests {
@@ -2,9 +2,9 @@ package daemon
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/binary"
5 "io"6 "io"
6 "math"7 "math"
7- "strings"
8 "sync"8 "sync"
9 "testing"9 "testing"
10 "time"10 "time"
@@ -26,6 +26,7 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
26 CreatedAt: 99,26 CreatedAt: 99,
27 Active: 0,27 Active: 0,
28 Tabs: []snapcodec.Tab{{28 Tabs: []snapcodec.Tab{{
29+ StableID: "t_saved",
29 Cols: 80,30 Cols: 80,
30 Rows: 24,31 Rows: 24,
31 NextPaneID: 3,32 NextPaneID: 3,
@@ -35,8 +36,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
35 layout.NewLeaf("pane-2"),36 layout.NewLeaf("pane-2"),
36 }}},37 }}},
37 Panes: []snapcodec.Pane{38 Panes: []snapcodec.Pane{
38- {ID: "pane-1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}},39+ {ID: "pane-1", StableID: "p_saved_1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}},
39- {ID: "pane-2", Cwd: "/two", Scrollback: [][]renderer.Cell{cells("old2")}, Visible: [][]renderer.Cell{cells("vis2")}},40+ {ID: "pane-2", StableID: "p_saved_2", Cwd: "/two", Scrollback: [][]renderer.Cell{cells("old2")}, Visible: [][]renderer.Cell{cells("vis2")}},
40 },41 },
41 }},42 }},
42 })}}43 })}}
@@ -83,9 +84,9 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
83 pane1StableID := tb.panes["pane-1"].stableID84 pane1StableID := tb.panes["pane-1"].stableID
84 pane2StableID := tb.panes["pane-2"].stableID85 pane2StableID := tb.panes["pane-2"].stableID
85 tb.mu.Unlock()86 tb.mu.Unlock()
86- require.True(t, strings.HasPrefix(tabStableID, "t_"), tabStableID)87+ require.Equal(t, "t_saved", tabStableID)
87- require.True(t, strings.HasPrefix(pane1StableID, "p_"), pane1StableID)88+ require.Equal(t, "p_saved_1", pane1StableID)
88- require.True(t, strings.HasPrefix(pane2StableID, "p_"), pane2StableID)89+ require.Equal(t, "p_saved_2", pane2StableID)
89 require.Contains(t, factory.opens[0].env, "TERM=xterm-direct")90 require.Contains(t, factory.opens[0].env, "TERM=xterm-direct")
90 require.Contains(t, factory.opens[0].env, "VEV=session=work,tab="+tabStableID+",pane="+pane1StableID)91 require.Contains(t, factory.opens[0].env, "VEV=session=work,tab="+tabStableID+",pane="+pane1StableID)
91 require.Contains(t, factory.opens[1].env, "TERM=xterm-direct")92 require.Contains(t, factory.opens[1].env, "TERM=xterm-direct")
@@ -132,6 +133,8 @@ func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T)
132 133
133 func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) {134 func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) {
134 valid := mustSnapshotBytes(t, snapcodec.Session{Name: "live", CreatedAt: 1, Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/ok"}}}}})135 valid := mustSnapshotBytes(t, snapcodec.Session{Name: "live", CreatedAt: 1, Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/ok"}}}}})
136+ badVersion := append([]byte(nil), valid...)
137+ binary.BigEndian.PutUint16(badVersion[4:6], 1)
135 tests := []struct {138 tests := []struct {
136 name string139 name string
137 blobs []ports.SnapshotBlob140 blobs []ports.SnapshotBlob
@@ -140,6 +143,7 @@ func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) {
140 }{143 }{
141 {name: "empty store"},144 {name: "empty store"},
142 {name: "corrupt blob", blobs: []ports.SnapshotBlob{{Name: "bad", Data: []byte("not a snapshot")}}},145 {name: "corrupt blob", blobs: []ports.SnapshotBlob{{Name: "bad", Data: []byte("not a snapshot")}}},
146+ {name: "old v1 blob", blobs: []ports.SnapshotBlob{{Name: "old", Data: badVersion}}},
143 {name: "live name", blobs: []ports.SnapshotBlob{{Name: "live", Data: valid}}, liveName: "live"},147 {name: "live name", blobs: []ports.SnapshotBlob{{Name: "live", Data: valid}}, liveName: "live"},
144 }148 }
145 for _, tt := range tests {149 for _, tt := range tests {
M internal/usecase/snapshot/codec.go +101 -6
@@ -27,7 +27,7 @@ var (
2727
2828 const (
2929 magic = "VEVS"
30- version = uint16(1)
30+ version = uint16(2)
3131 flagFlate = uint16(1 << 0)
3232 flateCutoff = 4 << 10
3333 maxDecodedBodySize = 256 << 20
@@ -36,7 +36,7 @@ const (
3636 maxDecodedCells = 16 << 20
3737 )
3838
39-// Marshal encodes a v1 durable snapshot.
39+// Marshal encodes a v2 durable snapshot.
4040 func Marshal(s Session) ([]byte, error) {
4141 var w payloadWriter
4242 if err := writeSession(&w, s); err != nil {
@@ -73,7 +73,7 @@ func Marshal(s Session) ([]byte, error) {
7373 return out, nil
7474 }
7575
76-// Unmarshal decodes a strict v1 durable snapshot.
76+// Unmarshal decodes a strict v2 durable snapshot.
7777 func Unmarshal(b []byte) (Session, error) {
7878 if len(b) < 16 {
7979 return Session{}, ErrShortPayload
@@ -166,6 +166,18 @@ func (w *payloadWriter) putString(s string) error {
166166 w.b = append(w.b, s...)
167167 return nil
168168 }
169+func (w *payloadWriter) putStrings(ss []string) error {
170+ if len(ss) > math.MaxUint16 {
171+ return fmt.Errorf("%w: too many strings", ErrInvalidData)
172+ }
173+ w.putUint16(uint16(len(ss)))
174+ for _, s := range ss {
175+ if err := w.putString(s); err != nil {
176+ return err
177+ }
178+ }
179+ return nil
180+}
169181
170182 type payloadReader struct{ b []byte }
171183
@@ -213,6 +225,21 @@ func (r *payloadReader) getString() (string, error) {
213225 r.b = r.b[n:]
214226 return s, nil
215227 }
228+func (r *payloadReader) getStrings() ([]string, error) {
229+ n, err := r.getUint16()
230+ if err != nil {
231+ return nil, err
232+ }
233+ out := make([]string, n)
234+ for i := range out {
235+ s, err := r.getString()
236+ if err != nil {
237+ return nil, err
238+ }
239+ out[i] = s
240+ }
241+ return out, nil
242+}
216243 func (r *payloadReader) done() error {
217244 if len(r.b) != 0 {
218245 return ErrTrailingBytes
@@ -267,6 +294,9 @@ func readSession(r *payloadReader) (Session, error) {
267294 }
268295
269296 func writeTab(w *payloadWriter, t Tab) error {
297+ if err := w.putString(t.StableID); err != nil {
298+ return err
299+ }
270300 w.putUint16(t.Cols)
271301 w.putUint16(t.Rows)
272302 w.putUint64(t.NextPaneID)
@@ -293,6 +323,10 @@ func writeTab(w *payloadWriter, t Tab) error {
293323 }
294324
295325 func readTab(r *payloadReader) (Tab, error) {
326+ stableID, err := r.getString()
327+ if err != nil {
328+ return Tab{}, err
329+ }
296330 cols, err := r.getUint16()
297331 if err != nil {
298332 return Tab{}, err
@@ -317,7 +351,7 @@ func readTab(r *payloadReader) (Tab, error) {
317351 if err != nil {
318352 return Tab{}, err
319353 }
320- t := Tab{Cols: cols, Rows: rows, NextPaneID: next, Focus: layout.PaneID(focus), Tree: &layout.Tree{Root: root, Focus: layout.PaneID(focus)}, Panes: make([]Pane, np)}
354+ t := Tab{StableID: stableID, Cols: cols, Rows: rows, NextPaneID: next, Focus: layout.PaneID(focus), Tree: &layout.Tree{Root: root, Focus: layout.PaneID(focus)}, Panes: make([]Pane, np)}
321355 for i := range t.Panes {
322356 p, err := readPane(r)
323357 if err != nil {
@@ -424,6 +458,9 @@ func writePane(w *payloadWriter, p Pane) error {
424458 if err := w.putString(string(p.ID)); err != nil {
425459 return err
426460 }
461+ if err := w.putString(p.StableID); err != nil {
462+ return err
463+ }
427464 if err := w.putString(p.Cwd); err != nil {
428465 return err
429466 }
@@ -442,7 +479,10 @@ func writePane(w *payloadWriter, p Pane) error {
442479 if err := writeRows(w, p.Scrollback, idx, false); err != nil {
443480 return err
444481 }
445- return writeRows(w, p.Visible, idx, true)
482+ if err := writeRows(w, p.Visible, idx, true); err != nil {
483+ return err
484+ }
485+ return writeProcess(w, p.Process)
446486 }
447487
448488 func readPane(r *payloadReader) (Pane, error) {
@@ -450,6 +490,10 @@ func readPane(r *payloadReader) (Pane, error) {
450490 if err != nil {
451491 return Pane{}, err
452492 }
493+ stableID, err := r.getString()
494+ if err != nil {
495+ return Pane{}, err
496+ }
453497 cwd, err := r.getString()
454498 if err != nil {
455499 return Pane{}, err
@@ -475,7 +519,58 @@ func readPane(r *payloadReader) (Pane, error) {
475519 if err != nil {
476520 return Pane{}, err
477521 }
478- return Pane{ID: layout.PaneID(id), Cwd: cwd, Scrollback: sb, Visible: vis}, nil
522+ proc, err := readProcess(r)
523+ if err != nil {
524+ return Pane{}, err
525+ }
526+ return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, Scrollback: sb, Visible: vis, Process: proc}, nil
527+}
528+
529+func writeProcess(w *payloadWriter, p *Process) error {
530+ if p == nil {
531+ w.putUint8(0)
532+ return nil
533+ }
534+ if len(p.Argv) == 0 {
535+ return fmt.Errorf("%w: process argv empty", ErrInvalidData)
536+ }
537+ w.putUint8(1)
538+ if err := w.putStrings(p.Argv); err != nil {
539+ return err
540+ }
541+ if err := w.putString(p.Strategy); err != nil {
542+ return err
543+ }
544+ return w.putString(p.Opts.AgentSessionID)
545+}
546+
547+func readProcess(r *payloadReader) (*Process, error) {
548+ present, err := r.getUint8()
549+ if err != nil {
550+ return nil, err
551+ }
552+ if present == 0 {
553+ return nil, nil
554+ }
555+ if present != 1 {
556+ return nil, fmt.Errorf("%w: process presence", ErrInvalidData)
557+ }
558+ argv, err := r.getStrings()
559+ if err != nil {
560+ return nil, err
561+ }
562+ if len(argv) == 0 {
563+ return nil, fmt.Errorf("%w: process argv empty", ErrInvalidData)
564+ }
565+ strategy, err := r.getString()
566+ if err != nil {
567+ return nil, err
568+ }
569+ agentSessionID, err := r.getString()
570+ if err != nil {
571+ return nil, err
572+ }
573+ return &Process{Argv: argv, Strategy: strategy, Opts: ProcessOpts{AgentSessionID: agentSessionID}}, nil
479574 }
480575
481576 func writeRows(w *payloadWriter, rows [][]renderer.Cell, idx map[renderer.Style]uint16, trimBlankRows bool) error {
@@ -27,7 +27,7 @@ var (
27 27
28 const (28 const (
29 magic = "VEVS"29 magic = "VEVS"
30- version = uint16(1)30+ version = uint16(2)
31 flagFlate = uint16(1 << 0)31 flagFlate = uint16(1 << 0)
32 flateCutoff = 4 << 1032 flateCutoff = 4 << 10
33 maxDecodedBodySize = 256 << 2033 maxDecodedBodySize = 256 << 20
@@ -36,7 +36,7 @@ const (
36 maxDecodedCells = 16 << 2036 maxDecodedCells = 16 << 20
37 )37 )
38 38
39-// Marshal encodes a v1 durable snapshot.39+// Marshal encodes a v2 durable snapshot.
40 func Marshal(s Session) ([]byte, error) {40 func Marshal(s Session) ([]byte, error) {
41 var w payloadWriter41 var w payloadWriter
42 if err := writeSession(&w, s); err != nil {42 if err := writeSession(&w, s); err != nil {
@@ -73,7 +73,7 @@ func Marshal(s Session) ([]byte, error) {
73 return out, nil73 return out, nil
74 }74 }
75 75
76-// Unmarshal decodes a strict v1 durable snapshot.76+// Unmarshal decodes a strict v2 durable snapshot.
77 func Unmarshal(b []byte) (Session, error) {77 func Unmarshal(b []byte) (Session, error) {
78 if len(b) < 16 {78 if len(b) < 16 {
79 return Session{}, ErrShortPayload79 return Session{}, ErrShortPayload
@@ -166,6 +166,18 @@ func (w *payloadWriter) putString(s string) error {
166 w.b = append(w.b, s...)166 w.b = append(w.b, s...)
167 return nil167 return nil
168 }168 }
169+func (w *payloadWriter) putStrings(ss []string) error {
170+ if len(ss) > math.MaxUint16 {
171+ return fmt.Errorf("%w: too many strings", ErrInvalidData)
172+ }
173+ w.putUint16(uint16(len(ss)))
174+ for _, s := range ss {
175+ if err := w.putString(s); err != nil {
176+ return err
177+ }
178+ }
179+ return nil
180+}
169 181
170 type payloadReader struct{ b []byte }182 type payloadReader struct{ b []byte }
171 183
@@ -213,6 +225,21 @@ func (r *payloadReader) getString() (string, error) {
213 r.b = r.b[n:]225 r.b = r.b[n:]
214 return s, nil226 return s, nil
215 }227 }
228+func (r *payloadReader) getStrings() ([]string, error) {
229+ n, err := r.getUint16()
230+ if err != nil {
231+ return nil, err
232+ }
233+ out := make([]string, n)
234+ for i := range out {
235+ s, err := r.getString()
236+ if err != nil {
237+ return nil, err
238+ }
239+ out[i] = s
240+ }
241+ return out, nil
242+}
216 func (r *payloadReader) done() error {243 func (r *payloadReader) done() error {
217 if len(r.b) != 0 {244 if len(r.b) != 0 {
218 return ErrTrailingBytes245 return ErrTrailingBytes
@@ -267,6 +294,9 @@ func readSession(r *payloadReader) (Session, error) {
267 }294 }
268 295
269 func writeTab(w *payloadWriter, t Tab) error {296 func writeTab(w *payloadWriter, t Tab) error {
297+ if err := w.putString(t.StableID); err != nil {
298+ return err
299+ }
270 w.putUint16(t.Cols)300 w.putUint16(t.Cols)
271 w.putUint16(t.Rows)301 w.putUint16(t.Rows)
272 w.putUint64(t.NextPaneID)302 w.putUint64(t.NextPaneID)
@@ -293,6 +323,10 @@ func writeTab(w *payloadWriter, t Tab) error {
293 }323 }
294 324
295 func readTab(r *payloadReader) (Tab, error) {325 func readTab(r *payloadReader) (Tab, error) {
326+ stableID, err := r.getString()
327+ if err != nil {
328+ return Tab{}, err
329+ }
296 cols, err := r.getUint16()330 cols, err := r.getUint16()
297 if err != nil {331 if err != nil {
298 return Tab{}, err332 return Tab{}, err
@@ -317,7 +351,7 @@ func readTab(r *payloadReader) (Tab, error) {
317 if err != nil {351 if err != nil {
318 return Tab{}, err352 return Tab{}, err
319 }353 }
320- t := Tab{Cols: cols, Rows: rows, NextPaneID: next, Focus: layout.PaneID(focus), Tree: &layout.Tree{Root: root, Focus: layout.PaneID(focus)}, Panes: make([]Pane, np)}354+ t := Tab{StableID: stableID, Cols: cols, Rows: rows, NextPaneID: next, Focus: layout.PaneID(focus), Tree: &layout.Tree{Root: root, Focus: layout.PaneID(focus)}, Panes: make([]Pane, np)}
321 for i := range t.Panes {355 for i := range t.Panes {
322 p, err := readPane(r)356 p, err := readPane(r)
323 if err != nil {357 if err != nil {
@@ -424,6 +458,9 @@ func writePane(w *payloadWriter, p Pane) error {
424 if err := w.putString(string(p.ID)); err != nil {458 if err := w.putString(string(p.ID)); err != nil {
425 return err459 return err
426 }460 }
461+ if err := w.putString(p.StableID); err != nil {
462+ return err
463+ }
427 if err := w.putString(p.Cwd); err != nil {464 if err := w.putString(p.Cwd); err != nil {
428 return err465 return err
429 }466 }
@@ -442,7 +479,10 @@ func writePane(w *payloadWriter, p Pane) error {
442 if err := writeRows(w, p.Scrollback, idx, false); err != nil {479 if err := writeRows(w, p.Scrollback, idx, false); err != nil {
443 return err480 return err
444 }481 }
445- return writeRows(w, p.Visible, idx, true)482+ if err := writeRows(w, p.Visible, idx, true); err != nil {
483+ return err
484+ }
485+ return writeProcess(w, p.Process)
446 }486 }
447 487
448 func readPane(r *payloadReader) (Pane, error) {488 func readPane(r *payloadReader) (Pane, error) {
@@ -450,6 +490,10 @@ func readPane(r *payloadReader) (Pane, error) {
450 if err != nil {490 if err != nil {
451 return Pane{}, err491 return Pane{}, err
452 }492 }
493+ stableID, err := r.getString()
494+ if err != nil {
495+ return Pane{}, err
496+ }
453 cwd, err := r.getString()497 cwd, err := r.getString()
454 if err != nil {498 if err != nil {
455 return Pane{}, err499 return Pane{}, err
@@ -475,7 +519,58 @@ func readPane(r *payloadReader) (Pane, error) {
475 if err != nil {519 if err != nil {
476 return Pane{}, err520 return Pane{}, err
477 }521 }
478- return Pane{ID: layout.PaneID(id), Cwd: cwd, Scrollback: sb, Visible: vis}, nil522+ proc, err := readProcess(r)
523+ if err != nil {
524+ return Pane{}, err
525+ }
526+ return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, Scrollback: sb, Visible: vis, Process: proc}, nil
527+}
528+
529+func writeProcess(w *payloadWriter, p *Process) error {
530+ if p == nil {
531+ w.putUint8(0)
532+ return nil
533+ }
534+ if len(p.Argv) == 0 {
535+ return fmt.Errorf("%w: process argv empty", ErrInvalidData)
536+ }
537+ w.putUint8(1)
538+ if err := w.putStrings(p.Argv); err != nil {
539+ return err
540+ }
541+ if err := w.putString(p.Strategy); err != nil {
542+ return err
543+ }
544+ return w.putString(p.Opts.AgentSessionID)
545+}
546+
547+func readProcess(r *payloadReader) (*Process, error) {
548+ present, err := r.getUint8()
549+ if err != nil {
550+ return nil, err
551+ }
552+ if present == 0 {
553+ return nil, nil
554+ }
555+ if present != 1 {
556+ return nil, fmt.Errorf("%w: process presence", ErrInvalidData)
557+ }
558+ argv, err := r.getStrings()
559+ if err != nil {
560+ return nil, err
561+ }
562+ if len(argv) == 0 {
563+ return nil, fmt.Errorf("%w: process argv empty", ErrInvalidData)
564+ }
565+ strategy, err := r.getString()
566+ if err != nil {
567+ return nil, err
568+ }
569+ agentSessionID, err := r.getString()
570+ if err != nil {
571+ return nil, err
572+ }
573+ return &Process{Argv: argv, Strategy: strategy, Opts: ProcessOpts{AgentSessionID: agentSessionID}}, nil
479 }574 }
480 575
481 func writeRows(w *payloadWriter, rows [][]renderer.Cell, idx map[renderer.Style]uint16, trimBlankRows bool) error {576 func writeRows(w *payloadWriter, rows [][]renderer.Cell, idx map[renderer.Style]uint16, trimBlankRows bool) error {
M internal/usecase/snapshot/codec_test.go +59 -6
@@ -18,7 +18,7 @@ func TestMarshalMinimalGolden(t *testing.T) {
1818 t.Fatalf("Marshal() error = %v", err)
1919 }
2020 want := []byte{
21- 'V', 'E', 'V', 'S', 0, 1, 0, 0, 0, 0, 0, 15, 0xff, 0xc2, 0x2e, 0xcb,
21+ 'V', 'E', 'V', 'S', 0, 2, 0, 0, 0, 0, 0, 15, 0xff, 0xc2, 0x2e, 0xcb,
2222 0, 1, 's', 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0,
2323 }
2424 if !bytes.Equal(got, want) {
@@ -76,7 +76,7 @@ func TestRoundTripSessions(t *testing.T) {
7676 {Rune: 'x', Style: indexed},
7777 }}
7878 multi := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{
79- {Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2", Tree: deepTree, Panes: []Pane{{ID: "1", Cwd: "/a", Scrollback: rows("abc"), Visible: rows("v")}, {ID: "2", Cwd: "/b", Visible: rows("focus")}, {ID: "3", Cwd: "/c"}}},
79+ {StableID: "t_stable", Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2", Tree: deepTree, Panes: []Pane{{ID: "1", StableID: "p_one", Cwd: "/a", Scrollback: rows("abc"), Visible: rows("v"), Process: &Process{Argv: []string{"claude", "--resume"}, Strategy: "claude", Opts: ProcessOpts{AgentSessionID: "agent-123"}}}, {ID: "2", StableID: "p_two", Cwd: "/b", Visible: rows("focus")}, {ID: "3", StableID: "p_three", Cwd: "/c"}}},
8080 {Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", Visible: cjkVisible}}},
8181 }}
8282 empty := Session{Name: "blank", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: nil, Visible: rows(" ", "")}}}}}
@@ -116,7 +116,7 @@ func TestUnmarshalRejectsMalformedWithoutPanic(t *testing.T) {
116116 data []byte
117117 }{
118118 {"bad magic", append([]byte("NOPE"), good[4:]...)},
119- {"bad version", replaceU16(good, 4, 2)},
119+ {"bad version", replaceU16(good, 4, 1)},
120120 {"bad crc", append([]byte(nil), good[:len(good)-1]...)},
121121 {"trailing", append(append([]byte(nil), good...), 0)},
122122 {"body len overrun", replaceU32(good, 8, 999999)},
@@ -143,6 +143,42 @@ func TestUnmarshalRejectsMalformedWithoutPanic(t *testing.T) {
143143 }
144144 }
145145
146+func TestMarshalRejectsProcessWithoutArgv(t *testing.T) {
147+ _, err := Marshal(Session{Name: "s", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Process: &Process{Strategy: "generic"}}}}}})
148+ if !errors.Is(err, ErrInvalidData) {
149+ t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData)
150+ }
151+}
152+
153+func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) {
154+ var w payloadWriter
155+ _ = w.putString("x")
156+ w.putUint64(0)
157+ w.putUint16(0)
158+ w.putUint16(1)
159+ _ = w.putString("t")
160+ w.putUint16(80)
161+ w.putUint16(24)
162+ w.putUint64(1)
163+ _ = w.putString("p")
164+ _ = writeNode(&w, layout.NewLeaf("p"))
165+ w.putUint16(1)
166+ _ = w.putString("p")
167+ _ = w.putString("p_stable")
168+ _ = w.putString("")
169+ w.putUint16(0)
170+ w.putUint32(0)
171+ w.putUint32(0)
172+ w.putUint8(1)
173+ w.putUint16(0)
174+ _ = w.putString("generic")
175+ _ = w.putString("")
176+ _, err := Unmarshal(reheader(w.b, 0))
177+ if !errors.Is(err, ErrInvalidData) {
178+ t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData)
179+ }
180+}
181+
146182 func TestUnmarshalRejectsOversizedBodiesBeforeAllocation(t *testing.T) {
147183 body := []byte{0}
148184 oversizedRaw := reheader(body, 0)
@@ -248,11 +284,26 @@ func equalSession(a, b Session) bool {
248284 }
249285
250286 func equalTab(a, b Tab) bool {
251- if a.Cols != b.Cols || a.Rows != b.Rows || a.NextPaneID != b.NextPaneID || a.Focus != b.Focus || len(a.Panes) != len(b.Panes) || !equalNode(treeRoot(a.Tree), treeRoot(b.Tree)) {
287+ if a.StableID != b.StableID || a.Cols != b.Cols || a.Rows != b.Rows || a.NextPaneID != b.NextPaneID || a.Focus != b.Focus || len(a.Panes) != len(b.Panes) || !equalNode(treeRoot(a.Tree), treeRoot(b.Tree)) {
252288 return false
253289 }
254290 for i := range a.Panes {
255- if a.Panes[i].ID != b.Panes[i].ID || a.Panes[i].Cwd != b.Panes[i].Cwd || !equalRows(a.Panes[i].Scrollback, b.Panes[i].Scrollback) || !equalRows(a.Panes[i].Visible, b.Panes[i].Visible) {
291+ if a.Panes[i].ID != b.Panes[i].ID || a.Panes[i].StableID != b.Panes[i].StableID || a.Panes[i].Cwd != b.Panes[i].Cwd || !equalProcess(a.Panes[i].Process, b.Panes[i].Process) || !equalRows(a.Panes[i].Scrollback, b.Panes[i].Scrollback) || !equalRows(a.Panes[i].Visible, b.Panes[i].Visible) {
292+ return false
293+ }
294+ }
295+ return true
296+}
297+
298+func equalProcess(a, b *Process) bool {
299+ if a == nil || b == nil {
300+ return a == b
301+ }
302+ if a.Strategy != b.Strategy || a.Opts.AgentSessionID != b.Opts.AgentSessionID || len(a.Argv) != len(b.Argv) {
303+ return false
304+ }
305+ for i := range a.Argv {
306+ if a.Argv[i] != b.Argv[i] {
256307 return false
257308 }
258309 }
@@ -311,7 +362,7 @@ func trimSession(s Session) Session {
311362 func reheader(body []byte, flags uint16) []byte {
312363 out := make([]byte, 16+len(body))
313364 copy(out[:4], []byte("VEVS"))
314- binary.BigEndian.PutUint16(out[4:6], 1)
365+ binary.BigEndian.PutUint16(out[4:6], version)
315366 binary.BigEndian.PutUint16(out[6:8], flags)
316367 binary.BigEndian.PutUint32(out[8:12], uint32(len(body)))
317368 binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
@@ -336,6 +387,7 @@ func malformedRowsSnapshot(rowCount uint32, runCount uint16) []byte {
336387 w.putUint64(0)
337388 w.putUint16(0)
338389 w.putUint16(1)
390+ _ = w.putString("")
339391 w.putUint16(0)
340392 w.putUint16(0)
341393 w.putUint64(0)
@@ -344,6 +396,7 @@ func malformedRowsSnapshot(rowCount uint32, runCount uint16) []byte {
344396 w.putUint16(1)
345397 _ = w.putString("p")
346398 _ = w.putString("")
399+ _ = w.putString("")
347400 w.putUint16(0)
348401 w.putUint32(rowCount)
349402 if rowCount > 0 {
@@ -18,7 +18,7 @@ func TestMarshalMinimalGolden(t *testing.T) {
18 t.Fatalf("Marshal() error = %v", err)18 t.Fatalf("Marshal() error = %v", err)
19 }19 }
20 want := []byte{20 want := []byte{
21- 'V', 'E', 'V', 'S', 0, 1, 0, 0, 0, 0, 0, 15, 0xff, 0xc2, 0x2e, 0xcb,21+ 'V', 'E', 'V', 'S', 0, 2, 0, 0, 0, 0, 0, 15, 0xff, 0xc2, 0x2e, 0xcb,
22 0, 1, 's', 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0,22 0, 1, 's', 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0,
23 }23 }
24 if !bytes.Equal(got, want) {24 if !bytes.Equal(got, want) {
@@ -76,7 +76,7 @@ func TestRoundTripSessions(t *testing.T) {
76 {Rune: 'x', Style: indexed},76 {Rune: 'x', Style: indexed},
77 }}77 }}
78 multi := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{78 multi := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{
79- {Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2", Tree: deepTree, Panes: []Pane{{ID: "1", Cwd: "/a", Scrollback: rows("abc"), Visible: rows("v")}, {ID: "2", Cwd: "/b", Visible: rows("focus")}, {ID: "3", Cwd: "/c"}}},79+ {StableID: "t_stable", Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2", Tree: deepTree, Panes: []Pane{{ID: "1", StableID: "p_one", Cwd: "/a", Scrollback: rows("abc"), Visible: rows("v"), Process: &Process{Argv: []string{"claude", "--resume"}, Strategy: "claude", Opts: ProcessOpts{AgentSessionID: "agent-123"}}}, {ID: "2", StableID: "p_two", Cwd: "/b", Visible: rows("focus")}, {ID: "3", StableID: "p_three", Cwd: "/c"}}},
80 {Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", Visible: cjkVisible}}},80 {Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", Visible: cjkVisible}}},
81 }}81 }}
82 empty := Session{Name: "blank", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: nil, Visible: rows(" ", "")}}}}}82 empty := Session{Name: "blank", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: nil, Visible: rows(" ", "")}}}}}
@@ -116,7 +116,7 @@ func TestUnmarshalRejectsMalformedWithoutPanic(t *testing.T) {
116 data []byte116 data []byte
117 }{117 }{
118 {"bad magic", append([]byte("NOPE"), good[4:]...)},118 {"bad magic", append([]byte("NOPE"), good[4:]...)},
119- {"bad version", replaceU16(good, 4, 2)},119+ {"bad version", replaceU16(good, 4, 1)},
120 {"bad crc", append([]byte(nil), good[:len(good)-1]...)},120 {"bad crc", append([]byte(nil), good[:len(good)-1]...)},
121 {"trailing", append(append([]byte(nil), good...), 0)},121 {"trailing", append(append([]byte(nil), good...), 0)},
122 {"body len overrun", replaceU32(good, 8, 999999)},122 {"body len overrun", replaceU32(good, 8, 999999)},
@@ -143,6 +143,42 @@ func TestUnmarshalRejectsMalformedWithoutPanic(t *testing.T) {
143 }143 }
144 }144 }
145 145
146+func TestMarshalRejectsProcessWithoutArgv(t *testing.T) {
147+ _, err := Marshal(Session{Name: "s", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Process: &Process{Strategy: "generic"}}}}}})
148+ if !errors.Is(err, ErrInvalidData) {
149+ t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData)
150+ }
151+}
152+
153+func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) {
154+ var w payloadWriter
155+ _ = w.putString("x")
156+ w.putUint64(0)
157+ w.putUint16(0)
158+ w.putUint16(1)
159+ _ = w.putString("t")
160+ w.putUint16(80)
161+ w.putUint16(24)
162+ w.putUint64(1)
163+ _ = w.putString("p")
164+ _ = writeNode(&w, layout.NewLeaf("p"))
165+ w.putUint16(1)
166+ _ = w.putString("p")
167+ _ = w.putString("p_stable")
168+ _ = w.putString("")
169+ w.putUint16(0)
170+ w.putUint32(0)
171+ w.putUint32(0)
172+ w.putUint8(1)
173+ w.putUint16(0)
174+ _ = w.putString("generic")
175+ _ = w.putString("")
176+ _, err := Unmarshal(reheader(w.b, 0))
177+ if !errors.Is(err, ErrInvalidData) {
178+ t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData)
179+ }
180+}
181+
146 func TestUnmarshalRejectsOversizedBodiesBeforeAllocation(t *testing.T) {182 func TestUnmarshalRejectsOversizedBodiesBeforeAllocation(t *testing.T) {
147 body := []byte{0}183 body := []byte{0}
148 oversizedRaw := reheader(body, 0)184 oversizedRaw := reheader(body, 0)
@@ -248,11 +284,26 @@ func equalSession(a, b Session) bool {
248 }284 }
249 285
250 func equalTab(a, b Tab) bool {286 func equalTab(a, b Tab) bool {
251- if a.Cols != b.Cols || a.Rows != b.Rows || a.NextPaneID != b.NextPaneID || a.Focus != b.Focus || len(a.Panes) != len(b.Panes) || !equalNode(treeRoot(a.Tree), treeRoot(b.Tree)) {287+ if a.StableID != b.StableID || a.Cols != b.Cols || a.Rows != b.Rows || a.NextPaneID != b.NextPaneID || a.Focus != b.Focus || len(a.Panes) != len(b.Panes) || !equalNode(treeRoot(a.Tree), treeRoot(b.Tree)) {
252 return false288 return false
253 }289 }
254 for i := range a.Panes {290 for i := range a.Panes {
255- if a.Panes[i].ID != b.Panes[i].ID || a.Panes[i].Cwd != b.Panes[i].Cwd || !equalRows(a.Panes[i].Scrollback, b.Panes[i].Scrollback) || !equalRows(a.Panes[i].Visible, b.Panes[i].Visible) {291+ if a.Panes[i].ID != b.Panes[i].ID || a.Panes[i].StableID != b.Panes[i].StableID || a.Panes[i].Cwd != b.Panes[i].Cwd || !equalProcess(a.Panes[i].Process, b.Panes[i].Process) || !equalRows(a.Panes[i].Scrollback, b.Panes[i].Scrollback) || !equalRows(a.Panes[i].Visible, b.Panes[i].Visible) {
292+ return false
293+ }
294+ }
295+ return true
296+}
297+
298+func equalProcess(a, b *Process) bool {
299+ if a == nil || b == nil {
300+ return a == b
301+ }
302+ if a.Strategy != b.Strategy || a.Opts.AgentSessionID != b.Opts.AgentSessionID || len(a.Argv) != len(b.Argv) {
303+ return false
304+ }
305+ for i := range a.Argv {
306+ if a.Argv[i] != b.Argv[i] {
256 return false307 return false
257 }308 }
258 }309 }
@@ -311,7 +362,7 @@ func trimSession(s Session) Session {
311 func reheader(body []byte, flags uint16) []byte {362 func reheader(body []byte, flags uint16) []byte {
312 out := make([]byte, 16+len(body))363 out := make([]byte, 16+len(body))
313 copy(out[:4], []byte("VEVS"))364 copy(out[:4], []byte("VEVS"))
314- binary.BigEndian.PutUint16(out[4:6], 1)365+ binary.BigEndian.PutUint16(out[4:6], version)
315 binary.BigEndian.PutUint16(out[6:8], flags)366 binary.BigEndian.PutUint16(out[6:8], flags)
316 binary.BigEndian.PutUint32(out[8:12], uint32(len(body)))367 binary.BigEndian.PutUint32(out[8:12], uint32(len(body)))
317 binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))368 binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
@@ -336,6 +387,7 @@ func malformedRowsSnapshot(rowCount uint32, runCount uint16) []byte {
336 w.putUint64(0)387 w.putUint64(0)
337 w.putUint16(0)388 w.putUint16(0)
338 w.putUint16(1)389 w.putUint16(1)
390+ _ = w.putString("")
339 w.putUint16(0)391 w.putUint16(0)
340 w.putUint16(0)392 w.putUint16(0)
341 w.putUint64(0)393 w.putUint64(0)
@@ -344,6 +396,7 @@ func malformedRowsSnapshot(rowCount uint32, runCount uint16) []byte {
344 w.putUint16(1)396 w.putUint16(1)
345 _ = w.putString("p")397 _ = w.putString("p")
346 _ = w.putString("")398 _ = w.putString("")
399+ _ = w.putString("")
347 w.putUint16(0)400 w.putUint16(0)
348 w.putUint32(rowCount)401 w.putUint32(rowCount)
349 if rowCount > 0 {402 if rowCount > 0 {
M internal/usecase/snapshot/snapshot.go +15 -0
@@ -15,6 +15,7 @@ type Session struct {
1515
1616 // Tab captures one tab's layout, dimensions, and pane snapshots.
1717 type Tab struct {
18+ StableID string
1819 Cols uint16
1920 Rows uint16
2021 NextPaneID uint64
@@ -27,7 +28,21 @@ type Tab struct {
2728 // with its scrollback and final visible primary screen.
2829 type Pane struct {
2930 ID layout.PaneID
31+ StableID string
3032 Cwd string
3133 Scrollback [][]renderer.Cell
3234 Visible [][]renderer.Cell
35+ Process *Process
36+}
37+
38+// Process captures restorable foreground process metadata.
39+type Process struct {
40+ Argv []string
41+ Strategy string
42+ Opts ProcessOpts
43+}
44+
45+// ProcessOpts captures strategy-specific process restore options.
46+type ProcessOpts struct {
47+ AgentSessionID string
3348 }
@@ -15,6 +15,7 @@ type Session struct {
15 15
16 // Tab captures one tab's layout, dimensions, and pane snapshots.16 // Tab captures one tab's layout, dimensions, and pane snapshots.
17 type Tab struct {17 type Tab struct {
18+ StableID string
18 Cols uint1619 Cols uint16
19 Rows uint1620 Rows uint16
20 NextPaneID uint6421 NextPaneID uint64
@@ -27,7 +28,21 @@ type Tab struct {
27 // with its scrollback and final visible primary screen.28 // with its scrollback and final visible primary screen.
28 type Pane struct {29 type Pane struct {
29 ID layout.PaneID30 ID layout.PaneID
31+ StableID string
30 Cwd string32 Cwd string
31 Scrollback [][]renderer.Cell33 Scrollback [][]renderer.Cell
32 Visible [][]renderer.Cell34 Visible [][]renderer.Cell
35+ Process *Process
36+}
37+
38+// Process captures restorable foreground process metadata.
39+type Process struct {
40+ Argv []string
41+ Strategy string
42+ Opts ProcessOpts
43+}
44+
45+// ProcessOpts captures strategy-specific process restore options.
46+type ProcessOpts struct {
47+ AgentSessionID string
33 }48 }

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