v0.0.17 36e0efe

editor.go RawHistoryFolder

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

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