refactor(url): move local path probing out of domain
- commit
- f4ac668742cf1bcf3183525b17d46943d71b7516
- author
- brice · · 2026-06-25 09:44 UTC
- parent
- c55079e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 790 | 790 | updateChecker := updater.NewGitHubChecker() | |
| 791 | 791 | updateDownloader := updater.NewGitHubDownloader() | |
| 792 | 792 | updateApplier := updater.NewApplier(stateDir) | |
| 793 | + | localPaths := filesystem.New() | |
| 793 | 794 | ||
| 794 | 795 | // Permission use case will be initialized later with dialog presenter | |
| 795 | 796 | permissionUC := usecase.NewHandlePermissionUseCase(repos.permission, nil, logging.FromContext) | |
| @@ -800,8 +801,8 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases { | |||
| 800 | 801 | historyUC.SetHistoryMutationCoordinator(historyRecorderUC) | |
| 801 | 802 | ||
| 802 | 803 | 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)), | |
| 805 | 806 | history: historyUC, | |
| 806 | 807 | favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag), | |
| 807 | 808 | 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 presenter | 795 | // 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 ( | |||
| 8 | 8 | "sort" | |
| 9 | 9 | "strings" | |
| 10 | 10 | ||
| 11 | + | "github.com/bnema/dumber/internal/application/port" | |
| 11 | 12 | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | - | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 13 | 13 | "github.com/bnema/dumber/internal/logging" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| @@ -72,13 +72,37 @@ const consumeOrExpelExpelCycleMarker = "_expel_cycle" | |||
| 72 | 72 | // ManagePanesUseCase handles pane tree operations. | |
| 73 | 73 | type ManagePanesUseCase struct { | |
| 74 | 74 | 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 | 88 | // 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{ | |
| 80 | 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 | 108 | // SplitPaneInput contains parameters for splitting a pane. | |
| @@ -138,7 +162,7 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) ( | |||
| 138 | 162 | paneID := entity.PaneID(uc.idGenerator()) | |
| 139 | 163 | newPane = entity.NewPane(paneID) | |
| 140 | 164 | if input.InitialURL != "" { | |
| 141 | - | newPane.URI = domainurl.Normalize(input.InitialURL) | |
| 165 | + | newPane.URI = uc.normalizer.normalize(ctx, input.InitialURL) | |
| 142 | 166 | } else { | |
| 143 | 167 | newPane.URI = "about:blank" | |
| 144 | 168 | } | |
| @@ -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 IDGenerator | 74 | 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 ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | - | "github.com/bnema/dumber/internal/domain/url" | |
| 9 | 9 | "github.com/bnema/dumber/internal/logging" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| @@ -15,13 +15,29 @@ type IDGenerator func() string | |||
| 15 | 15 | // ManageTabsUseCase handles tab lifecycle operations. | |
| 16 | 16 | type ManageTabsUseCase struct { | |
| 17 | 17 | 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 | 31 | // 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{ | |
| 23 | 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 | 43 | // CreateTabInput contains parameters for creating a new tab. | |
| @@ -58,7 +74,7 @@ func (uc *ManageTabsUseCase) Create(ctx context.Context, input CreateTabInput) ( | |||
| 58 | 74 | // Create initial pane with normalized URL | |
| 59 | 75 | pane := entity.NewPane(paneID) | |
| 60 | 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 | 80 | // Create tab with workspace | |
| @@ -355,7 +371,7 @@ func (uc *ManageTabsUseCase) CreateWithPane(ctx context.Context, input CreateTab | |||
| 355 | 371 | // Use the provided pane | |
| 356 | 372 | pane := input.Pane | |
| 357 | 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 | 377 | // 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 IDGenerator | 17 | 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 URL | 74 | // 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 workspace | 80 | // Create tab with workspace | ||
| @@ -355,7 +371,7 @@ func (uc *ManageTabsUseCase) CreateWithPane(ctx context.Context, input CreateTab | |||||
| 355 | // Use the provided pane | 371 | // Use the provided pane | ||
| 356 | pane := input.Pane | 372 | 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 pane | 377 | // 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 ( | |||
| 18 | 18 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 19 | 19 | "github.com/bnema/dumber/internal/infrastructure/externaltheme/noctalia" | |
| 20 | 20 | "github.com/bnema/dumber/internal/infrastructure/favicon" | |
| 21 | + | "github.com/bnema/dumber/internal/infrastructure/filesystem" | |
| 21 | 22 | infralogging "github.com/bnema/dumber/internal/infrastructure/logging" | |
| 22 | 23 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 23 | 24 | "github.com/bnema/dumber/internal/logging" | |
| @@ -39,8 +40,10 @@ type App struct { | |||
| 39 | 40 | DeleteSessionUC *usecase.DeleteSessionUseCase | |
| 40 | 41 | ||
| 41 | 42 | // 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 | |
| 44 | 47 | ||
| 45 | 48 | // Context with logger | |
| 46 | 49 | ctx context.Context | |
| @@ -128,21 +131,24 @@ func NewApp() (*App, error) { | |||
| 128 | 131 | // Create favicon service for CLI (path resolution for dmenu/fuzzel) | |
| 129 | 132 | faviconCacheDir, _ := config.GetFaviconCacheDir() | |
| 130 | 133 | faviconService := favicon.NewService(faviconCacheDir) | |
| 134 | + | localPaths := filesystem.New() | |
| 131 | 135 | ||
| 132 | 136 | 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, | |
| 146 | 152 | }, nil | |
| 147 | 153 | } | |
| 148 | 154 | ||
| @@ -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.DeleteSessionUseCase | 40 | DeleteSessionUC *usecase.DeleteSessionUseCase | ||
| 40 | 41 | ||||
| 41 | // Services | 42 | // Services | ||
| 42 | - | FaviconService *favicon.Service | 43 | + | FaviconService *favicon.Service |
| 43 | - | SessionSpawner port.SessionSpawner | 44 | + | SessionSpawner port.SessionSpawner |
| 45 | + | LocalPaths port.LocalPathResolver | |||
| 46 | + | NavigationURLNormalizer *usecase.NavigationURLNormalizer | |||
| 44 | 47 | ||||
| 45 | // Context with logger | 48 | // Context with logger | ||
| 46 | ctx context.Context | 49 | 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 | }, nil | 152 | }, nil | ||
| 147 | } | 153 | } | ||
| 148 | 154 | ||||
M internal/cli/cmd/dmenu.go +14 -1
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "github.com/spf13/cobra" | |
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/dumber/assets" | |
| 15 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 15 | 16 | "github.com/bnema/dumber/internal/cli/model" | |
| 16 | 17 | "github.com/bnema/dumber/internal/domain/entity" | |
| 17 | 18 | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| @@ -217,9 +218,21 @@ func parseSelection(selection string) string { | |||
| 217 | 218 | return "" | |
| 218 | 219 | } | |
| 219 | 220 | ||
| 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. | |
| 221 | 224 | app := GetApp() | |
| 222 | 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 | 236 | return domainurl.BuildSearchURL(selection, app.Config.ShortcutURLs(), app.Config.DefaultSearchEngine) | |
| 224 | 237 | } | |
| 225 | 238 | ||
| @@ -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 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. | |||
| 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 ( | |||
| 7 | 7 | "fmt" | |
| 8 | 8 | "net" | |
| 9 | 9 | "net/url" | |
| 10 | - | "os" | |
| 11 | - | "path/filepath" | |
| 12 | 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 | 13 | // looksLikeFilePath returns true if the input looks like a filesystem path. | |
| 26 | 14 | // This includes absolute paths (/path), relative paths (./path, ../path), | |
| 27 | 15 | // and home-relative paths (~/path). | |
| @@ -41,7 +29,7 @@ func looksLikeFilePath(input string) bool { | |||
| 41 | 29 | ||
| 42 | 30 | // Normalize adds https:// prefix if missing for URL-like inputs. | |
| 43 | 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 | 33 | func Normalize(input string) string { | |
| 46 | 34 | if input == "" { | |
| 47 | 35 | return "" | |
| @@ -65,29 +53,11 @@ func Normalize(input string) string { | |||
| 65 | 53 | return input | |
| 66 | 54 | } | |
| 67 | 55 | ||
| 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. | |
| 69 | 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 | 58 | 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 | 61 | // Looks like a URL (contains . and no spaces) | |
| 92 | 62 | if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") { | |
| 93 | 63 | 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 input | 53 | return input | ||
| 66 | } | 54 | } | ||
| 67 | 55 | ||||
| 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. |
| 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 input | 58 | 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://" + input | 63 | return "http://" + input | ||
M internal/domain/url/normalize_test.go +7 -116
| @@ -1,8 +1,6 @@ | |||
| 1 | 1 | package url | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "os" | |
| 5 | - | "path/filepath" | |
| 6 | 4 | "strings" | |
| 7 | 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) { | |
| 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) { | |
| 213 | 122 | tests := []struct { | |
| 214 | 123 | name string | |
| 215 | 124 | input string | |
| 216 | - | want string | |
| 217 | 125 | }{ | |
| 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"}, | |
| 238 | 130 | } | |
| 239 | 131 | ||
| 240 | 132 | for _, tt := range tests { | |
| 241 | 133 | 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) | |
| 245 | 136 | } | |
| 246 | 137 | }) | |
| 247 | 138 | } | |
| @@ -1,8 +1,6 @@ | |||||
| 1 | package url | 1 | 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 string | 123 | name string | ||
| 215 | input string | 124 | 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 ( | |||
| 4 | 4 | "fmt" | |
| 5 | 5 | "math" | |
| 6 | 6 | "net/url" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 7 | 9 | "sort" | |
| 8 | 10 | "strings" | |
| 9 | 11 | ||
| @@ -443,6 +445,9 @@ func validateWorkspaceNewPaneURL(config *Config) []string { | |||
| 443 | 445 | ||
| 444 | 446 | func validateWorkspaceURLValue(fieldPath, value string) []string { | |
| 445 | 447 | trimmed := strings.TrimSpace(value) | |
| 448 | + | if isLocalPathLikeWorkspaceURL(trimmed) || isExistingRelativeWorkspacePath(trimmed) { | |
| 449 | + | return nil | |
| 450 | + | } | |
| 446 | 451 | candidate := trimmed | |
| 447 | 452 | parsed, err := url.Parse(candidate) | |
| 448 | 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 | 492 | func validateOmnibox(config *Config) []string { | |
| 469 | 493 | var validationErrors []string | |
| 470 | 494 | 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 := trimmed | 451 | 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 []string | 493 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "math" | |
| 5 | + | "os" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| 7 | 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 | 208 | func TestValidateConfig_WebKitDefaultProfileIgnoresZeroGPUThreads(t *testing.T) { | |
| 173 | 209 | cfg := DefaultConfig() | |
| 174 | 210 | 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 = ProfileDefault | 210 | 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 { | |||
| 67 | 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 | 97 | 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) { | |||
| 1437 | 1437 | } | |
| 1438 | 1438 | ||
| 1439 | 1439 | 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) | |
| 1446 | 1447 | } | |
| 1447 | 1448 | ||
| 1448 | 1449 | func buildOmniboxConfig( | |
| @@ -1464,22 +1465,23 @@ func buildOmniboxConfig( | |||
| 1464 | 1465 | } | |
| 1465 | 1466 | ||
| 1466 | 1467 | 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, | |
| 1483 | 1485 | } | |
| 1484 | 1486 | } | |
| 1485 | 1487 | ||
| @@ -1506,10 +1508,19 @@ func NewStandaloneOmniboxRuntime( | |||
| 1506 | 1508 | if deps != nil && deps.RuntimeConfig != nil { | |
| 1507 | 1509 | 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 | 1519 | omniboxCfg := buildOmniboxConfig(deps, runtimeCfg, faviconAdapter, omniboxCallbacks{ | |
| 1510 | 1520 | OnNavigate: func(navCtx context.Context, url string) error { | |
| 1511 | 1521 | return handleStandaloneOmniboxNavigation(deps, navCtx, url) | |
| 1512 | 1522 | }, | |
| 1523 | + | NormalizeNavigationURL: normalizeNavigationURL, | |
| 1513 | 1524 | OnToast: func(toastCtx context.Context, message string, level component.ToastLevel) { | |
| 1514 | 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 | 1559 | return | |
| 1549 | 1560 | } | |
| 1550 | 1561 | ||
| 1562 | + | navigationURLNormalizer := usecase.NewNavigationURLNormalizer(nil) | |
| 1551 | 1563 | a.omniboxCfg = buildOmniboxConfig(a.deps, a.runtimeConfigSnapshot().UI, a.faviconAdapter, omniboxCallbacks{ | |
| 1552 | 1564 | OnNavigate: func(navCtx context.Context, url string) error { | |
| 1553 | 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 | 1573 | OnToast: func(toastCtx context.Context, message string, level component.ToastLevel) { | |
| 1556 | 1574 | a.showToastOnLastFocusedBrowserWindow(toastCtx, message, level) | |
| 1557 | 1575 | }, | |
| @@ -2599,7 +2617,7 @@ func (a *App) switchBrowserWindowTabIndex(ctx context.Context, bw *browserWindow | |||
| 2599 | 2617 | } | |
| 2600 | 2618 | a.activateBrowserWindow(bw) | |
| 2601 | 2619 | ensureTarget := a.ensureTabTargetForBrowserWindow(bw) | |
| 2602 | - | _, err := a.tabCoord.Create(ctx, ensureTarget, urlutil.Normalize(newPaneURL)) | |
| 2620 | + | _, err := a.tabCoord.Create(ctx, ensureTarget, newPaneURL) | |
| 2603 | 2621 | return err | |
| 2604 | 2622 | } | |
| 2605 | 2623 | ||
| @@ -3507,7 +3525,7 @@ func (a *App) keyboardActions() dispatcher.KeyboardActions { | |||
| 3507 | 3525 | return fmt.Errorf("newPaneURL is not configured") | |
| 3508 | 3526 | } | |
| 3509 | 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 | 3529 | return err | |
| 3512 | 3530 | }) | |
| 3513 | 3531 | }, | |
| @@ -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) error | 1440 | + | 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) bool | 1444 | + | 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().UI | 1509 | 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 | return | 1559 | 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 err | 2621 | 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 err | 3529 | return err | ||
| 3512 | }) | 3530 | }) | ||
| 3513 | }, | 3531 | }, | ||
M internal/ui/app_omnibox_config_test.go +12 -1
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package ui | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 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 | 32 | if got.MostVisitedDays != 7 { | |
| 28 | 33 | t.Fatalf("MostVisitedDays = %d, want 7", got.MostVisitedDays) | |
| 29 | 34 | } | |
| @@ -39,4 +44,10 @@ func TestBuildOmniboxConfigUsesRuntimeUIConfig(t *testing.T) { | |||
| 39 | 44 | if got.ShortcutsUC == nil { | |
| 40 | 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 | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package ui | 1 | 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 { | |||
| 108 | 108 | isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText | |
| 109 | 109 | ||
| 110 | 110 | // 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 | |
| 121 | 122 | ||
| 122 | 123 | // Callbacks | |
| 123 | 124 | onNavigate func(ctx context.Context, url string) error | |
| @@ -155,16 +156,19 @@ type Omnibox struct { | |||
| 155 | 156 | ||
| 156 | 157 | // OmniboxConfig holds configuration for creating an Omnibox. | |
| 157 | 158 | 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 | |
| 168 | 172 | // OnNavigate is called when the user submits a URL; returning nil closes the omnibox. | |
| 169 | 173 | OnNavigate func(ctx context.Context, url string) error | |
| 170 | 174 | 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 | 192 | sizeCfg := ResolveModalSizeConfig(cfg.SizeConfig, OmniboxSizeDefaults) | |
| 189 | 193 | ||
| 190 | 194 | 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, | |
| 208 | 213 | } | |
| 209 | 214 | o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) { | |
| 210 | 215 | var cb glib.SourceFunc = func(uintptr) bool { | |
| @@ -2617,6 +2622,12 @@ func (o *Omnibox) buildURL(text string) string { | |||
| 2617 | 2622 | if o.shortcutsUC != nil { | |
| 2618 | 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 | 2631 | return url.BuildSearchURL(text, shortcutURLs, o.defaultSearch) | |
| 2621 | 2632 | } | |
| 2622 | 2633 | ||
| @@ -108,16 +108,17 @@ type Omnibox struct { | |||||
| 108 | isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText | 108 | isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText | ||
| 109 | 109 | ||||
| 110 | // Dependencies | 110 | // Dependencies | ||
| 111 | - | historyUC *usecase.SearchHistoryUseCase | 111 | + | historyUC *usecase.SearchHistoryUseCase |
| 112 | - | favoritesUC *usecase.ManageFavoritesUseCase | 112 | + | favoritesUC *usecase.ManageFavoritesUseCase |
| 113 | - | faviconAdapter *adapter.FaviconAdapter | 113 | + | faviconAdapter *adapter.FaviconAdapter |
| 114 | - | copyURLUC *usecase.CopyURLUseCase | 114 | + | copyURLUC *usecase.CopyURLUseCase |
| 115 | - | shortcutsUC *usecase.SearchShortcutsUseCase | 115 | + | shortcutsUC *usecase.SearchShortcutsUseCase |
| 116 | - | defaultSearch string | 116 | + | defaultSearch string |
| 117 | - | initialBehavior entity.OmniboxInitialBehavior | 117 | + | normalizeNavigationURL func(ctx context.Context, input string) string |
| 118 | - | mostVisitedDays int | 118 | + | initialBehavior entity.OmniboxInitialBehavior |
| 119 | - | saveInitialBehaviorFn func(context.Context, entity.OmniboxInitialBehavior) error | 119 | + | mostVisitedDays int |
| 120 | - | ctx context.Context | 120 | + | saveInitialBehaviorFn func(context.Context, entity.OmniboxInitialBehavior) error |
| 121 | + | ctx context.Context | |||
| 121 | 122 | ||||
| 122 | // Callbacks | 123 | // Callbacks | ||
| 123 | onNavigate func(ctx context.Context, url string) error | 124 | 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.SearchHistoryUseCase | 159 | + | HistoryUC *usecase.SearchHistoryUseCase |
| 159 | - | FavoritesUC *usecase.ManageFavoritesUseCase | 160 | + | FavoritesUC *usecase.ManageFavoritesUseCase |
| 160 | - | FaviconAdapter *adapter.FaviconAdapter | 161 | + | FaviconAdapter *adapter.FaviconAdapter |
| 161 | - | CopyURLUC *usecase.CopyURLUseCase | 162 | + | CopyURLUC *usecase.CopyURLUseCase |
| 162 | - | ShortcutsUC *usecase.SearchShortcutsUseCase | 163 | + | ShortcutsUC *usecase.SearchShortcutsUseCase |
| 163 | - | DefaultSearch string | 164 | + | DefaultSearch string |
| 164 | - | InitialBehavior entity.OmniboxInitialBehavior | 165 | + | // NormalizeNavigationURL resolves navigation input before search fallback. |
| 165 | - | MostVisitedDays int | 166 | + | // It is injected so local filesystem probing stays outside the domain URL package. |
| 166 | - | SaveInitialBehavior func(ctx context.Context, behavior entity.OmniboxInitialBehavior) error | 167 | + | NormalizeNavigationURL func(ctx context.Context, input string) string |
| 167 | - | UIScale float64 // UI scale for favicon sizing | 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 | |||
| 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) error | 173 | OnNavigate func(ctx context.Context, url string) error | ||
| 170 | OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification | 174 | 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 | |||
| 173 | 173 | ||
| 174 | 174 | // Split splits the active pane in the given direction. | |
| 175 | 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 | 179 | // SplitWithURL splits the active pane in the given direction and loads initialURL. | |
| 180 | 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 | 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 | 1547 | // Create new pane entity | |
| 1548 | 1548 | newPaneID := entity.PaneID(c.generateID()) | |
| 1549 | 1549 | newPane := entity.NewPane(newPaneID) | |
| 1550 | - | newPane.URI = domainurl.Normalize(c.newPaneURL) | |
| 1550 | + | newPane.URI = c.panesUC.NormalizeNavigationURL(ctx, c.newPaneURL) | |
| 1551 | 1551 | newPane.Title = defaultPaneTitle | |
| 1552 | 1552 | ||
| 1553 | 1553 | // 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 entity | 1547 | // 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 = defaultPaneTitle | 1551 | newPane.Title = defaultPaneTitle | ||
| 1552 | 1552 | ||||
| 1553 | // Determine if we need to create a new stack or add to existing | 1553 | // Determine if we need to create a new stack or add to existing | ||