detached 604afe4

fix(daemon): restore terminal color capabilities

commit
604afe4673c3029a61d442246ade2550e0895a2c
author
brice · · 2026-08-02 11:02 UTC
parent
087e78d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +72 -15
M docs/terminal.md +1 -1
@@ -2,7 +2,7 @@
22
33 ## Color
44
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.
66
77 ## Environment
88
@@ -2,7 +2,7 @@
2 2
3 ## Color3 ## 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 ## Environment7 ## Environment
8 8
M internal/ports/terminal.go +12 -0
@@ -2,6 +2,7 @@ package ports
22
33 import (
44 "io"
5+ "strings"
56
67 "github.com/bnema/vev/internal/domain"
78 )
@@ -15,3 +16,14 @@ type Terminal interface {
1516 Out() io.Writer
1617 Flush() error
1718 }
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.Writer16 Out() io.Writer
16 Flush() error17 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 (
1515 "io"
1616 "log/slog"
1717 "os"
18- "strings"
1918 "sync"
2019 "sync/atomic"
2120 "time"
@@ -592,13 +591,7 @@ func paletteSlot(slot int) (uint8, bool) {
592591
593592 // DetectTrueColor reports whether TERM/COLORTERM advertise direct color support.
594593 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)
602595 }
603596
604597 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) {
18071807 return name, value, ok
18081808 }
18091809
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+
18101827 func (d *Daemon) ptyCommand(env []string) (string, []string) {
18111828 if d.shellOverride {
18121829 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, ok1807 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
142142 }
143143 }()
144144
145- sess := d.newRestoredSession(snap, sctx, cancel, opened)
145+ sess := d.newRestoredSession(snap, sctx, cancel, opened, terminalEnvFromEnvironment(d.baseEnv))
146146 // The loaded manifest is the repository head for this name. Future dirty
147147 // checkpoints must continue from it rather than reuse generation one.
148148 sess.snapshotPublishedGeneration = repositoryGeneration
@@ -165,10 +165,10 @@ func (d *Daemon) restoredSessionAlreadyExists(name string) bool {
165165 }
166166
167167 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.
171170 restoreEnv := copyEnvironment(d.baseEnv)
171+ restoreTerm := terminalEnvFromEnvironment(restoreEnv)
172172 allowlist := d.restoreProcessAllowlistSnapshot()
173173 stoppedTabNames := d.restoredSessionTabNames(snap.Name)
174174 opened := make([]*tab, 0, len(snap.Tabs))
@@ -289,8 +289,8 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable
289289 return p, nil
290290 }
291291
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)}
294294 // Restored tabs remain private until persistAndRegisterRestoredSession.
295295 // Initialize owners now so registration and reader startup cannot expose an
296296 // 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 dirty146 // 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 = repositoryGeneration148 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 the168+ // 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, nil289 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 an295 // 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 (
88 "sync"
99 "testing"
1010
11+ "github.com/stretchr/testify/mock"
1112 "github.com/stretchr/testify/require"
1213
1314 "github.com/bnema/vev/internal/domain"
1415 "github.com/bnema/vev/internal/ports"
16+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
1517 "github.com/bnema/vev/internal/usecase/layout"
1618 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
1719 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
@@ -213,6 +215,7 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
213215 repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: generation}}
214216 pty, release := newBlockingPTY(t)
215217 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
218+ d.baseEnv = []string{"TERM=xterm-kitty", "COLORTERM=truecolor"}
216219 checkpoint := domain.CheckpointRef{Generation: generation.Generation, ManifestDigest: snapcodec.ManifestDigest(generation.Manifest)}
217220 record := domain.CatalogueRecord{Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd", CreatedAt: int64(snapshot.CreatedAt), Committed: &checkpoint}
218221 catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
@@ -229,6 +232,7 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
229232 require.NotNil(t, restored)
230233 require.False(t, restored.snapDirty.Load(), "a loaded generation must begin clean")
231234 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")
232236 require.Equal(t, 0, restored.active)
233237 require.Equal(t, "/snapshot/cwd", restored.cwd)
234238 require.Len(t, restored.tabs, 1)
@@ -301,6 +305,37 @@ func TestSnapshotRuntimeRestorePreservesConsumedAndExpelledPaneLayout(t *testing
301305 require.Equal(t, fixture.paneStableIDs, restored.paneStableIDs, "runtime restore must preserve every pane stable ID")
302306 }
303307
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+
304339 func captureConsumedAndExpelledPaneLayout(t *testing.T) paneLayoutSnapshotFixture {
305340 t.Helper()
306341 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())

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