v0.3.95 beceda1

wrap_test.go RawHistoryFolder

1package tui
2
3import (
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).
17func 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//
38// Uses Insert directly to bypass the KeyPaste collapse path (which
39// would turn this into a placeholder token); the test's concern is
40// wrap / cursor math, not paste behaviour.
41func TestEditorCursorAfterMultilinePaste(t *testing.T) {
42	e := NewEditor("▌ ")
43	e.Insert("aaa\nbbb\nccccc")
44
45	// Logical end: last line "ccccc", cursor past its 5 runes.
46	if e.CursorR != 2 || e.CursorC != 5 {
47		t.Fatalf("logical cursor: want (2, 5), got (%d, %d)", e.CursorR, e.CursorC)
48	}
49
50	lines, row, col := e.Render(80)
51	if len(lines) != 3 {
52		t.Fatalf("want 3 rendered rows, got %d: %v", len(lines), lines)
53	}
54	// Row 0 "▌ aaa", row 1 "  bbb", row 2 "  ccccc".
55	// Cursor lives at row 2; column = 2 (cont indent) + 5 = 7.
56	if row != 2 {
57		t.Errorf("visual row: want 2, got %d", row)
58	}
59	if col != 7 {
60		t.Errorf("visual col: want 7, got %d", col)
61	}
62}
63
64// TestEditorCursorAfterLongPasteWithWrap verifies the cursor lands
65// correctly when a pasted line is long enough to wrap at the given
66// render width. This is the scenario that was broken: before the
67// fix, the first wrap continuation missed its cont indent, so the
68// terminal cursor drifted when typed after pasting a wrapped path.
69func TestEditorCursorAfterLongPasteWithWrap(t *testing.T) {
70	e := NewEditor("▌ ")
71	// Direct Insert bypasses the multi-line collapse path; the
72	// concern here is wrap-column math, not the collapse logic.
73	e.Insert("this is a very long line that should wrap\nshort")
74
75	lines, row, col := e.Render(30)
76
77	// Every continuation row (anything after row 0) must be
78	// cont-indented so locateCursor's rune-counting stays honest.
79	for i := 1; i < len(lines); i++ {
80		if !strings.HasPrefix(lines[i], "  ") {
81			t.Errorf("continuation row %d missing indent: %q", i, lines[i])
82		}
83	}
84	// Cursor should be at the end of "short" on the last rendered row.
85	if row != len(lines)-1 {
86		t.Errorf("visual row: want %d (last), got %d", len(lines)-1, row)
87	}
88	if col != 2+5 { // cont indent + len("short")
89		t.Errorf("visual col: want 7, got %d", col)
90	}
91}
92
93// TestWrapLongTokenKeepsPromptOnFirstLine guards against a bug where
94// pasting a single very long path (no spaces within it) stranded the
95// prompt on its own line and started the path on line 2 with the
96// cont indent. That happened because wrapLine broke to a new line
97// before checking whether the oncoming token would need rune-by-rune
98// splitting anyway. Stranding the prompt offset locateCursor's
99// rune walk and the terminal cursor drew in the wrong column after
100// any typing past the paste.
101func TestWrapLongTokenKeepsPromptOnFirstLine(t *testing.T) {
102	// Long single token (a path) + prompt "▌ ". Width 40, the token
103	// itself is way longer than (40 - cont).
104	s := "▌ /var/folders/xq/hdh5qm6j66nbzd0sh3ljsxyc0000gn/T/TemporaryItems/verylongnamehere"
105	out := wrapLine(s, 40, "  ")
106
107	if len(out) < 2 {
108		t.Fatalf("expected multiple wrapped rows, got %d: %v", len(out), out)
109	}
110	// Row 0 must contain BOTH the prompt and the start of the path,
111	// not just the prompt alone. A stranded-prompt regression would
112	// show up as a short first row and the path starting on row 1.
113	if !strings.HasPrefix(out[0], "▌ /") {
114		t.Errorf("row 0 should start with the prompt AND path content, got %q", out[0])
115	}
116	if visibleWidth(out[0]) < 20 {
117		t.Errorf("row 0 width %d too small; prompt got stranded", visibleWidth(out[0]))
118	}
119}
120
121// TestEditorCursorAfterLongPathPaste verifies the on-screen cursor
122// lines up with the logical end of the buffer after a drag-dropped
123// long path is pasted AND the user types additional characters.
124// Screenshot scenario from 20:15 on 2026-04-19.
125func TestEditorCursorAfterLongPathPaste(t *testing.T) {
126	e := NewEditor("▌ ")
127	path := "/very/long/path/that/exceeds/our/terminal/width/by/a/comfortable/margin/file.png"
128	e.HandleKey(Key{Kind: KeyPaste, Paste: path})
129	for _, r := range "hello" {
130		e.HandleKey(Key{Kind: KeyRune, Rune: r})
131	}
132	lines, row, col := e.Render(40)
133
134	// Cursor must be on the last rendered row.
135	if row != len(lines)-1 {
136		t.Errorf("visual row: want %d (last), got %d (lines: %v)", len(lines)-1, row, lines)
137	}
138	// And its column should equal the width of the last row (cursor
139	// at end-of-line).
140	if col != visibleWidth(lines[len(lines)-1]) {
141		t.Errorf("visual col %d != last-row width %d", col, visibleWidth(lines[len(lines)-1]))
142	}
143}

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