detached f4ac668

refactor(url): move local path probing out of domain

commit
f4ac668742cf1bcf3183525b17d46943d71b7516
author
brice · · 2026-06-25 09:44 UTC
parent
c55079e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
21 files · +638 -240
M cmd/dumber/main.go +3 -2
@@ -790,6 +790,7 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases {
790790 updateChecker := updater.NewGitHubChecker()
791791 updateDownloader := updater.NewGitHubDownloader()
792792 updateApplier := updater.NewApplier(stateDir)
793+ localPaths := filesystem.New()
793794
794795 // Permission use case will be initialized later with dialog presenter
795796 permissionUC := usecase.NewHandlePermissionUseCase(repos.permission, nil, logging.FromContext)
@@ -800,8 +801,8 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases {
800801 historyUC.SetHistoryMutationCoordinator(historyRecorderUC)
801802
802803 return &useCases{
803- tabs: usecase.NewManageTabsUseCase(idGenerator),
804- panes: usecase.NewManagePanesUseCase(idGenerator),
804+ tabs: usecase.NewManageTabsUseCase(idGenerator, usecase.WithManageTabsLocalPathResolver(localPaths)),
805+ panes: usecase.NewManagePanesUseCase(idGenerator, usecase.WithManagePanesLocalPathResolver(localPaths)),
805806 history: historyUC,
806807 favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),
807808 zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache),
@@ -790,6 +790,7 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases {
790 updateChecker := updater.NewGitHubChecker()790 updateChecker := updater.NewGitHubChecker()
791 updateDownloader := updater.NewGitHubDownloader()791 updateDownloader := updater.NewGitHubDownloader()
792 updateApplier := updater.NewApplier(stateDir)792 updateApplier := updater.NewApplier(stateDir)
793+ localPaths := filesystem.New()
793 794
794 // Permission use case will be initialized later with dialog presenter795 // Permission use case will be initialized later with dialog presenter
795 permissionUC := usecase.NewHandlePermissionUseCase(repos.permission, nil, logging.FromContext)796 permissionUC := usecase.NewHandlePermissionUseCase(repos.permission, nil, logging.FromContext)
@@ -800,8 +801,8 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases {
800 historyUC.SetHistoryMutationCoordinator(historyRecorderUC)801 historyUC.SetHistoryMutationCoordinator(historyRecorderUC)
801 802
802 return &useCases{803 return &useCases{
803- tabs: usecase.NewManageTabsUseCase(idGenerator),804+ tabs: usecase.NewManageTabsUseCase(idGenerator, usecase.WithManageTabsLocalPathResolver(localPaths)),
804- panes: usecase.NewManagePanesUseCase(idGenerator),805+ panes: usecase.NewManagePanesUseCase(idGenerator, usecase.WithManagePanesLocalPathResolver(localPaths)),
805 history: historyUC,806 history: historyUC,
806 favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),807 favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),
807 zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache),808 zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache),
A internal/application/port/local_path_resolver.go +9 -0
@@ -0,0 +1,9 @@
1+package port
2+
3+import "context"
4+
5+// LocalPathResolver resolves user-provided local filesystem paths.
6+// Implementations may perform filesystem and environment I/O.
7+type LocalPathResolver interface {
8+ ResolveExistingPath(ctx context.Context, input string) (absPath string, ok bool, err error)
9+}
@@ -0,0 +1,9 @@
1+package port
2+
3+import "context"
4+
5+// LocalPathResolver resolves user-provided local filesystem paths.
6+// Implementations may perform filesystem and environment I/O.
7+type LocalPathResolver interface {
8+ ResolveExistingPath(ctx context.Context, input string) (absPath string, ok bool, err error)
9+}
M internal/application/usecase/manage_panes.go +28 -4
@@ -8,8 +8,8 @@ import (
88 "sort"
99 "strings"
1010
11+ "github.com/bnema/dumber/internal/application/port"
1112 "github.com/bnema/dumber/internal/domain/entity"
12- domainurl "github.com/bnema/dumber/internal/domain/url"
1313 "github.com/bnema/dumber/internal/logging"
1414 )
1515
@@ -72,13 +72,37 @@ const consumeOrExpelExpelCycleMarker = "_expel_cycle"
7272 // ManagePanesUseCase handles pane tree operations.
7373 type ManagePanesUseCase struct {
7474 idGenerator IDGenerator
75+ normalizer *NavigationURLNormalizer
76+}
77+
78+// ManagePanesOption configures ManagePanesUseCase.
79+type ManagePanesOption func(*ManagePanesUseCase)
80+
81+// WithManagePanesLocalPathResolver configures local path resolution for initial navigation URLs.
82+func WithManagePanesLocalPathResolver(resolver port.LocalPathResolver) ManagePanesOption {
83+ return func(uc *ManagePanesUseCase) {
84+ uc.normalizer = NewNavigationURLNormalizer(resolver)
85+ }
7586 }
7687
7788 // NewManagePanesUseCase creates a new pane management use case.
78-func NewManagePanesUseCase(idGenerator IDGenerator) *ManagePanesUseCase {
79- return &ManagePanesUseCase{
89+func NewManagePanesUseCase(idGenerator IDGenerator, opts ...ManagePanesOption) *ManagePanesUseCase {
90+ uc := &ManagePanesUseCase{
8091 idGenerator: idGenerator,
92+ normalizer: NewNavigationURLNormalizer(nil),
93+ }
94+ for _, opt := range opts {
95+ opt(uc)
96+ }
97+ return uc
98+}
99+
100+// NormalizeNavigationURL normalizes user navigation input for pane creation.
101+func (uc *ManagePanesUseCase) NormalizeNavigationURL(ctx context.Context, input string) string {
102+ if uc == nil {
103+ return NewNavigationURLNormalizer(nil).Normalize(ctx, input)
81104 }
105+ return uc.normalizer.normalize(ctx, input)
82106 }
83107
84108 // SplitPaneInput contains parameters for splitting a pane.
@@ -138,7 +162,7 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) (
138162 paneID := entity.PaneID(uc.idGenerator())
139163 newPane = entity.NewPane(paneID)
140164 if input.InitialURL != "" {
141- newPane.URI = domainurl.Normalize(input.InitialURL)
165+ newPane.URI = uc.normalizer.normalize(ctx, input.InitialURL)
142166 } else {
143167 newPane.URI = "about:blank"
144168 }
@@ -8,8 +8,8 @@ import (
8 "sort"8 "sort"
9 "strings"9 "strings"
10 10
11+ "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/domain/entity"12 "github.com/bnema/dumber/internal/domain/entity"
12- domainurl "github.com/bnema/dumber/internal/domain/url"
13 "github.com/bnema/dumber/internal/logging"13 "github.com/bnema/dumber/internal/logging"
14 )14 )
15 15
@@ -72,13 +72,37 @@ const consumeOrExpelExpelCycleMarker = "_expel_cycle"
72 // ManagePanesUseCase handles pane tree operations.72 // ManagePanesUseCase handles pane tree operations.
73 type ManagePanesUseCase struct {73 type ManagePanesUseCase struct {
74 idGenerator IDGenerator74 idGenerator IDGenerator
75+ normalizer *NavigationURLNormalizer
76+}
77+
78+// ManagePanesOption configures ManagePanesUseCase.
79+type ManagePanesOption func(*ManagePanesUseCase)
80+
81+// WithManagePanesLocalPathResolver configures local path resolution for initial navigation URLs.
82+func WithManagePanesLocalPathResolver(resolver port.LocalPathResolver) ManagePanesOption {
83+ return func(uc *ManagePanesUseCase) {
84+ uc.normalizer = NewNavigationURLNormalizer(resolver)
85+ }
75 }86 }
76 87
77 // NewManagePanesUseCase creates a new pane management use case.88 // NewManagePanesUseCase creates a new pane management use case.
78-func NewManagePanesUseCase(idGenerator IDGenerator) *ManagePanesUseCase {89+func NewManagePanesUseCase(idGenerator IDGenerator, opts ...ManagePanesOption) *ManagePanesUseCase {
79- return &ManagePanesUseCase{90+ uc := &ManagePanesUseCase{
80 idGenerator: idGenerator,91 idGenerator: idGenerator,
92+ normalizer: NewNavigationURLNormalizer(nil),
93+ }
94+ for _, opt := range opts {
95+ opt(uc)
96+ }
97+ return uc
98+}
99+
100+// NormalizeNavigationURL normalizes user navigation input for pane creation.
101+func (uc *ManagePanesUseCase) NormalizeNavigationURL(ctx context.Context, input string) string {
102+ if uc == nil {
103+ return NewNavigationURLNormalizer(nil).Normalize(ctx, input)
81 }104 }
105+ return uc.normalizer.normalize(ctx, input)
82 }106 }
83 107
84 // SplitPaneInput contains parameters for splitting a pane.108 // SplitPaneInput contains parameters for splitting a pane.
@@ -138,7 +162,7 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) (
138 paneID := entity.PaneID(uc.idGenerator())162 paneID := entity.PaneID(uc.idGenerator())
139 newPane = entity.NewPane(paneID)163 newPane = entity.NewPane(paneID)
140 if input.InitialURL != "" {164 if input.InitialURL != "" {
141- newPane.URI = domainurl.Normalize(input.InitialURL)165+ newPane.URI = uc.normalizer.normalize(ctx, input.InitialURL)
142 } else {166 } else {
143 newPane.URI = "about:blank"167 newPane.URI = "about:blank"
144 }168 }
M internal/application/usecase/manage_tabs.go +21 -5
@@ -4,8 +4,8 @@ import (
44 "context"
55 "fmt"
66
7+ "github.com/bnema/dumber/internal/application/port"
78 "github.com/bnema/dumber/internal/domain/entity"
8- "github.com/bnema/dumber/internal/domain/url"
99 "github.com/bnema/dumber/internal/logging"
1010 )
1111
@@ -15,13 +15,29 @@ type IDGenerator func() string
1515 // ManageTabsUseCase handles tab lifecycle operations.
1616 type ManageTabsUseCase struct {
1717 idGenerator IDGenerator
18+ normalizer *NavigationURLNormalizer
19+}
20+
21+// ManageTabsOption configures ManageTabsUseCase.
22+type ManageTabsOption func(*ManageTabsUseCase)
23+
24+// WithManageTabsLocalPathResolver configures local path resolution for initial navigation URLs.
25+func WithManageTabsLocalPathResolver(resolver port.LocalPathResolver) ManageTabsOption {
26+ return func(uc *ManageTabsUseCase) {
27+ uc.normalizer = NewNavigationURLNormalizer(resolver)
28+ }
1829 }
1930
2031 // NewManageTabsUseCase creates a new tab management use case.
21-func NewManageTabsUseCase(idGenerator IDGenerator) *ManageTabsUseCase {
22- return &ManageTabsUseCase{
32+func NewManageTabsUseCase(idGenerator IDGenerator, opts ...ManageTabsOption) *ManageTabsUseCase {
33+ uc := &ManageTabsUseCase{
2334 idGenerator: idGenerator,
35+ normalizer: NewNavigationURLNormalizer(nil),
36+ }
37+ for _, opt := range opts {
38+ opt(uc)
2439 }
40+ return uc
2541 }
2642
2743 // CreateTabInput contains parameters for creating a new tab.
@@ -58,7 +74,7 @@ func (uc *ManageTabsUseCase) Create(ctx context.Context, input CreateTabInput) (
5874 // Create initial pane with normalized URL
5975 pane := entity.NewPane(paneID)
6076 if input.InitialURL != "" {
61- pane.URI = url.Normalize(input.InitialURL)
77+ pane.URI = uc.normalizer.normalize(ctx, input.InitialURL)
6278 }
6379
6480 // Create tab with workspace
@@ -355,7 +371,7 @@ func (uc *ManageTabsUseCase) CreateWithPane(ctx context.Context, input CreateTab
355371 // Use the provided pane
356372 pane := input.Pane
357373 if input.InitialURL != "" {
358- pane.URI = url.Normalize(input.InitialURL)
374+ pane.URI = uc.normalizer.normalize(ctx, input.InitialURL)
359375 }
360376
361377 // Create tab with workspace using the pre-created pane
@@ -4,8 +4,8 @@ import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 6
7+ "github.com/bnema/dumber/internal/application/port"
7 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
8- "github.com/bnema/dumber/internal/domain/url"
9 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
10 )10 )
11 11
@@ -15,13 +15,29 @@ type IDGenerator func() string
15 // ManageTabsUseCase handles tab lifecycle operations.15 // ManageTabsUseCase handles tab lifecycle operations.
16 type ManageTabsUseCase struct {16 type ManageTabsUseCase struct {
17 idGenerator IDGenerator17 idGenerator IDGenerator
18+ normalizer *NavigationURLNormalizer
19+}
20+
21+// ManageTabsOption configures ManageTabsUseCase.
22+type ManageTabsOption func(*ManageTabsUseCase)
23+
24+// WithManageTabsLocalPathResolver configures local path resolution for initial navigation URLs.
25+func WithManageTabsLocalPathResolver(resolver port.LocalPathResolver) ManageTabsOption {
26+ return func(uc *ManageTabsUseCase) {
27+ uc.normalizer = NewNavigationURLNormalizer(resolver)
28+ }
18 }29 }
19 30
20 // NewManageTabsUseCase creates a new tab management use case.31 // NewManageTabsUseCase creates a new tab management use case.
21-func NewManageTabsUseCase(idGenerator IDGenerator) *ManageTabsUseCase {32+func NewManageTabsUseCase(idGenerator IDGenerator, opts ...ManageTabsOption) *ManageTabsUseCase {
22- return &ManageTabsUseCase{33+ uc := &ManageTabsUseCase{
23 idGenerator: idGenerator,34 idGenerator: idGenerator,
35+ normalizer: NewNavigationURLNormalizer(nil),
36+ }
37+ for _, opt := range opts {
38+ opt(uc)
24 }39 }
40+ return uc
25 }41 }
26 42
27 // CreateTabInput contains parameters for creating a new tab.43 // CreateTabInput contains parameters for creating a new tab.
@@ -58,7 +74,7 @@ func (uc *ManageTabsUseCase) Create(ctx context.Context, input CreateTabInput) (
58 // Create initial pane with normalized URL74 // Create initial pane with normalized URL
59 pane := entity.NewPane(paneID)75 pane := entity.NewPane(paneID)
60 if input.InitialURL != "" {76 if input.InitialURL != "" {
61- pane.URI = url.Normalize(input.InitialURL)77+ pane.URI = uc.normalizer.normalize(ctx, input.InitialURL)
62 }78 }
63 79
64 // Create tab with workspace80 // Create tab with workspace
@@ -355,7 +371,7 @@ func (uc *ManageTabsUseCase) CreateWithPane(ctx context.Context, input CreateTab
355 // Use the provided pane371 // Use the provided pane
356 pane := input.Pane372 pane := input.Pane
357 if input.InitialURL != "" {373 if input.InitialURL != "" {
358- pane.URI = url.Normalize(input.InitialURL)374+ pane.URI = uc.normalizer.normalize(ctx, input.InitialURL)
359 }375 }
360 376
361 // Create tab with workspace using the pre-created pane377 // Create tab with workspace using the pre-created pane
A internal/application/usecase/navigation_url_normalizer.go +51 -0
@@ -0,0 +1,51 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ domainurl "github.com/bnema/dumber/internal/domain/url"
9+)
10+
11+// NavigationURLNormalizer normalizes user navigation input while keeping
12+// filesystem probing behind an application port.
13+type NavigationURLNormalizer struct {
14+ localPaths port.LocalPathResolver
15+}
16+
17+// NewNavigationURLNormalizer creates a navigation URL normalizer.
18+func NewNavigationURLNormalizer(localPaths port.LocalPathResolver) *NavigationURLNormalizer {
19+ return &NavigationURLNormalizer{localPaths: localPaths}
20+}
21+
22+// Normalize converts existing local paths to file:// URLs, then falls back to
23+// the pure domain URL normalizer for URL-like decisions.
24+func (n *NavigationURLNormalizer) Normalize(ctx context.Context, input string) string {
25+ if input == "" {
26+ return ""
27+ }
28+ if n != nil && n.localPaths != nil && shouldProbeLocalPath(input) {
29+ if absPath, ok, err := n.localPaths.ResolveExistingPath(ctx, input); err == nil && ok {
30+ return "file://" + absPath
31+ }
32+ }
33+ return domainurl.Normalize(input)
34+}
35+
36+func (n *NavigationURLNormalizer) normalize(ctx context.Context, input string) string {
37+ if n == nil {
38+ return domainurl.Normalize(input)
39+ }
40+ return n.Normalize(ctx, input)
41+}
42+
43+func shouldProbeLocalPath(input string) bool {
44+ if input == "" {
45+ return false
46+ }
47+ if strings.Contains(input, "://") || strings.HasPrefix(input, "about:") || domainurl.IsExternalScheme(input) {
48+ return false
49+ }
50+ return true
51+}
@@ -0,0 +1,51 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ domainurl "github.com/bnema/dumber/internal/domain/url"
9+)
10+
11+// NavigationURLNormalizer normalizes user navigation input while keeping
12+// filesystem probing behind an application port.
13+type NavigationURLNormalizer struct {
14+ localPaths port.LocalPathResolver
15+}
16+
17+// NewNavigationURLNormalizer creates a navigation URL normalizer.
18+func NewNavigationURLNormalizer(localPaths port.LocalPathResolver) *NavigationURLNormalizer {
19+ return &NavigationURLNormalizer{localPaths: localPaths}
20+}
21+
22+// Normalize converts existing local paths to file:// URLs, then falls back to
23+// the pure domain URL normalizer for URL-like decisions.
24+func (n *NavigationURLNormalizer) Normalize(ctx context.Context, input string) string {
25+ if input == "" {
26+ return ""
27+ }
28+ if n != nil && n.localPaths != nil && shouldProbeLocalPath(input) {
29+ if absPath, ok, err := n.localPaths.ResolveExistingPath(ctx, input); err == nil && ok {
30+ return "file://" + absPath
31+ }
32+ }
33+ return domainurl.Normalize(input)
34+}
35+
36+func (n *NavigationURLNormalizer) normalize(ctx context.Context, input string) string {
37+ if n == nil {
38+ return domainurl.Normalize(input)
39+ }
40+ return n.Normalize(ctx, input)
41+}
42+
43+func shouldProbeLocalPath(input string) bool {
44+ if input == "" {
45+ return false
46+ }
47+ if strings.Contains(input, "://") || strings.HasPrefix(input, "about:") || domainurl.IsExternalScheme(input) {
48+ return false
49+ }
50+ return true
51+}
A internal/application/usecase/navigation_url_normalizer_test.go +53 -0
@@ -0,0 +1,53 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "path/filepath"
7+ "testing"
8+)
9+
10+type fakeLocalPathResolver struct {
11+ paths map[string]string
12+ err error
13+}
14+
15+func (r fakeLocalPathResolver) ResolveExistingPath(_ context.Context, input string) (string, bool, error) {
16+ if r.err != nil {
17+ return "", false, r.err
18+ }
19+ abs, ok := r.paths[input]
20+ return abs, ok, nil
21+}
22+
23+func TestNavigationURLNormalizerResolvesExistingLocalPaths(t *testing.T) {
24+ ctx := context.Background()
25+ absFile := filepath.Join(string(filepath.Separator), "tmp", "page.html")
26+ normalizer := NewNavigationURLNormalizer(fakeLocalPathResolver{paths: map[string]string{
27+ absFile: absFile,
28+ "page.html": absFile,
29+ "~/page.html": absFile,
30+ }})
31+
32+ for _, input := range []string{absFile, "page.html", "~/page.html"} {
33+ t.Run(input, func(t *testing.T) {
34+ got := normalizer.Normalize(ctx, input)
35+ want := "file://" + absFile
36+ if got != want {
37+ t.Fatalf("Normalize(%q) = %q, want %q", input, got, want)
38+ }
39+ })
40+ }
41+}
42+
43+func TestNavigationURLNormalizerFallsBackToDomainNormalize(t *testing.T) {
44+ ctx := context.Background()
45+ normalizer := NewNavigationURLNormalizer(fakeLocalPathResolver{err: errors.New("boom")})
46+
47+ if got := normalizer.Normalize(ctx, "example.com"); got != "https://example.com" {
48+ t.Fatalf("Normalize(example.com) = %q", got)
49+ }
50+ if got := normalizer.Normalize(ctx, "./missing.html"); got != "./missing.html" {
51+ t.Fatalf("Normalize(./missing.html) = %q", got)
52+ }
53+}
@@ -0,0 +1,53 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "path/filepath"
7+ "testing"
8+)
9+
10+type fakeLocalPathResolver struct {
11+ paths map[string]string
12+ err error
13+}
14+
15+func (r fakeLocalPathResolver) ResolveExistingPath(_ context.Context, input string) (string, bool, error) {
16+ if r.err != nil {
17+ return "", false, r.err
18+ }
19+ abs, ok := r.paths[input]
20+ return abs, ok, nil
21+}
22+
23+func TestNavigationURLNormalizerResolvesExistingLocalPaths(t *testing.T) {
24+ ctx := context.Background()
25+ absFile := filepath.Join(string(filepath.Separator), "tmp", "page.html")
26+ normalizer := NewNavigationURLNormalizer(fakeLocalPathResolver{paths: map[string]string{
27+ absFile: absFile,
28+ "page.html": absFile,
29+ "~/page.html": absFile,
30+ }})
31+
32+ for _, input := range []string{absFile, "page.html", "~/page.html"} {
33+ t.Run(input, func(t *testing.T) {
34+ got := normalizer.Normalize(ctx, input)
35+ want := "file://" + absFile
36+ if got != want {
37+ t.Fatalf("Normalize(%q) = %q, want %q", input, got, want)
38+ }
39+ })
40+ }
41+}
42+
43+func TestNavigationURLNormalizerFallsBackToDomainNormalize(t *testing.T) {
44+ ctx := context.Background()
45+ normalizer := NewNavigationURLNormalizer(fakeLocalPathResolver{err: errors.New("boom")})
46+
47+ if got := normalizer.Normalize(ctx, "example.com"); got != "https://example.com" {
48+ t.Fatalf("Normalize(example.com) = %q", got)
49+ }
50+ if got := normalizer.Normalize(ctx, "./missing.html"); got != "./missing.html" {
51+ t.Fatalf("Normalize(./missing.html) = %q", got)
52+ }
53+}
A internal/application/usecase/navigation_url_usecase_integration_test.go +83 -0
@@ -0,0 +1,83 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+func TestManageTabsCreateResolvesExistingLocalInitialURL(t *testing.T) {
12+ ctx := context.Background()
13+ tabs := entity.NewTabList()
14+ tabsFile := filepath.Join(string(filepath.Separator), "tmp", "tabs.html")
15+ uc := NewManageTabsUseCase(
16+ sequentialIDGenerator("tab", "workspace", "pane"),
17+ WithManageTabsLocalPathResolver(fakeLocalPathResolver{paths: map[string]string{"tabs.html": tabsFile}}),
18+ )
19+
20+ out, err := uc.Create(ctx, CreateTabInput{TabList: tabs, InitialURL: "tabs.html"})
21+ if err != nil {
22+ t.Fatalf("Create returned error: %v", err)
23+ }
24+ if got, want := out.Tab.Workspace.Root.Pane.URI, "file://"+tabsFile; got != want {
25+ t.Fatalf("created pane URI = %q, want %q", got, want)
26+ }
27+}
28+
29+func TestManageTabsCreateWithPaneResolvesExistingLocalInitialURL(t *testing.T) {
30+ ctx := context.Background()
31+ tabs := entity.NewTabList()
32+ tabsFile := filepath.Join(string(filepath.Separator), "tmp", "provided.html")
33+ uc := NewManageTabsUseCase(
34+ sequentialIDGenerator("tab", "workspace"),
35+ WithManageTabsLocalPathResolver(fakeLocalPathResolver{paths: map[string]string{"provided.html": tabsFile}}),
36+ )
37+ pane := entity.NewPane("provided-pane")
38+
39+ out, err := uc.CreateWithPane(ctx, CreateTabWithPaneInput{TabList: tabs, Pane: pane, InitialURL: "provided.html"})
40+ if err != nil {
41+ t.Fatalf("CreateWithPane returned error: %v", err)
42+ }
43+ if got, want := out.Tab.Workspace.Root.Pane.URI, "file://"+tabsFile; got != want {
44+ t.Fatalf("provided pane URI = %q, want %q", got, want)
45+ }
46+}
47+
48+func TestManagePanesSplitResolvesExistingLocalInitialURL(t *testing.T) {
49+ ctx := context.Background()
50+ paneFile := filepath.Join(string(filepath.Separator), "tmp", "split.html")
51+ uc := NewManagePanesUseCase(
52+ sequentialIDGenerator("new-pane", "parent"),
53+ WithManagePanesLocalPathResolver(fakeLocalPathResolver{paths: map[string]string{"split.html": paneFile}}),
54+ )
55+ active := entity.NewPane("active-pane")
56+ activeNode := &entity.PaneNode{ID: "active-node", Pane: active}
57+ workspace := &entity.Workspace{ID: "workspace", Root: activeNode, ActivePaneID: active.ID}
58+
59+ out, err := uc.Split(ctx, SplitPaneInput{
60+ Workspace: workspace,
61+ TargetPane: activeNode,
62+ Direction: SplitRight,
63+ InitialURL: "split.html",
64+ })
65+ if err != nil {
66+ t.Fatalf("Split returned error: %v", err)
67+ }
68+ if got, want := out.NewPaneNode.Pane.URI, "file://"+paneFile; got != want {
69+ t.Fatalf("split pane URI = %q, want %q", got, want)
70+ }
71+}
72+
73+func sequentialIDGenerator(ids ...string) IDGenerator {
74+ idx := 0
75+ return func() string {
76+ if idx >= len(ids) {
77+ return "extra-id"
78+ }
79+ id := ids[idx]
80+ idx++
81+ return id
82+ }
83+}
@@ -0,0 +1,83 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+func TestManageTabsCreateResolvesExistingLocalInitialURL(t *testing.T) {
12+ ctx := context.Background()
13+ tabs := entity.NewTabList()
14+ tabsFile := filepath.Join(string(filepath.Separator), "tmp", "tabs.html")
15+ uc := NewManageTabsUseCase(
16+ sequentialIDGenerator("tab", "workspace", "pane"),
17+ WithManageTabsLocalPathResolver(fakeLocalPathResolver{paths: map[string]string{"tabs.html": tabsFile}}),
18+ )
19+
20+ out, err := uc.Create(ctx, CreateTabInput{TabList: tabs, InitialURL: "tabs.html"})
21+ if err != nil {
22+ t.Fatalf("Create returned error: %v", err)
23+ }
24+ if got, want := out.Tab.Workspace.Root.Pane.URI, "file://"+tabsFile; got != want {
25+ t.Fatalf("created pane URI = %q, want %q", got, want)
26+ }
27+}
28+
29+func TestManageTabsCreateWithPaneResolvesExistingLocalInitialURL(t *testing.T) {
30+ ctx := context.Background()
31+ tabs := entity.NewTabList()
32+ tabsFile := filepath.Join(string(filepath.Separator), "tmp", "provided.html")
33+ uc := NewManageTabsUseCase(
34+ sequentialIDGenerator("tab", "workspace"),
35+ WithManageTabsLocalPathResolver(fakeLocalPathResolver{paths: map[string]string{"provided.html": tabsFile}}),
36+ )
37+ pane := entity.NewPane("provided-pane")
38+
39+ out, err := uc.CreateWithPane(ctx, CreateTabWithPaneInput{TabList: tabs, Pane: pane, InitialURL: "provided.html"})
40+ if err != nil {
41+ t.Fatalf("CreateWithPane returned error: %v", err)
42+ }
43+ if got, want := out.Tab.Workspace.Root.Pane.URI, "file://"+tabsFile; got != want {
44+ t.Fatalf("provided pane URI = %q, want %q", got, want)
45+ }
46+}
47+
48+func TestManagePanesSplitResolvesExistingLocalInitialURL(t *testing.T) {
49+ ctx := context.Background()
50+ paneFile := filepath.Join(string(filepath.Separator), "tmp", "split.html")
51+ uc := NewManagePanesUseCase(
52+ sequentialIDGenerator("new-pane", "parent"),
53+ WithManagePanesLocalPathResolver(fakeLocalPathResolver{paths: map[string]string{"split.html": paneFile}}),
54+ )
55+ active := entity.NewPane("active-pane")
56+ activeNode := &entity.PaneNode{ID: "active-node", Pane: active}
57+ workspace := &entity.Workspace{ID: "workspace", Root: activeNode, ActivePaneID: active.ID}
58+
59+ out, err := uc.Split(ctx, SplitPaneInput{
60+ Workspace: workspace,
61+ TargetPane: activeNode,
62+ Direction: SplitRight,
63+ InitialURL: "split.html",
64+ })
65+ if err != nil {
66+ t.Fatalf("Split returned error: %v", err)
67+ }
68+ if got, want := out.NewPaneNode.Pane.URI, "file://"+paneFile; got != want {
69+ t.Fatalf("split pane URI = %q, want %q", got, want)
70+ }
71+}
72+
73+func sequentialIDGenerator(ids ...string) IDGenerator {
74+ idx := 0
75+ return func() string {
76+ if idx >= len(ids) {
77+ return "extra-id"
78+ }
79+ id := ids[idx]
80+ idx++
81+ return id
82+ }
83+}
M internal/cli/app.go +21 -15
@@ -18,6 +18,7 @@ import (
1818 "github.com/bnema/dumber/internal/infrastructure/config"
1919 "github.com/bnema/dumber/internal/infrastructure/externaltheme/noctalia"
2020 "github.com/bnema/dumber/internal/infrastructure/favicon"
21+ "github.com/bnema/dumber/internal/infrastructure/filesystem"
2122 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
2223 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
2324 "github.com/bnema/dumber/internal/logging"
@@ -39,8 +40,10 @@ type App struct {
3940 DeleteSessionUC *usecase.DeleteSessionUseCase
4041
4142 // Services
42- FaviconService *favicon.Service
43- SessionSpawner port.SessionSpawner
43+ FaviconService *favicon.Service
44+ SessionSpawner port.SessionSpawner
45+ LocalPaths port.LocalPathResolver
46+ NavigationURLNormalizer *usecase.NavigationURLNormalizer
4447
4548 // Context with logger
4649 ctx context.Context
@@ -128,21 +131,24 @@ func NewApp() (*App, error) {
128131 // Create favicon service for CLI (path resolution for dmenu/fuzzel)
129132 faviconCacheDir, _ := config.GetFaviconCacheDir()
130133 faviconService := favicon.NewService(faviconCacheDir)
134+ localPaths := filesystem.New()
131135
132136 return &App{
133- Config: cfg,
134- Theme: theme,
135- db: db,
136- History: historyRepo,
137- SearchHistoryUC: searchHistoryUC,
138- SessionUC: sessionUC,
139- ListSessionsUC: listSessionsUC,
140- RestoreUC: restoreUC,
141- DeleteSessionUC: deleteSessionUC,
142- FaviconService: faviconService,
143- SessionSpawner: bootstrap.NewSessionSpawner(ctx, profile),
144- ctx: ctx,
145- logCleanup: logCleanup,
137+ Config: cfg,
138+ Theme: theme,
139+ db: db,
140+ History: historyRepo,
141+ SearchHistoryUC: searchHistoryUC,
142+ SessionUC: sessionUC,
143+ ListSessionsUC: listSessionsUC,
144+ RestoreUC: restoreUC,
145+ DeleteSessionUC: deleteSessionUC,
146+ FaviconService: faviconService,
147+ SessionSpawner: bootstrap.NewSessionSpawner(ctx, profile),
148+ LocalPaths: localPaths,
149+ NavigationURLNormalizer: usecase.NewNavigationURLNormalizer(localPaths),
150+ ctx: ctx,
151+ logCleanup: logCleanup,
146152 }, nil
147153 }
148154
@@ -18,6 +18,7 @@ import (
18 "github.com/bnema/dumber/internal/infrastructure/config"18 "github.com/bnema/dumber/internal/infrastructure/config"
19 "github.com/bnema/dumber/internal/infrastructure/externaltheme/noctalia"19 "github.com/bnema/dumber/internal/infrastructure/externaltheme/noctalia"
20 "github.com/bnema/dumber/internal/infrastructure/favicon"20 "github.com/bnema/dumber/internal/infrastructure/favicon"
21+ "github.com/bnema/dumber/internal/infrastructure/filesystem"
21 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"22 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
22 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"23 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
23 "github.com/bnema/dumber/internal/logging"24 "github.com/bnema/dumber/internal/logging"
@@ -39,8 +40,10 @@ type App struct {
39 DeleteSessionUC *usecase.DeleteSessionUseCase40 DeleteSessionUC *usecase.DeleteSessionUseCase
40 41
41 // Services42 // Services
42- FaviconService *favicon.Service43+ FaviconService *favicon.Service
43- SessionSpawner port.SessionSpawner44+ SessionSpawner port.SessionSpawner
45+ LocalPaths port.LocalPathResolver
46+ NavigationURLNormalizer *usecase.NavigationURLNormalizer
44 47
45 // Context with logger48 // Context with logger
46 ctx context.Context49 ctx context.Context
@@ -128,21 +131,24 @@ func NewApp() (*App, error) {
128 // Create favicon service for CLI (path resolution for dmenu/fuzzel)131 // Create favicon service for CLI (path resolution for dmenu/fuzzel)
129 faviconCacheDir, _ := config.GetFaviconCacheDir()132 faviconCacheDir, _ := config.GetFaviconCacheDir()
130 faviconService := favicon.NewService(faviconCacheDir)133 faviconService := favicon.NewService(faviconCacheDir)
134+ localPaths := filesystem.New()
131 135
132 return &App{136 return &App{
133- Config: cfg,137+ Config: cfg,
134- Theme: theme,138+ Theme: theme,
135- db: db,139+ db: db,
136- History: historyRepo,140+ History: historyRepo,
137- SearchHistoryUC: searchHistoryUC,141+ SearchHistoryUC: searchHistoryUC,
138- SessionUC: sessionUC,142+ SessionUC: sessionUC,
139- ListSessionsUC: listSessionsUC,143+ ListSessionsUC: listSessionsUC,
140- RestoreUC: restoreUC,144+ RestoreUC: restoreUC,
141- DeleteSessionUC: deleteSessionUC,145+ DeleteSessionUC: deleteSessionUC,
142- FaviconService: faviconService,146+ FaviconService: faviconService,
143- SessionSpawner: bootstrap.NewSessionSpawner(ctx, profile),147+ SessionSpawner: bootstrap.NewSessionSpawner(ctx, profile),
144- ctx: ctx,148+ LocalPaths: localPaths,
145- logCleanup: logCleanup,149+ NavigationURLNormalizer: usecase.NewNavigationURLNormalizer(localPaths),
150+ ctx: ctx,
151+ logCleanup: logCleanup,
146 }, nil152 }, nil
147 }153 }
148 154
M internal/cli/cmd/dmenu.go +14 -1
@@ -12,6 +12,7 @@ import (
1212 "github.com/spf13/cobra"
1313
1414 "github.com/bnema/dumber/assets"
15+ "github.com/bnema/dumber/internal/application/usecase"
1516 "github.com/bnema/dumber/internal/cli/model"
1617 "github.com/bnema/dumber/internal/domain/entity"
1718 domainurl "github.com/bnema/dumber/internal/domain/url"
@@ -217,9 +218,21 @@ func parseSelection(selection string) string {
217218 return ""
218219 }
219220
220- // Use BuildSearchURL to handle bang shortcuts, URLs, and search queries
221+ // Use BuildSearchURL to handle bang shortcuts, URLs, and search queries.
222+ // Existing local paths are resolved before URL/search fallback so dmenu keeps
223+ // the same file:// behavior as the in-app omnibox.
221224 app := GetApp()
222225 if app != nil {
226+ if _, _, found := domainurl.ParseBangShortcut(selection); !found {
227+ normalizer := app.NavigationURLNormalizer
228+ if normalizer == nil {
229+ normalizer = usecase.NewNavigationURLNormalizer(app.LocalPaths)
230+ }
231+ normalized := normalizer.Normalize(context.Background(), selection)
232+ if normalized != selection {
233+ return normalized
234+ }
235+ }
223236 return domainurl.BuildSearchURL(selection, app.Config.ShortcutURLs(), app.Config.DefaultSearchEngine)
224237 }
225238
@@ -12,6 +12,7 @@ import (
12 "github.com/spf13/cobra"12 "github.com/spf13/cobra"
13 13
14 "github.com/bnema/dumber/assets"14 "github.com/bnema/dumber/assets"
15+ "github.com/bnema/dumber/internal/application/usecase"
15 "github.com/bnema/dumber/internal/cli/model"16 "github.com/bnema/dumber/internal/cli/model"
16 "github.com/bnema/dumber/internal/domain/entity"17 "github.com/bnema/dumber/internal/domain/entity"
17 domainurl "github.com/bnema/dumber/internal/domain/url"18 domainurl "github.com/bnema/dumber/internal/domain/url"
@@ -217,9 +218,21 @@ func parseSelection(selection string) string {
217 return ""218 return ""
218 }219 }
219 220
220- // Use BuildSearchURL to handle bang shortcuts, URLs, and search queries221+ // Use BuildSearchURL to handle bang shortcuts, URLs, and search queries.
222+ // Existing local paths are resolved before URL/search fallback so dmenu keeps
223+ // the same file:// behavior as the in-app omnibox.
221 app := GetApp()224 app := GetApp()
222 if app != nil {225 if app != nil {
226+ if _, _, found := domainurl.ParseBangShortcut(selection); !found {
227+ normalizer := app.NavigationURLNormalizer
228+ if normalizer == nil {
229+ normalizer = usecase.NewNavigationURLNormalizer(app.LocalPaths)
230+ }
231+ normalized := normalizer.Normalize(context.Background(), selection)
232+ if normalized != selection {
233+ return normalized
234+ }
235+ }
223 return domainurl.BuildSearchURL(selection, app.Config.ShortcutURLs(), app.Config.DefaultSearchEngine)236 return domainurl.BuildSearchURL(selection, app.Config.ShortcutURLs(), app.Config.DefaultSearchEngine)
224 }237 }
225 238
A internal/cli/cmd/dmenu_test.go +53 -0
@@ -0,0 +1,53 @@
1+package cmd
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/cli"
10+ "github.com/bnema/dumber/internal/infrastructure/config"
11+ "github.com/bnema/dumber/internal/infrastructure/filesystem"
12+)
13+
14+func TestParseSelectionResolvesExistingLocalFile(t *testing.T) {
15+ oldApp := app
16+ t.Cleanup(func() { app = oldApp })
17+ localPaths := filesystem.New()
18+ app = &cli.App{
19+ Config: config.DefaultConfig(),
20+ LocalPaths: localPaths,
21+ NavigationURLNormalizer: usecase.NewNavigationURLNormalizer(localPaths),
22+ }
23+
24+ tmpDir := t.TempDir()
25+ file := filepath.Join(tmpDir, "page.html")
26+ if err := os.WriteFile(file, []byte("ok"), 0644); err != nil {
27+ t.Fatal(err)
28+ }
29+ oldWD, err := os.Getwd()
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ if err := os.Chdir(tmpDir); err != nil {
34+ t.Fatal(err)
35+ }
36+ t.Cleanup(func() { _ = os.Chdir(oldWD) })
37+
38+ if got := parseSelection("page.html"); got != "file://"+file {
39+ t.Fatalf("parseSelection(page.html) = %q, want file URL", got)
40+ }
41+}
42+
43+func TestParseSelectionLeavesBangShortcutsToSearchHandling(t *testing.T) {
44+ oldApp := app
45+ t.Cleanup(func() { app = oldApp })
46+ cfg := config.DefaultConfig()
47+ cfg.DefaultSearchEngine = "https://search.example/?q=%s"
48+ app = &cli.App{Config: cfg}
49+
50+ if got := parseSelection("!unknown page.html"); got != "https://search.example/?q=!unknown page.html" {
51+ t.Fatalf("parseSelection(unknown bang) = %q, want search fallback", got)
52+ }
53+}
@@ -0,0 +1,53 @@
1+package cmd
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/cli"
10+ "github.com/bnema/dumber/internal/infrastructure/config"
11+ "github.com/bnema/dumber/internal/infrastructure/filesystem"
12+)
13+
14+func TestParseSelectionResolvesExistingLocalFile(t *testing.T) {
15+ oldApp := app
16+ t.Cleanup(func() { app = oldApp })
17+ localPaths := filesystem.New()
18+ app = &cli.App{
19+ Config: config.DefaultConfig(),
20+ LocalPaths: localPaths,
21+ NavigationURLNormalizer: usecase.NewNavigationURLNormalizer(localPaths),
22+ }
23+
24+ tmpDir := t.TempDir()
25+ file := filepath.Join(tmpDir, "page.html")
26+ if err := os.WriteFile(file, []byte("ok"), 0644); err != nil {
27+ t.Fatal(err)
28+ }
29+ oldWD, err := os.Getwd()
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ if err := os.Chdir(tmpDir); err != nil {
34+ t.Fatal(err)
35+ }
36+ t.Cleanup(func() { _ = os.Chdir(oldWD) })
37+
38+ if got := parseSelection("page.html"); got != "file://"+file {
39+ t.Fatalf("parseSelection(page.html) = %q, want file URL", got)
40+ }
41+}
42+
43+func TestParseSelectionLeavesBangShortcutsToSearchHandling(t *testing.T) {
44+ oldApp := app
45+ t.Cleanup(func() { app = oldApp })
46+ cfg := config.DefaultConfig()
47+ cfg.DefaultSearchEngine = "https://search.example/?q=%s"
48+ app = &cli.App{Config: cfg}
49+
50+ if got := parseSelection("!unknown page.html"); got != "https://search.example/?q=!unknown page.html" {
51+ t.Fatalf("parseSelection(unknown bang) = %q, want search fallback", got)
52+ }
53+}
M internal/domain/url/normalize.go +2 -32
@@ -7,21 +7,9 @@ import (
77 "fmt"
88 "net"
99 "net/url"
10- "os"
11- "path/filepath"
1210 "strings"
1311 )
1412
15-// expandHome expands ~ prefix to user's home directory.
16-func expandHome(path string) string {
17- if strings.HasPrefix(path, "~/") {
18- if home, err := os.UserHomeDir(); err == nil {
19- return filepath.Join(home, path[2:])
20- }
21- }
22- return path
23-}
24-
2513 // looksLikeFilePath returns true if the input looks like a filesystem path.
2614 // This includes absolute paths (/path), relative paths (./path, ../path),
2715 // and home-relative paths (~/path).
@@ -41,7 +29,7 @@ func looksLikeFilePath(input string) bool {
4129
4230 // Normalize adds https:// prefix if missing for URL-like inputs.
4331 // Returns the input unchanged if it already has a scheme or doesn't look like a URL.
44-// If the input is an existing local file, returns a file:// URL.
32+// Path-like inputs are returned unchanged; filesystem probing belongs in application adapters.
4533 func Normalize(input string) string {
4634 if input == "" {
4735 return ""
@@ -65,29 +53,11 @@ func Normalize(input string) string {
6553 return input
6654 }
6755
68- // Check if input looks like a file path
56+ // Path-like input is not treated as a URL to avoid malformed URLs like https:///path.
6957 if looksLikeFilePath(input) {
70- expanded := expandHome(input)
71- absPath, err := filepath.Abs(expanded)
72- if err == nil {
73- if _, statErr := os.Stat(absPath); statErr == nil {
74- // File exists, convert to file:// URL
75- return "file://" + absPath
76- }
77- }
78- // Path-like input but file doesn't exist - return unchanged
79- // Don't treat as URL to avoid malformed URLs like https:///path
8058 return input
8159 }
8260
83- // Check if input is an existing file in current directory (relative without ./ prefix)
84- absPath, err := filepath.Abs(input)
85- if err == nil {
86- if _, statErr := os.Stat(absPath); statErr == nil {
87- return "file://" + absPath
88- }
89- }
90-
9161 // Looks like a URL (contains . and no spaces)
9262 if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") {
9363 return "http://" + input
@@ -7,21 +7,9 @@ import (
7 "fmt"7 "fmt"
8 "net"8 "net"
9 "net/url"9 "net/url"
10- "os"
11- "path/filepath"
12 "strings"10 "strings"
13 )11 )
14 12
15-// expandHome expands ~ prefix to user's home directory.
16-func expandHome(path string) string {
17- if strings.HasPrefix(path, "~/") {
18- if home, err := os.UserHomeDir(); err == nil {
19- return filepath.Join(home, path[2:])
20- }
21- }
22- return path
23-}
24-
25 // looksLikeFilePath returns true if the input looks like a filesystem path.13 // looksLikeFilePath returns true if the input looks like a filesystem path.
26 // This includes absolute paths (/path), relative paths (./path, ../path),14 // This includes absolute paths (/path), relative paths (./path, ../path),
27 // and home-relative paths (~/path).15 // and home-relative paths (~/path).
@@ -41,7 +29,7 @@ func looksLikeFilePath(input string) bool {
41 29
42 // Normalize adds https:// prefix if missing for URL-like inputs.30 // Normalize adds https:// prefix if missing for URL-like inputs.
43 // Returns the input unchanged if it already has a scheme or doesn't look like a URL.31 // Returns the input unchanged if it already has a scheme or doesn't look like a URL.
44-// If the input is an existing local file, returns a file:// URL.32+// Path-like inputs are returned unchanged; filesystem probing belongs in application adapters.
45 func Normalize(input string) string {33 func Normalize(input string) string {
46 if input == "" {34 if input == "" {
47 return ""35 return ""
@@ -65,29 +53,11 @@ func Normalize(input string) string {
65 return input53 return input
66 }54 }
67 55
68- // Check if input looks like a file path56+ // Path-like input is not treated as a URL to avoid malformed URLs like https:///path.
69 if looksLikeFilePath(input) {57 if looksLikeFilePath(input) {
70- expanded := expandHome(input)
71- absPath, err := filepath.Abs(expanded)
72- if err == nil {
73- if _, statErr := os.Stat(absPath); statErr == nil {
74- // File exists, convert to file:// URL
75- return "file://" + absPath
76- }
77- }
78- // Path-like input but file doesn't exist - return unchanged
79- // Don't treat as URL to avoid malformed URLs like https:///path
80 return input58 return input
81 }59 }
82 60
83- // Check if input is an existing file in current directory (relative without ./ prefix)
84- absPath, err := filepath.Abs(input)
85- if err == nil {
86- if _, statErr := os.Stat(absPath); statErr == nil {
87- return "file://" + absPath
88- }
89- }
90-
91 // Looks like a URL (contains . and no spaces)61 // Looks like a URL (contains . and no spaces)
92 if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") {62 if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") {
93 return "http://" + input63 return "http://" + input
M internal/domain/url/normalize_test.go +7 -116
@@ -1,8 +1,6 @@
11 package url
22
33 import (
4- "os"
5- "path/filepath"
64 "strings"
75 "testing"
86 )
@@ -120,128 +118,21 @@ func TestNormalize(t *testing.T) {
120118 }
121119 }
122120
123-func TestNormalize_LocalFiles(t *testing.T) {
124- // Create a temporary file for testing
125- tmpDir := t.TempDir()
126- tmpFile := filepath.Join(tmpDir, "test.html")
127- if err := os.WriteFile(tmpFile, []byte("<html></html>"), 0644); err != nil {
128- t.Fatalf("failed to create temp file: %v", err)
129- }
130-
131- // Create a subdirectory with a file
132- subDir := filepath.Join(tmpDir, "subdir")
133- if err := os.Mkdir(subDir, 0755); err != nil {
134- t.Fatalf("failed to create subdir: %v", err)
135- }
136- subFile := filepath.Join(subDir, "page.html")
137- if err := os.WriteFile(subFile, []byte("<html></html>"), 0644); err != nil {
138- t.Fatalf("failed to create subdir file: %v", err)
139- }
140-
141- tests := []struct {
142- name string
143- input string
144- want string
145- }{
146- {
147- name: "absolute path to existing file",
148- input: tmpFile,
149- want: "file://" + tmpFile,
150- },
151- {
152- name: "absolute path to existing directory",
153- input: tmpDir,
154- want: "file://" + tmpDir,
155- },
156- {
157- name: "non-existent absolute path returned unchanged",
158- input: "/nonexistent/file.html",
159- want: "/nonexistent/file.html",
160- },
161- {
162- name: "non-existent relative path with dot prefix returned unchanged",
163- input: "./nonexistent/file.html",
164- want: "./nonexistent/file.html",
165- },
166- {
167- name: "non-existent home path returned unchanged",
168- input: "~/nonexistent/file.html",
169- want: "~/nonexistent/file.html",
170- },
171- }
172-
173- for _, tt := range tests {
174- t.Run(tt.name, func(t *testing.T) {
175- got := Normalize(tt.input)
176- if got != tt.want {
177- t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
178- }
179- })
180- }
181-}
182-
183-func TestNormalize_RelativePaths(t *testing.T) {
184- // Create a temp file in current directory
185- tmpFile, err := os.CreateTemp(".", "test-*.html")
186- if err != nil {
187- t.Fatalf("failed to create temp file: %v", err)
188- }
189- tmpFile.Close()
190- defer os.Remove(tmpFile.Name())
191-
192- // Get absolute path for comparison
193- absPath, err := filepath.Abs(tmpFile.Name())
194- if err != nil {
195- t.Fatalf("failed to get abs path: %v", err)
196- }
197-
198- t.Run("relative path to existing file", func(t *testing.T) {
199- got := Normalize(tmpFile.Name())
200- want := "file://" + absPath
201- if got != want {
202- t.Errorf("Normalize(%q) = %q, want %q", tmpFile.Name(), got, want)
203- }
204- })
205-}
206-
207-func TestExpandHome(t *testing.T) {
208- home, err := os.UserHomeDir()
209- if err != nil {
210- t.Skip("cannot get home directory")
211- }
212-
121+func TestNormalize_PathLikeInputsArePure(t *testing.T) {
213122 tests := []struct {
214123 name string
215124 input string
216- want string
217125 }{
218- {
219- name: "tilde expansion",
220- input: "~/Documents",
221- want: filepath.Join(home, "Documents"),
222- },
223- {
224- name: "no tilde unchanged",
225- input: "/absolute/path",
226- want: "/absolute/path",
227- },
228- {
229- name: "relative path unchanged",
230- input: "./relative/path",
231- want: "./relative/path",
232- },
233- {
234- name: "tilde in middle unchanged",
235- input: "/path/~/file",
236- want: "/path/~/file",
237- },
126+ {name: "absolute path", input: "/tmp/example.html"},
127+ {name: "relative path with dot", input: "./example.html"},
128+ {name: "relative parent path", input: "../example.html"},
129+ {name: "home path", input: "~/example.html"},
238130 }
239131
240132 for _, tt := range tests {
241133 t.Run(tt.name, func(t *testing.T) {
242- got := expandHome(tt.input)
243- if got != tt.want {
244- t.Errorf("expandHome(%q) = %q, want %q", tt.input, got, tt.want)
134+ if got := Normalize(tt.input); got != tt.input {
135+ t.Errorf("Normalize(%q) = %q, want unchanged", tt.input, got)
245136 }
246137 })
247138 }
@@ -1,8 +1,6 @@
1 package url1 package url
2 2
3 import (3 import (
4- "os"
5- "path/filepath"
6 "strings"4 "strings"
7 "testing"5 "testing"
8 )6 )
@@ -120,128 +118,21 @@ func TestNormalize(t *testing.T) {
120 }118 }
121 }119 }
122 120
123-func TestNormalize_LocalFiles(t *testing.T) {121+func TestNormalize_PathLikeInputsArePure(t *testing.T) {
124- // Create a temporary file for testing
125- tmpDir := t.TempDir()
126- tmpFile := filepath.Join(tmpDir, "test.html")
127- if err := os.WriteFile(tmpFile, []byte("<html></html>"), 0644); err != nil {
128- t.Fatalf("failed to create temp file: %v", err)
129- }
130-
131- // Create a subdirectory with a file
132- subDir := filepath.Join(tmpDir, "subdir")
133- if err := os.Mkdir(subDir, 0755); err != nil {
134- t.Fatalf("failed to create subdir: %v", err)
135- }
136- subFile := filepath.Join(subDir, "page.html")
137- if err := os.WriteFile(subFile, []byte("<html></html>"), 0644); err != nil {
138- t.Fatalf("failed to create subdir file: %v", err)
139- }
140-
141- tests := []struct {
142- name string
143- input string
144- want string
145- }{
146- {
147- name: "absolute path to existing file",
148- input: tmpFile,
149- want: "file://" + tmpFile,
150- },
151- {
152- name: "absolute path to existing directory",
153- input: tmpDir,
154- want: "file://" + tmpDir,
155- },
156- {
157- name: "non-existent absolute path returned unchanged",
158- input: "/nonexistent/file.html",
159- want: "/nonexistent/file.html",
160- },
161- {
162- name: "non-existent relative path with dot prefix returned unchanged",
163- input: "./nonexistent/file.html",
164- want: "./nonexistent/file.html",
165- },
166- {
167- name: "non-existent home path returned unchanged",
168- input: "~/nonexistent/file.html",
169- want: "~/nonexistent/file.html",
170- },
171- }
172-
173- for _, tt := range tests {
174- t.Run(tt.name, func(t *testing.T) {
175- got := Normalize(tt.input)
176- if got != tt.want {
177- t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
178- }
179- })
180- }
181-}
182-
183-func TestNormalize_RelativePaths(t *testing.T) {
184- // Create a temp file in current directory
185- tmpFile, err := os.CreateTemp(".", "test-*.html")
186- if err != nil {
187- t.Fatalf("failed to create temp file: %v", err)
188- }
189- tmpFile.Close()
190- defer os.Remove(tmpFile.Name())
191-
192- // Get absolute path for comparison
193- absPath, err := filepath.Abs(tmpFile.Name())
194- if err != nil {
195- t.Fatalf("failed to get abs path: %v", err)
196- }
197-
198- t.Run("relative path to existing file", func(t *testing.T) {
199- got := Normalize(tmpFile.Name())
200- want := "file://" + absPath
201- if got != want {
202- t.Errorf("Normalize(%q) = %q, want %q", tmpFile.Name(), got, want)
203- }
204- })
205-}
206-
207-func TestExpandHome(t *testing.T) {
208- home, err := os.UserHomeDir()
209- if err != nil {
210- t.Skip("cannot get home directory")
211- }
212-
213 tests := []struct {122 tests := []struct {
214 name string123 name string
215 input string124 input string
216- want string
217 }{125 }{
218- {126+ {name: "absolute path", input: "/tmp/example.html"},
219- name: "tilde expansion",127+ {name: "relative path with dot", input: "./example.html"},
220- input: "~/Documents",128+ {name: "relative parent path", input: "../example.html"},
221- want: filepath.Join(home, "Documents"),129+ {name: "home path", input: "~/example.html"},
222- },
223- {
224- name: "no tilde unchanged",
225- input: "/absolute/path",
226- want: "/absolute/path",
227- },
228- {
229- name: "relative path unchanged",
230- input: "./relative/path",
231- want: "./relative/path",
232- },
233- {
234- name: "tilde in middle unchanged",
235- input: "/path/~/file",
236- want: "/path/~/file",
237- },
238 }130 }
239 131
240 for _, tt := range tests {132 for _, tt := range tests {
241 t.Run(tt.name, func(t *testing.T) {133 t.Run(tt.name, func(t *testing.T) {
242- got := expandHome(tt.input)134+ if got := Normalize(tt.input); got != tt.input {
243- if got != tt.want {135+ t.Errorf("Normalize(%q) = %q, want unchanged", tt.input, got)
244- t.Errorf("expandHome(%q) = %q, want %q", tt.input, got, tt.want)
245 }136 }
246 })137 })
247 }138 }
M internal/infrastructure/config/validation.go +24 -0
@@ -4,6 +4,8 @@ import (
44 "fmt"
55 "math"
66 "net/url"
7+ "os"
8+ "path/filepath"
79 "sort"
810 "strings"
911
@@ -443,6 +445,9 @@ func validateWorkspaceNewPaneURL(config *Config) []string {
443445
444446 func validateWorkspaceURLValue(fieldPath, value string) []string {
445447 trimmed := strings.TrimSpace(value)
448+ if isLocalPathLikeWorkspaceURL(trimmed) || isExistingRelativeWorkspacePath(trimmed) {
449+ return nil
450+ }
446451 candidate := trimmed
447452 parsed, err := url.Parse(candidate)
448453 if err != nil || parsed.Scheme == "" {
@@ -465,6 +470,25 @@ func validateWorkspaceURLValue(fieldPath, value string) []string {
465470 }
466471 }
467472
473+func isLocalPathLikeWorkspaceURL(value string) bool {
474+ return strings.HasPrefix(value, "/") ||
475+ strings.HasPrefix(value, "./") ||
476+ strings.HasPrefix(value, "../") ||
477+ strings.HasPrefix(value, "~/")
478+}
479+
480+func isExistingRelativeWorkspacePath(value string) bool {
481+ if value == "" || strings.Contains(value, "://") || filepath.IsAbs(value) {
482+ return false
483+ }
484+ absPath, err := filepath.Abs(value)
485+ if err != nil {
486+ return false
487+ }
488+ _, err = os.Stat(absPath)
489+ return err == nil
490+}
491+
468492 func validateOmnibox(config *Config) []string {
469493 var validationErrors []string
470494 if config.Omnibox.MostVisitedDays < 0 {
@@ -4,6 +4,8 @@ import (
4 "fmt"4 "fmt"
5 "math"5 "math"
6 "net/url"6 "net/url"
7+ "os"
8+ "path/filepath"
7 "sort"9 "sort"
8 "strings"10 "strings"
9 11
@@ -443,6 +445,9 @@ func validateWorkspaceNewPaneURL(config *Config) []string {
443 445
444 func validateWorkspaceURLValue(fieldPath, value string) []string {446 func validateWorkspaceURLValue(fieldPath, value string) []string {
445 trimmed := strings.TrimSpace(value)447 trimmed := strings.TrimSpace(value)
448+ if isLocalPathLikeWorkspaceURL(trimmed) || isExistingRelativeWorkspacePath(trimmed) {
449+ return nil
450+ }
446 candidate := trimmed451 candidate := trimmed
447 parsed, err := url.Parse(candidate)452 parsed, err := url.Parse(candidate)
448 if err != nil || parsed.Scheme == "" {453 if err != nil || parsed.Scheme == "" {
@@ -465,6 +470,25 @@ func validateWorkspaceURLValue(fieldPath, value string) []string {
465 }470 }
466 }471 }
467 472
473+func isLocalPathLikeWorkspaceURL(value string) bool {
474+ return strings.HasPrefix(value, "/") ||
475+ strings.HasPrefix(value, "./") ||
476+ strings.HasPrefix(value, "../") ||
477+ strings.HasPrefix(value, "~/")
478+}
479+
480+func isExistingRelativeWorkspacePath(value string) bool {
481+ if value == "" || strings.Contains(value, "://") || filepath.IsAbs(value) {
482+ return false
483+ }
484+ absPath, err := filepath.Abs(value)
485+ if err != nil {
486+ return false
487+ }
488+ _, err = os.Stat(absPath)
489+ return err == nil
490+}
491+
468 func validateOmnibox(config *Config) []string {492 func validateOmnibox(config *Config) []string {
469 var validationErrors []string493 var validationErrors []string
470 if config.Omnibox.MostVisitedDays < 0 {494 if config.Omnibox.MostVisitedDays < 0 {
M internal/infrastructure/config/validation_test.go +36 -0
@@ -2,6 +2,7 @@ package config
22
33 import (
44 "math"
5+ "os"
56 "testing"
67
78 "github.com/stretchr/testify/assert"
@@ -169,6 +170,41 @@ func TestValidateConfig_CEFConfig(t *testing.T) {
169170 }
170171 }
171172
173+func TestValidateConfig_WorkspaceNewPaneURLAllowsLocalPathLikeValues(t *testing.T) {
174+ for _, value := range []string{"/tmp/page.html", "./page.html", "../page.html", "~/page.html"} {
175+ t.Run(value, func(t *testing.T) {
176+ cfg := DefaultConfig()
177+ cfg.Workspace.NewPaneURL = value
178+
179+ err := validateConfig(cfg)
180+ require.NoError(t, err)
181+ })
182+ }
183+}
184+
185+func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingBareRelativeFile(t *testing.T) {
186+ tmpDir := t.TempDir()
187+ oldWD, err := os.Getwd()
188+ require.NoError(t, err)
189+ require.NoError(t, os.Chdir(tmpDir))
190+ t.Cleanup(func() { _ = os.Chdir(oldWD) })
191+ require.NoError(t, os.WriteFile("README", []byte("ok"), 0644))
192+
193+ cfg := DefaultConfig()
194+ cfg.Workspace.NewPaneURL = "README"
195+
196+ require.NoError(t, validateConfig(cfg))
197+}
198+
199+func TestValidateConfig_WorkspaceNewPaneURLRejectsMissingBareRelativeValue(t *testing.T) {
200+ cfg := DefaultConfig()
201+ cfg.Workspace.NewPaneURL = "missing-local-file"
202+
203+ err := validateConfig(cfg)
204+ require.Error(t, err)
205+ assert.Contains(t, err.Error(), "workspace.new_pane_url")
206+}
207+
172208 func TestValidateConfig_WebKitDefaultProfileIgnoresZeroGPUThreads(t *testing.T) {
173209 cfg := DefaultConfig()
174210 cfg.Engine.Profile = ProfileDefault
@@ -2,6 +2,7 @@ package config
2 2
3 import (3 import (
4 "math"4 "math"
5+ "os"
5 "testing"6 "testing"
6 7
7 "github.com/stretchr/testify/assert"8 "github.com/stretchr/testify/assert"
@@ -169,6 +170,41 @@ func TestValidateConfig_CEFConfig(t *testing.T) {
169 }170 }
170 }171 }
171 172
173+func TestValidateConfig_WorkspaceNewPaneURLAllowsLocalPathLikeValues(t *testing.T) {
174+ for _, value := range []string{"/tmp/page.html", "./page.html", "../page.html", "~/page.html"} {
175+ t.Run(value, func(t *testing.T) {
176+ cfg := DefaultConfig()
177+ cfg.Workspace.NewPaneURL = value
178+
179+ err := validateConfig(cfg)
180+ require.NoError(t, err)
181+ })
182+ }
183+}
184+
185+func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingBareRelativeFile(t *testing.T) {
186+ tmpDir := t.TempDir()
187+ oldWD, err := os.Getwd()
188+ require.NoError(t, err)
189+ require.NoError(t, os.Chdir(tmpDir))
190+ t.Cleanup(func() { _ = os.Chdir(oldWD) })
191+ require.NoError(t, os.WriteFile("README", []byte("ok"), 0644))
192+
193+ cfg := DefaultConfig()
194+ cfg.Workspace.NewPaneURL = "README"
195+
196+ require.NoError(t, validateConfig(cfg))
197+}
198+
199+func TestValidateConfig_WorkspaceNewPaneURLRejectsMissingBareRelativeValue(t *testing.T) {
200+ cfg := DefaultConfig()
201+ cfg.Workspace.NewPaneURL = "missing-local-file"
202+
203+ err := validateConfig(cfg)
204+ require.Error(t, err)
205+ assert.Contains(t, err.Error(), "workspace.new_pane_url")
206+}
207+
172 func TestValidateConfig_WebKitDefaultProfileIgnoresZeroGPUThreads(t *testing.T) {208 func TestValidateConfig_WebKitDefaultProfileIgnoresZeroGPUThreads(t *testing.T) {
173 cfg := DefaultConfig()209 cfg := DefaultConfig()
174 cfg.Engine.Profile = ProfileDefault210 cfg.Engine.Profile = ProfileDefault
M internal/infrastructure/filesystem/adapter.go +28 -0
@@ -67,4 +67,32 @@ func (a *Adapter) RemoveAll(_ context.Context, path string) error {
6767 return os.RemoveAll(path)
6868 }
6969
70+// ResolveExistingPath expands supported user path syntax, absolutizes the path,
71+// and reports whether it exists in the OS filesystem.
72+func (a *Adapter) ResolveExistingPath(_ context.Context, input string) (string, bool, error) {
73+ if input == "" {
74+ return "", false, nil
75+ }
76+ path := input
77+ if len(path) >= 2 && path[0:2] == "~/" {
78+ home, err := os.UserHomeDir()
79+ if err != nil {
80+ return "", false, err
81+ }
82+ path = filepath.Join(home, path[2:])
83+ }
84+ absPath, err := filepath.Abs(path)
85+ if err != nil {
86+ return "", false, err
87+ }
88+ if _, err := os.Stat(absPath); err != nil {
89+ if os.IsNotExist(err) {
90+ return "", false, nil
91+ }
92+ return "", false, err
93+ }
94+ return absPath, true, nil
95+}
96+
7097 var _ port.FileSystem = (*Adapter)(nil)
98+var _ port.LocalPathResolver = (*Adapter)(nil)
@@ -67,4 +67,32 @@ func (a *Adapter) RemoveAll(_ context.Context, path string) error {
67 return os.RemoveAll(path)67 return os.RemoveAll(path)
68 }68 }
69 69
70+// ResolveExistingPath expands supported user path syntax, absolutizes the path,
71+// and reports whether it exists in the OS filesystem.
72+func (a *Adapter) ResolveExistingPath(_ context.Context, input string) (string, bool, error) {
73+ if input == "" {
74+ return "", false, nil
75+ }
76+ path := input
77+ if len(path) >= 2 && path[0:2] == "~/" {
78+ home, err := os.UserHomeDir()
79+ if err != nil {
80+ return "", false, err
81+ }
82+ path = filepath.Join(home, path[2:])
83+ }
84+ absPath, err := filepath.Abs(path)
85+ if err != nil {
86+ return "", false, err
87+ }
88+ if _, err := os.Stat(absPath); err != nil {
89+ if os.IsNotExist(err) {
90+ return "", false, nil
91+ }
92+ return "", false, err
93+ }
94+ return absPath, true, nil
95+}
96+
70 var _ port.FileSystem = (*Adapter)(nil)97 var _ port.FileSystem = (*Adapter)(nil)
98+var _ port.LocalPathResolver = (*Adapter)(nil)
A internal/infrastructure/filesystem/local_path_resolver_test.go +63 -0
@@ -0,0 +1,63 @@
1+package filesystem
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+)
9+
10+func TestResolveExistingPath(t *testing.T) {
11+ ctx := context.Background()
12+ adapter := New()
13+ tmpDir := t.TempDir()
14+ file := filepath.Join(tmpDir, "page.html")
15+ if err := os.WriteFile(file, []byte("ok"), 0644); err != nil {
16+ t.Fatal(err)
17+ }
18+
19+ oldWD, err := os.Getwd()
20+ if err != nil {
21+ t.Fatal(err)
22+ }
23+ if err := os.Chdir(tmpDir); err != nil {
24+ t.Fatal(err)
25+ }
26+ t.Cleanup(func() { _ = os.Chdir(oldWD) })
27+
28+ t.Setenv("HOME", tmpDir)
29+
30+ tests := []struct {
31+ name string
32+ input string
33+ }{
34+ {name: "absolute", input: file},
35+ {name: "relative", input: "page.html"},
36+ {name: "home", input: "~/page.html"},
37+ }
38+
39+ for _, tt := range tests {
40+ t.Run(tt.name, func(t *testing.T) {
41+ got, ok, err := adapter.ResolveExistingPath(ctx, tt.input)
42+ if err != nil {
43+ t.Fatalf("ResolveExistingPath returned error: %v", err)
44+ }
45+ if !ok {
46+ t.Fatalf("ResolveExistingPath ok=false")
47+ }
48+ if got != file {
49+ t.Fatalf("ResolveExistingPath(%q) = %q, want %q", tt.input, got, file)
50+ }
51+ })
52+ }
53+}
54+
55+func TestResolveExistingPathMissing(t *testing.T) {
56+ got, ok, err := New().ResolveExistingPath(context.Background(), filepath.Join(t.TempDir(), "missing.html"))
57+ if err != nil {
58+ t.Fatalf("ResolveExistingPath returned error: %v", err)
59+ }
60+ if ok || got != "" {
61+ t.Fatalf("ResolveExistingPath missing = (%q, %v), want empty false", got, ok)
62+ }
63+}
@@ -0,0 +1,63 @@
1+package filesystem
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+)
9+
10+func TestResolveExistingPath(t *testing.T) {
11+ ctx := context.Background()
12+ adapter := New()
13+ tmpDir := t.TempDir()
14+ file := filepath.Join(tmpDir, "page.html")
15+ if err := os.WriteFile(file, []byte("ok"), 0644); err != nil {
16+ t.Fatal(err)
17+ }
18+
19+ oldWD, err := os.Getwd()
20+ if err != nil {
21+ t.Fatal(err)
22+ }
23+ if err := os.Chdir(tmpDir); err != nil {
24+ t.Fatal(err)
25+ }
26+ t.Cleanup(func() { _ = os.Chdir(oldWD) })
27+
28+ t.Setenv("HOME", tmpDir)
29+
30+ tests := []struct {
31+ name string
32+ input string
33+ }{
34+ {name: "absolute", input: file},
35+ {name: "relative", input: "page.html"},
36+ {name: "home", input: "~/page.html"},
37+ }
38+
39+ for _, tt := range tests {
40+ t.Run(tt.name, func(t *testing.T) {
41+ got, ok, err := adapter.ResolveExistingPath(ctx, tt.input)
42+ if err != nil {
43+ t.Fatalf("ResolveExistingPath returned error: %v", err)
44+ }
45+ if !ok {
46+ t.Fatalf("ResolveExistingPath ok=false")
47+ }
48+ if got != file {
49+ t.Fatalf("ResolveExistingPath(%q) = %q, want %q", tt.input, got, file)
50+ }
51+ })
52+ }
53+}
54+
55+func TestResolveExistingPathMissing(t *testing.T) {
56+ got, ok, err := New().ResolveExistingPath(context.Background(), filepath.Join(t.TempDir(), "missing.html"))
57+ if err != nil {
58+ t.Fatalf("ResolveExistingPath returned error: %v", err)
59+ }
60+ if ok || got != "" {
61+ t.Fatalf("ResolveExistingPath missing = (%q, %v), want empty false", got, ok)
62+ }
63+}
M internal/ui/app.go +42 -24
@@ -1437,12 +1437,13 @@ func (a *App) Cancel(ctx context.Context) {
14371437 }
14381438
14391439 type omniboxCallbacks struct {
1440- OnNavigate func(ctx context.Context, url string) error
1441- OnToast func(ctx context.Context, message string, level component.ToastLevel)
1442- OnFocusIn func(entry *gtk.SearchEntry)
1443- OnFocusOut func()
1444- OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool
1445- OnAccentKeyRelease func(keyval uint)
1440+ OnNavigate func(ctx context.Context, url string) error
1441+ NormalizeNavigationURL func(ctx context.Context, input string) string
1442+ OnToast func(ctx context.Context, message string, level component.ToastLevel)
1443+ OnFocusIn func(entry *gtk.SearchEntry)
1444+ OnFocusOut func()
1445+ OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool
1446+ OnAccentKeyRelease func(keyval uint)
14461447 }
14471448
14481449 func buildOmniboxConfig(
@@ -1464,22 +1465,23 @@ func buildOmniboxConfig(
14641465 }
14651466
14661467 return component.OmniboxConfig{
1467- HistoryUC: deps.HistoryUC,
1468- FavoritesUC: deps.FavoritesUC,
1469- FaviconAdapter: faviconAdapter,
1470- CopyURLUC: deps.CopyURLUC,
1471- ShortcutsUC: usecase.NewSearchShortcutsUseCase(shortcuts),
1472- DefaultSearch: runtimeCfg.DefaultSearchEngine,
1473- InitialBehavior: runtimeCfg.Omnibox.InitialBehavior,
1474- MostVisitedDays: runtimeCfg.Omnibox.MostVisitedDays,
1475- SaveInitialBehavior: deps.HandlerDeps.SaveOmniboxInitialBehavior,
1476- UIScale: runtimeCfg.DefaultUIScale,
1477- OnNavigate: callbacks.OnNavigate,
1478- OnToast: callbacks.OnToast,
1479- OnFocusIn: callbacks.OnFocusIn,
1480- OnFocusOut: callbacks.OnFocusOut,
1481- OnAccentKeyPress: callbacks.OnAccentKeyPress,
1482- OnAccentKeyRelease: callbacks.OnAccentKeyRelease,
1468+ HistoryUC: deps.HistoryUC,
1469+ FavoritesUC: deps.FavoritesUC,
1470+ FaviconAdapter: faviconAdapter,
1471+ CopyURLUC: deps.CopyURLUC,
1472+ ShortcutsUC: usecase.NewSearchShortcutsUseCase(shortcuts),
1473+ DefaultSearch: runtimeCfg.DefaultSearchEngine,
1474+ NormalizeNavigationURL: callbacks.NormalizeNavigationURL,
1475+ InitialBehavior: runtimeCfg.Omnibox.InitialBehavior,
1476+ MostVisitedDays: runtimeCfg.Omnibox.MostVisitedDays,
1477+ SaveInitialBehavior: deps.HandlerDeps.SaveOmniboxInitialBehavior,
1478+ UIScale: runtimeCfg.DefaultUIScale,
1479+ OnNavigate: callbacks.OnNavigate,
1480+ OnToast: callbacks.OnToast,
1481+ OnFocusIn: callbacks.OnFocusIn,
1482+ OnFocusOut: callbacks.OnFocusOut,
1483+ OnAccentKeyPress: callbacks.OnAccentKeyPress,
1484+ OnAccentKeyRelease: callbacks.OnAccentKeyRelease,
14831485 }
14841486 }
14851487
@@ -1506,10 +1508,19 @@ func NewStandaloneOmniboxRuntime(
15061508 if deps != nil && deps.RuntimeConfig != nil {
15071509 runtimeCfg = deps.RuntimeConfig.Current().UI
15081510 }
1511+ var localPathResolver port.LocalPathResolver
1512+ if deps != nil {
1513+ localPathResolver, _ = deps.FileSystem.(port.LocalPathResolver)
1514+ }
1515+ navigationURLNormalizer := usecase.NewNavigationURLNormalizer(localPathResolver)
1516+ normalizeNavigationURL := func(navCtx context.Context, input string) string {
1517+ return navigationURLNormalizer.Normalize(navCtx, input)
1518+ }
15091519 omniboxCfg := buildOmniboxConfig(deps, runtimeCfg, faviconAdapter, omniboxCallbacks{
15101520 OnNavigate: func(navCtx context.Context, url string) error {
15111521 return handleStandaloneOmniboxNavigation(deps, navCtx, url)
15121522 },
1523+ NormalizeNavigationURL: normalizeNavigationURL,
15131524 OnToast: func(toastCtx context.Context, message string, level component.ToastLevel) {
15141525 logging.FromContext(toastCtx).Debug().Str("message", message).Int("level", int(level)).Msg("standalone omnibox toast")
15151526 },
@@ -1548,10 +1559,17 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
15481559 return
15491560 }
15501561
1562+ navigationURLNormalizer := usecase.NewNavigationURLNormalizer(nil)
15511563 a.omniboxCfg = buildOmniboxConfig(a.deps, a.runtimeConfigSnapshot().UI, a.faviconAdapter, omniboxCallbacks{
15521564 OnNavigate: func(navCtx context.Context, url string) error {
15531565 return a.navigateFromOmnibox(navCtx, url)
15541566 },
1567+ NormalizeNavigationURL: func(navCtx context.Context, input string) string {
1568+ if a.panesUC != nil {
1569+ return a.panesUC.NormalizeNavigationURL(navCtx, input)
1570+ }
1571+ return navigationURLNormalizer.Normalize(navCtx, input)
1572+ },
15551573 OnToast: func(toastCtx context.Context, message string, level component.ToastLevel) {
15561574 a.showToastOnLastFocusedBrowserWindow(toastCtx, message, level)
15571575 },
@@ -2599,7 +2617,7 @@ func (a *App) switchBrowserWindowTabIndex(ctx context.Context, bw *browserWindow
25992617 }
26002618 a.activateBrowserWindow(bw)
26012619 ensureTarget := a.ensureTabTargetForBrowserWindow(bw)
2602- _, err := a.tabCoord.Create(ctx, ensureTarget, urlutil.Normalize(newPaneURL))
2620+ _, err := a.tabCoord.Create(ctx, ensureTarget, newPaneURL)
26032621 return err
26042622 }
26052623
@@ -3507,7 +3525,7 @@ func (a *App) keyboardActions() dispatcher.KeyboardActions {
35073525 return fmt.Errorf("newPaneURL is not configured")
35083526 }
35093527 return a.withFocusedTabTarget(ctx, "new tab", true, func(target coordinator.TabTarget) error {
3510- _, err := a.tabCoord.Create(ctx, target, urlutil.Normalize(newPaneURL))
3528+ _, err := a.tabCoord.Create(ctx, target, newPaneURL)
35113529 return err
35123530 })
35133531 },
@@ -1437,12 +1437,13 @@ func (a *App) Cancel(ctx context.Context) {
1437 }1437 }
1438 1438
1439 type omniboxCallbacks struct {1439 type omniboxCallbacks struct {
1440- OnNavigate func(ctx context.Context, url string) error1440+ OnNavigate func(ctx context.Context, url string) error
1441- OnToast func(ctx context.Context, message string, level component.ToastLevel)1441+ NormalizeNavigationURL func(ctx context.Context, input string) string
1442- OnFocusIn func(entry *gtk.SearchEntry)1442+ OnToast func(ctx context.Context, message string, level component.ToastLevel)
1443- OnFocusOut func()1443+ OnFocusIn func(entry *gtk.SearchEntry)
1444- OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool1444+ OnFocusOut func()
1445- OnAccentKeyRelease func(keyval uint)1445+ OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool
1446+ OnAccentKeyRelease func(keyval uint)
1446 }1447 }
1447 1448
1448 func buildOmniboxConfig(1449 func buildOmniboxConfig(
@@ -1464,22 +1465,23 @@ func buildOmniboxConfig(
1464 }1465 }
1465 1466
1466 return component.OmniboxConfig{1467 return component.OmniboxConfig{
1467- HistoryUC: deps.HistoryUC,1468+ HistoryUC: deps.HistoryUC,
1468- FavoritesUC: deps.FavoritesUC,1469+ FavoritesUC: deps.FavoritesUC,
1469- FaviconAdapter: faviconAdapter,1470+ FaviconAdapter: faviconAdapter,
1470- CopyURLUC: deps.CopyURLUC,1471+ CopyURLUC: deps.CopyURLUC,
1471- ShortcutsUC: usecase.NewSearchShortcutsUseCase(shortcuts),1472+ ShortcutsUC: usecase.NewSearchShortcutsUseCase(shortcuts),
1472- DefaultSearch: runtimeCfg.DefaultSearchEngine,1473+ DefaultSearch: runtimeCfg.DefaultSearchEngine,
1473- InitialBehavior: runtimeCfg.Omnibox.InitialBehavior,1474+ NormalizeNavigationURL: callbacks.NormalizeNavigationURL,
1474- MostVisitedDays: runtimeCfg.Omnibox.MostVisitedDays,1475+ InitialBehavior: runtimeCfg.Omnibox.InitialBehavior,
1475- SaveInitialBehavior: deps.HandlerDeps.SaveOmniboxInitialBehavior,1476+ MostVisitedDays: runtimeCfg.Omnibox.MostVisitedDays,
1476- UIScale: runtimeCfg.DefaultUIScale,1477+ SaveInitialBehavior: deps.HandlerDeps.SaveOmniboxInitialBehavior,
1477- OnNavigate: callbacks.OnNavigate,1478+ UIScale: runtimeCfg.DefaultUIScale,
1478- OnToast: callbacks.OnToast,1479+ OnNavigate: callbacks.OnNavigate,
1479- OnFocusIn: callbacks.OnFocusIn,1480+ OnToast: callbacks.OnToast,
1480- OnFocusOut: callbacks.OnFocusOut,1481+ OnFocusIn: callbacks.OnFocusIn,
1481- OnAccentKeyPress: callbacks.OnAccentKeyPress,1482+ OnFocusOut: callbacks.OnFocusOut,
1482- OnAccentKeyRelease: callbacks.OnAccentKeyRelease,1483+ OnAccentKeyPress: callbacks.OnAccentKeyPress,
1484+ OnAccentKeyRelease: callbacks.OnAccentKeyRelease,
1483 }1485 }
1484 }1486 }
1485 1487
@@ -1506,10 +1508,19 @@ func NewStandaloneOmniboxRuntime(
1506 if deps != nil && deps.RuntimeConfig != nil {1508 if deps != nil && deps.RuntimeConfig != nil {
1507 runtimeCfg = deps.RuntimeConfig.Current().UI1509 runtimeCfg = deps.RuntimeConfig.Current().UI
1508 }1510 }
1511+ var localPathResolver port.LocalPathResolver
1512+ if deps != nil {
1513+ localPathResolver, _ = deps.FileSystem.(port.LocalPathResolver)
1514+ }
1515+ navigationURLNormalizer := usecase.NewNavigationURLNormalizer(localPathResolver)
1516+ normalizeNavigationURL := func(navCtx context.Context, input string) string {
1517+ return navigationURLNormalizer.Normalize(navCtx, input)
1518+ }
1509 omniboxCfg := buildOmniboxConfig(deps, runtimeCfg, faviconAdapter, omniboxCallbacks{1519 omniboxCfg := buildOmniboxConfig(deps, runtimeCfg, faviconAdapter, omniboxCallbacks{
1510 OnNavigate: func(navCtx context.Context, url string) error {1520 OnNavigate: func(navCtx context.Context, url string) error {
1511 return handleStandaloneOmniboxNavigation(deps, navCtx, url)1521 return handleStandaloneOmniboxNavigation(deps, navCtx, url)
1512 },1522 },
1523+ NormalizeNavigationURL: normalizeNavigationURL,
1513 OnToast: func(toastCtx context.Context, message string, level component.ToastLevel) {1524 OnToast: func(toastCtx context.Context, message string, level component.ToastLevel) {
1514 logging.FromContext(toastCtx).Debug().Str("message", message).Int("level", int(level)).Msg("standalone omnibox toast")1525 logging.FromContext(toastCtx).Debug().Str("message", message).Int("level", int(level)).Msg("standalone omnibox toast")
1515 },1526 },
@@ -1548,10 +1559,17 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
1548 return1559 return
1549 }1560 }
1550 1561
1562+ navigationURLNormalizer := usecase.NewNavigationURLNormalizer(nil)
1551 a.omniboxCfg = buildOmniboxConfig(a.deps, a.runtimeConfigSnapshot().UI, a.faviconAdapter, omniboxCallbacks{1563 a.omniboxCfg = buildOmniboxConfig(a.deps, a.runtimeConfigSnapshot().UI, a.faviconAdapter, omniboxCallbacks{
1552 OnNavigate: func(navCtx context.Context, url string) error {1564 OnNavigate: func(navCtx context.Context, url string) error {
1553 return a.navigateFromOmnibox(navCtx, url)1565 return a.navigateFromOmnibox(navCtx, url)
1554 },1566 },
1567+ NormalizeNavigationURL: func(navCtx context.Context, input string) string {
1568+ if a.panesUC != nil {
1569+ return a.panesUC.NormalizeNavigationURL(navCtx, input)
1570+ }
1571+ return navigationURLNormalizer.Normalize(navCtx, input)
1572+ },
1555 OnToast: func(toastCtx context.Context, message string, level component.ToastLevel) {1573 OnToast: func(toastCtx context.Context, message string, level component.ToastLevel) {
1556 a.showToastOnLastFocusedBrowserWindow(toastCtx, message, level)1574 a.showToastOnLastFocusedBrowserWindow(toastCtx, message, level)
1557 },1575 },
@@ -2599,7 +2617,7 @@ func (a *App) switchBrowserWindowTabIndex(ctx context.Context, bw *browserWindow
2599 }2617 }
2600 a.activateBrowserWindow(bw)2618 a.activateBrowserWindow(bw)
2601 ensureTarget := a.ensureTabTargetForBrowserWindow(bw)2619 ensureTarget := a.ensureTabTargetForBrowserWindow(bw)
2602- _, err := a.tabCoord.Create(ctx, ensureTarget, urlutil.Normalize(newPaneURL))2620+ _, err := a.tabCoord.Create(ctx, ensureTarget, newPaneURL)
2603 return err2621 return err
2604 }2622 }
2605 2623
@@ -3507,7 +3525,7 @@ func (a *App) keyboardActions() dispatcher.KeyboardActions {
3507 return fmt.Errorf("newPaneURL is not configured")3525 return fmt.Errorf("newPaneURL is not configured")
3508 }3526 }
3509 return a.withFocusedTabTarget(ctx, "new tab", true, func(target coordinator.TabTarget) error {3527 return a.withFocusedTabTarget(ctx, "new tab", true, func(target coordinator.TabTarget) error {
3510- _, err := a.tabCoord.Create(ctx, target, urlutil.Normalize(newPaneURL))3528+ _, err := a.tabCoord.Create(ctx, target, newPaneURL)
3511 return err3529 return err
3512 })3530 })
3513 },3531 },
M internal/ui/app_omnibox_config_test.go +12 -1
@@ -1,6 +1,7 @@
11 package ui
22
33 import (
4+ "context"
45 "testing"
56
67 "github.com/bnema/dumber/internal/domain/entity"
@@ -23,7 +24,11 @@ func TestBuildOmniboxConfigUsesRuntimeUIConfig(t *testing.T) {
2324 },
2425 }
2526
26- got := buildOmniboxConfig(deps, runtimeCfg, nil, omniboxCallbacks{})
27+ got := buildOmniboxConfig(deps, runtimeCfg, nil, omniboxCallbacks{
28+ NormalizeNavigationURL: func(_ context.Context, input string) string {
29+ return "normalized:" + input
30+ },
31+ })
2732 if got.MostVisitedDays != 7 {
2833 t.Fatalf("MostVisitedDays = %d, want 7", got.MostVisitedDays)
2934 }
@@ -39,4 +44,10 @@ func TestBuildOmniboxConfigUsesRuntimeUIConfig(t *testing.T) {
3944 if got.ShortcutsUC == nil {
4045 t.Fatal("ShortcutsUC is nil")
4146 }
47+ if got.NormalizeNavigationURL == nil {
48+ t.Fatal("NormalizeNavigationURL is nil")
49+ }
50+ if normalized := got.NormalizeNavigationURL(context.Background(), "page.html"); normalized != "normalized:page.html" {
51+ t.Fatalf("NormalizeNavigationURL propagated callback = %q", normalized)
52+ }
4253 }
@@ -1,6 +1,7 @@
1 package ui1 package ui
2 2
3 import (3 import (
4+ "context"
4 "testing"5 "testing"
5 6
6 "github.com/bnema/dumber/internal/domain/entity"7 "github.com/bnema/dumber/internal/domain/entity"
@@ -23,7 +24,11 @@ func TestBuildOmniboxConfigUsesRuntimeUIConfig(t *testing.T) {
23 },24 },
24 }25 }
25 26
26- got := buildOmniboxConfig(deps, runtimeCfg, nil, omniboxCallbacks{})27+ got := buildOmniboxConfig(deps, runtimeCfg, nil, omniboxCallbacks{
28+ NormalizeNavigationURL: func(_ context.Context, input string) string {
29+ return "normalized:" + input
30+ },
31+ })
27 if got.MostVisitedDays != 7 {32 if got.MostVisitedDays != 7 {
28 t.Fatalf("MostVisitedDays = %d, want 7", got.MostVisitedDays)33 t.Fatalf("MostVisitedDays = %d, want 7", got.MostVisitedDays)
29 }34 }
@@ -39,4 +44,10 @@ func TestBuildOmniboxConfigUsesRuntimeUIConfig(t *testing.T) {
39 if got.ShortcutsUC == nil {44 if got.ShortcutsUC == nil {
40 t.Fatal("ShortcutsUC is nil")45 t.Fatal("ShortcutsUC is nil")
41 }46 }
47+ if got.NormalizeNavigationURL == nil {
48+ t.Fatal("NormalizeNavigationURL is nil")
49+ }
50+ if normalized := got.NormalizeNavigationURL(context.Background(), "page.html"); normalized != "normalized:page.html" {
51+ t.Fatalf("NormalizeNavigationURL propagated callback = %q", normalized)
52+ }
42 }53 }
M internal/ui/component/omnibox.go +48 -37
@@ -108,16 +108,17 @@ type Omnibox struct {
108108 isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText
109109
110110 // Dependencies
111- historyUC *usecase.SearchHistoryUseCase
112- favoritesUC *usecase.ManageFavoritesUseCase
113- faviconAdapter *adapter.FaviconAdapter
114- copyURLUC *usecase.CopyURLUseCase
115- shortcutsUC *usecase.SearchShortcutsUseCase
116- defaultSearch string
117- initialBehavior entity.OmniboxInitialBehavior
118- mostVisitedDays int
119- saveInitialBehaviorFn func(context.Context, entity.OmniboxInitialBehavior) error
120- ctx context.Context
111+ historyUC *usecase.SearchHistoryUseCase
112+ favoritesUC *usecase.ManageFavoritesUseCase
113+ faviconAdapter *adapter.FaviconAdapter
114+ copyURLUC *usecase.CopyURLUseCase
115+ shortcutsUC *usecase.SearchShortcutsUseCase
116+ defaultSearch string
117+ normalizeNavigationURL func(ctx context.Context, input string) string
118+ initialBehavior entity.OmniboxInitialBehavior
119+ mostVisitedDays int
120+ saveInitialBehaviorFn func(context.Context, entity.OmniboxInitialBehavior) error
121+ ctx context.Context
121122
122123 // Callbacks
123124 onNavigate func(ctx context.Context, url string) error
@@ -155,16 +156,19 @@ type Omnibox struct {
155156
156157 // OmniboxConfig holds configuration for creating an Omnibox.
157158 type OmniboxConfig struct {
158- HistoryUC *usecase.SearchHistoryUseCase
159- FavoritesUC *usecase.ManageFavoritesUseCase
160- FaviconAdapter *adapter.FaviconAdapter
161- CopyURLUC *usecase.CopyURLUseCase
162- ShortcutsUC *usecase.SearchShortcutsUseCase
163- DefaultSearch string
164- InitialBehavior entity.OmniboxInitialBehavior
165- MostVisitedDays int
166- SaveInitialBehavior func(ctx context.Context, behavior entity.OmniboxInitialBehavior) error
167- UIScale float64 // UI scale for favicon sizing
159+ HistoryUC *usecase.SearchHistoryUseCase
160+ FavoritesUC *usecase.ManageFavoritesUseCase
161+ FaviconAdapter *adapter.FaviconAdapter
162+ CopyURLUC *usecase.CopyURLUseCase
163+ ShortcutsUC *usecase.SearchShortcutsUseCase
164+ DefaultSearch string
165+ // NormalizeNavigationURL resolves navigation input before search fallback.
166+ // It is injected so local filesystem probing stays outside the domain URL package.
167+ NormalizeNavigationURL func(ctx context.Context, input string) string
168+ InitialBehavior entity.OmniboxInitialBehavior
169+ MostVisitedDays int
170+ SaveInitialBehavior func(ctx context.Context, behavior entity.OmniboxInitialBehavior) error
171+ UIScale float64 // UI scale for favicon sizing
168172 // OnNavigate is called when the user submits a URL; returning nil closes the omnibox.
169173 OnNavigate func(ctx context.Context, url string) error
170174 OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification
@@ -188,23 +192,24 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
188192 sizeCfg := ResolveModalSizeConfig(cfg.SizeConfig, OmniboxSizeDefaults)
189193
190194 o := &Omnibox{
191- viewMode: ViewModeHistory,
192- selectedIndex: -1,
193- historyUC: cfg.HistoryUC,
194- favoritesUC: cfg.FavoritesUC,
195- faviconAdapter: cfg.FaviconAdapter,
196- copyURLUC: cfg.CopyURLUC,
197- shortcutsUC: cfg.ShortcutsUC,
198- defaultSearch: cfg.DefaultSearch,
199- initialBehavior: cfg.InitialBehavior,
200- mostVisitedDays: cfg.MostVisitedDays,
201- saveInitialBehaviorFn: cfg.SaveInitialBehavior,
202- onToast: cfg.OnToast,
203- onAccentKeyPress: cfg.OnAccentKeyPress,
204- onAccentKeyRelease: cfg.OnAccentKeyRelease,
205- ctx: ctx,
206- uiScale: uiScale,
207- sizeCfg: sizeCfg,
195+ viewMode: ViewModeHistory,
196+ selectedIndex: -1,
197+ historyUC: cfg.HistoryUC,
198+ favoritesUC: cfg.FavoritesUC,
199+ faviconAdapter: cfg.FaviconAdapter,
200+ copyURLUC: cfg.CopyURLUC,
201+ shortcutsUC: cfg.ShortcutsUC,
202+ defaultSearch: cfg.DefaultSearch,
203+ normalizeNavigationURL: cfg.NormalizeNavigationURL,
204+ initialBehavior: cfg.InitialBehavior,
205+ mostVisitedDays: cfg.MostVisitedDays,
206+ saveInitialBehaviorFn: cfg.SaveInitialBehavior,
207+ onToast: cfg.OnToast,
208+ onAccentKeyPress: cfg.OnAccentKeyPress,
209+ onAccentKeyRelease: cfg.OnAccentKeyRelease,
210+ ctx: ctx,
211+ uiScale: uiScale,
212+ sizeCfg: sizeCfg,
208213 }
209214 o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) {
210215 var cb glib.SourceFunc = func(uintptr) bool {
@@ -2617,6 +2622,12 @@ func (o *Omnibox) buildURL(text string) string {
26172622 if o.shortcutsUC != nil {
26182623 shortcutURLs = o.shortcutsUC.ShortcutURLs()
26192624 }
2625+ if _, _, found := url.ParseBangShortcut(text); !found && o.normalizeNavigationURL != nil {
2626+ normalized := o.normalizeNavigationURL(o.ctx, text)
2627+ if normalized != text {
2628+ return normalized
2629+ }
2630+ }
26202631 return url.BuildSearchURL(text, shortcutURLs, o.defaultSearch)
26212632 }
26222633
@@ -108,16 +108,17 @@ type Omnibox struct {
108 isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText108 isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText
109 109
110 // Dependencies110 // Dependencies
111- historyUC *usecase.SearchHistoryUseCase111+ historyUC *usecase.SearchHistoryUseCase
112- favoritesUC *usecase.ManageFavoritesUseCase112+ favoritesUC *usecase.ManageFavoritesUseCase
113- faviconAdapter *adapter.FaviconAdapter113+ faviconAdapter *adapter.FaviconAdapter
114- copyURLUC *usecase.CopyURLUseCase114+ copyURLUC *usecase.CopyURLUseCase
115- shortcutsUC *usecase.SearchShortcutsUseCase115+ shortcutsUC *usecase.SearchShortcutsUseCase
116- defaultSearch string116+ defaultSearch string
117- initialBehavior entity.OmniboxInitialBehavior117+ normalizeNavigationURL func(ctx context.Context, input string) string
118- mostVisitedDays int118+ initialBehavior entity.OmniboxInitialBehavior
119- saveInitialBehaviorFn func(context.Context, entity.OmniboxInitialBehavior) error119+ mostVisitedDays int
120- ctx context.Context120+ saveInitialBehaviorFn func(context.Context, entity.OmniboxInitialBehavior) error
121+ ctx context.Context
121 122
122 // Callbacks123 // Callbacks
123 onNavigate func(ctx context.Context, url string) error124 onNavigate func(ctx context.Context, url string) error
@@ -155,16 +156,19 @@ type Omnibox struct {
155 156
156 // OmniboxConfig holds configuration for creating an Omnibox.157 // OmniboxConfig holds configuration for creating an Omnibox.
157 type OmniboxConfig struct {158 type OmniboxConfig struct {
158- HistoryUC *usecase.SearchHistoryUseCase159+ HistoryUC *usecase.SearchHistoryUseCase
159- FavoritesUC *usecase.ManageFavoritesUseCase160+ FavoritesUC *usecase.ManageFavoritesUseCase
160- FaviconAdapter *adapter.FaviconAdapter161+ FaviconAdapter *adapter.FaviconAdapter
161- CopyURLUC *usecase.CopyURLUseCase162+ CopyURLUC *usecase.CopyURLUseCase
162- ShortcutsUC *usecase.SearchShortcutsUseCase163+ ShortcutsUC *usecase.SearchShortcutsUseCase
163- DefaultSearch string164+ DefaultSearch string
164- InitialBehavior entity.OmniboxInitialBehavior165+ // NormalizeNavigationURL resolves navigation input before search fallback.
165- MostVisitedDays int166+ // It is injected so local filesystem probing stays outside the domain URL package.
166- SaveInitialBehavior func(ctx context.Context, behavior entity.OmniboxInitialBehavior) error167+ NormalizeNavigationURL func(ctx context.Context, input string) string
167- UIScale float64 // UI scale for favicon sizing168+ InitialBehavior entity.OmniboxInitialBehavior
169+ MostVisitedDays int
170+ SaveInitialBehavior func(ctx context.Context, behavior entity.OmniboxInitialBehavior) error
171+ UIScale float64 // UI scale for favicon sizing
168 // OnNavigate is called when the user submits a URL; returning nil closes the omnibox.172 // OnNavigate is called when the user submits a URL; returning nil closes the omnibox.
169 OnNavigate func(ctx context.Context, url string) error173 OnNavigate func(ctx context.Context, url string) error
170 OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification174 OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification
@@ -188,23 +192,24 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
188 sizeCfg := ResolveModalSizeConfig(cfg.SizeConfig, OmniboxSizeDefaults)192 sizeCfg := ResolveModalSizeConfig(cfg.SizeConfig, OmniboxSizeDefaults)
189 193
190 o := &Omnibox{194 o := &Omnibox{
191- viewMode: ViewModeHistory,195+ viewMode: ViewModeHistory,
192- selectedIndex: -1,196+ selectedIndex: -1,
193- historyUC: cfg.HistoryUC,197+ historyUC: cfg.HistoryUC,
194- favoritesUC: cfg.FavoritesUC,198+ favoritesUC: cfg.FavoritesUC,
195- faviconAdapter: cfg.FaviconAdapter,199+ faviconAdapter: cfg.FaviconAdapter,
196- copyURLUC: cfg.CopyURLUC,200+ copyURLUC: cfg.CopyURLUC,
197- shortcutsUC: cfg.ShortcutsUC,201+ shortcutsUC: cfg.ShortcutsUC,
198- defaultSearch: cfg.DefaultSearch,202+ defaultSearch: cfg.DefaultSearch,
199- initialBehavior: cfg.InitialBehavior,203+ normalizeNavigationURL: cfg.NormalizeNavigationURL,
200- mostVisitedDays: cfg.MostVisitedDays,204+ initialBehavior: cfg.InitialBehavior,
201- saveInitialBehaviorFn: cfg.SaveInitialBehavior,205+ mostVisitedDays: cfg.MostVisitedDays,
202- onToast: cfg.OnToast,206+ saveInitialBehaviorFn: cfg.SaveInitialBehavior,
203- onAccentKeyPress: cfg.OnAccentKeyPress,207+ onToast: cfg.OnToast,
204- onAccentKeyRelease: cfg.OnAccentKeyRelease,208+ onAccentKeyPress: cfg.OnAccentKeyPress,
205- ctx: ctx,209+ onAccentKeyRelease: cfg.OnAccentKeyRelease,
206- uiScale: uiScale,210+ ctx: ctx,
207- sizeCfg: sizeCfg,211+ uiScale: uiScale,
212+ sizeCfg: sizeCfg,
208 }213 }
209 o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) {214 o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) {
210 var cb glib.SourceFunc = func(uintptr) bool {215 var cb glib.SourceFunc = func(uintptr) bool {
@@ -2617,6 +2622,12 @@ func (o *Omnibox) buildURL(text string) string {
2617 if o.shortcutsUC != nil {2622 if o.shortcutsUC != nil {
2618 shortcutURLs = o.shortcutsUC.ShortcutURLs()2623 shortcutURLs = o.shortcutsUC.ShortcutURLs()
2619 }2624 }
2625+ if _, _, found := url.ParseBangShortcut(text); !found && o.normalizeNavigationURL != nil {
2626+ normalized := o.normalizeNavigationURL(o.ctx, text)
2627+ if normalized != text {
2628+ return normalized
2629+ }
2630+ }
2620 return url.BuildSearchURL(text, shortcutURLs, o.defaultSearch)2631 return url.BuildSearchURL(text, shortcutURLs, o.defaultSearch)
2621 }2632 }
2622 2633
A internal/ui/component/omnibox_build_url_test.go +37 -0
@@ -0,0 +1,37 @@
1+package component
2+
3+import (
4+ "context"
5+ "testing"
6+)
7+
8+func TestOmniboxBuildURLUsesNavigationNormalizerBeforeURLFallback(t *testing.T) {
9+ o := &Omnibox{
10+ ctx: context.Background(),
11+ defaultSearch: "https://search.example/?q=%s",
12+ normalizeNavigationURL: func(_ context.Context, input string) string {
13+ if input == "page.html" {
14+ return "file:///tmp/page.html"
15+ }
16+ return input
17+ },
18+ }
19+
20+ if got := o.buildURL("page.html"); got != "file:///tmp/page.html" {
21+ t.Fatalf("buildURL(page.html) = %q, want local file URL", got)
22+ }
23+}
24+
25+func TestOmniboxBuildURLLeavesBangShortcutsToSearchHandling(t *testing.T) {
26+ o := &Omnibox{
27+ ctx: context.Background(),
28+ defaultSearch: "https://search.example/?q=%s",
29+ normalizeNavigationURL: func(_ context.Context, _ string) string {
30+ return "file:///tmp/page.html"
31+ },
32+ }
33+
34+ if got := o.buildURL("!unknown page.html"); got != "https://search.example/?q=!unknown page.html" {
35+ t.Fatalf("buildURL(unknown bang) = %q, want search fallback", got)
36+ }
37+}
@@ -0,0 +1,37 @@
1+package component
2+
3+import (
4+ "context"
5+ "testing"
6+)
7+
8+func TestOmniboxBuildURLUsesNavigationNormalizerBeforeURLFallback(t *testing.T) {
9+ o := &Omnibox{
10+ ctx: context.Background(),
11+ defaultSearch: "https://search.example/?q=%s",
12+ normalizeNavigationURL: func(_ context.Context, input string) string {
13+ if input == "page.html" {
14+ return "file:///tmp/page.html"
15+ }
16+ return input
17+ },
18+ }
19+
20+ if got := o.buildURL("page.html"); got != "file:///tmp/page.html" {
21+ t.Fatalf("buildURL(page.html) = %q, want local file URL", got)
22+ }
23+}
24+
25+func TestOmniboxBuildURLLeavesBangShortcutsToSearchHandling(t *testing.T) {
26+ o := &Omnibox{
27+ ctx: context.Background(),
28+ defaultSearch: "https://search.example/?q=%s",
29+ normalizeNavigationURL: func(_ context.Context, _ string) string {
30+ return "file:///tmp/page.html"
31+ },
32+ }
33+
34+ if got := o.buildURL("!unknown page.html"); got != "https://search.example/?q=!unknown page.html" {
35+ t.Fatalf("buildURL(unknown bang) = %q, want search fallback", got)
36+ }
37+}
M internal/ui/coordinator/workspace.go +3 -3
@@ -173,12 +173,12 @@ func setupPaneViewHover(ctx context.Context, pv *component.PaneView, wsView *com
173173
174174 // Split splits the active pane in the given direction.
175175 func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.SplitDirection) error {
176- return c.splitWithInitialURL(ctx, direction, domainurl.Normalize(c.newPaneURL))
176+ return c.splitWithInitialURL(ctx, direction, c.newPaneURL)
177177 }
178178
179179 // SplitWithURL splits the active pane in the given direction and loads initialURL.
180180 func (c *WorkspaceCoordinator) SplitWithURL(ctx context.Context, direction usecase.SplitDirection, initialURL string) error {
181- return c.splitWithInitialURL(ctx, direction, domainurl.Normalize(initialURL))
181+ return c.splitWithInitialURL(ctx, direction, initialURL)
182182 }
183183
184184 func (c *WorkspaceCoordinator) splitWithInitialURL(ctx context.Context, direction usecase.SplitDirection, initialURL string) error {
@@ -1547,7 +1547,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
15471547 // Create new pane entity
15481548 newPaneID := entity.PaneID(c.generateID())
15491549 newPane := entity.NewPane(newPaneID)
1550- newPane.URI = domainurl.Normalize(c.newPaneURL)
1550+ newPane.URI = c.panesUC.NormalizeNavigationURL(ctx, c.newPaneURL)
15511551 newPane.Title = defaultPaneTitle
15521552
15531553 // Determine if we need to create a new stack or add to existing
@@ -173,12 +173,12 @@ func setupPaneViewHover(ctx context.Context, pv *component.PaneView, wsView *com
173 173
174 // Split splits the active pane in the given direction.174 // Split splits the active pane in the given direction.
175 func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.SplitDirection) error {175 func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.SplitDirection) error {
176- return c.splitWithInitialURL(ctx, direction, domainurl.Normalize(c.newPaneURL))176+ return c.splitWithInitialURL(ctx, direction, c.newPaneURL)
177 }177 }
178 178
179 // SplitWithURL splits the active pane in the given direction and loads initialURL.179 // SplitWithURL splits the active pane in the given direction and loads initialURL.
180 func (c *WorkspaceCoordinator) SplitWithURL(ctx context.Context, direction usecase.SplitDirection, initialURL string) error {180 func (c *WorkspaceCoordinator) SplitWithURL(ctx context.Context, direction usecase.SplitDirection, initialURL string) error {
181- return c.splitWithInitialURL(ctx, direction, domainurl.Normalize(initialURL))181+ return c.splitWithInitialURL(ctx, direction, initialURL)
182 }182 }
183 183
184 func (c *WorkspaceCoordinator) splitWithInitialURL(ctx context.Context, direction usecase.SplitDirection, initialURL string) error {184 func (c *WorkspaceCoordinator) splitWithInitialURL(ctx context.Context, direction usecase.SplitDirection, initialURL string) error {
@@ -1547,7 +1547,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1547 // Create new pane entity1547 // Create new pane entity
1548 newPaneID := entity.PaneID(c.generateID())1548 newPaneID := entity.PaneID(c.generateID())
1549 newPane := entity.NewPane(newPaneID)1549 newPane := entity.NewPane(newPaneID)
1550- newPane.URI = domainurl.Normalize(c.newPaneURL)1550+ newPane.URI = c.panesUC.NormalizeNavigationURL(ctx, c.newPaneURL)
1551 newPane.Title = defaultPaneTitle1551 newPane.Title = defaultPaneTitle
1552 1552
1553 // Determine if we need to create a new stack or add to existing1553 // Determine if we need to create a new stack or add to existing

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