From 4c057d02a11a81579836e451e74c0e7ebb1649ea Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 19 Jul 2026 10:51:32 +0300 Subject: 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 --- internal/ui/fileref_test.go | 31 +++++++++++++++++++++++++++++++ internal/ui/keyactions.go | 33 +++++++++++++++++++++++++++------ 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 -- cgit v1.2.3