detached 6389371

fix(panes): address PR review findings

commit
6389371ce8d570120fad8168c78e1ac5d5aed226
author
brice · · 2026-07-04 09:06 UTC
parent
23380f8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +153 -67
M internal/usecase/daemon/attention_test.go +4 -18
@@ -103,11 +103,7 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) {
103103 acW := &attachedClient{tr: trW, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
104104 sctxW, cancelW := context.WithCancel(d.serveCtx)
105105 t.Cleanup(cancelW)
106- tabW := newTab(newScriptPTY(nil), domain.Size{Cols: 80, Rows: 23})
107- tabW.ctx, tabW.cancel = sctxW, cancelW
108- for _, pane := range tabW.panes {
109- pane.ctx, pane.cancel = sctxW, cancelW
110- }
106+ tabW := newTestTabWithContext(newScriptPTY(nil), sctxW, cancelW)
111107 sessW := &session{id: "wedged", name: "wedged", ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}, client: acW}
112108 acW.setSession(sessW)
113109 acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW})
@@ -354,11 +350,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
354350 acB := &attachedClient{tr: trB, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
355351 sctxB, cancelB := context.WithCancel(d.serveCtx)
356352 t.Cleanup(cancelB)
357- tbB := newTab(pB, domain.Size{Cols: 80, Rows: 23})
358- tbB.ctx, tbB.cancel = sctxB, cancelB
359- for _, pane := range tbB.panes {
360- pane.ctx, pane.cancel = sctxB, cancelB
361- }
353+ tbB := newTestTabWithContext(pB, sctxB, cancelB)
362354 sessB := &session{id: "sessB", name: "other", ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}, client: acB}
363355 acB.setSession(sessB)
364356 acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB})
@@ -404,14 +396,8 @@ func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) {
404396 d, sess1, ac, sends := newManualSessionWithPTYs(t, p1)
405397 sctx2, cancel2 := context.WithCancel(d.serveCtx)
406398 defer cancel2()
407- tab2a := newTab(p2, domain.Size{Cols: 80, Rows: 23})
408- tab2b := newTab(p3, domain.Size{Cols: 80, Rows: 23})
409- for _, tb := range []*tab{tab2a, tab2b} {
410- tb.ctx, tb.cancel = sctx2, cancel2
411- for _, pane := range tb.panes {
412- pane.ctx, pane.cancel = sctx2, cancel2
413- }
414- }
399+ tab2a := newTestTabWithContext(p2, sctx2, cancel2)
400+ tab2b := newTestTabWithContext(p3, sctx2, cancel2)
415401 tab2a.attention = true
416402 tab2a.attentionAt = time.Unix(9, 0)
417403 tab2b.attention = true
@@ -103,11 +103,7 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) {
103 acW := &attachedClient{tr: trW, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}103 acW := &attachedClient{tr: trW, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
104 sctxW, cancelW := context.WithCancel(d.serveCtx)104 sctxW, cancelW := context.WithCancel(d.serveCtx)
105 t.Cleanup(cancelW)105 t.Cleanup(cancelW)
106- tabW := newTab(newScriptPTY(nil), domain.Size{Cols: 80, Rows: 23})106+ tabW := newTestTabWithContext(newScriptPTY(nil), sctxW, cancelW)
107- tabW.ctx, tabW.cancel = sctxW, cancelW
108- for _, pane := range tabW.panes {
109- pane.ctx, pane.cancel = sctxW, cancelW
110- }
111 sessW := &session{id: "wedged", name: "wedged", ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}, client: acW}107 sessW := &session{id: "wedged", name: "wedged", ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}, client: acW}
112 acW.setSession(sessW)108 acW.setSession(sessW)
113 acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW})109 acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW})
@@ -354,11 +350,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
354 acB := &attachedClient{tr: trB, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}350 acB := &attachedClient{tr: trB, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
355 sctxB, cancelB := context.WithCancel(d.serveCtx)351 sctxB, cancelB := context.WithCancel(d.serveCtx)
356 t.Cleanup(cancelB)352 t.Cleanup(cancelB)
357- tbB := newTab(pB, domain.Size{Cols: 80, Rows: 23})353+ tbB := newTestTabWithContext(pB, sctxB, cancelB)
358- tbB.ctx, tbB.cancel = sctxB, cancelB
359- for _, pane := range tbB.panes {
360- pane.ctx, pane.cancel = sctxB, cancelB
361- }
362 sessB := &session{id: "sessB", name: "other", ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}, client: acB}354 sessB := &session{id: "sessB", name: "other", ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}, client: acB}
363 acB.setSession(sessB)355 acB.setSession(sessB)
364 acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB})356 acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB})
@@ -404,14 +396,8 @@ func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) {
404 d, sess1, ac, sends := newManualSessionWithPTYs(t, p1)396 d, sess1, ac, sends := newManualSessionWithPTYs(t, p1)
405 sctx2, cancel2 := context.WithCancel(d.serveCtx)397 sctx2, cancel2 := context.WithCancel(d.serveCtx)
406 defer cancel2()398 defer cancel2()
407- tab2a := newTab(p2, domain.Size{Cols: 80, Rows: 23})399+ tab2a := newTestTabWithContext(p2, sctx2, cancel2)
408- tab2b := newTab(p3, domain.Size{Cols: 80, Rows: 23})400+ tab2b := newTestTabWithContext(p3, sctx2, cancel2)
409- for _, tb := range []*tab{tab2a, tab2b} {
410- tb.ctx, tb.cancel = sctx2, cancel2
411- for _, pane := range tb.panes {
412- pane.ctx, pane.cancel = sctx2, cancel2
413- }
414- }
415 tab2a.attention = true401 tab2a.attention = true
416 tab2a.attentionAt = time.Unix(9, 0)402 tab2a.attentionAt = time.Unix(9, 0)
417 tab2b.attention = true403 tab2b.attention = true
M internal/usecase/daemon/input.go +2 -7
@@ -278,7 +278,7 @@ func clampInt(v, lo, hi int) int {
278278 }
279279
280280 func (d *Daemon) writeToPane(sess *session, p *pane, data []byte) {
281- if p == nil {
281+ if p == nil || p.pty == nil {
282282 return
283283 }
284284 if _, err := p.pty.Write(data); err != nil {
@@ -349,12 +349,7 @@ func (h daemonKeyHandler) Forward(data []byte) {
349349 tb.mu.Lock()
350350 p := tb.focusedPane()
351351 tb.mu.Unlock()
352- if p == nil {
353- return
354- }
355- if _, err := p.pty.Write(data); err != nil {
356- h.d.log.Error("pty write failed", "err", err, "session", sess.name)
357- }
352+ h.d.writeToPane(sess, p, data)
358353 }
359354
360355 func (h daemonKeyHandler) Action(action keys.Action) {
@@ -278,7 +278,7 @@ func clampInt(v, lo, hi int) int {
278 }278 }
279 279
280 func (d *Daemon) writeToPane(sess *session, p *pane, data []byte) {280 func (d *Daemon) writeToPane(sess *session, p *pane, data []byte) {
281- if p == nil {281+ if p == nil || p.pty == nil {
282 return282 return
283 }283 }
284 if _, err := p.pty.Write(data); err != nil {284 if _, err := p.pty.Write(data); err != nil {
@@ -349,12 +349,7 @@ func (h daemonKeyHandler) Forward(data []byte) {
349 tb.mu.Lock()349 tb.mu.Lock()
350 p := tb.focusedPane()350 p := tb.focusedPane()
351 tb.mu.Unlock()351 tb.mu.Unlock()
352- if p == nil {352+ h.d.writeToPane(sess, p, data)
353- return
354- }
355- if _, err := p.pty.Write(data); err != nil {
356- h.d.log.Error("pty write failed", "err", err, "session", sess.name)
357- }
358 }353 }
359 354
360 func (h daemonKeyHandler) Action(action keys.Action) {355 func (h daemonKeyHandler) Action(action keys.Action) {
M internal/usecase/daemon/mouse_panes.go +10 -2
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "strconv"
45 "strings"
56
67 "github.com/bnema/vev/internal/domain"
@@ -80,9 +81,9 @@ func writeLayoutFingerprint(b *strings.Builder, n *layout.Node) {
8081 }
8182 b.WriteByte(byte('0' + n.Kind))
8283 b.WriteByte(byte('0' + n.Dir))
83- b.WriteString(string(n.Leaf))
84+ writePaneIDFingerprint(b, n.Leaf)
8485 b.WriteByte('|')
85- b.WriteString(string(n.Expanded))
86+ writePaneIDFingerprint(b, n.Expanded)
8687 b.WriteByte('[')
8788 for _, child := range n.Children {
8889 writeLayoutFingerprint(b, child)
@@ -91,6 +92,13 @@ func writeLayoutFingerprint(b *strings.Builder, n *layout.Node) {
9192 b.WriteByte(']')
9293 }
9394
95+func writePaneIDFingerprint(b *strings.Builder, id layout.PaneID) {
96+ s := string(id)
97+ b.WriteString(strconv.Itoa(len(s)))
98+ b.WriteByte(':')
99+ b.WriteString(s)
100+}
101+
94102 func pointInRect(col, row int, r domain.Rect) bool {
95103 return r.Width > 0 && r.Height > 0 && col >= r.X && col < r.X+r.Width && row >= r.Y && row < r.Y+r.Height
96104 }
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "strconv"
4 "strings"5 "strings"
5 6
6 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
@@ -80,9 +81,9 @@ func writeLayoutFingerprint(b *strings.Builder, n *layout.Node) {
80 }81 }
81 b.WriteByte(byte('0' + n.Kind))82 b.WriteByte(byte('0' + n.Kind))
82 b.WriteByte(byte('0' + n.Dir))83 b.WriteByte(byte('0' + n.Dir))
83- b.WriteString(string(n.Leaf))84+ writePaneIDFingerprint(b, n.Leaf)
84 b.WriteByte('|')85 b.WriteByte('|')
85- b.WriteString(string(n.Expanded))86+ writePaneIDFingerprint(b, n.Expanded)
86 b.WriteByte('[')87 b.WriteByte('[')
87 for _, child := range n.Children {88 for _, child := range n.Children {
88 writeLayoutFingerprint(b, child)89 writeLayoutFingerprint(b, child)
@@ -91,6 +92,13 @@ func writeLayoutFingerprint(b *strings.Builder, n *layout.Node) {
91 b.WriteByte(']')92 b.WriteByte(']')
92 }93 }
93 94
95+func writePaneIDFingerprint(b *strings.Builder, id layout.PaneID) {
96+ s := string(id)
97+ b.WriteString(strconv.Itoa(len(s)))
98+ b.WriteByte(':')
99+ b.WriteString(s)
100+}
101+
94 func pointInRect(col, row int, r domain.Rect) bool {102 func pointInRect(col, row int, r domain.Rect) bool {
95 return r.Width > 0 && r.Height > 0 && col >= r.X && col < r.X+r.Width && row >= r.Y && row < r.Y+r.Height103 return r.Width > 0 && r.Height > 0 && col >= r.X && col < r.X+r.Width && row >= r.Y && row < r.Y+r.Height
96 }104 }
A internal/usecase/daemon/nil_pty_test.go +65 -0
@@ -0,0 +1,65 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "io"
6+ "log/slog"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/usecase/layout"
14+)
15+
16+func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) {
17+ d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
18+ ctx, cancel := context.WithCancel(context.Background())
19+ defer cancel()
20+ sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel}
21+ first := newTab(nil, domain.Size{Cols: 41, Rows: 10})
22+ second := newTab(nil, domain.Size{Cols: 41, Rows: 10})
23+ sess.tabs = []*tab{first, second}
24+
25+ require.NotPanics(t, func() {
26+ d.resizePane(first.focusedPane(), domain.Rect{Width: 20, Height: 5})
27+ })
28+ require.Equal(t, 20, first.focusedPane().screen.Frame.Width)
29+ require.Equal(t, 5, first.focusedPane().screen.Frame.Height)
30+
31+ first.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
32+ first.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
33+ require.NotPanics(t, func() {
34+ require.NoError(t, d.closePane(sess, first, "pane-2", nil, false))
35+ })
36+ require.NotContains(t, first.panes, layout.PaneID("pane-2"))
37+
38+ require.NotPanics(t, func() { d.closeTab(sess, second, false) })
39+ require.Len(t, sess.tabs, 1)
40+}
41+
42+func TestKillSessionIgnoresNilPanePTYs(t *testing.T) {
43+ d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
44+ ctx, cancel := context.WithCancel(context.Background())
45+ sess := &session{id: "s", name: "s", ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{newTab(nil, domain.Size{Cols: 10, Rows: 3})}}
46+ d.sessions[sess.id] = sess
47+
48+ require.NotPanics(t, func() {
49+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
50+ })
51+ require.NotContains(t, d.sessions, sess.id)
52+}
53+
54+func TestNilPTYInputPathsDoNotPanic(t *testing.T) {
55+ d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
56+ ctx, cancel := context.WithCancel(context.Background())
57+ defer cancel()
58+ tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
59+ sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel, tabs: []*tab{tb}}
60+ ac := &attachedClient{}
61+ ac.setSession(sess)
62+
63+ require.NotPanics(t, func() { d.writeToPane(sess, tb.focusedPane(), []byte("x")) })
64+ require.NotPanics(t, func() { daemonKeyHandler{d: d, ac: ac}.Forward([]byte("x")) })
65+}
@@ -0,0 +1,65 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "io"
6+ "log/slog"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/usecase/layout"
14+)
15+
16+func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) {
17+ d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
18+ ctx, cancel := context.WithCancel(context.Background())
19+ defer cancel()
20+ sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel}
21+ first := newTab(nil, domain.Size{Cols: 41, Rows: 10})
22+ second := newTab(nil, domain.Size{Cols: 41, Rows: 10})
23+ sess.tabs = []*tab{first, second}
24+
25+ require.NotPanics(t, func() {
26+ d.resizePane(first.focusedPane(), domain.Rect{Width: 20, Height: 5})
27+ })
28+ require.Equal(t, 20, first.focusedPane().screen.Frame.Width)
29+ require.Equal(t, 5, first.focusedPane().screen.Frame.Height)
30+
31+ first.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
32+ first.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
33+ require.NotPanics(t, func() {
34+ require.NoError(t, d.closePane(sess, first, "pane-2", nil, false))
35+ })
36+ require.NotContains(t, first.panes, layout.PaneID("pane-2"))
37+
38+ require.NotPanics(t, func() { d.closeTab(sess, second, false) })
39+ require.Len(t, sess.tabs, 1)
40+}
41+
42+func TestKillSessionIgnoresNilPanePTYs(t *testing.T) {
43+ d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
44+ ctx, cancel := context.WithCancel(context.Background())
45+ sess := &session{id: "s", name: "s", ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{newTab(nil, domain.Size{Cols: 10, Rows: 3})}}
46+ d.sessions[sess.id] = sess
47+
48+ require.NotPanics(t, func() {
49+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
50+ })
51+ require.NotContains(t, d.sessions, sess.id)
52+}
53+
54+func TestNilPTYInputPathsDoNotPanic(t *testing.T) {
55+ d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
56+ ctx, cancel := context.WithCancel(context.Background())
57+ defer cancel()
58+ tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
59+ sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel, tabs: []*tab{tb}}
60+ ac := &attachedClient{}
61+ ac.setSession(sess)
62+
63+ require.NotPanics(t, func() { d.writeToPane(sess, tb.focusedPane(), []byte("x")) })
64+ require.NotPanics(t, func() { daemonKeyHandler{d: d, ac: ac}.Forward([]byte("x")) })
65+}
M internal/usecase/daemon/pane_actions.go +13 -4
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67
78 "github.com/bnema/vev/internal/domain"
@@ -123,8 +124,10 @@ func (d *Daemon) resizePane(p *pane, r domain.Rect) {
123124 return
124125 }
125126 sz := rectSize(r)
126- if err := p.pty.Resize(sz); err != nil {
127- d.log.Warn("pty resize failed", "err", err)
127+ if p.pty != nil {
128+ if err := p.pty.Resize(sz); err != nil {
129+ d.log.Warn("pty resize failed", "err", err)
130+ }
128131 }
129132 p.mu.Lock()
130133 p.screen.Resize(sz.Cols, sz.Rows)
@@ -161,6 +164,10 @@ func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
161164 return layout.ErrNotFound
162165 }
163166 tb.mu.Lock()
167+ if tb.tree == nil {
168+ tb.mu.Unlock()
169+ return layout.ErrNotFound
170+ }
164171 err := tb.tree.ToggleStack(tb.tree.Focus, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
165172 if err == nil {
166173 d.applyLayoutLocked(tb)
@@ -226,7 +233,9 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
226233 if p.cancel != nil {
227234 p.cancel()
228235 }
229- _ = p.pty.Close()
236+ if p.pty != nil {
237+ _ = p.pty.Close()
238+ }
230239 if repaint {
231240 if ac == nil {
232241 sess.mu.Lock()
@@ -269,7 +278,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
269278 }
270279 d.paint(sess, ac, true)
271280 }
272- if err == layout.ErrNoPane {
281+ if errors.Is(err, layout.ErrNoPane) {
273282 return nil
274283 }
275284 return err
@@ -2,6 +2,7 @@ package daemon
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "fmt"6 "fmt"
6 7
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
@@ -123,8 +124,10 @@ func (d *Daemon) resizePane(p *pane, r domain.Rect) {
123 return124 return
124 }125 }
125 sz := rectSize(r)126 sz := rectSize(r)
126- if err := p.pty.Resize(sz); err != nil {127+ if p.pty != nil {
127- d.log.Warn("pty resize failed", "err", err)128+ if err := p.pty.Resize(sz); err != nil {
129+ d.log.Warn("pty resize failed", "err", err)
130+ }
128 }131 }
129 p.mu.Lock()132 p.mu.Lock()
130 p.screen.Resize(sz.Cols, sz.Rows)133 p.screen.Resize(sz.Cols, sz.Rows)
@@ -161,6 +164,10 @@ func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
161 return layout.ErrNotFound164 return layout.ErrNotFound
162 }165 }
163 tb.mu.Lock()166 tb.mu.Lock()
167+ if tb.tree == nil {
168+ tb.mu.Unlock()
169+ return layout.ErrNotFound
170+ }
164 err := tb.tree.ToggleStack(tb.tree.Focus, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})171 err := tb.tree.ToggleStack(tb.tree.Focus, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows})
165 if err == nil {172 if err == nil {
166 d.applyLayoutLocked(tb)173 d.applyLayoutLocked(tb)
@@ -226,7 +233,9 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
226 if p.cancel != nil {233 if p.cancel != nil {
227 p.cancel()234 p.cancel()
228 }235 }
229- _ = p.pty.Close()236+ if p.pty != nil {
237+ _ = p.pty.Close()
238+ }
230 if repaint {239 if repaint {
231 if ac == nil {240 if ac == nil {
232 sess.mu.Lock()241 sess.mu.Lock()
@@ -269,7 +278,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
269 }278 }
270 d.paint(sess, ac, true)279 d.paint(sess, ac, true)
271 }280 }
272- if err == layout.ErrNoPane {281+ if errors.Is(err, layout.ErrNoPane) {
273 return nil282 return nil
274 }283 }
275 return err284 return err
M internal/usecase/daemon/pane_actions_test.go +5 -5
@@ -25,12 +25,12 @@ func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) {
2525 size domain.Size
2626 wantNew domain.Rect
2727 wantOld domain.Rect
28- isNewSide func(old, new domain.Rect) bool
28+ isNewSide func(oldRect, newRect domain.Rect) bool
2929 }{
30- {name: "right", dir: layout.Right, size: domain.Size{Cols: 41, Rows: 10}, wantOld: domain.Rect{Width: 20, Height: 10}, wantNew: domain.Rect{X: 21, Width: 20, Height: 10}, isNewSide: func(old, new domain.Rect) bool { return new.X > old.X }},
31- {name: "left", dir: layout.Left, size: domain.Size{Cols: 41, Rows: 10}, wantOld: domain.Rect{X: 21, Width: 20, Height: 10}, wantNew: domain.Rect{Width: 20, Height: 10}, isNewSide: func(old, new domain.Rect) bool { return new.X < old.X }},
32- {name: "down", dir: layout.Down, size: domain.Size{Cols: 80, Rows: 5}, wantOld: domain.Rect{Width: 80, Height: 2}, wantNew: domain.Rect{Y: 3, Width: 80, Height: 2}, isNewSide: func(old, new domain.Rect) bool { return new.Y > old.Y }},
33- {name: "up", dir: layout.Up, size: domain.Size{Cols: 80, Rows: 5}, wantOld: domain.Rect{Y: 3, Width: 80, Height: 2}, wantNew: domain.Rect{Width: 80, Height: 2}, isNewSide: func(old, new domain.Rect) bool { return new.Y < old.Y }},
30+ {name: "right", dir: layout.Right, size: domain.Size{Cols: 41, Rows: 10}, wantOld: domain.Rect{Width: 20, Height: 10}, wantNew: domain.Rect{X: 21, Width: 20, Height: 10}, isNewSide: func(oldRect, newRect domain.Rect) bool { return newRect.X > oldRect.X }},
31+ {name: "left", dir: layout.Left, size: domain.Size{Cols: 41, Rows: 10}, wantOld: domain.Rect{X: 21, Width: 20, Height: 10}, wantNew: domain.Rect{Width: 20, Height: 10}, isNewSide: func(oldRect, newRect domain.Rect) bool { return newRect.X < oldRect.X }},
32+ {name: "down", dir: layout.Down, size: domain.Size{Cols: 80, Rows: 5}, wantOld: domain.Rect{Width: 80, Height: 2}, wantNew: domain.Rect{Y: 3, Width: 80, Height: 2}, isNewSide: func(oldRect, newRect domain.Rect) bool { return newRect.Y > oldRect.Y }},
33+ {name: "up", dir: layout.Up, size: domain.Size{Cols: 80, Rows: 5}, wantOld: domain.Rect{Y: 3, Width: 80, Height: 2}, wantNew: domain.Rect{Width: 80, Height: 2}, isNewSide: func(oldRect, newRect domain.Rect) bool { return newRect.Y < oldRect.Y }},
3434 }
3535
3636 for _, tt := range tests {
@@ -25,12 +25,12 @@ func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) {
25 size domain.Size25 size domain.Size
26 wantNew domain.Rect26 wantNew domain.Rect
27 wantOld domain.Rect27 wantOld domain.Rect
28- isNewSide func(old, new domain.Rect) bool28+ isNewSide func(oldRect, newRect domain.Rect) bool
29 }{29 }{
30- {name: "right", dir: layout.Right, size: domain.Size{Cols: 41, Rows: 10}, wantOld: domain.Rect{Width: 20, Height: 10}, wantNew: domain.Rect{X: 21, Width: 20, Height: 10}, isNewSide: func(old, new domain.Rect) bool { return new.X > old.X }},30+ {name: "right", dir: layout.Right, size: domain.Size{Cols: 41, Rows: 10}, wantOld: domain.Rect{Width: 20, Height: 10}, wantNew: domain.Rect{X: 21, Width: 20, Height: 10}, isNewSide: func(oldRect, newRect domain.Rect) bool { return newRect.X > oldRect.X }},
31- {name: "left", dir: layout.Left, size: domain.Size{Cols: 41, Rows: 10}, wantOld: domain.Rect{X: 21, Width: 20, Height: 10}, wantNew: domain.Rect{Width: 20, Height: 10}, isNewSide: func(old, new domain.Rect) bool { return new.X < old.X }},31+ {name: "left", dir: layout.Left, size: domain.Size{Cols: 41, Rows: 10}, wantOld: domain.Rect{X: 21, Width: 20, Height: 10}, wantNew: domain.Rect{Width: 20, Height: 10}, isNewSide: func(oldRect, newRect domain.Rect) bool { return newRect.X < oldRect.X }},
32- {name: "down", dir: layout.Down, size: domain.Size{Cols: 80, Rows: 5}, wantOld: domain.Rect{Width: 80, Height: 2}, wantNew: domain.Rect{Y: 3, Width: 80, Height: 2}, isNewSide: func(old, new domain.Rect) bool { return new.Y > old.Y }},32+ {name: "down", dir: layout.Down, size: domain.Size{Cols: 80, Rows: 5}, wantOld: domain.Rect{Width: 80, Height: 2}, wantNew: domain.Rect{Y: 3, Width: 80, Height: 2}, isNewSide: func(oldRect, newRect domain.Rect) bool { return newRect.Y > oldRect.Y }},
33- {name: "up", dir: layout.Up, size: domain.Size{Cols: 80, Rows: 5}, wantOld: domain.Rect{Y: 3, Width: 80, Height: 2}, wantNew: domain.Rect{Width: 80, Height: 2}, isNewSide: func(old, new domain.Rect) bool { return new.Y < old.Y }},33+ {name: "up", dir: layout.Up, size: domain.Size{Cols: 80, Rows: 5}, wantOld: domain.Rect{Y: 3, Width: 80, Height: 2}, wantNew: domain.Rect{Width: 80, Height: 2}, isNewSide: func(oldRect, newRect domain.Rect) bool { return newRect.Y < oldRect.Y }},
34 }34 }
35 35
36 for _, tt := range tests {36 for _, tt := range tests {
M internal/usecase/daemon/picker_test.go +1 -1
@@ -331,7 +331,7 @@ func TestPickerOpenCloseNavigationConcurrentWithRenderRace(t *testing.T) {
331331 p.screen.Write([]byte("render-race"))
332332 p.mu.Unlock()
333333 }
334- d.render(sess, tb, tb.focusedPane())
334+ d.render(sess, tb, p)
335335 })
336336 }
337337 wg.Wait()
@@ -331,7 +331,7 @@ func TestPickerOpenCloseNavigationConcurrentWithRenderRace(t *testing.T) {
331 p.screen.Write([]byte("render-race"))331 p.screen.Write([]byte("render-race"))
332 p.mu.Unlock()332 p.mu.Unlock()
333 }333 }
334- d.render(sess, tb, tb.focusedPane())334+ d.render(sess, tb, p)
335 })335 })
336 }336 }
337 wg.Wait()337 wg.Wait()
M internal/usecase/daemon/render.go +9 -1
@@ -322,6 +322,12 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
322322 p := tb.focusedPane()
323323 if p == nil {
324324 tb.mu.Unlock()
325+ if paletteActive {
326+ ac.paletteMu.Unlock()
327+ }
328+ if promptActive {
329+ ac.promptMu.Unlock()
330+ }
325331 ac.sendMu.Unlock()
326332 return
327333 }
@@ -550,7 +556,9 @@ func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, l
550556 }
551557 placements = []layout.Placement{{ID: fallback.id, Content: area}}
552558 }
553- drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))
559+ if ok {
560+ drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))
561+ }
554562 var damage []renderer.Damage
555563 for _, pl := range placements {
556564 p := tb.panes[pl.ID]
@@ -322,6 +322,12 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
322 p := tb.focusedPane()322 p := tb.focusedPane()
323 if p == nil {323 if p == nil {
324 tb.mu.Unlock()324 tb.mu.Unlock()
325+ if paletteActive {
326+ ac.paletteMu.Unlock()
327+ }
328+ if promptActive {
329+ ac.promptMu.Unlock()
330+ }
325 ac.sendMu.Unlock()331 ac.sendMu.Unlock()
326 return332 return
327 }333 }
@@ -550,7 +556,9 @@ func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, l
550 }556 }
551 placements = []layout.Placement{{ID: fallback.id, Content: area}}557 placements = []layout.Placement{{ID: fallback.id, Content: area}}
552 }558 }
553- drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))559+ if ok {
560+ drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))
561+ }
554 var damage []renderer.Damage562 var damage []renderer.Damage
555 for _, pl := range placements {563 for _, pl := range placements {
556 p := tb.panes[pl.ID]564 p := tb.panes[pl.ID]
M internal/usecase/daemon/session.go +19 -18
@@ -195,6 +195,9 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
195195 return errors.New("daemon: session closed")
196196 }
197197 tb.ctx, tb.cancel = context.WithCancel(sess.ctx)
198+ for _, p := range tb.panes {
199+ p.ctx, p.cancel = context.WithCancel(tb.ctx)
200+ }
198201 sess.mu.Lock()
199202 sess.tabs = append(sess.tabs, tb)
200203 sess.active = len(sess.tabs) - 1
@@ -233,6 +236,20 @@ func (tb *tab) panesSnapshot() []*pane {
233236 return out
234237 }
235238
239+func (tb *tab) closeAllPanes() {
240+ tb.mu.Lock()
241+ panes := tb.panesSnapshot()
242+ tb.mu.Unlock()
243+ for _, p := range panes {
244+ if p.cancel != nil {
245+ p.cancel()
246+ }
247+ if p.pty != nil {
248+ _ = p.pty.Close()
249+ }
250+ }
251+}
252+
236253 func tabSize(clientSize domain.Size) domain.Size {
237254 if !clientSize.Valid() {
238255 clientSize = defaultSize
@@ -372,15 +389,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
372389 if tb.cancel != nil {
373390 tb.cancel()
374391 }
375- tb.mu.Lock()
376- panes := tb.panesSnapshot()
377- tb.mu.Unlock()
378- for _, p := range panes {
379- if p.cancel != nil {
380- p.cancel()
381- }
382- _ = p.pty.Close()
383- }
392+ tb.closeAllPanes()
384393 if repaint && ac != nil {
385394 d.paint(sess, ac, true)
386395 }
@@ -460,15 +469,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
460469 sess.mu.Unlock()
461470 for _, tb := range tabs {
462471 d.clearDestroyedTabPreview(tb)
463- tb.mu.Lock()
464- panes := tb.panesSnapshot()
465- tb.mu.Unlock()
466- for _, p := range panes {
467- if p.cancel != nil {
468- p.cancel()
469- }
470- _ = p.pty.Close()
471- }
472+ tb.closeAllPanes()
472473 }
473474 if empty {
474475 d.doneOnce.Do(func() { close(d.done) })
@@ -195,6 +195,9 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
195 return errors.New("daemon: session closed")195 return errors.New("daemon: session closed")
196 }196 }
197 tb.ctx, tb.cancel = context.WithCancel(sess.ctx)197 tb.ctx, tb.cancel = context.WithCancel(sess.ctx)
198+ for _, p := range tb.panes {
199+ p.ctx, p.cancel = context.WithCancel(tb.ctx)
200+ }
198 sess.mu.Lock()201 sess.mu.Lock()
199 sess.tabs = append(sess.tabs, tb)202 sess.tabs = append(sess.tabs, tb)
200 sess.active = len(sess.tabs) - 1203 sess.active = len(sess.tabs) - 1
@@ -233,6 +236,20 @@ func (tb *tab) panesSnapshot() []*pane {
233 return out236 return out
234 }237 }
235 238
239+func (tb *tab) closeAllPanes() {
240+ tb.mu.Lock()
241+ panes := tb.panesSnapshot()
242+ tb.mu.Unlock()
243+ for _, p := range panes {
244+ if p.cancel != nil {
245+ p.cancel()
246+ }
247+ if p.pty != nil {
248+ _ = p.pty.Close()
249+ }
250+ }
251+}
252+
236 func tabSize(clientSize domain.Size) domain.Size {253 func tabSize(clientSize domain.Size) domain.Size {
237 if !clientSize.Valid() {254 if !clientSize.Valid() {
238 clientSize = defaultSize255 clientSize = defaultSize
@@ -372,15 +389,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
372 if tb.cancel != nil {389 if tb.cancel != nil {
373 tb.cancel()390 tb.cancel()
374 }391 }
375- tb.mu.Lock()392+ tb.closeAllPanes()
376- panes := tb.panesSnapshot()
377- tb.mu.Unlock()
378- for _, p := range panes {
379- if p.cancel != nil {
380- p.cancel()
381- }
382- _ = p.pty.Close()
383- }
384 if repaint && ac != nil {393 if repaint && ac != nil {
385 d.paint(sess, ac, true)394 d.paint(sess, ac, true)
386 }395 }
@@ -460,15 +469,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
460 sess.mu.Unlock()469 sess.mu.Unlock()
461 for _, tb := range tabs {470 for _, tb := range tabs {
462 d.clearDestroyedTabPreview(tb)471 d.clearDestroyedTabPreview(tb)
463- tb.mu.Lock()472+ tb.closeAllPanes()
464- panes := tb.panesSnapshot()
465- tb.mu.Unlock()
466- for _, p := range panes {
467- if p.cancel != nil {
468- p.cancel()
469- }
470- _ = p.pty.Close()
471- }
472 }473 }
473 if empty {474 if empty {
474 d.doneOnce.Do(func() { close(d.done) })475 d.doneOnce.Do(func() { close(d.done) })
M internal/usecase/layout/layout_test.go +8 -0
@@ -97,6 +97,14 @@ func TestStackNewEmptyTreeReturnsError(t *testing.T) {
9797 require.Empty(t, tr.Focus)
9898 }
9999
100+func TestSplitEmptyTreeReturnsError(t *testing.T) {
101+ t.Parallel()
102+ tr := &Tree{}
103+ require.ErrorIs(t, tr.Split("missing", Right, true, "new", domain.Rect{Width: 41, Height: 2}), ErrNotFound)
104+ require.Nil(t, tr.Root)
105+ require.Empty(t, tr.Focus)
106+}
107+
100108 func TestNilTreeCloneIsSafe(t *testing.T) {
101109 t.Parallel()
102110 var tr *Tree
@@ -97,6 +97,14 @@ func TestStackNewEmptyTreeReturnsError(t *testing.T) {
97 require.Empty(t, tr.Focus)97 require.Empty(t, tr.Focus)
98 }98 }
99 99
100+func TestSplitEmptyTreeReturnsError(t *testing.T) {
101+ t.Parallel()
102+ tr := &Tree{}
103+ require.ErrorIs(t, tr.Split("missing", Right, true, "new", domain.Rect{Width: 41, Height: 2}), ErrNotFound)
104+ require.Nil(t, tr.Root)
105+ require.Empty(t, tr.Focus)
106+}
107+
100 func TestNilTreeCloneIsSafe(t *testing.T) {108 func TestNilTreeCloneIsSafe(t *testing.T) {
101 t.Parallel()109 t.Parallel()
102 var tr *Tree110 var tr *Tree
M internal/usecase/layout/ops.go +4 -1
@@ -18,7 +18,7 @@ var (
1818 // is placed after target on the chosen axis.
1919 func (t *Tree) Split(target PaneID, dir Direction, after bool, newID PaneID, area domain.Rect) error {
2020 candidate := t.clone()
21- if !insertSplit(candidate.Root, target, axisFor(dir), after, newID) {
21+ if candidate == nil || candidate.Root == nil || !insertSplit(candidate.Root, target, axisFor(dir), after, newID) {
2222 return ErrNotFound
2323 }
2424 candidate.Focus = newID
@@ -30,6 +30,9 @@ func (t *Tree) Split(target PaneID, dir Direction, after bool, newID PaneID, are
3030 }
3131
3232 func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID) bool {
33+ if n == nil {
34+ return false
35+ }
3336 for i, child := range n.Children {
3437 if child.Kind == Leaf && child.Leaf == target {
3538 newLeaf := NewLeaf(newID)
@@ -18,7 +18,7 @@ var (
18 // is placed after target on the chosen axis.18 // is placed after target on the chosen axis.
19 func (t *Tree) Split(target PaneID, dir Direction, after bool, newID PaneID, area domain.Rect) error {19 func (t *Tree) Split(target PaneID, dir Direction, after bool, newID PaneID, area domain.Rect) error {
20 candidate := t.clone()20 candidate := t.clone()
21- if !insertSplit(candidate.Root, target, axisFor(dir), after, newID) {21+ if candidate == nil || candidate.Root == nil || !insertSplit(candidate.Root, target, axisFor(dir), after, newID) {
22 return ErrNotFound22 return ErrNotFound
23 }23 }
24 candidate.Focus = newID24 candidate.Focus = newID
@@ -30,6 +30,9 @@ func (t *Tree) Split(target PaneID, dir Direction, after bool, newID PaneID, are
30 }30 }
31 31
32 func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID) bool {32 func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID) bool {
33+ if n == nil {
34+ return false
35+ }
33 for i, child := range n.Children {36 for i, child := range n.Children {
34 if child.Kind == Leaf && child.Leaf == target {37 if child.Kind == Leaf && child.Leaf == target {
35 newLeaf := NewLeaf(newID)38 newLeaf := NewLeaf(newID)
M internal/usecase/theme/theme.go +13 -10
@@ -274,29 +274,32 @@ func DimStyle(style renderer.Style, t Theme) renderer.Style {
274274 return out
275275 }
276276
277-func xterm256Color(index int) renderer.RGB {
278- if index < 0 {
279- return renderer.RGB{}
280- }
281- base := [...]renderer.RGB{
277+var (
278+ xterm256Base = [...]renderer.RGB{
282279 {R: 0x00, G: 0x00, B: 0x00}, {R: 0x80, G: 0x00, B: 0x00}, {R: 0x00, G: 0x80, B: 0x00}, {R: 0x80, G: 0x80, B: 0x00},
283280 {R: 0x00, G: 0x00, B: 0x80}, {R: 0x80, G: 0x00, B: 0x80}, {R: 0x00, G: 0x80, B: 0x80}, {R: 0xc0, G: 0xc0, B: 0xc0},
284281 {R: 0x80, G: 0x80, B: 0x80}, {R: 0xff, G: 0x00, B: 0x00}, {R: 0x00, G: 0xff, B: 0x00}, {R: 0xff, G: 0xff, B: 0x00},
285282 {R: 0x00, G: 0x00, B: 0xff}, {R: 0xff, G: 0x00, B: 0xff}, {R: 0x00, G: 0xff, B: 0xff}, {R: 0xff, G: 0xff, B: 0xff},
286283 }
287- if index < len(base) {
288- return base[index]
284+ xterm256Levels = [...]uint8{0, 95, 135, 175, 215, 255}
285+)
286+
287+func xterm256Color(index int) renderer.RGB {
288+ if index < 0 {
289+ return renderer.RGB{}
290+ }
291+ if index < len(xterm256Base) {
292+ return xterm256Base[index]
289293 }
290294 if index < 232 {
291295 index -= 16
292- levels := [...]uint8{0, 95, 135, 175, 215, 255}
293- return renderer.RGB{R: levels[index/36], G: levels[(index/6)%6], B: levels[index%6]}
296+ return renderer.RGB{R: xterm256Levels[index/36], G: xterm256Levels[(index/6)%6], B: xterm256Levels[index%6]}
294297 }
295298 if index < 256 {
296299 level := uint8(8 + (index-232)*10)
297300 return renderer.RGB{R: level, G: level, B: level}
298301 }
299- return base[index%len(base)]
302+ return xterm256Base[index%len(xterm256Base)]
300303 }
301304
302305 func SelectionStyle(t Theme) renderer.Style {
@@ -274,29 +274,32 @@ func DimStyle(style renderer.Style, t Theme) renderer.Style {
274 return out274 return out
275 }275 }
276 276
277-func xterm256Color(index int) renderer.RGB {277+var (
278- if index < 0 {278+ xterm256Base = [...]renderer.RGB{
279- return renderer.RGB{}
280- }
281- base := [...]renderer.RGB{
282 {R: 0x00, G: 0x00, B: 0x00}, {R: 0x80, G: 0x00, B: 0x00}, {R: 0x00, G: 0x80, B: 0x00}, {R: 0x80, G: 0x80, B: 0x00},279 {R: 0x00, G: 0x00, B: 0x00}, {R: 0x80, G: 0x00, B: 0x00}, {R: 0x00, G: 0x80, B: 0x00}, {R: 0x80, G: 0x80, B: 0x00},
283 {R: 0x00, G: 0x00, B: 0x80}, {R: 0x80, G: 0x00, B: 0x80}, {R: 0x00, G: 0x80, B: 0x80}, {R: 0xc0, G: 0xc0, B: 0xc0},280 {R: 0x00, G: 0x00, B: 0x80}, {R: 0x80, G: 0x00, B: 0x80}, {R: 0x00, G: 0x80, B: 0x80}, {R: 0xc0, G: 0xc0, B: 0xc0},
284 {R: 0x80, G: 0x80, B: 0x80}, {R: 0xff, G: 0x00, B: 0x00}, {R: 0x00, G: 0xff, B: 0x00}, {R: 0xff, G: 0xff, B: 0x00},281 {R: 0x80, G: 0x80, B: 0x80}, {R: 0xff, G: 0x00, B: 0x00}, {R: 0x00, G: 0xff, B: 0x00}, {R: 0xff, G: 0xff, B: 0x00},
285 {R: 0x00, G: 0x00, B: 0xff}, {R: 0xff, G: 0x00, B: 0xff}, {R: 0x00, G: 0xff, B: 0xff}, {R: 0xff, G: 0xff, B: 0xff},282 {R: 0x00, G: 0x00, B: 0xff}, {R: 0xff, G: 0x00, B: 0xff}, {R: 0x00, G: 0xff, B: 0xff}, {R: 0xff, G: 0xff, B: 0xff},
286 }283 }
287- if index < len(base) {284+ xterm256Levels = [...]uint8{0, 95, 135, 175, 215, 255}
288- return base[index]285+)
286+
287+func xterm256Color(index int) renderer.RGB {
288+ if index < 0 {
289+ return renderer.RGB{}
290+ }
291+ if index < len(xterm256Base) {
292+ return xterm256Base[index]
289 }293 }
290 if index < 232 {294 if index < 232 {
291 index -= 16295 index -= 16
292- levels := [...]uint8{0, 95, 135, 175, 215, 255}296+ return renderer.RGB{R: xterm256Levels[index/36], G: xterm256Levels[(index/6)%6], B: xterm256Levels[index%6]}
293- return renderer.RGB{R: levels[index/36], G: levels[(index/6)%6], B: levels[index%6]}
294 }297 }
295 if index < 256 {298 if index < 256 {
296 level := uint8(8 + (index-232)*10)299 level := uint8(8 + (index-232)*10)
297 return renderer.RGB{R: level, G: level, B: level}300 return renderer.RGB{R: level, G: level, B: level}
298 }301 }
299- return base[index%len(base)]302+ return xterm256Base[index%len(xterm256Base)]
300 }303 }
301 304
302 func SelectionStyle(t Theme) renderer.Style {305 func SelectionStyle(t Theme) renderer.Style {

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