diff options
| author | Paul Buetow <paul@buetow.org> | 2026-07-19 10:51:32 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-07-19 10:51:32 +0300 |
| commit | 4c057d02a11a81579836e451e74c0e7ebb1649ea (patch) | |
| tree | 8b97a063ae6488628386f47e8fea874adaa9d424 | |
| parent | 8ec2807ece746b001e3d64a1db990ce239a98801 (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.go | 31 | ||||
| -rw-r--r-- | internal/ui/keyactions.go | 33 | ||||
| -rw-r--r-- | internal/ui/table.go | 5 |
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 |
