fix(panes): address PR review findings
- commit
- 6389371ce8d570120fad8168c78e1ac5d5aed226
- author
- brice · · 2026-07-04 09:06 UTC
- parent
- 23380f8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 103 | 103 | acW := &attachedClient{tr: trW, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | |
| 104 | 104 | sctxW, cancelW := context.WithCancel(d.serveCtx) | |
| 105 | 105 | 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) | |
| 111 | 107 | sessW := &session{id: "wedged", name: "wedged", ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}, client: acW} | |
| 112 | 108 | acW.setSession(sessW) | |
| 113 | 109 | acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW}) | |
| @@ -354,11 +350,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) { | |||
| 354 | 350 | acB := &attachedClient{tr: trB, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | |
| 355 | 351 | sctxB, cancelB := context.WithCancel(d.serveCtx) | |
| 356 | 352 | 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) | |
| 362 | 354 | sessB := &session{id: "sessB", name: "other", ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}, client: acB} | |
| 363 | 355 | acB.setSession(sessB) | |
| 364 | 356 | acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}) | |
| @@ -404,14 +396,8 @@ func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) { | |||
| 404 | 396 | d, sess1, ac, sends := newManualSessionWithPTYs(t, p1) | |
| 405 | 397 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| 406 | 398 | 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) | |
| 415 | 401 | tab2a.attention = true | |
| 416 | 402 | tab2a.attentionAt = time.Unix(9, 0) | |
| 417 | 403 | 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 = true | 401 | tab2a.attention = true | ||
| 416 | tab2a.attentionAt = time.Unix(9, 0) | 402 | tab2a.attentionAt = time.Unix(9, 0) | ||
| 417 | tab2b.attention = true | 403 | tab2b.attention = true | ||
M internal/usecase/daemon/input.go +2 -7
| @@ -278,7 +278,7 @@ func clampInt(v, lo, hi int) int { | |||
| 278 | 278 | } | |
| 279 | 279 | ||
| 280 | 280 | func (d *Daemon) writeToPane(sess *session, p *pane, data []byte) { | |
| 281 | - | if p == nil { | |
| 281 | + | if p == nil || p.pty == nil { | |
| 282 | 282 | return | |
| 283 | 283 | } | |
| 284 | 284 | if _, err := p.pty.Write(data); err != nil { | |
| @@ -349,12 +349,7 @@ func (h daemonKeyHandler) Forward(data []byte) { | |||
| 349 | 349 | tb.mu.Lock() | |
| 350 | 350 | p := tb.focusedPane() | |
| 351 | 351 | 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) | |
| 358 | 353 | } | |
| 359 | 354 | ||
| 360 | 355 | 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 | return | 282 | 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 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "strconv" | |
| 4 | 5 | "strings" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/vev/internal/domain" | |
| @@ -80,9 +81,9 @@ func writeLayoutFingerprint(b *strings.Builder, n *layout.Node) { | |||
| 80 | 81 | } | |
| 81 | 82 | b.WriteByte(byte('0' + n.Kind)) | |
| 82 | 83 | b.WriteByte(byte('0' + n.Dir)) | |
| 83 | - | b.WriteString(string(n.Leaf)) | |
| 84 | + | writePaneIDFingerprint(b, n.Leaf) | |
| 84 | 85 | b.WriteByte('|') | |
| 85 | - | b.WriteString(string(n.Expanded)) | |
| 86 | + | writePaneIDFingerprint(b, n.Expanded) | |
| 86 | 87 | b.WriteByte('[') | |
| 87 | 88 | for _, child := range n.Children { | |
| 88 | 89 | writeLayoutFingerprint(b, child) | |
| @@ -91,6 +92,13 @@ func writeLayoutFingerprint(b *strings.Builder, n *layout.Node) { | |||
| 91 | 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 | 102 | func pointInRect(col, row int, r domain.Rect) bool { | |
| 95 | 103 | 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 | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | 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.Height | 103 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/vev/internal/domain" | |
| @@ -123,8 +124,10 @@ func (d *Daemon) resizePane(p *pane, r domain.Rect) { | |||
| 123 | 124 | return | |
| 124 | 125 | } | |
| 125 | 126 | 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 | + | } | |
| 128 | 131 | } | |
| 129 | 132 | p.mu.Lock() | |
| 130 | 133 | p.screen.Resize(sz.Cols, sz.Rows) | |
| @@ -161,6 +164,10 @@ func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error { | |||
| 161 | 164 | return layout.ErrNotFound | |
| 162 | 165 | } | |
| 163 | 166 | tb.mu.Lock() | |
| 167 | + | if tb.tree == nil { | |
| 168 | + | tb.mu.Unlock() | |
| 169 | + | return layout.ErrNotFound | |
| 170 | + | } | |
| 164 | 171 | err := tb.tree.ToggleStack(tb.tree.Focus, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |
| 165 | 172 | if err == nil { | |
| 166 | 173 | d.applyLayoutLocked(tb) | |
| @@ -226,7 +233,9 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||
| 226 | 233 | if p.cancel != nil { | |
| 227 | 234 | p.cancel() | |
| 228 | 235 | } | |
| 229 | - | _ = p.pty.Close() | |
| 236 | + | if p.pty != nil { | |
| 237 | + | _ = p.pty.Close() | |
| 238 | + | } | |
| 230 | 239 | if repaint { | |
| 231 | 240 | if ac == nil { | |
| 232 | 241 | sess.mu.Lock() | |
| @@ -269,7 +278,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio | |||
| 269 | 278 | } | |
| 270 | 279 | d.paint(sess, ac, true) | |
| 271 | 280 | } | |
| 272 | - | if err == layout.ErrNoPane { | |
| 281 | + | if errors.Is(err, layout.ErrNoPane) { | |
| 273 | 282 | return nil | |
| 274 | 283 | } | |
| 275 | 284 | 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 | return | 124 | 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.ErrNotFound | 164 | 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 nil | 282 | return nil | ||
| 274 | } | 283 | } | ||
| 275 | return err | 284 | return err | ||
M internal/usecase/daemon/pane_actions_test.go +5 -5
| @@ -25,12 +25,12 @@ func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) { | |||
| 25 | 25 | size domain.Size | |
| 26 | 26 | wantNew domain.Rect | |
| 27 | 27 | wantOld domain.Rect | |
| 28 | - | isNewSide func(old, new domain.Rect) bool | |
| 28 | + | 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 }}, | |
| 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 }}, | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | 36 | for _, tt := range tests { | |
| @@ -25,12 +25,12 @@ func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) { | |||||
| 25 | size domain.Size | 25 | size domain.Size | ||
| 26 | wantNew domain.Rect | 26 | wantNew domain.Rect | ||
| 27 | wantOld domain.Rect | 27 | wantOld domain.Rect | ||
| 28 | - | isNewSide func(old, new domain.Rect) bool | 28 | + | 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) { | |||
| 331 | 331 | p.screen.Write([]byte("render-race")) | |
| 332 | 332 | p.mu.Unlock() | |
| 333 | 333 | } | |
| 334 | - | d.render(sess, tb, tb.focusedPane()) | |
| 334 | + | d.render(sess, tb, p) | |
| 335 | 335 | }) | |
| 336 | 336 | } | |
| 337 | 337 | 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) { | |||
| 322 | 322 | p := tb.focusedPane() | |
| 323 | 323 | if p == nil { | |
| 324 | 324 | tb.mu.Unlock() | |
| 325 | + | if paletteActive { | |
| 326 | + | ac.paletteMu.Unlock() | |
| 327 | + | } | |
| 328 | + | if promptActive { | |
| 329 | + | ac.promptMu.Unlock() | |
| 330 | + | } | |
| 325 | 331 | ac.sendMu.Unlock() | |
| 326 | 332 | return | |
| 327 | 333 | } | |
| @@ -550,7 +556,9 @@ func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, l | |||
| 550 | 556 | } | |
| 551 | 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 | 562 | var damage []renderer.Damage | |
| 555 | 563 | for _, pl := range placements { | |
| 556 | 564 | 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 | return | 332 | 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.Damage | 562 | 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 { | |||
| 195 | 195 | return errors.New("daemon: session closed") | |
| 196 | 196 | } | |
| 197 | 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 | 201 | sess.mu.Lock() | |
| 199 | 202 | sess.tabs = append(sess.tabs, tb) | |
| 200 | 203 | sess.active = len(sess.tabs) - 1 | |
| @@ -233,6 +236,20 @@ func (tb *tab) panesSnapshot() []*pane { | |||
| 233 | 236 | 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 | 253 | func tabSize(clientSize domain.Size) domain.Size { | |
| 237 | 254 | if !clientSize.Valid() { | |
| 238 | 255 | clientSize = defaultSize | |
| @@ -372,15 +389,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||
| 372 | 389 | if tb.cancel != nil { | |
| 373 | 390 | tb.cancel() | |
| 374 | 391 | } | |
| 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() | |
| 384 | 393 | if repaint && ac != nil { | |
| 385 | 394 | d.paint(sess, ac, true) | |
| 386 | 395 | } | |
| @@ -460,15 +469,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||
| 460 | 469 | sess.mu.Unlock() | |
| 461 | 470 | for _, tb := range tabs { | |
| 462 | 471 | 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() | |
| 472 | 473 | } | |
| 473 | 474 | if empty { | |
| 474 | 475 | 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) - 1 | 203 | sess.active = len(sess.tabs) - 1 | ||
| @@ -233,6 +236,20 @@ func (tb *tab) panesSnapshot() []*pane { | |||||
| 233 | return out | 236 | 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 = defaultSize | 255 | 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) { | |||
| 97 | 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 | 108 | func TestNilTreeCloneIsSafe(t *testing.T) { | |
| 101 | 109 | t.Parallel() | |
| 102 | 110 | 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 *Tree | 110 | var tr *Tree | ||
M internal/usecase/layout/ops.go +4 -1
| @@ -18,7 +18,7 @@ var ( | |||
| 18 | 18 | // is placed after target on the chosen axis. | |
| 19 | 19 | func (t *Tree) Split(target PaneID, dir Direction, after bool, newID PaneID, area domain.Rect) error { | |
| 20 | 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 | 22 | return ErrNotFound | |
| 23 | 23 | } | |
| 24 | 24 | 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 | 32 | func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID) bool { | |
| 33 | + | if n == nil { | |
| 34 | + | return false | |
| 35 | + | } | |
| 33 | 36 | for i, child := range n.Children { | |
| 34 | 37 | if child.Kind == Leaf && child.Leaf == target { | |
| 35 | 38 | 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 ErrNotFound | 22 | return ErrNotFound | ||
| 23 | } | 23 | } | ||
| 24 | candidate.Focus = newID | 24 | 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 { | |||
| 274 | 274 | return out | |
| 275 | 275 | } | |
| 276 | 276 | ||
| 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{ | |
| 282 | 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 | 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 | 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 | 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) { | |
| 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] | |
| 289 | 293 | } | |
| 290 | 294 | if index < 232 { | |
| 291 | 295 | 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]} | |
| 294 | 297 | } | |
| 295 | 298 | if index < 256 { | |
| 296 | 299 | level := uint8(8 + (index-232)*10) | |
| 297 | 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 | 305 | func SelectionStyle(t Theme) renderer.Style { | |
| @@ -274,29 +274,32 @@ func DimStyle(style renderer.Style, t Theme) renderer.Style { | |||||
| 274 | return out | 274 | 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 -= 16 | 295 | 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 { | ||