docs(panes): document consume and expel commands
- commit
- db8c344884c2fa73204b287e50811060e8586319
- author
- brice · · 2026-07-27 14:20 UTC
- parent
- 5cc817b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +132 -1
M README.md +1 -1
| @@ -50,7 +50,7 @@ No prefix key; everything is Alt. | |||
| 50 | 50 | | Alt+h/j/k/l, Alt+Arrow | focus pane | | |
| 51 | 51 | | Alt+a | jump to a session needing attention | | |
| 52 | 52 | ||
| 53 | - | The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list. Named active and stopped sessions are fuzzy-searchable, and selecting a stopped session resumes it. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52. | |
| 53 | + | The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list; the unbound pane consume/expel actions are discoverable there as `CEL` and `CER`. Named active and stopped sessions are fuzzy-searchable, and selecting a stopped session resumes it. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52. | |
| 54 | 54 | ||
| 55 | 55 | ## Remote attach | |
| 56 | 56 | ||
| @@ -50,7 +50,7 @@ No prefix key; everything is Alt. | |||||
| 50 | | Alt+h/j/k/l, Alt+Arrow | focus pane | | 50 | | Alt+h/j/k/l, Alt+Arrow | focus pane | | ||
| 51 | | Alt+a | jump to a session needing attention | | 51 | | Alt+a | jump to a session needing attention | | ||
| 52 | 52 | ||||
| 53 | - | The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list. Named active and stopped sessions are fuzzy-searchable, and selecting a stopped session resumes it. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52. | 53 | + | The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list; the unbound pane consume/expel actions are discoverable there as `CEL` and `CER`. Named active and stopped sessions are fuzzy-searchable, and selecting a stopped session resumes it. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52. |
| 54 | 54 | ||||
| 55 | ## Remote attach | 55 | ## Remote attach | ||
| 56 | 56 | ||||
M docs/configuration.md +13 -0
| @@ -34,6 +34,9 @@ focus-pane-left = alt+h | |||
| 34 | 34 | focus-pane-right = alt+l | |
| 35 | 35 | focus-pane-up = alt+k | |
| 36 | 36 | focus-pane-down = alt+j | |
| 37 | + | # Optional pane rearrangement actions are unbound by default. | |
| 38 | + | # consume-or-expel-pane-left = alt+H | |
| 39 | + | # consume-or-expel-pane-right = alt+L | |
| 37 | 40 | switch-tab-1 = alt+1 | |
| 38 | 41 | # ... through switch-tab-9 = alt+9 | |
| 39 | 42 | ||
| @@ -53,6 +56,8 @@ code.split-right = SPR | |||
| 53 | 56 | code.split-left = SPL | |
| 54 | 57 | code.split-up = SPU | |
| 55 | 58 | code.split-down = SPD | |
| 59 | + | code.consume-or-expel-pane-left = CEL | |
| 60 | + | code.consume-or-expel-pane-right = CER | |
| 56 | 61 | code.stack-pane = STP | |
| 57 | 62 | code.toggle-stack = TST | |
| 58 | 63 | code.close-pane = CLP | |
| @@ -96,6 +101,14 @@ Slots `0`, `7`, `8`, and `15` are valid explicit selections, but log a warning b | |||
| 96 | 101 | ||
| 97 | 102 | Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config. | |
| 98 | 103 | ||
| 104 | + | `alt+[` is unsupported because terminals frame it as the CSI prefix `ESC [`, which vev passes through as terminal input. Cmd/Super is not a vev key-spec modifier; map a physical Cmd/Super chord in the terminal emulator to an unused, safe `ESC` + character sequence (not `ESC [`), then configure the matching `alt+<char>` in vev. | |
| 105 | + | ||
| 106 | + | ## Pane consume or expel | |
| 107 | + | ||
| 108 | + | The `consume-or-expel-pane-left` and `consume-or-expel-pane-right` actions are unbound by default; the commented Alt+H/Alt+L bindings above are optional examples. Use palette codes `CEL` and `CER`, or script them as `vev cmd consume-or-expel-pane-left` and `vev cmd consume-or-expel-pane-right`. | |
| 109 | + | ||
| 110 | + | A singleton pane moves into the immediate column on the requested side; at the outer edge, nothing changes. A pane in a multi-member vertical or stack column moves out as an adjacent singleton column. This works only with canonical column layouts: one column, or a top-level horizontal split of columns, where each column is a singleton pane, a vertical split of panes, or a pane stack. Nested mixed splits are unsupported. | |
| 111 | + | ||
| 99 | 112 | ## Navigation overflow | |
| 100 | 113 | ||
| 101 | 114 | Both settings are independent and default to `off`. With `nav.overflow-tabs = on`, left/right keyboard focus (`Alt+H`/`Alt+L` by default) continues from a pane edge to the adjacent tab. With `nav.overflow-sessions = on`, up/down keyboard focus (`Alt+K`/`Alt+J` by default) continues to the adjacent live session in alphabetical order. Neither setting wraps at the first or last destination. | |
| @@ -34,6 +34,9 @@ focus-pane-left = alt+h | |||||
| 34 | focus-pane-right = alt+l | 34 | focus-pane-right = alt+l | ||
| 35 | focus-pane-up = alt+k | 35 | focus-pane-up = alt+k | ||
| 36 | focus-pane-down = alt+j | 36 | focus-pane-down = alt+j | ||
| 37 | + | # Optional pane rearrangement actions are unbound by default. | |||
| 38 | + | # consume-or-expel-pane-left = alt+H | |||
| 39 | + | # consume-or-expel-pane-right = alt+L | |||
| 37 | switch-tab-1 = alt+1 | 40 | switch-tab-1 = alt+1 | ||
| 38 | # ... through switch-tab-9 = alt+9 | 41 | # ... through switch-tab-9 = alt+9 | ||
| 39 | 42 | ||||
| @@ -53,6 +56,8 @@ code.split-right = SPR | |||||
| 53 | code.split-left = SPL | 56 | code.split-left = SPL | ||
| 54 | code.split-up = SPU | 57 | code.split-up = SPU | ||
| 55 | code.split-down = SPD | 58 | code.split-down = SPD | ||
| 59 | + | code.consume-or-expel-pane-left = CEL | |||
| 60 | + | code.consume-or-expel-pane-right = CER | |||
| 56 | code.stack-pane = STP | 61 | code.stack-pane = STP | ||
| 57 | code.toggle-stack = TST | 62 | code.toggle-stack = TST | ||
| 58 | code.close-pane = CLP | 63 | code.close-pane = CLP | ||
| @@ -96,6 +101,14 @@ Slots `0`, `7`, `8`, and `15` are valid explicit selections, but log a warning b | |||||
| 96 | 101 | ||||
| 97 | Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config. | 102 | Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config. | ||
| 98 | 103 | ||||
| 104 | + | `alt+[` is unsupported because terminals frame it as the CSI prefix `ESC [`, which vev passes through as terminal input. Cmd/Super is not a vev key-spec modifier; map a physical Cmd/Super chord in the terminal emulator to an unused, safe `ESC` + character sequence (not `ESC [`), then configure the matching `alt+<char>` in vev. | |||
| 105 | + | ||||
| 106 | + | ## Pane consume or expel | |||
| 107 | + | ||||
| 108 | + | The `consume-or-expel-pane-left` and `consume-or-expel-pane-right` actions are unbound by default; the commented Alt+H/Alt+L bindings above are optional examples. Use palette codes `CEL` and `CER`, or script them as `vev cmd consume-or-expel-pane-left` and `vev cmd consume-or-expel-pane-right`. | |||
| 109 | + | ||||
| 110 | + | A singleton pane moves into the immediate column on the requested side; at the outer edge, nothing changes. A pane in a multi-member vertical or stack column moves out as an adjacent singleton column. This works only with canonical column layouts: one column, or a top-level horizontal split of columns, where each column is a singleton pane, a vertical split of panes, or a pane stack. Nested mixed splits are unsupported. | |||
| 111 | + | ||||
| 99 | ## Navigation overflow | 112 | ## Navigation overflow | ||
| 100 | 113 | ||||
| 101 | Both settings are independent and default to `off`. With `nav.overflow-tabs = on`, left/right keyboard focus (`Alt+H`/`Alt+L` by default) continues from a pane edge to the adjacent tab. With `nav.overflow-sessions = on`, up/down keyboard focus (`Alt+K`/`Alt+J` by default) continues to the adjacent live session in alphabetical order. Neither setting wraps at the first or last destination. | 114 | Both settings are independent and default to `off`. With `nav.overflow-tabs = on`, left/right keyboard focus (`Alt+H`/`Alt+L` by default) continues from a pane edge to the adjacent tab. With `nav.overflow-sessions = on`, up/down keyboard focus (`Alt+K`/`Alt+J` by default) continues to the adjacent live session in alphabetical order. Neither setting wraps at the first or last destination. | ||
M internal/usecase/daemon/snapshot_restore_test.go +118 -0
| @@ -257,6 +257,124 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) { | |||
| 257 | 257 | require.Equal(t, uint64(10), repository.publishes[0].Generation, "a restored session must continue the concrete repository generation stream") | |
| 258 | 258 | } | |
| 259 | 259 | ||
| 260 | + | func TestSnapshotCaptureRestorePreservesConsumedAndExpelledPaneLayout(t *testing.T) { | |
| 261 | + | initial := &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Weight: 9, Children: []*layout.Node{ | |
| 262 | + | {Kind: layout.Leaf, Leaf: "pane-1", Weight: 40}, | |
| 263 | + | {Kind: layout.Split, Dir: layout.Vertical, Weight: 80, Children: []*layout.Node{ | |
| 264 | + | {Kind: layout.Leaf, Leaf: "pane-2", Weight: 10}, | |
| 265 | + | {Kind: layout.Leaf, Leaf: "pane-3", Weight: 20}, | |
| 266 | + | }}, | |
| 267 | + | {Kind: layout.Stack, Weight: 40, Expanded: "pane-5", Children: []*layout.Node{ | |
| 268 | + | {Kind: layout.Leaf, Leaf: "pane-4", Weight: 7}, | |
| 269 | + | {Kind: layout.Leaf, Leaf: "pane-5", Weight: 3}, | |
| 270 | + | }}, | |
| 271 | + | }}, Focus: "pane-4"} | |
| 272 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 200, Rows: 30}, initial) | |
| 273 | + | h.session.createdAt = 77 | |
| 274 | + | h.session.incarnation = domain.IncarnationID{7} | |
| 275 | + | h.tab.mu.Lock() | |
| 276 | + | h.tab.stableID = "tab-stable-rearranged" | |
| 277 | + | h.tab.nextPaneID = 6 | |
| 278 | + | paneStableIDs := map[layout.PaneID]string{ | |
| 279 | + | "pane-1": "pane-stable-1", | |
| 280 | + | "pane-2": "pane-stable-2", | |
| 281 | + | "pane-3": "pane-stable-3", | |
| 282 | + | "pane-4": "pane-stable-4", | |
| 283 | + | "pane-5": "pane-stable-5", | |
| 284 | + | } | |
| 285 | + | for id, stableID := range paneStableIDs { | |
| 286 | + | h.tab.panes[id].mu.Lock() | |
| 287 | + | h.tab.panes[id].stableID = stableID | |
| 288 | + | h.tab.panes[id].mu.Unlock() | |
| 289 | + | } | |
| 290 | + | h.tab.mu.Unlock() | |
| 291 | + | ||
| 292 | + | operations := []struct { | |
| 293 | + | name string | |
| 294 | + | pane layout.PaneID | |
| 295 | + | direction layout.Direction | |
| 296 | + | }{ | |
| 297 | + | {name: "consume singleton into vertical column", pane: "pane-1", direction: layout.Right}, | |
| 298 | + | {name: "expel vertical member as singleton column", pane: "pane-3", direction: layout.Right}, | |
| 299 | + | } | |
| 300 | + | for _, operation := range operations { | |
| 301 | + | t.Run(operation.name, func(t *testing.T) { | |
| 302 | + | err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{ | |
| 303 | + | kind: daemonActionConsumeOrExpelPane, target: h.target(operation.pane), direction: operation.direction, | |
| 304 | + | }) | |
| 305 | + | require.NoError(t, err) | |
| 306 | + | }) | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | h.tab.mu.Lock() | |
| 310 | + | wantTree := h.tab.tree.Clone() | |
| 311 | + | h.tab.mu.Unlock() | |
| 312 | + | require.Equal(t, layout.Horizontal, wantTree.Root.Dir) | |
| 313 | + | require.Len(t, wantTree.Root.Children, 3) | |
| 314 | + | require.Equal(t, layout.Vertical, wantTree.Root.Children[0].Dir) | |
| 315 | + | require.Equal(t, layout.Leaf, wantTree.Root.Children[1].Kind) | |
| 316 | + | require.Equal(t, layout.Stack, wantTree.Root.Children[2].Kind) | |
| 317 | + | require.Equal(t, layout.PaneID("pane-5"), wantTree.Root.Children[2].Expanded) | |
| 318 | + | require.Equal(t, layout.PaneID("pane-3"), wantTree.Focus) | |
| 319 | + | ||
| 320 | + | capture, ok := h.daemon.captureSnapshotState(h.session, 7) | |
| 321 | + | require.True(t, ok) | |
| 322 | + | publication, err := h.daemon.incrementalPublication(capture) | |
| 323 | + | require.NoError(t, err) | |
| 324 | + | objects := make(map[ports.SnapshotDigest][]byte, len(publication.Objects)) | |
| 325 | + | for _, object := range publication.Objects { | |
| 326 | + | objects[object.Digest] = append([]byte(nil), object.Data...) | |
| 327 | + | } | |
| 328 | + | generation := ports.SnapshotGeneration{ | |
| 329 | + | IncarnationID: publication.IncarnationID, | |
| 330 | + | Name: publication.Name, | |
| 331 | + | Generation: publication.Generation, | |
| 332 | + | Manifest: append([]byte(nil), publication.Manifest...), | |
| 333 | + | Objects: objects, | |
| 334 | + | } | |
| 335 | + | decoded, err := sessionFromGeneration(generation) | |
| 336 | + | require.NoError(t, err) | |
| 337 | + | require.Len(t, decoded.Tabs, 1) | |
| 338 | + | decodedTab := decoded.Tabs[0] | |
| 339 | + | require.Equal(t, "tab-stable-rearranged", decodedTab.StableID) | |
| 340 | + | require.Equal(t, layout.PaneID("pane-3"), decodedTab.Focus) | |
| 341 | + | require.Equal(t, wantTree, decodedTab.Tree, "manifest codec must preserve the complete rearranged tree") | |
| 342 | + | require.Equal(t, snapshotLayoutWeights(wantTree.Root), snapshotLayoutWeights(decodedTab.Tree.Root)) | |
| 343 | + | require.Equal(t, layout.PaneID("pane-5"), decodedTab.Tree.Root.Children[2].Expanded) | |
| 344 | + | decodedStableIDs := make(map[layout.PaneID]string, len(decodedTab.Panes)) | |
| 345 | + | for _, restoredPane := range decodedTab.Panes { | |
| 346 | + | decodedStableIDs[restoredPane.ID] = restoredPane.StableID | |
| 347 | + | } | |
| 348 | + | require.Equal(t, paneStableIDs, decodedStableIDs) | |
| 349 | + | ||
| 350 | + | restorePTY := &paneRearrangePTY{} | |
| 351 | + | restoreDaemon := newTestDaemon(t, newFactory(t, restorePTY), stubClock{}) | |
| 352 | + | restoredTabs, err := restoreDaemon.restoreSnapshotTabs(context.Background(), restoreDaemon.serveCtx, decoded) | |
| 353 | + | require.NoError(t, err) | |
| 354 | + | require.Len(t, restoredTabs, 1) | |
| 355 | + | defer closeRestoredTabs(restoredTabs) | |
| 356 | + | restoredTab := restoredTabs[0] | |
| 357 | + | require.Equal(t, "tab-stable-rearranged", restoredTab.stableID) | |
| 358 | + | require.Equal(t, wantTree, restoredTab.tree, "runtime restore must preserve the complete rearranged tree") | |
| 359 | + | require.Equal(t, snapshotLayoutWeights(wantTree.Root), snapshotLayoutWeights(restoredTab.tree.Root)) | |
| 360 | + | require.Equal(t, layout.PaneID("pane-3"), restoredTab.tree.Focus) | |
| 361 | + | require.Equal(t, layout.PaneID("pane-5"), restoredTab.tree.Root.Children[2].Expanded) | |
| 362 | + | for id, stableID := range paneStableIDs { | |
| 363 | + | require.Equal(t, stableID, restoredTab.panes[id].stableID, "pane %s stable ID", id) | |
| 364 | + | } | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | func snapshotLayoutWeights(node *layout.Node) []float64 { | |
| 368 | + | if node == nil { | |
| 369 | + | return nil | |
| 370 | + | } | |
| 371 | + | weights := []float64{node.Weight} | |
| 372 | + | for _, child := range node.Children { | |
| 373 | + | weights = append(weights, snapshotLayoutWeights(child)...) | |
| 374 | + | } | |
| 375 | + | return weights | |
| 376 | + | } | |
| 377 | + | ||
| 260 | 378 | func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) { | |
| 261 | 379 | snapshot := restoreAcceptanceSession(t, "restored") | |
| 262 | 380 | generation := acceptanceGeneration(t, snapshot, 9) | |
| @@ -257,6 +257,124 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) { | |||||
| 257 | require.Equal(t, uint64(10), repository.publishes[0].Generation, "a restored session must continue the concrete repository generation stream") | 257 | require.Equal(t, uint64(10), repository.publishes[0].Generation, "a restored session must continue the concrete repository generation stream") | ||
| 258 | } | 258 | } | ||
| 259 | 259 | ||||
| 260 | + | func TestSnapshotCaptureRestorePreservesConsumedAndExpelledPaneLayout(t *testing.T) { | |||
| 261 | + | initial := &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Weight: 9, Children: []*layout.Node{ | |||
| 262 | + | {Kind: layout.Leaf, Leaf: "pane-1", Weight: 40}, | |||
| 263 | + | {Kind: layout.Split, Dir: layout.Vertical, Weight: 80, Children: []*layout.Node{ | |||
| 264 | + | {Kind: layout.Leaf, Leaf: "pane-2", Weight: 10}, | |||
| 265 | + | {Kind: layout.Leaf, Leaf: "pane-3", Weight: 20}, | |||
| 266 | + | }}, | |||
| 267 | + | {Kind: layout.Stack, Weight: 40, Expanded: "pane-5", Children: []*layout.Node{ | |||
| 268 | + | {Kind: layout.Leaf, Leaf: "pane-4", Weight: 7}, | |||
| 269 | + | {Kind: layout.Leaf, Leaf: "pane-5", Weight: 3}, | |||
| 270 | + | }}, | |||
| 271 | + | }}, Focus: "pane-4"} | |||
| 272 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 200, Rows: 30}, initial) | |||
| 273 | + | h.session.createdAt = 77 | |||
| 274 | + | h.session.incarnation = domain.IncarnationID{7} | |||
| 275 | + | h.tab.mu.Lock() | |||
| 276 | + | h.tab.stableID = "tab-stable-rearranged" | |||
| 277 | + | h.tab.nextPaneID = 6 | |||
| 278 | + | paneStableIDs := map[layout.PaneID]string{ | |||
| 279 | + | "pane-1": "pane-stable-1", | |||
| 280 | + | "pane-2": "pane-stable-2", | |||
| 281 | + | "pane-3": "pane-stable-3", | |||
| 282 | + | "pane-4": "pane-stable-4", | |||
| 283 | + | "pane-5": "pane-stable-5", | |||
| 284 | + | } | |||
| 285 | + | for id, stableID := range paneStableIDs { | |||
| 286 | + | h.tab.panes[id].mu.Lock() | |||
| 287 | + | h.tab.panes[id].stableID = stableID | |||
| 288 | + | h.tab.panes[id].mu.Unlock() | |||
| 289 | + | } | |||
| 290 | + | h.tab.mu.Unlock() | |||
| 291 | + | ||||
| 292 | + | operations := []struct { | |||
| 293 | + | name string | |||
| 294 | + | pane layout.PaneID | |||
| 295 | + | direction layout.Direction | |||
| 296 | + | }{ | |||
| 297 | + | {name: "consume singleton into vertical column", pane: "pane-1", direction: layout.Right}, | |||
| 298 | + | {name: "expel vertical member as singleton column", pane: "pane-3", direction: layout.Right}, | |||
| 299 | + | } | |||
| 300 | + | for _, operation := range operations { | |||
| 301 | + | t.Run(operation.name, func(t *testing.T) { | |||
| 302 | + | err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{ | |||
| 303 | + | kind: daemonActionConsumeOrExpelPane, target: h.target(operation.pane), direction: operation.direction, | |||
| 304 | + | }) | |||
| 305 | + | require.NoError(t, err) | |||
| 306 | + | }) | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | h.tab.mu.Lock() | |||
| 310 | + | wantTree := h.tab.tree.Clone() | |||
| 311 | + | h.tab.mu.Unlock() | |||
| 312 | + | require.Equal(t, layout.Horizontal, wantTree.Root.Dir) | |||
| 313 | + | require.Len(t, wantTree.Root.Children, 3) | |||
| 314 | + | require.Equal(t, layout.Vertical, wantTree.Root.Children[0].Dir) | |||
| 315 | + | require.Equal(t, layout.Leaf, wantTree.Root.Children[1].Kind) | |||
| 316 | + | require.Equal(t, layout.Stack, wantTree.Root.Children[2].Kind) | |||
| 317 | + | require.Equal(t, layout.PaneID("pane-5"), wantTree.Root.Children[2].Expanded) | |||
| 318 | + | require.Equal(t, layout.PaneID("pane-3"), wantTree.Focus) | |||
| 319 | + | ||||
| 320 | + | capture, ok := h.daemon.captureSnapshotState(h.session, 7) | |||
| 321 | + | require.True(t, ok) | |||
| 322 | + | publication, err := h.daemon.incrementalPublication(capture) | |||
| 323 | + | require.NoError(t, err) | |||
| 324 | + | objects := make(map[ports.SnapshotDigest][]byte, len(publication.Objects)) | |||
| 325 | + | for _, object := range publication.Objects { | |||
| 326 | + | objects[object.Digest] = append([]byte(nil), object.Data...) | |||
| 327 | + | } | |||
| 328 | + | generation := ports.SnapshotGeneration{ | |||
| 329 | + | IncarnationID: publication.IncarnationID, | |||
| 330 | + | Name: publication.Name, | |||
| 331 | + | Generation: publication.Generation, | |||
| 332 | + | Manifest: append([]byte(nil), publication.Manifest...), | |||
| 333 | + | Objects: objects, | |||
| 334 | + | } | |||
| 335 | + | decoded, err := sessionFromGeneration(generation) | |||
| 336 | + | require.NoError(t, err) | |||
| 337 | + | require.Len(t, decoded.Tabs, 1) | |||
| 338 | + | decodedTab := decoded.Tabs[0] | |||
| 339 | + | require.Equal(t, "tab-stable-rearranged", decodedTab.StableID) | |||
| 340 | + | require.Equal(t, layout.PaneID("pane-3"), decodedTab.Focus) | |||
| 341 | + | require.Equal(t, wantTree, decodedTab.Tree, "manifest codec must preserve the complete rearranged tree") | |||
| 342 | + | require.Equal(t, snapshotLayoutWeights(wantTree.Root), snapshotLayoutWeights(decodedTab.Tree.Root)) | |||
| 343 | + | require.Equal(t, layout.PaneID("pane-5"), decodedTab.Tree.Root.Children[2].Expanded) | |||
| 344 | + | decodedStableIDs := make(map[layout.PaneID]string, len(decodedTab.Panes)) | |||
| 345 | + | for _, restoredPane := range decodedTab.Panes { | |||
| 346 | + | decodedStableIDs[restoredPane.ID] = restoredPane.StableID | |||
| 347 | + | } | |||
| 348 | + | require.Equal(t, paneStableIDs, decodedStableIDs) | |||
| 349 | + | ||||
| 350 | + | restorePTY := &paneRearrangePTY{} | |||
| 351 | + | restoreDaemon := newTestDaemon(t, newFactory(t, restorePTY), stubClock{}) | |||
| 352 | + | restoredTabs, err := restoreDaemon.restoreSnapshotTabs(context.Background(), restoreDaemon.serveCtx, decoded) | |||
| 353 | + | require.NoError(t, err) | |||
| 354 | + | require.Len(t, restoredTabs, 1) | |||
| 355 | + | defer closeRestoredTabs(restoredTabs) | |||
| 356 | + | restoredTab := restoredTabs[0] | |||
| 357 | + | require.Equal(t, "tab-stable-rearranged", restoredTab.stableID) | |||
| 358 | + | require.Equal(t, wantTree, restoredTab.tree, "runtime restore must preserve the complete rearranged tree") | |||
| 359 | + | require.Equal(t, snapshotLayoutWeights(wantTree.Root), snapshotLayoutWeights(restoredTab.tree.Root)) | |||
| 360 | + | require.Equal(t, layout.PaneID("pane-3"), restoredTab.tree.Focus) | |||
| 361 | + | require.Equal(t, layout.PaneID("pane-5"), restoredTab.tree.Root.Children[2].Expanded) | |||
| 362 | + | for id, stableID := range paneStableIDs { | |||
| 363 | + | require.Equal(t, stableID, restoredTab.panes[id].stableID, "pane %s stable ID", id) | |||
| 364 | + | } | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | func snapshotLayoutWeights(node *layout.Node) []float64 { | |||
| 368 | + | if node == nil { | |||
| 369 | + | return nil | |||
| 370 | + | } | |||
| 371 | + | weights := []float64{node.Weight} | |||
| 372 | + | for _, child := range node.Children { | |||
| 373 | + | weights = append(weights, snapshotLayoutWeights(child)...) | |||
| 374 | + | } | |||
| 375 | + | return weights | |||
| 376 | + | } | |||
| 377 | + | ||||
| 260 | func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) { | 378 | func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) { | ||
| 261 | snapshot := restoreAcceptanceSession(t, "restored") | 379 | snapshot := restoreAcceptanceSession(t, "restored") | ||
| 262 | generation := acceptanceGeneration(t, snapshot, 9) | 380 | generation := acceptanceGeneration(t, snapshot, 9) | ||