detached 230caa1

fix(navigation): confirm external scheme launches instead of looping back

OAuth flows ending with a redirect to claude://, vscode:// and similar were stopped and navigated back, which re-issued the OAuth redirect in a loop and never reached the desktop app. The pane now stops and asks the user, through the window permission popup, whether to open the external application.
commit
230caa1c5c590124e399ab03e4e3ed1bda8fc7a8
author
brice · · 2026-10-15 06:05 UTC
parent
44cde30
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +218 -64
M internal/ui/app.go +21 -0
@@ -3207,6 +3207,7 @@ func (a *App) initContentCoordinator(
32073207 // Wire external URL launcher (e.g. xdg-open for vscode://, spotify://)
32083208 if a.deps.LaunchExternalURL != nil {
32093209 a.contentCoord.SetOnLaunchExternalURL(a.deps.LaunchExternalURL)
3210+ a.contentCoord.SetOnConfirmExternalLaunch(a.confirmExternalLaunch)
32103211 }
32113212
32123213 a.contentCoord.SetOnTouchpadNavigationGesture(func(paneID entity.PaneID, gesture entity.TouchpadNavigationGesture) {
@@ -3601,6 +3602,26 @@ func (a *App) wireKeyboardActions() {
36013602 })
36023603 }
36033604
3605+// externalLaunchConfirmer is implemented by the window permission dialog.
3606+type externalLaunchConfirmer interface {
3607+ ShowExternalLaunchConfirm(ctx context.Context, uri string, callback func(allowed bool))
3608+}
3609+
3610+// confirmExternalLaunch asks, in the window owning paneID, whether uri may be
3611+// opened by an external application. Without a window it denies.
3612+func (a *App) confirmExternalLaunch(paneID entity.PaneID, uri string, respond func(allowed bool)) {
3613+ ctx := context.Background()
3614+ if a.deps != nil && a.deps.Ctx != nil {
3615+ ctx = a.deps.Ctx
3616+ }
3617+ confirmer, ok := a.permissionDialogForPane(paneID).(externalLaunchConfirmer)
3618+ if !ok {
3619+ respond(false)
3620+ return
3621+ }
3622+ confirmer.ShowExternalLaunchConfirm(ctx, uri, respond)
3623+}
3624+
36043625 // permissionDialogForPane returns the permission dialog of the window owning the
36053626 // pane (including floating panes), or nil when no window or dialog is available.
36063627 func (a *App) permissionDialogForPane(paneID entity.PaneID) port.PermissionDialogPresenter {
@@ -3207,6 +3207,7 @@ func (a *App) initContentCoordinator(
3207 // Wire external URL launcher (e.g. xdg-open for vscode://, spotify://)3207 // Wire external URL launcher (e.g. xdg-open for vscode://, spotify://)
3208 if a.deps.LaunchExternalURL != nil {3208 if a.deps.LaunchExternalURL != nil {
3209 a.contentCoord.SetOnLaunchExternalURL(a.deps.LaunchExternalURL)3209 a.contentCoord.SetOnLaunchExternalURL(a.deps.LaunchExternalURL)
3210+ a.contentCoord.SetOnConfirmExternalLaunch(a.confirmExternalLaunch)
3210 }3211 }
3211 3212
3212 a.contentCoord.SetOnTouchpadNavigationGesture(func(paneID entity.PaneID, gesture entity.TouchpadNavigationGesture) {3213 a.contentCoord.SetOnTouchpadNavigationGesture(func(paneID entity.PaneID, gesture entity.TouchpadNavigationGesture) {
@@ -3601,6 +3602,26 @@ func (a *App) wireKeyboardActions() {
3601 })3602 })
3602 }3603 }
3603 3604
3605+// externalLaunchConfirmer is implemented by the window permission dialog.
3606+type externalLaunchConfirmer interface {
3607+ ShowExternalLaunchConfirm(ctx context.Context, uri string, callback func(allowed bool))
3608+}
3609+
3610+// confirmExternalLaunch asks, in the window owning paneID, whether uri may be
3611+// opened by an external application. Without a window it denies.
3612+func (a *App) confirmExternalLaunch(paneID entity.PaneID, uri string, respond func(allowed bool)) {
3613+ ctx := context.Background()
3614+ if a.deps != nil && a.deps.Ctx != nil {
3615+ ctx = a.deps.Ctx
3616+ }
3617+ confirmer, ok := a.permissionDialogForPane(paneID).(externalLaunchConfirmer)
3618+ if !ok {
3619+ respond(false)
3620+ return
3621+ }
3622+ confirmer.ShowExternalLaunchConfirm(ctx, uri, respond)
3623+}
3624+
3604 // permissionDialogForPane returns the permission dialog of the window owning the3625 // permissionDialogForPane returns the permission dialog of the window owning the
3605 // pane (including floating panes), or nil when no window or dialog is available.3626 // pane (including floating panes), or nil when no window or dialog is available.
3606 func (a *App) permissionDialogForPane(paneID entity.PaneID) port.PermissionDialogPresenter {3627 func (a *App) permissionDialogForPane(paneID entity.PaneID) port.PermissionDialogPresenter {
M internal/ui/coordinator/content/coordinator.go +15 -0
@@ -134,6 +134,15 @@ type Coordinator struct {
134134 // Callback to open a URL with the system's default handler (e.g. xdg-open).
135135 // Used for external URL schemes like vscode://, spotify://, etc.
136136 onLaunchExternalURL func(uri string)
137+ // Callback asking the user to confirm an external scheme launch for a pane.
138+ // respond must be called exactly once on the GTK thread.
139+ onConfirmExternalLaunch func(paneID entity.PaneID, uri string, respond func(allowed bool))
140+
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.
144+ externalPromptMu sync.Mutex
145+ externalPrompts map[entity.PaneID]string
137146 }
138147
139148 type pendingThemeUpdate struct {
@@ -307,6 +316,12 @@ func (c *Coordinator) SetOnLaunchExternalURL(fn func(uri string)) {
307316 c.onLaunchExternalURL = fn
308317 }
309318
319+// SetOnConfirmExternalLaunch sets the callback that asks the user whether an
320+// external URL scheme reached by page navigation may be opened.
321+func (c *Coordinator) SetOnConfirmExternalLaunch(fn func(paneID entity.PaneID, uri string, respond func(allowed bool))) {
322+ c.onConfirmExternalLaunch = fn
323+}
324+
310325 // SetSettingsApplier sets the engine settings applier for config hot-reload.
311326 func (c *Coordinator) SetSettingsApplier(sa port.SettingsApplier) {
312327 c.settingsApplier = sa
@@ -134,6 +134,15 @@ type Coordinator struct {
134 // Callback to open a URL with the system's default handler (e.g. xdg-open).134 // Callback to open a URL with the system's default handler (e.g. xdg-open).
135 // Used for external URL schemes like vscode://, spotify://, etc.135 // Used for external URL schemes like vscode://, spotify://, etc.
136 onLaunchExternalURL func(uri string)136 onLaunchExternalURL func(uri string)
137+ // Callback asking the user to confirm an external scheme launch for a pane.
138+ // respond must be called exactly once on the GTK thread.
139+ onConfirmExternalLaunch func(paneID entity.PaneID, uri string, respond func(allowed bool))
140+
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.
144+ externalPromptMu sync.Mutex
145+ externalPrompts map[entity.PaneID]string
137 }146 }
138 147
139 type pendingThemeUpdate struct {148 type pendingThemeUpdate struct {
@@ -307,6 +316,12 @@ func (c *Coordinator) SetOnLaunchExternalURL(fn func(uri string)) {
307 c.onLaunchExternalURL = fn316 c.onLaunchExternalURL = fn
308 }317 }
309 318
319+// SetOnConfirmExternalLaunch sets the callback that asks the user whether an
320+// external URL scheme reached by page navigation may be opened.
321+func (c *Coordinator) SetOnConfirmExternalLaunch(fn func(paneID entity.PaneID, uri string, respond func(allowed bool))) {
322+ c.onConfirmExternalLaunch = fn
323+}
324+
310 // SetSettingsApplier sets the engine settings applier for config hot-reload.325 // SetSettingsApplier sets the engine settings applier for config hot-reload.
311 func (c *Coordinator) SetSettingsApplier(sa port.SettingsApplier) {326 func (c *Coordinator) SetSettingsApplier(sa port.SettingsApplier) {
312 c.settingsApplier = sa327 c.settingsApplier = sa
M internal/ui/coordinator/content/navigation.go +44 -11
@@ -387,6 +387,42 @@ 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) {
393+ log := logging.FromContext(ctx)
394+ if c.onConfirmExternalLaunch == nil || c.onLaunchExternalURL == nil {
395+ log.Info().Str("pane_id", string(paneID)).Msg("external scheme launch unavailable; blocked")
396+ return
397+ }
398+
399+ c.externalPromptMu.Lock()
400+ if c.externalPrompts[paneID] == uri {
401+ c.externalPromptMu.Unlock()
402+ return
403+ }
404+ if c.externalPrompts == nil {
405+ c.externalPrompts = make(map[entity.PaneID]string)
406+ }
407+ c.externalPrompts[paneID] = uri
408+ c.externalPromptMu.Unlock()
409+
410+ c.onConfirmExternalLaunch(paneID, uri, func(allowed bool) {
411+ c.externalPromptMu.Lock()
412+ if c.externalPrompts[paneID] == uri {
413+ delete(c.externalPrompts, paneID)
414+ }
415+ c.externalPromptMu.Unlock()
416+
417+ log.Info().Str("pane_id", string(paneID)).Bool("allowed", allowed).
418+ Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
419+ Msg("external scheme launch decision")
420+ if allowed {
421+ c.onLaunchExternalURL(uri)
422+ }
423+ })
424+}
425+
390426 // handleURIChanged handles URI changes from WebKit, including external scheme detection
391427 // and SPA navigation tracking.
392428 func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID, wv port.WebView, uri string) {
@@ -401,21 +437,18 @@ func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID
401437 isExternal := urlutil.IsExternalScheme(uri)
402438
403439 if isExternal {
404- log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme URI change blocked")
440+ log.Info().Str("pane_id", string(paneID)).Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
441+ Msg("external scheme URI change intercepted")
405442
406- // External schemes must only be launched from WebKit's navigation-policy path,
407- // where NavigationAction.IsUserGesture is available and verified. URI changes can
408- // be driven by page script or redirects, so this path only cleans up WebKit state.
409443 if err := wv.Stop(ctx); err != nil {
410- log.Warn().Str("pane_id", string(paneID)).Str("uri", uri).Err(err).Msg("stop webview for blocked external URL")
444+ log.Warn().Str("pane_id", string(paneID)).Err(err).Msg("stop webview for external URL")
411445 }
412446
413- // Navigate back to avoid stale URI in omnibox/history.
414- if wv.CanGoBack() {
415- if err := wv.GoBack(ctx); err != nil {
416- log.Warn().Str("pane_id", string(paneID)).Err(err).Msg("GoBack after blocked external URL")
417- }
418- }
447+ // URI changes can be driven by page script or redirects (OAuth flows end
448+ // with a redirect to claude://, vscode://, ...), so the launch is never
449+ // automatic: the user must confirm it. Do not navigate back: going back
450+ // to an OAuth endpoint re-issues the redirect and loops.
451+ c.promptExternalLaunch(ctx, paneID, uri)
419452 return
420453 }
421454
@@ -387,6 +387,42 @@ 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 external
391+// URI may be handed to the system handler.
392+func (c *Coordinator) promptExternalLaunch(ctx context.Context, paneID entity.PaneID, uri string) {
393+ log := logging.FromContext(ctx)
394+ if c.onConfirmExternalLaunch == nil || c.onLaunchExternalURL == nil {
395+ log.Info().Str("pane_id", string(paneID)).Msg("external scheme launch unavailable; blocked")
396+ return
397+ }
398+
399+ c.externalPromptMu.Lock()
400+ if c.externalPrompts[paneID] == uri {
401+ c.externalPromptMu.Unlock()
402+ return
403+ }
404+ if c.externalPrompts == nil {
405+ c.externalPrompts = make(map[entity.PaneID]string)
406+ }
407+ c.externalPrompts[paneID] = uri
408+ c.externalPromptMu.Unlock()
409+
410+ c.onConfirmExternalLaunch(paneID, uri, func(allowed bool) {
411+ c.externalPromptMu.Lock()
412+ if c.externalPrompts[paneID] == uri {
413+ delete(c.externalPrompts, paneID)
414+ }
415+ c.externalPromptMu.Unlock()
416+
417+ log.Info().Str("pane_id", string(paneID)).Bool("allowed", allowed).
418+ Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
419+ Msg("external scheme launch decision")
420+ if allowed {
421+ c.onLaunchExternalURL(uri)
422+ }
423+ })
424+}
425+
390 // handleURIChanged handles URI changes from WebKit, including external scheme detection426 // handleURIChanged handles URI changes from WebKit, including external scheme detection
391 // and SPA navigation tracking.427 // and SPA navigation tracking.
392 func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID, wv port.WebView, uri string) {428 func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID, wv port.WebView, uri string) {
@@ -401,21 +437,18 @@ func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID
401 isExternal := urlutil.IsExternalScheme(uri)437 isExternal := urlutil.IsExternalScheme(uri)
402 438
403 if isExternal {439 if isExternal {
404- log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme URI change blocked")440+ log.Info().Str("pane_id", string(paneID)).Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
441+ Msg("external scheme URI change intercepted")
405 442
406- // External schemes must only be launched from WebKit's navigation-policy path,
407- // where NavigationAction.IsUserGesture is available and verified. URI changes can
408- // be driven by page script or redirects, so this path only cleans up WebKit state.
409 if err := wv.Stop(ctx); err != nil {443 if err := wv.Stop(ctx); err != nil {
410- log.Warn().Str("pane_id", string(paneID)).Str("uri", uri).Err(err).Msg("stop webview for blocked external URL")444+ log.Warn().Str("pane_id", string(paneID)).Err(err).Msg("stop webview for external URL")
411 }445 }
412 446
413- // Navigate back to avoid stale URI in omnibox/history.447+ // URI changes can be driven by page script or redirects (OAuth flows end
414- if wv.CanGoBack() {448+ // with a redirect to claude://, vscode://, ...), so the launch is never
415- if err := wv.GoBack(ctx); err != nil {449+ // automatic: the user must confirm it. Do not navigate back: going back
416- log.Warn().Str("pane_id", string(paneID)).Err(err).Msg("GoBack after blocked external URL")450+ // to an OAuth endpoint re-issues the redirect and loops.
417- }451+ c.promptExternalLaunch(ctx, paneID, uri)
418- }
419 return452 return
420 }453 }
421 454
M internal/ui/coordinator/content/navigation_security_test.go +77 -51
@@ -11,47 +11,99 @@ import (
1111 "github.com/bnema/dumber/internal/domain/entity"
1212 )
1313
14-func TestHandleURIChanged_BlocksExternalSchemeWithoutLaunching(t *testing.T) {
14+type externalLaunchRecorder struct {
15+ prompts []string
16+ respond func(bool)
17+ launched []string
18+}
19+
20+func (r *externalLaunchRecorder) install(c *Coordinator) {
21+ c.onLaunchExternalURL = func(uri string) {
22+ r.launched = append(r.launched, uri)
23+ }
24+ c.onConfirmExternalLaunch = func(_ entity.PaneID, uri string, respond func(bool)) {
25+ r.prompts = append(r.prompts, uri)
26+ r.respond = respond
27+ }
28+}
29+
30+func TestHandleURIChanged_ExternalSchemeWithoutConfirmerIsBlocked(t *testing.T) {
1531 t.Parallel()
1632
1733 ctx := context.Background()
18- paneID := entity.PaneID("pane-1")
1934 wv := mocks.NewMockWebView(t)
2035 wv.EXPECT().Stop(ctx).Return(nil).Once()
21- wv.EXPECT().CanGoBack().Return(false).Once()
2236
2337 launchCount := 0
2438 c := &Coordinator{
25- onLaunchExternalURL: func(uri string) {
26- launchCount++
27- },
39+ onLaunchExternalURL: func(string) { launchCount++ },
2840 }
2941
30- c.handleURIChanged(ctx, paneID, wv, "vscode://file/etc/passwd")
42+ c.handleURIChanged(ctx, "pane-1", wv, "vscode://file/etc/passwd")
3143
3244 assert.Equal(t, 0, launchCount)
3345 }
3446
35-func TestHandleURIChanged_BlocksExternalSchemeRedirectAndRestoresPreviousPage(t *testing.T) {
47+func TestHandleURIChanged_ExternalSchemeLaunchesOnlyAfterConfirmation(t *testing.T) {
3648 t.Parallel()
3749
3850 ctx := context.Background()
39- paneID := entity.PaneID("pane-1")
4051 wv := mocks.NewMockWebView(t)
4152 wv.EXPECT().Stop(ctx).Return(nil).Once()
42- wv.EXPECT().CanGoBack().Return(true).Once()
43- wv.EXPECT().GoBack(ctx).Return(nil).Once()
4453
45- launchCount := 0
46- c := &Coordinator{
47- onLaunchExternalURL: func(uri string) {
48- launchCount++
49- },
50- }
54+ rec := &externalLaunchRecorder{}
55+ c := &Coordinator{}
56+ rec.install(c)
5157
52- c.handleURIChanged(ctx, paneID, wv, "spotify://track/123")
58+ uri := "claude://login/google-auth?code=abc"
59+ c.handleURIChanged(ctx, "pane-1", wv, uri)
5360
54- assert.Equal(t, 0, launchCount)
61+ assert.Equal(t, []string{uri}, rec.prompts)
62+ assert.Empty(t, rec.launched)
63+
64+ rec.respond(true)
65+ assert.Equal(t, []string{uri}, rec.launched)
66+}
67+
68+func TestHandleURIChanged_ExternalSchemeDeniedDoesNotLaunchOrGoBack(t *testing.T) {
69+ t.Parallel()
70+
71+ ctx := context.Background()
72+ wv := mocks.NewMockWebView(t)
73+ // GoBack must not be called: going back to an OAuth endpoint loops.
74+ wv.EXPECT().Stop(ctx).Return(nil).Once()
75+
76+ rec := &externalLaunchRecorder{}
77+ c := &Coordinator{}
78+ rec.install(c)
79+
80+ c.handleURIChanged(ctx, "pane-1", wv, "spotify://track/123")
81+ rec.respond(false)
82+
83+ assert.Empty(t, rec.launched)
84+}
85+
86+func TestHandleURIChanged_DuplicateExternalURIPromptsOnce(t *testing.T) {
87+ t.Parallel()
88+
89+ ctx := context.Background()
90+ wv := mocks.NewMockWebView(t)
91+ wv.EXPECT().Stop(ctx).Return(nil).Twice()
92+
93+ rec := &externalLaunchRecorder{}
94+ c := &Coordinator{}
95+ rec.install(c)
96+
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)
101+
102+ // Once answered, a later identical request may prompt again.
103+ rec.respond(false)
104+ wv.EXPECT().Stop(ctx).Return(nil).Once()
105+ c.handleURIChanged(ctx, "pane-1", wv, uri)
106+ assert.Len(t, rec.prompts, 2)
55107 }
56108
57109 func TestHandleURIChanged_HTTPURIStillRecordsSPANavigation(t *testing.T) {
@@ -81,41 +133,15 @@ func TestHandleURIChanged_StopErrorDoesNotPanic(t *testing.T) {
81133 t.Parallel()
82134
83135 ctx := context.Background()
84- paneID := entity.PaneID("pane-1")
85136 wv := mocks.NewMockWebView(t)
86137 wv.EXPECT().Stop(ctx).Return(errors.New("stop failed")).Once()
87- wv.EXPECT().CanGoBack().Return(false).Once()
88-
89- launchCount := 0
90- c := &Coordinator{
91- onLaunchExternalURL: func(uri string) {
92- launchCount++
93- },
94- }
95-
96- c.handleURIChanged(ctx, paneID, wv, "vscode://file/etc/passwd")
97-
98- assert.Equal(t, 0, launchCount)
99-}
100-
101-func TestHandleURIChanged_GoBackErrorDoesNotPanic(t *testing.T) {
102- t.Parallel()
103-
104- ctx := context.Background()
105- paneID := entity.PaneID("pane-1")
106- wv := mocks.NewMockWebView(t)
107- wv.EXPECT().Stop(ctx).Return(nil).Once()
108- wv.EXPECT().CanGoBack().Return(true).Once()
109- wv.EXPECT().GoBack(ctx).Return(errors.New("goback failed")).Once()
110138
111- launchCount := 0
112- c := &Coordinator{
113- onLaunchExternalURL: func(uri string) {
114- launchCount++
115- },
116- }
139+ rec := &externalLaunchRecorder{}
140+ c := &Coordinator{}
141+ rec.install(c)
117142
118- c.handleURIChanged(ctx, paneID, wv, "spotify://track/123")
143+ c.handleURIChanged(ctx, "pane-1", wv, "vscode://file/etc/passwd")
119144
120- assert.Equal(t, 0, launchCount)
145+ assert.Len(t, rec.prompts, 1)
146+ assert.Empty(t, rec.launched)
121147 }
@@ -11,47 +11,99 @@ import (
11 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
12 )12 )
13 13
14-func TestHandleURIChanged_BlocksExternalSchemeWithoutLaunching(t *testing.T) {14+type externalLaunchRecorder struct {
15+ prompts []string
16+ respond func(bool)
17+ launched []string
18+}
19+
20+func (r *externalLaunchRecorder) install(c *Coordinator) {
21+ c.onLaunchExternalURL = func(uri string) {
22+ r.launched = append(r.launched, uri)
23+ }
24+ c.onConfirmExternalLaunch = func(_ entity.PaneID, uri string, respond func(bool)) {
25+ r.prompts = append(r.prompts, uri)
26+ r.respond = respond
27+ }
28+}
29+
30+func TestHandleURIChanged_ExternalSchemeWithoutConfirmerIsBlocked(t *testing.T) {
15 t.Parallel()31 t.Parallel()
16 32
17 ctx := context.Background()33 ctx := context.Background()
18- paneID := entity.PaneID("pane-1")
19 wv := mocks.NewMockWebView(t)34 wv := mocks.NewMockWebView(t)
20 wv.EXPECT().Stop(ctx).Return(nil).Once()35 wv.EXPECT().Stop(ctx).Return(nil).Once()
21- wv.EXPECT().CanGoBack().Return(false).Once()
22 36
23 launchCount := 037 launchCount := 0
24 c := &Coordinator{38 c := &Coordinator{
25- onLaunchExternalURL: func(uri string) {39+ onLaunchExternalURL: func(string) { launchCount++ },
26- launchCount++
27- },
28 }40 }
29 41
30- c.handleURIChanged(ctx, paneID, wv, "vscode://file/etc/passwd")42+ c.handleURIChanged(ctx, "pane-1", wv, "vscode://file/etc/passwd")
31 43
32 assert.Equal(t, 0, launchCount)44 assert.Equal(t, 0, launchCount)
33 }45 }
34 46
35-func TestHandleURIChanged_BlocksExternalSchemeRedirectAndRestoresPreviousPage(t *testing.T) {47+func TestHandleURIChanged_ExternalSchemeLaunchesOnlyAfterConfirmation(t *testing.T) {
36 t.Parallel()48 t.Parallel()
37 49
38 ctx := context.Background()50 ctx := context.Background()
39- paneID := entity.PaneID("pane-1")
40 wv := mocks.NewMockWebView(t)51 wv := mocks.NewMockWebView(t)
41 wv.EXPECT().Stop(ctx).Return(nil).Once()52 wv.EXPECT().Stop(ctx).Return(nil).Once()
42- wv.EXPECT().CanGoBack().Return(true).Once()
43- wv.EXPECT().GoBack(ctx).Return(nil).Once()
44 53
45- launchCount := 054+ rec := &externalLaunchRecorder{}
46- c := &Coordinator{55+ c := &Coordinator{}
47- onLaunchExternalURL: func(uri string) {56+ rec.install(c)
48- launchCount++
49- },
50- }
51 57
52- c.handleURIChanged(ctx, paneID, wv, "spotify://track/123")58+ uri := "claude://login/google-auth?code=abc"
59+ c.handleURIChanged(ctx, "pane-1", wv, uri)
53 60
54- assert.Equal(t, 0, launchCount)61+ assert.Equal(t, []string{uri}, rec.prompts)
62+ assert.Empty(t, rec.launched)
63+
64+ rec.respond(true)
65+ assert.Equal(t, []string{uri}, rec.launched)
66+}
67+
68+func TestHandleURIChanged_ExternalSchemeDeniedDoesNotLaunchOrGoBack(t *testing.T) {
69+ t.Parallel()
70+
71+ ctx := context.Background()
72+ wv := mocks.NewMockWebView(t)
73+ // GoBack must not be called: going back to an OAuth endpoint loops.
74+ wv.EXPECT().Stop(ctx).Return(nil).Once()
75+
76+ rec := &externalLaunchRecorder{}
77+ c := &Coordinator{}
78+ rec.install(c)
79+
80+ c.handleURIChanged(ctx, "pane-1", wv, "spotify://track/123")
81+ rec.respond(false)
82+
83+ assert.Empty(t, rec.launched)
84+}
85+
86+func TestHandleURIChanged_DuplicateExternalURIPromptsOnce(t *testing.T) {
87+ t.Parallel()
88+
89+ ctx := context.Background()
90+ wv := mocks.NewMockWebView(t)
91+ wv.EXPECT().Stop(ctx).Return(nil).Twice()
92+
93+ rec := &externalLaunchRecorder{}
94+ c := &Coordinator{}
95+ rec.install(c)
96+
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)
101+
102+ // Once answered, a later identical request may prompt again.
103+ rec.respond(false)
104+ wv.EXPECT().Stop(ctx).Return(nil).Once()
105+ c.handleURIChanged(ctx, "pane-1", wv, uri)
106+ assert.Len(t, rec.prompts, 2)
55 }107 }
56 108
57 func TestHandleURIChanged_HTTPURIStillRecordsSPANavigation(t *testing.T) {109 func TestHandleURIChanged_HTTPURIStillRecordsSPANavigation(t *testing.T) {
@@ -81,41 +133,15 @@ func TestHandleURIChanged_StopErrorDoesNotPanic(t *testing.T) {
81 t.Parallel()133 t.Parallel()
82 134
83 ctx := context.Background()135 ctx := context.Background()
84- paneID := entity.PaneID("pane-1")
85 wv := mocks.NewMockWebView(t)136 wv := mocks.NewMockWebView(t)
86 wv.EXPECT().Stop(ctx).Return(errors.New("stop failed")).Once()137 wv.EXPECT().Stop(ctx).Return(errors.New("stop failed")).Once()
87- wv.EXPECT().CanGoBack().Return(false).Once()
88-
89- launchCount := 0
90- c := &Coordinator{
91- onLaunchExternalURL: func(uri string) {
92- launchCount++
93- },
94- }
95-
96- c.handleURIChanged(ctx, paneID, wv, "vscode://file/etc/passwd")
97-
98- assert.Equal(t, 0, launchCount)
99-}
100-
101-func TestHandleURIChanged_GoBackErrorDoesNotPanic(t *testing.T) {
102- t.Parallel()
103-
104- ctx := context.Background()
105- paneID := entity.PaneID("pane-1")
106- wv := mocks.NewMockWebView(t)
107- wv.EXPECT().Stop(ctx).Return(nil).Once()
108- wv.EXPECT().CanGoBack().Return(true).Once()
109- wv.EXPECT().GoBack(ctx).Return(errors.New("goback failed")).Once()
110 138
111- launchCount := 0139+ rec := &externalLaunchRecorder{}
112- c := &Coordinator{140+ c := &Coordinator{}
113- onLaunchExternalURL: func(uri string) {141+ rec.install(c)
114- launchCount++
115- },
116- }
117 142
118- c.handleURIChanged(ctx, paneID, wv, "spotify://track/123")143+ c.handleURIChanged(ctx, "pane-1", wv, "vscode://file/etc/passwd")
119 144
120- assert.Equal(t, 0, launchCount)145+ assert.Len(t, rec.prompts, 1)
146+ assert.Empty(t, rec.launched)
121 }147 }
M internal/ui/dialog/permission.go +38 -2
@@ -4,6 +4,7 @@ package dialog
44 import (
55 "context"
66 "fmt"
7+ neturl "net/url"
78 "strings"
89 "sync"
910
@@ -23,6 +24,11 @@ type permissionDialogRequest struct {
2324 permTypes []entity.PermissionType
2425 metadata entity.PermissionMetadata
2526 callback func(result port.PermissionDialogResult)
27+
28+ // heading and body, when set, replace the permission-derived text. Used
29+ // for one-shot confirmations that share the popup and its queue.
30+ heading string
31+ body string
2632 }
2733
2834 // PermissionDialog implements the port.PermissionDialogPresenter interface.
@@ -67,6 +73,33 @@ func (d *PermissionDialog) ShowPermissionDialog(
6773 d.showRequest(req)
6874 }
6975
76+// ShowExternalLaunchConfirm asks whether an external application may open uri.
77+// The prompt shares the permission popup queue and is never persistable.
78+func (d *PermissionDialog) ShowExternalLaunchConfirm(ctx context.Context, uri string, callback func(allowed bool)) {
79+ req := permissionDialogRequest{
80+ ctx: ctx,
81+ heading: "Open External Application?",
82+ body: externalLaunchBody(uri),
83+ callback: func(result port.PermissionDialogResult) {
84+ callback(result.Allowed)
85+ },
86+ }
87+ if !d.enqueueOrStart(req) {
88+ return
89+ }
90+ d.showRequest(req)
91+}
92+
93+// externalLaunchBody names only the scheme and host so OAuth codes in the
94+// query string are never displayed.
95+func externalLaunchBody(uri string) string {
96+ target := uri
97+ if parsed, err := neturl.Parse(uri); err == nil && parsed.Scheme != "" {
98+ target = parsed.Scheme + "://" + parsed.Host
99+ }
100+ return fmt.Sprintf("This page wants to open %s in an external application.", target)
101+}
102+
70103 func (d *PermissionDialog) enqueueOrStart(req permissionDialogRequest) bool {
71104 d.mu.Lock()
72105 defer d.mu.Unlock()
@@ -97,8 +130,11 @@ func (d *PermissionDialog) showRequest(req permissionDialogRequest) {
97130 }
98131
99132 // Build dialog text
100- heading := d.buildHeading(permTypes)
101- body := d.buildBody(origin, permTypes, metadata)
133+ heading, body := req.heading, req.body
134+ if heading == "" {
135+ heading = d.buildHeading(permTypes)
136+ body = d.buildBody(origin, permTypes, metadata)
137+ }
102138
103139 isDataAccess := parsePermFlags(permTypes).dataAccess
104140 if isDataAccess {
@@ -4,6 +4,7 @@ package dialog
4 import (4 import (
5 "context"5 "context"
6 "fmt"6 "fmt"
7+ neturl "net/url"
7 "strings"8 "strings"
8 "sync"9 "sync"
9 10
@@ -23,6 +24,11 @@ type permissionDialogRequest struct {
23 permTypes []entity.PermissionType24 permTypes []entity.PermissionType
24 metadata entity.PermissionMetadata25 metadata entity.PermissionMetadata
25 callback func(result port.PermissionDialogResult)26 callback func(result port.PermissionDialogResult)
27+
28+ // heading and body, when set, replace the permission-derived text. Used
29+ // for one-shot confirmations that share the popup and its queue.
30+ heading string
31+ body string
26 }32 }
27 33
28 // PermissionDialog implements the port.PermissionDialogPresenter interface.34 // PermissionDialog implements the port.PermissionDialogPresenter interface.
@@ -67,6 +73,33 @@ func (d *PermissionDialog) ShowPermissionDialog(
67 d.showRequest(req)73 d.showRequest(req)
68 }74 }
69 75
76+// ShowExternalLaunchConfirm asks whether an external application may open uri.
77+// The prompt shares the permission popup queue and is never persistable.
78+func (d *PermissionDialog) ShowExternalLaunchConfirm(ctx context.Context, uri string, callback func(allowed bool)) {
79+ req := permissionDialogRequest{
80+ ctx: ctx,
81+ heading: "Open External Application?",
82+ body: externalLaunchBody(uri),
83+ callback: func(result port.PermissionDialogResult) {
84+ callback(result.Allowed)
85+ },
86+ }
87+ if !d.enqueueOrStart(req) {
88+ return
89+ }
90+ d.showRequest(req)
91+}
92+
93+// externalLaunchBody names only the scheme and host so OAuth codes in the
94+// query string are never displayed.
95+func externalLaunchBody(uri string) string {
96+ target := uri
97+ if parsed, err := neturl.Parse(uri); err == nil && parsed.Scheme != "" {
98+ target = parsed.Scheme + "://" + parsed.Host
99+ }
100+ return fmt.Sprintf("This page wants to open %s in an external application.", target)
101+}
102+
70 func (d *PermissionDialog) enqueueOrStart(req permissionDialogRequest) bool {103 func (d *PermissionDialog) enqueueOrStart(req permissionDialogRequest) bool {
71 d.mu.Lock()104 d.mu.Lock()
72 defer d.mu.Unlock()105 defer d.mu.Unlock()
@@ -97,8 +130,11 @@ func (d *PermissionDialog) showRequest(req permissionDialogRequest) {
97 }130 }
98 131
99 // Build dialog text132 // Build dialog text
100- heading := d.buildHeading(permTypes)133+ heading, body := req.heading, req.body
101- body := d.buildBody(origin, permTypes, metadata)134+ if heading == "" {
135+ heading = d.buildHeading(permTypes)
136+ body = d.buildBody(origin, permTypes, metadata)
137+ }
102 138
103 isDataAccess := parsePermFlags(permTypes).dataAccess139 isDataAccess := parsePermFlags(permTypes).dataAccess
104 if isDataAccess {140 if isDataAccess {
M internal/ui/dialog/permission_test.go +23 -0
@@ -255,3 +255,26 @@ func TestPermissionDialog_PassesPersistableToPopup(t *testing.T) {
255255 })
256256 }
257257 }
258+
259+func TestPermissionDialog_ExternalLaunchConfirmHidesQueryAndIsNotPersistable(t *testing.T) {
260+ popup := dialogmocks.NewMockPermissionPopup(t)
261+ var body string
262+ var persistable bool
263+ var cb func(allowed, persistent bool)
264+ popup.EXPECT().
265+ Show(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
266+ Run(func(_ context.Context, _ string, b string, p bool, c func(allowed, persistent bool)) {
267+ body, persistable, cb = b, p, c
268+ }).Once()
269+
270+ d := &PermissionDialog{popup: popup}
271+ var allowed bool
272+ d.ShowExternalLaunchConfirm(context.Background(), "claude://login/google-auth?code=secret", func(a bool) { allowed = a })
273+
274+ assert.Contains(t, body, "claude://login")
275+ assert.NotContains(t, body, "secret")
276+ assert.False(t, persistable)
277+
278+ cb(true, false)
279+ assert.True(t, allowed)
280+}
@@ -255,3 +255,26 @@ func TestPermissionDialog_PassesPersistableToPopup(t *testing.T) {
255 })255 })
256 }256 }
257 }257 }
258+
259+func TestPermissionDialog_ExternalLaunchConfirmHidesQueryAndIsNotPersistable(t *testing.T) {
260+ popup := dialogmocks.NewMockPermissionPopup(t)
261+ var body string
262+ var persistable bool
263+ var cb func(allowed, persistent bool)
264+ popup.EXPECT().
265+ Show(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
266+ Run(func(_ context.Context, _ string, b string, p bool, c func(allowed, persistent bool)) {
267+ body, persistable, cb = b, p, c
268+ }).Once()
269+
270+ d := &PermissionDialog{popup: popup}
271+ var allowed bool
272+ d.ShowExternalLaunchConfirm(context.Background(), "claude://login/google-auth?code=secret", func(a bool) { allowed = a })
273+
274+ assert.Contains(t, body, "claude://login")
275+ assert.NotContains(t, body, "secret")
276+ assert.False(t, persistable)
277+
278+ cb(true, false)
279+ assert.True(t, allowed)
280+}

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