summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-07-19 10:46:22 +0300
committerPaul Buetow <paul@buetow.org>2026-07-19 10:46:22 +0300
commit8ec2807ece746b001e3d64a1db990ce239a98801 (patch)
tree435415c1a9040c41276466d8a74137a964c01a2d
parent9c87952fbae0b84cde3802086eed88f3a0c51051 (diff)
cv0: open YouTube links in an alternative browser
The 'o' open key now routes youtube.com/youtu.be video links to an optional alternative browser (e.g. chromium) via the new --youtube-browser-cmd flag, while every other URL keeps using --browser-cmd. When the flag is unset, YouTube links fall back to the default browser as before. - add youtubeBrowserCmd field and SetYouTubeBrowserCmd setter - add youtubeHostRegex plus isYouTubeURL and browserForURL helpers - wire --youtube-browser-cmd flag in main.go - unit-test URL detection and browser selection - document the flag in README Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
-rw-r--r--README.md1
-rw-r--r--cmd/tasksamurai/main.go5
-rw-r--r--internal/ui/keyactions.go27
-rw-r--r--internal/ui/table.go28
-rw-r--r--internal/ui/youtube_test.go79
5 files changed, 134 insertions, 6 deletions
diff --git a/README.md b/README.md
index 926bfa6..35f5992 100644
--- a/README.md
+++ b/README.md
@@ -69,6 +69,7 @@ tasksamurai -- -excludetag +includetag
### Flags
- `--browser-cmd <command>`: command used to open URLs (default: firefox on Linux, open on macOS)
+- `--youtube-browser-cmd <command>`: command used to open `youtube.com` / `youtu.be` links with the `o` key (default: unset, so YouTube links use `--browser-cmd`). Set it to, e.g., `chromium` to play YouTube videos in a different browser than your default.
- `--agent-hotkey <key>`: hotkey used to toggle the `+agent` / `-agent` filter (default: `3`)
- `--debug-log <path>`: path to debug log file for Taskwarrior commands
- `--debug-dir <directory>`: directory for runtime debug output (goroutine dumps, profiles)
diff --git a/cmd/tasksamurai/main.go b/cmd/tasksamurai/main.go
index 2b90dc4..8d4924c 100644
--- a/cmd/tasksamurai/main.go
+++ b/cmd/tasksamurai/main.go
@@ -24,6 +24,10 @@ func main() {
debugLog := flag.String("debug-log", "", "path to debug log file")
debugDir := flag.String("debug-dir", "", "directory for runtime debug output (goroutine dumps, profiles)")
browserCmd := flag.String("browser-cmd", browserCmdDefault, "command used to open URLs")
+ // Empty by default: YouTube links then open with --browser-cmd like any
+ // other URL. Set this to route youtube.com/youtu.be links to an
+ // alternative browser (e.g. chromium) that plays them better.
+ youtubeBrowserCmd := flag.String("youtube-browser-cmd", "", "command used to open youtube.com/youtu.be links (defaults to --browser-cmd)")
agentHotkey := flag.String("agent-hotkey", "3", "key used to toggle the +agent/-agent filter")
disco := flag.Bool("disco", false, "enable disco mode")
ultra := flag.Bool("ultra", false, "start directly in ultra mode")
@@ -48,6 +52,7 @@ func main() {
fmt.Fprintln(os.Stderr, "invalid --agent-hotkey:", err)
fmt.Fprintln(os.Stderr, "using default hotkey 3")
}
+ m.SetYouTubeBrowserCmd(*youtubeBrowserCmd)
m.SetDisco(*disco)
m.SetUltra(*ultra)
diff --git a/internal/ui/keyactions.go b/internal/ui/keyactions.go
index 70d113f..a2902a5 100644
--- a/internal/ui/keyactions.go
+++ b/internal/ui/keyactions.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"math/rand"
+ "net/url"
"os"
"os/exec"
"path/filepath"
@@ -108,7 +109,7 @@ func (m *Model) handleOpenURL() (tea.Model, tea.Cmd) {
}
if url := findTaskURL(task); url != "" {
- return m, openURLCmd(m.browserCmd, url, task.ID)
+ return m, openURLCmd(m.browserForURL(url), url, task.ID)
}
if path := findTaskFileRef(task); path != "" {
@@ -118,6 +119,30 @@ func (m *Model) handleOpenURL() (tea.Model, tea.Cmd) {
return m, nil
}
+// browserForURL picks the command used to open rawURL. YouTube video links are
+// routed to the configured alternative browser (youtubeBrowserCmd) when one is
+// set, so videos can play in a browser better suited for them; every other URL
+// (and YouTube links when no alternative is configured) uses the default
+// browserCmd.
+func (m *Model) browserForURL(rawURL string) string {
+ if m.youtubeBrowserCmd != "" && isYouTubeURL(rawURL) {
+ return m.youtubeBrowserCmd
+ }
+ return m.browserCmd
+}
+
+// isYouTubeURL reports whether rawURL points at a YouTube video link. The host
+// is parsed with net/url and matched against youtubeHostRegex so that only the
+// real host is considered — a path or query string that merely mentions
+// "youtube.com" (e.g. https://example.com/?u=youtube.com) does not match.
+func isYouTubeURL(rawURL string) bool {
+ u, err := url.Parse(rawURL)
+ if err != nil {
+ return false
+ }
+ return youtubeHostRegex.MatchString(u.Hostname())
+}
+
// findTaskURL returns the first http(s) URL found in the task description, or
// failing that in any annotation.
func findTaskURL(t *task.Task) string {
diff --git a/internal/ui/table.go b/internal/ui/table.go
index e168ba7..5efb41e 100644
--- a/internal/ui/table.go
+++ b/internal/ui/table.go
@@ -35,7 +35,12 @@ var (
// 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+)`)
+ fileRefRegex = regexp.MustCompile(`(^|\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
+ // ignored because hostnames are case-insensitive.
+ youtubeHostRegex = regexp.MustCompile(`(?i)^(www\.|m\.)?(youtube\.com|youtu\.be)$`)
searchRegexCache = make(map[string]*regexp.Regexp, 16)
searchRegexMu sync.RWMutex
)
@@ -221,10 +226,15 @@ type Model struct {
inProgress int
due int
- filters []string
- tasks []task.Task
- undoStack []undoAction
- browserCmd string
+ filters []string
+ tasks []task.Task
+ undoStack []undoAction
+ browserCmd string
+ // youtubeBrowserCmd, when non-empty, overrides browserCmd for YouTube
+ // links opened with the "o" key. This lets the user route videos to a
+ // browser better suited for them (e.g. chromium) while keeping the
+ // default browser (e.g. firefox) for everything else.
+ youtubeBrowserCmd string
agentFilterHotkey string
taskwarrior task.Taskwarrior
@@ -1436,6 +1446,14 @@ func (m *Model) SetAgentFilterHotkey(key string) error {
return nil
}
+// SetYouTubeBrowserCmd configures the browser command used specifically for
+// YouTube links opened with the "o" key. An empty value (the default) means
+// YouTube links are opened with the regular browser command like any other
+// URL. Surrounding whitespace is trimmed so a blank flag value counts as unset.
+func (m *Model) SetYouTubeBrowserCmd(cmd string) {
+ m.youtubeBrowserCmd = strings.TrimSpace(cmd)
+}
+
func (m *Model) agentFilterHotkeyLabel() string {
if strings.TrimSpace(m.agentFilterHotkey) == "" {
return "3"
diff --git a/internal/ui/youtube_test.go b/internal/ui/youtube_test.go
new file mode 100644
index 0000000..86fa324
--- /dev/null
+++ b/internal/ui/youtube_test.go
@@ -0,0 +1,79 @@
+package ui
+
+import "testing"
+
+// TestIsYouTubeURL verifies that only real YouTube video hosts are recognised,
+// covering the canonical domains and short link, subdomains, and cases where
+// "youtube.com" merely appears in a path or query string of another host.
+func TestIsYouTubeURL(t *testing.T) {
+ cases := []struct {
+ name string
+ url string
+ want bool
+ }{
+ {"youtube.com watch", "https://youtube.com/watch?v=abc123", true},
+ {"www.youtube.com", "https://www.youtube.com/watch?v=abc123", true},
+ {"m.youtube.com", "https://m.youtube.com/watch?v=abc123", true},
+ {"youtu.be short link", "https://youtu.be/abc123", true},
+ {"uppercase host", "https://WWW.YOUTUBE.COM/watch?v=abc", true},
+ {"plain http", "http://youtube.com/", true},
+ {"not youtube", "https://example.com/watch?v=abc123", false},
+ {"youtube in path only", "https://example.com/youtube.com/x", false},
+ {"youtube in query only", "https://example.com/?u=youtube.com", false},
+ {"lookalike host", "https://notyoutube.com/watch", false},
+ {"empty", "", false},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := isYouTubeURL(tc.url); got != tc.want {
+ t.Fatalf("isYouTubeURL(%q) = %v, want %v", tc.url, got, tc.want)
+ }
+ })
+ }
+}
+
+// TestBrowserForURL verifies the browser-selection logic: YouTube links use the
+// configured alternative browser only when one is set, and every other URL (as
+// well as YouTube links with no override) falls back to the default browser.
+func TestBrowserForURL(t *testing.T) {
+ cases := []struct {
+ name string
+ browser string
+ youtube string
+ url string
+ wantSelected string
+ }{
+ {"youtube with override", "firefox", "chromium", "https://youtu.be/x", "chromium"},
+ {"youtube without override", "firefox", "", "https://youtu.be/x", "firefox"},
+ {"non-youtube with override set", "firefox", "chromium", "https://example.com", "firefox"},
+ {"non-youtube no override", "firefox", "", "https://example.com", "firefox"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ m := &Model{browserCmd: tc.browser, youtubeBrowserCmd: tc.youtube}
+ if got := m.browserForURL(tc.url); got != tc.wantSelected {
+ t.Fatalf("browserForURL(%q) = %q, want %q", tc.url, got, tc.wantSelected)
+ }
+ })
+ }
+}
+
+// TestSetYouTubeBrowserCmdTrimsWhitespace verifies that a blank flag value is
+// treated as unset so YouTube links keep using the default browser.
+func TestSetYouTubeBrowserCmdTrimsWhitespace(t *testing.T) {
+ m := &Model{browserCmd: "firefox"}
+ m.SetYouTubeBrowserCmd(" ")
+ if m.youtubeBrowserCmd != "" {
+ t.Fatalf("blank value should be empty, got %q", m.youtubeBrowserCmd)
+ }
+ if got := m.browserForURL("https://youtu.be/x"); got != "firefox" {
+ t.Fatalf("browserForURL = %q, want firefox", got)
+ }
+
+ m.SetYouTubeBrowserCmd(" chromium ")
+ if m.youtubeBrowserCmd != "chromium" {
+ t.Fatalf("value should be trimmed to chromium, got %q", m.youtubeBrowserCmd)
+ }
+}