mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-26 21:36:31 +02:00
1323 lines
36 KiB
Go
1323 lines
36 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/mattn/go-runewidth"
|
|
)
|
|
|
|
// Editor is a simple multi-line text editor for the input area.
|
|
//
|
|
// Users can type, paste, move the cursor, and submit. The editor
|
|
// exposes its rendered height and the current cursor row/col for the
|
|
// outer renderer. It does NOT draw itself directly; instead, Render()
|
|
// returns the visible lines.
|
|
type Editor struct {
|
|
// Lines is the current buffer, one entry per line.
|
|
Lines []string
|
|
CursorR int // row index into Lines
|
|
CursorC int // rune index into Lines[CursorR]
|
|
Prompt string
|
|
MaxWidth int
|
|
|
|
// lastRenderWidth is the column count passed to the most recent
|
|
// Render() call. Up/Down key handling needs this to walk the
|
|
// same visual layout the user sees: a logical line that wraps to
|
|
// two rows should respond to Up by moving to the previous visual
|
|
// row, not do nothing because CursorR is already 0.
|
|
lastRenderWidth int
|
|
|
|
// pastes stores the full content of every multi-line paste,
|
|
// keyed by the id embedded in the visible placeholder token.
|
|
// Pasted text is collapsed to "[paste #N +L lines]" in the
|
|
// editor so a 500-line drop doesn't explode the input area;
|
|
// SubmitValue() expands placeholders back to their real bodies
|
|
// right before the prompt goes to the agent. The map is reset
|
|
// on Clear() so stale pastes never leak into a follow-up turn.
|
|
pastes map[int]string
|
|
pasteSeq int
|
|
|
|
// files and dirs store full paths of drag-dropped items, keyed
|
|
// by separate sequence ids. The editor shows compact chips like
|
|
// [file:1:name] and [dir:1:name/] and SubmitValue() expands
|
|
// them back to the full path.
|
|
files map[int]string
|
|
fileSeq int
|
|
dirs map[int]string
|
|
dirSeq int
|
|
}
|
|
|
|
// NewEditor returns an empty editor with the given prompt.
|
|
func NewEditor(prompt string) *Editor {
|
|
return &Editor{
|
|
Lines: []string{""},
|
|
Prompt: prompt,
|
|
}
|
|
}
|
|
|
|
// Value returns the buffer as a single string, WITHOUT expanding
|
|
// paste placeholders. Used for anything that should reflect what's
|
|
// visible on screen (history, slash-command detection, editor
|
|
// state). For the string that actually goes to the agent, use
|
|
// SubmitValue(), which expands each [paste #N +L lines] token
|
|
// back into the full pasted body.
|
|
func (e *Editor) Value() string { return strings.Join(e.Lines, "\n") }
|
|
|
|
// SubmitValue returns the buffer with every paste placeholder
|
|
// expanded to its stored body. Call once at submit time; the
|
|
// expansion is lossless (placeholders are only injected in
|
|
// HandleKey for KeyPaste with multi-line content).
|
|
//
|
|
// Expansion is non-destructive: the internal paste map isn't
|
|
// touched. Clear() is what resets both the placeholder text and
|
|
// the map, and the caller already calls Clear() right after
|
|
// reading SubmitValue() as part of the submit flow.
|
|
func (e *Editor) SubmitValue() string {
|
|
raw := e.Value()
|
|
if len(e.pastes) > 0 && strings.Contains(raw, "[pasted text #") {
|
|
raw = expandPastePlaceholders(raw, e.pastes)
|
|
}
|
|
if (len(e.files) > 0 || len(e.dirs) > 0) && (strings.Contains(raw, "[file:") || strings.Contains(raw, "[dir:")) {
|
|
raw = expandFilePlaceholders(raw, e.files, e.dirs)
|
|
}
|
|
return raw
|
|
}
|
|
|
|
// SetValue replaces the buffer and places the cursor at the end.
|
|
// Also drops any stored pastes because the placeholders they back
|
|
// are now gone from the visible text.
|
|
func (e *Editor) SetValue(s string) {
|
|
s = normalizeEditorText(s)
|
|
e.Lines = strings.Split(s, "\n")
|
|
if len(e.Lines) == 0 {
|
|
e.Lines = []string{""}
|
|
}
|
|
e.CursorR = len(e.Lines) - 1
|
|
e.CursorC = runeLen(e.Lines[e.CursorR])
|
|
e.pastes = nil
|
|
e.pasteSeq = 0
|
|
e.files = nil
|
|
e.fileSeq = 0
|
|
e.dirs = nil
|
|
e.dirSeq = 0
|
|
}
|
|
|
|
// Clear resets the buffer.
|
|
func (e *Editor) Clear() { e.SetValue("") }
|
|
|
|
// IsEmpty reports whether the buffer has no visible content.
|
|
func (e *Editor) IsEmpty() bool {
|
|
return len(e.Lines) == 1 && e.Lines[0] == ""
|
|
}
|
|
|
|
// HandleKey applies k to the editor. It returns submit=true when
|
|
// the user pressed enter and there is content to send; the caller
|
|
// should read SubmitValue() and then Clear().
|
|
func (e *Editor) HandleKey(k Key) (submit bool) {
|
|
switch k.Kind {
|
|
case KeyRune:
|
|
if k.Alt && (k.Rune == '\r' || k.Rune == '\n') {
|
|
e.newline()
|
|
return false
|
|
}
|
|
e.insert(string(k.Rune))
|
|
case KeyEnter:
|
|
if k.Shift || k.Alt {
|
|
e.newline()
|
|
return false
|
|
}
|
|
return true
|
|
case KeyBackspace:
|
|
if k.Alt {
|
|
// Alt+Backspace (Option+Delete on macOS) — delete previous word.
|
|
e.deleteWord()
|
|
} else {
|
|
e.backspace()
|
|
}
|
|
case KeyDelete:
|
|
e.delete()
|
|
case KeyLeft:
|
|
if k.Alt {
|
|
e.moveWordLeft()
|
|
} else {
|
|
e.moveLeft()
|
|
}
|
|
case KeyRight:
|
|
if k.Alt {
|
|
e.moveWordRight()
|
|
} else {
|
|
e.moveRight()
|
|
}
|
|
case KeyUp:
|
|
// Visual-row navigation. When a single logical line wraps
|
|
// to several visual rows, Up needs to climb one visual
|
|
// row — which may mean moving within the same
|
|
// e.Lines[CursorR] back toward an earlier rune index,
|
|
// not jumping to CursorR-1. Buffer-line navigation is
|
|
// subsumed: a visual row above may also live in the
|
|
// previous logical line when a short line precedes a
|
|
// wrapped one.
|
|
e.MoveVertical(-1)
|
|
case KeyDown:
|
|
e.MoveVertical(+1)
|
|
case KeyHome, KeyCtrlA:
|
|
e.CursorC = 0
|
|
case KeyEnd, KeyCtrlE:
|
|
e.CursorC = runeLen(e.Lines[e.CursorR])
|
|
case KeyCtrlU:
|
|
e.Lines[e.CursorR] = substringAfter(e.Lines[e.CursorR], e.CursorC)
|
|
e.CursorC = 0
|
|
case KeyCtrlK:
|
|
e.Lines[e.CursorR] = substringBefore(e.Lines[e.CursorR], e.CursorC)
|
|
case KeyCtrlW:
|
|
e.deleteWord()
|
|
case KeyPaste:
|
|
paste := normalizeEditorText(k.Paste)
|
|
// Large multi-line pastes are collapsed to a short
|
|
// placeholder token so the editor doesn't balloon to
|
|
// hundreds of rows. The full body is stashed in e.pastes,
|
|
// keyed by a monotonically increasing id, and swapped
|
|
// back in at submit time via SubmitValue. Threshold:
|
|
// two or more newlines triggers collapse; one-liners and
|
|
// drag-dropped file paths fall through to the original
|
|
// insert path (including file-path quoting).
|
|
if pasteShouldCollapse(paste) {
|
|
if e.pastes == nil {
|
|
e.pastes = map[int]string{}
|
|
}
|
|
e.pasteSeq++
|
|
id := e.pasteSeq
|
|
e.pastes[id] = paste
|
|
e.insert(formatPastePlaceholder(id, paste))
|
|
} else {
|
|
// macOS Terminal / iTerm / Ghostty deliver drag-dropped
|
|
// files as bracketed-paste text. Detect that pattern
|
|
// and collapse long paths to a [file:basename] chip.
|
|
inserted := e.collapseOrQuoteFilePaths(paste)
|
|
e.insert(inserted)
|
|
}
|
|
case KeyEsc:
|
|
e.Clear()
|
|
}
|
|
return false
|
|
}
|
|
|
|
// quotePastedFilePaths returns paste with any drag-dropped file
|
|
// paths wrapped in single quotes. Heuristics:
|
|
//
|
|
// - one line only; multi-line text is returned unchanged so we
|
|
// never touch a real paste of code.
|
|
// - whitespace-separated tokens are inspected one by one. A token
|
|
// that looks like a filesystem path (absolute, ~/relative, or
|
|
// file:// URL) is normalised and re-quoted; everything else is
|
|
// left exactly as the user dropped it.
|
|
// - terminal-style backslash escapes ("foo\ bar.png") are folded
|
|
// back to literal characters before quoting, since wrapping in
|
|
// single quotes makes them unnecessary.
|
|
// - file:// URLs are URL-decoded and stripped of the scheme so
|
|
// the agent receives a plain filesystem path.
|
|
// - if a path contains a literal single quote, it's escaped using
|
|
// the standard '\” splice.
|
|
//
|
|
// Pastes that don't contain any path-shaped tokens are returned
|
|
// unchanged.
|
|
func quotePastedFilePaths(paste string) string {
|
|
if paste == "" || strings.ContainsRune(paste, '\n') {
|
|
return paste
|
|
}
|
|
trimmed := strings.TrimSpace(paste)
|
|
if trimmed == "" {
|
|
return paste
|
|
}
|
|
|
|
// Split on runs of whitespace, preserving the original separators
|
|
// so multi-file drops keep their spacing on rebuild. Backslash
|
|
// before a space is treated as part of the preceding token, since
|
|
// macOS Terminal escapes spaces in dropped paths that way.
|
|
tokens := splitPreservingSeparators(paste)
|
|
changed := false
|
|
for i, tk := range tokens {
|
|
if tk.isSpace {
|
|
continue
|
|
}
|
|
if p, ok := normalisePathToken(tk.text); ok {
|
|
tokens[i].text = singleQuote(p)
|
|
changed = true
|
|
}
|
|
}
|
|
if !changed {
|
|
return paste
|
|
}
|
|
var out strings.Builder
|
|
out.Grow(len(paste) + 8)
|
|
for _, tk := range tokens {
|
|
out.WriteString(tk.text)
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
type pasteToken struct {
|
|
text string
|
|
isSpace bool
|
|
}
|
|
|
|
// splitPreservingSeparators splits s into runs of whitespace and
|
|
// runs of non-whitespace, keeping the separator runs intact so a
|
|
// rebuild via concatenation reproduces the original string exactly.
|
|
// A backslash immediately followed by a space ("\ ") is treated as
|
|
// part of the preceding non-whitespace run, since that's how macOS
|
|
// Terminal escapes spaces in drag-dropped file paths.
|
|
func splitPreservingSeparators(s string) []pasteToken {
|
|
runes := []rune(s)
|
|
var out []pasteToken
|
|
var buf strings.Builder
|
|
inSpace := false
|
|
flush := func() {
|
|
if buf.Len() == 0 {
|
|
return
|
|
}
|
|
out = append(out, pasteToken{text: buf.String(), isSpace: inSpace})
|
|
buf.Reset()
|
|
}
|
|
for i := 0; i < len(runes); i++ {
|
|
r := runes[i]
|
|
isEscapedSpace := r == '\\' && i+1 < len(runes) && (runes[i+1] == ' ' || runes[i+1] == '\t')
|
|
rSpace := !isEscapedSpace && (r == ' ' || r == '\t')
|
|
if buf.Len() == 0 {
|
|
inSpace = rSpace
|
|
} else if rSpace != inSpace {
|
|
flush()
|
|
inSpace = rSpace
|
|
}
|
|
if isEscapedSpace {
|
|
// Skip the backslash; emit the literal space as part of
|
|
// this non-whitespace token. normalisePathToken's
|
|
// unescapeBackslashes is now redundant for this path but
|
|
// stays in case some other source escapes other chars.
|
|
buf.WriteRune(runes[i+1])
|
|
i++
|
|
continue
|
|
}
|
|
buf.WriteRune(r)
|
|
}
|
|
flush()
|
|
return out
|
|
}
|
|
|
|
// normalisePathToken decides whether tk is a drag-dropped path and,
|
|
// if so, returns the cleaned-up path string.
|
|
//
|
|
// To avoid mistaking URL path segments (e.g. "/de/downloads/foo"
|
|
// pasted from a browser address bar) for filesystem paths, the
|
|
// candidate must point to an entry that actually exists on disk.
|
|
// Drag-and-drop from a file manager always satisfies this; a
|
|
// hand-typed URL fragment doesn't, so it falls through to the
|
|
// regular insert path untouched.
|
|
func normalisePathToken(tk string) (string, bool) {
|
|
// Strip pre-existing surrounding quotes; we'll re-quote consistently.
|
|
if n := len(tk); n >= 2 {
|
|
if (tk[0] == '\'' && tk[n-1] == '\'') || (tk[0] == '"' && tk[n-1] == '"') {
|
|
tk = tk[1 : n-1]
|
|
}
|
|
}
|
|
|
|
// file:// URL form: decode and strip the scheme.
|
|
if strings.HasPrefix(tk, "file://") {
|
|
decoded, err := url.PathUnescape(strings.TrimPrefix(tk, "file://"))
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
if decoded == "" || decoded[0] != '/' {
|
|
return "", false
|
|
}
|
|
if !pathExists(decoded) {
|
|
return "", false
|
|
}
|
|
return decoded, true
|
|
}
|
|
|
|
// Looks-like-a-path heuristic: starts with /, ~, ~/. Must contain
|
|
// at least one path separator after the prefix (otherwise a bare
|
|
// "/" or "~" gets quoted, which is never what the user meant).
|
|
if !strings.HasPrefix(tk, "/") && !strings.HasPrefix(tk, "~") {
|
|
return "", false
|
|
}
|
|
if !strings.ContainsAny(tk[1:], "/.") {
|
|
return "", false
|
|
}
|
|
unescaped := unescapeBackslashes(tk)
|
|
if strings.ContainsAny(unescaped, "|;&$`<>") {
|
|
return "", false
|
|
}
|
|
if !pathExists(unescaped) {
|
|
return "", false
|
|
}
|
|
return unescaped, true
|
|
}
|
|
|
|
// pathExists reports whether p resolves to an existing filesystem
|
|
// entry (file, directory, or symlink target). "~" / "~/" prefixes
|
|
// are expanded relative to the current user's home directory before
|
|
// the check, mirroring how the agent will later read the path.
|
|
func pathExists(p string) bool {
|
|
if p == "" {
|
|
return false
|
|
}
|
|
expanded := p
|
|
if p == "~" || strings.HasPrefix(p, "~/") {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil || home == "" {
|
|
return false
|
|
}
|
|
if p == "~" {
|
|
expanded = home
|
|
} else {
|
|
expanded = filepath.Join(home, p[2:])
|
|
}
|
|
}
|
|
_, err := os.Stat(expanded)
|
|
return err == nil
|
|
}
|
|
|
|
// unescapeBackslashes turns "foo\ bar" into "foo bar". Terminal
|
|
// drag-and-drop on macOS uses backslash escaping by default; quoting
|
|
// makes that unnecessary.
|
|
func unescapeBackslashes(s string) string {
|
|
if !strings.Contains(s, `\`) {
|
|
return s
|
|
}
|
|
var out strings.Builder
|
|
out.Grow(len(s))
|
|
prev := false
|
|
for _, r := range s {
|
|
if prev {
|
|
out.WriteRune(r)
|
|
prev = false
|
|
continue
|
|
}
|
|
if r == '\\' {
|
|
prev = true
|
|
continue
|
|
}
|
|
out.WriteRune(r)
|
|
}
|
|
if prev {
|
|
out.WriteRune('\\')
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
// singleQuote wraps s in single quotes, escaping any embedded single
|
|
// quote using the splice idiom: 'foo'\”bar' for foo'bar.
|
|
func singleQuote(s string) string {
|
|
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
|
}
|
|
|
|
// Insert places s into the editor at the cursor, splitting on
|
|
// newlines so multi-line pastes preserve their structure.
|
|
func (e *Editor) Insert(s string) { e.insert(s) }
|
|
|
|
func (e *Editor) insert(s string) {
|
|
s = normalizeEditorText(s)
|
|
line := e.Lines[e.CursorR]
|
|
pre := substringBefore(line, e.CursorC)
|
|
post := substringAfter(line, e.CursorC)
|
|
// Split pasted text on newlines.
|
|
parts := strings.Split(s, "\n")
|
|
if len(parts) == 1 {
|
|
e.Lines[e.CursorR] = pre + s + post
|
|
e.CursorC += runeLen(s)
|
|
return
|
|
}
|
|
newLines := make([]string, 0, len(e.Lines)+len(parts)-1)
|
|
newLines = append(newLines, e.Lines[:e.CursorR]...)
|
|
newLines = append(newLines, pre+parts[0])
|
|
for i := 1; i < len(parts)-1; i++ {
|
|
newLines = append(newLines, parts[i])
|
|
}
|
|
last := parts[len(parts)-1]
|
|
newLines = append(newLines, last+post)
|
|
newLines = append(newLines, e.Lines[e.CursorR+1:]...)
|
|
e.Lines = newLines
|
|
e.CursorR += len(parts) - 1
|
|
e.CursorC = runeLen(last)
|
|
}
|
|
|
|
func (e *Editor) newline() {
|
|
line := e.Lines[e.CursorR]
|
|
pre := substringBefore(line, e.CursorC)
|
|
post := substringAfter(line, e.CursorC)
|
|
e.Lines[e.CursorR] = pre
|
|
e.Lines = append(e.Lines, "")
|
|
copy(e.Lines[e.CursorR+2:], e.Lines[e.CursorR+1:])
|
|
e.Lines[e.CursorR+1] = post
|
|
e.CursorR++
|
|
e.CursorC = 0
|
|
}
|
|
|
|
func (e *Editor) backspace() {
|
|
if e.CursorC == 0 {
|
|
if e.CursorR == 0 {
|
|
return
|
|
}
|
|
prev := e.Lines[e.CursorR-1]
|
|
cur := e.Lines[e.CursorR]
|
|
e.Lines = append(e.Lines[:e.CursorR], e.Lines[e.CursorR+1:]...)
|
|
e.CursorR--
|
|
e.CursorC = runeLen(prev)
|
|
e.Lines[e.CursorR] = prev + cur
|
|
return
|
|
}
|
|
line := e.Lines[e.CursorR]
|
|
e.Lines[e.CursorR] = substringBefore(line, e.CursorC-1) + substringAfter(line, e.CursorC)
|
|
e.CursorC--
|
|
}
|
|
|
|
func (e *Editor) delete() {
|
|
line := e.Lines[e.CursorR]
|
|
if e.CursorC == runeLen(line) {
|
|
if e.CursorR == len(e.Lines)-1 {
|
|
return
|
|
}
|
|
next := e.Lines[e.CursorR+1]
|
|
e.Lines = append(e.Lines[:e.CursorR+1], e.Lines[e.CursorR+2:]...)
|
|
e.Lines[e.CursorR] = line + next
|
|
return
|
|
}
|
|
e.Lines[e.CursorR] = substringBefore(line, e.CursorC) + substringAfter(line, e.CursorC+1)
|
|
}
|
|
|
|
// moveWordLeft jumps the cursor to the start of the previous word,
|
|
// using the same word-separator rules as deleteWord. If already at the
|
|
// start of the line, wraps to the end of the previous line.
|
|
func (e *Editor) moveWordLeft() {
|
|
if e.CursorC == 0 {
|
|
if e.CursorR > 0 {
|
|
e.CursorR--
|
|
e.CursorC = runeLen(e.Lines[e.CursorR])
|
|
}
|
|
return
|
|
}
|
|
r := []rune(e.Lines[e.CursorR])
|
|
i := e.CursorC
|
|
for i > 0 && isWordSep(r[i-1]) {
|
|
i--
|
|
}
|
|
for i > 0 && !isWordSep(r[i-1]) {
|
|
i--
|
|
}
|
|
e.CursorC = i
|
|
}
|
|
|
|
// moveWordRight jumps the cursor to the start of the next word. If
|
|
// already at the end of the line, wraps to the start of the next line.
|
|
func (e *Editor) moveWordRight() {
|
|
line := e.Lines[e.CursorR]
|
|
if e.CursorC >= runeLen(line) {
|
|
if e.CursorR < len(e.Lines)-1 {
|
|
e.CursorR++
|
|
e.CursorC = 0
|
|
}
|
|
return
|
|
}
|
|
r := []rune(line)
|
|
i := e.CursorC
|
|
for i < len(r) && !isWordSep(r[i]) {
|
|
i++
|
|
}
|
|
for i < len(r) && isWordSep(r[i]) {
|
|
i++
|
|
}
|
|
e.CursorC = i
|
|
}
|
|
|
|
func (e *Editor) moveLeft() {
|
|
if e.CursorC > 0 {
|
|
e.CursorC--
|
|
return
|
|
}
|
|
if e.CursorR > 0 {
|
|
e.CursorR--
|
|
e.CursorC = runeLen(e.Lines[e.CursorR])
|
|
}
|
|
}
|
|
|
|
func (e *Editor) moveRight() {
|
|
if e.CursorC < runeLen(e.Lines[e.CursorR]) {
|
|
e.CursorC++
|
|
return
|
|
}
|
|
if e.CursorR < len(e.Lines)-1 {
|
|
e.CursorR++
|
|
e.CursorC = 0
|
|
}
|
|
}
|
|
|
|
// moveCursorVisual moves the cursor one visual row in direction
|
|
// dir (-1 = up, +1 = down) through the wrapped layout the user
|
|
// sees on screen. Handles both multi-line logical inputs and the
|
|
// case where a single long line wraps across several visual
|
|
// rows.
|
|
//
|
|
// Algorithm: rebuild the same wrapped layout Render produces,
|
|
// tagging each visual row with (logicalRow, runeOffsetStart,
|
|
// runeOffsetEnd, leadingWidth). Find the row the cursor sits on,
|
|
// then pick (row+dir) and map the cursor's current visual column
|
|
// (minus the target row's leading indent) to a rune index inside
|
|
// that row's slice of its logical line. No-op at the top/bottom
|
|
// edges of the whole buffer.
|
|
// MoveVertical moves the cursor one visual row up/down through the
|
|
// rendered editor layout. It returns true when the cursor moved, false
|
|
// at the top/bottom edge. Callers can use false to fall back to outer
|
|
// UI scrolling.
|
|
func (e *Editor) MoveVertical(dir int) bool {
|
|
width := e.lastRenderWidth
|
|
if width <= 0 {
|
|
// Fall back to logical-line navigation if Render hasn't
|
|
// been called yet (shouldn't happen in practice; the
|
|
// host always renders once before accepting input).
|
|
return e.moveCursorLogical(dir)
|
|
}
|
|
|
|
type vrow struct {
|
|
logical int // e.Lines index
|
|
runeStart int // rune offset into e.Lines[logical]
|
|
runeEnd int // exclusive
|
|
leadWidth int // width of prompt / cont indent on this row
|
|
leadPrefix string // the prefix used (prompt on row 0, indent on cont)
|
|
}
|
|
|
|
// Match Render's layout exactly: strip ANSI from the prompt before
|
|
// wrapping, otherwise escape bytes count as body runes and Up/Down
|
|
// lands in the wrong visual cell when the prompt is styled.
|
|
plainPrompt := stripANSI(e.Prompt)
|
|
promptLen := runewidth.StringWidth(plainPrompt)
|
|
indent := strings.Repeat(" ", promptLen)
|
|
|
|
var rows []vrow
|
|
curVRow, curVCol := 0, 0
|
|
for r, line := range e.Lines {
|
|
prefix := indent
|
|
if r == 0 {
|
|
prefix = plainPrompt
|
|
}
|
|
wrapped := wrapLine(prefix+line, width, indent)
|
|
lineRunes := []rune(line)
|
|
seen := 0
|
|
for wi, w := range wrapped {
|
|
var leadW int
|
|
var leadP string
|
|
body := w
|
|
if wi == 0 {
|
|
if strings.HasPrefix(body, prefix) {
|
|
body = body[len(prefix):]
|
|
}
|
|
leadW = promptLen
|
|
leadP = prefix
|
|
} else {
|
|
if strings.HasPrefix(body, indent) {
|
|
body = body[len(indent):]
|
|
}
|
|
leadW = promptLen
|
|
leadP = indent
|
|
}
|
|
bodyRunes := []rune(body)
|
|
start := seen
|
|
end := seen + len(bodyRunes)
|
|
rows = append(rows, vrow{
|
|
logical: r, runeStart: start, runeEnd: end,
|
|
leadWidth: leadW, leadPrefix: leadP,
|
|
})
|
|
// Record where the cursor currently sits.
|
|
if r == e.CursorR && e.CursorC >= start && e.CursorC <= end {
|
|
curVRow = len(rows) - 1
|
|
inner := e.CursorC - start
|
|
if inner < 0 {
|
|
inner = 0
|
|
}
|
|
if inner > len(bodyRunes) {
|
|
inner = len(bodyRunes)
|
|
}
|
|
curVCol = leadW + runewidth.StringWidth(string(bodyRunes[:inner]))
|
|
}
|
|
seen = end
|
|
// Word-wrap often drops a single space at the boundary.
|
|
for seen < len(lineRunes) && lineRunes[seen] == ' ' {
|
|
seen++
|
|
}
|
|
}
|
|
}
|
|
|
|
target := curVRow + dir
|
|
if target < 0 || target >= len(rows) {
|
|
return false
|
|
}
|
|
tr := rows[target]
|
|
line := e.Lines[tr.logical]
|
|
lineRunes := []rune(line)
|
|
bodyRunes := lineRunes[tr.runeStart:tr.runeEnd]
|
|
|
|
// Find the rune offset inside bodyRunes whose visible column
|
|
// most closely matches curVCol after accounting for leadWidth.
|
|
want := curVCol - tr.leadWidth
|
|
if want < 0 {
|
|
want = 0
|
|
}
|
|
best := 0
|
|
bestW := 0
|
|
for i := 1; i <= len(bodyRunes); i++ {
|
|
w := runewidth.StringWidth(string(bodyRunes[:i]))
|
|
if w > want {
|
|
break
|
|
}
|
|
best = i
|
|
bestW = w
|
|
if w == want {
|
|
break
|
|
}
|
|
}
|
|
_ = bestW
|
|
e.CursorR = tr.logical
|
|
e.CursorC = tr.runeStart + best
|
|
return true
|
|
}
|
|
|
|
// moveCursorLogical is the pre-visual-navigation fallback used
|
|
// when Render hasn't told us the terminal width yet. Walks the
|
|
// e.Lines array directly.
|
|
func (e *Editor) moveCursorLogical(dir int) bool {
|
|
switch {
|
|
case dir < 0 && e.CursorR > 0:
|
|
e.CursorR--
|
|
case dir > 0 && e.CursorR < len(e.Lines)-1:
|
|
e.CursorR++
|
|
default:
|
|
return false
|
|
}
|
|
if e.CursorC > runeLen(e.Lines[e.CursorR]) {
|
|
e.CursorC = runeLen(e.Lines[e.CursorR])
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (e *Editor) deleteWord() {
|
|
line := e.Lines[e.CursorR]
|
|
if e.CursorC == 0 {
|
|
e.backspace()
|
|
return
|
|
}
|
|
r := []rune(line)
|
|
i := e.CursorC
|
|
// Step 1: walk over any trailing whitespace to the left of the cursor.
|
|
for i > 0 && isWordSep(r[i-1]) {
|
|
i--
|
|
}
|
|
// Step 2: walk over the word itself (non-separator runes).
|
|
for i > 0 && !isWordSep(r[i-1]) {
|
|
i--
|
|
}
|
|
e.Lines[e.CursorR] = string(r[:i]) + string(r[e.CursorC:])
|
|
e.CursorC = i
|
|
}
|
|
|
|
// isWordSep reports whether r is a word separator. Anything that isn't
|
|
// a letter, digit, or underscore counts as a separator so delete-word
|
|
// feels natural on paths, code, and prose.
|
|
func isWordSep(r rune) bool {
|
|
switch {
|
|
case r == '_':
|
|
return false
|
|
case r >= '0' && r <= '9':
|
|
return false
|
|
case r >= 'a' && r <= 'z':
|
|
return false
|
|
case r >= 'A' && r <= 'Z':
|
|
return false
|
|
case r >= 0x80:
|
|
// Treat all non-ASCII as word chars so CJK/äöü don't get chopped.
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ---- rendering ----
|
|
|
|
// Render returns the editor's visible lines (wrapped to width).
|
|
// visualRow/visualCol describe where the cursor lands within the returned lines.
|
|
func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) {
|
|
e.lastRenderWidth = width
|
|
// The Prompt may carry ANSI styling (theme-coloured glyph + reset).
|
|
// We must compute wrap geometry using its *visible* width only;
|
|
// otherwise the raw escape bytes leak into wrapLine's per-rune
|
|
// width accounting and the cursor column lands inside the row
|
|
// instead of at the end. See the wrapLine docs for why we keep it
|
|
// ANSI-unaware: the rest of the codebase relies on its simple
|
|
// rune-based behaviour for plain text and would regress if we
|
|
// taught it about escape sequences here. Strip ANSI from the
|
|
// prompt used for layout, then re-apply the original styling to
|
|
// the very first wrapped row before returning.
|
|
plainPrompt := stripANSI(e.Prompt)
|
|
promptLen := runewidth.StringWidth(plainPrompt)
|
|
indent := strings.Repeat(" ", promptLen)
|
|
|
|
for r, line := range e.Lines {
|
|
var prefix string
|
|
if r == 0 {
|
|
prefix = plainPrompt
|
|
} else {
|
|
prefix = indent
|
|
}
|
|
wrapped := wrapLine(prefix+line, width, indent)
|
|
if r == e.CursorR {
|
|
// Compute where CursorC lands inside the wrapped rows by
|
|
// walking the wrapped output character-by-character and
|
|
// tracking which wrapped row + column corresponds to the
|
|
// cursor's rune index in `line`. This is the only reliable
|
|
// answer under word-wrap, where a simple (promptLen+col)/width
|
|
// formula overshoots when wrapLine broke on a space.
|
|
targetRunes := e.CursorC
|
|
row, col := locateCursor(wrapped, prefix, line, targetRunes, indent)
|
|
visualRow = len(lines) + row
|
|
visualCol = col
|
|
}
|
|
// Re-attach the styled prompt to the very first wrapped row of
|
|
// the first logical line. All later rows are indent-only and
|
|
// don't need styling. The replacement is byte-exact because the
|
|
// plain prompt sits at the row's start.
|
|
if r == 0 && len(wrapped) > 0 && strings.HasPrefix(wrapped[0], plainPrompt) {
|
|
wrapped[0] = e.Prompt + wrapped[0][len(plainPrompt):]
|
|
}
|
|
lines = append(lines, wrapped...)
|
|
}
|
|
return lines, visualRow, visualCol
|
|
}
|
|
|
|
// locateCursor finds the wrapped row + visible column corresponding to
|
|
// `targetRunes` rune positions into the logical `line`, given that the
|
|
// wrapped output `wrapped` started with `prefix` on its first row and
|
|
// uses `cont` as the continuation indent on subsequent rows.
|
|
func locateCursor(wrapped []string, prefix, line string, targetRunes int, cont string) (int, int) {
|
|
prefixW := visibleWidth(prefix)
|
|
contW := visibleWidth(cont)
|
|
lineRunes := []rune(line)
|
|
if targetRunes > len(lineRunes) {
|
|
targetRunes = len(lineRunes)
|
|
}
|
|
|
|
seenRunes := 0
|
|
for row, w := range wrapped {
|
|
// strip leading prefix / continuation indent before counting runes
|
|
body := w
|
|
var leadW int
|
|
if row == 0 {
|
|
if strings.HasPrefix(body, prefix) {
|
|
body = body[len(prefix):]
|
|
}
|
|
leadW = prefixW
|
|
} else {
|
|
if strings.HasPrefix(body, cont) {
|
|
body = body[len(cont):]
|
|
}
|
|
leadW = contW
|
|
}
|
|
bodyRunes := []rune(body)
|
|
// Could this wrapped row contain the cursor?
|
|
if targetRunes <= seenRunes+len(bodyRunes) {
|
|
// Column inside body.
|
|
inner := targetRunes - seenRunes
|
|
if inner < 0 {
|
|
inner = 0
|
|
}
|
|
col := leadW + runewidth.StringWidth(string(bodyRunes[:inner]))
|
|
return row, col
|
|
}
|
|
seenRunes += len(bodyRunes)
|
|
// Word-wrap may have dropped whitespace at the boundary; skip the
|
|
// corresponding runes in `line` so counts stay aligned.
|
|
for seenRunes < len(lineRunes) && lineRunes[seenRunes] == ' ' {
|
|
seenRunes++
|
|
if seenRunes >= targetRunes {
|
|
// Cursor landed in the skipped whitespace — place it at
|
|
// the start of the next wrapped row.
|
|
nextRow := row + 1
|
|
if nextRow >= len(wrapped) {
|
|
nextRow = row
|
|
}
|
|
return nextRow, contW
|
|
}
|
|
}
|
|
}
|
|
// Fallback: end of the last wrapped row.
|
|
if len(wrapped) == 0 {
|
|
return 0, prefixW
|
|
}
|
|
last := wrapped[len(wrapped)-1]
|
|
return len(wrapped) - 1, visibleWidth(last)
|
|
}
|
|
|
|
// ---- helpers ----
|
|
|
|
func substringBefore(s string, col int) string {
|
|
r := []rune(s)
|
|
if col > len(r) {
|
|
col = len(r)
|
|
}
|
|
return string(r[:col])
|
|
}
|
|
|
|
func substringAfter(s string, col int) string {
|
|
r := []rune(s)
|
|
if col > len(r) {
|
|
col = len(r)
|
|
}
|
|
return string(r[col:])
|
|
}
|
|
|
|
func runeLen(s string) int { return len([]rune(s)) }
|
|
|
|
// normalizeEditorText converts all common line endings to \n before text
|
|
// reaches the renderer. A literal carriage return would move the terminal
|
|
// cursor back to column 0 and overwrite the left side of the input row.
|
|
func normalizeEditorText(s string) string {
|
|
if !strings.ContainsRune(s, '\r') {
|
|
return s
|
|
}
|
|
s = strings.ReplaceAll(s, "\r\n", "\n")
|
|
return strings.ReplaceAll(s, "\r", "\n")
|
|
}
|
|
|
|
func visualColumn(s string, runeCol int) int {
|
|
r := []rune(s)
|
|
if runeCol > len(r) {
|
|
runeCol = len(r)
|
|
}
|
|
return runewidth.StringWidth(string(r[:runeCol]))
|
|
}
|
|
|
|
func visibleWidth(s string) int {
|
|
return runewidth.StringWidth(stripANSI(s))
|
|
}
|
|
|
|
func stripANSI(s string) string {
|
|
// Minimal ANSI stripper; handles CSI sequences (ESC [ ... final).
|
|
var out []rune
|
|
i := 0
|
|
runes := []rune(s)
|
|
for i < len(runes) {
|
|
if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
|
|
i += 2
|
|
for i < len(runes) {
|
|
c := runes[i]
|
|
i++
|
|
if c >= 0x40 && c <= 0x7e {
|
|
break
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
out = append(out, runes[i])
|
|
i++
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
// WrapANSILine is the exported form of wrapANSILine so other modes /
|
|
// dialogs can reuse the same visible-width-aware wrap behavior.
|
|
func WrapANSILine(s string, limit int) []string { return wrapANSILine(s, limit) }
|
|
|
|
// wrapANSILine folds a string that may contain ANSI CSI escapes so that
|
|
// the visible width of each line stays within limit. Breaks happen on
|
|
// spaces when possible, falling back to mid-token splits for very long
|
|
// unbroken runs. Escape sequences are preserved verbatim and never
|
|
// counted toward the visible column.
|
|
func wrapANSILine(s string, limit int) []string {
|
|
if limit <= 0 {
|
|
return []string{s}
|
|
}
|
|
if visibleWidth(s) <= limit {
|
|
return []string{s}
|
|
}
|
|
runes := []rune(s)
|
|
var out []string
|
|
var line strings.Builder
|
|
var word strings.Builder
|
|
lineW := 0
|
|
wordW := 0
|
|
|
|
flushLine := func() {
|
|
out = append(out, line.String())
|
|
line.Reset()
|
|
lineW = 0
|
|
}
|
|
flushWord := func() {
|
|
if word.Len() == 0 {
|
|
return
|
|
}
|
|
if lineW+wordW > limit && lineW > 0 {
|
|
flushLine()
|
|
}
|
|
// If the word alone is bigger than the limit, split it by runes.
|
|
if wordW > limit {
|
|
wr := []rune(word.String())
|
|
for i := 0; i < len(wr); {
|
|
r := wr[i]
|
|
if r == 0x1b && i+1 < len(wr) && wr[i+1] == '[' {
|
|
line.WriteRune(r)
|
|
line.WriteRune(wr[i+1])
|
|
i += 2
|
|
for i < len(wr) {
|
|
c := wr[i]
|
|
line.WriteRune(c)
|
|
i++
|
|
if c >= 0x40 && c <= 0x7e {
|
|
break
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
rw := runewidth.RuneWidth(r)
|
|
if lineW+rw > limit && lineW > 0 {
|
|
flushLine()
|
|
}
|
|
line.WriteRune(r)
|
|
lineW += rw
|
|
i++
|
|
}
|
|
word.Reset()
|
|
wordW = 0
|
|
return
|
|
}
|
|
line.WriteString(word.String())
|
|
lineW += wordW
|
|
word.Reset()
|
|
wordW = 0
|
|
}
|
|
|
|
for i := 0; i < len(runes); {
|
|
r := runes[i]
|
|
if r == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
|
|
word.WriteRune(r)
|
|
word.WriteRune(runes[i+1])
|
|
i += 2
|
|
for i < len(runes) {
|
|
c := runes[i]
|
|
word.WriteRune(c)
|
|
i++
|
|
if c >= 0x40 && c <= 0x7e {
|
|
break
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
if r == ' ' || r == '\t' {
|
|
flushWord()
|
|
rw := runewidth.RuneWidth(r)
|
|
if lineW+rw > limit && lineW > 0 {
|
|
flushLine()
|
|
i++
|
|
continue
|
|
}
|
|
line.WriteRune(r)
|
|
lineW += rw
|
|
i++
|
|
continue
|
|
}
|
|
word.WriteRune(r)
|
|
wordW += runewidth.RuneWidth(r)
|
|
i++
|
|
}
|
|
flushWord()
|
|
if line.Len() > 0 {
|
|
flushLine()
|
|
}
|
|
if len(out) == 0 {
|
|
out = []string{""}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func wrapLine(s string, width int, cont string) []string {
|
|
if width <= 0 {
|
|
return []string{s}
|
|
}
|
|
|
|
// Tokenize on spaces while preserving their widths. Runs of spaces
|
|
// stay attached to the preceding word so trailing whitespace doesn't
|
|
// create empty wrapped lines.
|
|
type token struct {
|
|
text string
|
|
width int
|
|
space bool // true if this token is whitespace only
|
|
}
|
|
var tokens []token
|
|
{
|
|
var buf strings.Builder
|
|
var bufW int
|
|
inSpace := false
|
|
flush := func() {
|
|
if buf.Len() == 0 {
|
|
return
|
|
}
|
|
tokens = append(tokens, token{text: buf.String(), width: bufW, space: inSpace})
|
|
buf.Reset()
|
|
bufW = 0
|
|
}
|
|
for _, r := range s {
|
|
rSpace := r == ' ' || r == '\t'
|
|
if buf.Len() == 0 {
|
|
inSpace = rSpace
|
|
} else if rSpace != inSpace {
|
|
flush()
|
|
inSpace = rSpace
|
|
}
|
|
buf.WriteRune(r)
|
|
bufW += runewidth.RuneWidth(r)
|
|
}
|
|
flush()
|
|
}
|
|
|
|
var out []string
|
|
var cur strings.Builder
|
|
curW := 0
|
|
firstLine := true
|
|
contW := visibleWidth(cont)
|
|
|
|
newLine := func() {
|
|
out = append(out, cur.String())
|
|
cur.Reset()
|
|
curW = 0
|
|
// After flushing, everything we append next is a CONTINUATION
|
|
// row, which must start with the cont indent so the editor's
|
|
// visual cursor alignment stays consistent. The old code only
|
|
// wrote cont when !firstLine BEFORE toggling firstLine, which
|
|
// meant the very first wrap never got its indent. That caused
|
|
// the terminal cursor to land in the wrong column after a
|
|
// multi-line paste.
|
|
firstLine = false
|
|
cur.WriteString(cont)
|
|
curW = contW
|
|
}
|
|
|
|
for i := 0; i < len(tokens); i++ {
|
|
tk := tokens[i]
|
|
|
|
// Drop leading whitespace after a wrap.
|
|
if tk.space && curW == contW && !firstLine {
|
|
continue
|
|
}
|
|
|
|
if curW+tk.width <= width {
|
|
cur.WriteString(tk.text)
|
|
curW += tk.width
|
|
continue
|
|
}
|
|
|
|
// Token overflows.
|
|
//
|
|
// If the token on its own is wider than what fits on a
|
|
// continuation line (width - contW), it'll need to be split
|
|
// rune-by-rune no matter what. In that case, don't break
|
|
// first: let the rune-split start right here at the current
|
|
// column and wrap naturally. Breaking first would strand the
|
|
// prompt (on firstLine) or the cont indent (on later lines)
|
|
// on a row by itself, which is the drag-drop-long-path bug.
|
|
if tk.width > width-contW {
|
|
// Rune-by-rune starting from the current column. newLine()
|
|
// inside handles wrapping and re-indents.
|
|
for _, r := range tk.text {
|
|
rw := runewidth.RuneWidth(r)
|
|
if curW+rw > width {
|
|
newLine()
|
|
}
|
|
cur.WriteRune(r)
|
|
curW += rw
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Token fits on a fresh continuation line; break first, then
|
|
// emit it whole.
|
|
if cur.Len() > 0 && !(firstLine && curW == 0) {
|
|
trimmed := strings.TrimRight(cur.String(), " \t")
|
|
cur.Reset()
|
|
cur.WriteString(trimmed)
|
|
curW = visibleWidth(trimmed)
|
|
newLine()
|
|
if tk.space {
|
|
continue
|
|
}
|
|
}
|
|
cur.WriteString(tk.text)
|
|
curW += tk.width
|
|
}
|
|
|
|
if cur.Len() > 0 || len(out) == 0 {
|
|
out = append(out, cur.String())
|
|
}
|
|
return out
|
|
}
|
|
|
|
func max(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
// pasteCollapseLineThreshold and pasteCollapseCharThreshold govern
|
|
// when a bracketed paste gets collapsed to a [pasted text #N ...]
|
|
// placeholder instead of being inserted inline. Either trigger
|
|
// alone is enough — a 500-line log dump and a 1200-character
|
|
// one-line log entry both bloat the editor in ways the user
|
|
// doesn't want to scroll through while composing a prompt.
|
|
const (
|
|
pasteCollapseLineThreshold = 10
|
|
pasteCollapseCharThreshold = 1000
|
|
)
|
|
|
|
// pasteShouldCollapse reports whether a pasted chunk is big enough
|
|
// to deserve a placeholder token instead of being inserted verbatim.
|
|
// Collapse on > 10 lines OR > 1000 characters, whichever fires
|
|
// first.
|
|
func pasteShouldCollapse(s string) bool {
|
|
return countLines(s) > pasteCollapseLineThreshold || len(s) > pasteCollapseCharThreshold
|
|
}
|
|
|
|
// formatPastePlaceholder builds the visible marker for a
|
|
// collapsed paste. Two shapes:
|
|
//
|
|
// [pasted text #N +L lines] — used when the line count is the
|
|
// trigger (multi-line dumps)
|
|
// [pasted text #N C chars] — used when the character count is
|
|
// the trigger (long single-line
|
|
// or near-single-line pastes)
|
|
//
|
|
// Line-triggered takes precedence so a 12-line 4000-char paste
|
|
// reads as "+12 lines", not "4000 chars".
|
|
func formatPastePlaceholder(id int, body string) string {
|
|
if countLines(body) > pasteCollapseLineThreshold {
|
|
return fmt.Sprintf("[pasted text #%d +%d lines]", id, countLines(body))
|
|
}
|
|
return fmt.Sprintf("[pasted text #%d %d chars]", id, len(body))
|
|
}
|
|
|
|
// countLines returns the number of visual lines in s. A trailing
|
|
// newline is not counted as an extra empty line so
|
|
// "foo\nbar\n" reads as 2 lines (what the user expects in the
|
|
// "+N lines" summary) instead of 3.
|
|
func countLines(s string) int {
|
|
if s == "" {
|
|
return 0
|
|
}
|
|
n := strings.Count(s, "\n")
|
|
if !strings.HasSuffix(s, "\n") {
|
|
n++
|
|
}
|
|
return n
|
|
}
|
|
|
|
// pastePlaceholderRE matches both placeholder shapes the editor
|
|
// emits for a collapsed paste:
|
|
//
|
|
// [pasted text #N +L lines] — multi-line trigger
|
|
// [pasted text #N C chars] — long-char trigger
|
|
//
|
|
// Capture group 1 is the numeric id used to look up the full
|
|
// body in e.pastes; the rest of the token is free-form and gets
|
|
// discarded during expansion.
|
|
var pastePlaceholderRE = regexp.MustCompile(`\[pasted text #(\d+) (?:\+\d+ lines?|\d+ chars?)\]`)
|
|
var filePlaceholderRE = regexp.MustCompile(`\[(file|dir):(\d+):[^\]]+\]`)
|
|
|
|
// expandPastePlaceholders returns raw with every paste token
|
|
// swapped for the body stored under its id in pastes. Tokens
|
|
// whose id isn't in the map are left as-is (user deleted the
|
|
// corresponding entry somehow, or the id is spurious user text
|
|
// that happens to match the shape).
|
|
func expandPastePlaceholders(raw string, pastes map[int]string) string {
|
|
return pastePlaceholderRE.ReplaceAllStringFunc(raw, func(match string) string {
|
|
groups := pastePlaceholderRE.FindStringSubmatch(match)
|
|
if len(groups) < 2 {
|
|
return match
|
|
}
|
|
var id int
|
|
if _, err := fmt.Sscanf(groups[1], "%d", &id); err != nil {
|
|
return match
|
|
}
|
|
if body, ok := pastes[id]; ok {
|
|
return body
|
|
}
|
|
return match
|
|
})
|
|
}
|
|
|
|
// expandFilePlaceholders returns raw with every [file:N:name] and
|
|
// [dir:N:name/] token swapped for the full path stored under its id.
|
|
func expandFilePlaceholders(raw string, files, dirs map[int]string) string {
|
|
return filePlaceholderRE.ReplaceAllStringFunc(raw, func(match string) string {
|
|
groups := filePlaceholderRE.FindStringSubmatch(match)
|
|
if len(groups) < 3 {
|
|
return match
|
|
}
|
|
kind := groups[1] // "file" or "dir"
|
|
var id int
|
|
if _, err := fmt.Sscanf(groups[2], "%d", &id); err != nil {
|
|
return match
|
|
}
|
|
var m map[int]string
|
|
if kind == "dir" {
|
|
m = dirs
|
|
} else {
|
|
m = files
|
|
}
|
|
if path, ok := m[id]; ok {
|
|
return path
|
|
}
|
|
return match
|
|
})
|
|
}
|
|
|
|
// collapseOrQuoteFilePaths checks if the paste is a file path and
|
|
// collapses it to a [file:basename] chip. URLs are left as-is.
|
|
// Non-path pastes fall through to quotePastedFilePaths.
|
|
func (e *Editor) collapseOrQuoteFilePaths(paste string) string {
|
|
if paste == "" || strings.ContainsRune(paste, '\n') {
|
|
return quotePastedFilePaths(paste)
|
|
}
|
|
trimmed := strings.TrimSpace(paste)
|
|
// Don't collapse URLs.
|
|
if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
|
|
return paste
|
|
}
|
|
// Check if it's a single file path.
|
|
p, ok := normalisePathToken(trimmed)
|
|
if !ok {
|
|
return quotePastedFilePaths(paste)
|
|
}
|
|
// Only collapse if the path is long enough to benefit.
|
|
base := filepath.Base(p)
|
|
if len(p) <= len(base)+5 {
|
|
// Short path like "./foo.txt" - just quote it.
|
|
return singleQuote(p)
|
|
}
|
|
// Check if it's a directory.
|
|
info, err := os.Stat(p)
|
|
if err == nil && info.IsDir() {
|
|
if e.dirs == nil {
|
|
e.dirs = map[int]string{}
|
|
}
|
|
e.dirSeq++
|
|
e.dirs[e.dirSeq] = singleQuote(p)
|
|
return fmt.Sprintf("[dir:%d:%s/]", e.dirSeq, base)
|
|
}
|
|
if e.files == nil {
|
|
e.files = map[int]string{}
|
|
}
|
|
e.fileSeq++
|
|
e.files[e.fileSeq] = singleQuote(p)
|
|
return fmt.Sprintf("[file:%d:%s]", e.fileSeq, base)
|
|
}
|