summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-07-19 10:51:32 +0300
committerPaul Buetow <paul@buetow.org>2026-07-19 10:51:32 +0300
commit4c057d02a11a81579836e451e74c0e7ebb1649ea (patch)
tree8b97a063ae6488628386f47e8fea874adaa9d424
parent8ec2807ece746b001e3d64a1db990ce239a98801 (diff)
Support "@ path" file reference (space after @) when file exists
Extend the `o` open-file feature so a reference written with a space after the @ ("@ path/to/file.txt") is also recognized. Because "@ word" is common in prose (e.g. "meet @ 5pm"), the spaced form is only treated as a file reference when the resolved path actually exists on disk. The original no-space "@path" form keeps precedence and its existing behavior (opened as-is, so a not-yet-existing file can be created). Adds fileRefSpacedRegex, a fileExists guard, and unit tests covering the existing/missing/prose/precedence cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
-rw-r--r--internal/ui/fileref_test.go31
-rw-r--r--internal/ui/keyactions.go33
-rw-r--r--internal/ui/table.go5
3 files changed, 63 insertions, 6 deletions
diff --git a/internal/ui/fileref_test.go b/internal/ui/fileref_test.go
index 662f5ab..1ab9c06 100644
--- a/internal/ui/fileref_test.go
+++ b/internal/ui/fileref_test.go
@@ -50,6 +50,37 @@ func TestResolveFileRefPathTilde(t *testing.T) {
}
}
+// TestExtractFileRefSpaced verifies the "@ path" form: a space after the @ is
+// only treated as a file reference when the resolved path exists on disk, so
+// ordinary "@ word" prose does not false-match.
+func TestExtractFileRefSpaced(t *testing.T) {
+ dir := t.TempDir()
+ existing := filepath.Join(dir, "real.txt")
+ if err := os.WriteFile(existing, []byte("hi"), 0o644); err != nil {
+ t.Fatalf("write temp file: %v", err)
+ }
+ missing := filepath.Join(dir, "nope.txt")
+
+ cases := []struct {
+ name string
+ text string
+ want string
+ }{
+ {"spaced existing file", "please open @ " + existing, existing},
+ {"spaced missing file", "please open @ " + missing, ""},
+ {"spaced prose not a file", "let's meet @ 5pm today", ""},
+ {"no-space form still wins", "@main.go and @ " + existing, "main.go"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := extractFileRef(tc.text); got != tc.want {
+ t.Fatalf("extractFileRef(%q) = %q, want %q", tc.text, got, tc.want)
+ }
+ })
+ }
+}
+
// TestFindTaskFileRefFallsBackToAnnotations verifies that the description is
// scanned first and annotations are used only when the description has no
// reference.
diff --git a/internal/ui/keyactions.go b/internal/ui/keyactions.go
index a2902a5..871ff05 100644
--- a/internal/ui/keyactions.go
+++ b/internal/ui/keyactions.go
@@ -171,14 +171,35 @@ func findTaskFileRef(t *task.Task) string {
return ""
}
-// extractFileRef parses an "@path/to/file.txt" reference out of text and
-// returns the resolved filesystem path (empty when no reference is present).
+// extractFileRef parses a file reference out of text and returns the resolved
+// filesystem path (empty when no reference is present). Two forms are accepted:
+//
+// - "@path/to/file.txt" (no space) — taken as-is, so a not-yet-existing file
+// can still be opened/created in the editor.
+// - "@ path/to/file.txt" (space after @) — only accepted when the resolved
+// path exists on disk, because "@ word" is common in prose and would
+// otherwise produce false matches.
+//
+// The no-space form is tried first so it keeps precedence.
func extractFileRef(text string) string {
- match := fileRefRegex.FindStringSubmatch(text)
- if match == nil {
- return ""
+ if match := fileRefRegex.FindStringSubmatch(text); match != nil {
+ if path := resolveFileRefPath(match[2]); path != "" {
+ return path
+ }
}
- return resolveFileRefPath(match[2])
+ if match := fileRefSpacedRegex.FindStringSubmatch(text); match != nil {
+ if path := resolveFileRefPath(match[2]); path != "" && fileExists(path) {
+ return path
+ }
+ }
+ return ""
+}
+
+// fileExists reports whether path names an existing filesystem entry. It is the
+// guard that disambiguates the "@ path" form from ordinary "@ word" prose.
+func fileExists(path string) bool {
+ _, err := os.Stat(path)
+ return err == nil
}
// resolveFileRefPath cleans up a raw @-reference path. Trailing punctuation
diff --git a/internal/ui/table.go b/internal/ui/table.go
index 5efb41e..ab71815 100644
--- a/internal/ui/table.go
+++ b/internal/ui/table.go
@@ -36,6 +36,11 @@ var (
// "@path/to/file.txt". The leading (^|\s) anchor keeps it from matching
// the "@host" part of an email address; capture group 2 is the path.
fileRefRegex = regexp.MustCompile(`(^|\s)@(\S+)`)
+ // fileRefSpacedRegex matches the "@ path/to/file.txt" form where a space
+ // follows the @. Because "@ word" is extremely common in prose (e.g.
+ // "meet @ 5pm"), a match here is only treated as a file reference when the
+ // resolved path actually exists on disk; capture group 2 is the path.
+ fileRefSpacedRegex = regexp.MustCompile(`(^|\s)@\s+(\S+)`)
// youtubeHostRegex matches the host of a YouTube video link so the "o"
// key can route it to an alternative browser. It covers youtube.com (with
// optional www./m. subdomains) and the youtu.be short-link domain. Case is