detached 65f79a8

fix(navigation): limit external launch prompts to one per pane

A pane keeps at most one pending external-launch confirmation, and a confirmation answered after its pane was released or rebound no longer launches anything.
commit
65f79a87552fdbb86d2127e28070577e9c74968f
author
brice · · 2026-10-15 06:09 UTC
parent
f6fdf4d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +63 -26
M internal/ui/coordinator/content/coordinator.go +3 -4
@@ -138,11 +138,10 @@ type Coordinator struct {
138138 // respond must be called exactly once on the GTK thread.
139139 onConfirmExternalLaunch func(paneID entity.PaneID, uri string, respond func(allowed bool))
140140
141- // externalPromptMu guards externalPrompts, the external URI currently
142- // awaiting confirmation per pane. It suppresses duplicate prompts when the
143- // engine reports the same address change more than once.
141+ // externalPromptMu guards externalPrompts: panes with an outstanding
142+ // external-launch prompt, mapped to the WebView that asked.
144143 externalPromptMu sync.Mutex
145- externalPrompts map[entity.PaneID]string
144+ externalPrompts map[entity.PaneID]port.WebView
146145 }
147146
148147 type pendingThemeUpdate struct {
@@ -138,11 +138,10 @@ type Coordinator struct {
138 // respond must be called exactly once on the GTK thread.138 // respond must be called exactly once on the GTK thread.
139 onConfirmExternalLaunch func(paneID entity.PaneID, uri string, respond func(allowed bool))139 onConfirmExternalLaunch func(paneID entity.PaneID, uri string, respond func(allowed bool))
140 140
141- // externalPromptMu guards externalPrompts, the external URI currently141+ // externalPromptMu guards externalPrompts: panes with an outstanding
142- // awaiting confirmation per pane. It suppresses duplicate prompts when the142+ // external-launch prompt, mapped to the WebView that asked.
143- // engine reports the same address change more than once.
144 externalPromptMu sync.Mutex143 externalPromptMu sync.Mutex
145- externalPrompts map[entity.PaneID]string144+ externalPrompts map[entity.PaneID]port.WebView
146 }145 }
147 146
148 type pendingThemeUpdate struct {147 type pendingThemeUpdate struct {
M internal/ui/coordinator/content/lifecycle.go +1 -0
@@ -49,6 +49,7 @@ func (c *Coordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (
4949 func (c *Coordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) {
5050 log := logging.FromContext(ctx)
5151 c.ensurePopupManager().cancelDeferredPopupsForParent(paneID)
52+ c.clearExternalPrompt(paneID)
5253
5354 // deleteWebViewLocked clears reveal state before returning, even when a
5455 // previous cleanup already removed this pane's mapping.
@@ -49,6 +49,7 @@ func (c *Coordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (
49 func (c *Coordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) {49 func (c *Coordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) {
50 log := logging.FromContext(ctx)50 log := logging.FromContext(ctx)
51 c.ensurePopupManager().cancelDeferredPopupsForParent(paneID)51 c.ensurePopupManager().cancelDeferredPopupsForParent(paneID)
52+ c.clearExternalPrompt(paneID)
52 53
53 // deleteWebViewLocked clears reveal state before returning, even when a54 // deleteWebViewLocked clears reveal state before returning, even when a
54 // previous cleanup already removed this pane's mapping.55 // previous cleanup already removed this pane's mapping.
M internal/ui/coordinator/content/navigation.go +25 -10
@@ -387,9 +387,11 @@ func (c *Coordinator) onLinkHover(paneID entity.PaneID, uri string) {
387387 }
388388 }
389389
390-// promptExternalLaunch asks the user once per pane and URI whether the external
391-// URI may be handed to the system handler.
392-func (c *Coordinator) promptExternalLaunch(ctx context.Context, paneID entity.PaneID, uri string) {
390+// promptExternalLaunch asks the user whether the external URI may be handed to
391+// the system handler. A pane has at most one outstanding prompt: further
392+// external URIs from that pane are dropped until it is answered, so a page
393+// cannot flood the window's prompt queue.
394+func (c *Coordinator) promptExternalLaunch(ctx context.Context, paneID entity.PaneID, wv port.WebView, uri string) {
393395 log := logging.FromContext(ctx)
394396 if c.onConfirmExternalLaunch == nil || c.onLaunchExternalURL == nil {
395397 log.Info().Str("pane_id", string(paneID)).Msg("external scheme launch unavailable; blocked")
@@ -397,32 +399,45 @@ func (c *Coordinator) promptExternalLaunch(ctx context.Context, paneID entity.Pa
397399 }
398400
399401 c.externalPromptMu.Lock()
400- if c.externalPrompts[paneID] == uri {
402+ if _, pending := c.externalPrompts[paneID]; pending {
401403 c.externalPromptMu.Unlock()
402404 return
403405 }
404406 if c.externalPrompts == nil {
405- c.externalPrompts = make(map[entity.PaneID]string)
407+ c.externalPrompts = make(map[entity.PaneID]port.WebView)
406408 }
407- c.externalPrompts[paneID] = uri
409+ c.externalPrompts[paneID] = wv
408410 c.externalPromptMu.Unlock()
409411
410412 c.onConfirmExternalLaunch(paneID, uri, func(allowed bool) {
411413 c.externalPromptMu.Lock()
412- if c.externalPrompts[paneID] == uri {
414+ owner, pending := c.externalPrompts[paneID]
415+ // The prompt is stale when its pane was released (or rebound to
416+ // another WebView) while it was queued: never launch for it.
417+ current := pending && owner == wv
418+ if current {
413419 delete(c.externalPrompts, paneID)
414420 }
415421 c.externalPromptMu.Unlock()
422+ current = current && c.GetWebView(paneID) == wv
416423
417- log.Info().Str("pane_id", string(paneID)).Bool("allowed", allowed).
424+ log.Info().Str("pane_id", string(paneID)).Bool("allowed", allowed).Bool("stale", !current).
418425 Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
419426 Msg("external scheme launch decision")
420- if allowed {
427+ if allowed && current {
421428 c.onLaunchExternalURL(uri)
422429 }
423430 })
424431 }
425432
433+// clearExternalPrompt invalidates the pane's outstanding external-launch
434+// prompt so a late answer cannot launch anything.
435+func (c *Coordinator) clearExternalPrompt(paneID entity.PaneID) {
436+ c.externalPromptMu.Lock()
437+ delete(c.externalPrompts, paneID)
438+ c.externalPromptMu.Unlock()
439+}
440+
426441 // handleURIChanged handles URI changes from WebKit, including external scheme detection
427442 // and SPA navigation tracking.
428443 func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID, wv port.WebView, uri string) {
@@ -448,7 +463,7 @@ func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID
448463 // with a redirect to claude://, vscode://, ...), so the launch is never
449464 // automatic: the user must confirm it. Do not navigate back: going back
450465 // to an OAuth endpoint re-issues the redirect and loops.
451- c.promptExternalLaunch(ctx, paneID, uri)
466+ c.promptExternalLaunch(ctx, paneID, wv, uri)
452467 return
453468 }
454469
@@ -387,9 +387,11 @@ func (c *Coordinator) onLinkHover(paneID entity.PaneID, uri string) {
387 }387 }
388 }388 }
389 389
390-// promptExternalLaunch asks the user once per pane and URI whether the external390+// promptExternalLaunch asks the user whether the external URI may be handed to
391-// URI may be handed to the system handler.391+// the system handler. A pane has at most one outstanding prompt: further
392-func (c *Coordinator) promptExternalLaunch(ctx context.Context, paneID entity.PaneID, uri string) {392+// external URIs from that pane are dropped until it is answered, so a page
393+// cannot flood the window's prompt queue.
394+func (c *Coordinator) promptExternalLaunch(ctx context.Context, paneID entity.PaneID, wv port.WebView, uri string) {
393 log := logging.FromContext(ctx)395 log := logging.FromContext(ctx)
394 if c.onConfirmExternalLaunch == nil || c.onLaunchExternalURL == nil {396 if c.onConfirmExternalLaunch == nil || c.onLaunchExternalURL == nil {
395 log.Info().Str("pane_id", string(paneID)).Msg("external scheme launch unavailable; blocked")397 log.Info().Str("pane_id", string(paneID)).Msg("external scheme launch unavailable; blocked")
@@ -397,32 +399,45 @@ func (c *Coordinator) promptExternalLaunch(ctx context.Context, paneID entity.Pa
397 }399 }
398 400
399 c.externalPromptMu.Lock()401 c.externalPromptMu.Lock()
400- if c.externalPrompts[paneID] == uri {402+ if _, pending := c.externalPrompts[paneID]; pending {
401 c.externalPromptMu.Unlock()403 c.externalPromptMu.Unlock()
402 return404 return
403 }405 }
404 if c.externalPrompts == nil {406 if c.externalPrompts == nil {
405- c.externalPrompts = make(map[entity.PaneID]string)407+ c.externalPrompts = make(map[entity.PaneID]port.WebView)
406 }408 }
407- c.externalPrompts[paneID] = uri409+ c.externalPrompts[paneID] = wv
408 c.externalPromptMu.Unlock()410 c.externalPromptMu.Unlock()
409 411
410 c.onConfirmExternalLaunch(paneID, uri, func(allowed bool) {412 c.onConfirmExternalLaunch(paneID, uri, func(allowed bool) {
411 c.externalPromptMu.Lock()413 c.externalPromptMu.Lock()
412- if c.externalPrompts[paneID] == uri {414+ owner, pending := c.externalPrompts[paneID]
415+ // The prompt is stale when its pane was released (or rebound to
416+ // another WebView) while it was queued: never launch for it.
417+ current := pending && owner == wv
418+ if current {
413 delete(c.externalPrompts, paneID)419 delete(c.externalPrompts, paneID)
414 }420 }
415 c.externalPromptMu.Unlock()421 c.externalPromptMu.Unlock()
422+ current = current && c.GetWebView(paneID) == wv
416 423
417- log.Info().Str("pane_id", string(paneID)).Bool("allowed", allowed).424+ log.Info().Str("pane_id", string(paneID)).Bool("allowed", allowed).Bool("stale", !current).
418 Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).425 Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
419 Msg("external scheme launch decision")426 Msg("external scheme launch decision")
420- if allowed {427+ if allowed && current {
421 c.onLaunchExternalURL(uri)428 c.onLaunchExternalURL(uri)
422 }429 }
423 })430 })
424 }431 }
425 432
433+// clearExternalPrompt invalidates the pane's outstanding external-launch
434+// prompt so a late answer cannot launch anything.
435+func (c *Coordinator) clearExternalPrompt(paneID entity.PaneID) {
436+ c.externalPromptMu.Lock()
437+ delete(c.externalPrompts, paneID)
438+ c.externalPromptMu.Unlock()
439+}
440+
426 // handleURIChanged handles URI changes from WebKit, including external scheme detection441 // handleURIChanged handles URI changes from WebKit, including external scheme detection
427 // and SPA navigation tracking.442 // and SPA navigation tracking.
428 func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID, wv port.WebView, uri string) {443 func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID, wv port.WebView, uri string) {
@@ -448,7 +463,7 @@ func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID
448 // with a redirect to claude://, vscode://, ...), so the launch is never463 // with a redirect to claude://, vscode://, ...), so the launch is never
449 // automatic: the user must confirm it. Do not navigate back: going back464 // automatic: the user must confirm it. Do not navigate back: going back
450 // to an OAuth endpoint re-issues the redirect and loops.465 // to an OAuth endpoint re-issues the redirect and loops.
451- c.promptExternalLaunch(ctx, paneID, uri)466+ c.promptExternalLaunch(ctx, paneID, wv, uri)
452 return467 return
453 }468 }
454 469
M internal/ui/coordinator/content/navigation_security_test.go +34 -12
@@ -7,6 +7,7 @@ import (
77
88 "github.com/stretchr/testify/assert"
99
10+ "github.com/bnema/dumber/internal/application/port"
1011 "github.com/bnema/dumber/internal/application/port/mocks"
1112 "github.com/bnema/dumber/internal/domain/entity"
1213 )
@@ -52,7 +53,7 @@ func TestHandleURIChanged_ExternalSchemeLaunchesOnlyAfterConfirmation(t *testing
5253 wv.EXPECT().Stop(ctx).Return(nil).Once()
5354
5455 rec := &externalLaunchRecorder{}
55- c := &Coordinator{}
56+ c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
5657 rec.install(c)
5758
5859 uri := "claude://login/google-auth?code=abc"
@@ -74,7 +75,7 @@ func TestHandleURIChanged_ExternalSchemeDeniedDoesNotLaunchOrGoBack(t *testing.T
7475 wv.EXPECT().Stop(ctx).Return(nil).Once()
7576
7677 rec := &externalLaunchRecorder{}
77- c := &Coordinator{}
78+ c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
7879 rec.install(c)
7980
8081 c.handleURIChanged(ctx, "pane-1", wv, "spotify://track/123")
@@ -83,29 +84,50 @@ func TestHandleURIChanged_ExternalSchemeDeniedDoesNotLaunchOrGoBack(t *testing.T
8384 assert.Empty(t, rec.launched)
8485 }
8586
86-func TestHandleURIChanged_DuplicateExternalURIPromptsOnce(t *testing.T) {
87+func TestHandleURIChanged_OneOutstandingPromptPerPane(t *testing.T) {
8788 t.Parallel()
8889
8990 ctx := context.Background()
9091 wv := mocks.NewMockWebView(t)
91- wv.EXPECT().Stop(ctx).Return(nil).Twice()
92+ wv.EXPECT().Stop(ctx).Return(nil).Times(3)
9293
9394 rec := &externalLaunchRecorder{}
94- c := &Coordinator{}
95+ c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
9596 rec.install(c)
9697
97- uri := "claude://login/google-auth?code=abc"
98- c.handleURIChanged(ctx, "pane-1", wv, uri)
99- c.handleURIChanged(ctx, "pane-1", wv, uri)
100- assert.Len(t, rec.prompts, 1)
98+ // Alternating URIs must not queue more prompts while one is pending.
99+ c.handleURIChanged(ctx, "pane-1", wv, "claude://a")
100+ c.handleURIChanged(ctx, "pane-1", wv, "claude://b")
101+ c.handleURIChanged(ctx, "pane-1", wv, "claude://a")
102+ assert.Equal(t, []string{"claude://a"}, rec.prompts)
101103
102- // Once answered, a later identical request may prompt again.
104+ // Once answered, the pane may prompt again.
103105 rec.respond(false)
104106 wv.EXPECT().Stop(ctx).Return(nil).Once()
105- c.handleURIChanged(ctx, "pane-1", wv, uri)
107+ c.handleURIChanged(ctx, "pane-1", wv, "claude://b")
106108 assert.Len(t, rec.prompts, 2)
107109 }
108110
111+func TestHandleURIChanged_StalePromptAfterReleaseDoesNotLaunch(t *testing.T) {
112+ t.Parallel()
113+
114+ ctx := context.Background()
115+ wv := mocks.NewMockWebView(t)
116+ wv.EXPECT().Stop(ctx).Return(nil).Once()
117+
118+ rec := &externalLaunchRecorder{}
119+ c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
120+ rec.install(c)
121+
122+ c.handleURIChanged(ctx, "pane-1", wv, "claude://login?code=x")
123+ // Pane closed while the prompt was queued.
124+ c.clearExternalPrompt("pane-1")
125+ delete(c.webViews, "pane-1")
126+
127+ rec.respond(true)
128+ assert.Empty(t, rec.launched)
129+}
130+
109131 func TestHandleURIChanged_HTTPURIStillRecordsSPANavigation(t *testing.T) {
110132 t.Parallel()
111133
@@ -137,7 +159,7 @@ func TestHandleURIChanged_StopErrorDoesNotPanic(t *testing.T) {
137159 wv.EXPECT().Stop(ctx).Return(errors.New("stop failed")).Once()
138160
139161 rec := &externalLaunchRecorder{}
140- c := &Coordinator{}
162+ c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
141163 rec.install(c)
142164
143165 c.handleURIChanged(ctx, "pane-1", wv, "vscode://file/etc/passwd")
@@ -7,6 +7,7 @@ import (
7 7
8 "github.com/stretchr/testify/assert"8 "github.com/stretchr/testify/assert"
9 9
10+ "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/application/port/mocks"11 "github.com/bnema/dumber/internal/application/port/mocks"
11 "github.com/bnema/dumber/internal/domain/entity"12 "github.com/bnema/dumber/internal/domain/entity"
12 )13 )
@@ -52,7 +53,7 @@ func TestHandleURIChanged_ExternalSchemeLaunchesOnlyAfterConfirmation(t *testing
52 wv.EXPECT().Stop(ctx).Return(nil).Once()53 wv.EXPECT().Stop(ctx).Return(nil).Once()
53 54
54 rec := &externalLaunchRecorder{}55 rec := &externalLaunchRecorder{}
55- c := &Coordinator{}56+ c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
56 rec.install(c)57 rec.install(c)
57 58
58 uri := "claude://login/google-auth?code=abc"59 uri := "claude://login/google-auth?code=abc"
@@ -74,7 +75,7 @@ func TestHandleURIChanged_ExternalSchemeDeniedDoesNotLaunchOrGoBack(t *testing.T
74 wv.EXPECT().Stop(ctx).Return(nil).Once()75 wv.EXPECT().Stop(ctx).Return(nil).Once()
75 76
76 rec := &externalLaunchRecorder{}77 rec := &externalLaunchRecorder{}
77- c := &Coordinator{}78+ c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
78 rec.install(c)79 rec.install(c)
79 80
80 c.handleURIChanged(ctx, "pane-1", wv, "spotify://track/123")81 c.handleURIChanged(ctx, "pane-1", wv, "spotify://track/123")
@@ -83,29 +84,50 @@ func TestHandleURIChanged_ExternalSchemeDeniedDoesNotLaunchOrGoBack(t *testing.T
83 assert.Empty(t, rec.launched)84 assert.Empty(t, rec.launched)
84 }85 }
85 86
86-func TestHandleURIChanged_DuplicateExternalURIPromptsOnce(t *testing.T) {87+func TestHandleURIChanged_OneOutstandingPromptPerPane(t *testing.T) {
87 t.Parallel()88 t.Parallel()
88 89
89 ctx := context.Background()90 ctx := context.Background()
90 wv := mocks.NewMockWebView(t)91 wv := mocks.NewMockWebView(t)
91- wv.EXPECT().Stop(ctx).Return(nil).Twice()92+ wv.EXPECT().Stop(ctx).Return(nil).Times(3)
92 93
93 rec := &externalLaunchRecorder{}94 rec := &externalLaunchRecorder{}
94- c := &Coordinator{}95+ c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
95 rec.install(c)96 rec.install(c)
96 97
97- uri := "claude://login/google-auth?code=abc"98+ // Alternating URIs must not queue more prompts while one is pending.
98- c.handleURIChanged(ctx, "pane-1", wv, uri)99+ c.handleURIChanged(ctx, "pane-1", wv, "claude://a")
99- c.handleURIChanged(ctx, "pane-1", wv, uri)100+ c.handleURIChanged(ctx, "pane-1", wv, "claude://b")
100- assert.Len(t, rec.prompts, 1)101+ c.handleURIChanged(ctx, "pane-1", wv, "claude://a")
102+ assert.Equal(t, []string{"claude://a"}, rec.prompts)
101 103
102- // Once answered, a later identical request may prompt again.104+ // Once answered, the pane may prompt again.
103 rec.respond(false)105 rec.respond(false)
104 wv.EXPECT().Stop(ctx).Return(nil).Once()106 wv.EXPECT().Stop(ctx).Return(nil).Once()
105- c.handleURIChanged(ctx, "pane-1", wv, uri)107+ c.handleURIChanged(ctx, "pane-1", wv, "claude://b")
106 assert.Len(t, rec.prompts, 2)108 assert.Len(t, rec.prompts, 2)
107 }109 }
108 110
111+func TestHandleURIChanged_StalePromptAfterReleaseDoesNotLaunch(t *testing.T) {
112+ t.Parallel()
113+
114+ ctx := context.Background()
115+ wv := mocks.NewMockWebView(t)
116+ wv.EXPECT().Stop(ctx).Return(nil).Once()
117+
118+ rec := &externalLaunchRecorder{}
119+ c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
120+ rec.install(c)
121+
122+ c.handleURIChanged(ctx, "pane-1", wv, "claude://login?code=x")
123+ // Pane closed while the prompt was queued.
124+ c.clearExternalPrompt("pane-1")
125+ delete(c.webViews, "pane-1")
126+
127+ rec.respond(true)
128+ assert.Empty(t, rec.launched)
129+}
130+
109 func TestHandleURIChanged_HTTPURIStillRecordsSPANavigation(t *testing.T) {131 func TestHandleURIChanged_HTTPURIStillRecordsSPANavigation(t *testing.T) {
110 t.Parallel()132 t.Parallel()
111 133
@@ -137,7 +159,7 @@ func TestHandleURIChanged_StopErrorDoesNotPanic(t *testing.T) {
137 wv.EXPECT().Stop(ctx).Return(errors.New("stop failed")).Once()159 wv.EXPECT().Stop(ctx).Return(errors.New("stop failed")).Once()
138 160
139 rec := &externalLaunchRecorder{}161 rec := &externalLaunchRecorder{}
140- c := &Coordinator{}162+ c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
141 rec.install(c)163 rec.install(c)
142 164
143 c.handleURIChanged(ctx, "pane-1", wv, "vscode://file/etc/passwd")165 c.handleURIChanged(ctx, "pane-1", wv, "vscode://file/etc/passwd")

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close