detached ca353b8

Support zoom for local file URLs

commit
ca353b8e54a0a362237023db69c0d5141c8a3aa1
author
brice · · 2026-05-15 05:18 UTC
parent
cc04d15
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +118 -15
M internal/application/usecase/manage_zoom.go +23 -0
@@ -5,6 +5,7 @@ import (
55 "context"
66 "fmt"
77 "net/url"
8+ "strings"
89
910 "github.com/bnema/dumber/internal/application/port"
1011 "github.com/bnema/dumber/internal/domain/entity"
@@ -198,6 +199,28 @@ func (uc *ManageZoomUseCase) GetAll(ctx context.Context) ([]*entity.ZoomLevel, e
198199 return levels, nil
199200 }
200201
202+// ExtractZoomKey extracts the persistent zoom storage key from a URL string.
203+// Host-based URLs use their host; file:// URLs use the file URI without query
204+// or fragment so local documents can persist zoom too.
205+func ExtractZoomKey(rawURL string) (string, error) {
206+ u, err := url.Parse(rawURL)
207+ if err != nil {
208+ return "", fmt.Errorf("invalid URL: %w", err)
209+ }
210+ if strings.EqualFold(u.Scheme, "file") {
211+ if u.Path == "" {
212+ return "", fmt.Errorf("file URL has no path: %s", rawURL)
213+ }
214+ u.RawQuery = ""
215+ u.Fragment = ""
216+ return u.String(), nil
217+ }
218+ if u.Host == "" {
219+ return "", fmt.Errorf("URL has no host: %s", rawURL)
220+ }
221+ return u.Host, nil
222+}
223+
201224 // ExtractDomain extracts the host from a URL string.
202225 func ExtractDomain(rawURL string) (string, error) {
203226 u, err := url.Parse(rawURL)
@@ -5,6 +5,7 @@ import (
5 "context"5 "context"
6 "fmt"6 "fmt"
7 "net/url"7 "net/url"
8+ "strings"
8 9
9 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
@@ -198,6 +199,28 @@ func (uc *ManageZoomUseCase) GetAll(ctx context.Context) ([]*entity.ZoomLevel, e
198 return levels, nil199 return levels, nil
199 }200 }
200 201
202+// ExtractZoomKey extracts the persistent zoom storage key from a URL string.
203+// Host-based URLs use their host; file:// URLs use the file URI without query
204+// or fragment so local documents can persist zoom too.
205+func ExtractZoomKey(rawURL string) (string, error) {
206+ u, err := url.Parse(rawURL)
207+ if err != nil {
208+ return "", fmt.Errorf("invalid URL: %w", err)
209+ }
210+ if strings.EqualFold(u.Scheme, "file") {
211+ if u.Path == "" {
212+ return "", fmt.Errorf("file URL has no path: %s", rawURL)
213+ }
214+ u.RawQuery = ""
215+ u.Fragment = ""
216+ return u.String(), nil
217+ }
218+ if u.Host == "" {
219+ return "", fmt.Errorf("URL has no host: %s", rawURL)
220+ }
221+ return u.Host, nil
222+}
223+
201 // ExtractDomain extracts the host from a URL string.224 // ExtractDomain extracts the host from a URL string.
202 func ExtractDomain(rawURL string) (string, error) {225 func ExtractDomain(rawURL string) (string, error) {
203 u, err := url.Parse(rawURL)226 u, err := url.Parse(rawURL)
A internal/application/usecase/manage_zoom_test.go +46 -0
@@ -0,0 +1,46 @@
1+package usecase
2+
3+import "testing"
4+
5+func TestExtractZoomKey(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ rawURL string
9+ want string
10+ wantErr bool
11+ }{
12+ {
13+ name: "host based url uses host",
14+ rawURL: "https://example.com/docs?q=1#top",
15+ want: "example.com",
16+ },
17+ {
18+ name: "file url uses canonical file uri without query or fragment",
19+ rawURL: "file:///tmp/demo.html?print=1#section",
20+ want: "file:///tmp/demo.html",
21+ },
22+ {
23+ name: "about blank has no zoom key",
24+ rawURL: "about:blank",
25+ wantErr: true,
26+ },
27+ }
28+
29+ for _, tt := range tests {
30+ t.Run(tt.name, func(t *testing.T) {
31+ got, err := ExtractZoomKey(tt.rawURL)
32+ if tt.wantErr {
33+ if err == nil {
34+ t.Fatalf("ExtractZoomKey(%q) error = nil, want error", tt.rawURL)
35+ }
36+ return
37+ }
38+ if err != nil {
39+ t.Fatalf("ExtractZoomKey(%q) error = %v", tt.rawURL, err)
40+ }
41+ if got != tt.want {
42+ t.Fatalf("ExtractZoomKey(%q) = %q, want %q", tt.rawURL, got, tt.want)
43+ }
44+ })
45+ }
46+}
@@ -0,0 +1,46 @@
1+package usecase
2+
3+import "testing"
4+
5+func TestExtractZoomKey(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ rawURL string
9+ want string
10+ wantErr bool
11+ }{
12+ {
13+ name: "host based url uses host",
14+ rawURL: "https://example.com/docs?q=1#top",
15+ want: "example.com",
16+ },
17+ {
18+ name: "file url uses canonical file uri without query or fragment",
19+ rawURL: "file:///tmp/demo.html?print=1#section",
20+ want: "file:///tmp/demo.html",
21+ },
22+ {
23+ name: "about blank has no zoom key",
24+ rawURL: "about:blank",
25+ wantErr: true,
26+ },
27+ }
28+
29+ for _, tt := range tests {
30+ t.Run(tt.name, func(t *testing.T) {
31+ got, err := ExtractZoomKey(tt.rawURL)
32+ if tt.wantErr {
33+ if err == nil {
34+ t.Fatalf("ExtractZoomKey(%q) error = nil, want error", tt.rawURL)
35+ }
36+ return
37+ }
38+ if err != nil {
39+ t.Fatalf("ExtractZoomKey(%q) error = %v", tt.rawURL, err)
40+ }
41+ if got != tt.want {
42+ t.Fatalf("ExtractZoomKey(%q) = %q, want %q", tt.rawURL, got, tt.want)
43+ }
44+ })
45+ }
46+}
M internal/ui/app.go +6 -6
@@ -2221,9 +2221,9 @@ func (a *App) zoomBrowserWindow(ctx context.Context, bw *browserWindow, action s
22212221 return nil
22222222 }
22232223
2224- domain, err := usecase.ExtractDomain(wv.URI())
2224+ zoomKey, err := usecase.ExtractZoomKey(wv.URI())
22252225 if err != nil {
2226- logging.FromContext(ctx).Debug().Str("uri", wv.URI()).Msg("cannot extract domain for zoom")
2226+ logging.FromContext(ctx).Debug().Str("uri", wv.URI()).Msg("cannot extract zoom key")
22272227 return nil
22282228 }
22292229
@@ -2231,13 +2231,13 @@ func (a *App) zoomBrowserWindow(ctx context.Context, bw *browserWindow, action s
22312231 var newZoom *entity.ZoomLevel
22322232 switch action {
22332233 case "in":
2234- newZoom, err = a.deps.ZoomUC.ZoomIn(ctx, domain, current)
2234+ newZoom, err = a.deps.ZoomUC.ZoomIn(ctx, zoomKey, current)
22352235 case "out":
2236- newZoom, err = a.deps.ZoomUC.ZoomOut(ctx, domain, current)
2236+ newZoom, err = a.deps.ZoomUC.ZoomOut(ctx, zoomKey, current)
22372237 case "reset":
2238- err = a.deps.ZoomUC.ResetZoom(ctx, domain)
2238+ err = a.deps.ZoomUC.ResetZoom(ctx, zoomKey)
22392239 if err == nil {
2240- newZoom = entity.NewZoomLevel(domain, a.deps.ZoomUC.DefaultZoom())
2240+ newZoom = entity.NewZoomLevel(zoomKey, a.deps.ZoomUC.DefaultZoom())
22412241 }
22422242 default:
22432243 return fmt.Errorf("unknown zoom action %q", action)
@@ -2221,9 +2221,9 @@ func (a *App) zoomBrowserWindow(ctx context.Context, bw *browserWindow, action s
2221 return nil2221 return nil
2222 }2222 }
2223 2223
2224- domain, err := usecase.ExtractDomain(wv.URI())2224+ zoomKey, err := usecase.ExtractZoomKey(wv.URI())
2225 if err != nil {2225 if err != nil {
2226- logging.FromContext(ctx).Debug().Str("uri", wv.URI()).Msg("cannot extract domain for zoom")2226+ logging.FromContext(ctx).Debug().Str("uri", wv.URI()).Msg("cannot extract zoom key")
2227 return nil2227 return nil
2228 }2228 }
2229 2229
@@ -2231,13 +2231,13 @@ func (a *App) zoomBrowserWindow(ctx context.Context, bw *browserWindow, action s
2231 var newZoom *entity.ZoomLevel2231 var newZoom *entity.ZoomLevel
2232 switch action {2232 switch action {
2233 case "in":2233 case "in":
2234- newZoom, err = a.deps.ZoomUC.ZoomIn(ctx, domain, current)2234+ newZoom, err = a.deps.ZoomUC.ZoomIn(ctx, zoomKey, current)
2235 case "out":2235 case "out":
2236- newZoom, err = a.deps.ZoomUC.ZoomOut(ctx, domain, current)2236+ newZoom, err = a.deps.ZoomUC.ZoomOut(ctx, zoomKey, current)
2237 case "reset":2237 case "reset":
2238- err = a.deps.ZoomUC.ResetZoom(ctx, domain)2238+ err = a.deps.ZoomUC.ResetZoom(ctx, zoomKey)
2239 if err == nil {2239 if err == nil {
2240- newZoom = entity.NewZoomLevel(domain, a.deps.ZoomUC.DefaultZoom())2240+ newZoom = entity.NewZoomLevel(zoomKey, a.deps.ZoomUC.DefaultZoom())
2241 }2241 }
2242 default:2242 default:
2243 return fmt.Errorf("unknown zoom action %q", action)2243 return fmt.Errorf("unknown zoom action %q", action)
M internal/ui/app_browser_launch_test.go +34 -0
@@ -4,6 +4,7 @@ import (
44 "context"
55 "errors"
66 "io"
7+ "math"
78 "os"
89 "path/filepath"
910 "reflect"
@@ -2197,6 +2198,39 @@ func TestApp_DispatchBrowserWindowActionUsesSourceWindow(t *testing.T) {
21972198 assertRecordingWebViewBrowserActionsCalledOnce(t, "second", fakeWv2)
21982199 }
21992200
2201+func TestApp_DispatchBrowserWindowActionZoomInSupportsFileURLs(t *testing.T) {
2202+ ctx := context.Background()
2203+
2204+ tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
2205+ tabs := entity.NewTabList()
2206+ tabs.Add(tab)
2207+ tabs.SetActive(tab.ID)
2208+ bw := &browserWindow{id: "window-1", tabs: tabs}
2209+
2210+ fakeWv := &fakeRecordingWebView{id: 1, loadURILastURI: "file:///tmp/demo.html"}
2211+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
2212+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv)
2213+
2214+ app := &App{
2215+ browserWindows: map[string]*browserWindow{bw.id: bw},
2216+ contentCoord: contentCoord,
2217+ deps: &Dependencies{ZoomUC: usecase.NewManageZoomUseCase(&fakeZoomRepo{}, 1.0, nil)},
2218+ workspaceViews: map[entity.TabID]*component.WorkspaceView{
2219+ tab.ID: &component.WorkspaceView{},
2220+ },
2221+ }
2222+
2223+ if err := app.dispatchBrowserWindowAction(ctx, bw, input.ActionZoomIn); err != nil {
2224+ t.Fatalf("dispatchBrowserWindowAction returned error: %v", err)
2225+ }
2226+ if fakeWv.setZoomLevelCalls != 1 {
2227+ t.Fatalf("webview SetZoomLevel calls = %d, want 1", fakeWv.setZoomLevelCalls)
2228+ }
2229+ if diff := math.Abs(fakeWv.setZoomLevelLastLevel - 1.1); diff > 0.0001 {
2230+ t.Fatalf("webview SetZoomLevel level = %f, want 1.1", fakeWv.setZoomLevelLastLevel)
2231+ }
2232+}
2233+
22002234 func counterIDGen() func() string {
22012235 var counter int
22022236 return func() string {
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "io"6 "io"
7+ "math"
7 "os"8 "os"
8 "path/filepath"9 "path/filepath"
9 "reflect"10 "reflect"
@@ -2197,6 +2198,39 @@ func TestApp_DispatchBrowserWindowActionUsesSourceWindow(t *testing.T) {
2197 assertRecordingWebViewBrowserActionsCalledOnce(t, "second", fakeWv2)2198 assertRecordingWebViewBrowserActionsCalledOnce(t, "second", fakeWv2)
2198 }2199 }
2199 2200
2201+func TestApp_DispatchBrowserWindowActionZoomInSupportsFileURLs(t *testing.T) {
2202+ ctx := context.Background()
2203+
2204+ tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
2205+ tabs := entity.NewTabList()
2206+ tabs.Add(tab)
2207+ tabs.SetActive(tab.ID)
2208+ bw := &browserWindow{id: "window-1", tabs: tabs}
2209+
2210+ fakeWv := &fakeRecordingWebView{id: 1, loadURILastURI: "file:///tmp/demo.html"}
2211+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
2212+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv)
2213+
2214+ app := &App{
2215+ browserWindows: map[string]*browserWindow{bw.id: bw},
2216+ contentCoord: contentCoord,
2217+ deps: &Dependencies{ZoomUC: usecase.NewManageZoomUseCase(&fakeZoomRepo{}, 1.0, nil)},
2218+ workspaceViews: map[entity.TabID]*component.WorkspaceView{
2219+ tab.ID: &component.WorkspaceView{},
2220+ },
2221+ }
2222+
2223+ if err := app.dispatchBrowserWindowAction(ctx, bw, input.ActionZoomIn); err != nil {
2224+ t.Fatalf("dispatchBrowserWindowAction returned error: %v", err)
2225+ }
2226+ if fakeWv.setZoomLevelCalls != 1 {
2227+ t.Fatalf("webview SetZoomLevel calls = %d, want 1", fakeWv.setZoomLevelCalls)
2228+ }
2229+ if diff := math.Abs(fakeWv.setZoomLevelLastLevel - 1.1); diff > 0.0001 {
2230+ t.Fatalf("webview SetZoomLevel level = %f, want 1.1", fakeWv.setZoomLevelLastLevel)
2231+ }
2232+}
2233+
2200 func counterIDGen() func() string {2234 func counterIDGen() func() string {
2201 var counter int2235 var counter int
2202 return func() string {2236 return func() string {
M internal/ui/coordinator/content/navigation.go +2 -2
@@ -106,12 +106,12 @@ func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID,
106106 return
107107 }
108108
109- domain, err := usecase.ExtractDomain(uri)
109+ zoomKey, err := usecase.ExtractZoomKey(uri)
110110 if err != nil {
111111 return
112112 }
113113
114- _ = c.zoomUC.ApplyToWebView(ctx, wv, domain)
114+ _ = c.zoomUC.ApplyToWebView(ctx, wv, zoomKey)
115115 }
116116
117117 func (c *Coordinator) notifyActiveNavigation(paneID entity.PaneID, uri string) {
@@ -106,12 +106,12 @@ func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID,
106 return106 return
107 }107 }
108 108
109- domain, err := usecase.ExtractDomain(uri)109+ zoomKey, err := usecase.ExtractZoomKey(uri)
110 if err != nil {110 if err != nil {
111 return111 return
112 }112 }
113 113
114- _ = c.zoomUC.ApplyToWebView(ctx, wv, domain)114+ _ = c.zoomUC.ApplyToWebView(ctx, wv, zoomKey)
115 }115 }
116 116
117 func (c *Coordinator) notifyActiveNavigation(paneID entity.PaneID, uri string) {117 func (c *Coordinator) notifyActiveNavigation(paneID entity.PaneID, uri string) {
M internal/ui/dispatcher/keyboard.go +7 -7
@@ -435,9 +435,9 @@ func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) erro
435435 return nil
436436 }
437437
438- domain, err := usecase.ExtractDomain(wv.URI())
438+ zoomKey, err := usecase.ExtractZoomKey(wv.URI())
439439 if err != nil {
440- log.Debug().Str("uri", wv.URI()).Msg("cannot extract domain for zoom")
440+ log.Debug().Str("uri", wv.URI()).Msg("cannot extract zoom key")
441441 return nil
442442 }
443443
@@ -446,13 +446,13 @@ func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) erro
446446
447447 switch action {
448448 case "in":
449- newZoom, err = d.zoomUC.ZoomIn(ctx, domain, current)
449+ newZoom, err = d.zoomUC.ZoomIn(ctx, zoomKey, current)
450450 case "out":
451- newZoom, err = d.zoomUC.ZoomOut(ctx, domain, current)
451+ newZoom, err = d.zoomUC.ZoomOut(ctx, zoomKey, current)
452452 case "reset":
453- err = d.zoomUC.ResetZoom(ctx, domain)
453+ err = d.zoomUC.ResetZoom(ctx, zoomKey)
454454 if err == nil {
455- newZoom = entity.NewZoomLevel(domain, d.zoomUC.DefaultZoom())
455+ newZoom = entity.NewZoomLevel(zoomKey, d.zoomUC.DefaultZoom())
456456 }
457457 }
458458
@@ -475,7 +475,7 @@ func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) erro
475475 d.wsCoord.ShowZoomToast(ctx, zoomPercent)
476476
477477 log.Debug().
478- Str("domain", domain).
478+ Str("zoom_key", zoomKey).
479479 Str("action", action).
480480 Float64("zoom", newZoom.ZoomFactor).
481481 Msg("zoom applied")
@@ -435,9 +435,9 @@ func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) erro
435 return nil435 return nil
436 }436 }
437 437
438- domain, err := usecase.ExtractDomain(wv.URI())438+ zoomKey, err := usecase.ExtractZoomKey(wv.URI())
439 if err != nil {439 if err != nil {
440- log.Debug().Str("uri", wv.URI()).Msg("cannot extract domain for zoom")440+ log.Debug().Str("uri", wv.URI()).Msg("cannot extract zoom key")
441 return nil441 return nil
442 }442 }
443 443
@@ -446,13 +446,13 @@ func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) erro
446 446
447 switch action {447 switch action {
448 case "in":448 case "in":
449- newZoom, err = d.zoomUC.ZoomIn(ctx, domain, current)449+ newZoom, err = d.zoomUC.ZoomIn(ctx, zoomKey, current)
450 case "out":450 case "out":
451- newZoom, err = d.zoomUC.ZoomOut(ctx, domain, current)451+ newZoom, err = d.zoomUC.ZoomOut(ctx, zoomKey, current)
452 case "reset":452 case "reset":
453- err = d.zoomUC.ResetZoom(ctx, domain)453+ err = d.zoomUC.ResetZoom(ctx, zoomKey)
454 if err == nil {454 if err == nil {
455- newZoom = entity.NewZoomLevel(domain, d.zoomUC.DefaultZoom())455+ newZoom = entity.NewZoomLevel(zoomKey, d.zoomUC.DefaultZoom())
456 }456 }
457 }457 }
458 458
@@ -475,7 +475,7 @@ func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) erro
475 d.wsCoord.ShowZoomToast(ctx, zoomPercent)475 d.wsCoord.ShowZoomToast(ctx, zoomPercent)
476 476
477 log.Debug().477 log.Debug().
478- Str("domain", domain).478+ Str("zoom_key", zoomKey).
479 Str("action", action).479 Str("action", action).
480 Float64("zoom", newZoom.ZoomFactor).480 Float64("zoom", newZoom.ZoomFactor).
481 Msg("zoom applied")481 Msg("zoom applied")

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