detached 4e93dda

Merge pull request #134 from bnema/fix/disable-autowrap-alt-screen

fix(term): disable autowrap while vev owns the alt screen
commit
4e93dda579f25f3cebce7a68f4e342377b3c936f
author
bnema · · 2026-07-25 14:21 UTC
committer
GitHub · · 2026-07-25 14:21 UTC
merge of
33be6b4 + 62f5d14 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
3 files · +214 -5
M internal/adapters/term/pty_integration_test.go +1 -1
@@ -89,7 +89,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) {
8989
9090 select {
9191 case got := <-captured:
92- want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
92+ want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit
9393 if string(got) != want {
9494 t.Fatalf("captured escapes = %q, want %q", got, want)
9595 }
@@ -89,7 +89,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) {
89 89
90 select {90 select {
91 case got := <-captured:91 case got := <-captured:
92- want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit92+ want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit
93 if string(got) != want {93 if string(got) != want {
94 t.Fatalf("captured escapes = %q, want %q", got, want)94 t.Fatalf("captured escapes = %q, want %q", got, want)
95 }95 }
M internal/adapters/term/terminal.go +34 -2
@@ -31,8 +31,24 @@ const (
3131 bracketedPasteDisable = "\x1b[?2004l"
3232 colorSchemeEnable = "\x1b[?2031h"
3333 colorSchemeDisable = "\x1b[?2031l"
34+ // DECAWM. vev addresses every row explicitly and never relies on the
35+ // terminal wrapping its output, so autowrap stays off while it owns the
36+ // alt screen: a frame the daemon composed for a wider terminal is then
37+ // clipped at the right margin instead of bleeding a styled run onto the
38+ // next row. Restored alongside the other modes, since DECAWM is global
39+ // and is not saved by the alt-screen switch.
40+ autowrapDisable = "\x1b[?7l"
41+ autowrapEnable = "\x1b[?7h"
3442 )
3543
44+// visualRestore turns off every mode EnterRaw turns on, in reverse order, and
45+// hands the normal screen back. Shared by the ordinary restore and the direct
46+// fallback so the two can never drift: a partial emission may have left any
47+// prefix of the enter sequence in effect, so the reset has to cover all of it.
48+// Every mode reset here is idempotent.
49+const visualRestore = cursorShow + cursorStyleDefault + mouseDisable +
50+ bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit
51+
3652 // bufSize is the batched writer's buffer capacity.
3753 const bufSize = 64 * 1024
3854
@@ -107,11 +123,13 @@ func (t *Terminal) EnterRaw() (func() error, error) {
107123 }
108124 t.orig = old
109125
110- if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable); err != nil {
126+ if _, err := t.bw.WriteString(altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable); err != nil {
127+ t.resetVisualModesDirect()
111128 _ = t.restoreRawLocked()
112129 return nil, fmt.Errorf("term: enter alt screen: %w", err)
113130 }
114131 if err := t.bw.Flush(); err != nil {
132+ t.resetVisualModesDirect()
115133 _ = t.restoreRawLocked()
116134 return nil, fmt.Errorf("term: enter alt screen: %w", err)
117135 }
@@ -121,6 +139,17 @@ func (t *Terminal) EnterRaw() (func() error, error) {
121139 return t.restoreFn, nil
122140 }
123141
142+// resetVisualModesDirect writes visualRestore straight to the descriptor
143+// instead of through the batched writer. It is the fallback for a writer that
144+// has already failed: any prefix of the enter sequence may have reached the
145+// terminal, and a hidden cursor, live mouse reporting or the alt screen would
146+// outlive the process. Replaying the resets when nothing was emitted is
147+// harmless. Best effort by construction — the descriptor is usually broken
148+// too. Must be called with t.mu held.
149+func (t *Terminal) resetVisualModesDirect() {
150+ _, _ = t.out.WriteString(visualRestore)
151+}
152+
124153 // makeRestoreLocked returns an idempotent restore closure for the
125154 // current EnterRaw "session". Must be called with t.mu held.
126155 func (t *Terminal) makeRestoreLocked() func() error {
@@ -141,8 +170,11 @@ func (t *Terminal) restore() error {
141170 t.mu.Lock()
142171 defer t.mu.Unlock()
143172
144- _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit)
173+ _, werr := t.bw.WriteString(visualRestore)
145174 ferr := t.bw.Flush()
175+ if werr != nil || ferr != nil {
176+ t.resetVisualModesDirect()
177+ }
146178
147179 rerr := t.restoreRawLocked()
148180
@@ -31,8 +31,24 @@ const (
31 bracketedPasteDisable = "\x1b[?2004l"31 bracketedPasteDisable = "\x1b[?2004l"
32 colorSchemeEnable = "\x1b[?2031h"32 colorSchemeEnable = "\x1b[?2031h"
33 colorSchemeDisable = "\x1b[?2031l"33 colorSchemeDisable = "\x1b[?2031l"
34+ // DECAWM. vev addresses every row explicitly and never relies on the
35+ // terminal wrapping its output, so autowrap stays off while it owns the
36+ // alt screen: a frame the daemon composed for a wider terminal is then
37+ // clipped at the right margin instead of bleeding a styled run onto the
38+ // next row. Restored alongside the other modes, since DECAWM is global
39+ // and is not saved by the alt-screen switch.
40+ autowrapDisable = "\x1b[?7l"
41+ autowrapEnable = "\x1b[?7h"
34 )42 )
35 43
44+// visualRestore turns off every mode EnterRaw turns on, in reverse order, and
45+// hands the normal screen back. Shared by the ordinary restore and the direct
46+// fallback so the two can never drift: a partial emission may have left any
47+// prefix of the enter sequence in effect, so the reset has to cover all of it.
48+// Every mode reset here is idempotent.
49+const visualRestore = cursorShow + cursorStyleDefault + mouseDisable +
50+ bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit
51+
36 // bufSize is the batched writer's buffer capacity.52 // bufSize is the batched writer's buffer capacity.
37 const bufSize = 64 * 102453 const bufSize = 64 * 1024
38 54
@@ -107,11 +123,13 @@ func (t *Terminal) EnterRaw() (func() error, error) {
107 }123 }
108 t.orig = old124 t.orig = old
109 125
110- if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable); err != nil {126+ if _, err := t.bw.WriteString(altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable); err != nil {
127+ t.resetVisualModesDirect()
111 _ = t.restoreRawLocked()128 _ = t.restoreRawLocked()
112 return nil, fmt.Errorf("term: enter alt screen: %w", err)129 return nil, fmt.Errorf("term: enter alt screen: %w", err)
113 }130 }
114 if err := t.bw.Flush(); err != nil {131 if err := t.bw.Flush(); err != nil {
132+ t.resetVisualModesDirect()
115 _ = t.restoreRawLocked()133 _ = t.restoreRawLocked()
116 return nil, fmt.Errorf("term: enter alt screen: %w", err)134 return nil, fmt.Errorf("term: enter alt screen: %w", err)
117 }135 }
@@ -121,6 +139,17 @@ func (t *Terminal) EnterRaw() (func() error, error) {
121 return t.restoreFn, nil139 return t.restoreFn, nil
122 }140 }
123 141
142+// resetVisualModesDirect writes visualRestore straight to the descriptor
143+// instead of through the batched writer. It is the fallback for a writer that
144+// has already failed: any prefix of the enter sequence may have reached the
145+// terminal, and a hidden cursor, live mouse reporting or the alt screen would
146+// outlive the process. Replaying the resets when nothing was emitted is
147+// harmless. Best effort by construction — the descriptor is usually broken
148+// too. Must be called with t.mu held.
149+func (t *Terminal) resetVisualModesDirect() {
150+ _, _ = t.out.WriteString(visualRestore)
151+}
152+
124 // makeRestoreLocked returns an idempotent restore closure for the153 // makeRestoreLocked returns an idempotent restore closure for the
125 // current EnterRaw "session". Must be called with t.mu held.154 // current EnterRaw "session". Must be called with t.mu held.
126 func (t *Terminal) makeRestoreLocked() func() error {155 func (t *Terminal) makeRestoreLocked() func() error {
@@ -141,8 +170,11 @@ func (t *Terminal) restore() error {
141 t.mu.Lock()170 t.mu.Lock()
142 defer t.mu.Unlock()171 defer t.mu.Unlock()
143 172
144- _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit)173+ _, werr := t.bw.WriteString(visualRestore)
145 ferr := t.bw.Flush()174 ferr := t.bw.Flush()
175+ if werr != nil || ferr != nil {
176+ t.resetVisualModesDirect()
177+ }
146 178
147 rerr := t.restoreRawLocked()179 rerr := t.restoreRawLocked()
148 180
M internal/adapters/term/terminal_test.go +179 -2
@@ -2,8 +2,10 @@ package term
22
33 import (
44 "bytes"
5+ "errors"
56 "io"
67 "os"
8+ "strings"
79 "testing"
810 "time"
911
@@ -60,7 +62,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) {
6062 _ = outW.Close()
6163 <-done
6264
63- want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
65+ want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit
6466 if got := captured.String(); got != want {
6567 t.Fatalf("captured escapes = %q, want %q", got, want)
6668 }
@@ -69,6 +71,181 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) {
6971 }
7072 }
7173
74+// A frame the daemon composed for a wider terminal must never bleed onto the
75+// next row: with DECAWM left on, an over-long styled run (the full-width tab
76+// bar) wraps and paints a second row in the accent colour. vev positions every
77+// row explicitly and never relies on the terminal wrapping for it, so autowrap
78+// stays off for as long as vev owns the alt screen.
79+func TestTerminal_EnterRaw_DisablesAutowrapInsideAltScreen(t *testing.T) {
80+ const (
81+ autowrapOff = "\x1b[?7l"
82+ autowrapOn = "\x1b[?7h"
83+ altEnter = "\x1b[?1049h"
84+ altExit = "\x1b[?1049l"
85+ )
86+
87+ inR, inW, err := os.Pipe()
88+ if err != nil {
89+ t.Fatalf("os.Pipe(in): %v", err)
90+ }
91+ defer func() { _ = inW.Close() }()
92+ defer func() { _ = inR.Close() }()
93+
94+ outR, outW, captured, done := pipeCapture(t)
95+ defer func() { _ = outR.Close() }()
96+
97+ tm := NewWithFiles(inR, outW)
98+
99+ restore, err := tm.EnterRaw()
100+ if err != nil {
101+ t.Fatalf("EnterRaw: %v", err)
102+ }
103+ if err := restore(); err != nil {
104+ t.Fatalf("restore: %v", err)
105+ }
106+
107+ _ = outW.Close()
108+ <-done
109+ got := captured.String()
110+
111+ for _, tc := range []struct {
112+ name string
113+ seq string
114+ }{
115+ {"autowrap disabled exactly once", autowrapOff},
116+ {"autowrap restored exactly once", autowrapOn},
117+ } {
118+ t.Run(tc.name, func(t *testing.T) {
119+ if n := strings.Count(got, tc.seq); n != 1 {
120+ t.Fatalf("emitted %q %d times, want exactly 1; full output %q", tc.seq, n, got)
121+ }
122+ })
123+ }
124+
125+ // Ordering: disabling autowrap must land inside the alt screen, and it
126+ // must be restored before vev hands the screen back to the shell.
127+ for _, tc := range []struct {
128+ name string
129+ before string
130+ after string
131+ }{
132+ {"alt screen entered before autowrap is disabled", altEnter, autowrapOff},
133+ {"autowrap disabled before it is restored", autowrapOff, autowrapOn},
134+ {"autowrap restored before leaving the alt screen", autowrapOn, altExit},
135+ } {
136+ t.Run(tc.name, func(t *testing.T) {
137+ i, j := strings.Index(got, tc.before), strings.Index(got, tc.after)
138+ if i < 0 || j < 0 {
139+ t.Fatalf("missing sequence: %q at %d, %q at %d; full output %q", tc.before, i, tc.after, j, got)
140+ }
141+ if i >= j {
142+ t.Fatalf("%q (index %d) must precede %q (index %d); full output %q", tc.before, i, tc.after, j, got)
143+ }
144+ })
145+ }
146+}
147+
148+// prefixThenFailWriter forwards at most prefix bytes to sink and then reports
149+// a failure, standing in for a tty that accepts part of a mode sequence and
150+// then stops. A prefix of 0 fails without emitting anything.
151+type prefixThenFailWriter struct {
152+ sink io.Writer
153+ prefix int
154+}
155+
156+func (w *prefixThenFailWriter) Write(p []byte) (int, error) {
157+ if len(p) > w.prefix {
158+ p = p[:w.prefix]
159+ }
160+ n := 0
161+ if len(p) > 0 {
162+ var err error
163+ n, err = w.sink.Write(p)
164+ if err != nil {
165+ return n, err
166+ }
167+ }
168+ w.prefix -= n
169+ return n, errors.New("sink failed after prefix")
170+}
171+
172+// When the batched writer fails, part of the mode sequence may already have
173+// reached the terminal, so the visual modes must be reset straight to the fd
174+// rather than through the writer that just failed. Poisoning tm.bw while
175+// leaving tm.out intact isolates that fallback: the buffered path fails, the
176+// real descriptor still records what the fallback attempted.
177+func TestTerminal_WriteFailure_AttemptsVisualResetOnFd(t *testing.T) {
178+ // Every mode EnterRaw can turn on has to be turned back off, not just the
179+ // alt screen: a partial emission that got as far as cursorHide/mouseEnable
180+ // would otherwise leave the cursor hidden and mouse reporting on.
181+ const wantCleanup = cursorShow + cursorStyleDefault + mouseDisable +
182+ bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit
183+ const enterSequence = altScreenEnter + autowrapDisable + cursorHide +
184+ mouseEnable + bracketedPasteEnable + colorSchemeEnable
185+
186+ for _, tc := range []struct {
187+ name string
188+ prefix int // bytes the sink accepts before failing
189+ poisonAfter bool // poison the writer after EnterRaw, i.e. fail during restore
190+ want string
191+ }{
192+ {
193+ name: "enter fails before emitting anything",
194+ want: wantCleanup,
195+ },
196+ {
197+ name: "enter fails after emitting part of the sequence",
198+ prefix: len(altScreenEnter + autowrapDisable + cursorHide),
199+ want: altScreenEnter + autowrapDisable + cursorHide + wantCleanup,
200+ },
201+ {
202+ name: "restore fails after a successful enter",
203+ poisonAfter: true,
204+ want: enterSequence + wantCleanup,
205+ },
206+ } {
207+ t.Run(tc.name, func(t *testing.T) {
208+ inR, inW, err := os.Pipe()
209+ if err != nil {
210+ t.Fatalf("os.Pipe(in): %v", err)
211+ }
212+ defer func() { _ = inW.Close() }()
213+ defer func() { _ = inR.Close() }()
214+
215+ outR, outW, captured, done := pipeCapture(t)
216+ defer func() { _ = outR.Close() }()
217+
218+ tm := NewWithFiles(inR, outW)
219+ poison := func() {
220+ tm.bw = newBatchWriter(&prefixThenFailWriter{sink: outW, prefix: tc.prefix}, bufSize)
221+ }
222+
223+ if !tc.poisonAfter {
224+ poison()
225+ if _, err := tm.EnterRaw(); err == nil {
226+ t.Fatalf("EnterRaw returned nil error on a failing sink")
227+ }
228+ } else {
229+ restore, err := tm.EnterRaw()
230+ if err != nil {
231+ t.Fatalf("EnterRaw: %v", err)
232+ }
233+ poison()
234+ if err := restore(); err == nil {
235+ t.Fatalf("restore returned nil error on a failing sink")
236+ }
237+ }
238+
239+ _ = outW.Close()
240+ <-done
241+
242+ if got := captured.String(); got != tc.want {
243+ t.Fatalf("bytes written to the fd = %q, want %q", got, tc.want)
244+ }
245+ })
246+ }
247+}
248+
72249 func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
73250 inR, inW, err := os.Pipe()
74251 if err != nil {
@@ -103,7 +280,7 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
103280
104281 // Alt-screen/cursor escapes must appear exactly once for enter and exits
105282 // exactly once, regardless of how many times EnterRaw/restore were called.
106- want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
283+ want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit
107284 if got := captured.String(); got != want {
108285 t.Fatalf("captured escapes = %q, want %q", got, want)
109286 }
@@ -2,8 +2,10 @@ package term
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "errors"
5 "io"6 "io"
6 "os"7 "os"
8+ "strings"
7 "testing"9 "testing"
8 "time"10 "time"
9 11
@@ -60,7 +62,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) {
60 _ = outW.Close()62 _ = outW.Close()
61 <-done63 <-done
62 64
63- want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit65+ want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit
64 if got := captured.String(); got != want {66 if got := captured.String(); got != want {
65 t.Fatalf("captured escapes = %q, want %q", got, want)67 t.Fatalf("captured escapes = %q, want %q", got, want)
66 }68 }
@@ -69,6 +71,181 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) {
69 }71 }
70 }72 }
71 73
74+// A frame the daemon composed for a wider terminal must never bleed onto the
75+// next row: with DECAWM left on, an over-long styled run (the full-width tab
76+// bar) wraps and paints a second row in the accent colour. vev positions every
77+// row explicitly and never relies on the terminal wrapping for it, so autowrap
78+// stays off for as long as vev owns the alt screen.
79+func TestTerminal_EnterRaw_DisablesAutowrapInsideAltScreen(t *testing.T) {
80+ const (
81+ autowrapOff = "\x1b[?7l"
82+ autowrapOn = "\x1b[?7h"
83+ altEnter = "\x1b[?1049h"
84+ altExit = "\x1b[?1049l"
85+ )
86+
87+ inR, inW, err := os.Pipe()
88+ if err != nil {
89+ t.Fatalf("os.Pipe(in): %v", err)
90+ }
91+ defer func() { _ = inW.Close() }()
92+ defer func() { _ = inR.Close() }()
93+
94+ outR, outW, captured, done := pipeCapture(t)
95+ defer func() { _ = outR.Close() }()
96+
97+ tm := NewWithFiles(inR, outW)
98+
99+ restore, err := tm.EnterRaw()
100+ if err != nil {
101+ t.Fatalf("EnterRaw: %v", err)
102+ }
103+ if err := restore(); err != nil {
104+ t.Fatalf("restore: %v", err)
105+ }
106+
107+ _ = outW.Close()
108+ <-done
109+ got := captured.String()
110+
111+ for _, tc := range []struct {
112+ name string
113+ seq string
114+ }{
115+ {"autowrap disabled exactly once", autowrapOff},
116+ {"autowrap restored exactly once", autowrapOn},
117+ } {
118+ t.Run(tc.name, func(t *testing.T) {
119+ if n := strings.Count(got, tc.seq); n != 1 {
120+ t.Fatalf("emitted %q %d times, want exactly 1; full output %q", tc.seq, n, got)
121+ }
122+ })
123+ }
124+
125+ // Ordering: disabling autowrap must land inside the alt screen, and it
126+ // must be restored before vev hands the screen back to the shell.
127+ for _, tc := range []struct {
128+ name string
129+ before string
130+ after string
131+ }{
132+ {"alt screen entered before autowrap is disabled", altEnter, autowrapOff},
133+ {"autowrap disabled before it is restored", autowrapOff, autowrapOn},
134+ {"autowrap restored before leaving the alt screen", autowrapOn, altExit},
135+ } {
136+ t.Run(tc.name, func(t *testing.T) {
137+ i, j := strings.Index(got, tc.before), strings.Index(got, tc.after)
138+ if i < 0 || j < 0 {
139+ t.Fatalf("missing sequence: %q at %d, %q at %d; full output %q", tc.before, i, tc.after, j, got)
140+ }
141+ if i >= j {
142+ t.Fatalf("%q (index %d) must precede %q (index %d); full output %q", tc.before, i, tc.after, j, got)
143+ }
144+ })
145+ }
146+}
147+
148+// prefixThenFailWriter forwards at most prefix bytes to sink and then reports
149+// a failure, standing in for a tty that accepts part of a mode sequence and
150+// then stops. A prefix of 0 fails without emitting anything.
151+type prefixThenFailWriter struct {
152+ sink io.Writer
153+ prefix int
154+}
155+
156+func (w *prefixThenFailWriter) Write(p []byte) (int, error) {
157+ if len(p) > w.prefix {
158+ p = p[:w.prefix]
159+ }
160+ n := 0
161+ if len(p) > 0 {
162+ var err error
163+ n, err = w.sink.Write(p)
164+ if err != nil {
165+ return n, err
166+ }
167+ }
168+ w.prefix -= n
169+ return n, errors.New("sink failed after prefix")
170+}
171+
172+// When the batched writer fails, part of the mode sequence may already have
173+// reached the terminal, so the visual modes must be reset straight to the fd
174+// rather than through the writer that just failed. Poisoning tm.bw while
175+// leaving tm.out intact isolates that fallback: the buffered path fails, the
176+// real descriptor still records what the fallback attempted.
177+func TestTerminal_WriteFailure_AttemptsVisualResetOnFd(t *testing.T) {
178+ // Every mode EnterRaw can turn on has to be turned back off, not just the
179+ // alt screen: a partial emission that got as far as cursorHide/mouseEnable
180+ // would otherwise leave the cursor hidden and mouse reporting on.
181+ const wantCleanup = cursorShow + cursorStyleDefault + mouseDisable +
182+ bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit
183+ const enterSequence = altScreenEnter + autowrapDisable + cursorHide +
184+ mouseEnable + bracketedPasteEnable + colorSchemeEnable
185+
186+ for _, tc := range []struct {
187+ name string
188+ prefix int // bytes the sink accepts before failing
189+ poisonAfter bool // poison the writer after EnterRaw, i.e. fail during restore
190+ want string
191+ }{
192+ {
193+ name: "enter fails before emitting anything",
194+ want: wantCleanup,
195+ },
196+ {
197+ name: "enter fails after emitting part of the sequence",
198+ prefix: len(altScreenEnter + autowrapDisable + cursorHide),
199+ want: altScreenEnter + autowrapDisable + cursorHide + wantCleanup,
200+ },
201+ {
202+ name: "restore fails after a successful enter",
203+ poisonAfter: true,
204+ want: enterSequence + wantCleanup,
205+ },
206+ } {
207+ t.Run(tc.name, func(t *testing.T) {
208+ inR, inW, err := os.Pipe()
209+ if err != nil {
210+ t.Fatalf("os.Pipe(in): %v", err)
211+ }
212+ defer func() { _ = inW.Close() }()
213+ defer func() { _ = inR.Close() }()
214+
215+ outR, outW, captured, done := pipeCapture(t)
216+ defer func() { _ = outR.Close() }()
217+
218+ tm := NewWithFiles(inR, outW)
219+ poison := func() {
220+ tm.bw = newBatchWriter(&prefixThenFailWriter{sink: outW, prefix: tc.prefix}, bufSize)
221+ }
222+
223+ if !tc.poisonAfter {
224+ poison()
225+ if _, err := tm.EnterRaw(); err == nil {
226+ t.Fatalf("EnterRaw returned nil error on a failing sink")
227+ }
228+ } else {
229+ restore, err := tm.EnterRaw()
230+ if err != nil {
231+ t.Fatalf("EnterRaw: %v", err)
232+ }
233+ poison()
234+ if err := restore(); err == nil {
235+ t.Fatalf("restore returned nil error on a failing sink")
236+ }
237+ }
238+
239+ _ = outW.Close()
240+ <-done
241+
242+ if got := captured.String(); got != tc.want {
243+ t.Fatalf("bytes written to the fd = %q, want %q", got, tc.want)
244+ }
245+ })
246+ }
247+}
248+
72 func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {249 func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
73 inR, inW, err := os.Pipe()250 inR, inW, err := os.Pipe()
74 if err != nil {251 if err != nil {
@@ -103,7 +280,7 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
103 280
104 // Alt-screen/cursor escapes must appear exactly once for enter and exits281 // Alt-screen/cursor escapes must appear exactly once for enter and exits
105 // exactly once, regardless of how many times EnterRaw/restore were called.282 // exactly once, regardless of how many times EnterRaw/restore were called.
106- want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit283+ want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit
107 if got := captured.String(); got != want {284 if got := captured.String(); got != want {
108 t.Fatalf("captured escapes = %q, want %q", got, want)285 t.Fatalf("captured escapes = %q, want %q", got, want)
109 }286 }

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