v0.0.76 ce80627

editor.go RawHistoryFolder

1package tui
2
3import (
4	"fmt"
5	"net/url"
6	"regexp"
7	"strings"
8
9	"github.com/mattn/go-runewidth"
10)
11
12// Editor is a simple multi-line text editor for the input area.
13//
14// Users can type, paste, move the cursor, and submit. The editor
15// exposes its rendered height and the current cursor row/col for the
16// outer renderer. It does NOT draw itself directly; instead, Render()
17// returns the visible lines.
18type Editor struct {
19	// Lines is the current buffer, one entry per line.
20	Lines    []string
21	CursorR  int // row index into Lines
22	CursorC  int // rune index into Lines[CursorR]
23	Prompt   string
24	MaxWidth int
25
26	// History is a ring of previously submitted buffers (newest last).
27	History    []string
28	histIdx    int // -1 means "editing current buffer"
29	savedDraft string
30
31	// pastes stores the full content of every multi-line paste,
32	// keyed by the id embedded in the visible placeholder token.
33	// Pasted text is collapsed to "[paste #N +L lines]" in the
34	// editor so a 500-line drop doesn't explode the input area;
35	// SubmitValue() expands placeholders back to their real bodies
36	// right before the prompt goes to the agent. The map is reset
37	// on Clear() so stale pastes never leak into a follow-up turn.
38	pastes   map[int]string
39	pasteSeq int
40}
41
42// NewEditor returns an empty editor with the given prompt.
43func NewEditor(prompt string) *Editor {
44	return &Editor{
45		Lines:   []string{""},
46		Prompt:  prompt,
47		histIdx: -1,
48	}
49}
50
51// Value returns the buffer as a single string, WITHOUT expanding
52// paste placeholders. Used for anything that should reflect what's
53// visible on screen (history, slash-command detection, editor
54// state). For the string that actually goes to the agent, use
55// SubmitValue(), which expands each [paste #N +L lines] token
56// back into the full pasted body.
57func (e *Editor) Value() string { return strings.Join(e.Lines, "\n") }
58
59// SubmitValue returns the buffer with every paste placeholder
60// expanded to its stored body. Call once at submit time; the
61// expansion is lossless (placeholders are only injected in
62// HandleKey for KeyPaste with multi-line content).
63//
64// Expansion is non-destructive: the internal paste map isn't
65// touched. Clear() is what resets both the placeholder text and
66// the map, and the caller already calls Clear() right after
67// reading SubmitValue() as part of the submit flow.
68func (e *Editor) SubmitValue() string {
69	raw := e.Value()
70	if len(e.pastes) == 0 || !strings.Contains(raw, "[pasted text #") {
71		return raw
72	}
73	return expandPastePlaceholders(raw, e.pastes)
74}
75
76// SetValue replaces the buffer and places the cursor at the end.
77// Also drops any stored pastes because the placeholders they back
78// are now gone from the visible text.
79func (e *Editor) SetValue(s string) {
80	e.Lines = strings.Split(s, "\n")
81	if len(e.Lines) == 0 {
82		e.Lines = []string{""}
83	}
84	e.CursorR = len(e.Lines) - 1
85	e.CursorC = runeLen(e.Lines[e.CursorR])
86	e.histIdx = -1
87	e.pastes = nil
88	e.pasteSeq = 0
89}
90
91// Clear resets the buffer.
92func (e *Editor) Clear() { e.SetValue("") }
93
94// IsEmpty reports whether the buffer has no visible content.
95func (e *Editor) IsEmpty() bool {
96	return len(e.Lines) == 1 && e.Lines[0] == ""
97}
98
99// HandleKey applies k to the editor. It returns (submit, key).
100// If submit is true, the caller should read Value() and call
101// PushHistory + Clear.
102func (e *Editor) HandleKey(k Key) (submit bool) {
103	switch k.Kind {
104	case KeyRune:
105		if k.Alt && (k.Rune == '\r' || k.Rune == '\n') {
106			e.newline()
107			return false
108		}
109		e.insert(string(k.Rune))
110	case KeyEnter:
111		// Shift+Enter would be a separate key in some terminals; we treat
112		// the literal newline byte as Enter. Newline on submit is a decision
113		// for the outer UI via slash commands. Here Enter submits.
114		return true
115	case KeyBackspace:
116		if k.Alt {
117			// Alt+Backspace (Option+Delete on macOS) — delete previous word.
118			e.deleteWord()
119		} else {
120			e.backspace()
121		}
122	case KeyDelete:
123		e.delete()
124	case KeyLeft:
125		if k.Alt {
126			e.moveWordLeft()
127		} else {
128			e.moveLeft()
129		}
130	case KeyRight:
131		if k.Alt {
132			e.moveWordRight()
133		} else {
134			e.moveRight()
135		}
136	case KeyUp:
137		if e.CursorR == 0 {
138			e.historyPrev()
139		} else {
140			e.CursorR--
141			if e.CursorC > runeLen(e.Lines[e.CursorR]) {
142				e.CursorC = runeLen(e.Lines[e.CursorR])
143			}
144		}
145	case KeyDown:
146		if e.CursorR == len(e.Lines)-1 {
147			e.historyNext()
148		} else {
149			e.CursorR++
150			if e.CursorC > runeLen(e.Lines[e.CursorR]) {
151				e.CursorC = runeLen(e.Lines[e.CursorR])
152			}
153		}
154	case KeyHome, KeyCtrlA:
155		e.CursorC = 0
156	case KeyEnd, KeyCtrlE:
157		e.CursorC = runeLen(e.Lines[e.CursorR])
158	case KeyCtrlU:
159		e.Lines[e.CursorR] = substringAfter(e.Lines[e.CursorR], e.CursorC)
160		e.CursorC = 0
161	case KeyCtrlK:
162		e.Lines[e.CursorR] = substringBefore(e.Lines[e.CursorR], e.CursorC)
163	case KeyCtrlW:
164		e.deleteWord()
165	case KeyPaste:
166		// Large multi-line pastes are collapsed to a short
167		// placeholder token so the editor doesn't balloon to
168		// hundreds of rows. The full body is stashed in e.pastes,
169		// keyed by a monotonically increasing id, and swapped
170		// back in at submit time via SubmitValue. Threshold:
171		// two or more newlines triggers collapse; one-liners and
172		// drag-dropped file paths fall through to the original
173		// insert path (including file-path quoting).
174		if pasteShouldCollapse(k.Paste) {
175			if e.pastes == nil {
176				e.pastes = map[int]string{}
177			}
178			e.pasteSeq++
179			id := e.pasteSeq
180			e.pastes[id] = k.Paste
181			placeholder := fmt.Sprintf("[pasted text #%d +%d lines]", id, countLines(k.Paste))
182			e.insert(placeholder)
183		} else {
184			// macOS Terminal / iTerm / Ghostty deliver drag-dropped
185			// files as bracketed-paste text. Detect that pattern
186			// and wrap the path(s) in single quotes so the agent
187			// sees them as one argument.
188			e.insert(quotePastedFilePaths(k.Paste))
189		}
190	case KeyEsc:
191		e.Clear()
192	}
193	return false
194}
195
196// quotePastedFilePaths returns paste with any drag-dropped file
197// paths wrapped in single quotes. Heuristics:
198//
199//   - one line only; multi-line text is returned unchanged so we
200//     never touch a real paste of code.
201//   - whitespace-separated tokens are inspected one by one. A token
202//     that looks like a filesystem path (absolute, ~/relative, or
203//     file:// URL) is normalised and re-quoted; everything else is
204//     left exactly as the user dropped it.
205//   - terminal-style backslash escapes ("foo\ bar.png") are folded
206//     back to literal characters before quoting, since wrapping in
207//     single quotes makes them unnecessary.
208//   - file:// URLs are URL-decoded and stripped of the scheme so
209//     the agent receives a plain filesystem path.
210//   - if a path contains a literal single quote, it's escaped using
211//     the standard '\” splice.
212//
213// Pastes that don't contain any path-shaped tokens are returned
214// unchanged.
215func quotePastedFilePaths(paste string) string {
216	if paste == "" || strings.ContainsRune(paste, '\n') {
217		return paste
218	}
219	trimmed := strings.TrimSpace(paste)
220	if trimmed == "" {
221		return paste
222	}
223
224	// Split on runs of whitespace, preserving the original separators
225	// so multi-file drops keep their spacing on rebuild. Backslash
226	// before a space is treated as part of the preceding token, since
227	// macOS Terminal escapes spaces in dropped paths that way.
228	tokens := splitPreservingSeparators(paste)
229	changed := false
230	for i, tk := range tokens {
231		if tk.isSpace {
232			continue
233		}
234		if p, ok := normalisePathToken(tk.text); ok {
235			tokens[i].text = singleQuote(p)
236			changed = true
237		}
238	}
239	if !changed {
240		return paste
241	}
242	var out strings.Builder
243	out.Grow(len(paste) + 8)
244	for _, tk := range tokens {
245		out.WriteString(tk.text)
246	}
247	return out.String()
248}
249
250type pasteToken struct {
251	text    string
252	isSpace bool
253}
254
255// splitPreservingSeparators splits s into runs of whitespace and
256// runs of non-whitespace, keeping the separator runs intact so a
257// rebuild via concatenation reproduces the original string exactly.
258// A backslash immediately followed by a space ("\ ") is treated as
259// part of the preceding non-whitespace run, since that's how macOS
260// Terminal escapes spaces in drag-dropped file paths.
261func splitPreservingSeparators(s string) []pasteToken {
262	runes := []rune(s)
263	var out []pasteToken
264	var buf strings.Builder
265	inSpace := false
266	flush := func() {
267		if buf.Len() == 0 {
268			return
269		}
270		out = append(out, pasteToken{text: buf.String(), isSpace: inSpace})
271		buf.Reset()
272	}
273	for i := 0; i < len(runes); i++ {
274		r := runes[i]
275		isEscapedSpace := r == '\\' && i+1 < len(runes) && (runes[i+1] == ' ' || runes[i+1] == '\t')
276		rSpace := !isEscapedSpace && (r == ' ' || r == '\t')
277		if buf.Len() == 0 {
278			inSpace = rSpace
279		} else if rSpace != inSpace {
280			flush()
281			inSpace = rSpace
282		}
283		if isEscapedSpace {
284			// Skip the backslash; emit the literal space as part of
285			// this non-whitespace token. normalisePathToken's
286			// unescapeBackslashes is now redundant for this path but
287			// stays in case some other source escapes other chars.
288			buf.WriteRune(runes[i+1])
289			i++
290			continue
291		}
292		buf.WriteRune(r)
293	}
294	flush()
295	return out
296}
297
298// normalisePathToken decides whether tk is a drag-dropped path and,
299// if so, returns the cleaned-up path string.
300func normalisePathToken(tk string) (string, bool) {
301	// Strip pre-existing surrounding quotes; we'll re-quote consistently.
302	if n := len(tk); n >= 2 {
303		if (tk[0] == '\'' && tk[n-1] == '\'') || (tk[0] == '"' && tk[n-1] == '"') {
304			tk = tk[1 : n-1]
305		}
306	}
307
308	// file:// URL form: decode and strip the scheme.
309	if strings.HasPrefix(tk, "file://") {
310		decoded, err := url.PathUnescape(strings.TrimPrefix(tk, "file://"))
311		if err != nil {
312			return "", false
313		}
314		if decoded == "" || decoded[0] != '/' {
315			return "", false
316		}
317		return decoded, true
318	}
319
320	// Looks-like-a-path heuristic: starts with /, ~, ~/. Must contain
321	// at least one path separator after the prefix (otherwise a bare
322	// "/" or "~" gets quoted, which is never what the user meant).
323	if !strings.HasPrefix(tk, "/") && !strings.HasPrefix(tk, "~") {
324		return "", false
325	}
326	if !strings.ContainsAny(tk[1:], "/.") {
327		return "", false
328	}
329	unescaped := unescapeBackslashes(tk)
330	if strings.ContainsAny(unescaped, "|;&$`<>") {
331		return "", false
332	}
333	return unescaped, true
334}
335
336// unescapeBackslashes turns "foo\ bar" into "foo bar". Terminal
337// drag-and-drop on macOS uses backslash escaping by default; quoting
338// makes that unnecessary.
339func unescapeBackslashes(s string) string {
340	if !strings.Contains(s, `\`) {
341		return s
342	}
343	var out strings.Builder
344	out.Grow(len(s))
345	prev := false
346	for _, r := range s {
347		if prev {
348			out.WriteRune(r)
349			prev = false
350			continue
351		}
352		if r == '\\' {
353			prev = true
354			continue
355		}
356		out.WriteRune(r)
357	}
358	if prev {
359		out.WriteRune('\\')
360	}
361	return out.String()
362}
363
364// singleQuote wraps s in single quotes, escaping any embedded single
365// quote using the splice idiom: 'foo'\”bar' for foo'bar.
366func singleQuote(s string) string {
367	return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
368}
369
370// Insert places s into the editor at the cursor, splitting on
371// newlines so multi-line pastes preserve their structure.
372func (e *Editor) Insert(s string) { e.insert(s) }
373
374func (e *Editor) insert(s string) {
375	e.histIdx = -1
376	line := e.Lines[e.CursorR]
377	pre := substringBefore(line, e.CursorC)
378	post := substringAfter(line, e.CursorC)
379	// Split pasted text on newlines.
380	parts := strings.Split(s, "\n")
381	if len(parts) == 1 {
382		e.Lines[e.CursorR] = pre + s + post
383		e.CursorC += runeLen(s)
384		return
385	}
386	newLines := make([]string, 0, len(e.Lines)+len(parts)-1)
387	newLines = append(newLines, e.Lines[:e.CursorR]...)
388	newLines = append(newLines, pre+parts[0])
389	for i := 1; i < len(parts)-1; i++ {
390		newLines = append(newLines, parts[i])
391	}
392	last := parts[len(parts)-1]
393	newLines = append(newLines, last+post)
394	newLines = append(newLines, e.Lines[e.CursorR+1:]...)
395	e.Lines = newLines
396	e.CursorR += len(parts) - 1
397	e.CursorC = runeLen(last)
398}
399
400func (e *Editor) newline() {
401	e.histIdx = -1
402	line := e.Lines[e.CursorR]
403	pre := substringBefore(line, e.CursorC)
404	post := substringAfter(line, e.CursorC)
405	e.Lines[e.CursorR] = pre
406	e.Lines = append(e.Lines, "")
407	copy(e.Lines[e.CursorR+2:], e.Lines[e.CursorR+1:])
408	e.Lines[e.CursorR+1] = post
409	e.CursorR++
410	e.CursorC = 0
411}
412
413func (e *Editor) backspace() {
414	e.histIdx = -1
415	if e.CursorC == 0 {
416		if e.CursorR == 0 {
417			return
418		}
419		prev := e.Lines[e.CursorR-1]
420		cur := e.Lines[e.CursorR]
421		e.Lines = append(e.Lines[:e.CursorR], e.Lines[e.CursorR+1:]...)
422		e.CursorR--
423		e.CursorC = runeLen(prev)
424		e.Lines[e.CursorR] = prev + cur
425		return
426	}
427	line := e.Lines[e.CursorR]
428	e.Lines[e.CursorR] = substringBefore(line, e.CursorC-1) + substringAfter(line, e.CursorC)
429	e.CursorC--
430}
431
432func (e *Editor) delete() {
433	e.histIdx = -1
434	line := e.Lines[e.CursorR]
435	if e.CursorC == runeLen(line) {
436		if e.CursorR == len(e.Lines)-1 {
437			return
438		}
439		next := e.Lines[e.CursorR+1]
440		e.Lines = append(e.Lines[:e.CursorR+1], e.Lines[e.CursorR+2:]...)
441		e.Lines[e.CursorR] = line + next
442		return
443	}
444	e.Lines[e.CursorR] = substringBefore(line, e.CursorC) + substringAfter(line, e.CursorC+1)
445}
446
447// moveWordLeft jumps the cursor to the start of the previous word,
448// using the same word-separator rules as deleteWord. If already at the
449// start of the line, wraps to the end of the previous line.
450func (e *Editor) moveWordLeft() {
451	if e.CursorC == 0 {
452		if e.CursorR > 0 {
453			e.CursorR--
454			e.CursorC = runeLen(e.Lines[e.CursorR])
455		}
456		return
457	}
458	r := []rune(e.Lines[e.CursorR])
459	i := e.CursorC
460	for i > 0 && isWordSep(r[i-1]) {
461		i--
462	}
463	for i > 0 && !isWordSep(r[i-1]) {
464		i--
465	}
466	e.CursorC = i
467}
468
469// moveWordRight jumps the cursor to the start of the next word. If
470// already at the end of the line, wraps to the start of the next line.
471func (e *Editor) moveWordRight() {
472	line := e.Lines[e.CursorR]
473	if e.CursorC >= runeLen(line) {
474		if e.CursorR < len(e.Lines)-1 {
475			e.CursorR++
476			e.CursorC = 0
477		}
478		return
479	}
480	r := []rune(line)
481	i := e.CursorC
482	for i < len(r) && !isWordSep(r[i]) {
483		i++
484	}
485	for i < len(r) && isWordSep(r[i]) {
486		i++
487	}
488	e.CursorC = i
489}
490
491func (e *Editor) moveLeft() {
492	if e.CursorC > 0 {
493		e.CursorC--
494		return
495	}
496	if e.CursorR > 0 {
497		e.CursorR--
498		e.CursorC = runeLen(e.Lines[e.CursorR])
499	}
500}
501
502func (e *Editor) moveRight() {
503	if e.CursorC < runeLen(e.Lines[e.CursorR]) {
504		e.CursorC++
505		return
506	}
507	if e.CursorR < len(e.Lines)-1 {
508		e.CursorR++
509		e.CursorC = 0
510	}
511}
512
513func (e *Editor) deleteWord() {
514	e.histIdx = -1
515	line := e.Lines[e.CursorR]
516	if e.CursorC == 0 {
517		e.backspace()
518		return
519	}
520	r := []rune(line)
521	i := e.CursorC
522	// Step 1: walk over any trailing whitespace to the left of the cursor.
523	for i > 0 && isWordSep(r[i-1]) {
524		i--
525	}
526	// Step 2: walk over the word itself (non-separator runes).
527	for i > 0 && !isWordSep(r[i-1]) {
528		i--
529	}
530	e.Lines[e.CursorR] = string(r[:i]) + string(r[e.CursorC:])
531	e.CursorC = i
532}
533
534// isWordSep reports whether r is a word separator. Anything that isn't
535// a letter, digit, or underscore counts as a separator so delete-word
536// feels natural on paths, code, and prose.
537func isWordSep(r rune) bool {
538	switch {
539	case r == '_':
540		return false
541	case r >= '0' && r <= '9':
542		return false
543	case r >= 'a' && r <= 'z':
544		return false
545	case r >= 'A' && r <= 'Z':
546		return false
547	case r >= 0x80:
548		// Treat all non-ASCII as word chars so CJK/äöü don't get chopped.
549		return false
550	}
551	return true
552}
553
554// ---- history ----
555
556// PushHistory saves s to the history ring.
557func (e *Editor) PushHistory(s string) {
558	s = strings.TrimRight(s, "\n")
559	if s == "" {
560		return
561	}
562	if n := len(e.History); n > 0 && e.History[n-1] == s {
563		return
564	}
565	e.History = append(e.History, s)
566	if len(e.History) > 200 {
567		e.History = e.History[len(e.History)-200:]
568	}
569	e.histIdx = -1
570}
571
572func (e *Editor) historyPrev() {
573	if len(e.History) == 0 {
574		return
575	}
576	if e.histIdx == -1 {
577		e.savedDraft = e.Value()
578		e.histIdx = len(e.History) - 1
579	} else if e.histIdx > 0 {
580		e.histIdx--
581	}
582	e.SetValue(e.History[e.histIdx])
583	// SetValue resets histIdx; restore.
584	e.histIdx = e.findInHistory()
585}
586
587func (e *Editor) historyNext() {
588	if e.histIdx == -1 {
589		return
590	}
591	if e.histIdx == len(e.History)-1 {
592		e.histIdx = -1
593		e.SetValue(e.savedDraft)
594		e.histIdx = -1
595		return
596	}
597	e.histIdx++
598	e.SetValue(e.History[e.histIdx])
599	e.histIdx = e.findInHistory()
600}
601
602func (e *Editor) findInHistory() int {
603	v := e.Value()
604	for i, h := range e.History {
605		if h == v {
606			return i
607		}
608	}
609	return -1
610}
611
612// ---- rendering ----
613
614// Render returns the editor's visible lines (wrapped to width).
615// visualRow/visualCol describe where the cursor lands within the returned lines.
616func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) {
617	promptLen := visibleWidth(e.Prompt)
618	indent := strings.Repeat(" ", promptLen)
619
620	for r, line := range e.Lines {
621		var prefix string
622		if r == 0 {
623			prefix = e.Prompt
624		} else {
625			prefix = indent
626		}
627		wrapped := wrapLine(prefix+line, width, indent)
628		if r == e.CursorR {
629			// Compute where CursorC lands inside the wrapped rows by
630			// walking the wrapped output character-by-character and
631			// tracking which wrapped row + column corresponds to the
632			// cursor's rune index in `line`. This is the only reliable
633			// answer under word-wrap, where a simple (promptLen+col)/width
634			// formula overshoots when wrapLine broke on a space.
635			targetRunes := e.CursorC
636			row, col := locateCursor(wrapped, prefix, line, targetRunes, indent)
637			visualRow = len(lines) + row
638			visualCol = col
639		}
640		lines = append(lines, wrapped...)
641	}
642	return lines, visualRow, visualCol
643}
644
645// locateCursor finds the wrapped row + visible column corresponding to
646// `targetRunes` rune positions into the logical `line`, given that the
647// wrapped output `wrapped` started with `prefix` on its first row and
648// uses `cont` as the continuation indent on subsequent rows.
649func locateCursor(wrapped []string, prefix, line string, targetRunes int, cont string) (int, int) {
650	prefixW := visibleWidth(prefix)
651	contW := visibleWidth(cont)
652	lineRunes := []rune(line)
653	if targetRunes > len(lineRunes) {
654		targetRunes = len(lineRunes)
655	}
656
657	seenRunes := 0
658	for row, w := range wrapped {
659		// strip leading prefix / continuation indent before counting runes
660		body := w
661		var leadW int
662		if row == 0 {
663			if strings.HasPrefix(body, prefix) {
664				body = body[len(prefix):]
665			}
666			leadW = prefixW
667		} else {
668			if strings.HasPrefix(body, cont) {
669				body = body[len(cont):]
670			}
671			leadW = contW
672		}
673		bodyRunes := []rune(body)
674		// Could this wrapped row contain the cursor?
675		if targetRunes <= seenRunes+len(bodyRunes) {
676			// Column inside body.
677			inner := targetRunes - seenRunes
678			if inner < 0 {
679				inner = 0
680			}
681			col := leadW + runewidth.StringWidth(string(bodyRunes[:inner]))
682			return row, col
683		}
684		seenRunes += len(bodyRunes)
685		// Word-wrap may have dropped whitespace at the boundary; skip the
686		// corresponding runes in `line` so counts stay aligned.
687		for seenRunes < len(lineRunes) && lineRunes[seenRunes] == ' ' {
688			seenRunes++
689			if seenRunes >= targetRunes {
690				// Cursor landed in the skipped whitespace — place it at
691				// the start of the next wrapped row.
692				nextRow := row + 1
693				if nextRow >= len(wrapped) {
694					nextRow = row
695				}
696				return nextRow, contW
697			}
698		}
699	}
700	// Fallback: end of the last wrapped row.
701	if len(wrapped) == 0 {
702		return 0, prefixW
703	}
704	last := wrapped[len(wrapped)-1]
705	return len(wrapped) - 1, visibleWidth(last)
706}
707
708// ---- helpers ----
709
710func substringBefore(s string, col int) string {
711	r := []rune(s)
712	if col > len(r) {
713		col = len(r)
714	}
715	return string(r[:col])
716}
717
718func substringAfter(s string, col int) string {
719	r := []rune(s)
720	if col > len(r) {
721		col = len(r)
722	}
723	return string(r[col:])
724}
725
726func runeLen(s string) int { return len([]rune(s)) }
727
728func visualColumn(s string, runeCol int) int {
729	r := []rune(s)
730	if runeCol > len(r) {
731		runeCol = len(r)
732	}
733	return runewidth.StringWidth(string(r[:runeCol]))
734}
735
736func visibleWidth(s string) int {
737	return runewidth.StringWidth(stripANSI(s))
738}
739
740func stripANSI(s string) string {
741	// Minimal ANSI stripper; handles CSI sequences (ESC [ ... final).
742	var out []rune
743	i := 0
744	runes := []rune(s)
745	for i < len(runes) {
746		if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
747			i += 2
748			for i < len(runes) {
749				c := runes[i]
750				i++
751				if c >= 0x40 && c <= 0x7e {
752					break
753				}
754			}
755			continue
756		}
757		out = append(out, runes[i])
758		i++
759	}
760	return string(out)
761}
762
763func wrapLine(s string, width int, cont string) []string {
764	if width <= 0 {
765		return []string{s}
766	}
767
768	// Tokenize on spaces while preserving their widths. Runs of spaces
769	// stay attached to the preceding word so trailing whitespace doesn't
770	// create empty wrapped lines.
771	type token struct {
772		text  string
773		width int
774		space bool // true if this token is whitespace only
775	}
776	var tokens []token
777	{
778		var buf strings.Builder
779		var bufW int
780		inSpace := false
781		flush := func() {
782			if buf.Len() == 0 {
783				return
784			}
785			tokens = append(tokens, token{text: buf.String(), width: bufW, space: inSpace})
786			buf.Reset()
787			bufW = 0
788		}
789		for _, r := range s {
790			rSpace := r == ' ' || r == '\t'
791			if buf.Len() == 0 {
792				inSpace = rSpace
793			} else if rSpace != inSpace {
794				flush()
795				inSpace = rSpace
796			}
797			buf.WriteRune(r)
798			bufW += runewidth.RuneWidth(r)
799		}
800		flush()
801	}
802
803	var out []string
804	var cur strings.Builder
805	curW := 0
806	firstLine := true
807	contW := visibleWidth(cont)
808
809	newLine := func() {
810		out = append(out, cur.String())
811		cur.Reset()
812		curW = 0
813		// After flushing, everything we append next is a CONTINUATION
814		// row, which must start with the cont indent so the editor's
815		// visual cursor alignment stays consistent. The old code only
816		// wrote cont when !firstLine BEFORE toggling firstLine, which
817		// meant the very first wrap never got its indent. That caused
818		// the terminal cursor to land in the wrong column after a
819		// multi-line paste.
820		firstLine = false
821		cur.WriteString(cont)
822		curW = contW
823	}
824
825	for i := 0; i < len(tokens); i++ {
826		tk := tokens[i]
827
828		// Drop leading whitespace after a wrap.
829		if tk.space && curW == contW && !firstLine {
830			continue
831		}
832
833		if curW+tk.width <= width {
834			cur.WriteString(tk.text)
835			curW += tk.width
836			continue
837		}
838
839		// Token overflows.
840		//
841		// If the token on its own is wider than what fits on a
842		// continuation line (width - contW), it'll need to be split
843		// rune-by-rune no matter what. In that case, don't break
844		// first: let the rune-split start right here at the current
845		// column and wrap naturally. Breaking first would strand the
846		// prompt (on firstLine) or the cont indent (on later lines)
847		// on a row by itself, which is the drag-drop-long-path bug.
848		if tk.width > width-contW {
849			// Rune-by-rune starting from the current column. newLine()
850			// inside handles wrapping and re-indents.
851			for _, r := range tk.text {
852				rw := runewidth.RuneWidth(r)
853				if curW+rw > width {
854					newLine()
855				}
856				cur.WriteRune(r)
857				curW += rw
858			}
859			continue
860		}
861
862		// Token fits on a fresh continuation line; break first, then
863		// emit it whole.
864		if cur.Len() > 0 && !(firstLine && curW == 0) {
865			trimmed := strings.TrimRight(cur.String(), " \t")
866			cur.Reset()
867			cur.WriteString(trimmed)
868			curW = visibleWidth(trimmed)
869			newLine()
870			if tk.space {
871				continue
872			}
873		}
874		cur.WriteString(tk.text)
875		curW += tk.width
876	}
877
878	if cur.Len() > 0 || len(out) == 0 {
879		out = append(out, cur.String())
880	}
881	return out
882}
883
884func max(a, b int) int {
885	if a > b {
886		return a
887	}
888	return b
889}
890
891// pasteShouldCollapse reports whether a pasted chunk is big enough
892// to deserve a placeholder token instead of being inserted verbatim.
893// Two or more newlines (i.e. three-plus lines) is the cutoff: that
894// covers real multi-line code/log pastes while leaving single-line
895// file-drop paths and two-line copy-pastes of a sentence alone.
896func pasteShouldCollapse(s string) bool {
897	return strings.Count(s, "\n") >= 2
898}
899
900// countLines returns the number of visual lines in s. A trailing
901// newline is not counted as an extra empty line so
902// "foo\nbar\n" reads as 2 lines (what the user expects in the
903// "+N lines" summary) instead of 3.
904func countLines(s string) int {
905	if s == "" {
906		return 0
907	}
908	n := strings.Count(s, "\n")
909	if !strings.HasSuffix(s, "\n") {
910		n++
911	}
912	return n
913}
914
915// pastePlaceholderRE matches the "[pasted text #N +L lines]" token
916// that collapsed pastes leave in the editor. Capture group 1 is
917// the numeric id used to look up the full body in e.pastes.
918var pastePlaceholderRE = regexp.MustCompile(`\[pasted text #(\d+) \+\d+ lines?\]`)
919
920// expandPastePlaceholders returns raw with every paste token
921// swapped for the body stored under its id in pastes. Tokens
922// whose id isn't in the map are left as-is (user deleted the
923// corresponding entry somehow, or the id is spurious user text
924// that happens to match the shape).
925func expandPastePlaceholders(raw string, pastes map[int]string) string {
926	return pastePlaceholderRE.ReplaceAllStringFunc(raw, func(match string) string {
927		groups := pastePlaceholderRE.FindStringSubmatch(match)
928		if len(groups) < 2 {
929			return match
930		}
931		var id int
932		if _, err := fmt.Sscanf(groups[1], "%d", &id); err != nil {
933			return match
934		}
935		if body, ok := pastes[id]; ok {
936			return body
937		}
938		return match
939	})
940}

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 477

↑↓ moveenter openesc close