summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-07-19 10:42:59 +0300
committerPaul Buetow <paul@buetow.org>2026-07-19 10:42:59 +0300
commit9c87952fbae0b84cde3802086eed88f3a0c51051 (patch)
tree0c0b0994183e4bb871a9c78b280fb5c7e90d3cb8 /internal
parentd4f446c82281e5941e3436b207c59f6eca2e16f5 (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.go86
-rw-r--r--internal/ui/keyactions.go105
-rw-r--r--internal/ui/keyhandlers.go2
-rw-r--r--internal/ui/table.go15
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: