fix(daemon): restore terminal color capabilities
- commit
- 604afe4673c3029a61d442246ade2550e0895a2c
- author
- brice · · 2026-08-02 11:02 UTC
- parent
- 087e78d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +72 -15
M docs/terminal.md +1 -1
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | 3 | ## Color | |
| 4 | 4 | ||
| 5 | - | When the client reports truecolor, new panes get `TERM=xterm-direct` and `COLORTERM=truecolor`; otherwise `TERM=xterm-256color`. The capability travels inside vev's own protocol, so it works over SSH without `SendEnv`/`AcceptEnv`. Panes restored from a snapshot start conservative until a client attaches. | |
| 5 | + | When the client reports truecolor, new panes get `TERM=xterm-direct` and `COLORTERM=truecolor`; otherwise `TERM=xterm-256color`. The capability travels inside vev's own protocol, so it works over SSH without `SendEnv`/`AcceptEnv`. Panes restored from a snapshot derive the same capability from the daemon's startup environment until a client attaches and refreshes it. | |
| 6 | 6 | ||
| 7 | 7 | ## Environment | |
| 8 | 8 | ||
| @@ -2,7 +2,7 @@ | |||||
| 2 | 2 | ||||
| 3 | ## Color | 3 | ## Color | ||
| 4 | 4 | ||||
| 5 | - | When the client reports truecolor, new panes get `TERM=xterm-direct` and `COLORTERM=truecolor`; otherwise `TERM=xterm-256color`. The capability travels inside vev's own protocol, so it works over SSH without `SendEnv`/`AcceptEnv`. Panes restored from a snapshot start conservative until a client attaches. | 5 | + | When the client reports truecolor, new panes get `TERM=xterm-direct` and `COLORTERM=truecolor`; otherwise `TERM=xterm-256color`. The capability travels inside vev's own protocol, so it works over SSH without `SendEnv`/`AcceptEnv`. Panes restored from a snapshot derive the same capability from the daemon's startup environment until a client attaches and refreshes it. |
| 6 | 6 | ||||
| 7 | ## Environment | 7 | ## Environment | ||
| 8 | 8 | ||||
M internal/ports/terminal.go +12 -0
| @@ -2,6 +2,7 @@ package ports | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "io" | |
| 5 | + | "strings" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/vev/internal/domain" | |
| 7 | 8 | ) | |
| @@ -15,3 +16,14 @@ type Terminal interface { | |||
| 15 | 16 | Out() io.Writer | |
| 16 | 17 | Flush() error | |
| 17 | 18 | } | |
| 19 | + | ||
| 20 | + | // DetectTrueColor reports whether TERM/COLORTERM advertise direct color support. | |
| 21 | + | func DetectTrueColor(termEnv, colorTerm string) bool { | |
| 22 | + | switch strings.ToLower(strings.TrimSpace(colorTerm)) { | |
| 23 | + | case "truecolor", "24bit": | |
| 24 | + | return true | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | termEnv = strings.ToLower(strings.TrimSpace(termEnv)) | |
| 28 | + | return termEnv == "xterm-direct" || strings.HasSuffix(termEnv, "-direct") | |
| 29 | + | } | |
| @@ -2,6 +2,7 @@ package ports | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "io" | 4 | "io" | ||
| 5 | + | "strings" | |||
| 5 | 6 | ||||
| 6 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 7 | ) | 8 | ) | ||
| @@ -15,3 +16,14 @@ type Terminal interface { | |||||
| 15 | Out() io.Writer | 16 | Out() io.Writer | ||
| 16 | Flush() error | 17 | Flush() error | ||
| 17 | } | 18 | } | ||
| 19 | + | ||||
| 20 | + | // DetectTrueColor reports whether TERM/COLORTERM advertise direct color support. | |||
| 21 | + | func DetectTrueColor(termEnv, colorTerm string) bool { | |||
| 22 | + | switch strings.ToLower(strings.TrimSpace(colorTerm)) { | |||
| 23 | + | case "truecolor", "24bit": | |||
| 24 | + | return true | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | termEnv = strings.ToLower(strings.TrimSpace(termEnv)) | |||
| 28 | + | return termEnv == "xterm-direct" || strings.HasSuffix(termEnv, "-direct") | |||
| 29 | + | } | |||
M internal/usecase/client/client.go +1 -8
| @@ -15,7 +15,6 @@ import ( | |||
| 15 | 15 | "io" | |
| 16 | 16 | "log/slog" | |
| 17 | 17 | "os" | |
| 18 | - | "strings" | |
| 19 | 18 | "sync" | |
| 20 | 19 | "sync/atomic" | |
| 21 | 20 | "time" | |
| @@ -592,13 +591,7 @@ func paletteSlot(slot int) (uint8, bool) { | |||
| 592 | 591 | ||
| 593 | 592 | // DetectTrueColor reports whether TERM/COLORTERM advertise direct color support. | |
| 594 | 593 | func DetectTrueColor(termEnv, colorTerm string) bool { | |
| 595 | - | switch strings.ToLower(strings.TrimSpace(colorTerm)) { | |
| 596 | - | case "truecolor", "24bit": | |
| 597 | - | return true | |
| 598 | - | } | |
| 599 | - | ||
| 600 | - | termEnv = strings.ToLower(strings.TrimSpace(termEnv)) | |
| 601 | - | return termEnv == "xterm-direct" || strings.HasSuffix(termEnv, "-direct") | |
| 594 | + | return ports.DetectTrueColor(termEnv, colorTerm) | |
| 602 | 595 | } | |
| 603 | 596 | ||
| 604 | 597 | func requestedOutputWindow(transport ports.Transport) uint8 { | |
| @@ -15,7 +15,6 @@ import ( | |||||
| 15 | "io" | 15 | "io" | ||
| 16 | "log/slog" | 16 | "log/slog" | ||
| 17 | "os" | 17 | "os" | ||
| 18 | - | "strings" | |||
| 19 | "sync" | 18 | "sync" | ||
| 20 | "sync/atomic" | 19 | "sync/atomic" | ||
| 21 | "time" | 20 | "time" | ||
| @@ -592,13 +591,7 @@ func paletteSlot(slot int) (uint8, bool) { | |||||
| 592 | 591 | ||||
| 593 | // DetectTrueColor reports whether TERM/COLORTERM advertise direct color support. | 592 | // DetectTrueColor reports whether TERM/COLORTERM advertise direct color support. | ||
| 594 | func DetectTrueColor(termEnv, colorTerm string) bool { | 593 | func DetectTrueColor(termEnv, colorTerm string) bool { | ||
| 595 | - | switch strings.ToLower(strings.TrimSpace(colorTerm)) { | 594 | + | return ports.DetectTrueColor(termEnv, colorTerm) |
| 596 | - | case "truecolor", "24bit": | |||
| 597 | - | return true | |||
| 598 | - | } | |||
| 599 | - | ||||
| 600 | - | termEnv = strings.ToLower(strings.TrimSpace(termEnv)) | |||
| 601 | - | return termEnv == "xterm-direct" || strings.HasSuffix(termEnv, "-direct") | |||
| 602 | } | 595 | } | ||
| 603 | 596 | ||||
| 604 | func requestedOutputWindow(transport ports.Transport) uint8 { | 597 | func requestedOutputWindow(transport ports.Transport) uint8 { | ||
M internal/usecase/daemon/session.go +17 -0
| @@ -1807,6 +1807,23 @@ func environmentEntry(entry string) (name, value string, ok bool) { | |||
| 1807 | 1807 | return name, value, ok | |
| 1808 | 1808 | } | |
| 1809 | 1809 | ||
| 1810 | + | func terminalEnvFromEnvironment(env []string) terminalEnv { | |
| 1811 | + | var term, colorTerm string | |
| 1812 | + | for _, entry := range env { | |
| 1813 | + | name, value, ok := environmentEntry(entry) | |
| 1814 | + | if !ok { | |
| 1815 | + | continue | |
| 1816 | + | } | |
| 1817 | + | switch name { | |
| 1818 | + | case "TERM": | |
| 1819 | + | term = value | |
| 1820 | + | case "COLORTERM": | |
| 1821 | + | colorTerm = value | |
| 1822 | + | } | |
| 1823 | + | } | |
| 1824 | + | return terminalEnv{TrueColor: ports.DetectTrueColor(term, colorTerm)} | |
| 1825 | + | } | |
| 1826 | + | ||
| 1810 | 1827 | func (d *Daemon) ptyCommand(env []string) (string, []string) { | |
| 1811 | 1828 | if d.shellOverride { | |
| 1812 | 1829 | return d.shell, append([]string(nil), d.shellArgs...) | |
| @@ -1807,6 +1807,23 @@ func environmentEntry(entry string) (name, value string, ok bool) { | |||||
| 1807 | return name, value, ok | 1807 | return name, value, ok | ||
| 1808 | } | 1808 | } | ||
| 1809 | 1809 | ||||
| 1810 | + | func terminalEnvFromEnvironment(env []string) terminalEnv { | |||
| 1811 | + | var term, colorTerm string | |||
| 1812 | + | for _, entry := range env { | |||
| 1813 | + | name, value, ok := environmentEntry(entry) | |||
| 1814 | + | if !ok { | |||
| 1815 | + | continue | |||
| 1816 | + | } | |||
| 1817 | + | switch name { | |||
| 1818 | + | case "TERM": | |||
| 1819 | + | term = value | |||
| 1820 | + | case "COLORTERM": | |||
| 1821 | + | colorTerm = value | |||
| 1822 | + | } | |||
| 1823 | + | } | |||
| 1824 | + | return terminalEnv{TrueColor: ports.DetectTrueColor(term, colorTerm)} | |||
| 1825 | + | } | |||
| 1826 | + | ||||
| 1810 | func (d *Daemon) ptyCommand(env []string) (string, []string) { | 1827 | func (d *Daemon) ptyCommand(env []string) (string, []string) { | ||
| 1811 | if d.shellOverride { | 1828 | if d.shellOverride { | ||
| 1812 | return d.shell, append([]string(nil), d.shellArgs...) | 1829 | return d.shell, append([]string(nil), d.shellArgs...) | ||
M internal/usecase/daemon/snapshot_restore.go +6 -6
| @@ -142,7 +142,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session, rep | |||
| 142 | 142 | } | |
| 143 | 143 | }() | |
| 144 | 144 | ||
| 145 | - | sess := d.newRestoredSession(snap, sctx, cancel, opened) | |
| 145 | + | sess := d.newRestoredSession(snap, sctx, cancel, opened, terminalEnvFromEnvironment(d.baseEnv)) | |
| 146 | 146 | // The loaded manifest is the repository head for this name. Future dirty | |
| 147 | 147 | // checkpoints must continue from it rather than reuse generation one. | |
| 148 | 148 | sess.snapshotPublishedGeneration = repositoryGeneration | |
| @@ -165,10 +165,10 @@ func (d *Daemon) restoredSessionAlreadyExists(name string) bool { | |||
| 165 | 165 | } | |
| 166 | 166 | ||
| 167 | 167 | func (d *Daemon) restoreSnapshotTabs(ctx, sctx context.Context, snap snapcodec.Session) ([]*tab, error) { | |
| 168 | - | // Restore runs before client Hello. Future panes therefore inherit the | |
| 169 | - | // daemon's startup environment until an attach supplies terminal capability. | |
| 170 | - | restoreTerm := terminalEnv{} | |
| 168 | + | // Restore runs before client Hello. Use the daemon's startup environment, | |
| 169 | + | // including its terminal capability, until an attach supplies a fresher one. | |
| 171 | 170 | restoreEnv := copyEnvironment(d.baseEnv) | |
| 171 | + | restoreTerm := terminalEnvFromEnvironment(restoreEnv) | |
| 172 | 172 | allowlist := d.restoreProcessAllowlistSnapshot() | |
| 173 | 173 | stoppedTabNames := d.restoredSessionTabNames(snap.Name) | |
| 174 | 174 | opened := make([]*tab, 0, len(snap.Tabs)) | |
| @@ -289,8 +289,8 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable | |||
| 289 | 289 | return p, nil | |
| 290 | 290 | } | |
| 291 | 291 | ||
| 292 | - | func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab) *session { | |
| 293 | - | sess := &session{sessionCore: sessionCore{name: snap.Name, createdAt: int64(snap.CreatedAt)}, ctx: sctx, cancel: cancel, tabs: tabs, active: int(snap.Active), terminal: terminalEnv{}, env: copyEnvironment(d.baseEnv), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)} | |
| 292 | + | func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab, term terminalEnv) *session { | |
| 293 | + | sess := &session{sessionCore: sessionCore{name: snap.Name, createdAt: int64(snap.CreatedAt)}, ctx: sctx, cancel: cancel, tabs: tabs, active: int(snap.Active), terminal: term, env: copyEnvironment(d.baseEnv), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)} | |
| 294 | 294 | // Restored tabs remain private until persistAndRegisterRestoredSession. | |
| 295 | 295 | // Initialize owners now so registration and reader startup cannot expose an | |
| 296 | 296 | // ownerless pane. | |
| @@ -142,7 +142,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session, rep | |||||
| 142 | } | 142 | } | ||
| 143 | }() | 143 | }() | ||
| 144 | 144 | ||||
| 145 | - | sess := d.newRestoredSession(snap, sctx, cancel, opened) | 145 | + | sess := d.newRestoredSession(snap, sctx, cancel, opened, terminalEnvFromEnvironment(d.baseEnv)) |
| 146 | // The loaded manifest is the repository head for this name. Future dirty | 146 | // The loaded manifest is the repository head for this name. Future dirty | ||
| 147 | // checkpoints must continue from it rather than reuse generation one. | 147 | // checkpoints must continue from it rather than reuse generation one. | ||
| 148 | sess.snapshotPublishedGeneration = repositoryGeneration | 148 | sess.snapshotPublishedGeneration = repositoryGeneration | ||
| @@ -165,10 +165,10 @@ func (d *Daemon) restoredSessionAlreadyExists(name string) bool { | |||||
| 165 | } | 165 | } | ||
| 166 | 166 | ||||
| 167 | func (d *Daemon) restoreSnapshotTabs(ctx, sctx context.Context, snap snapcodec.Session) ([]*tab, error) { | 167 | func (d *Daemon) restoreSnapshotTabs(ctx, sctx context.Context, snap snapcodec.Session) ([]*tab, error) { | ||
| 168 | - | // Restore runs before client Hello. Future panes therefore inherit the | 168 | + | // Restore runs before client Hello. Use the daemon's startup environment, |
| 169 | - | // daemon's startup environment until an attach supplies terminal capability. | 169 | + | // including its terminal capability, until an attach supplies a fresher one. |
| 170 | - | restoreTerm := terminalEnv{} | |||
| 171 | restoreEnv := copyEnvironment(d.baseEnv) | 170 | restoreEnv := copyEnvironment(d.baseEnv) | ||
| 171 | + | restoreTerm := terminalEnvFromEnvironment(restoreEnv) | |||
| 172 | allowlist := d.restoreProcessAllowlistSnapshot() | 172 | allowlist := d.restoreProcessAllowlistSnapshot() | ||
| 173 | stoppedTabNames := d.restoredSessionTabNames(snap.Name) | 173 | stoppedTabNames := d.restoredSessionTabNames(snap.Name) | ||
| 174 | opened := make([]*tab, 0, len(snap.Tabs)) | 174 | opened := make([]*tab, 0, len(snap.Tabs)) | ||
| @@ -289,8 +289,8 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable | |||||
| 289 | return p, nil | 289 | return p, nil | ||
| 290 | } | 290 | } | ||
| 291 | 291 | ||||
| 292 | - | func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab) *session { | 292 | + | func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab, term terminalEnv) *session { |
| 293 | - | sess := &session{sessionCore: sessionCore{name: snap.Name, createdAt: int64(snap.CreatedAt)}, ctx: sctx, cancel: cancel, tabs: tabs, active: int(snap.Active), terminal: terminalEnv{}, env: copyEnvironment(d.baseEnv), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)} | 293 | + | sess := &session{sessionCore: sessionCore{name: snap.Name, createdAt: int64(snap.CreatedAt)}, ctx: sctx, cancel: cancel, tabs: tabs, active: int(snap.Active), terminal: term, env: copyEnvironment(d.baseEnv), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)} |
| 294 | // Restored tabs remain private until persistAndRegisterRestoredSession. | 294 | // Restored tabs remain private until persistAndRegisterRestoredSession. | ||
| 295 | // Initialize owners now so registration and reader startup cannot expose an | 295 | // Initialize owners now so registration and reader startup cannot expose an | ||
| 296 | // ownerless pane. | 296 | // ownerless pane. | ||
M internal/usecase/daemon/snapshot_restore_test.go +35 -0
| @@ -8,10 +8,12 @@ import ( | |||
| 8 | 8 | "sync" | |
| 9 | 9 | "testing" | |
| 10 | 10 | ||
| 11 | + | "github.com/stretchr/testify/mock" | |
| 11 | 12 | "github.com/stretchr/testify/require" | |
| 12 | 13 | ||
| 13 | 14 | "github.com/bnema/vev/internal/domain" | |
| 14 | 15 | "github.com/bnema/vev/internal/ports" | |
| 16 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 15 | 17 | "github.com/bnema/vev/internal/usecase/layout" | |
| 16 | 18 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | |
| 17 | 19 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| @@ -213,6 +215,7 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) { | |||
| 213 | 215 | repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: generation}} | |
| 214 | 216 | pty, release := newBlockingPTY(t) | |
| 215 | 217 | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 218 | + | d.baseEnv = []string{"TERM=xterm-kitty", "COLORTERM=truecolor"} | |
| 216 | 219 | checkpoint := domain.CheckpointRef{Generation: generation.Generation, ManifestDigest: snapcodec.ManifestDigest(generation.Manifest)} | |
| 217 | 220 | record := domain.CatalogueRecord{Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd", CreatedAt: int64(snapshot.CreatedAt), Committed: &checkpoint} | |
| 218 | 221 | catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}) | |
| @@ -229,6 +232,7 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) { | |||
| 229 | 232 | require.NotNil(t, restored) | |
| 230 | 233 | require.False(t, restored.snapDirty.Load(), "a loaded generation must begin clean") | |
| 231 | 234 | require.Equal(t, uint64(9), restored.snapshotPublishedGeneration, "the loaded manifest is the repository generation head") | |
| 235 | + | require.True(t, restored.terminal.TrueColor, "restored sessions must retain startup terminal capability for future panes") | |
| 232 | 236 | require.Equal(t, 0, restored.active) | |
| 233 | 237 | require.Equal(t, "/snapshot/cwd", restored.cwd) | |
| 234 | 238 | require.Len(t, restored.tabs, 1) | |
| @@ -301,6 +305,37 @@ func TestSnapshotRuntimeRestorePreservesConsumedAndExpelledPaneLayout(t *testing | |||
| 301 | 305 | require.Equal(t, fixture.paneStableIDs, restored.paneStableIDs, "runtime restore must preserve every pane stable ID") | |
| 302 | 306 | } | |
| 303 | 307 | ||
| 308 | + | func TestSnapshotRestoreUsesDaemonTerminalCapabilities(t *testing.T) { | |
| 309 | + | snapshot := restoreAcceptanceSession(t, "restored") | |
| 310 | + | pty, release := newBlockingPTY(t) | |
| 311 | + | defer release() | |
| 312 | + | ||
| 313 | + | var gotEnv []string | |
| 314 | + | factory := portsmocks.NewMockPTYFactory(t) | |
| 315 | + | factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). | |
| 316 | + | RunAndReturn(func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | |
| 317 | + | gotEnv = append([]string(nil), env...) | |
| 318 | + | return pty, nil | |
| 319 | + | }).Once() | |
| 320 | + | ||
| 321 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 322 | + | d.baseEnv = []string{ | |
| 323 | + | "TERM=xterm-kitty", | |
| 324 | + | "COLORTERM=truecolor", | |
| 325 | + | "XDG_RUNTIME_DIR=/run/user/1000", | |
| 326 | + | "WAYLAND_DISPLAY=wayland-1", | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | tabs, err := d.restoreSnapshotTabs(context.Background(), d.serveCtx, snapshot) | |
| 330 | + | require.NoError(t, err) | |
| 331 | + | defer closeRestoredTabs(tabs) | |
| 332 | + | ||
| 333 | + | require.Contains(t, gotEnv, "TERM=xterm-direct") | |
| 334 | + | require.Contains(t, gotEnv, "COLORTERM=truecolor") | |
| 335 | + | require.Contains(t, gotEnv, "XDG_RUNTIME_DIR=/run/user/1000") | |
| 336 | + | require.Contains(t, gotEnv, "WAYLAND_DISPLAY=wayland-1") | |
| 337 | + | } | |
| 338 | + | ||
| 304 | 339 | func captureConsumedAndExpelledPaneLayout(t *testing.T) paneLayoutSnapshotFixture { | |
| 305 | 340 | t.Helper() | |
| 306 | 341 | h := newPaneRearrangeHarness(t, domain.Size{Cols: 200, Rows: 30}, paneLayoutSnapshotTree()) | |
| @@ -8,10 +8,12 @@ import ( | |||||
| 8 | "sync" | 8 | "sync" | ||
| 9 | "testing" | 9 | "testing" | ||
| 10 | 10 | ||||
| 11 | + | "github.com/stretchr/testify/mock" | |||
| 11 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 12 | 13 | ||||
| 13 | "github.com/bnema/vev/internal/domain" | 14 | "github.com/bnema/vev/internal/domain" | ||
| 14 | "github.com/bnema/vev/internal/ports" | 15 | "github.com/bnema/vev/internal/ports" | ||
| 16 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 15 | "github.com/bnema/vev/internal/usecase/layout" | 17 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 16 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | 18 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | ||
| 17 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | 19 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | ||
| @@ -213,6 +215,7 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) { | |||||
| 213 | repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: generation}} | 215 | repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: generation}} | ||
| 214 | pty, release := newBlockingPTY(t) | 216 | pty, release := newBlockingPTY(t) | ||
| 215 | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | 217 | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | ||
| 218 | + | d.baseEnv = []string{"TERM=xterm-kitty", "COLORTERM=truecolor"} | |||
| 216 | checkpoint := domain.CheckpointRef{Generation: generation.Generation, ManifestDigest: snapcodec.ManifestDigest(generation.Manifest)} | 219 | checkpoint := domain.CheckpointRef{Generation: generation.Generation, ManifestDigest: snapcodec.ManifestDigest(generation.Manifest)} | ||
| 217 | record := domain.CatalogueRecord{Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd", CreatedAt: int64(snapshot.CreatedAt), Committed: &checkpoint} | 220 | record := domain.CatalogueRecord{Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd", CreatedAt: int64(snapshot.CreatedAt), Committed: &checkpoint} | ||
| 218 | catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}) | 221 | catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}) | ||
| @@ -229,6 +232,7 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) { | |||||
| 229 | require.NotNil(t, restored) | 232 | require.NotNil(t, restored) | ||
| 230 | require.False(t, restored.snapDirty.Load(), "a loaded generation must begin clean") | 233 | require.False(t, restored.snapDirty.Load(), "a loaded generation must begin clean") | ||
| 231 | require.Equal(t, uint64(9), restored.snapshotPublishedGeneration, "the loaded manifest is the repository generation head") | 234 | require.Equal(t, uint64(9), restored.snapshotPublishedGeneration, "the loaded manifest is the repository generation head") | ||
| 235 | + | require.True(t, restored.terminal.TrueColor, "restored sessions must retain startup terminal capability for future panes") | |||
| 232 | require.Equal(t, 0, restored.active) | 236 | require.Equal(t, 0, restored.active) | ||
| 233 | require.Equal(t, "/snapshot/cwd", restored.cwd) | 237 | require.Equal(t, "/snapshot/cwd", restored.cwd) | ||
| 234 | require.Len(t, restored.tabs, 1) | 238 | require.Len(t, restored.tabs, 1) | ||
| @@ -301,6 +305,37 @@ func TestSnapshotRuntimeRestorePreservesConsumedAndExpelledPaneLayout(t *testing | |||||
| 301 | require.Equal(t, fixture.paneStableIDs, restored.paneStableIDs, "runtime restore must preserve every pane stable ID") | 305 | require.Equal(t, fixture.paneStableIDs, restored.paneStableIDs, "runtime restore must preserve every pane stable ID") | ||
| 302 | } | 306 | } | ||
| 303 | 307 | ||||
| 308 | + | func TestSnapshotRestoreUsesDaemonTerminalCapabilities(t *testing.T) { | |||
| 309 | + | snapshot := restoreAcceptanceSession(t, "restored") | |||
| 310 | + | pty, release := newBlockingPTY(t) | |||
| 311 | + | defer release() | |||
| 312 | + | ||||
| 313 | + | var gotEnv []string | |||
| 314 | + | factory := portsmocks.NewMockPTYFactory(t) | |||
| 315 | + | factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). | |||
| 316 | + | RunAndReturn(func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | |||
| 317 | + | gotEnv = append([]string(nil), env...) | |||
| 318 | + | return pty, nil | |||
| 319 | + | }).Once() | |||
| 320 | + | ||||
| 321 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 322 | + | d.baseEnv = []string{ | |||
| 323 | + | "TERM=xterm-kitty", | |||
| 324 | + | "COLORTERM=truecolor", | |||
| 325 | + | "XDG_RUNTIME_DIR=/run/user/1000", | |||
| 326 | + | "WAYLAND_DISPLAY=wayland-1", | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | tabs, err := d.restoreSnapshotTabs(context.Background(), d.serveCtx, snapshot) | |||
| 330 | + | require.NoError(t, err) | |||
| 331 | + | defer closeRestoredTabs(tabs) | |||
| 332 | + | ||||
| 333 | + | require.Contains(t, gotEnv, "TERM=xterm-direct") | |||
| 334 | + | require.Contains(t, gotEnv, "COLORTERM=truecolor") | |||
| 335 | + | require.Contains(t, gotEnv, "XDG_RUNTIME_DIR=/run/user/1000") | |||
| 336 | + | require.Contains(t, gotEnv, "WAYLAND_DISPLAY=wayland-1") | |||
| 337 | + | } | |||
| 338 | + | ||||
| 304 | func captureConsumedAndExpelledPaneLayout(t *testing.T) paneLayoutSnapshotFixture { | 339 | func captureConsumedAndExpelledPaneLayout(t *testing.T) paneLayoutSnapshotFixture { | ||
| 305 | t.Helper() | 340 | t.Helper() | ||
| 306 | h := newPaneRearrangeHarness(t, domain.Size{Cols: 200, Rows: 30}, paneLayoutSnapshotTree()) | 341 | h := newPaneRearrangeHarness(t, domain.Size{Cols: 200, Rows: 30}, paneLayoutSnapshotTree()) | ||