detached 84b1781

fix(daemon): preserve buffered redraws through resize

commit
84b17816ab57546cec7dd981d6beea750627fee9
author
brice · · 2026-07-12 09:56 UTC
parent
24d8746
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +174 -23
M internal/usecase/daemon/output_state_sync_test.go +28 -9
@@ -279,30 +279,49 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) {
279279 {name: "stream window", window: maxUnackedOutputStates},
280280 } {
281281 t.Run(tc.name, func(t *testing.T) {
282- p, releasePTY := newBlockingPTY(t)
283- defer releasePTY()
282+ // resizeReaderPTY delivers the child's SIGWINCH redraw through the
283+ // actual ptyReader. Its Resize callback does not return until Read has
284+ // accepted the bytes, at which point resizeApplying is still true.
285+ p := newResizeReaderPTY([]byte("\x1b[1;81H" + strings.Repeat("B", 40)))
284286 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
285287 ac.output.maxOutstanding = tc.window
286288 pane := sess.activeTab().focusedPane()
287-
289+ p.applying = func() bool {
290+ pane.mu.Lock()
291+ defer pane.mu.Unlock()
292+ return pane.resizeApplying
293+ }
288294 pane.screen.Write([]byte(strings.Repeat("A", 79)))
289295 d.paint(sess, ac, true)
290296 client := vt.NewScreen(80, 24)
291297 initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
292298 ac.ackOutputState(initial.NewStateNum)
293299
294- // The child can redraw the newly exposed columns while the resize
295- // output is being prepared. The first visible grown frame must contain
296- // that redraw, regardless of the negotiated output window.
300+ d.sessWg.Add(1)
301+ pane.onExit = func() {}
302+ go d.ptyReader(sess, sess.activeTab(), pane)
303+ defer func() { p.close(); d.sessWg.Wait() }()
304+
305+ // The resize deadline drives coordinator prepare/apply/commit. No test
306+ // writes the grown columns directly.
307+ clock := &signalClock{timers: make(chan *signalTimer, 2)}
308+ d.clock = clock
297309 d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
298- pane.screen.Write([]byte("\x1b[1;81H" + strings.Repeat("B", 40)))
299- d.paint(sess, ac, false)
310+ timer := <-clock.timers
311+ timer.ch <- time.Time{}
312+ awaitResizeCallback(t, sess.renderCoordinator())
313+ require.True(t, p.deliveredWhileApplying(), "redraw must arrive while PTY.Resize owns the apply gate")
314+ require.Equal(t, 'B', pane.screen.Frame.At(100, 0).Rune, "replay must parse the redraw before commit")
300315
301316 client.Resize(120, 24)
302317 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
303318 require.Equal(t, 'B', client.Frame.At(100, 1).Rune,
304319 "first grown frame exposed stale pre-SIGWINCH pane content")
305- requireNoOutputFrame(t, sends)
320+ select {
321+ case extra := <-sends:
322+ t.Fatalf("resize emitted more than one frame: %#v", extra)
323+ default:
324+ }
306325 })
307326 }
308327 }
@@ -279,30 +279,49 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) {
279 {name: "stream window", window: maxUnackedOutputStates},279 {name: "stream window", window: maxUnackedOutputStates},
280 } {280 } {
281 t.Run(tc.name, func(t *testing.T) {281 t.Run(tc.name, func(t *testing.T) {
282- p, releasePTY := newBlockingPTY(t)282+ // resizeReaderPTY delivers the child's SIGWINCH redraw through the
283- defer releasePTY()283+ // actual ptyReader. Its Resize callback does not return until Read has
284+ // accepted the bytes, at which point resizeApplying is still true.
285+ p := newResizeReaderPTY([]byte("\x1b[1;81H" + strings.Repeat("B", 40)))
284 d, sess, ac, sends := newManualSessionWithPTYs(t, p)286 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
285 ac.output.maxOutstanding = tc.window287 ac.output.maxOutstanding = tc.window
286 pane := sess.activeTab().focusedPane()288 pane := sess.activeTab().focusedPane()
287-289+ p.applying = func() bool {
290+ pane.mu.Lock()
291+ defer pane.mu.Unlock()
292+ return pane.resizeApplying
293+ }
288 pane.screen.Write([]byte(strings.Repeat("A", 79)))294 pane.screen.Write([]byte(strings.Repeat("A", 79)))
289 d.paint(sess, ac, true)295 d.paint(sess, ac, true)
290 client := vt.NewScreen(80, 24)296 client := vt.NewScreen(80, 24)
291 initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))297 initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
292 ac.ackOutputState(initial.NewStateNum)298 ac.ackOutputState(initial.NewStateNum)
293 299
294- // The child can redraw the newly exposed columns while the resize300+ d.sessWg.Add(1)
295- // output is being prepared. The first visible grown frame must contain301+ pane.onExit = func() {}
296- // that redraw, regardless of the negotiated output window.302+ go d.ptyReader(sess, sess.activeTab(), pane)
303+ defer func() { p.close(); d.sessWg.Wait() }()
304+
305+ // The resize deadline drives coordinator prepare/apply/commit. No test
306+ // writes the grown columns directly.
307+ clock := &signalClock{timers: make(chan *signalTimer, 2)}
308+ d.clock = clock
297 d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})309 d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
298- pane.screen.Write([]byte("\x1b[1;81H" + strings.Repeat("B", 40)))310+ timer := <-clock.timers
299- d.paint(sess, ac, false)311+ timer.ch <- time.Time{}
312+ awaitResizeCallback(t, sess.renderCoordinator())
313+ require.True(t, p.deliveredWhileApplying(), "redraw must arrive while PTY.Resize owns the apply gate")
314+ require.Equal(t, 'B', pane.screen.Frame.At(100, 0).Rune, "replay must parse the redraw before commit")
300 315
301 client.Resize(120, 24)316 client.Resize(120, 24)
302 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))317 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
303 require.Equal(t, 'B', client.Frame.At(100, 1).Rune,318 require.Equal(t, 'B', client.Frame.At(100, 1).Rune,
304 "first grown frame exposed stale pre-SIGWINCH pane content")319 "first grown frame exposed stale pre-SIGWINCH pane content")
305- requireNoOutputFrame(t, sends)320+ select {
321+ case extra := <-sends:
322+ t.Fatalf("resize emitted more than one frame: %#v", extra)
323+ default:
324+ }
306 })325 })
307 }326 }
308 }327 }
M internal/usecase/daemon/transactional_resize.go +17 -12
@@ -8,14 +8,15 @@ import (
88 // resizeMember is an unpublished layout decision. Prepare only reads guarded
99 // state; apply performs the external PTY call; commit is the sole publisher.
1010 type resizeMember struct {
11- session *session
12- tab *tab
13- pane *pane
14- rect domain.Rect
15- floating floatingGeometry
16- isFloating bool
17- retry bool
18- ok bool
11+ session *session
12+ tab *tab
13+ pane *pane
14+ rect domain.Rect
15+ floating floatingGeometry
16+ isFloating bool
17+ retry bool
18+ ok bool
19+ screenResized bool
1920 }
2021
2122 type resizePlan struct {
@@ -93,6 +94,7 @@ func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool {
9394 } else {
9495 m.ok = true
9596 d.replayResizePending(m.session, m.tab, m.pane, true, m.rect)
97+ m.screenResized = true
9698 }
9799 m.pane.resizeMu.Unlock()
98100 }
@@ -148,7 +150,7 @@ func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan
148150 if m.isFloating {
149151 m.pane.popupGeometry = m.floating
150152 }
151- if m.ok {
153+ if m.ok && !m.screenResized {
152154 m.pane.screen.Resize(m.rect.Width, m.rect.Height)
153155 }
154156 m.pane.mu.Unlock()
@@ -223,9 +225,12 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, epoch uin
223225 continue
224226 }
225227 member.pane.mu.Lock()
226- // The rect is the already committed layout target. Resize VT only now,
227- // then force a reset so renderer shadow cannot retain padded cells.
228- member.pane.screen.Resize(member.rect.Width, member.rect.Height)
228+ // The rect is the already committed layout target. A successful apply
229+ // already resized before replaying buffered bytes; PTY-less members still
230+ // need their VT resized here. Force a reset below in either case.
231+ if !member.screenResized {
232+ member.pane.screen.Resize(member.rect.Width, member.rect.Height)
233+ }
229234 member.pane.mu.Unlock()
230235 succeeded = true
231236 }
@@ -8,14 +8,15 @@ import (
8 // resizeMember is an unpublished layout decision. Prepare only reads guarded8 // resizeMember is an unpublished layout decision. Prepare only reads guarded
9 // state; apply performs the external PTY call; commit is the sole publisher.9 // state; apply performs the external PTY call; commit is the sole publisher.
10 type resizeMember struct {10 type resizeMember struct {
11- session *session11+ session *session
12- tab *tab12+ tab *tab
13- pane *pane13+ pane *pane
14- rect domain.Rect14+ rect domain.Rect
15- floating floatingGeometry15+ floating floatingGeometry
16- isFloating bool16+ isFloating bool
17- retry bool17+ retry bool
18- ok bool18+ ok bool
19+ screenResized bool
19 }20 }
20 21
21 type resizePlan struct {22 type resizePlan struct {
@@ -93,6 +94,7 @@ func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool {
93 } else {94 } else {
94 m.ok = true95 m.ok = true
95 d.replayResizePending(m.session, m.tab, m.pane, true, m.rect)96 d.replayResizePending(m.session, m.tab, m.pane, true, m.rect)
97+ m.screenResized = true
96 }98 }
97 m.pane.resizeMu.Unlock()99 m.pane.resizeMu.Unlock()
98 }100 }
@@ -148,7 +150,7 @@ func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan
148 if m.isFloating {150 if m.isFloating {
149 m.pane.popupGeometry = m.floating151 m.pane.popupGeometry = m.floating
150 }152 }
151- if m.ok {153+ if m.ok && !m.screenResized {
152 m.pane.screen.Resize(m.rect.Width, m.rect.Height)154 m.pane.screen.Resize(m.rect.Width, m.rect.Height)
153 }155 }
154 m.pane.mu.Unlock()156 m.pane.mu.Unlock()
@@ -223,9 +225,12 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, epoch uin
223 continue225 continue
224 }226 }
225 member.pane.mu.Lock()227 member.pane.mu.Lock()
226- // The rect is the already committed layout target. Resize VT only now,228+ // The rect is the already committed layout target. A successful apply
227- // then force a reset so renderer shadow cannot retain padded cells.229+ // already resized before replaying buffered bytes; PTY-less members still
228- member.pane.screen.Resize(member.rect.Width, member.rect.Height)230+ // need their VT resized here. Force a reset below in either case.
231+ if !member.screenResized {
232+ member.pane.screen.Resize(member.rect.Width, member.rect.Height)
233+ }
229 member.pane.mu.Unlock()234 member.pane.mu.Unlock()
230 succeeded = true235 succeeded = true
231 }236 }
M internal/usecase/daemon/transactional_resize_test.go +129 -2
@@ -22,6 +22,7 @@ type transactionalResizePTY struct {
2222 sizes []domain.Size
2323 errs []error
2424 onResize func()
25+ onWrite func([]byte)
2526 }
2627
2728 func (p *transactionalResizePTY) Resize(size domain.Size) error {
@@ -39,8 +40,16 @@ func (p *transactionalResizePTY) Resize(size domain.Size) error {
3940 }
4041 return err
4142 }
42-func (*transactionalResizePTY) Read([]byte) (int, error) { return 0, io.EOF }
43-func (*transactionalResizePTY) Write(b []byte) (int, error) { return len(b), nil }
43+func (*transactionalResizePTY) Read([]byte) (int, error) { return 0, io.EOF }
44+func (p *transactionalResizePTY) Write(b []byte) (int, error) {
45+ p.mu.Lock()
46+ hook := p.onWrite
47+ p.mu.Unlock()
48+ if hook != nil {
49+ hook(append([]byte(nil), b...))
50+ }
51+ return len(b), nil
52+}
4453 func (*transactionalResizePTY) Close() error { return nil }
4554 func (*transactionalResizePTY) Pid() int { return 0 }
4655 func (*transactionalResizePTY) ForegroundPgid() (int, error) { return 0, nil }
@@ -50,6 +59,124 @@ func (p *transactionalResizePTY) requested() []domain.Size {
5059 return append([]domain.Size(nil), p.sizes...)
5160 }
5261
62+// resizeReaderPTY is a deterministic child: Resize releases its redraw only
63+// after the daemon has installed resizeApplying, and waits until ptyReader has
64+// accepted that read. It deliberately has no timing dependency.
65+type resizeReaderPTY struct {
66+ redraw []byte
67+ reads chan []byte
68+ accepted chan struct{}
69+ closed chan struct{}
70+ closeOnce sync.Once
71+ mu sync.Mutex
72+ applying func() bool
73+ delivered bool
74+}
75+
76+func newResizeReaderPTY(redraw []byte) *resizeReaderPTY {
77+ return &resizeReaderPTY{redraw: redraw, reads: make(chan []byte), accepted: make(chan struct{}), closed: make(chan struct{})}
78+}
79+
80+func (p *resizeReaderPTY) Resize(domain.Size) error {
81+ p.reads <- p.redraw
82+ <-p.accepted
83+ // A second, empty read cannot begin until ptyReader has processed the
84+ // redraw from the first read. This is a channel rendezvous, not a timing
85+ // assumption, and proves the redraw entered resizePending before Resize
86+ // returns to replay it.
87+ p.reads <- nil
88+ <-p.accepted
89+ p.mu.Lock()
90+ p.delivered = p.applying != nil && p.applying()
91+ p.mu.Unlock()
92+ return nil
93+}
94+func (p *resizeReaderPTY) Read(dst []byte) (int, error) {
95+ select {
96+ case data := <-p.reads:
97+ n := copy(dst, data)
98+ p.accepted <- struct{}{}
99+ return n, nil
100+ case <-p.closed:
101+ return 0, io.EOF
102+ }
103+}
104+func (*resizeReaderPTY) Write(b []byte) (int, error) { return len(b), nil }
105+func (*resizeReaderPTY) Pid() int { return 0 }
106+func (*resizeReaderPTY) ForegroundPgid() (int, error) { return 0, nil }
107+func (p *resizeReaderPTY) Close() error { p.close(); return nil }
108+func (p *resizeReaderPTY) close() { p.closeOnce.Do(func() { close(p.closed) }) }
109+func (p *resizeReaderPTY) deliveredWhileApplying() bool {
110+ p.mu.Lock()
111+ defer p.mu.Unlock()
112+ return p.delivered
113+}
114+
115+func TestReplayResizePendingBuffersSuccessFailureAndBatchOrder(t *testing.T) {
116+ t.Run("success resizes then replays", func(t *testing.T) {
117+ pty := &transactionalResizePTY{}
118+ d, sess, _, _ := newManualSessionWithPTYs(t, pty)
119+ tb, p := sess.activeTab(), sess.activeTab().focusedPane()
120+ p.resizeApplying = true
121+ p.resizePending = []byte("A\x1b[1;81HB")
122+ d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 120, Height: 23})
123+ require.Equal(t, 120, p.screen.Frame.Width)
124+ require.Equal(t, 'B', p.screen.Frame.At(80, 0).Rune)
125+ require.False(t, p.resizeApplying)
126+ })
127+ t.Run("failure retains old parser width", func(t *testing.T) {
128+ pty := &transactionalResizePTY{}
129+ d, sess, _, _ := newManualSessionWithPTYs(t, pty)
130+ tb, p := sess.activeTab(), sess.activeTab().focusedPane()
131+ p.resizeApplying = true
132+ p.resizePending = []byte("\x1b[1;81HB")
133+ d.replayResizePending(sess, tb, p, false, domain.Rect{Width: 120, Height: 23})
134+ require.Equal(t, 80, p.screen.Frame.Width)
135+ require.Equal(t, 'B', p.screen.Frame.At(79, 0).Rune)
136+ })
137+ t.Run("batches retain read order", func(t *testing.T) {
138+ pty := &transactionalResizePTY{}
139+ d, sess, _, _ := newManualSessionWithPTYs(t, pty)
140+ tb, p := sess.activeTab(), sess.activeTab().focusedPane()
141+ p.screen.OnResponse = func([]byte) { p.ptyResponses = append(p.ptyResponses, 'r') }
142+ pty.onWrite = func([]byte) {
143+ p.mu.Lock()
144+ p.resizePending = append(p.resizePending, 'B')
145+ p.mu.Unlock()
146+ }
147+ p.resizeApplying = true
148+ p.resizePending = []byte("A\x1b[6n")
149+ d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 80, Height: 23})
150+ require.Equal(t, "AB", screenLineText(p.screen, 0)[:2])
151+ })
152+}
153+
154+func TestProcessPTYDataRetainsCallbacksDuringResizeReplay(t *testing.T) {
155+ responses := make(chan []byte, 1)
156+ pty := &transactionalResizePTY{onWrite: func(b []byte) { responses <- b }}
157+ d, sess, _, sends := newManualSessionWithPTYs(t, pty)
158+ tb, p := sess.activeTab(), sess.activeTab().focusedPane()
159+ p.screen.OnResponse = func(b []byte) { p.ptyResponses = append(p.ptyResponses, b...) }
160+ p.screen.OnBell = func() { p.ptyAttention = true }
161+ p.screen.OnClipboard = func(b64 string) { p.ptyClipboards = append(p.ptyClipboards, b64) }
162+ p.resizeApplying = true
163+ p.resizePending = []byte("\x1b]2;resized\a\a\x1b]52;c;YQ==\a\x1b[6n")
164+ d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 80, Height: 23})
165+
166+ p.mu.Lock()
167+ title := p.title.terminalTitle
168+ p.mu.Unlock()
169+ require.Equal(t, "resized", title)
170+ require.True(t, tb.attention)
171+ require.NotEmpty(t, <-responses, "DSR response must be flushed back to the child")
172+ // DSR is flushed back to the child and OSC 52 is forwarded through the
173+ // normal asynchronous client path, proving replay uses processPTYData.
174+ frame := awaitFrame(t, sends, ports.MsgOutput)
175+ out, err := ports.UnmarshalOutput(frame.Payload)
176+ require.NoError(t, err)
177+ require.Contains(t, string(out.Data), "\x1b]52;c;YQ==\a")
178+}
179+
53180 // S3 acceptance: prepare may inspect layout under its locks, but apply must
54181 // invoke PTY.Resize after all session/tab/pane locks are released. This is the
55182 // deadlock boundary with PTY readers and scripts which synchronously re-enter
@@ -22,6 +22,7 @@ type transactionalResizePTY struct {
22 sizes []domain.Size22 sizes []domain.Size
23 errs []error23 errs []error
24 onResize func()24 onResize func()
25+ onWrite func([]byte)
25 }26 }
26 27
27 func (p *transactionalResizePTY) Resize(size domain.Size) error {28 func (p *transactionalResizePTY) Resize(size domain.Size) error {
@@ -39,8 +40,16 @@ func (p *transactionalResizePTY) Resize(size domain.Size) error {
39 }40 }
40 return err41 return err
41 }42 }
42-func (*transactionalResizePTY) Read([]byte) (int, error) { return 0, io.EOF }43+func (*transactionalResizePTY) Read([]byte) (int, error) { return 0, io.EOF }
43-func (*transactionalResizePTY) Write(b []byte) (int, error) { return len(b), nil }44+func (p *transactionalResizePTY) Write(b []byte) (int, error) {
45+ p.mu.Lock()
46+ hook := p.onWrite
47+ p.mu.Unlock()
48+ if hook != nil {
49+ hook(append([]byte(nil), b...))
50+ }
51+ return len(b), nil
52+}
44 func (*transactionalResizePTY) Close() error { return nil }53 func (*transactionalResizePTY) Close() error { return nil }
45 func (*transactionalResizePTY) Pid() int { return 0 }54 func (*transactionalResizePTY) Pid() int { return 0 }
46 func (*transactionalResizePTY) ForegroundPgid() (int, error) { return 0, nil }55 func (*transactionalResizePTY) ForegroundPgid() (int, error) { return 0, nil }
@@ -50,6 +59,124 @@ func (p *transactionalResizePTY) requested() []domain.Size {
50 return append([]domain.Size(nil), p.sizes...)59 return append([]domain.Size(nil), p.sizes...)
51 }60 }
52 61
62+// resizeReaderPTY is a deterministic child: Resize releases its redraw only
63+// after the daemon has installed resizeApplying, and waits until ptyReader has
64+// accepted that read. It deliberately has no timing dependency.
65+type resizeReaderPTY struct {
66+ redraw []byte
67+ reads chan []byte
68+ accepted chan struct{}
69+ closed chan struct{}
70+ closeOnce sync.Once
71+ mu sync.Mutex
72+ applying func() bool
73+ delivered bool
74+}
75+
76+func newResizeReaderPTY(redraw []byte) *resizeReaderPTY {
77+ return &resizeReaderPTY{redraw: redraw, reads: make(chan []byte), accepted: make(chan struct{}), closed: make(chan struct{})}
78+}
79+
80+func (p *resizeReaderPTY) Resize(domain.Size) error {
81+ p.reads <- p.redraw
82+ <-p.accepted
83+ // A second, empty read cannot begin until ptyReader has processed the
84+ // redraw from the first read. This is a channel rendezvous, not a timing
85+ // assumption, and proves the redraw entered resizePending before Resize
86+ // returns to replay it.
87+ p.reads <- nil
88+ <-p.accepted
89+ p.mu.Lock()
90+ p.delivered = p.applying != nil && p.applying()
91+ p.mu.Unlock()
92+ return nil
93+}
94+func (p *resizeReaderPTY) Read(dst []byte) (int, error) {
95+ select {
96+ case data := <-p.reads:
97+ n := copy(dst, data)
98+ p.accepted <- struct{}{}
99+ return n, nil
100+ case <-p.closed:
101+ return 0, io.EOF
102+ }
103+}
104+func (*resizeReaderPTY) Write(b []byte) (int, error) { return len(b), nil }
105+func (*resizeReaderPTY) Pid() int { return 0 }
106+func (*resizeReaderPTY) ForegroundPgid() (int, error) { return 0, nil }
107+func (p *resizeReaderPTY) Close() error { p.close(); return nil }
108+func (p *resizeReaderPTY) close() { p.closeOnce.Do(func() { close(p.closed) }) }
109+func (p *resizeReaderPTY) deliveredWhileApplying() bool {
110+ p.mu.Lock()
111+ defer p.mu.Unlock()
112+ return p.delivered
113+}
114+
115+func TestReplayResizePendingBuffersSuccessFailureAndBatchOrder(t *testing.T) {
116+ t.Run("success resizes then replays", func(t *testing.T) {
117+ pty := &transactionalResizePTY{}
118+ d, sess, _, _ := newManualSessionWithPTYs(t, pty)
119+ tb, p := sess.activeTab(), sess.activeTab().focusedPane()
120+ p.resizeApplying = true
121+ p.resizePending = []byte("A\x1b[1;81HB")
122+ d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 120, Height: 23})
123+ require.Equal(t, 120, p.screen.Frame.Width)
124+ require.Equal(t, 'B', p.screen.Frame.At(80, 0).Rune)
125+ require.False(t, p.resizeApplying)
126+ })
127+ t.Run("failure retains old parser width", func(t *testing.T) {
128+ pty := &transactionalResizePTY{}
129+ d, sess, _, _ := newManualSessionWithPTYs(t, pty)
130+ tb, p := sess.activeTab(), sess.activeTab().focusedPane()
131+ p.resizeApplying = true
132+ p.resizePending = []byte("\x1b[1;81HB")
133+ d.replayResizePending(sess, tb, p, false, domain.Rect{Width: 120, Height: 23})
134+ require.Equal(t, 80, p.screen.Frame.Width)
135+ require.Equal(t, 'B', p.screen.Frame.At(79, 0).Rune)
136+ })
137+ t.Run("batches retain read order", func(t *testing.T) {
138+ pty := &transactionalResizePTY{}
139+ d, sess, _, _ := newManualSessionWithPTYs(t, pty)
140+ tb, p := sess.activeTab(), sess.activeTab().focusedPane()
141+ p.screen.OnResponse = func([]byte) { p.ptyResponses = append(p.ptyResponses, 'r') }
142+ pty.onWrite = func([]byte) {
143+ p.mu.Lock()
144+ p.resizePending = append(p.resizePending, 'B')
145+ p.mu.Unlock()
146+ }
147+ p.resizeApplying = true
148+ p.resizePending = []byte("A\x1b[6n")
149+ d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 80, Height: 23})
150+ require.Equal(t, "AB", screenLineText(p.screen, 0)[:2])
151+ })
152+}
153+
154+func TestProcessPTYDataRetainsCallbacksDuringResizeReplay(t *testing.T) {
155+ responses := make(chan []byte, 1)
156+ pty := &transactionalResizePTY{onWrite: func(b []byte) { responses <- b }}
157+ d, sess, _, sends := newManualSessionWithPTYs(t, pty)
158+ tb, p := sess.activeTab(), sess.activeTab().focusedPane()
159+ p.screen.OnResponse = func(b []byte) { p.ptyResponses = append(p.ptyResponses, b...) }
160+ p.screen.OnBell = func() { p.ptyAttention = true }
161+ p.screen.OnClipboard = func(b64 string) { p.ptyClipboards = append(p.ptyClipboards, b64) }
162+ p.resizeApplying = true
163+ p.resizePending = []byte("\x1b]2;resized\a\a\x1b]52;c;YQ==\a\x1b[6n")
164+ d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 80, Height: 23})
165+
166+ p.mu.Lock()
167+ title := p.title.terminalTitle
168+ p.mu.Unlock()
169+ require.Equal(t, "resized", title)
170+ require.True(t, tb.attention)
171+ require.NotEmpty(t, <-responses, "DSR response must be flushed back to the child")
172+ // DSR is flushed back to the child and OSC 52 is forwarded through the
173+ // normal asynchronous client path, proving replay uses processPTYData.
174+ frame := awaitFrame(t, sends, ports.MsgOutput)
175+ out, err := ports.UnmarshalOutput(frame.Payload)
176+ require.NoError(t, err)
177+ require.Contains(t, string(out.Data), "\x1b]52;c;YQ==\a")
178+}
179+
53 // S3 acceptance: prepare may inspect layout under its locks, but apply must180 // S3 acceptance: prepare may inspect layout under its locks, but apply must
54 // invoke PTY.Resize after all session/tab/pane locks are released. This is the181 // invoke PTY.Resize after all session/tab/pane locks are released. This is the
55 // deadlock boundary with PTY readers and scripts which synchronously re-enter182 // deadlock boundary with PTY readers and scripts which synchronously re-enter

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