detached 902122e

tui: require pasted path candidates to exist on disk

normalisePathToken used a purely syntactic heuristic ("starts with
/ or ~ and contains / or .") to decide whether a pasted token was
a drag-dropped file path. URL path segments pasted from a browser
address bar (e.g. /de/downloads/dokumentenarchiv) matched that
shape and got collapsed to a [file:N:basename] chip or wrapped in
single quotes, which is never what the user meant.

Add a pathExists check (with ~ / ~/ expansion via os.UserHomeDir)
and gate normalisePathToken on it. Real drag-and-drop from a file
manager still works because those paths exist; URL fragments and
fictional paths fall through untouched.

Rewrote quote_paste_test.go to build fixtures under t.TempDir() so
the existence check has something real to stat, plus regression
cases for URL-segment and non-existent-path pastes.
commit
902122ee7b3e88c18a85b8752dc8e78452aef316
author
patriceckhart · · 2026-04-27 08:59 UTC
parent
ec79818
signature
unsigned
2 files · +119 -12
M internal/tui/editor.go +37 -0
@@ -308,6 +308,13 @@ func splitPreservingSeparators(s string) []pasteToken {
308308
309309 // normalisePathToken decides whether tk is a drag-dropped path and,
310310 // if so, returns the cleaned-up path string.
311+//
312+// To avoid mistaking URL path segments (e.g. "/de/downloads/foo"
313+// pasted from a browser address bar) for filesystem paths, the
314+// candidate must point to an entry that actually exists on disk.
315+// Drag-and-drop from a file manager always satisfies this; a
316+// hand-typed URL fragment doesn't, so it falls through to the
317+// regular insert path untouched.
311318 func normalisePathToken(tk string) (string, bool) {
312319 // Strip pre-existing surrounding quotes; we'll re-quote consistently.
313320 if n := len(tk); n >= 2 {
@@ -325,6 +332,9 @@ func normalisePathToken(tk string) (string, bool) {
325332 if decoded == "" || decoded[0] != '/' {
326333 return "", false
327334 }
335+ if !pathExists(decoded) {
336+ return "", false
337+ }
328338 return decoded, true
329339 }
330340
@@ -341,9 +351,36 @@ func normalisePathToken(tk string) (string, bool) {
341351 if strings.ContainsAny(unescaped, "|;&$`<>") {
342352 return "", false
343353 }
354+ if !pathExists(unescaped) {
355+ return "", false
356+ }
344357 return unescaped, true
345358 }
346359
360+// pathExists reports whether p resolves to an existing filesystem
361+// entry (file, directory, or symlink target). "~" / "~/" prefixes
362+// are expanded relative to the current user's home directory before
363+// the check, mirroring how the agent will later read the path.
364+func pathExists(p string) bool {
365+ if p == "" {
366+ return false
367+ }
368+ expanded := p
369+ if p == "~" || strings.HasPrefix(p, "~/") {
370+ home, err := os.UserHomeDir()
371+ if err != nil || home == "" {
372+ return false
373+ }
374+ if p == "~" {
375+ expanded = home
376+ } else {
377+ expanded = filepath.Join(home, p[2:])
378+ }
379+ }
380+ _, err := os.Stat(expanded)
381+ return err == nil
382+}
383+
347384 // unescapeBackslashes turns "foo\ bar" into "foo bar". Terminal
348385 // drag-and-drop on macOS uses backslash escaping by default; quoting
349386 // makes that unnecessary.
@@ -308,6 +308,13 @@ func splitPreservingSeparators(s string) []pasteToken {
308 308
309 // normalisePathToken decides whether tk is a drag-dropped path and,309 // normalisePathToken decides whether tk is a drag-dropped path and,
310 // if so, returns the cleaned-up path string.310 // if so, returns the cleaned-up path string.
311+//
312+// To avoid mistaking URL path segments (e.g. "/de/downloads/foo"
313+// pasted from a browser address bar) for filesystem paths, the
314+// candidate must point to an entry that actually exists on disk.
315+// Drag-and-drop from a file manager always satisfies this; a
316+// hand-typed URL fragment doesn't, so it falls through to the
317+// regular insert path untouched.
311 func normalisePathToken(tk string) (string, bool) {318 func normalisePathToken(tk string) (string, bool) {
312 // Strip pre-existing surrounding quotes; we'll re-quote consistently.319 // Strip pre-existing surrounding quotes; we'll re-quote consistently.
313 if n := len(tk); n >= 2 {320 if n := len(tk); n >= 2 {
@@ -325,6 +332,9 @@ func normalisePathToken(tk string) (string, bool) {
325 if decoded == "" || decoded[0] != '/' {332 if decoded == "" || decoded[0] != '/' {
326 return "", false333 return "", false
327 }334 }
335+ if !pathExists(decoded) {
336+ return "", false
337+ }
328 return decoded, true338 return decoded, true
329 }339 }
330 340
@@ -341,9 +351,36 @@ func normalisePathToken(tk string) (string, bool) {
341 if strings.ContainsAny(unescaped, "|;&$`<>") {351 if strings.ContainsAny(unescaped, "|;&$`<>") {
342 return "", false352 return "", false
343 }353 }
354+ if !pathExists(unescaped) {
355+ return "", false
356+ }
344 return unescaped, true357 return unescaped, true
345 }358 }
346 359
360+// pathExists reports whether p resolves to an existing filesystem
361+// entry (file, directory, or symlink target). "~" / "~/" prefixes
362+// are expanded relative to the current user's home directory before
363+// the check, mirroring how the agent will later read the path.
364+func pathExists(p string) bool {
365+ if p == "" {
366+ return false
367+ }
368+ expanded := p
369+ if p == "~" || strings.HasPrefix(p, "~/") {
370+ home, err := os.UserHomeDir()
371+ if err != nil || home == "" {
372+ return false
373+ }
374+ if p == "~" {
375+ expanded = home
376+ } else {
377+ expanded = filepath.Join(home, p[2:])
378+ }
379+ }
380+ _, err := os.Stat(expanded)
381+ return err == nil
382+}
383+
347 // unescapeBackslashes turns "foo\ bar" into "foo bar". Terminal384 // unescapeBackslashes turns "foo\ bar" into "foo bar". Terminal
348 // drag-and-drop on macOS uses backslash escaping by default; quoting385 // drag-and-drop on macOS uses backslash escaping by default; quoting
349 // makes that unnecessary.386 // makes that unnecessary.
M internal/tui/quote_paste_test.go +82 -12
@@ -1,36 +1,75 @@
11 package tui
22
3-import "testing"
3+import (
4+ "net/url"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+)
10+
11+// makeFiles creates files (and intermediate dirs) under base for each
12+// relative path in rels. Returns base for convenience.
13+func makeFiles(t *testing.T, base string, rels ...string) string {
14+ t.Helper()
15+ for _, rel := range rels {
16+ full := filepath.Join(base, rel)
17+ if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
18+ t.Fatalf("mkdir: %v", err)
19+ }
20+ if err := os.WriteFile(full, []byte("x"), 0o644); err != nil {
21+ t.Fatalf("write: %v", err)
22+ }
23+ }
24+ return base
25+}
426
527 func TestQuotePastedFilePaths(t *testing.T) {
28+ dir := t.TempDir()
29+ makeFiles(t, dir,
30+ "foo bar.png",
31+ "file.png",
32+ "a.png",
33+ "b.png",
34+ "x.png",
35+ "it's.png",
36+ "only.png",
37+ )
38+
39+ // file:// URL form needs URL-encoded spaces.
40+ fileURL := "file://" + dir + "/" + url.PathEscape("foo bar.png")
41+ // PathEscape doesn't escape "/" but escapes the space — that's
42+ // what macOS Finder produces. Build the path-only segment so the
43+ // "file://" prefix is added cleanly.
44+ fileURLPath := dir + "/" + strings.ReplaceAll("foo bar.png", " ", "%20")
45+ fileURLForm := "file://" + fileURLPath
46+ _ = fileURL // keep linter happy if unused
47+
648 cases := []struct {
749 name string
850 in string
951 want string
1052 }{
1153 // macOS Terminal default: backslash-escaped spaces in path.
12- {"backslash-escaped space", `/Users/pat/foo\ bar.png`, `'/Users/pat/foo bar.png'`},
54+ {"backslash-escaped space", dir + `/foo\ bar.png`, `'` + dir + `/foo bar.png'`},
1355
1456 // Plain absolute path with no special chars.
15- {"plain absolute", `/Users/pat/file.png`, `'/Users/pat/file.png'`},
57+ {"plain absolute", dir + `/file.png`, `'` + dir + `/file.png'`},
1658
1759 // file:// URL form; URL-decoded and scheme stripped.
18- {"file url", `file:///Users/pat/foo%20bar.png`, `'/Users/pat/foo bar.png'`},
19-
20- // Tilde paths.
21- {"tilde", `~/Pictures/x.png`, `'~/Pictures/x.png'`},
60+ {"file url", fileURLForm, `'` + dir + `/foo bar.png'`},
2261
2362 // Multi-file drop, each path quoted independently.
24- {"two paths", `/a.png /b.png`, `'/a.png' '/b.png'`},
63+ {"two paths", dir + `/a.png ` + dir + `/b.png`, `'` + dir + `/a.png' '` + dir + `/b.png'`},
2564
2665 // Already single-quoted: re-normalise to consistent quoting.
27- {"already single quoted", `'/Users/pat/x.png'`, `'/Users/pat/x.png'`},
66+ {"already single quoted", `'` + dir + `/x.png'`, `'` + dir + `/x.png'`},
2867
2968 // Already double-quoted: re-normalise.
30- {"already double quoted", `"/Users/pat/x.png"`, `'/Users/pat/x.png'`},
69+ {"already double quoted", `"` + dir + `/x.png"`, `'` + dir + `/x.png'`},
3170
3271 // Embedded apostrophe gets the standard '\'' splice escape.
33- {"embedded apostrophe", `/Users/pat/it's.png`, `'/Users/pat/it'\''s.png'`},
72+ {"embedded apostrophe", dir + `/it's.png`, `'` + dir + `/it'\''s.png'`},
3473
3574 // Plain prose left alone.
3675 {"prose", `hello world`, `hello world`},
@@ -43,7 +82,15 @@ func TestQuotePastedFilePaths(t *testing.T) {
4382 {"metachar", `/foo;rm -rf /`, `/foo;rm -rf /`},
4483
4584 // Mixed: valid path token quoted, surrounding words preserved.
46- {"path mixed with prose", `/x.png is good`, `'/x.png' is good`},
85+ {"path mixed with prose", dir + `/only.png is good`, `'` + dir + `/only.png' is good`},
86+
87+ // URL path segment that happens to start with "/" but doesn't
88+ // exist on disk: must NOT be quoted or chip-collapsed. This
89+ // is the regression that motivated the existence check.
90+ {"url path segment", `/de/downloads/dokumentenarchiv`, `/de/downloads/dokumentenarchiv`},
91+
92+ // Non-existent path that looks like a real one: also untouched.
93+ {"non-existent absolute", `/this/does/not/exist.png`, `/this/does/not/exist.png`},
4794 }
4895 for _, c := range cases {
4996 t.Run(c.name, func(t *testing.T) {
@@ -54,3 +101,26 @@ func TestQuotePastedFilePaths(t *testing.T) {
54101 })
55102 }
56103 }
104+
105+// TestTildePathExists checks that ~ expansion in pathExists works
106+// when the home directory contains the candidate. We don't write
107+// into the user's real home; instead, override HOME for the test.
108+func TestTildePathExists(t *testing.T) {
109+ tmp := t.TempDir()
110+ makeFiles(t, tmp, "tilde-test.png")
111+ t.Setenv("HOME", tmp)
112+
113+ in := `~/tilde-test.png`
114+ want := `'~/tilde-test.png'`
115+ got := quotePastedFilePaths(in)
116+ if got != want {
117+ t.Errorf("input %q\n want %q\n got %q", in, want, got)
118+ }
119+
120+ // A tilde path that doesn't exist must be left alone.
121+ in = `~/no-such-file.png`
122+ got = quotePastedFilePaths(in)
123+ if got != in {
124+ t.Errorf("non-existent tilde path mutated: got %q want %q", got, in)
125+ }
126+}
@@ -1,36 +1,75 @@
1 package tui1 package tui
2 2
3-import "testing"3+import (
4+ "net/url"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+)
10+
11+// makeFiles creates files (and intermediate dirs) under base for each
12+// relative path in rels. Returns base for convenience.
13+func makeFiles(t *testing.T, base string, rels ...string) string {
14+ t.Helper()
15+ for _, rel := range rels {
16+ full := filepath.Join(base, rel)
17+ if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
18+ t.Fatalf("mkdir: %v", err)
19+ }
20+ if err := os.WriteFile(full, []byte("x"), 0o644); err != nil {
21+ t.Fatalf("write: %v", err)
22+ }
23+ }
24+ return base
25+}
4 26
5 func TestQuotePastedFilePaths(t *testing.T) {27 func TestQuotePastedFilePaths(t *testing.T) {
28+ dir := t.TempDir()
29+ makeFiles(t, dir,
30+ "foo bar.png",
31+ "file.png",
32+ "a.png",
33+ "b.png",
34+ "x.png",
35+ "it's.png",
36+ "only.png",
37+ )
38+
39+ // file:// URL form needs URL-encoded spaces.
40+ fileURL := "file://" + dir + "/" + url.PathEscape("foo bar.png")
41+ // PathEscape doesn't escape "/" but escapes the space — that's
42+ // what macOS Finder produces. Build the path-only segment so the
43+ // "file://" prefix is added cleanly.
44+ fileURLPath := dir + "/" + strings.ReplaceAll("foo bar.png", " ", "%20")
45+ fileURLForm := "file://" + fileURLPath
46+ _ = fileURL // keep linter happy if unused
47+
6 cases := []struct {48 cases := []struct {
7 name string49 name string
8 in string50 in string
9 want string51 want string
10 }{52 }{
11 // macOS Terminal default: backslash-escaped spaces in path.53 // macOS Terminal default: backslash-escaped spaces in path.
12- {"backslash-escaped space", `/Users/pat/foo\ bar.png`, `'/Users/pat/foo bar.png'`},54+ {"backslash-escaped space", dir + `/foo\ bar.png`, `'` + dir + `/foo bar.png'`},
13 55
14 // Plain absolute path with no special chars.56 // Plain absolute path with no special chars.
15- {"plain absolute", `/Users/pat/file.png`, `'/Users/pat/file.png'`},57+ {"plain absolute", dir + `/file.png`, `'` + dir + `/file.png'`},
16 58
17 // file:// URL form; URL-decoded and scheme stripped.59 // file:// URL form; URL-decoded and scheme stripped.
18- {"file url", `file:///Users/pat/foo%20bar.png`, `'/Users/pat/foo bar.png'`},60+ {"file url", fileURLForm, `'` + dir + `/foo bar.png'`},
19-
20- // Tilde paths.
21- {"tilde", `~/Pictures/x.png`, `'~/Pictures/x.png'`},
22 61
23 // Multi-file drop, each path quoted independently.62 // Multi-file drop, each path quoted independently.
24- {"two paths", `/a.png /b.png`, `'/a.png' '/b.png'`},63+ {"two paths", dir + `/a.png ` + dir + `/b.png`, `'` + dir + `/a.png' '` + dir + `/b.png'`},
25 64
26 // Already single-quoted: re-normalise to consistent quoting.65 // Already single-quoted: re-normalise to consistent quoting.
27- {"already single quoted", `'/Users/pat/x.png'`, `'/Users/pat/x.png'`},66+ {"already single quoted", `'` + dir + `/x.png'`, `'` + dir + `/x.png'`},
28 67
29 // Already double-quoted: re-normalise.68 // Already double-quoted: re-normalise.
30- {"already double quoted", `"/Users/pat/x.png"`, `'/Users/pat/x.png'`},69+ {"already double quoted", `"` + dir + `/x.png"`, `'` + dir + `/x.png'`},
31 70
32 // Embedded apostrophe gets the standard '\'' splice escape.71 // Embedded apostrophe gets the standard '\'' splice escape.
33- {"embedded apostrophe", `/Users/pat/it's.png`, `'/Users/pat/it'\''s.png'`},72+ {"embedded apostrophe", dir + `/it's.png`, `'` + dir + `/it'\''s.png'`},
34 73
35 // Plain prose left alone.74 // Plain prose left alone.
36 {"prose", `hello world`, `hello world`},75 {"prose", `hello world`, `hello world`},
@@ -43,7 +82,15 @@ func TestQuotePastedFilePaths(t *testing.T) {
43 {"metachar", `/foo;rm -rf /`, `/foo;rm -rf /`},82 {"metachar", `/foo;rm -rf /`, `/foo;rm -rf /`},
44 83
45 // Mixed: valid path token quoted, surrounding words preserved.84 // Mixed: valid path token quoted, surrounding words preserved.
46- {"path mixed with prose", `/x.png is good`, `'/x.png' is good`},85+ {"path mixed with prose", dir + `/only.png is good`, `'` + dir + `/only.png' is good`},
86+
87+ // URL path segment that happens to start with "/" but doesn't
88+ // exist on disk: must NOT be quoted or chip-collapsed. This
89+ // is the regression that motivated the existence check.
90+ {"url path segment", `/de/downloads/dokumentenarchiv`, `/de/downloads/dokumentenarchiv`},
91+
92+ // Non-existent path that looks like a real one: also untouched.
93+ {"non-existent absolute", `/this/does/not/exist.png`, `/this/does/not/exist.png`},
47 }94 }
48 for _, c := range cases {95 for _, c := range cases {
49 t.Run(c.name, func(t *testing.T) {96 t.Run(c.name, func(t *testing.T) {
@@ -54,3 +101,26 @@ func TestQuotePastedFilePaths(t *testing.T) {
54 })101 })
55 }102 }
56 }103 }
104+
105+// TestTildePathExists checks that ~ expansion in pathExists works
106+// when the home directory contains the candidate. We don't write
107+// into the user's real home; instead, override HOME for the test.
108+func TestTildePathExists(t *testing.T) {
109+ tmp := t.TempDir()
110+ makeFiles(t, tmp, "tilde-test.png")
111+ t.Setenv("HOME", tmp)
112+
113+ in := `~/tilde-test.png`
114+ want := `'~/tilde-test.png'`
115+ got := quotePastedFilePaths(in)
116+ if got != want {
117+ t.Errorf("input %q\n want %q\n got %q", in, want, got)
118+ }
119+
120+ // A tilde path that doesn't exist must be left alone.
121+ in = `~/no-such-file.png`
122+ got = quotePastedFilePaths(in)
123+ if got != in {
124+ t.Errorf("non-existent tilde path mutated: got %q want %q", got, in)
125+ }
126+}

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