detached fd1a5db

fix: address PR review findings

commit
fd1a5db925ae175e0ee4fe04e01777bd8be1afb1
author
brice · · 2026-06-25 21:27 UTC
parent
895ec25
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +574 -51
M .mockery.yaml +1 -0
@@ -107,6 +107,7 @@ packages:
107107 FaviconRepository: {}
108108 FaviconService: {}
109109 FileSystem: {}
110+ LocalPathResolver: {}
110111 FilterManager: {}
111112 Focusable: {}
112113 FindController: {}
@@ -107,6 +107,7 @@ packages:
107 FaviconRepository: {}107 FaviconRepository: {}
108 FaviconService: {}108 FaviconService: {}
109 FileSystem: {}109 FileSystem: {}
110+ LocalPathResolver: {}
110 FilterManager: {}111 FilterManager: {}
111 Focusable: {}112 Focusable: {}
112 FindController: {}113 FindController: {}
M docs/config/index.md +6 -0
@@ -630,6 +630,7 @@ dumber doctor --media
630630 - `XDG_DATA_DIRS` (schemas/resources)
631631
632632 **Example:**
633+
633634 ```toml
634635 [engine.cef]
635636 cef_dir = "/opt/cef"
@@ -647,6 +648,7 @@ prefix = "/opt/webkitgtk"
647648 When enabled, selecting text in a web page immediately copies it to the clipboard with a brief toast notification. Does not apply to text selection in input fields or textareas.
648649
649650 **Example:**
651+
650652 ```toml
651653 [clipboard]
652654 auto_copy_on_selection = true # Enabled by default
@@ -672,6 +674,7 @@ Notes:
672674 | `update.notify_on_new_settings` | bool | `true` | Show toast notification when new config settings are available |
673675
674676 **Example:**
677+
675678 ```toml
676679 [update]
677680 enable_on_startup = true # Check for updates on startup
@@ -680,6 +683,7 @@ notify_on_new_settings = true # Show toast when config migration available
680683 ```
681684
682685 **CLI Commands:**
686+
683687 ```bash
684688 # Check config status and available migrations
685689 dumber config status
@@ -714,6 +718,7 @@ Performance profiles tune engine resource behavior. CEF is the default engine; t
714718 | `custom` | Manual control over all settings | Advanced users who want fine-grained control |
715719
716720 **Example:**
721+
717722 ```toml
718723 [engine]
719724 profile = "default"
@@ -740,6 +745,7 @@ When `engine.profile = "custom"` and `engine.type = "webkit"`, you can configure
740745 | `engine.webkit.network_process_memory_strict_threshold` | float | `0` | Network strict threshold |
741746
742747 **Custom WebKit fallback example:**
748+
743749 ```toml
744750 [engine]
745751 type = "webkit"
@@ -630,6 +630,7 @@ dumber doctor --media
630 - `XDG_DATA_DIRS` (schemas/resources)630 - `XDG_DATA_DIRS` (schemas/resources)
631 631
632 **Example:**632 **Example:**
633+
633 ```toml634 ```toml
634 [engine.cef]635 [engine.cef]
635 cef_dir = "/opt/cef"636 cef_dir = "/opt/cef"
@@ -647,6 +648,7 @@ prefix = "/opt/webkitgtk"
647 When enabled, selecting text in a web page immediately copies it to the clipboard with a brief toast notification. Does not apply to text selection in input fields or textareas.648 When enabled, selecting text in a web page immediately copies it to the clipboard with a brief toast notification. Does not apply to text selection in input fields or textareas.
648 649
649 **Example:**650 **Example:**
651+
650 ```toml652 ```toml
651 [clipboard]653 [clipboard]
652 auto_copy_on_selection = true # Enabled by default654 auto_copy_on_selection = true # Enabled by default
@@ -672,6 +674,7 @@ Notes:
672 | `update.notify_on_new_settings` | bool | `true` | Show toast notification when new config settings are available |674 | `update.notify_on_new_settings` | bool | `true` | Show toast notification when new config settings are available |
673 675
674 **Example:**676 **Example:**
677+
675 ```toml678 ```toml
676 [update]679 [update]
677 enable_on_startup = true # Check for updates on startup680 enable_on_startup = true # Check for updates on startup
@@ -680,6 +683,7 @@ notify_on_new_settings = true # Show toast when config migration available
680 ```683 ```
681 684
682 **CLI Commands:**685 **CLI Commands:**
686+
683 ```bash687 ```bash
684 # Check config status and available migrations688 # Check config status and available migrations
685 dumber config status689 dumber config status
@@ -714,6 +718,7 @@ Performance profiles tune engine resource behavior. CEF is the default engine; t
714 | `custom` | Manual control over all settings | Advanced users who want fine-grained control |718 | `custom` | Manual control over all settings | Advanced users who want fine-grained control |
715 719
716 **Example:**720 **Example:**
721+
717 ```toml722 ```toml
718 [engine]723 [engine]
719 profile = "default"724 profile = "default"
@@ -740,6 +745,7 @@ When `engine.profile = "custom"` and `engine.type = "webkit"`, you can configure
740 | `engine.webkit.network_process_memory_strict_threshold` | float | `0` | Network strict threshold |745 | `engine.webkit.network_process_memory_strict_threshold` | float | `0` | Network strict threshold |
741 746
742 **Custom WebKit fallback example:**747 **Custom WebKit fallback example:**
748+
743 ```toml749 ```toml
744 [engine]750 [engine]
745 type = "webkit"751 type = "webkit"
M docs/reference/troubleshooting.md +14 -5
@@ -17,7 +17,7 @@ dumber doctor --media # GStreamer/VA-API only
1717 **Symptoms:** Error about the default CEF runtime, GTK libraries, or WebKitGTK fallback version.
1818
1919 **Solution:**
20-1. Run `dumber doctor --runtime`.
20+1. If the error mentions GTK or the WebKit fallback, run `dumber doctor --runtime`.
2121 2. Match the fix to the backend in the error:
2222 - **Default CEF backend:** install or provide a CEF runtime. On Arch, use `pacman -S cef`. On Fedora or Ubuntu, install an available CEF runtime/package or set `engine.cef.cef_dir` to a downloaded runtime.
2323 - **GTK/WebKit fallback backend:** install GTK4 and WebKitGTK. On Arch, use `pacman -S webkitgtk-6.0 gtk4`. On Fedora, install GTK4 and WebKitGTK packages from your distribution. On Ubuntu, use `apt install libwebkitgtk-6.0-4 libgtk-4-1`.
@@ -28,15 +28,19 @@ dumber doctor --media # GStreamer/VA-API only
2828
2929 **Solution:**
3030 1. Run `dumber doctor --media`
31-2. Enable diagnostic mode for startup media warnings:
31+2. If the issue occurs with `engine.type = "webkit"`, enable GStreamer startup diagnostics:
3232
3333 ```toml
3434 # ~/.config/dumber/config.toml
3535 [media]
3636 show_diagnostics = true
37+
38+ [engine.webkit]
3739 gstreamer_debug_level = 3
3840 ```
3941
42+ For the default CEF backend, skip `engine.webkit.gstreamer_debug_level`; it only affects the WebKit fallback.
43+
4044 3. Check hardware decoding:
4145
4246 ```toml
@@ -49,11 +53,13 @@ dumber doctor --media # GStreamer/VA-API only
4953 - AMD: `libva-mesa-driver`
5054 - Intel: `intel-media-driver`
5155 - NVIDIA: `libva-nvidia-driver`
52-5. After diagnosing, disable diagnostic mode for quieter daily runs:
56+5. After diagnosing the WebKit fallback, disable diagnostic mode for quieter daily runs:
5357
5458 ```toml
5559 [media]
5660 show_diagnostics = false
61+
62+ [engine.webkit]
5763 gstreamer_debug_level = 0
5864 ```
5965
@@ -62,8 +68,9 @@ dumber doctor --media # GStreamer/VA-API only
6268 **Symptoms:** Flicker when scrolling or rendering while using `engine.type = "webkit"`
6369
6470 **Solution:**
71+
6572 ```toml
66-[rendering]
73+[engine.webkit]
6774 disable_dmabuf_renderer = true
6875 ```
6976
@@ -72,6 +79,7 @@ disable_dmabuf_renderer = true
7279 **Symptoms:** Missing characters, wrong font rendering
7380
7481 **Solution:**
82+
7583 ```toml
7684 [appearance]
7785 sans_font = "Your Preferred Font"
@@ -84,8 +92,9 @@ default_font_size = 16
8492 **Symptoms:** Browser uses too much RAM
8593
8694 **Solution:**
95+
8796 ```toml
88-[performance]
97+[engine]
8998 profile = "lite" # Reduces resource usage
9099 ```
91100
@@ -17,7 +17,7 @@ dumber doctor --media # GStreamer/VA-API only
17 **Symptoms:** Error about the default CEF runtime, GTK libraries, or WebKitGTK fallback version.17 **Symptoms:** Error about the default CEF runtime, GTK libraries, or WebKitGTK fallback version.
18 18
19 **Solution:**19 **Solution:**
20-1. Run `dumber doctor --runtime`.20+1. If the error mentions GTK or the WebKit fallback, run `dumber doctor --runtime`.
21 2. Match the fix to the backend in the error:21 2. Match the fix to the backend in the error:
22 - **Default CEF backend:** install or provide a CEF runtime. On Arch, use `pacman -S cef`. On Fedora or Ubuntu, install an available CEF runtime/package or set `engine.cef.cef_dir` to a downloaded runtime.22 - **Default CEF backend:** install or provide a CEF runtime. On Arch, use `pacman -S cef`. On Fedora or Ubuntu, install an available CEF runtime/package or set `engine.cef.cef_dir` to a downloaded runtime.
23 - **GTK/WebKit fallback backend:** install GTK4 and WebKitGTK. On Arch, use `pacman -S webkitgtk-6.0 gtk4`. On Fedora, install GTK4 and WebKitGTK packages from your distribution. On Ubuntu, use `apt install libwebkitgtk-6.0-4 libgtk-4-1`.23 - **GTK/WebKit fallback backend:** install GTK4 and WebKitGTK. On Arch, use `pacman -S webkitgtk-6.0 gtk4`. On Fedora, install GTK4 and WebKitGTK packages from your distribution. On Ubuntu, use `apt install libwebkitgtk-6.0-4 libgtk-4-1`.
@@ -28,15 +28,19 @@ dumber doctor --media # GStreamer/VA-API only
28 28
29 **Solution:**29 **Solution:**
30 1. Run `dumber doctor --media`30 1. Run `dumber doctor --media`
31-2. Enable diagnostic mode for startup media warnings:31+2. If the issue occurs with `engine.type = "webkit"`, enable GStreamer startup diagnostics:
32 32
33 ```toml33 ```toml
34 # ~/.config/dumber/config.toml34 # ~/.config/dumber/config.toml
35 [media]35 [media]
36 show_diagnostics = true36 show_diagnostics = true
37+
38+ [engine.webkit]
37 gstreamer_debug_level = 339 gstreamer_debug_level = 3
38 ```40 ```
39 41
42+ For the default CEF backend, skip `engine.webkit.gstreamer_debug_level`; it only affects the WebKit fallback.
43+
40 3. Check hardware decoding:44 3. Check hardware decoding:
41 45
42 ```toml46 ```toml
@@ -49,11 +53,13 @@ dumber doctor --media # GStreamer/VA-API only
49 - AMD: `libva-mesa-driver`53 - AMD: `libva-mesa-driver`
50 - Intel: `intel-media-driver`54 - Intel: `intel-media-driver`
51 - NVIDIA: `libva-nvidia-driver`55 - NVIDIA: `libva-nvidia-driver`
52-5. After diagnosing, disable diagnostic mode for quieter daily runs:56+5. After diagnosing the WebKit fallback, disable diagnostic mode for quieter daily runs:
53 57
54 ```toml58 ```toml
55 [media]59 [media]
56 show_diagnostics = false60 show_diagnostics = false
61+
62+ [engine.webkit]
57 gstreamer_debug_level = 063 gstreamer_debug_level = 0
58 ```64 ```
59 65
@@ -62,8 +68,9 @@ dumber doctor --media # GStreamer/VA-API only
62 **Symptoms:** Flicker when scrolling or rendering while using `engine.type = "webkit"`68 **Symptoms:** Flicker when scrolling or rendering while using `engine.type = "webkit"`
63 69
64 **Solution:**70 **Solution:**
71+
65 ```toml72 ```toml
66-[rendering]73+[engine.webkit]
67 disable_dmabuf_renderer = true74 disable_dmabuf_renderer = true
68 ```75 ```
69 76
@@ -72,6 +79,7 @@ disable_dmabuf_renderer = true
72 **Symptoms:** Missing characters, wrong font rendering79 **Symptoms:** Missing characters, wrong font rendering
73 80
74 **Solution:**81 **Solution:**
82+
75 ```toml83 ```toml
76 [appearance]84 [appearance]
77 sans_font = "Your Preferred Font"85 sans_font = "Your Preferred Font"
@@ -84,8 +92,9 @@ default_font_size = 16
84 **Symptoms:** Browser uses too much RAM92 **Symptoms:** Browser uses too much RAM
85 93
86 **Solution:**94 **Solution:**
95+
87 ```toml96 ```toml
88-[performance]97+[engine]
89 profile = "lite" # Reduces resource usage98 profile = "lite" # Reduces resource usage
90 ```99 ```
91 100
A internal/application/port/mocks/local_path_resolver.go +110 -0
@@ -0,0 +1,110 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// NewMockLocalPathResolver creates a new instance of MockLocalPathResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func NewMockLocalPathResolver(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *MockLocalPathResolver {
19+ mock := &MockLocalPathResolver{}
20+ mock.Mock.Test(t)
21+
22+ t.Cleanup(func() { mock.AssertExpectations(t) })
23+
24+ return mock
25+}
26+
27+// MockLocalPathResolver is an autogenerated mock type for the LocalPathResolver type
28+type MockLocalPathResolver struct {
29+ mock.Mock
30+}
31+
32+type MockLocalPathResolver_Expecter struct {
33+ mock *mock.Mock
34+}
35+
36+func (_m *MockLocalPathResolver) EXPECT() *MockLocalPathResolver_Expecter {
37+ return &MockLocalPathResolver_Expecter{mock: &_m.Mock}
38+}
39+
40+// ResolveExistingPath provides a mock function for the type MockLocalPathResolver
41+func (_mock *MockLocalPathResolver) ResolveExistingPath(ctx context.Context, input string) (string, bool, error) {
42+ ret := _mock.Called(ctx, input)
43+
44+ if len(ret) == 0 {
45+ panic("no return value specified for ResolveExistingPath")
46+ }
47+
48+ var r0 string
49+ var r1 bool
50+ var r2 error
51+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, bool, error)); ok {
52+ return returnFunc(ctx, input)
53+ }
54+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok {
55+ r0 = returnFunc(ctx, input)
56+ } else {
57+ r0 = ret.Get(0).(string)
58+ }
59+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) bool); ok {
60+ r1 = returnFunc(ctx, input)
61+ } else {
62+ r1 = ret.Get(1).(bool)
63+ }
64+ if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok {
65+ r2 = returnFunc(ctx, input)
66+ } else {
67+ r2 = ret.Error(2)
68+ }
69+ return r0, r1, r2
70+}
71+
72+// MockLocalPathResolver_ResolveExistingPath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveExistingPath'
73+type MockLocalPathResolver_ResolveExistingPath_Call struct {
74+ *mock.Call
75+}
76+
77+// ResolveExistingPath is a helper method to define mock.On call
78+// - ctx context.Context
79+// - input string
80+func (_e *MockLocalPathResolver_Expecter) ResolveExistingPath(ctx any, input any) *MockLocalPathResolver_ResolveExistingPath_Call {
81+ return &MockLocalPathResolver_ResolveExistingPath_Call{Call: _e.mock.On("ResolveExistingPath", ctx, input)}
82+}
83+
84+func (_c *MockLocalPathResolver_ResolveExistingPath_Call) Run(run func(ctx context.Context, input string)) *MockLocalPathResolver_ResolveExistingPath_Call {
85+ _c.Call.Run(func(args mock.Arguments) {
86+ var arg0 context.Context
87+ if args[0] != nil {
88+ arg0 = args[0].(context.Context)
89+ }
90+ var arg1 string
91+ if args[1] != nil {
92+ arg1 = args[1].(string)
93+ }
94+ run(
95+ arg0,
96+ arg1,
97+ )
98+ })
99+ return _c
100+}
101+
102+func (_c *MockLocalPathResolver_ResolveExistingPath_Call) Return(s string, b bool, err error) *MockLocalPathResolver_ResolveExistingPath_Call {
103+ _c.Call.Return(s, b, err)
104+ return _c
105+}
106+
107+func (_c *MockLocalPathResolver_ResolveExistingPath_Call) RunAndReturn(run func(ctx context.Context, input string) (string, bool, error)) *MockLocalPathResolver_ResolveExistingPath_Call {
108+ _c.Call.Return(run)
109+ return _c
110+}
@@ -0,0 +1,110 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// NewMockLocalPathResolver creates a new instance of MockLocalPathResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func NewMockLocalPathResolver(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *MockLocalPathResolver {
19+ mock := &MockLocalPathResolver{}
20+ mock.Mock.Test(t)
21+
22+ t.Cleanup(func() { mock.AssertExpectations(t) })
23+
24+ return mock
25+}
26+
27+// MockLocalPathResolver is an autogenerated mock type for the LocalPathResolver type
28+type MockLocalPathResolver struct {
29+ mock.Mock
30+}
31+
32+type MockLocalPathResolver_Expecter struct {
33+ mock *mock.Mock
34+}
35+
36+func (_m *MockLocalPathResolver) EXPECT() *MockLocalPathResolver_Expecter {
37+ return &MockLocalPathResolver_Expecter{mock: &_m.Mock}
38+}
39+
40+// ResolveExistingPath provides a mock function for the type MockLocalPathResolver
41+func (_mock *MockLocalPathResolver) ResolveExistingPath(ctx context.Context, input string) (string, bool, error) {
42+ ret := _mock.Called(ctx, input)
43+
44+ if len(ret) == 0 {
45+ panic("no return value specified for ResolveExistingPath")
46+ }
47+
48+ var r0 string
49+ var r1 bool
50+ var r2 error
51+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, bool, error)); ok {
52+ return returnFunc(ctx, input)
53+ }
54+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok {
55+ r0 = returnFunc(ctx, input)
56+ } else {
57+ r0 = ret.Get(0).(string)
58+ }
59+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) bool); ok {
60+ r1 = returnFunc(ctx, input)
61+ } else {
62+ r1 = ret.Get(1).(bool)
63+ }
64+ if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok {
65+ r2 = returnFunc(ctx, input)
66+ } else {
67+ r2 = ret.Error(2)
68+ }
69+ return r0, r1, r2
70+}
71+
72+// MockLocalPathResolver_ResolveExistingPath_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveExistingPath'
73+type MockLocalPathResolver_ResolveExistingPath_Call struct {
74+ *mock.Call
75+}
76+
77+// ResolveExistingPath is a helper method to define mock.On call
78+// - ctx context.Context
79+// - input string
80+func (_e *MockLocalPathResolver_Expecter) ResolveExistingPath(ctx any, input any) *MockLocalPathResolver_ResolveExistingPath_Call {
81+ return &MockLocalPathResolver_ResolveExistingPath_Call{Call: _e.mock.On("ResolveExistingPath", ctx, input)}
82+}
83+
84+func (_c *MockLocalPathResolver_ResolveExistingPath_Call) Run(run func(ctx context.Context, input string)) *MockLocalPathResolver_ResolveExistingPath_Call {
85+ _c.Call.Run(func(args mock.Arguments) {
86+ var arg0 context.Context
87+ if args[0] != nil {
88+ arg0 = args[0].(context.Context)
89+ }
90+ var arg1 string
91+ if args[1] != nil {
92+ arg1 = args[1].(string)
93+ }
94+ run(
95+ arg0,
96+ arg1,
97+ )
98+ })
99+ return _c
100+}
101+
102+func (_c *MockLocalPathResolver_ResolveExistingPath_Call) Return(s string, b bool, err error) *MockLocalPathResolver_ResolveExistingPath_Call {
103+ _c.Call.Return(s, b, err)
104+ return _c
105+}
106+
107+func (_c *MockLocalPathResolver_ResolveExistingPath_Call) RunAndReturn(run func(ctx context.Context, input string) (string, bool, error)) *MockLocalPathResolver_ResolveExistingPath_Call {
108+ _c.Call.Return(run)
109+ return _c
110+}
M internal/application/usecase/navigation_url_normalizer.go +9 -2
@@ -3,7 +3,6 @@ package usecase
33 import (
44 "context"
55 stdurl "net/url"
6- "strings"
76
87 "github.com/bnema/dumber/internal/application/port"
98 domainurl "github.com/bnema/dumber/internal/domain/url"
@@ -45,8 +44,16 @@ func shouldProbeLocalPath(input string) bool {
4544 if input == "" {
4645 return false
4746 }
48- if strings.Contains(input, "://") || strings.HasPrefix(input, "about:") || domainurl.IsExternalScheme(input) {
47+ if hasPreservedNavigationScheme(input) {
4948 return false
5049 }
5150 return true
5251 }
52+
53+func hasPreservedNavigationScheme(input string) bool {
54+ parsed, err := stdurl.Parse(input)
55+ if err != nil || parsed.Scheme == "" {
56+ return false
57+ }
58+ return domainurl.Normalize(input) == input
59+}
@@ -3,7 +3,6 @@ package usecase
3 import (3 import (
4 "context"4 "context"
5 stdurl "net/url"5 stdurl "net/url"
6- "strings"
7 6
8 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
9 domainurl "github.com/bnema/dumber/internal/domain/url"8 domainurl "github.com/bnema/dumber/internal/domain/url"
@@ -45,8 +44,16 @@ func shouldProbeLocalPath(input string) bool {
45 if input == "" {44 if input == "" {
46 return false45 return false
47 }46 }
48- if strings.Contains(input, "://") || strings.HasPrefix(input, "about:") || domainurl.IsExternalScheme(input) {47+ if hasPreservedNavigationScheme(input) {
49 return false48 return false
50 }49 }
51 return true50 return true
52 }51 }
52+
53+func hasPreservedNavigationScheme(input string) bool {
54+ parsed, err := stdurl.Parse(input)
55+ if err != nil || parsed.Scheme == "" {
56+ return false
57+ }
58+ return domainurl.Normalize(input) == input
59+}
M internal/application/usecase/navigation_url_normalizer_test.go +60 -0
@@ -8,6 +8,15 @@ import (
88 "testing"
99 )
1010
11+type spyLocalPathResolver struct {
12+ calls int
13+}
14+
15+func (r *spyLocalPathResolver) ResolveExistingPath(_ context.Context, _ string) (string, bool, error) {
16+ r.calls++
17+ return filepath.Join(string(filepath.Separator), "tmp", "should-not-be-used.html"), true, nil
18+}
19+
1120 func TestNavigationURLNormalizerResolvesExistingLocalPaths(t *testing.T) {
1221 ctx := context.Background()
1322 absFile := filepath.Join(string(filepath.Separator), "tmp", "page.html")
@@ -39,3 +48,54 @@ func TestNavigationURLNormalizerFallsBackToDomainNormalize(t *testing.T) {
3948 t.Fatalf("Normalize(./missing.html) = %q", got)
4049 }
4150 }
51+
52+func TestNavigationURLNormalizerDoesNotProbeSchemeBearingInputs(t *testing.T) {
53+ ctx := context.Background()
54+
55+ tests := []struct {
56+ name string
57+ input string
58+ want string
59+ }{
60+ {
61+ name: "https scheme",
62+ input: "https://example.com/page",
63+ want: "https://example.com/page",
64+ },
65+ {
66+ name: "file scheme",
67+ input: "file:///tmp/page.html",
68+ want: "file:///tmp/page.html",
69+ },
70+ {
71+ name: "about scheme",
72+ input: "about:blank",
73+ want: "about:blank",
74+ },
75+ {
76+ name: "external scheme without slashes",
77+ input: "mailto:user@example.com",
78+ want: "mailto:user@example.com",
79+ },
80+ {
81+ name: "data scheme",
82+ input: "data:text/plain,hello",
83+ want: "data:text/plain,hello",
84+ },
85+ }
86+
87+ for _, tt := range tests {
88+ t.Run(tt.name, func(t *testing.T) {
89+ resolver := &spyLocalPathResolver{}
90+ normalizer := NewNavigationURLNormalizer(resolver)
91+
92+ got := normalizer.Normalize(ctx, tt.input)
93+ if got != tt.want {
94+ t.Fatalf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
95+ }
96+ if resolver.calls != 0 {
97+ t.Fatalf("ResolveExistingPath called %d times for %q, want 0", resolver.calls, tt.input)
98+ }
99+ })
100+ }
101+}
@@ -8,6 +8,15 @@ import (
8 "testing"8 "testing"
9 )9 )
10 10
11+type spyLocalPathResolver struct {
12+ calls int
13+}
14+
15+func (r *spyLocalPathResolver) ResolveExistingPath(_ context.Context, _ string) (string, bool, error) {
16+ r.calls++
17+ return filepath.Join(string(filepath.Separator), "tmp", "should-not-be-used.html"), true, nil
18+}
19+
11 func TestNavigationURLNormalizerResolvesExistingLocalPaths(t *testing.T) {20 func TestNavigationURLNormalizerResolvesExistingLocalPaths(t *testing.T) {
12 ctx := context.Background()21 ctx := context.Background()
13 absFile := filepath.Join(string(filepath.Separator), "tmp", "page.html")22 absFile := filepath.Join(string(filepath.Separator), "tmp", "page.html")
@@ -39,3 +48,54 @@ func TestNavigationURLNormalizerFallsBackToDomainNormalize(t *testing.T) {
39 t.Fatalf("Normalize(./missing.html) = %q", got)48 t.Fatalf("Normalize(./missing.html) = %q", got)
40 }49 }
41 }50 }
51+
52+func TestNavigationURLNormalizerDoesNotProbeSchemeBearingInputs(t *testing.T) {
53+ ctx := context.Background()
54+
55+ tests := []struct {
56+ name string
57+ input string
58+ want string
59+ }{
60+ {
61+ name: "https scheme",
62+ input: "https://example.com/page",
63+ want: "https://example.com/page",
64+ },
65+ {
66+ name: "file scheme",
67+ input: "file:///tmp/page.html",
68+ want: "file:///tmp/page.html",
69+ },
70+ {
71+ name: "about scheme",
72+ input: "about:blank",
73+ want: "about:blank",
74+ },
75+ {
76+ name: "external scheme without slashes",
77+ input: "mailto:user@example.com",
78+ want: "mailto:user@example.com",
79+ },
80+ {
81+ name: "data scheme",
82+ input: "data:text/plain,hello",
83+ want: "data:text/plain,hello",
84+ },
85+ }
86+
87+ for _, tt := range tests {
88+ t.Run(tt.name, func(t *testing.T) {
89+ resolver := &spyLocalPathResolver{}
90+ normalizer := NewNavigationURLNormalizer(resolver)
91+
92+ got := normalizer.Normalize(ctx, tt.input)
93+ if got != tt.want {
94+ t.Fatalf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
95+ }
96+ if resolver.calls != 0 {
97+ t.Fatalf("ResolveExistingPath called %d times for %q, want 0", resolver.calls, tt.input)
98+ }
99+ })
100+ }
101+}
M internal/application/usecase/navigation_url_usecase_integration_test.go +47 -0
@@ -83,6 +83,53 @@ func TestManagePanesSplitResolvesExistingLocalInitialURL(t *testing.T) {
8383 }
8484 }
8585
86+func TestManagePanesCreateStackResolvesExistingLocalInitialURL(t *testing.T) {
87+ ctx := context.Background()
88+ stackFile := filepath.Join(string(filepath.Separator), "tmp", "stack.html")
89+ uc := NewManagePanesUseCase(
90+ sequentialIDGenerator("new-pane"),
91+ fakeLocalPathResolver{paths: map[string]string{"stack.html": stackFile}},
92+ )
93+ active := entity.NewPane("active-pane")
94+ activeNode := &entity.PaneNode{ID: "active-node", Pane: active}
95+ workspace := &entity.Workspace{ID: "workspace", Root: activeNode, ActivePaneID: active.ID}
96+
97+ out, err := uc.CreateStack(ctx, workspace, activeNode, "stack.html")
98+ if err != nil {
99+ t.Fatalf("CreateStack returned error: %v", err)
100+ }
101+ if got, want := out.NewPaneNode.Pane.URI, "file://"+stackFile; got != want {
102+ t.Fatalf("stack pane URI = %q, want %q", got, want)
103+ }
104+}
105+
106+func TestManagePanesAddToStackResolvesExistingLocalInitialURL(t *testing.T) {
107+ ctx := context.Background()
108+ stackFile := filepath.Join(string(filepath.Separator), "tmp", "added.html")
109+ uc := NewManagePanesUseCase(
110+ sequentialIDGenerator("added-pane"),
111+ fakeLocalPathResolver{paths: map[string]string{"added.html": stackFile}},
112+ )
113+ active := entity.NewPane("active-pane")
114+ activeNode := &entity.PaneNode{ID: "active-node", Pane: active}
115+ stackNode := &entity.PaneNode{
116+ ID: "stack",
117+ IsStacked: true,
118+ ActiveStackIndex: 0,
119+ Children: []*entity.PaneNode{activeNode},
120+ }
121+ activeNode.Parent = stackNode
122+ workspace := &entity.Workspace{ID: "workspace", Root: stackNode, ActivePaneID: active.ID}
123+
124+ out, err := uc.AddToStack(ctx, workspace, stackNode, nil, "added.html")
125+ if err != nil {
126+ t.Fatalf("AddToStack returned error: %v", err)
127+ }
128+ if got, want := out.NewPaneNode.Pane.URI, "file://"+stackFile; got != want {
129+ t.Fatalf("added stack pane URI = %q, want %q", got, want)
130+ }
131+}
132+
86133 func sequentialIDGenerator(ids ...string) IDGenerator {
87134 idx := 0
88135 return func() string {
@@ -83,6 +83,53 @@ func TestManagePanesSplitResolvesExistingLocalInitialURL(t *testing.T) {
83 }83 }
84 }84 }
85 85
86+func TestManagePanesCreateStackResolvesExistingLocalInitialURL(t *testing.T) {
87+ ctx := context.Background()
88+ stackFile := filepath.Join(string(filepath.Separator), "tmp", "stack.html")
89+ uc := NewManagePanesUseCase(
90+ sequentialIDGenerator("new-pane"),
91+ fakeLocalPathResolver{paths: map[string]string{"stack.html": stackFile}},
92+ )
93+ active := entity.NewPane("active-pane")
94+ activeNode := &entity.PaneNode{ID: "active-node", Pane: active}
95+ workspace := &entity.Workspace{ID: "workspace", Root: activeNode, ActivePaneID: active.ID}
96+
97+ out, err := uc.CreateStack(ctx, workspace, activeNode, "stack.html")
98+ if err != nil {
99+ t.Fatalf("CreateStack returned error: %v", err)
100+ }
101+ if got, want := out.NewPaneNode.Pane.URI, "file://"+stackFile; got != want {
102+ t.Fatalf("stack pane URI = %q, want %q", got, want)
103+ }
104+}
105+
106+func TestManagePanesAddToStackResolvesExistingLocalInitialURL(t *testing.T) {
107+ ctx := context.Background()
108+ stackFile := filepath.Join(string(filepath.Separator), "tmp", "added.html")
109+ uc := NewManagePanesUseCase(
110+ sequentialIDGenerator("added-pane"),
111+ fakeLocalPathResolver{paths: map[string]string{"added.html": stackFile}},
112+ )
113+ active := entity.NewPane("active-pane")
114+ activeNode := &entity.PaneNode{ID: "active-node", Pane: active}
115+ stackNode := &entity.PaneNode{
116+ ID: "stack",
117+ IsStacked: true,
118+ ActiveStackIndex: 0,
119+ Children: []*entity.PaneNode{activeNode},
120+ }
121+ activeNode.Parent = stackNode
122+ workspace := &entity.Workspace{ID: "workspace", Root: stackNode, ActivePaneID: active.ID}
123+
124+ out, err := uc.AddToStack(ctx, workspace, stackNode, nil, "added.html")
125+ if err != nil {
126+ t.Fatalf("AddToStack returned error: %v", err)
127+ }
128+ if got, want := out.NewPaneNode.Pane.URI, "file://"+stackFile; got != want {
129+ t.Fatalf("added stack pane URI = %q, want %q", got, want)
130+ }
131+}
132+
86 func sequentialIDGenerator(ids ...string) IDGenerator {133 func sequentialIDGenerator(ids ...string) IDGenerator {
87 idx := 0134 idx := 0
88 return func() string {135 return func() string {
M internal/cli/cmd/sessions.go +8 -5
@@ -44,7 +44,7 @@ func runSessions(_ *cobra.Command, _ []string) error {
4444 return fmt.Errorf("app not initialized")
4545 }
4646
47- if cliApp.ListSessionsUC == nil {
47+ if cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil {
4848 return fmt.Errorf("session management not available")
4949 }
5050
@@ -95,7 +95,7 @@ func runSessionsList(_ *cobra.Command, _ []string) error {
9595 }
9696 renderer := styles.NewSessionsCLIRenderer(cliApp.Theme)
9797
98- if cliApp.ListSessionsUC == nil {
98+ if cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil {
9999 err := fmt.Errorf("session management not available")
100100 fmt.Fprintln(os.Stderr, renderer.RenderError(err))
101101 return wrapPrintedError(err)
@@ -157,7 +157,7 @@ func runSessionsRestore(_ *cobra.Command, args []string) error {
157157 }
158158 renderer := styles.NewSessionsCLIRenderer(cliApp.Theme)
159159
160- if cliApp.RestoreUC == nil || cliApp.ListSessionsUC == nil {
160+ if cliApp.RestoreUC == nil || cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil {
161161 err := fmt.Errorf("session restoration not available")
162162 fmt.Fprintln(os.Stderr, renderer.RenderError(err))
163163 return wrapPrintedError(err)
@@ -225,7 +225,7 @@ func runSessionsDelete(_ *cobra.Command, args []string) error {
225225 }
226226 renderer := styles.NewSessionsCLIRenderer(cliApp.Theme)
227227
228- if cliApp.DeleteSessionUC == nil || cliApp.ListSessionsUC == nil {
228+ if cliApp.DeleteSessionUC == nil || cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil {
229229 err := fmt.Errorf("session management not available")
230230 fmt.Fprintln(os.Stderr, renderer.RenderError(err))
231231 return wrapPrintedError(err)
@@ -262,9 +262,12 @@ func runSessionsDelete(_ *cobra.Command, args []string) error {
262262 // Users typically identify sessions by the last few characters (e.g., "dee5").
263263 func findSessionByIDOrSuffix(idOrSuffix string) (*entity.SessionInfo, error) {
264264 cliApp := GetApp()
265- if cliApp == nil || cliApp.ListSessionsUC == nil {
265+ if cliApp == nil {
266266 return nil, fmt.Errorf("app not initialized")
267267 }
268+ if cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil {
269+ return nil, fmt.Errorf("session management not available")
270+ }
268271
269272 // Get current session ID (empty if not running as browser)
270273 var currentSessionID entity.SessionID
@@ -44,7 +44,7 @@ func runSessions(_ *cobra.Command, _ []string) error {
44 return fmt.Errorf("app not initialized")44 return fmt.Errorf("app not initialized")
45 }45 }
46 46
47- if cliApp.ListSessionsUC == nil {47+ if cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil {
48 return fmt.Errorf("session management not available")48 return fmt.Errorf("session management not available")
49 }49 }
50 50
@@ -95,7 +95,7 @@ func runSessionsList(_ *cobra.Command, _ []string) error {
95 }95 }
96 renderer := styles.NewSessionsCLIRenderer(cliApp.Theme)96 renderer := styles.NewSessionsCLIRenderer(cliApp.Theme)
97 97
98- if cliApp.ListSessionsUC == nil {98+ if cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil {
99 err := fmt.Errorf("session management not available")99 err := fmt.Errorf("session management not available")
100 fmt.Fprintln(os.Stderr, renderer.RenderError(err))100 fmt.Fprintln(os.Stderr, renderer.RenderError(err))
101 return wrapPrintedError(err)101 return wrapPrintedError(err)
@@ -157,7 +157,7 @@ func runSessionsRestore(_ *cobra.Command, args []string) error {
157 }157 }
158 renderer := styles.NewSessionsCLIRenderer(cliApp.Theme)158 renderer := styles.NewSessionsCLIRenderer(cliApp.Theme)
159 159
160- if cliApp.RestoreUC == nil || cliApp.ListSessionsUC == nil {160+ if cliApp.RestoreUC == nil || cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil {
161 err := fmt.Errorf("session restoration not available")161 err := fmt.Errorf("session restoration not available")
162 fmt.Fprintln(os.Stderr, renderer.RenderError(err))162 fmt.Fprintln(os.Stderr, renderer.RenderError(err))
163 return wrapPrintedError(err)163 return wrapPrintedError(err)
@@ -225,7 +225,7 @@ func runSessionsDelete(_ *cobra.Command, args []string) error {
225 }225 }
226 renderer := styles.NewSessionsCLIRenderer(cliApp.Theme)226 renderer := styles.NewSessionsCLIRenderer(cliApp.Theme)
227 227
228- if cliApp.DeleteSessionUC == nil || cliApp.ListSessionsUC == nil {228+ if cliApp.DeleteSessionUC == nil || cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil {
229 err := fmt.Errorf("session management not available")229 err := fmt.Errorf("session management not available")
230 fmt.Fprintln(os.Stderr, renderer.RenderError(err))230 fmt.Fprintln(os.Stderr, renderer.RenderError(err))
231 return wrapPrintedError(err)231 return wrapPrintedError(err)
@@ -262,9 +262,12 @@ func runSessionsDelete(_ *cobra.Command, args []string) error {
262 // Users typically identify sessions by the last few characters (e.g., "dee5").262 // Users typically identify sessions by the last few characters (e.g., "dee5").
263 func findSessionByIDOrSuffix(idOrSuffix string) (*entity.SessionInfo, error) {263 func findSessionByIDOrSuffix(idOrSuffix string) (*entity.SessionInfo, error) {
264 cliApp := GetApp()264 cliApp := GetApp()
265- if cliApp == nil || cliApp.ListSessionsUC == nil {265+ if cliApp == nil {
266 return nil, fmt.Errorf("app not initialized")266 return nil, fmt.Errorf("app not initialized")
267 }267 }
268+ if cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil {
269+ return nil, fmt.Errorf("session management not available")
270+ }
268 271
269 // Get current session ID (empty if not running as browser)272 // Get current session ID (empty if not running as browser)
270 var currentSessionID entity.SessionID273 var currentSessionID entity.SessionID
A internal/cli/cmd/sessions_test.go +92 -0
@@ -0,0 +1,92 @@
1+package cmd
2+
3+import (
4+ "io"
5+ "os"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/cli"
10+ "github.com/bnema/dumber/internal/cli/styles"
11+ "github.com/bnema/dumber/internal/infrastructure/config"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestSessionCommandsReturnManagementErrorWhenSessionUseCaseMissing(t *testing.T) {
16+ tests := []struct {
17+ name string
18+ run func() error
19+ }{
20+ {
21+ name: "interactive sessions",
22+ run: func() error { return runSessions(nil, nil) },
23+ },
24+ {
25+ name: "list sessions",
26+ run: func() error { return runSessionsList(nil, nil) },
27+ },
28+ {
29+ name: "delete session",
30+ run: func() error { return runSessionsDelete(nil, []string{"session-id"}) },
31+ },
32+ {
33+ name: "find session helper",
34+ run: func() error {
35+ _, err := findSessionByIDOrSuffix("session-id")
36+ return err
37+ },
38+ },
39+ }
40+
41+ for _, tt := range tests {
42+ t.Run(tt.name, func(t *testing.T) {
43+ withApp(t, sessionCommandTestApp())
44+ discardStderr(t)
45+
46+ err := tt.run()
47+
48+ require.ErrorContains(t, err, "session management not available")
49+ })
50+ }
51+}
52+
53+func TestSessionRestoreReturnsRestorationErrorWhenSessionUseCaseMissing(t *testing.T) {
54+ withApp(t, sessionCommandTestApp())
55+ discardStderr(t)
56+
57+ err := runSessionsRestore(nil, []string{"session-id"})
58+
59+ require.ErrorContains(t, err, "session restoration not available")
60+}
61+
62+func sessionCommandTestApp() *cli.App {
63+ cfg := config.DefaultConfig()
64+ return &cli.App{
65+ Config: cfg,
66+ Theme: styles.NewTheme(cfg),
67+ ListSessionsUC: &usecase.ListSessionsUseCase{},
68+ RestoreUC: &usecase.RestoreSessionUseCase{},
69+ DeleteSessionUC: &usecase.DeleteSessionUseCase{},
70+ }
71+}
72+
73+func withApp(t *testing.T, testApp *cli.App) {
74+ t.Helper()
75+ oldApp := app
76+ app = testApp
77+ t.Cleanup(func() { app = oldApp })
78+}
79+
80+func discardStderr(t *testing.T) {
81+ t.Helper()
82+ oldStderr := os.Stderr
83+ reader, writer, err := os.Pipe()
84+ require.NoError(t, err)
85+ os.Stderr = writer
86+ t.Cleanup(func() {
87+ os.Stderr = oldStderr
88+ require.NoError(t, writer.Close())
89+ _, _ = io.Copy(io.Discard, reader)
90+ require.NoError(t, reader.Close())
91+ })
92+}
@@ -0,0 +1,92 @@
1+package cmd
2+
3+import (
4+ "io"
5+ "os"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/cli"
10+ "github.com/bnema/dumber/internal/cli/styles"
11+ "github.com/bnema/dumber/internal/infrastructure/config"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestSessionCommandsReturnManagementErrorWhenSessionUseCaseMissing(t *testing.T) {
16+ tests := []struct {
17+ name string
18+ run func() error
19+ }{
20+ {
21+ name: "interactive sessions",
22+ run: func() error { return runSessions(nil, nil) },
23+ },
24+ {
25+ name: "list sessions",
26+ run: func() error { return runSessionsList(nil, nil) },
27+ },
28+ {
29+ name: "delete session",
30+ run: func() error { return runSessionsDelete(nil, []string{"session-id"}) },
31+ },
32+ {
33+ name: "find session helper",
34+ run: func() error {
35+ _, err := findSessionByIDOrSuffix("session-id")
36+ return err
37+ },
38+ },
39+ }
40+
41+ for _, tt := range tests {
42+ t.Run(tt.name, func(t *testing.T) {
43+ withApp(t, sessionCommandTestApp())
44+ discardStderr(t)
45+
46+ err := tt.run()
47+
48+ require.ErrorContains(t, err, "session management not available")
49+ })
50+ }
51+}
52+
53+func TestSessionRestoreReturnsRestorationErrorWhenSessionUseCaseMissing(t *testing.T) {
54+ withApp(t, sessionCommandTestApp())
55+ discardStderr(t)
56+
57+ err := runSessionsRestore(nil, []string{"session-id"})
58+
59+ require.ErrorContains(t, err, "session restoration not available")
60+}
61+
62+func sessionCommandTestApp() *cli.App {
63+ cfg := config.DefaultConfig()
64+ return &cli.App{
65+ Config: cfg,
66+ Theme: styles.NewTheme(cfg),
67+ ListSessionsUC: &usecase.ListSessionsUseCase{},
68+ RestoreUC: &usecase.RestoreSessionUseCase{},
69+ DeleteSessionUC: &usecase.DeleteSessionUseCase{},
70+ }
71+}
72+
73+func withApp(t *testing.T, testApp *cli.App) {
74+ t.Helper()
75+ oldApp := app
76+ app = testApp
77+ t.Cleanup(func() { app = oldApp })
78+}
79+
80+func discardStderr(t *testing.T) {
81+ t.Helper()
82+ oldStderr := os.Stderr
83+ reader, writer, err := os.Pipe()
84+ require.NoError(t, err)
85+ os.Stderr = writer
86+ t.Cleanup(func() {
87+ os.Stderr = oldStderr
88+ require.NoError(t, writer.Close())
89+ _, _ = io.Copy(io.Discard, reader)
90+ require.NoError(t, reader.Close())
91+ })
92+}
M internal/infrastructure/config/schema_provider_test.go +68 -13
@@ -53,25 +53,80 @@ func configurationReferenceCoversKey(docKeys map[string]struct{}, schemaKey stri
5353 return true
5454 }
5555
56- if strings.HasSuffix(schemaKey, ".*") {
57- prefix := strings.TrimSuffix(schemaKey, "*")
58- for docKey := range docKeys {
59- if strings.HasPrefix(docKey, prefix) {
60- return true
61- }
56+ schemaSegments := strings.Split(schemaKey, ".")
57+ for docKey := range docKeys {
58+ if configurationReferencePatternCoversKey(strings.Split(docKey, "."), schemaSegments) {
59+ return true
6260 }
6361 }
6462
65- if idx := strings.Index(schemaKey, "<"); idx >= 0 {
66- prefix := schemaKey[:idx]
67- for docKey := range docKeys {
68- if strings.HasPrefix(docKey, prefix) {
69- return true
70- }
63+ return false
64+}
65+
66+func configurationReferencePatternCoversKey(docSegments, schemaSegments []string) bool {
67+ for idx, schemaSegment := range schemaSegments {
68+ if schemaSegment == "*" {
69+ return idx == len(schemaSegments)-1 && len(docSegments) == idx+1
70+ }
71+ if idx >= len(docSegments) {
72+ return false
73+ }
74+ if isPlaceholderSegment(schemaSegment) && isPlaceholderSegment(docSegments[idx]) {
75+ continue
76+ }
77+ if docSegments[idx] != schemaSegment {
78+ return false
7179 }
7280 }
7381
74- return false
82+ if len(docSegments) == len(schemaSegments) {
83+ return true
84+ }
85+ return configurationReferenceAllowsObjectFields(strings.Join(schemaSegments, ".")) &&
86+ len(docSegments) > len(schemaSegments)
87+}
88+
89+func isPlaceholderSegment(segment string) bool {
90+ return strings.HasPrefix(segment, "<") && strings.HasSuffix(segment, ">")
91+}
92+
93+func configurationReferenceAllowsObjectFields(schemaKey string) bool {
94+ switch schemaKey {
95+ case "search_shortcuts.<key>", "workspace.floating_pane.profiles.<name>":
96+ return true
97+ default:
98+ return false
99+ }
100+}
101+
102+func TestConfigurationReferenceCoversKeyRequiresPlaceholderSegmentMatches(t *testing.T) {
103+ docKeys := map[string]struct{}{
104+ "workspace.floating_pane.profiles.<profile>.keys_extra": {},
105+ }
106+
107+ require.False(t, configurationReferenceCoversKey(
108+ docKeys,
109+ "workspace.floating_pane.profiles.<name>.keys",
110+ ))
111+}
112+
113+func TestConfigurationReferenceCoversKeyAllowsDocumentedPlaceholderSegments(t *testing.T) {
114+ docKeys := map[string]struct{}{
115+ "workspace.floating_pane.profiles.<profile>.keys": {},
116+ }
117+
118+ require.True(t, configurationReferenceCoversKey(
119+ docKeys,
120+ "workspace.floating_pane.profiles.<name>.keys",
121+ ))
122+}
123+
124+func TestConfigurationReferenceCoversKeyAllowsDocumentedObjectFields(t *testing.T) {
125+ docKeys := map[string]struct{}{
126+ "search_shortcuts.<name>.url": {},
127+ }
128+
129+ require.True(t, configurationReferenceCoversKey(docKeys, "search_shortcuts.<key>"))
75130 }
76131
77132 func TestSchemaProviderCoversViperDefaults(t *testing.T) {
@@ -53,25 +53,80 @@ func configurationReferenceCoversKey(docKeys map[string]struct{}, schemaKey stri
53 return true53 return true
54 }54 }
55 55
56- if strings.HasSuffix(schemaKey, ".*") {56+ schemaSegments := strings.Split(schemaKey, ".")
57- prefix := strings.TrimSuffix(schemaKey, "*")57+ for docKey := range docKeys {
58- for docKey := range docKeys {58+ if configurationReferencePatternCoversKey(strings.Split(docKey, "."), schemaSegments) {
59- if strings.HasPrefix(docKey, prefix) {59+ return true
60- return true
61- }
62 }60 }
63 }61 }
64 62
65- if idx := strings.Index(schemaKey, "<"); idx >= 0 {63+ return false
66- prefix := schemaKey[:idx]64+}
67- for docKey := range docKeys {65+
68- if strings.HasPrefix(docKey, prefix) {66+func configurationReferencePatternCoversKey(docSegments, schemaSegments []string) bool {
69- return true67+ for idx, schemaSegment := range schemaSegments {
70- }68+ if schemaSegment == "*" {
69+ return idx == len(schemaSegments)-1 && len(docSegments) == idx+1
70+ }
71+ if idx >= len(docSegments) {
72+ return false
73+ }
74+ if isPlaceholderSegment(schemaSegment) && isPlaceholderSegment(docSegments[idx]) {
75+ continue
76+ }
77+ if docSegments[idx] != schemaSegment {
78+ return false
71 }79 }
72 }80 }
73 81
74- return false82+ if len(docSegments) == len(schemaSegments) {
83+ return true
84+ }
85+ return configurationReferenceAllowsObjectFields(strings.Join(schemaSegments, ".")) &&
86+ len(docSegments) > len(schemaSegments)
87+}
88+
89+func isPlaceholderSegment(segment string) bool {
90+ return strings.HasPrefix(segment, "<") && strings.HasSuffix(segment, ">")
91+}
92+
93+func configurationReferenceAllowsObjectFields(schemaKey string) bool {
94+ switch schemaKey {
95+ case "search_shortcuts.<key>", "workspace.floating_pane.profiles.<name>":
96+ return true
97+ default:
98+ return false
99+ }
100+}
101+
102+func TestConfigurationReferenceCoversKeyRequiresPlaceholderSegmentMatches(t *testing.T) {
103+ docKeys := map[string]struct{}{
104+ "workspace.floating_pane.profiles.<profile>.keys_extra": {},
105+ }
106+
107+ require.False(t, configurationReferenceCoversKey(
108+ docKeys,
109+ "workspace.floating_pane.profiles.<name>.keys",
110+ ))
111+}
112+
113+func TestConfigurationReferenceCoversKeyAllowsDocumentedPlaceholderSegments(t *testing.T) {
114+ docKeys := map[string]struct{}{
115+ "workspace.floating_pane.profiles.<profile>.keys": {},
116+ }
117+
118+ require.True(t, configurationReferenceCoversKey(
119+ docKeys,
120+ "workspace.floating_pane.profiles.<name>.keys",
121+ ))
122+}
123+
124+func TestConfigurationReferenceCoversKeyAllowsDocumentedObjectFields(t *testing.T) {
125+ docKeys := map[string]struct{}{
126+ "search_shortcuts.<name>.url": {},
127+ }
128+
129+ require.True(t, configurationReferenceCoversKey(docKeys, "search_shortcuts.<key>"))
75 }130 }
76 131
77 func TestSchemaProviderCoversViperDefaults(t *testing.T) {132 func TestSchemaProviderCoversViperDefaults(t *testing.T) {
M internal/infrastructure/config/validation.go +33 -7
@@ -446,15 +446,19 @@ func validateWorkspaceNewPaneURL(config *Config) []string {
446446
447447 func validateWorkspaceURLValue(fieldPath, value string) []string {
448448 trimmed := strings.TrimSpace(value)
449+ if isRelativeWorkspacePath(trimmed) {
450+ return []string{fmt.Sprintf(
451+ "%s relative local path cannot be resolved without config file context (got: %s)",
452+ fieldPath,
453+ value,
454+ )}
455+ }
449456 if isLocalPathLikeWorkspaceURL(trimmed) {
450457 if isExistingWorkspacePath(trimmed) {
451458 return nil
452459 }
453460 return []string{fmt.Sprintf("%s local path must exist (got: %s)", fieldPath, value)}
454461 }
455- if isExistingRelativeWorkspacePath(trimmed) {
456- return nil
457- }
458462 candidate := trimmed
459463 parsed, err := url.Parse(candidate)
460464 if err != nil || parsed.Scheme == "" {
@@ -504,11 +508,33 @@ func isExistingWorkspacePath(value string) bool {
504508 return err == nil
505509 }
506510
507-func isExistingRelativeWorkspacePath(value string) bool {
508- if value == "" || strings.Contains(value, "://") || filepath.IsAbs(value) {
511+func isRelativeWorkspacePath(value string) bool {
512+ if value == "" || filepath.IsAbs(value) || strings.HasPrefix(value, "~/") ||
513+ strings.Contains(value, "://") {
514+ return false
515+ }
516+ if strings.HasPrefix(value, "./") || strings.HasPrefix(value, "../") ||
517+ looksLikeBareRelativeWorkspacePath(value) {
518+ return true
519+ }
520+ return !domainurl.LooksLikeURL(value) && !strings.Contains(value, ":")
521+}
522+
523+func looksLikeBareRelativeWorkspacePath(value string) bool {
524+ if strings.ContainsAny(value, `/\`) {
525+ firstSegment, _, _ := strings.Cut(strings.ReplaceAll(value, `\`, "/"), "/")
526+ return firstSegment == "" || (!strings.Contains(firstSegment, ".") &&
527+ firstSegment != "localhost" && !strings.HasPrefix(firstSegment, "localhost:"))
528+ }
529+
530+ switch strings.ToLower(filepath.Ext(value)) {
531+ case ".css", ".gif", ".htm", ".html", ".jpeg", ".jpg", ".js", ".json",
532+ ".md", ".pdf", ".png", ".svg", ".toml", ".txt", ".webp", ".xml",
533+ ".yaml", ".yml":
534+ return true
535+ default:
509536 return false
510537 }
511- return isExistingWorkspacePath(value)
512538 }
513539
514540 func validateOmnibox(config *Config) []string {
@@ -532,7 +558,7 @@ func validateOmnibox(config *Config) []string {
532558
533559 func validateEngine(config *Config) []string {
534560 switch config.Engine.Type {
535- case "", EngineTypeCEF, EngineTypeWebKit:
561+ case EngineTypeCEF, EngineTypeWebKit:
536562 return nil
537563 default:
538564 return []string{fmt.Sprintf(
@@ -446,15 +446,19 @@ func validateWorkspaceNewPaneURL(config *Config) []string {
446 446
447 func validateWorkspaceURLValue(fieldPath, value string) []string {447 func validateWorkspaceURLValue(fieldPath, value string) []string {
448 trimmed := strings.TrimSpace(value)448 trimmed := strings.TrimSpace(value)
449+ if isRelativeWorkspacePath(trimmed) {
450+ return []string{fmt.Sprintf(
451+ "%s relative local path cannot be resolved without config file context (got: %s)",
452+ fieldPath,
453+ value,
454+ )}
455+ }
449 if isLocalPathLikeWorkspaceURL(trimmed) {456 if isLocalPathLikeWorkspaceURL(trimmed) {
450 if isExistingWorkspacePath(trimmed) {457 if isExistingWorkspacePath(trimmed) {
451 return nil458 return nil
452 }459 }
453 return []string{fmt.Sprintf("%s local path must exist (got: %s)", fieldPath, value)}460 return []string{fmt.Sprintf("%s local path must exist (got: %s)", fieldPath, value)}
454 }461 }
455- if isExistingRelativeWorkspacePath(trimmed) {
456- return nil
457- }
458 candidate := trimmed462 candidate := trimmed
459 parsed, err := url.Parse(candidate)463 parsed, err := url.Parse(candidate)
460 if err != nil || parsed.Scheme == "" {464 if err != nil || parsed.Scheme == "" {
@@ -504,11 +508,33 @@ func isExistingWorkspacePath(value string) bool {
504 return err == nil508 return err == nil
505 }509 }
506 510
507-func isExistingRelativeWorkspacePath(value string) bool {511+func isRelativeWorkspacePath(value string) bool {
508- if value == "" || strings.Contains(value, "://") || filepath.IsAbs(value) {512+ if value == "" || filepath.IsAbs(value) || strings.HasPrefix(value, "~/") ||
513+ strings.Contains(value, "://") {
514+ return false
515+ }
516+ if strings.HasPrefix(value, "./") || strings.HasPrefix(value, "../") ||
517+ looksLikeBareRelativeWorkspacePath(value) {
518+ return true
519+ }
520+ return !domainurl.LooksLikeURL(value) && !strings.Contains(value, ":")
521+}
522+
523+func looksLikeBareRelativeWorkspacePath(value string) bool {
524+ if strings.ContainsAny(value, `/\`) {
525+ firstSegment, _, _ := strings.Cut(strings.ReplaceAll(value, `\`, "/"), "/")
526+ return firstSegment == "" || (!strings.Contains(firstSegment, ".") &&
527+ firstSegment != "localhost" && !strings.HasPrefix(firstSegment, "localhost:"))
528+ }
529+
530+ switch strings.ToLower(filepath.Ext(value)) {
531+ case ".css", ".gif", ".htm", ".html", ".jpeg", ".jpg", ".js", ".json",
532+ ".md", ".pdf", ".png", ".svg", ".toml", ".txt", ".webp", ".xml",
533+ ".yaml", ".yml":
534+ return true
535+ default:
509 return false536 return false
510 }537 }
511- return isExistingWorkspacePath(value)
512 }538 }
513 539
514 func validateOmnibox(config *Config) []string {540 func validateOmnibox(config *Config) []string {
@@ -532,7 +558,7 @@ func validateOmnibox(config *Config) []string {
532 558
533 func validateEngine(config *Config) []string {559 func validateEngine(config *Config) []string {
534 switch config.Engine.Type {560 switch config.Engine.Type {
535- case "", EngineTypeCEF, EngineTypeWebKit:561+ case EngineTypeCEF, EngineTypeWebKit:
536 return nil562 return nil
537 default:563 default:
538 return []string{fmt.Sprintf(564 return []string{fmt.Sprintf(
M internal/infrastructure/config/validation_test.go +42 -12
@@ -20,7 +20,7 @@ func TestValidateConfig_EngineType(t *testing.T) {
2020 }{
2121 {name: "cef", engineType: EngineTypeCEF, wantErr: false},
2222 {name: "webkit", engineType: EngineTypeWebKit, wantErr: false},
23- {name: "empty defaults to cef", engineType: "", wantErr: false},
23+ {name: "empty is invalid", engineType: "", wantErr: true},
2424 {name: "invalid", engineType: "netscape", wantErr: true},
2525 }
2626
@@ -294,7 +294,18 @@ func TestValidateConfig_CEFConfig(t *testing.T) {
294294 }
295295 }
296296
297-func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingLocalPathLikeValues(t *testing.T) {
297+func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingAbsoluteLocalPath(t *testing.T) {
298+ tmpDir := t.TempDir()
299+ path := filepath.Join(tmpDir, "page.html")
300+ require.NoError(t, os.WriteFile(path, []byte("ok"), 0644))
301+
302+ cfg := DefaultConfig()
303+ cfg.Workspace.NewPaneURL = path
304+
305+ require.NoError(t, validateConfig(cfg))
306+}
307+
308+func TestValidateConfig_WorkspaceNewPaneURLRejectsRelativeLocalPathLikeValues(t *testing.T) {
298309 // This test mutates process CWD; do not add t.Parallel here.
299310 tmpDir := t.TempDir()
300311 oldWD, err := os.Getwd()
@@ -306,19 +317,20 @@ func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingLocalPathLikeValues(t *
306317 require.NoError(t, os.Chdir(childDir))
307318 t.Cleanup(func() { _ = os.Chdir(oldWD) })
308319
309- for _, value := range []string{filepath.Join(tmpDir, "page.html"), "./page.html", "../page.html"} {
320+ for _, value := range []string{"./page.html", "../page.html"} {
310321 t.Run(value, func(t *testing.T) {
311322 cfg := DefaultConfig()
312323 cfg.Workspace.NewPaneURL = value
313324
314325 err := validateConfig(cfg)
315- require.NoError(t, err)
326+ require.Error(t, err)
327+ assert.Contains(t, err.Error(), "workspace.new_pane_url relative local path cannot be resolved")
316328 })
317329 }
318330 }
319331
320332 func TestValidateConfig_WorkspaceNewPaneURLRejectsMissingLocalPathLikeValues(t *testing.T) {
321- for _, value := range []string{"/missing/dumber/page.html", "./missing.html", "../missing.html", "~/missing-dumber-page.html"} {
333+ for _, value := range []string{"/missing/dumber/page.html", "~/missing-dumber-page.html"} {
322334 t.Run(value, func(t *testing.T) {
323335 cfg := DefaultConfig()
324336 cfg.Workspace.NewPaneURL = value
@@ -330,7 +342,7 @@ func TestValidateConfig_WorkspaceNewPaneURLRejectsMissingLocalPathLikeValues(t *
330342 }
331343 }
332344
333-func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingBareRelativeFile(t *testing.T) {
345+func TestValidateConfig_WorkspaceNewPaneURLRejectsExistingBareRelativeFile(t *testing.T) {
334346 // This test mutates process CWD; do not add t.Parallel here.
335347 tmpDir := t.TempDir()
336348 oldWD, err := os.Getwd()
@@ -338,20 +350,38 @@ func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingBareRelativeFile(t *tes
338350 require.NoError(t, os.Chdir(tmpDir))
339351 t.Cleanup(func() { _ = os.Chdir(oldWD) })
340352 require.NoError(t, os.WriteFile("README", []byte("ok"), 0644))
353+ require.NoError(t, os.WriteFile("index.html", []byte("ok"), 0644))
341354
342- cfg := DefaultConfig()
343- cfg.Workspace.NewPaneURL = "README"
355+ for _, value := range []string{"README", "index.html"} {
356+ t.Run(value, func(t *testing.T) {
357+ cfg := DefaultConfig()
358+ cfg.Workspace.NewPaneURL = value
344359
345- require.NoError(t, validateConfig(cfg))
360+ validationErr := validateConfig(cfg)
361+ require.Error(t, validationErr)
362+ assert.Contains(
363+ t,
364+ validationErr.Error(),
365+ "workspace.new_pane_url relative local path cannot be resolved",
366+ )
367+ })
368+ }
346369 }
347370
348371 func TestValidateConfig_WorkspaceNewPaneURLRejectsMissingBareRelativeValue(t *testing.T) {
372+ // This test mutates process CWD; do not add t.Parallel here.
373+ tmpDir := t.TempDir()
374+ oldWD, err := os.Getwd()
375+ require.NoError(t, err)
376+ require.NoError(t, os.Chdir(tmpDir))
377+ t.Cleanup(func() { _ = os.Chdir(oldWD) })
378+
349379 cfg := DefaultConfig()
350380 cfg.Workspace.NewPaneURL = "missing-local-file"
351381
352- err := validateConfig(cfg)
353- require.Error(t, err)
354- assert.Contains(t, err.Error(), "workspace.new_pane_url")
382+ validationErr := validateConfig(cfg)
383+ require.Error(t, validationErr)
384+ assert.Contains(t, validationErr.Error(), "workspace.new_pane_url")
355385 }
356386
357387 func TestValidateConfig_WebKitDefaultProfileIgnoresZeroGPUThreads(t *testing.T) {
@@ -20,7 +20,7 @@ func TestValidateConfig_EngineType(t *testing.T) {
20 }{20 }{
21 {name: "cef", engineType: EngineTypeCEF, wantErr: false},21 {name: "cef", engineType: EngineTypeCEF, wantErr: false},
22 {name: "webkit", engineType: EngineTypeWebKit, wantErr: false},22 {name: "webkit", engineType: EngineTypeWebKit, wantErr: false},
23- {name: "empty defaults to cef", engineType: "", wantErr: false},23+ {name: "empty is invalid", engineType: "", wantErr: true},
24 {name: "invalid", engineType: "netscape", wantErr: true},24 {name: "invalid", engineType: "netscape", wantErr: true},
25 }25 }
26 26
@@ -294,7 +294,18 @@ func TestValidateConfig_CEFConfig(t *testing.T) {
294 }294 }
295 }295 }
296 296
297-func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingLocalPathLikeValues(t *testing.T) {297+func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingAbsoluteLocalPath(t *testing.T) {
298+ tmpDir := t.TempDir()
299+ path := filepath.Join(tmpDir, "page.html")
300+ require.NoError(t, os.WriteFile(path, []byte("ok"), 0644))
301+
302+ cfg := DefaultConfig()
303+ cfg.Workspace.NewPaneURL = path
304+
305+ require.NoError(t, validateConfig(cfg))
306+}
307+
308+func TestValidateConfig_WorkspaceNewPaneURLRejectsRelativeLocalPathLikeValues(t *testing.T) {
298 // This test mutates process CWD; do not add t.Parallel here.309 // This test mutates process CWD; do not add t.Parallel here.
299 tmpDir := t.TempDir()310 tmpDir := t.TempDir()
300 oldWD, err := os.Getwd()311 oldWD, err := os.Getwd()
@@ -306,19 +317,20 @@ func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingLocalPathLikeValues(t *
306 require.NoError(t, os.Chdir(childDir))317 require.NoError(t, os.Chdir(childDir))
307 t.Cleanup(func() { _ = os.Chdir(oldWD) })318 t.Cleanup(func() { _ = os.Chdir(oldWD) })
308 319
309- for _, value := range []string{filepath.Join(tmpDir, "page.html"), "./page.html", "../page.html"} {320+ for _, value := range []string{"./page.html", "../page.html"} {
310 t.Run(value, func(t *testing.T) {321 t.Run(value, func(t *testing.T) {
311 cfg := DefaultConfig()322 cfg := DefaultConfig()
312 cfg.Workspace.NewPaneURL = value323 cfg.Workspace.NewPaneURL = value
313 324
314 err := validateConfig(cfg)325 err := validateConfig(cfg)
315- require.NoError(t, err)326+ require.Error(t, err)
327+ assert.Contains(t, err.Error(), "workspace.new_pane_url relative local path cannot be resolved")
316 })328 })
317 }329 }
318 }330 }
319 331
320 func TestValidateConfig_WorkspaceNewPaneURLRejectsMissingLocalPathLikeValues(t *testing.T) {332 func TestValidateConfig_WorkspaceNewPaneURLRejectsMissingLocalPathLikeValues(t *testing.T) {
321- for _, value := range []string{"/missing/dumber/page.html", "./missing.html", "../missing.html", "~/missing-dumber-page.html"} {333+ for _, value := range []string{"/missing/dumber/page.html", "~/missing-dumber-page.html"} {
322 t.Run(value, func(t *testing.T) {334 t.Run(value, func(t *testing.T) {
323 cfg := DefaultConfig()335 cfg := DefaultConfig()
324 cfg.Workspace.NewPaneURL = value336 cfg.Workspace.NewPaneURL = value
@@ -330,7 +342,7 @@ func TestValidateConfig_WorkspaceNewPaneURLRejectsMissingLocalPathLikeValues(t *
330 }342 }
331 }343 }
332 344
333-func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingBareRelativeFile(t *testing.T) {345+func TestValidateConfig_WorkspaceNewPaneURLRejectsExistingBareRelativeFile(t *testing.T) {
334 // This test mutates process CWD; do not add t.Parallel here.346 // This test mutates process CWD; do not add t.Parallel here.
335 tmpDir := t.TempDir()347 tmpDir := t.TempDir()
336 oldWD, err := os.Getwd()348 oldWD, err := os.Getwd()
@@ -338,20 +350,38 @@ func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingBareRelativeFile(t *tes
338 require.NoError(t, os.Chdir(tmpDir))350 require.NoError(t, os.Chdir(tmpDir))
339 t.Cleanup(func() { _ = os.Chdir(oldWD) })351 t.Cleanup(func() { _ = os.Chdir(oldWD) })
340 require.NoError(t, os.WriteFile("README", []byte("ok"), 0644))352 require.NoError(t, os.WriteFile("README", []byte("ok"), 0644))
353+ require.NoError(t, os.WriteFile("index.html", []byte("ok"), 0644))
341 354
342- cfg := DefaultConfig()355+ for _, value := range []string{"README", "index.html"} {
343- cfg.Workspace.NewPaneURL = "README"356+ t.Run(value, func(t *testing.T) {
357+ cfg := DefaultConfig()
358+ cfg.Workspace.NewPaneURL = value
344 359
345- require.NoError(t, validateConfig(cfg))360+ validationErr := validateConfig(cfg)
361+ require.Error(t, validationErr)
362+ assert.Contains(
363+ t,
364+ validationErr.Error(),
365+ "workspace.new_pane_url relative local path cannot be resolved",
366+ )
367+ })
368+ }
346 }369 }
347 370
348 func TestValidateConfig_WorkspaceNewPaneURLRejectsMissingBareRelativeValue(t *testing.T) {371 func TestValidateConfig_WorkspaceNewPaneURLRejectsMissingBareRelativeValue(t *testing.T) {
372+ // This test mutates process CWD; do not add t.Parallel here.
373+ tmpDir := t.TempDir()
374+ oldWD, err := os.Getwd()
375+ require.NoError(t, err)
376+ require.NoError(t, os.Chdir(tmpDir))
377+ t.Cleanup(func() { _ = os.Chdir(oldWD) })
378+
349 cfg := DefaultConfig()379 cfg := DefaultConfig()
350 cfg.Workspace.NewPaneURL = "missing-local-file"380 cfg.Workspace.NewPaneURL = "missing-local-file"
351 381
352- err := validateConfig(cfg)382+ validationErr := validateConfig(cfg)
353- require.Error(t, err)383+ require.Error(t, validationErr)
354- assert.Contains(t, err.Error(), "workspace.new_pane_url")384+ assert.Contains(t, validationErr.Error(), "workspace.new_pane_url")
355 }385 }
356 386
357 func TestValidateConfig_WebKitDefaultProfileIgnoresZeroGPUThreads(t *testing.T) {387 func TestValidateConfig_WebKitDefaultProfileIgnoresZeroGPUThreads(t *testing.T) {
M internal/ui/app.go +10 -1
@@ -2455,8 +2455,17 @@ func (a *App) createInitialTab(ctx context.Context) {
24552455 }
24562456 }
24572457
2458+ focusedWindow := a.lastFocusedBrowserWindow()
2459+ if tabs := a.tabListForBrowserWindow(focusedWindow); tabs != nil && tabs.Count() > 0 {
2460+ log.Debug().
2461+ Str("window_id", focusedWindow.id).
2462+ Int("tab_count", tabs.Count()).
2463+ Msg("initial tab creation skipped; focused window already has tabs")
2464+ return
2465+ }
2466+
24582467 // Create an initial tab using coordinator.
2459- target := a.ensureTabTargetForBrowserWindow(a.lastFocusedBrowserWindow())
2468+ target := a.ensureTabTargetForBrowserWindow(focusedWindow)
24602469 if _, err := a.tabCoord.Create(ctx, target, a.initialWindowURL()); err != nil {
24612470 log.Error().Err(err).Msg("failed to create initial tab")
24622471 }
@@ -2455,8 +2455,17 @@ func (a *App) createInitialTab(ctx context.Context) {
2455 }2455 }
2456 }2456 }
2457 2457
2458+ focusedWindow := a.lastFocusedBrowserWindow()
2459+ if tabs := a.tabListForBrowserWindow(focusedWindow); tabs != nil && tabs.Count() > 0 {
2460+ log.Debug().
2461+ Str("window_id", focusedWindow.id).
2462+ Int("tab_count", tabs.Count()).
2463+ Msg("initial tab creation skipped; focused window already has tabs")
2464+ return
2465+ }
2466+
2458 // Create an initial tab using coordinator.2467 // Create an initial tab using coordinator.
2459- target := a.ensureTabTargetForBrowserWindow(a.lastFocusedBrowserWindow())2468+ target := a.ensureTabTargetForBrowserWindow(focusedWindow)
2460 if _, err := a.tabCoord.Create(ctx, target, a.initialWindowURL()); err != nil {2469 if _, err := a.tabCoord.Create(ctx, target, a.initialWindowURL()); err != nil {
2461 log.Error().Err(err).Msg("failed to create initial tab")2470 log.Error().Err(err).Msg("failed to create initial tab")
2462 }2471 }
M internal/ui/app_window_lifecycle.go +3 -3
@@ -110,8 +110,8 @@ func (a *App) openFreshWindow(ctx context.Context, url string) error {
110110 Int("window_count_after_register", len(a.browserWindows)).
111111 Msg("ui: open fresh window registered browser window")
112112
113- if len(a.browserWindows) == 1 {
114- log.Debug().Str("window_id", created.id).Msg("ui: activating first browser window")
113+ if len(a.browserWindows) == 1 && (url == "" || (a.deps != nil && a.deps.RestoreSessionID != "")) {
114+ log.Debug().Str("window_id", created.id).Msg("ui: activating first browser window without initial tab")
115115 a.activateBrowserWindow(created)
116116 return nil
117117 }
@@ -234,7 +234,7 @@ func (a *App) openFreshWindowWithTabsUC(ctx context.Context, url string, created
234234 // This path uses the tabs usecase directly instead of tabCoord.Create,
235235 // so the per-window TabList must be populated explicitly.
236236 a.ensureTabListForBrowserWindow(created).Add(output.Tab)
237- if a.widgetFactory != nil {
237+ if a.widgetFactory != nil || a.workspaceViewCreateOverride != nil {
238238 a.createWorkspaceView(ctx, output.Tab)
239239 a.switchWorkspaceView(ctx, output.Tab.ID)
240240 }
@@ -110,8 +110,8 @@ func (a *App) openFreshWindow(ctx context.Context, url string) error {
110 Int("window_count_after_register", len(a.browserWindows)).110 Int("window_count_after_register", len(a.browserWindows)).
111 Msg("ui: open fresh window registered browser window")111 Msg("ui: open fresh window registered browser window")
112 112
113- if len(a.browserWindows) == 1 {113+ if len(a.browserWindows) == 1 && (url == "" || (a.deps != nil && a.deps.RestoreSessionID != "")) {
114- log.Debug().Str("window_id", created.id).Msg("ui: activating first browser window")114+ log.Debug().Str("window_id", created.id).Msg("ui: activating first browser window without initial tab")
115 a.activateBrowserWindow(created)115 a.activateBrowserWindow(created)
116 return nil116 return nil
117 }117 }
@@ -234,7 +234,7 @@ func (a *App) openFreshWindowWithTabsUC(ctx context.Context, url string, created
234 // This path uses the tabs usecase directly instead of tabCoord.Create,234 // This path uses the tabs usecase directly instead of tabCoord.Create,
235 // so the per-window TabList must be populated explicitly.235 // so the per-window TabList must be populated explicitly.
236 a.ensureTabListForBrowserWindow(created).Add(output.Tab)236 a.ensureTabListForBrowserWindow(created).Add(output.Tab)
237- if a.widgetFactory != nil {237+ if a.widgetFactory != nil || a.workspaceViewCreateOverride != nil {
238 a.createWorkspaceView(ctx, output.Tab)238 a.createWorkspaceView(ctx, output.Tab)
239 a.switchWorkspaceView(ctx, output.Tab.ID)239 a.switchWorkspaceView(ctx, output.Tab.ID)
240 }240 }
M internal/ui/browser_window_test.go +71 -3
@@ -459,7 +459,7 @@ func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) {
459459 }
460460 }
461461
462-func TestOpenFreshWindow_FirstWindowRegistersShellWithoutCreatingTab(t *testing.T) {
462+func TestOpenFreshWindow_FirstWindowWithEmptyURLRegistersShellWithoutCreatingTab(t *testing.T) {
463463 app := &App{
464464 tabs: entity.NewTabList(),
465465 tabsUC: usecase.NewManageTabsUseCase(func() string { return "unexpected-tab" }, nil),
@@ -473,7 +473,7 @@ func TestOpenFreshWindow_FirstWindowRegistersShellWithoutCreatingTab(t *testing.
473473 return &browserWindow{id: "window-1", tabs: entity.NewTabList()}, nil
474474 }
475475
476- if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
476+ if err := app.OpenFreshWindow(context.Background(), ""); err != nil {
477477 t.Fatalf("OpenFreshWindow returned error: %v", err)
478478 }
479479 if got := len(app.browserWindows); got != 1 {
@@ -483,8 +483,76 @@ func TestOpenFreshWindow_FirstWindowRegistersShellWithoutCreatingTab(t *testing.
483483 t.Fatalf("lastFocusedWindowID = %q, want window-1", app.lastFocusedWindowID)
484484 }
485485 if got := app.tabs.Count(); got != 0 {
486- t.Fatalf("tabs count = %d, want 0; first shell should wait for createInitialTab", got)
486+ t.Fatalf("tabs count = %d, want 0; first empty shell should wait for createInitialTab", got)
487+ }
488+ if got := app.browserWindows["window-1"].tabs.Count(); got != 0 {
489+ t.Fatalf("window tabs count = %d, want 0; first empty shell should wait for createInitialTab", got)
490+ }
491+}
492+
493+func TestOpenFreshWindow_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t *testing.T) {
494+ app := &App{
495+ tabs: entity.NewTabList(),
496+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
497+ browserWindows: make(map[string]*browserWindow),
498+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
499+ windowForTab: map[entity.TabID]*browserWindow{},
500+ }
501+ app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {
502+ fn()
503+ return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted}
504+ }
505+ app.browserWindowFactory = func(context.Context, string) (*browserWindow, error) {
506+ return &browserWindow{id: "window-1", tabs: entity.NewTabList()}, nil
487507 }
508+ app.workspaceViewCreateOverride = func(_ context.Context, tab *entity.Tab) bool {
509+ app.workspaceViews[tab.ID] = &component.WorkspaceView{}
510+ return true
511+ }
512+
513+ if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
514+ t.Fatalf("OpenFreshWindow returned error: %v", err)
515+ }
516+
517+ windowTabs := app.browserWindows["window-1"].tabs
518+ require.NotNil(t, windowTabs)
519+ require.Equal(t, 1, app.tabs.Count())
520+ require.Equal(t, 1, windowTabs.Count())
521+ tab := windowTabs.ActiveTab()
522+ require.NotNil(t, tab)
523+ require.NotNil(t, tab.Workspace)
524+ activePane := tab.Workspace.FindPane(tab.Workspace.ActivePaneID)
525+ require.NotNil(t, activePane)
526+ require.Equal(t, "https://example.com", activePane.Pane.URI)
527+ require.NotNil(t, app.tabs.Find(tab.ID))
528+ require.Same(t, app.browserWindows["window-1"], app.browserWindowForTab(tab.ID))
529+}
530+
531+func TestCreateInitialTabSkipsFocusedWindowThatAlreadyHasTabs(t *testing.T) {
532+ ctx := context.Background()
533+ existing := entity.NewTab(entity.TabID("existing-tab"), entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))
534+ windowTabs := entity.NewTabList()
535+ windowTabs.Add(existing)
536+ windowTabs.SetActive(existing.ID)
537+ bw := &browserWindow{id: "window-1", tabs: windowTabs}
538+ app := &App{
539+ runtimeConfig: runtimeConfigStateForTest(config.DefaultConfig()),
540+ tabs: entity.NewTabList(),
541+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
542+ browserWindows: map[string]*browserWindow{bw.id: bw},
543+ lastFocusedWindowID: bw.id,
544+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
545+ windowForTab: map[entity.TabID]*browserWindow{existing.ID: bw},
546+ }
547+ app.tabs.Add(existing)
548+ app.initTabCoordinator(ctx)
549+
550+ app.createInitialTab(ctx)
551+
552+ require.Equal(t, 1, windowTabs.Count())
553+ require.NotNil(t, windowTabs.Find(existing.ID))
554+ require.Equal(t, 1, app.tabs.Count())
555+ require.NotNil(t, app.tabs.Find(existing.ID))
488556 }
489557
490558 func TestOpenFreshWindow_PropagatesFactoryError(t *testing.T) {
@@ -459,7 +459,7 @@ func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) {
459 }459 }
460 }460 }
461 461
462-func TestOpenFreshWindow_FirstWindowRegistersShellWithoutCreatingTab(t *testing.T) {462+func TestOpenFreshWindow_FirstWindowWithEmptyURLRegistersShellWithoutCreatingTab(t *testing.T) {
463 app := &App{463 app := &App{
464 tabs: entity.NewTabList(),464 tabs: entity.NewTabList(),
465 tabsUC: usecase.NewManageTabsUseCase(func() string { return "unexpected-tab" }, nil),465 tabsUC: usecase.NewManageTabsUseCase(func() string { return "unexpected-tab" }, nil),
@@ -473,7 +473,7 @@ func TestOpenFreshWindow_FirstWindowRegistersShellWithoutCreatingTab(t *testing.
473 return &browserWindow{id: "window-1", tabs: entity.NewTabList()}, nil473 return &browserWindow{id: "window-1", tabs: entity.NewTabList()}, nil
474 }474 }
475 475
476- if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {476+ if err := app.OpenFreshWindow(context.Background(), ""); err != nil {
477 t.Fatalf("OpenFreshWindow returned error: %v", err)477 t.Fatalf("OpenFreshWindow returned error: %v", err)
478 }478 }
479 if got := len(app.browserWindows); got != 1 {479 if got := len(app.browserWindows); got != 1 {
@@ -483,8 +483,76 @@ func TestOpenFreshWindow_FirstWindowRegistersShellWithoutCreatingTab(t *testing.
483 t.Fatalf("lastFocusedWindowID = %q, want window-1", app.lastFocusedWindowID)483 t.Fatalf("lastFocusedWindowID = %q, want window-1", app.lastFocusedWindowID)
484 }484 }
485 if got := app.tabs.Count(); got != 0 {485 if got := app.tabs.Count(); got != 0 {
486- t.Fatalf("tabs count = %d, want 0; first shell should wait for createInitialTab", got)486+ t.Fatalf("tabs count = %d, want 0; first empty shell should wait for createInitialTab", got)
487+ }
488+ if got := app.browserWindows["window-1"].tabs.Count(); got != 0 {
489+ t.Fatalf("window tabs count = %d, want 0; first empty shell should wait for createInitialTab", got)
490+ }
491+}
492+
493+func TestOpenFreshWindow_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t *testing.T) {
494+ app := &App{
495+ tabs: entity.NewTabList(),
496+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
497+ browserWindows: make(map[string]*browserWindow),
498+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
499+ windowForTab: map[entity.TabID]*browserWindow{},
500+ }
501+ app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {
502+ fn()
503+ return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted}
504+ }
505+ app.browserWindowFactory = func(context.Context, string) (*browserWindow, error) {
506+ return &browserWindow{id: "window-1", tabs: entity.NewTabList()}, nil
487 }507 }
508+ app.workspaceViewCreateOverride = func(_ context.Context, tab *entity.Tab) bool {
509+ app.workspaceViews[tab.ID] = &component.WorkspaceView{}
510+ return true
511+ }
512+
513+ if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
514+ t.Fatalf("OpenFreshWindow returned error: %v", err)
515+ }
516+
517+ windowTabs := app.browserWindows["window-1"].tabs
518+ require.NotNil(t, windowTabs)
519+ require.Equal(t, 1, app.tabs.Count())
520+ require.Equal(t, 1, windowTabs.Count())
521+ tab := windowTabs.ActiveTab()
522+ require.NotNil(t, tab)
523+ require.NotNil(t, tab.Workspace)
524+ activePane := tab.Workspace.FindPane(tab.Workspace.ActivePaneID)
525+ require.NotNil(t, activePane)
526+ require.Equal(t, "https://example.com", activePane.Pane.URI)
527+ require.NotNil(t, app.tabs.Find(tab.ID))
528+ require.Same(t, app.browserWindows["window-1"], app.browserWindowForTab(tab.ID))
529+}
530+
531+func TestCreateInitialTabSkipsFocusedWindowThatAlreadyHasTabs(t *testing.T) {
532+ ctx := context.Background()
533+ existing := entity.NewTab(entity.TabID("existing-tab"), entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))
534+ windowTabs := entity.NewTabList()
535+ windowTabs.Add(existing)
536+ windowTabs.SetActive(existing.ID)
537+ bw := &browserWindow{id: "window-1", tabs: windowTabs}
538+ app := &App{
539+ runtimeConfig: runtimeConfigStateForTest(config.DefaultConfig()),
540+ tabs: entity.NewTabList(),
541+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
542+ browserWindows: map[string]*browserWindow{bw.id: bw},
543+ lastFocusedWindowID: bw.id,
544+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
545+ windowForTab: map[entity.TabID]*browserWindow{existing.ID: bw},
546+ }
547+ app.tabs.Add(existing)
548+ app.initTabCoordinator(ctx)
549+
550+ app.createInitialTab(ctx)
551+
552+ require.Equal(t, 1, windowTabs.Count())
553+ require.NotNil(t, windowTabs.Find(existing.ID))
554+ require.Equal(t, 1, app.tabs.Count())
555+ require.NotNil(t, app.tabs.Find(existing.ID))
488 }556 }
489 557
490 func TestOpenFreshWindow_PropagatesFactoryError(t *testing.T) {558 func TestOpenFreshWindow_PropagatesFactoryError(t *testing.T) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close