detached 1be3f85

fix(tui): cursor after multi-line paste lands in wrong column

wrapLine()'s internal newLine() toggled the firstLine flag BEFORE
checking it, so the very first wrap continuation flushed to the
output WITHOUT the cont indent. Second and later continuations
were fine. Visible as:

  0: '▌ this is a very long first line that'
  1: 'will wrap around terminal boundaries'       <- no indent
  2: '  still wrapping further past this point'   <- indented

Downstream, locateCursor() in the editor assumed continuation rows
always start with cont and stripped its width when counting runes.
When the first continuation didn't actually have it, the stripping
was a no-op but the leadW was still added, so the reported visual
column for the cursor drifted by cont-width (2 cells) to the right.

Effect for the user: after drag-dropping a multi-line payload (or
pasting any text where the first paragraph wraps), the terminal
cursor rendered mid-text instead of at the end of the pasted
content. Typing still appended at the correct logical position,
so keystrokes landed in the right place in the buffer, it was
purely visual drift.

Fix: in newLine(), always write cont to cur after flushing (and
after setting firstLine = false). That makes the second row, and
every subsequent wrap continuation, carry the indent consistently.

Added three regression tests:
  - wrapLine directly: every row >= 1 has cont prefix
  - editor multi-line paste: cursor lands at logical end with
    correct visual (row, col)
  - editor long-paste-with-wrap: wrap continuations all indented
    AND cursor still lands at correct column
commit
1be3f85a475d7390a16334af788e77c7eb489494
author
patriceckhart · · 2026-04-19 17:50 UTC
parent
00b3d9d
signature
unsigned
refs
tag: v0.0.47
2 files · +94 -4
M internal/tui/editor.go +9 -4
@@ -754,11 +754,16 @@ func wrapLine(s string, width int, cont string) []string {
754754 out = append(out, cur.String())
755755 cur.Reset()
756756 curW = 0
757- if !firstLine {
758- cur.WriteString(cont)
759- curW = contW
760- }
757+ // After flushing, everything we append next is a CONTINUATION
758+ // row, which must start with the cont indent so the editor's
759+ // visual cursor alignment stays consistent. The old code only
760+ // wrote cont when !firstLine BEFORE toggling firstLine, which
761+ // meant the very first wrap never got its indent. That caused
762+ // the terminal cursor to land in the wrong column after a
763+ // multi-line paste.
761764 firstLine = false
765+ cur.WriteString(cont)
766+ curW = contW
762767 }
763768
764769 for i := 0; i < len(tokens); i++ {
@@ -754,11 +754,16 @@ func wrapLine(s string, width int, cont string) []string {
754 out = append(out, cur.String())754 out = append(out, cur.String())
755 cur.Reset()755 cur.Reset()
756 curW = 0756 curW = 0
757- if !firstLine {757+ // After flushing, everything we append next is a CONTINUATION
758- cur.WriteString(cont)758+ // row, which must start with the cont indent so the editor's
759- curW = contW759+ // visual cursor alignment stays consistent. The old code only
760- }760+ // wrote cont when !firstLine BEFORE toggling firstLine, which
761+ // meant the very first wrap never got its indent. That caused
762+ // the terminal cursor to land in the wrong column after a
763+ // multi-line paste.
761 firstLine = false764 firstLine = false
765+ cur.WriteString(cont)
766+ curW = contW
762 }767 }
763 768
764 for i := 0; i < len(tokens); i++ {769 for i := 0; i < len(tokens); i++ {
A internal/tui/wrap_test.go +85 -0
@@ -0,0 +1,85 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+// TestWrapLineFirstContinuationHasIndent is a regression test for a
9+// bug where wrapLine()'s internal newLine() toggled the firstLine
10+// flag and THEN checked it, so the very first wrap continuation
11+// flushed without the cont indent. Any subsequent continuation
12+// (second wrap onwards) got the indent. That was visible as a
13+// misaligned second row and caused the editor's cursor to land in
14+// the wrong column after a multi-line paste (locateCursor assumes
15+// continuations carry cont, so when they didn't, the cursor drifted
16+// one-indent-worth to the right).
17+func TestWrapLineFirstContinuationHasIndent(t *testing.T) {
18+ s := "prefix this is a long line that will wrap around at forty cells"
19+ out := wrapLine(s, 40, " ")
20+
21+ if len(out) < 2 {
22+ t.Fatalf("want at least 2 wrapped rows, got %d: %v", len(out), out)
23+ }
24+ // Row 0 is the first line (no indent; it's the lead).
25+ // Every row from index 1 onward is a continuation and MUST start
26+ // with the cont prefix.
27+ for i := 1; i < len(out); i++ {
28+ if !strings.HasPrefix(out[i], " ") {
29+ t.Errorf("row %d missing cont indent: %q", i, out[i])
30+ }
31+ }
32+}
33+
34+// TestEditorCursorAfterMultilinePaste is the downstream test: the
35+// rendered editor cursor must land at the logical end of the paste,
36+// with its visual column equal to leadW + runewidth(last-line).
37+func TestEditorCursorAfterMultilinePaste(t *testing.T) {
38+ e := NewEditor("▌ ")
39+ e.HandleKey(Key{Kind: KeyPaste, Paste: "aaa\nbbb\nccccc"})
40+
41+ // Logical end: last line "ccccc", cursor past its 5 runes.
42+ if e.CursorR != 2 || e.CursorC != 5 {
43+ t.Fatalf("logical cursor: want (2, 5), got (%d, %d)", e.CursorR, e.CursorC)
44+ }
45+
46+ lines, row, col := e.Render(80)
47+ if len(lines) != 3 {
48+ t.Fatalf("want 3 rendered rows, got %d: %v", len(lines), lines)
49+ }
50+ // Row 0 "▌ aaa", row 1 " bbb", row 2 " ccccc".
51+ // Cursor lives at row 2; column = 2 (cont indent) + 5 = 7.
52+ if row != 2 {
53+ t.Errorf("visual row: want 2, got %d", row)
54+ }
55+ if col != 7 {
56+ t.Errorf("visual col: want 7, got %d", col)
57+ }
58+}
59+
60+// TestEditorCursorAfterLongPasteWithWrap verifies the cursor lands
61+// correctly when a pasted line is long enough to wrap at the given
62+// render width. This is the scenario that was broken: before the
63+// fix, the first wrap continuation missed its cont indent, so the
64+// terminal cursor drifted when typed after pasting a wrapped path.
65+func TestEditorCursorAfterLongPasteWithWrap(t *testing.T) {
66+ e := NewEditor("▌ ")
67+ e.HandleKey(Key{Kind: KeyPaste, Paste: "this is a very long line that should wrap\nshort"})
68+
69+ lines, row, col := e.Render(30)
70+
71+ // Every continuation row (anything after row 0) must be
72+ // cont-indented so locateCursor's rune-counting stays honest.
73+ for i := 1; i < len(lines); i++ {
74+ if !strings.HasPrefix(lines[i], " ") {
75+ t.Errorf("continuation row %d missing indent: %q", i, lines[i])
76+ }
77+ }
78+ // Cursor should be at the end of "short" on the last rendered row.
79+ if row != len(lines)-1 {
80+ t.Errorf("visual row: want %d (last), got %d", len(lines)-1, row)
81+ }
82+ if col != 2+5 { // cont indent + len("short")
83+ t.Errorf("visual col: want 7, got %d", col)
84+ }
85+}
@@ -0,0 +1,85 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+// TestWrapLineFirstContinuationHasIndent is a regression test for a
9+// bug where wrapLine()'s internal newLine() toggled the firstLine
10+// flag and THEN checked it, so the very first wrap continuation
11+// flushed without the cont indent. Any subsequent continuation
12+// (second wrap onwards) got the indent. That was visible as a
13+// misaligned second row and caused the editor's cursor to land in
14+// the wrong column after a multi-line paste (locateCursor assumes
15+// continuations carry cont, so when they didn't, the cursor drifted
16+// one-indent-worth to the right).
17+func TestWrapLineFirstContinuationHasIndent(t *testing.T) {
18+ s := "prefix this is a long line that will wrap around at forty cells"
19+ out := wrapLine(s, 40, " ")
20+
21+ if len(out) < 2 {
22+ t.Fatalf("want at least 2 wrapped rows, got %d: %v", len(out), out)
23+ }
24+ // Row 0 is the first line (no indent; it's the lead).
25+ // Every row from index 1 onward is a continuation and MUST start
26+ // with the cont prefix.
27+ for i := 1; i < len(out); i++ {
28+ if !strings.HasPrefix(out[i], " ") {
29+ t.Errorf("row %d missing cont indent: %q", i, out[i])
30+ }
31+ }
32+}
33+
34+// TestEditorCursorAfterMultilinePaste is the downstream test: the
35+// rendered editor cursor must land at the logical end of the paste,
36+// with its visual column equal to leadW + runewidth(last-line).
37+func TestEditorCursorAfterMultilinePaste(t *testing.T) {
38+ e := NewEditor("▌ ")
39+ e.HandleKey(Key{Kind: KeyPaste, Paste: "aaa\nbbb\nccccc"})
40+
41+ // Logical end: last line "ccccc", cursor past its 5 runes.
42+ if e.CursorR != 2 || e.CursorC != 5 {
43+ t.Fatalf("logical cursor: want (2, 5), got (%d, %d)", e.CursorR, e.CursorC)
44+ }
45+
46+ lines, row, col := e.Render(80)
47+ if len(lines) != 3 {
48+ t.Fatalf("want 3 rendered rows, got %d: %v", len(lines), lines)
49+ }
50+ // Row 0 "▌ aaa", row 1 " bbb", row 2 " ccccc".
51+ // Cursor lives at row 2; column = 2 (cont indent) + 5 = 7.
52+ if row != 2 {
53+ t.Errorf("visual row: want 2, got %d", row)
54+ }
55+ if col != 7 {
56+ t.Errorf("visual col: want 7, got %d", col)
57+ }
58+}
59+
60+// TestEditorCursorAfterLongPasteWithWrap verifies the cursor lands
61+// correctly when a pasted line is long enough to wrap at the given
62+// render width. This is the scenario that was broken: before the
63+// fix, the first wrap continuation missed its cont indent, so the
64+// terminal cursor drifted when typed after pasting a wrapped path.
65+func TestEditorCursorAfterLongPasteWithWrap(t *testing.T) {
66+ e := NewEditor("▌ ")
67+ e.HandleKey(Key{Kind: KeyPaste, Paste: "this is a very long line that should wrap\nshort"})
68+
69+ lines, row, col := e.Render(30)
70+
71+ // Every continuation row (anything after row 0) must be
72+ // cont-indented so locateCursor's rune-counting stays honest.
73+ for i := 1; i < len(lines); i++ {
74+ if !strings.HasPrefix(lines[i], " ") {
75+ t.Errorf("continuation row %d missing indent: %q", i, lines[i])
76+ }
77+ }
78+ // Cursor should be at the end of "short" on the last rendered row.
79+ if row != len(lines)-1 {
80+ t.Errorf("visual row: want %d (last), got %d", len(lines)-1, row)
81+ }
82+ if col != 2+5 { // cont indent + len("short")
83+ t.Errorf("visual col: want 7, got %d", col)
84+ }
85+}

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 1

Tags 476

↑↓ moveenter openesc close