From e03680f7db7e033f7b193dc8880cf1b0bd843bfc Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 22 Jun 2026 14:14:26 +0300 Subject: Fix async URL browser launch for mq0 --- internal/ui/keyactions.go | 24 ++++++-- internal/ui/table.go | 7 +++ internal/ui/table_test.go | 146 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 171 insertions(+), 6 deletions(-) (limited to 'internal') diff --git a/internal/ui/keyactions.go b/internal/ui/keyactions.go index e87f34a..e8e0c55 100644 --- a/internal/ui/keyactions.go +++ b/internal/ui/keyactions.go @@ -113,12 +113,28 @@ func (m *Model) handleOpenURL() (tea.Model, tea.Cmd) { return m, nil } - if err := exec.Command(m.browserCmd, url).Run(); err != nil { - m.showError(fmt.Errorf("opening browser: %w", err)) - return m, nil + return m, openURLCmd(m.browserCmd, url, task.ID) +} + +func openURLCmd(browserCmd, url string, taskID int) tea.Cmd { + return func() tea.Msg { + cmd := exec.Command(browserCmd, url) + if err := cmd.Start(); err != nil { + return openURLDoneMsg{err: fmt.Errorf("opening browser: %w", err)} + } + if err := cmd.Process.Release(); err != nil { + return openURLDoneMsg{err: fmt.Errorf("releasing browser process: %w", err)} + } + return openURLDoneMsg{taskID: taskID} } +} - return m, m.startBlink(task.ID, false) +func (m *Model) handleOpenURLDone(msg openURLDoneMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + m.showError(msg.err) + return m, nil + } + return m, m.startBlink(msg.taskID, false) } func (m *Model) handleUndo() (tea.Model, tea.Cmd) { diff --git a/internal/ui/table.go b/internal/ui/table.go index 69d1481..e629c20 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -247,6 +247,11 @@ type shellCompletionMsg struct { sources task.CompletionSources } +type openURLDoneMsg struct { + err error + taskID int +} + type blinkMsg struct{} type descriptionTempFile interface { @@ -609,6 +614,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.handleShellDone(msg) case shellCompletionMsg: return m.handleShellCompletion(msg) + case openURLDoneMsg: + return m.handleOpenURLDone(msg) case blinkMsg: return m.handleBlinkMsg() case struct{ clearStatus bool }: diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go index 5bb3440..065d817 100644 --- a/internal/ui/table_test.go +++ b/internal/ui/table_test.go @@ -798,9 +798,29 @@ func TestOpenURLHotkey(t *testing.T) { t.Fatalf("New: %v", err) } - mv, _ := (&m).Update(tea.KeyPressMsg{Code: 'o', Text: "o"}) + mv, cmd := (&m).Update(tea.KeyPressMsg{Code: 'o', Text: "o"}) + if cmd == nil { + t.Fatalf("open URL unexpectedly returned no command") + } + m = *mv.(*Model) + + msg := cmd() + done, ok := msg.(openURLDoneMsg) + if !ok { + t.Fatalf("open URL command returned %T, want openURLDoneMsg", msg) + } + if done.err != nil { + t.Fatalf("open URL command failed: %v", done.err) + } + mv, cmd = (&m).Update(done) + if cmd == nil { + t.Fatalf("successful open URL did not start blink") + } m = *mv.(*Model) + if !waitForFile(openFile, 2*time.Second) { + t.Fatalf("browser file was not written") + } data, err := os.ReadFile(openFile) if err != nil { t.Fatalf("read open: %v", err) @@ -2540,11 +2560,27 @@ func TestUltraOpenURLHotkeyUsesUltraSelection(t *testing.T) { mv, cmd := (&m).Update(tea.KeyPressMsg{Code: 'o', Text: "o"}) if cmd == nil { - // Opening a URL starts the blink animation, so a command is expected. t.Fatalf("ultra open URL unexpectedly returned no command") } m = *mv.(*Model) + msg := cmd() + done, ok := msg.(openURLDoneMsg) + if !ok { + t.Fatalf("open URL command returned %T, want openURLDoneMsg", msg) + } + if done.err != nil { + t.Fatalf("open URL command failed: %v", done.err) + } + mv, cmd = (&m).Update(done) + if cmd == nil { + t.Fatalf("successful open URL did not start blink") + } + m = *mv.(*Model) + + if !waitForFile(openLog, 2*time.Second) { + t.Fatalf("browser log was not written") + } data, err := os.ReadFile(openLog) if err != nil { t.Fatalf("read open log: %v", err) @@ -2573,6 +2609,112 @@ func TestUltraOpenURLHotkeyUsesUltraSelection(t *testing.T) { } } +func TestOpenURLStartErrorShowsStatus(t *testing.T) { + tmp := t.TempDir() + taskPath := filepath.Join(tmp, "task") + + taskScript := "#!/bin/sh\n" + + "if echo \"$@\" | grep -q export; then\n" + + " echo '{\"id\":1,\"uuid\":\"1\",\"description\":\"alpha https://example.com\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0}'\n" + + " exit 0\n" + + "fi\n" + if err := os.WriteFile(taskPath, []byte(taskScript), 0o755); err != nil { + t.Fatal(err) + } + setupEnv(t, taskPath) + + m, err := New(nil, filepath.Join(tmp, "missing-browser")) + if err != nil { + t.Fatalf("New: %v", err) + } + + mv, cmd := (&m).Update(tea.KeyPressMsg{Code: 'o', Text: "o"}) + if cmd == nil { + t.Fatalf("open URL unexpectedly returned no command") + } + m = *mv.(*Model) + if m.statusMsg != "" { + t.Fatalf("status was set before async command completed: %q", m.statusMsg) + } + + msg := cmd() + done, ok := msg.(openURLDoneMsg) + if !ok { + t.Fatalf("open URL command returned %T, want openURLDoneMsg", msg) + } + if done.err == nil { + t.Fatalf("open URL command unexpectedly succeeded") + } + + mv, cmd = (&m).Update(done) + if cmd != nil { + t.Fatalf("failed open URL unexpectedly returned a follow-up command") + } + m = *mv.(*Model) + if !strings.Contains(m.statusMsg, "Error: opening browser:") { + t.Fatalf("status = %q, want browser start error", m.statusMsg) + } +} + +func TestOpenURLCommandDoesNotWaitForBrowserExit(t *testing.T) { + tmp := t.TempDir() + taskPath := filepath.Join(tmp, "task") + browserPath := filepath.Join(tmp, "browser") + + taskScript := "#!/bin/sh\n" + + "if echo \"$@\" | grep -q export; then\n" + + " echo '{\"id\":1,\"uuid\":\"1\",\"description\":\"alpha https://example.com\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0}'\n" + + " exit 0\n" + + "fi\n" + if err := os.WriteFile(taskPath, []byte(taskScript), 0o755); err != nil { + t.Fatal(err) + } + + browserScript := "#!/bin/sh\n" + + "sleep 2\n" + if err := os.WriteFile(browserPath, []byte(browserScript), 0o755); err != nil { + t.Fatal(err) + } + setupEnv(t, taskPath) + + m, err := New(nil, browserPath) + if err != nil { + t.Fatalf("New: %v", err) + } + + start := time.Now() + mv, cmd := (&m).Update(tea.KeyPressMsg{Code: 'o', Text: "o"}) + if elapsed := time.Since(start); elapsed > 200*time.Millisecond { + t.Fatalf("Update blocked for %s", elapsed) + } + if cmd == nil { + t.Fatalf("open URL unexpectedly returned no command") + } + m = *mv.(*Model) + + done := make(chan tea.Msg, 1) + start = time.Now() + go func() { + done <- cmd() + }() + + select { + case msg := <-done: + if elapsed := time.Since(start); elapsed > 200*time.Millisecond { + t.Fatalf("open URL command waited for browser exit: %s", elapsed) + } + done, ok := msg.(openURLDoneMsg) + if !ok { + t.Fatalf("open URL command returned %T, want openURLDoneMsg", msg) + } + if done.err != nil { + t.Fatalf("open URL command failed: %v", done.err) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("open URL command did not return promptly") + } +} + func TestUltraReloadPreservesFilteredSelection(t *testing.T) { tmp := t.TempDir() taskPath, phaseFile := setupUltraReloadTaskSet(t, tmp) -- cgit v1.2.3