diff options
| author | Paul Buetow <paul@buetow.org> | 2026-07-19 10:42:59 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-07-19 10:42:59 +0300 |
| commit | 9c87952fbae0b84cde3802086eed88f3a0c51051 (patch) | |
| tree | 0c0b0994183e4bb871a9c78b280fb5c7e90d3cb8 /internal | |
| parent | d4f446c82281e5941e3436b207c59f6eca2e16f5 (diff) | |
bv0: open @file references from the 'o' key in $EDITOR
The 'o' key now opens an @path/to/file.txt reference in $EDITOR in the
foreground when the task carries no URL. URLs keep precedence so the
original open-URL behavior is unchanged when both are present.
- Add fileRefRegex plus extractFileRef/findTaskFileRef/resolveFileRefPath
helpers: scan description then annotations, expand a leading ~, and
anchor on (^|\s) so email @host parts are not treated as references.
- openFileInEditorCmd mirrors launchDescriptionEditorCmd, using
tea.ExecProcess to suspend the TUI, run the editor on the real
terminal, and restore the TUI on exit.
- Refactor handleOpenURL into small helpers to stay under the size limit.
- Add fileref_test.go covering parsing, ~ expansion, annotation fallback,
and URL precedence.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/ui/fileref_test.go | 86 | ||||
| -rw-r--r-- | internal/ui/keyactions.go | 105 | ||||
| -rw-r--r-- | internal/ui/keyhandlers.go | 2 | ||||
| -rw-r--r-- | internal/ui/table.go | 15 |
4 files changed, 196 insertions, 12 deletions
diff --git a/internal/ui/fileref_test.go b/internal/ui/fileref_test.go new file mode 100644 index 0000000..662f5ab --- /dev/null +++ b/internal/ui/fileref_test.go @@ -0,0 +1,86 @@ +package ui + +import ( + "os" + "path/filepath" + "testing" + + "codeberg.org/snonux/tasksamurai/internal/task" +) + +// TestExtractFileRef verifies that @path/to/file.txt style references are +// parsed out of free text (and that non-references such as email addresses are +// ignored). +func TestExtractFileRef(t *testing.T) { + cases := []struct { + name string + text string + want string + }{ + {"plain reference", "see @notes/todo.txt for details", "notes/todo.txt"}, + {"reference at start", "@path/to/file.txt is here", "path/to/file.txt"}, + {"absolute path", "check @/etc/hosts now", "/etc/hosts"}, + {"trailing punctuation", "open @docs/readme.md.", "docs/readme.md"}, + {"email is not a reference", "mail paul@example.com please", ""}, + {"no reference", "nothing to open here", ""}, + } + + 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) + } + }) + } +} + +// TestResolveFileRefPathTilde verifies that a leading "~" expands to the user's +// home directory while relative paths are left untouched. +func TestResolveFileRefPathTilde(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skipf("no home directory available: %v", err) + } + + if got := extractFileRef("edit @~/notes.txt today"); got != filepath.Join(home, "notes.txt") { + t.Fatalf("tilde path = %q, want %q", got, filepath.Join(home, "notes.txt")) + } + if got := extractFileRef("edit @relative/notes.txt"); got != "relative/notes.txt" { + t.Fatalf("relative path = %q, want %q", got, "relative/notes.txt") + } +} + +// TestFindTaskFileRefFallsBackToAnnotations verifies that the description is +// scanned first and annotations are used only when the description has no +// reference. +func TestFindTaskFileRefFallsBackToAnnotations(t *testing.T) { + desc := task.Task{Description: "open @main.go"} + if got := findTaskFileRef(&desc); got != "main.go" { + t.Fatalf("description ref = %q, want %q", got, "main.go") + } + + ann := task.Task{ + Description: "no file here", + Annotations: []task.Annotation{{Description: "see @docs/spec.md"}}, + } + if got := findTaskFileRef(&ann); got != "docs/spec.md" { + t.Fatalf("annotation ref = %q, want %q", got, "docs/spec.md") + } + + none := task.Task{Description: "plain text"} + if got := findTaskFileRef(&none); got != "" { + t.Fatalf("no ref = %q, want empty", got) + } +} + +// TestFindTaskURLPrecedence documents that URL detection is independent from +// file references so handleOpenURL can prefer URLs when both are present. +func TestFindTaskURLPrecedence(t *testing.T) { + both := task.Task{Description: "see https://example.com and @notes.txt"} + if got := findTaskURL(&both); got != "https://example.com" { + t.Fatalf("url = %q, want https://example.com", got) + } + if got := findTaskFileRef(&both); got != "notes.txt" { + t.Fatalf("file ref = %q, want notes.txt", got) + } +} diff --git a/internal/ui/keyactions.go b/internal/ui/keyactions.go index d8ec2a7..70d113f 100644 --- a/internal/ui/keyactions.go +++ b/internal/ui/keyactions.go @@ -5,7 +5,9 @@ import ( "errors" "fmt" "math/rand" + "os" "os/exec" + "path/filepath" "strings" "time" @@ -94,26 +96,81 @@ func (m *Model) handleDeleteTask() (tea.Model, tea.Cmd) { return m, nil } +// handleOpenURL implements the "o" key. URLs take precedence over file +// references: the binding began life as an open-URL action, so a task that +// carries both keeps opening the URL. When no URL is present the description +// and annotations are scanned for an @path/to/file.txt reference, which is +// opened in $EDITOR in the foreground instead. func (m *Model) handleOpenURL() (tea.Model, tea.Cmd) { task := m.getTaskForOpenURL() if task == nil { return m, nil } - url := urlRegex.FindString(task.Description) - if url == "" { - for _, ann := range task.Annotations { - url = urlRegex.FindString(ann.Description) - if url != "" { - break - } + if url := findTaskURL(task); url != "" { + return m, openURLCmd(m.browserCmd, url, task.ID) + } + + if path := findTaskFileRef(task); path != "" { + return m, openFileInEditorCmd(path, task.ID) + } + + return m, nil +} + +// findTaskURL returns the first http(s) URL found in the task description, or +// failing that in any annotation. +func findTaskURL(t *task.Task) string { + if url := urlRegex.FindString(t.Description); url != "" { + return url + } + for _, ann := range t.Annotations { + if url := urlRegex.FindString(ann.Description); url != "" { + return url } } - if url == "" { - return m, nil + return "" +} + +// findTaskFileRef returns the resolved path of the first @file reference found +// in the task description, or failing that in any annotation. +func findTaskFileRef(t *task.Task) string { + if path := extractFileRef(t.Description); path != "" { + return path } + for _, ann := range t.Annotations { + if path := extractFileRef(ann.Description); path != "" { + return path + } + } + return "" +} - return m, openURLCmd(m.browserCmd, url, task.ID) +// extractFileRef parses an "@path/to/file.txt" reference out of text and +// returns the resolved filesystem path (empty when no reference is present). +func extractFileRef(text string) string { + match := fileRefRegex.FindStringSubmatch(text) + if match == nil { + return "" + } + return resolveFileRefPath(match[2]) +} + +// resolveFileRefPath cleans up a raw @-reference path. Trailing punctuation +// that commonly abuts a path in prose is trimmed, a leading "~" is expanded to +// the user's home directory, and relative paths are left untouched so the +// editor resolves them against the process working directory. +func resolveFileRefPath(raw string) string { + raw = strings.TrimRight(raw, ".,;:)") + if raw == "" { + return "" + } + if raw == "~" || strings.HasPrefix(raw, "~/") { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, strings.TrimPrefix(raw[1:], "/")) + } + } + return raw } func openURLCmd(browserCmd, url string, taskID int) tea.Cmd { @@ -137,6 +194,34 @@ func (m *Model) handleOpenURLDone(msg openURLDoneMsg) (tea.Model, tea.Cmd) { return m, m.startBlink(msg.taskID, false) } +// openFileInEditorCmd opens path in $EDITOR (falling back to vi) in the +// foreground. It mirrors launchDescriptionEditorCmd: tea.ExecProcess suspends +// the TUI, runs the editor attached to the real terminal, and restores the TUI +// once the editor exits. +func openFileInEditorCmd(path string, taskID int) tea.Cmd { + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vi" + } + + c := exec.Command(editor, path) + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + + return tea.ExecProcess(c, func(err error) tea.Msg { + return openFileDoneMsg{err: err, taskID: taskID} + }) +} + +func (m *Model) handleOpenFileDone(msg openFileDoneMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + m.showError(fmt.Errorf("editor: %w", msg.err)) + return m, nil + } + return m, m.startBlink(msg.taskID, false) +} + func (m *Model) handleUndo() (tea.Model, tea.Cmd) { if len(m.undoStack) == 0 { return m, nil diff --git a/internal/ui/keyhandlers.go b/internal/ui/keyhandlers.go index 3c05513..1f44ac1 100644 --- a/internal/ui/keyhandlers.go +++ b/internal/ui/keyhandlers.go @@ -47,7 +47,7 @@ var sharedKeyBindings = []keyBinding{ {keys: []string{"s"}, modes: keyBindingAll, desc: "start/stop task", action: sharedKeyAction(func(h sharedKeyHandlers) func() (tea.Model, tea.Cmd) { return h.toggleStart })}, {keys: []string{"d"}, modes: keyBindingAll, desc: "mark task done", action: sharedKeyAction(func(h sharedKeyHandlers) func() (tea.Model, tea.Cmd) { return h.markDone })}, {keys: []string{"D"}, modes: keyBindingAll, desc: "delete task/recurring series", action: sharedKeyAction(func(h sharedKeyHandlers) func() (tea.Model, tea.Cmd) { return h.deleteTask })}, - {keys: []string{"o"}, modes: keyBindingAll, desc: "open URL from description", action: modelKeyAction((*Model).handleOpenURL)}, + {keys: []string{"o"}, modes: keyBindingAll, desc: "open URL or @file reference from description", action: modelKeyAction((*Model).handleOpenURL)}, {keys: []string{"U"}, modes: keyBindingAll, desc: "undo last done/delete", action: modelKeyAction((*Model).handleUndo)}, {keys: []string{"w"}, modes: keyBindingAll, desc: "set due date", action: sharedKeyAction(func(h sharedKeyHandlers) func() (tea.Model, tea.Cmd) { return h.setDueDate })}, {keys: []string{"W"}, modes: keyBindingAll, desc: "remove due date", action: sharedKeyAction(func(h sharedKeyHandlers) func() (tea.Model, tea.Cmd) { return h.removeDueDate })}, diff --git a/internal/ui/table.go b/internal/ui/table.go index b1e8914..e168ba7 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -31,7 +31,11 @@ var priorityOptions = []string{"H", "M", "L", ""} const taskOperationTimeout = 30 * time.Second var ( - urlRegex = regexp.MustCompile(`https?://\S+`) + urlRegex = regexp.MustCompile(`https?://\S+`) + // fileRefRegex matches an @-prefixed file reference such as + // "@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+)`) searchRegexCache = make(map[string]*regexp.Regexp, 16) searchRegexMu sync.RWMutex ) @@ -264,6 +268,13 @@ type openURLDoneMsg struct { taskID int } +// openFileDoneMsg is emitted when the foreground editor launched for an +// @path/to/file.txt reference (via the "o" key) finishes. +type openFileDoneMsg struct { + err error + taskID int +} + type blinkMsg struct{} type descriptionTempFile interface { @@ -662,6 +673,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.handleShellCompletion(msg) case openURLDoneMsg: return m.handleOpenURLDone(msg) + case openFileDoneMsg: + return m.handleOpenFileDone(msg) case blinkMsg: return m.handleBlinkMsg() case clearStatusMsg: |
