detached 646bc9f

fix(ui): address external link review findings

commit
646bc9fde99b407e6459cba39462dfe9bb205742
author
brice · · 2026-08-13 07:01 UTC
parent
f108dcf
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +27 -11
M cmd/dumber/main.go +1 -1
@@ -298,7 +298,7 @@ func runGUI(cfg *config.Config, timing startupTiming) int {
298298 if relaunchSetter, ok := engine.(port.AlreadyRunningAppRelaunchHandlerSetter); ok {
299299 relaunchSetter.SetAlreadyRunningAppRelaunchHandler(func(url string) {
300300 if err := app.OpenExternalURL(ctx, url); err != nil {
301- log.Warn().Err(err).Str("url", url).Msg("failed to open relaunch browser window")
301+ log.Warn().Err(err).Str("url", url).Msg("failed to open relaunch external URL")
302302 }
303303 })
304304 }
@@ -298,7 +298,7 @@ func runGUI(cfg *config.Config, timing startupTiming) int {
298 if relaunchSetter, ok := engine.(port.AlreadyRunningAppRelaunchHandlerSetter); ok {298 if relaunchSetter, ok := engine.(port.AlreadyRunningAppRelaunchHandlerSetter); ok {
299 relaunchSetter.SetAlreadyRunningAppRelaunchHandler(func(url string) {299 relaunchSetter.SetAlreadyRunningAppRelaunchHandler(func(url string) {
300 if err := app.OpenExternalURL(ctx, url); err != nil {300 if err := app.OpenExternalURL(ctx, url); err != nil {
301- log.Warn().Err(err).Str("url", url).Msg("failed to open relaunch browser window")301+ log.Warn().Err(err).Str("url", url).Msg("failed to open relaunch external URL")
302 }302 }
303 })303 })
304 }304 }
M docs/reference/configuration.md +2 -3
@@ -1,5 +1,7 @@
11 # Configuration Reference
22
3+`workspace.external_links` controls URLs opened from another application. Non-`windowed` modes target the last-focused window in the current Dumber profile and engine; if that destination is unavailable, Dumber falls back to opening a new window. `placement` applies only to `split`.
4+
35 | Key | Type | Default | Valid Values |
46 |-----|------|---------|--------------|
57 | `database.path` | string | `~/.local/share/dumber/dumber.db` | |
@@ -123,9 +125,6 @@
123125 | `workspace.browsing_contexts.blank_target_behavior` | string | `stacked` | `split`, `stacked`, `tabbed` |
124126 | `workspace.browsing_contexts.enable_smart_detection` | bool | `true` | |
125127 | `workspace.browsing_contexts.oauth_auto_close` | bool | `true` | |
126-
127-`workspace.external_links` controls URLs opened from another application. Non-`windowed` modes target the last-focused window in the current Dumber profile and engine; if that destination is unavailable, Dumber falls back to opening a new window. `placement` applies only to `split`.
128-
129128 | `workspace.styling.border_width` | int | `1` | |
130129 | `workspace.styling.border_color` | string | `@theme_selected_bg_color` | |
131130 | `workspace.styling.mode_border_width` | int | `4` | |
@@ -1,5 +1,7 @@
1 # Configuration Reference1 # Configuration Reference
2 2
3+`workspace.external_links` controls URLs opened from another application. Non-`windowed` modes target the last-focused window in the current Dumber profile and engine; if that destination is unavailable, Dumber falls back to opening a new window. `placement` applies only to `split`.
4+
3 | Key | Type | Default | Valid Values |5 | Key | Type | Default | Valid Values |
4 |-----|------|---------|--------------|6 |-----|------|---------|--------------|
5 | `database.path` | string | `~/.local/share/dumber/dumber.db` | |7 | `database.path` | string | `~/.local/share/dumber/dumber.db` | |
@@ -123,9 +125,6 @@
123 | `workspace.browsing_contexts.blank_target_behavior` | string | `stacked` | `split`, `stacked`, `tabbed` |125 | `workspace.browsing_contexts.blank_target_behavior` | string | `stacked` | `split`, `stacked`, `tabbed` |
124 | `workspace.browsing_contexts.enable_smart_detection` | bool | `true` | |126 | `workspace.browsing_contexts.enable_smart_detection` | bool | `true` | |
125 | `workspace.browsing_contexts.oauth_auto_close` | bool | `true` | |127 | `workspace.browsing_contexts.oauth_auto_close` | bool | `true` | |
126-
127-`workspace.external_links` controls URLs opened from another application. Non-`windowed` modes target the last-focused window in the current Dumber profile and engine; if that destination is unavailable, Dumber falls back to opening a new window. `placement` applies only to `split`.
128-
129 | `workspace.styling.border_width` | int | `1` | |128 | `workspace.styling.border_width` | int | `1` | |
130 | `workspace.styling.border_color` | string | `@theme_selected_bg_color` | |129 | `workspace.styling.border_color` | string | `@theme_selected_bg_color` | |
131 | `workspace.styling.mode_border_width` | int | `4` | |130 | `workspace.styling.mode_border_width` | int | `4` | |
M internal/ui/browser_window.go +5 -2
@@ -634,7 +634,6 @@ func (a *App) openExternalURLInFocusedWindow(ctx context.Context, url string, cf
634634 return err
635635 }
636636
637- a.activateBrowserWindow(target)
638637 switch cfg.Behavior {
639638 case entity.ExternalLinkBehaviorTabbed:
640639 if err := a.createExternalURLTab(ctx, target, url); err != nil {
@@ -710,7 +709,11 @@ func (a *App) placeExternalURLInPane(ctx context.Context, ws *entity.Workspace,
710709 }
711710
712711 func (a *App) rollbackExternalPaneCreation(ctx context.Context, ws *entity.Workspace, previous entity.PaneID) {
713- if a.panesUC == nil || ws == nil || ws.ActivePaneID == "" || ws.ActivePaneID == previous {
712+ if a.panesUC == nil {
713+ logging.FromContext(ctx).Warn().Msg("ui: cannot roll back external URL pane; panes use case is unavailable")
714+ return
715+ }
716+ if ws == nil || ws.ActivePaneID == "" || ws.ActivePaneID == previous {
714717 return
715718 }
716719 created := ws.ActivePane()
@@ -634,7 +634,6 @@ func (a *App) openExternalURLInFocusedWindow(ctx context.Context, url string, cf
634 return err634 return err
635 }635 }
636 636
637- a.activateBrowserWindow(target)
638 switch cfg.Behavior {637 switch cfg.Behavior {
639 case entity.ExternalLinkBehaviorTabbed:638 case entity.ExternalLinkBehaviorTabbed:
640 if err := a.createExternalURLTab(ctx, target, url); err != nil {639 if err := a.createExternalURLTab(ctx, target, url); err != nil {
@@ -710,7 +709,11 @@ func (a *App) placeExternalURLInPane(ctx context.Context, ws *entity.Workspace,
710 }709 }
711 710
712 func (a *App) rollbackExternalPaneCreation(ctx context.Context, ws *entity.Workspace, previous entity.PaneID) {711 func (a *App) rollbackExternalPaneCreation(ctx context.Context, ws *entity.Workspace, previous entity.PaneID) {
713- if a.panesUC == nil || ws == nil || ws.ActivePaneID == "" || ws.ActivePaneID == previous {712+ if a.panesUC == nil {
713+ logging.FromContext(ctx).Warn().Msg("ui: cannot roll back external URL pane; panes use case is unavailable")
714+ return
715+ }
716+ if ws == nil || ws.ActivePaneID == "" || ws.ActivePaneID == previous {
714 return717 return
715 }718 }
716 created := ws.ActivePane()719 created := ws.ActivePane()
M internal/ui/coordinator/workspace.go +16 -3
@@ -1490,6 +1490,7 @@ func (c *WorkspaceCoordinator) StackPaneWithURL(ctx context.Context, initialURL
14901490 addErr := c.stackedPaneMgr.AddPaneToStack(ctx, stackCtx.wsView, stackCtx.activePaneID, newPaneView, defaultPaneTitle)
14911491 if addErr != nil {
14921492 log.Error().Err(addErr).Msg("failed to add pane to stack")
1493+ c.rollbackStackPane(ctx, stackCtx, newPaneID)
14931494 return addErr
14941495 }
14951496
@@ -1506,21 +1507,24 @@ func (c *WorkspaceCoordinator) StackPaneWithURL(ctx context.Context, initialURL
15061507 }
15071508 widget := c.contentCoord.WrapWidget(ctx, wv)
15081509 if widget == nil {
1509- return fmt.Errorf("wrap webview for stacked pane")
1510+ err := fmt.Errorf("wrap webview for stacked pane")
1511+ c.rollbackStackPane(ctx, stackCtx, newPaneID)
1512+ return err
15101513 }
15111514 if err := stackCtx.wsView.SetWebViewWidget(newPaneID, widget); err != nil {
15121515 log.Warn().Err(err).Str("pane_id", string(newPaneID)).Msg("failed to attach webview widget for stacked pane")
1516+ c.rollbackStackPane(ctx, stackCtx, newPaneID)
15131517 return err
15141518 }
1515- // Load initial page for the new pane.
1519+ // Loading failures leave the attached pane available for a later navigation.
15161520 if err := wv.LoadURI(ctx, newPane.URI); err != nil {
15171521 log.Warn().Err(err).Str("uri_host", logging.SafeURLHost(newPane.URI)).Msg("failed to load initial page")
1518- return err
15191522 }
15201523
15211524 // Update workspace view
15221525 if err := stackCtx.wsView.SetActivePaneID(newPaneID); err != nil {
15231526 log.Warn().Err(err).Msg("failed to set active pane")
1527+ c.rollbackStackPane(ctx, stackCtx, newPaneID)
15241528 return err
15251529 }
15261530 stackCtx.wsView.FocusPane(newPaneID)
@@ -1554,6 +1558,15 @@ func (c *WorkspaceCoordinator) StackPaneWithURL(ctx context.Context, initialURL
15541558 return nil
15551559 }
15561560
1561+func (c *WorkspaceCoordinator) rollbackStackPane(ctx context.Context, stackCtx *stackPaneContext, paneID entity.PaneID) {
1562+ if err := c.ClosePaneByID(ctx, paneID); err != nil {
1563+ logging.FromContext(ctx).Warn().Err(err).Str("pane_id", string(paneID)).
1564+ Msg("failed to roll back stacked pane")
1565+ }
1566+ stackCtx.ws.ActivePaneID = stackCtx.activePaneID
1567+ c.notifyStateChanged()
1568+}
1569+
15571570 func (c *WorkspaceCoordinator) createOrAddStackPane(
15581571 ctx context.Context,
15591572 stackCtx *stackPaneContext,
@@ -1490,6 +1490,7 @@ func (c *WorkspaceCoordinator) StackPaneWithURL(ctx context.Context, initialURL
1490 addErr := c.stackedPaneMgr.AddPaneToStack(ctx, stackCtx.wsView, stackCtx.activePaneID, newPaneView, defaultPaneTitle)1490 addErr := c.stackedPaneMgr.AddPaneToStack(ctx, stackCtx.wsView, stackCtx.activePaneID, newPaneView, defaultPaneTitle)
1491 if addErr != nil {1491 if addErr != nil {
1492 log.Error().Err(addErr).Msg("failed to add pane to stack")1492 log.Error().Err(addErr).Msg("failed to add pane to stack")
1493+ c.rollbackStackPane(ctx, stackCtx, newPaneID)
1493 return addErr1494 return addErr
1494 }1495 }
1495 1496
@@ -1506,21 +1507,24 @@ func (c *WorkspaceCoordinator) StackPaneWithURL(ctx context.Context, initialURL
1506 }1507 }
1507 widget := c.contentCoord.WrapWidget(ctx, wv)1508 widget := c.contentCoord.WrapWidget(ctx, wv)
1508 if widget == nil {1509 if widget == nil {
1509- return fmt.Errorf("wrap webview for stacked pane")1510+ err := fmt.Errorf("wrap webview for stacked pane")
1511+ c.rollbackStackPane(ctx, stackCtx, newPaneID)
1512+ return err
1510 }1513 }
1511 if err := stackCtx.wsView.SetWebViewWidget(newPaneID, widget); err != nil {1514 if err := stackCtx.wsView.SetWebViewWidget(newPaneID, widget); err != nil {
1512 log.Warn().Err(err).Str("pane_id", string(newPaneID)).Msg("failed to attach webview widget for stacked pane")1515 log.Warn().Err(err).Str("pane_id", string(newPaneID)).Msg("failed to attach webview widget for stacked pane")
1516+ c.rollbackStackPane(ctx, stackCtx, newPaneID)
1513 return err1517 return err
1514 }1518 }
1515- // Load initial page for the new pane.1519+ // Loading failures leave the attached pane available for a later navigation.
1516 if err := wv.LoadURI(ctx, newPane.URI); err != nil {1520 if err := wv.LoadURI(ctx, newPane.URI); err != nil {
1517 log.Warn().Err(err).Str("uri_host", logging.SafeURLHost(newPane.URI)).Msg("failed to load initial page")1521 log.Warn().Err(err).Str("uri_host", logging.SafeURLHost(newPane.URI)).Msg("failed to load initial page")
1518- return err
1519 }1522 }
1520 1523
1521 // Update workspace view1524 // Update workspace view
1522 if err := stackCtx.wsView.SetActivePaneID(newPaneID); err != nil {1525 if err := stackCtx.wsView.SetActivePaneID(newPaneID); err != nil {
1523 log.Warn().Err(err).Msg("failed to set active pane")1526 log.Warn().Err(err).Msg("failed to set active pane")
1527+ c.rollbackStackPane(ctx, stackCtx, newPaneID)
1524 return err1528 return err
1525 }1529 }
1526 stackCtx.wsView.FocusPane(newPaneID)1530 stackCtx.wsView.FocusPane(newPaneID)
@@ -1554,6 +1558,15 @@ func (c *WorkspaceCoordinator) StackPaneWithURL(ctx context.Context, initialURL
1554 return nil1558 return nil
1555 }1559 }
1556 1560
1561+func (c *WorkspaceCoordinator) rollbackStackPane(ctx context.Context, stackCtx *stackPaneContext, paneID entity.PaneID) {
1562+ if err := c.ClosePaneByID(ctx, paneID); err != nil {
1563+ logging.FromContext(ctx).Warn().Err(err).Str("pane_id", string(paneID)).
1564+ Msg("failed to roll back stacked pane")
1565+ }
1566+ stackCtx.ws.ActivePaneID = stackCtx.activePaneID
1567+ c.notifyStateChanged()
1568+}
1569+
1557 func (c *WorkspaceCoordinator) createOrAddStackPane(1570 func (c *WorkspaceCoordinator) createOrAddStackPane(
1558 ctx context.Context,1571 ctx context.Context,
1559 stackCtx *stackPaneContext,1572 stackCtx *stackPaneContext,
M internal/ui/coordinator/workspace_external_url_test.go +2 -2
@@ -55,7 +55,7 @@ func TestWorkspaceCoordinator_StackPaneUsesConfiguredURLThroughLegacyEntryPoint(
5555
5656 // The test coordinator intentionally has no WebView pool. Creation reaches
5757 // that detectable UI failure only after the requested URL enters the model.
58- require.ErrorContains(t, coord.StackPane(context.Background()), "webview pool not configured")
58+ require.Error(t, coord.StackPane(context.Background()))
5959 assert.Equal(t, legacyURL, ws.ActivePane().Pane.URI)
6060 }
6161
@@ -63,7 +63,7 @@ func TestWorkspaceCoordinator_StackPaneWithURLUsesExplicitURL(t *testing.T) {
6363 const explicitURL = "https://explicit.example/path?token=secret"
6464 coord, ws := stackURLTestCoordinator(t, "https://legacy.example/")
6565
66- require.ErrorContains(t, coord.StackPaneWithURL(context.Background(), explicitURL), "webview pool not configured")
66+ require.Error(t, coord.StackPaneWithURL(context.Background(), explicitURL))
6767 assert.Equal(t, explicitURL, ws.ActivePane().Pane.URI)
6868 }
6969
@@ -55,7 +55,7 @@ func TestWorkspaceCoordinator_StackPaneUsesConfiguredURLThroughLegacyEntryPoint(
55 55
56 // The test coordinator intentionally has no WebView pool. Creation reaches56 // The test coordinator intentionally has no WebView pool. Creation reaches
57 // that detectable UI failure only after the requested URL enters the model.57 // that detectable UI failure only after the requested URL enters the model.
58- require.ErrorContains(t, coord.StackPane(context.Background()), "webview pool not configured")58+ require.Error(t, coord.StackPane(context.Background()))
59 assert.Equal(t, legacyURL, ws.ActivePane().Pane.URI)59 assert.Equal(t, legacyURL, ws.ActivePane().Pane.URI)
60 }60 }
61 61
@@ -63,7 +63,7 @@ func TestWorkspaceCoordinator_StackPaneWithURLUsesExplicitURL(t *testing.T) {
63 const explicitURL = "https://explicit.example/path?token=secret"63 const explicitURL = "https://explicit.example/path?token=secret"
64 coord, ws := stackURLTestCoordinator(t, "https://legacy.example/")64 coord, ws := stackURLTestCoordinator(t, "https://legacy.example/")
65 65
66- require.ErrorContains(t, coord.StackPaneWithURL(context.Background(), explicitURL), "webview pool not configured")66+ require.Error(t, coord.StackPaneWithURL(context.Background(), explicitURL))
67 assert.Equal(t, explicitURL, ws.ActivePane().Pane.URI)67 assert.Equal(t, explicitURL, ws.ActivePane().Pane.URI)
68 }68 }
69 69
M internal/ui/external_link_routing_test.go +1 -0
@@ -17,6 +17,7 @@ import (
1717
1818 func TestExternalLinkSplitDirectionPlacements(t *testing.T) {
1919 tests := map[entity.ExternalLinkPlacement]usecase.SplitDirection{
20+ "": usecase.SplitRight,
2021 entity.ExternalLinkPlacementLeft: usecase.SplitLeft,
2122 entity.ExternalLinkPlacementRight: usecase.SplitRight,
2223 entity.ExternalLinkPlacementTop: usecase.SplitUp,
@@ -17,6 +17,7 @@ import (
17 17
18 func TestExternalLinkSplitDirectionPlacements(t *testing.T) {18 func TestExternalLinkSplitDirectionPlacements(t *testing.T) {
19 tests := map[entity.ExternalLinkPlacement]usecase.SplitDirection{19 tests := map[entity.ExternalLinkPlacement]usecase.SplitDirection{
20+ "": usecase.SplitRight,
20 entity.ExternalLinkPlacementLeft: usecase.SplitLeft,21 entity.ExternalLinkPlacementLeft: usecase.SplitLeft,
21 entity.ExternalLinkPlacementRight: usecase.SplitRight,22 entity.ExternalLinkPlacementRight: usecase.SplitRight,
22 entity.ExternalLinkPlacementTop: usecase.SplitUp,23 entity.ExternalLinkPlacementTop: usecase.SplitUp,

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