detached 68c86a5

fix(client): harden terminal input handoff

commit
68c86a5312bc825b0a7efcc79c565a8b17c2f0fb
author
brice · · 2026-07-18 12:55 UTC
parent
3389f59
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +228 -37
M internal/usecase/client/client.go +56 -24
@@ -1002,6 +1002,7 @@ type terminalInputPump struct {
10021002 delivering uint64
10031003 deliveringResidual bool
10041004 nextID uint64
1005+ activation uint64
10051006 closed bool
10061007 active bool
10071008 ready chan struct{}
@@ -1017,13 +1018,14 @@ func newTerminalInputPump(in io.Reader) *terminalInputPump {
10171018 space := make(chan struct{}, 1)
10181019 space <- struct{}{}
10191020 return &terminalInputPump{
1020- in: in,
1021- done: make(chan struct{}),
1022- active: true,
1023- ready: make(chan struct{}, 1),
1024- space: space,
1025- state: make(chan struct{}, 1),
1026- exited: make(chan struct{}),
1021+ in: in,
1022+ done: make(chan struct{}),
1023+ activation: 1,
1024+ active: true,
1025+ ready: make(chan struct{}, 1),
1026+ space: space,
1027+ state: make(chan struct{}, 1),
1028+ exited: make(chan struct{}),
10271029 }
10281030 }
10291031
@@ -1069,14 +1071,14 @@ func (p *terminalInputPump) signalReady() {
10691071
10701072 // enqueue retains at most one completed terminal read. The reader blocks
10711073 // before publishing another result, keeping handoff buffering bounded.
1072-func (p *terminalInputPump) enqueue(result terminalReadResult) bool {
1074+func (p *terminalInputPump) enqueue(result terminalReadResult, activation uint64) bool {
10731075 select {
10741076 case <-p.done:
10751077 return false
10761078 case <-p.space:
10771079 }
10781080 p.mu.Lock()
1079- if p.closed || !p.active {
1081+ if p.closed || !p.active || p.activation != activation {
10801082 p.mu.Unlock()
10811083 p.space <- struct{}{}
10821084 return false
@@ -1176,20 +1178,20 @@ func (p *terminalInputPump) signalState() {
11761178 }
11771179 }
11781180
1179-func (p *terminalInputPump) waitForActive() bool {
1181+func (p *terminalInputPump) waitForActive() (uint64, bool) {
11801182 for {
11811183 p.mu.Lock()
1182- active, closed := p.active, p.closed
1184+ active, closed, activation := p.active, p.closed, p.activation
11831185 p.mu.Unlock()
11841186 if closed {
1185- return false
1187+ return 0, false
11861188 }
11871189 if active {
1188- return true
1190+ return activation, true
11891191 }
11901192 select {
11911193 case <-p.done:
1192- return false
1194+ return 0, false
11931195 case <-p.state:
11941196 }
11951197 }
@@ -1201,7 +1203,8 @@ func (p *terminalInputPump) start() {
12011203 defer close(p.exited)
12021204 buf := make([]byte, stdinBufSize)
12031205 for {
1204- if !p.waitForActive() {
1206+ activation, ok := p.waitForActive()
1207+ if !ok {
12051208 return
12061209 }
12071210 n, err := p.in.Read(buf)
@@ -1209,7 +1212,7 @@ func (p *terminalInputPump) start() {
12091212 if n > 0 {
12101213 result.data = append([]byte(nil), buf[:n]...)
12111214 }
1212- if !p.enqueue(result) {
1215+ if !p.enqueue(result, activation) {
12131216 if p.isClosed() {
12141217 return
12151218 }
@@ -1263,7 +1266,8 @@ func (p *terminalInputPump) suspend() {
12631266
12641267 func (p *terminalInputPump) resume() {
12651268 p.mu.Lock()
1266- if !p.closed {
1269+ if !p.closed && !p.active {
1270+ p.activation++
12671271 p.active = true
12681272 }
12691273 p.mu.Unlock()
@@ -1311,25 +1315,37 @@ func (p *stdinPump) run() {
13111315 var markers paletteMarkerScanner
13121316 var inputSeq uint64
13131317 var sendOK atomic.Bool
1318+ var undeliveredMu sync.Mutex
1319+ var undelivered []byte
13141320 sendOK.Store(true)
1315- send := func(frame ports.Frame) {
1321+ send := func(frame ports.Frame) bool {
1322+ if !sendOK.Load() {
1323+ return false
1324+ }
13161325 select {
13171326 case p.out <- frame:
13181327 if p.afterInputDelivered != nil {
13191328 p.afterInputDelivered()
13201329 }
1330+ return true
13211331 case <-p.ctx.Done():
13221332 sendOK.Store(false)
1333+ return false
13231334 }
13241335 }
1325- sendEvent := func(event paletteGenerationEvent) {
1336+ sendEvent := func(event paletteGenerationEvent) bool {
1337+ if !sendOK.Load() {
1338+ return false
1339+ }
13261340 select {
13271341 case p.paletteEvents <- event:
13281342 if p.afterPaletteEvent != nil {
13291343 p.afterPaletteEvent(event)
13301344 }
1345+ return true
13311346 case <-p.ctx.Done():
13321347 sendOK.Store(false)
1348+ return false
13331349 }
13341350 }
13351351 coalescer := newPasteCoalescer(p.clock, func(data []byte) {
@@ -1337,7 +1353,11 @@ func (p *stdinPump) run() {
13371353 return
13381354 }
13391355 inputSeq++
1340- send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: append([]byte(nil), data...)})})
1356+ if !send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: append([]byte(nil), data...)})}) {
1357+ undeliveredMu.Lock()
1358+ undelivered = append(undelivered, data...)
1359+ undeliveredMu.Unlock()
1360+ }
13411361 })
13421362 defer coalescer.Close()
13431363 sink := coalescer.Scan
@@ -1372,7 +1392,17 @@ func (p *stdinPump) run() {
13721392 // delivered and must not be replayed by the next attempt.
13731393 defer func() {
13741394 if p.ctx.Err() != nil {
1375- input.preserveResidual(consumer, markers.takePending())
1395+ // Closing flushes any bracketed-paste prefix or buffer through the
1396+ // coalescer's emit callback. Failed ordinary-input deliveries are
1397+ // retained ahead of a trailing undecided marker prefix, matching their
1398+ // original order within the raw read.
1399+ held := coalescer.CloseAndTakeHeld()
1400+ undeliveredMu.Lock()
1401+ residual := append([]byte(nil), undelivered...)
1402+ undeliveredMu.Unlock()
1403+ residual = append(residual, held...)
1404+ residual = append(residual, markers.takePending()...)
1405+ input.preserveResidual(consumer, residual)
13761406 }
13771407 }()
13781408
@@ -1465,6 +1495,11 @@ func (p *stdinPump) run() {
14651495 })
14661496 })
14671497 if !sendOK.Load() {
1498+ // Commit the source read so callbacks accepted before cancellation
1499+ // are never replayed. The deferred handoff retains only ordinary
1500+ // bytes whose callback could not be delivered, plus any undecided
1501+ // trailing marker prefix.
1502+ input.ack(consumer)
14681503 return
14691504 }
14701505 }
@@ -1481,9 +1516,6 @@ func (p *stdinPump) run() {
14811516 p.cancel()
14821517 return
14831518 }
1484- if !sendOK.Load() {
1485- return
1486- }
14871519 // Commit every callback from this raw read before observing a
14881520 // concurrent cancellation. Any remaining marker prefix is retained
14891521 // by the deferred handoff above, so reconnects never duplicate the
@@ -1002,6 +1002,7 @@ type terminalInputPump struct {
1002 delivering uint641002 delivering uint64
1003 deliveringResidual bool1003 deliveringResidual bool
1004 nextID uint641004 nextID uint64
1005+ activation uint64
1005 closed bool1006 closed bool
1006 active bool1007 active bool
1007 ready chan struct{}1008 ready chan struct{}
@@ -1017,13 +1018,14 @@ func newTerminalInputPump(in io.Reader) *terminalInputPump {
1017 space := make(chan struct{}, 1)1018 space := make(chan struct{}, 1)
1018 space <- struct{}{}1019 space <- struct{}{}
1019 return &terminalInputPump{1020 return &terminalInputPump{
1020- in: in,1021+ in: in,
1021- done: make(chan struct{}),1022+ done: make(chan struct{}),
1022- active: true,1023+ activation: 1,
1023- ready: make(chan struct{}, 1),1024+ active: true,
1024- space: space,1025+ ready: make(chan struct{}, 1),
1025- state: make(chan struct{}, 1),1026+ space: space,
1026- exited: make(chan struct{}),1027+ state: make(chan struct{}, 1),
1028+ exited: make(chan struct{}),
1027 }1029 }
1028 }1030 }
1029 1031
@@ -1069,14 +1071,14 @@ func (p *terminalInputPump) signalReady() {
1069 1071
1070 // enqueue retains at most one completed terminal read. The reader blocks1072 // enqueue retains at most one completed terminal read. The reader blocks
1071 // before publishing another result, keeping handoff buffering bounded.1073 // before publishing another result, keeping handoff buffering bounded.
1072-func (p *terminalInputPump) enqueue(result terminalReadResult) bool {1074+func (p *terminalInputPump) enqueue(result terminalReadResult, activation uint64) bool {
1073 select {1075 select {
1074 case <-p.done:1076 case <-p.done:
1075 return false1077 return false
1076 case <-p.space:1078 case <-p.space:
1077 }1079 }
1078 p.mu.Lock()1080 p.mu.Lock()
1079- if p.closed || !p.active {1081+ if p.closed || !p.active || p.activation != activation {
1080 p.mu.Unlock()1082 p.mu.Unlock()
1081 p.space <- struct{}{}1083 p.space <- struct{}{}
1082 return false1084 return false
@@ -1176,20 +1178,20 @@ func (p *terminalInputPump) signalState() {
1176 }1178 }
1177 }1179 }
1178 1180
1179-func (p *terminalInputPump) waitForActive() bool {1181+func (p *terminalInputPump) waitForActive() (uint64, bool) {
1180 for {1182 for {
1181 p.mu.Lock()1183 p.mu.Lock()
1182- active, closed := p.active, p.closed1184+ active, closed, activation := p.active, p.closed, p.activation
1183 p.mu.Unlock()1185 p.mu.Unlock()
1184 if closed {1186 if closed {
1185- return false1187+ return 0, false
1186 }1188 }
1187 if active {1189 if active {
1188- return true1190+ return activation, true
1189 }1191 }
1190 select {1192 select {
1191 case <-p.done:1193 case <-p.done:
1192- return false1194+ return 0, false
1193 case <-p.state:1195 case <-p.state:
1194 }1196 }
1195 }1197 }
@@ -1201,7 +1203,8 @@ func (p *terminalInputPump) start() {
1201 defer close(p.exited)1203 defer close(p.exited)
1202 buf := make([]byte, stdinBufSize)1204 buf := make([]byte, stdinBufSize)
1203 for {1205 for {
1204- if !p.waitForActive() {1206+ activation, ok := p.waitForActive()
1207+ if !ok {
1205 return1208 return
1206 }1209 }
1207 n, err := p.in.Read(buf)1210 n, err := p.in.Read(buf)
@@ -1209,7 +1212,7 @@ func (p *terminalInputPump) start() {
1209 if n > 0 {1212 if n > 0 {
1210 result.data = append([]byte(nil), buf[:n]...)1213 result.data = append([]byte(nil), buf[:n]...)
1211 }1214 }
1212- if !p.enqueue(result) {1215+ if !p.enqueue(result, activation) {
1213 if p.isClosed() {1216 if p.isClosed() {
1214 return1217 return
1215 }1218 }
@@ -1263,7 +1266,8 @@ func (p *terminalInputPump) suspend() {
1263 1266
1264 func (p *terminalInputPump) resume() {1267 func (p *terminalInputPump) resume() {
1265 p.mu.Lock()1268 p.mu.Lock()
1266- if !p.closed {1269+ if !p.closed && !p.active {
1270+ p.activation++
1267 p.active = true1271 p.active = true
1268 }1272 }
1269 p.mu.Unlock()1273 p.mu.Unlock()
@@ -1311,25 +1315,37 @@ func (p *stdinPump) run() {
1311 var markers paletteMarkerScanner1315 var markers paletteMarkerScanner
1312 var inputSeq uint641316 var inputSeq uint64
1313 var sendOK atomic.Bool1317 var sendOK atomic.Bool
1318+ var undeliveredMu sync.Mutex
1319+ var undelivered []byte
1314 sendOK.Store(true)1320 sendOK.Store(true)
1315- send := func(frame ports.Frame) {1321+ send := func(frame ports.Frame) bool {
1322+ if !sendOK.Load() {
1323+ return false
1324+ }
1316 select {1325 select {
1317 case p.out <- frame:1326 case p.out <- frame:
1318 if p.afterInputDelivered != nil {1327 if p.afterInputDelivered != nil {
1319 p.afterInputDelivered()1328 p.afterInputDelivered()
1320 }1329 }
1330+ return true
1321 case <-p.ctx.Done():1331 case <-p.ctx.Done():
1322 sendOK.Store(false)1332 sendOK.Store(false)
1333+ return false
1323 }1334 }
1324 }1335 }
1325- sendEvent := func(event paletteGenerationEvent) {1336+ sendEvent := func(event paletteGenerationEvent) bool {
1337+ if !sendOK.Load() {
1338+ return false
1339+ }
1326 select {1340 select {
1327 case p.paletteEvents <- event:1341 case p.paletteEvents <- event:
1328 if p.afterPaletteEvent != nil {1342 if p.afterPaletteEvent != nil {
1329 p.afterPaletteEvent(event)1343 p.afterPaletteEvent(event)
1330 }1344 }
1345+ return true
1331 case <-p.ctx.Done():1346 case <-p.ctx.Done():
1332 sendOK.Store(false)1347 sendOK.Store(false)
1348+ return false
1333 }1349 }
1334 }1350 }
1335 coalescer := newPasteCoalescer(p.clock, func(data []byte) {1351 coalescer := newPasteCoalescer(p.clock, func(data []byte) {
@@ -1337,7 +1353,11 @@ func (p *stdinPump) run() {
1337 return1353 return
1338 }1354 }
1339 inputSeq++1355 inputSeq++
1340- send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: append([]byte(nil), data...)})})1356+ if !send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: append([]byte(nil), data...)})}) {
1357+ undeliveredMu.Lock()
1358+ undelivered = append(undelivered, data...)
1359+ undeliveredMu.Unlock()
1360+ }
1341 })1361 })
1342 defer coalescer.Close()1362 defer coalescer.Close()
1343 sink := coalescer.Scan1363 sink := coalescer.Scan
@@ -1372,7 +1392,17 @@ func (p *stdinPump) run() {
1372 // delivered and must not be replayed by the next attempt.1392 // delivered and must not be replayed by the next attempt.
1373 defer func() {1393 defer func() {
1374 if p.ctx.Err() != nil {1394 if p.ctx.Err() != nil {
1375- input.preserveResidual(consumer, markers.takePending())1395+ // Closing flushes any bracketed-paste prefix or buffer through the
1396+ // coalescer's emit callback. Failed ordinary-input deliveries are
1397+ // retained ahead of a trailing undecided marker prefix, matching their
1398+ // original order within the raw read.
1399+ held := coalescer.CloseAndTakeHeld()
1400+ undeliveredMu.Lock()
1401+ residual := append([]byte(nil), undelivered...)
1402+ undeliveredMu.Unlock()
1403+ residual = append(residual, held...)
1404+ residual = append(residual, markers.takePending()...)
1405+ input.preserveResidual(consumer, residual)
1376 }1406 }
1377 }()1407 }()
1378 1408
@@ -1465,6 +1495,11 @@ func (p *stdinPump) run() {
1465 })1495 })
1466 })1496 })
1467 if !sendOK.Load() {1497 if !sendOK.Load() {
1498+ // Commit the source read so callbacks accepted before cancellation
1499+ // are never replayed. The deferred handoff retains only ordinary
1500+ // bytes whose callback could not be delivered, plus any undecided
1501+ // trailing marker prefix.
1502+ input.ack(consumer)
1468 return1503 return
1469 }1504 }
1470 }1505 }
@@ -1481,9 +1516,6 @@ func (p *stdinPump) run() {
1481 p.cancel()1516 p.cancel()
1482 return1517 return
1483 }1518 }
1484- if !sendOK.Load() {
1485- return
1486- }
1487 // Commit every callback from this raw read before observing a1519 // Commit every callback from this raw read before observing a
1488 // concurrent cancellation. Any remaining marker prefix is retained1520 // concurrent cancellation. Any remaining marker prefix is retained
1489 // by the deferred handoff above, so reconnects never duplicate the1521 // by the deferred handoff above, so reconnects never duplicate the
M internal/usecase/client/client_test.go +3 -6
@@ -230,8 +230,6 @@ func TestRunBoundsPreWelcomeOperations(t *testing.T) {
230230 closed := make(chan struct{})
231231 operationExited := make(chan struct{})
232232 tr := portsmocks.NewMockTransport(t)
233- tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
234- tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
235233 tr.EXPECT().Close().Run(func() { close(closed) }).Return(nil).Once()
236234
237235 block := func() error {
@@ -294,6 +292,7 @@ func TestRunBoundsPreWelcomeOperations(t *testing.T) {
294292 case <-time.After(time.Second):
295293 t.Fatal("pre-Welcome operation leaked after Run returned")
296294 }
295+ tr.AssertNotCalled(t, "Send", isType(ports.MsgTheme))
297296 })
298297 }
299298 }
@@ -427,8 +426,6 @@ func TestAttachVersionMismatch(t *testing.T) {
427426 // EnterRaw must NOT be called on the error-before-welcome path.
428427
429428 tr := portsmocks.NewMockTransport(t)
430- tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
431- tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
432429 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
433430 tr.EXPECT().Recv().Return(
434431 frameOf(ports.MsgError, ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrVersionMismatch, Text: "version mismatch"})),
@@ -441,6 +438,7 @@ func TestAttachVersionMismatch(t *testing.T) {
441438 var pe *client.ProtocolError
442439 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
443440 require.Equal(t, ports.ErrVersionMismatch, pe.Code)
441+ tr.AssertNotCalled(t, "Send", isType(ports.MsgTheme))
444442 }
445443
446444 func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) {
@@ -711,8 +709,6 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
711709 // EnterRaw must NOT be called when the daemon rejects Hello before Welcome.
712710
713711 tr := portsmocks.NewMockTransport(t)
714- tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
715- tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
716712 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
717713 tr.EXPECT().Recv().Return(
718714 frameOf(ports.MsgError, ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrVersionMismatch, Text: "version mismatch"})),
@@ -726,6 +722,7 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
726722 require.Error(t, err)
727723 var pe *client.ProtocolError
728724 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
725+ tr.AssertNotCalled(t, "Send", isType(ports.MsgTheme))
729726 }
730727
731728 func TestRunPhaseASingleAttempt(t *testing.T) {
@@ -230,8 +230,6 @@ func TestRunBoundsPreWelcomeOperations(t *testing.T) {
230 closed := make(chan struct{})230 closed := make(chan struct{})
231 operationExited := make(chan struct{})231 operationExited := make(chan struct{})
232 tr := portsmocks.NewMockTransport(t)232 tr := portsmocks.NewMockTransport(t)
233- tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
234- tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
235 tr.EXPECT().Close().Run(func() { close(closed) }).Return(nil).Once()233 tr.EXPECT().Close().Run(func() { close(closed) }).Return(nil).Once()
236 234
237 block := func() error {235 block := func() error {
@@ -294,6 +292,7 @@ func TestRunBoundsPreWelcomeOperations(t *testing.T) {
294 case <-time.After(time.Second):292 case <-time.After(time.Second):
295 t.Fatal("pre-Welcome operation leaked after Run returned")293 t.Fatal("pre-Welcome operation leaked after Run returned")
296 }294 }
295+ tr.AssertNotCalled(t, "Send", isType(ports.MsgTheme))
297 })296 })
298 }297 }
299 }298 }
@@ -427,8 +426,6 @@ func TestAttachVersionMismatch(t *testing.T) {
427 // EnterRaw must NOT be called on the error-before-welcome path.426 // EnterRaw must NOT be called on the error-before-welcome path.
428 427
429 tr := portsmocks.NewMockTransport(t)428 tr := portsmocks.NewMockTransport(t)
430- tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
431- tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
432 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()429 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
433 tr.EXPECT().Recv().Return(430 tr.EXPECT().Recv().Return(
434 frameOf(ports.MsgError, ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrVersionMismatch, Text: "version mismatch"})),431 frameOf(ports.MsgError, ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrVersionMismatch, Text: "version mismatch"})),
@@ -441,6 +438,7 @@ func TestAttachVersionMismatch(t *testing.T) {
441 var pe *client.ProtocolError438 var pe *client.ProtocolError
442 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)439 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
443 require.Equal(t, ports.ErrVersionMismatch, pe.Code)440 require.Equal(t, ports.ErrVersionMismatch, pe.Code)
441+ tr.AssertNotCalled(t, "Send", isType(ports.MsgTheme))
444 }442 }
445 443
446 func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) {444 func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) {
@@ -711,8 +709,6 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
711 // EnterRaw must NOT be called when the daemon rejects Hello before Welcome.709 // EnterRaw must NOT be called when the daemon rejects Hello before Welcome.
712 710
713 tr := portsmocks.NewMockTransport(t)711 tr := portsmocks.NewMockTransport(t)
714- tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
715- tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
716 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()712 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
717 tr.EXPECT().Recv().Return(713 tr.EXPECT().Recv().Return(
718 frameOf(ports.MsgError, ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrVersionMismatch, Text: "version mismatch"})),714 frameOf(ports.MsgError, ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrVersionMismatch, Text: "version mismatch"})),
@@ -726,6 +722,7 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
726 require.Error(t, err)722 require.Error(t, err)
727 var pe *client.ProtocolError723 var pe *client.ProtocolError
728 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)724 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
725+ tr.AssertNotCalled(t, "Send", isType(ports.MsgTheme))
729 }726 }
730 727
731 func TestRunPhaseASingleAttempt(t *testing.T) {728 func TestRunPhaseASingleAttempt(t *testing.T) {
M internal/usecase/client/paste_coalescer.go +15 -3
@@ -158,15 +158,27 @@ func (c *pasteCoalescer) Buffering() bool {
158158 return c.buffering || len(c.pending) > 0
159159 }
160160
161-// Close stops any pending timers and their goroutines. Held bytes are dropped:
162-// Close runs only as the stdin pump unwinds on detach, when there is nowhere
163-// left to deliver them.
161+// Close stops any pending timers and their goroutines. Callers which hand input
162+// to a replacement must first use CloseAndTakeHeld so undecided bytes survive.
164163 func (c *pasteCoalescer) Close() {
164+ _ = c.CloseAndTakeHeld()
165+}
166+
167+// CloseAndTakeHeld stops the coalescer and returns ordinary bytes withheld while
168+// deciding whether an opening marker or complete bracketed paste would arrive.
169+// The returned bytes retain their original order and are removed from c.
170+func (c *pasteCoalescer) CloseAndTakeHeld() []byte {
165171 c.mu.Lock()
166172 defer c.mu.Unlock()
167173 c.closed = true
168174 c.stopFlushTimer()
169175 c.stopSafetyTimer()
176+ held := append([]byte(nil), c.pending...)
177+ held = append(held, c.buf...)
178+ c.pending = nil
179+ c.buf = nil
180+ c.buffering = false
181+ return held
170182 }
171183
172184 func (c *pasteCoalescer) startFlushTimer() {
@@ -158,15 +158,27 @@ func (c *pasteCoalescer) Buffering() bool {
158 return c.buffering || len(c.pending) > 0158 return c.buffering || len(c.pending) > 0
159 }159 }
160 160
161-// Close stops any pending timers and their goroutines. Held bytes are dropped:161+// Close stops any pending timers and their goroutines. Callers which hand input
162-// Close runs only as the stdin pump unwinds on detach, when there is nowhere162+// to a replacement must first use CloseAndTakeHeld so undecided bytes survive.
163-// left to deliver them.
164 func (c *pasteCoalescer) Close() {163 func (c *pasteCoalescer) Close() {
164+ _ = c.CloseAndTakeHeld()
165+}
166+
167+// CloseAndTakeHeld stops the coalescer and returns ordinary bytes withheld while
168+// deciding whether an opening marker or complete bracketed paste would arrive.
169+// The returned bytes retain their original order and are removed from c.
170+func (c *pasteCoalescer) CloseAndTakeHeld() []byte {
165 c.mu.Lock()171 c.mu.Lock()
166 defer c.mu.Unlock()172 defer c.mu.Unlock()
167 c.closed = true173 c.closed = true
168 c.stopFlushTimer()174 c.stopFlushTimer()
169 c.stopSafetyTimer()175 c.stopSafetyTimer()
176+ held := append([]byte(nil), c.pending...)
177+ held = append(held, c.buf...)
178+ c.pending = nil
179+ c.buf = nil
180+ c.buffering = false
181+ return held
170 }182 }
171 183
172 func (c *pasteCoalescer) startFlushTimer() {184 func (c *pasteCoalescer) startFlushTimer() {
M internal/usecase/client/terminal_input_pump_test.go +154 -4
@@ -7,6 +7,7 @@ import (
77 "sync"
88 "sync/atomic"
99 "testing"
10+ "time"
1011
1112 "github.com/stretchr/testify/require"
1213
@@ -71,6 +72,37 @@ func (r *lateStopReader) Read(p []byte) (int, error) {
7172 return copy(p, result.data), result.err
7273 }
7374
75+func requireSignal(t *testing.T, ch <-chan struct{}, message string) {
76+ t.Helper()
77+ select {
78+ case <-ch:
79+ case <-time.After(time.Second):
80+ t.Fatal(message)
81+ }
82+}
83+
84+func requireFrame(t *testing.T, ch <-chan ports.Frame, message string) ports.Frame {
85+ t.Helper()
86+ select {
87+ case frame := <-ch:
88+ return frame
89+ case <-time.After(time.Second):
90+ t.Fatal(message)
91+ return ports.Frame{}
92+ }
93+}
94+
95+func requireTimer(t *testing.T, ch <-chan *attachPaletteTimer, message string) *attachPaletteTimer {
96+ t.Helper()
97+ select {
98+ case timer := <-ch:
99+ return timer
100+ case <-time.After(time.Second):
101+ t.Fatal(message)
102+ return nil
103+ }
104+}
105+
74106 func TestPaletteSlotRejectsOutOfRangeIntBeforeNarrowing(t *testing.T) {
75107 for _, tc := range []struct {
76108 slot int
@@ -140,7 +172,7 @@ func TestTerminalInputPumpCancellationHandoffPreservesQueuedBytes(t *testing.T)
140172 // Both cancellation and a terminal read are ready before the old scanner
141173 // tries to dequeue. It must surrender the read to its replacement.
142174 want := []byte("first\x00second\xff")
143- input.enqueue(terminalReadResult{data: append([]byte(nil), want...)})
175+ input.enqueue(terminalReadResult{data: append([]byte(nil), want...)}, 1)
144176
145177 var activeGeneration atomic.Uint64
146178 cancelledCtx, cancelCancelled := context.WithCancel(context.Background())
@@ -187,7 +219,7 @@ func TestTerminalInputPumpCancellationHandoffPreservesQueuedBytes(t *testing.T)
187219 func TestTerminalInputPumpCancellationAfterTakeRequeuesRawRead(t *testing.T) {
188220 input := newTerminalInputPump(nil)
189221 want := []byte("first\x00second\xff")
190- input.enqueue(terminalReadResult{data: append([]byte(nil), want...)})
222+ input.enqueue(terminalReadResult{data: append([]byte(nil), want...)}, 1)
191223
192224 ctx, cancel := context.WithCancel(context.Background())
193225 consumer := input.claim()
@@ -223,7 +255,7 @@ func TestTerminalInputPumpCancellationAfterTakeRequeuesRawRead(t *testing.T) {
223255
224256 func TestTerminalInputPumpCancellationPreservesStandaloneEscapeForReplacement(t *testing.T) {
225257 input := newTerminalInputPump(nil)
226- input.enqueue(terminalReadResult{data: []byte("\x1b")})
258+ input.enqueue(terminalReadResult{data: []byte("\x1b")}, 1)
227259 var activeGeneration atomic.Uint64
228260
229261 oldCtx, cancelOld := context.WithCancel(context.Background())
@@ -267,9 +299,127 @@ func TestTerminalInputPumpCancellationPreservesStandaloneEscapeForReplacement(t
267299 <-newDone
268300 }
269301
302+func TestTerminalInputPumpCancellationAfterInputBeforeOSCPreservesOnlyUndeliveredSuffix(t *testing.T) {
303+ input := newTerminalInputPump(nil)
304+ input.enqueue(terminalReadResult{data: []byte("x\x1b]10;rgb:ffff/0000/0000\x07y")}, 1)
305+ var activeGeneration atomic.Uint64
306+ ctx, cancel := context.WithCancel(context.Background())
307+ delivered := make(chan struct{})
308+ done := make(chan struct{})
309+ out := make(chan ports.Frame, 1)
310+ go func() {
311+ (&stdinPump{
312+ ctx: ctx, cancel: cancel, input: input, out: out, clock: newAttachPaletteClock(),
313+ logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent),
314+ activeGeneration: &activeGeneration,
315+ afterInputDelivered: func() {
316+ cancel()
317+ close(delivered)
318+ },
319+ }).run()
320+ close(done)
321+ }()
322+ frame := requireFrame(t, out, "timed out waiting for delivered input prefix")
323+ got, err := ports.UnmarshalInput(frame.Payload)
324+ require.NoError(t, err)
325+ require.Equal(t, []byte("x"), got.Data)
326+ requireSignal(t, delivered, "timed out waiting for cancellation after input delivery")
327+ requireSignal(t, done, "timed out waiting for cancelled stdin pump")
328+
329+ replacementCtx, cancelReplacement := context.WithCancel(context.Background())
330+ replacementOut := make(chan ports.Frame, 1)
331+ replacementDone := make(chan struct{})
332+ go func() {
333+ (&stdinPump{
334+ ctx: replacementCtx, cancel: cancelReplacement, input: input,
335+ out: replacementOut, clock: newAttachPaletteClock(),
336+ logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent),
337+ activeGeneration: &activeGeneration,
338+ }).run()
339+ close(replacementDone)
340+ }()
341+ frame = requireFrame(t, replacementOut, "timed out waiting for undelivered input suffix")
342+ got, err = ports.UnmarshalInput(frame.Payload)
343+ require.NoError(t, err)
344+ require.Equal(t, []byte("y"), got.Data, "replacement must receive only the undelivered ordinary suffix")
345+
346+ cancelReplacement()
347+ requireSignal(t, replacementDone, "timed out waiting for replacement stdin pump shutdown")
348+}
349+
350+func TestTerminalInputPumpCancellationPreservesCoalescerHeldSuffix(t *testing.T) {
351+ input := newTerminalInputPump(nil)
352+ held := ports.BracketedPasteOpenMarker[:3]
353+ raw := append([]byte("x\x1b]10;rgb:ffff/0000/0000\x07"), held...)
354+ input.enqueue(terminalReadResult{data: raw}, 1)
355+ var activeGeneration atomic.Uint64
356+ ctx, cancel := context.WithCancel(context.Background())
357+ out := make(chan ports.Frame, 1)
358+ done := make(chan struct{})
359+ go func() {
360+ (&stdinPump{
361+ ctx: ctx, cancel: cancel, input: input, out: out, clock: newAttachPaletteClock(),
362+ logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent),
363+ activeGeneration: &activeGeneration,
364+ afterInputDelivered: cancel,
365+ }).run()
366+ close(done)
367+ }()
368+ frame := requireFrame(t, out, "timed out waiting for delivered input prefix")
369+ got, err := ports.UnmarshalInput(frame.Payload)
370+ require.NoError(t, err)
371+ require.Equal(t, []byte("x"), got.Data)
372+ requireSignal(t, done, "timed out waiting for cancelled stdin pump")
373+
374+ replacementCtx, cancelReplacement := context.WithCancel(context.Background())
375+ replacementClock := newAttachPaletteClock()
376+ replacementOut := make(chan ports.Frame, 1)
377+ replacementDone := make(chan struct{})
378+ go func() {
379+ (&stdinPump{
380+ ctx: replacementCtx, cancel: cancelReplacement, input: input,
381+ out: replacementOut, clock: replacementClock,
382+ logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent),
383+ activeGeneration: &activeGeneration,
384+ }).run()
385+ close(replacementDone)
386+ }()
387+ requireTimer(t, replacementClock.timers, "timed out waiting for paste-prefix ambiguity timer").fire()
388+ frame = requireFrame(t, replacementOut, "timed out waiting for coalescer-held suffix")
389+ got, err = ports.UnmarshalInput(frame.Payload)
390+ require.NoError(t, err)
391+ require.Equal(t, held, got.Data, "replacement must receive the coalescer-held suffix exactly once")
392+
393+ cancelReplacement()
394+ requireSignal(t, replacementDone, "timed out waiting for replacement stdin pump shutdown")
395+}
396+
397+func TestTerminalInputPumpRejectsReadFromPriorActivation(t *testing.T) {
398+ reader := newLateStopReader()
399+ input := newTerminalInputPump(reader)
400+ input.start()
401+ requireSignal(t, reader.started, "timed out waiting for initial activation Read")
402+
403+ input.suspend()
404+ input.resume()
405+ reader.release <- terminalReadResult{data: []byte("stale")}
406+
407+ // The old read must be discarded after resume, then the reader may begin a
408+ // new Read for the current activation.
409+ requireSignal(t, reader.started, "timed out waiting for current activation Read")
410+ consumer := input.claim()
411+ _, ok := input.take(context.Background(), consumer)
412+ require.False(t, ok, "a Read authorized before suspend/resume must not reach the new activation")
413+ input.revoke(consumer)
414+
415+ input.stop()
416+ reader.release <- terminalReadResult{}
417+ requireSignal(t, input.exited, "timed out waiting for terminal input pump shutdown")
418+}
419+
270420 func TestTerminalInputPumpCancellationAfterDeliveredCallbackDoesNotReplay(t *testing.T) {
271421 input := newTerminalInputPump(nil)
272- input.enqueue(terminalReadResult{data: []byte("x")})
422+ input.enqueue(terminalReadResult{data: []byte("x")}, 1)
273423 var activeGeneration atomic.Uint64
274424 ctx, cancel := context.WithCancel(context.Background())
275425 delivered := make(chan struct{})
@@ -7,6 +7,7 @@ import (
7 "sync"7 "sync"
8 "sync/atomic"8 "sync/atomic"
9 "testing"9 "testing"
10+ "time"
10 11
11 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
12 13
@@ -71,6 +72,37 @@ func (r *lateStopReader) Read(p []byte) (int, error) {
71 return copy(p, result.data), result.err72 return copy(p, result.data), result.err
72 }73 }
73 74
75+func requireSignal(t *testing.T, ch <-chan struct{}, message string) {
76+ t.Helper()
77+ select {
78+ case <-ch:
79+ case <-time.After(time.Second):
80+ t.Fatal(message)
81+ }
82+}
83+
84+func requireFrame(t *testing.T, ch <-chan ports.Frame, message string) ports.Frame {
85+ t.Helper()
86+ select {
87+ case frame := <-ch:
88+ return frame
89+ case <-time.After(time.Second):
90+ t.Fatal(message)
91+ return ports.Frame{}
92+ }
93+}
94+
95+func requireTimer(t *testing.T, ch <-chan *attachPaletteTimer, message string) *attachPaletteTimer {
96+ t.Helper()
97+ select {
98+ case timer := <-ch:
99+ return timer
100+ case <-time.After(time.Second):
101+ t.Fatal(message)
102+ return nil
103+ }
104+}
105+
74 func TestPaletteSlotRejectsOutOfRangeIntBeforeNarrowing(t *testing.T) {106 func TestPaletteSlotRejectsOutOfRangeIntBeforeNarrowing(t *testing.T) {
75 for _, tc := range []struct {107 for _, tc := range []struct {
76 slot int108 slot int
@@ -140,7 +172,7 @@ func TestTerminalInputPumpCancellationHandoffPreservesQueuedBytes(t *testing.T)
140 // Both cancellation and a terminal read are ready before the old scanner172 // Both cancellation and a terminal read are ready before the old scanner
141 // tries to dequeue. It must surrender the read to its replacement.173 // tries to dequeue. It must surrender the read to its replacement.
142 want := []byte("first\x00second\xff")174 want := []byte("first\x00second\xff")
143- input.enqueue(terminalReadResult{data: append([]byte(nil), want...)})175+ input.enqueue(terminalReadResult{data: append([]byte(nil), want...)}, 1)
144 176
145 var activeGeneration atomic.Uint64177 var activeGeneration atomic.Uint64
146 cancelledCtx, cancelCancelled := context.WithCancel(context.Background())178 cancelledCtx, cancelCancelled := context.WithCancel(context.Background())
@@ -187,7 +219,7 @@ func TestTerminalInputPumpCancellationHandoffPreservesQueuedBytes(t *testing.T)
187 func TestTerminalInputPumpCancellationAfterTakeRequeuesRawRead(t *testing.T) {219 func TestTerminalInputPumpCancellationAfterTakeRequeuesRawRead(t *testing.T) {
188 input := newTerminalInputPump(nil)220 input := newTerminalInputPump(nil)
189 want := []byte("first\x00second\xff")221 want := []byte("first\x00second\xff")
190- input.enqueue(terminalReadResult{data: append([]byte(nil), want...)})222+ input.enqueue(terminalReadResult{data: append([]byte(nil), want...)}, 1)
191 223
192 ctx, cancel := context.WithCancel(context.Background())224 ctx, cancel := context.WithCancel(context.Background())
193 consumer := input.claim()225 consumer := input.claim()
@@ -223,7 +255,7 @@ func TestTerminalInputPumpCancellationAfterTakeRequeuesRawRead(t *testing.T) {
223 255
224 func TestTerminalInputPumpCancellationPreservesStandaloneEscapeForReplacement(t *testing.T) {256 func TestTerminalInputPumpCancellationPreservesStandaloneEscapeForReplacement(t *testing.T) {
225 input := newTerminalInputPump(nil)257 input := newTerminalInputPump(nil)
226- input.enqueue(terminalReadResult{data: []byte("\x1b")})258+ input.enqueue(terminalReadResult{data: []byte("\x1b")}, 1)
227 var activeGeneration atomic.Uint64259 var activeGeneration atomic.Uint64
228 260
229 oldCtx, cancelOld := context.WithCancel(context.Background())261 oldCtx, cancelOld := context.WithCancel(context.Background())
@@ -267,9 +299,127 @@ func TestTerminalInputPumpCancellationPreservesStandaloneEscapeForReplacement(t
267 <-newDone299 <-newDone
268 }300 }
269 301
302+func TestTerminalInputPumpCancellationAfterInputBeforeOSCPreservesOnlyUndeliveredSuffix(t *testing.T) {
303+ input := newTerminalInputPump(nil)
304+ input.enqueue(terminalReadResult{data: []byte("x\x1b]10;rgb:ffff/0000/0000\x07y")}, 1)
305+ var activeGeneration atomic.Uint64
306+ ctx, cancel := context.WithCancel(context.Background())
307+ delivered := make(chan struct{})
308+ done := make(chan struct{})
309+ out := make(chan ports.Frame, 1)
310+ go func() {
311+ (&stdinPump{
312+ ctx: ctx, cancel: cancel, input: input, out: out, clock: newAttachPaletteClock(),
313+ logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent),
314+ activeGeneration: &activeGeneration,
315+ afterInputDelivered: func() {
316+ cancel()
317+ close(delivered)
318+ },
319+ }).run()
320+ close(done)
321+ }()
322+ frame := requireFrame(t, out, "timed out waiting for delivered input prefix")
323+ got, err := ports.UnmarshalInput(frame.Payload)
324+ require.NoError(t, err)
325+ require.Equal(t, []byte("x"), got.Data)
326+ requireSignal(t, delivered, "timed out waiting for cancellation after input delivery")
327+ requireSignal(t, done, "timed out waiting for cancelled stdin pump")
328+
329+ replacementCtx, cancelReplacement := context.WithCancel(context.Background())
330+ replacementOut := make(chan ports.Frame, 1)
331+ replacementDone := make(chan struct{})
332+ go func() {
333+ (&stdinPump{
334+ ctx: replacementCtx, cancel: cancelReplacement, input: input,
335+ out: replacementOut, clock: newAttachPaletteClock(),
336+ logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent),
337+ activeGeneration: &activeGeneration,
338+ }).run()
339+ close(replacementDone)
340+ }()
341+ frame = requireFrame(t, replacementOut, "timed out waiting for undelivered input suffix")
342+ got, err = ports.UnmarshalInput(frame.Payload)
343+ require.NoError(t, err)
344+ require.Equal(t, []byte("y"), got.Data, "replacement must receive only the undelivered ordinary suffix")
345+
346+ cancelReplacement()
347+ requireSignal(t, replacementDone, "timed out waiting for replacement stdin pump shutdown")
348+}
349+
350+func TestTerminalInputPumpCancellationPreservesCoalescerHeldSuffix(t *testing.T) {
351+ input := newTerminalInputPump(nil)
352+ held := ports.BracketedPasteOpenMarker[:3]
353+ raw := append([]byte("x\x1b]10;rgb:ffff/0000/0000\x07"), held...)
354+ input.enqueue(terminalReadResult{data: raw}, 1)
355+ var activeGeneration atomic.Uint64
356+ ctx, cancel := context.WithCancel(context.Background())
357+ out := make(chan ports.Frame, 1)
358+ done := make(chan struct{})
359+ go func() {
360+ (&stdinPump{
361+ ctx: ctx, cancel: cancel, input: input, out: out, clock: newAttachPaletteClock(),
362+ logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent),
363+ activeGeneration: &activeGeneration,
364+ afterInputDelivered: cancel,
365+ }).run()
366+ close(done)
367+ }()
368+ frame := requireFrame(t, out, "timed out waiting for delivered input prefix")
369+ got, err := ports.UnmarshalInput(frame.Payload)
370+ require.NoError(t, err)
371+ require.Equal(t, []byte("x"), got.Data)
372+ requireSignal(t, done, "timed out waiting for cancelled stdin pump")
373+
374+ replacementCtx, cancelReplacement := context.WithCancel(context.Background())
375+ replacementClock := newAttachPaletteClock()
376+ replacementOut := make(chan ports.Frame, 1)
377+ replacementDone := make(chan struct{})
378+ go func() {
379+ (&stdinPump{
380+ ctx: replacementCtx, cancel: cancelReplacement, input: input,
381+ out: replacementOut, clock: replacementClock,
382+ logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent),
383+ activeGeneration: &activeGeneration,
384+ }).run()
385+ close(replacementDone)
386+ }()
387+ requireTimer(t, replacementClock.timers, "timed out waiting for paste-prefix ambiguity timer").fire()
388+ frame = requireFrame(t, replacementOut, "timed out waiting for coalescer-held suffix")
389+ got, err = ports.UnmarshalInput(frame.Payload)
390+ require.NoError(t, err)
391+ require.Equal(t, held, got.Data, "replacement must receive the coalescer-held suffix exactly once")
392+
393+ cancelReplacement()
394+ requireSignal(t, replacementDone, "timed out waiting for replacement stdin pump shutdown")
395+}
396+
397+func TestTerminalInputPumpRejectsReadFromPriorActivation(t *testing.T) {
398+ reader := newLateStopReader()
399+ input := newTerminalInputPump(reader)
400+ input.start()
401+ requireSignal(t, reader.started, "timed out waiting for initial activation Read")
402+
403+ input.suspend()
404+ input.resume()
405+ reader.release <- terminalReadResult{data: []byte("stale")}
406+
407+ // The old read must be discarded after resume, then the reader may begin a
408+ // new Read for the current activation.
409+ requireSignal(t, reader.started, "timed out waiting for current activation Read")
410+ consumer := input.claim()
411+ _, ok := input.take(context.Background(), consumer)
412+ require.False(t, ok, "a Read authorized before suspend/resume must not reach the new activation")
413+ input.revoke(consumer)
414+
415+ input.stop()
416+ reader.release <- terminalReadResult{}
417+ requireSignal(t, input.exited, "timed out waiting for terminal input pump shutdown")
418+}
419+
270 func TestTerminalInputPumpCancellationAfterDeliveredCallbackDoesNotReplay(t *testing.T) {420 func TestTerminalInputPumpCancellationAfterDeliveredCallbackDoesNotReplay(t *testing.T) {
271 input := newTerminalInputPump(nil)421 input := newTerminalInputPump(nil)
272- input.enqueue(terminalReadResult{data: []byte("x")})422+ input.enqueue(terminalReadResult{data: []byte("x")}, 1)
273 var activeGeneration atomic.Uint64423 var activeGeneration atomic.Uint64
274 ctx, cancel := context.WithCancel(context.Background())424 ctx, cancel := context.WithCancel(context.Background())
275 delivered := make(chan struct{})425 delivered := make(chan struct{})

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