detached 4959fb8

fix(ui): address CodeRabbit feedback across floating-pane paths

Harden floating-pane resize watching and GTK-thread favicon preload behavior while improving shortcut alias handling and logging safety. Refine related docs, tests, and config validation paths for clearer behavior and maintainability.
commit
4959fb82cb5f808807270a02cda15b38d9040847
author
brice · · 2026-02-09 12:19 UTC
parent
af377b4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +224 -116
M docs/getting-started.md +1 -1
@@ -38,7 +38,7 @@ Press `Ctrl+L` to open the omnibox for:
3838 ## Floating Pane
3939
4040 - Press `Alt+F` to toggle the floating pane.
41-- Press `Ctrl+W` to close the active pane; if floating is active, this fully releases it for a fresh next open.
41+- Press `Ctrl+W` to close the active pane; if the floating pane is active, this fully releases it so the next open starts fresh.
4242 - Some `Alt+<key>` bindings can conflict with default WebKit shortcuts or desktop-level handlers.
4343
4444 See [Floating Pane](./reference/floating-pane.md) for profile shortcuts and configuration.
@@ -38,7 +38,7 @@ Press `Ctrl+L` to open the omnibox for:
38 ## Floating Pane38 ## Floating Pane
39 39
40 - Press `Alt+F` to toggle the floating pane.40 - Press `Alt+F` to toggle the floating pane.
41-- Press `Ctrl+W` to close the active pane; if floating is active, this fully releases it for a fresh next open.41+- Press `Ctrl+W` to close the active pane; if the floating pane is active, this fully releases it so the next open starts fresh.
42 - Some `Alt+<key>` bindings can conflict with default WebKit shortcuts or desktop-level handlers.42 - Some `Alt+<key>` bindings can conflict with default WebKit shortcuts or desktop-level handlers.
43 43
44 See [Floating Pane](./reference/floating-pane.md) for profile shortcuts and configuration.44 See [Floating Pane](./reference/floating-pane.md) for profile shortcuts and configuration.
M internal/infrastructure/config/floating_pane_config_test.go +73 -63
@@ -85,70 +85,80 @@ func TestFloatingPaneConfig_ValidationRanges(t *testing.T) {
8585 }
8686
8787 func TestFloatingPaneConfig_ProfileValidation(t *testing.T) {
88- cfg := DefaultConfig()
89- cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
90- "google": {
91- URL: "",
92- Keys: []string{"alt+g"},
93- },
94- }
95-
96- err := validateConfig(cfg)
97- require.Error(t, err)
98- assert.Contains(t, err.Error(), "workspace.floating_pane.profiles.google.url")
99-
100- cfg = DefaultConfig()
101- cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
102- "google": {
103- URL: "https://google.com",
104- Keys: nil,
105- },
106- }
107-
108- err = validateConfig(cfg)
109- require.Error(t, err)
110- assert.Contains(t, err.Error(), "workspace.floating_pane.profiles.google.keys")
111-
112- cfg = DefaultConfig()
113- cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
114- "google": {
115- URL: "https://google.com",
116- Keys: []string{" ", "alt+g"},
117- },
118- }
119-
120- err = validateConfig(cfg)
121- require.Error(t, err)
122- assert.Contains(t, err.Error(), "empty or whitespace-only key binding")
123-
124- cfg = DefaultConfig()
125- cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
126- "google": {
127- URL: "https://google.com",
128- Keys: []string{"alt+g"},
129- },
130- "github": {
131- URL: "https://github.com",
132- Keys: []string{"alt+g"},
133- },
134- }
135-
136- err = validateConfig(cfg)
137- require.Error(t, err)
138- assert.Contains(t, err.Error(), "duplicate key binding")
139-
140- cfg = DefaultConfig()
141- cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
142- "invalid-scheme": {
143- URL: "ftp://example.com",
144- Keys: []string{"alt+i"},
145- },
146- }
88+ t.Run("missing URL", func(t *testing.T) {
89+ cfg := DefaultConfig()
90+ cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
91+ "google": {
92+ URL: "",
93+ Keys: []string{"alt+g"},
94+ },
95+ }
96+
97+ err := validateConfig(cfg)
98+ require.Error(t, err)
99+ assert.Contains(t, err.Error(), "workspace.floating_pane.profiles.google.url")
100+ })
101+
102+ t.Run("missing keys", func(t *testing.T) {
103+ cfg := DefaultConfig()
104+ cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
105+ "google": {
106+ URL: "https://google.com",
107+ Keys: nil,
108+ },
109+ }
110+
111+ err := validateConfig(cfg)
112+ require.Error(t, err)
113+ assert.Contains(t, err.Error(), "workspace.floating_pane.profiles.google.keys")
114+ })
115+
116+ t.Run("whitespace-only key", func(t *testing.T) {
117+ cfg := DefaultConfig()
118+ cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
119+ "google": {
120+ URL: "https://google.com",
121+ Keys: []string{" ", "alt+g"},
122+ },
123+ }
124+
125+ err := validateConfig(cfg)
126+ require.Error(t, err)
127+ assert.Contains(t, err.Error(), "empty or whitespace-only key binding")
128+ })
129+
130+ t.Run("duplicate key binding", func(t *testing.T) {
131+ cfg := DefaultConfig()
132+ cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
133+ "google": {
134+ URL: "https://google.com",
135+ Keys: []string{"alt+g"},
136+ },
137+ "github": {
138+ URL: "https://github.com",
139+ Keys: []string{"alt+g"},
140+ },
141+ }
142+
143+ err := validateConfig(cfg)
144+ require.Error(t, err)
145+ assert.Contains(t, err.Error(), "duplicate key binding")
146+ })
147+
148+ t.Run("invalid URL scheme", func(t *testing.T) {
149+ cfg := DefaultConfig()
150+ cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
151+ "invalid-scheme": {
152+ URL: "ftp://example.com",
153+ Keys: []string{"alt+i"},
154+ },
155+ }
147156
148- err = validateConfig(cfg)
149- require.Error(t, err)
150- assert.Contains(t, err.Error(), "workspace.floating_pane.profiles.invalid-scheme.url")
151- assert.Contains(t, err.Error(), "must use one of: http, https, dumb, file, about")
157+ err := validateConfig(cfg)
158+ require.Error(t, err)
159+ assert.Contains(t, err.Error(), "workspace.floating_pane.profiles.invalid-scheme.url")
160+ assert.Contains(t, err.Error(), "must use one of: http, https, dumb, file, about")
161+ })
152162 }
153163
154164 func TestFloatingPaneConfig_ProfileMultiplicity(t *testing.T) {
@@ -85,70 +85,80 @@ func TestFloatingPaneConfig_ValidationRanges(t *testing.T) {
85 }85 }
86 86
87 func TestFloatingPaneConfig_ProfileValidation(t *testing.T) {87 func TestFloatingPaneConfig_ProfileValidation(t *testing.T) {
88- cfg := DefaultConfig()88+ t.Run("missing URL", func(t *testing.T) {
89- cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{89+ cfg := DefaultConfig()
90- "google": {90+ cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
91- URL: "",91+ "google": {
92- Keys: []string{"alt+g"},92+ URL: "",
93- },93+ Keys: []string{"alt+g"},
94- }94+ },
95-95+ }
96- err := validateConfig(cfg)96+
97- require.Error(t, err)97+ err := validateConfig(cfg)
98- assert.Contains(t, err.Error(), "workspace.floating_pane.profiles.google.url")98+ require.Error(t, err)
99-99+ assert.Contains(t, err.Error(), "workspace.floating_pane.profiles.google.url")
100- cfg = DefaultConfig()100+ })
101- cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{101+
102- "google": {102+ t.Run("missing keys", func(t *testing.T) {
103- URL: "https://google.com",103+ cfg := DefaultConfig()
104- Keys: nil,104+ cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
105- },105+ "google": {
106- }106+ URL: "https://google.com",
107-107+ Keys: nil,
108- err = validateConfig(cfg)108+ },
109- require.Error(t, err)109+ }
110- assert.Contains(t, err.Error(), "workspace.floating_pane.profiles.google.keys")110+
111-111+ err := validateConfig(cfg)
112- cfg = DefaultConfig()112+ require.Error(t, err)
113- cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{113+ assert.Contains(t, err.Error(), "workspace.floating_pane.profiles.google.keys")
114- "google": {114+ })
115- URL: "https://google.com",115+
116- Keys: []string{" ", "alt+g"},116+ t.Run("whitespace-only key", func(t *testing.T) {
117- },117+ cfg := DefaultConfig()
118- }118+ cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
119-119+ "google": {
120- err = validateConfig(cfg)120+ URL: "https://google.com",
121- require.Error(t, err)121+ Keys: []string{" ", "alt+g"},
122- assert.Contains(t, err.Error(), "empty or whitespace-only key binding")122+ },
123-123+ }
124- cfg = DefaultConfig()124+
125- cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{125+ err := validateConfig(cfg)
126- "google": {126+ require.Error(t, err)
127- URL: "https://google.com",127+ assert.Contains(t, err.Error(), "empty or whitespace-only key binding")
128- Keys: []string{"alt+g"},128+ })
129- },129+
130- "github": {130+ t.Run("duplicate key binding", func(t *testing.T) {
131- URL: "https://github.com",131+ cfg := DefaultConfig()
132- Keys: []string{"alt+g"},132+ cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
133- },133+ "google": {
134- }134+ URL: "https://google.com",
135-135+ Keys: []string{"alt+g"},
136- err = validateConfig(cfg)136+ },
137- require.Error(t, err)137+ "github": {
138- assert.Contains(t, err.Error(), "duplicate key binding")138+ URL: "https://github.com",
139-139+ Keys: []string{"alt+g"},
140- cfg = DefaultConfig()140+ },
141- cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{141+ }
142- "invalid-scheme": {142+
143- URL: "ftp://example.com",143+ err := validateConfig(cfg)
144- Keys: []string{"alt+i"},144+ require.Error(t, err)
145- },145+ assert.Contains(t, err.Error(), "duplicate key binding")
146- }146+ })
147+
148+ t.Run("invalid URL scheme", func(t *testing.T) {
149+ cfg := DefaultConfig()
150+ cfg.Workspace.FloatingPane.Profiles = map[string]FloatingPaneProfile{
151+ "invalid-scheme": {
152+ URL: "ftp://example.com",
153+ Keys: []string{"alt+i"},
154+ },
155+ }
147 156
148- err = validateConfig(cfg)157+ err := validateConfig(cfg)
149- require.Error(t, err)158+ require.Error(t, err)
150- assert.Contains(t, err.Error(), "workspace.floating_pane.profiles.invalid-scheme.url")159+ assert.Contains(t, err.Error(), "workspace.floating_pane.profiles.invalid-scheme.url")
151- assert.Contains(t, err.Error(), "must use one of: http, https, dumb, file, about")160+ assert.Contains(t, err.Error(), "must use one of: http, https, dumb, file, about")
161+ })
152 }162 }
153 163
154 func TestFloatingPaneConfig_ProfileMultiplicity(t *testing.T) {164 func TestFloatingPaneConfig_ProfileMultiplicity(t *testing.T) {
M internal/infrastructure/config/migrate.go +2 -0
@@ -835,6 +835,8 @@ func (*Migrator) toStringAnyMap(value any) (map[string]any, bool) {
835835 return m, true
836836 }
837837
838+ // Some TOML/YAML parser paths yield non-map[string]any map implementations
839+ // for nested tables, so keep a reflect.Map coercion fallback here.
838840 rv := reflect.ValueOf(value)
839841 if rv.Kind() != reflect.Map {
840842 return nil, false
@@ -835,6 +835,8 @@ func (*Migrator) toStringAnyMap(value any) (map[string]any, bool) {
835 return m, true835 return m, true
836 }836 }
837 837
838+ // Some TOML/YAML parser paths yield non-map[string]any map implementations
839+ // for nested tables, so keep a reflect.Map coercion fallback here.
838 rv := reflect.ValueOf(value)840 rv := reflect.ValueOf(value)
839 if rv.Kind() != reflect.Map {841 if rv.Kind() != reflect.Map {
840 return nil, false842 return nil, false
M internal/infrastructure/config/validation.go +5 -6
@@ -334,13 +334,12 @@ func validateWorkspaceNewPaneURL(config *Config) []string {
334334
335335 func validateWorkspaceURLValue(fieldPath, value string) []string {
336336 trimmed := strings.TrimSpace(value)
337- normalized := domainurl.Normalize(trimmed)
338- candidate := normalized
339- if rawParsed, rawErr := url.Parse(trimmed); rawErr == nil && rawParsed.Scheme != "" {
340- candidate = trimmed
341- }
342-
337+ candidate := trimmed
343338 parsed, err := url.Parse(candidate)
339+ if err != nil || parsed.Scheme == "" {
340+ candidate = domainurl.Normalize(trimmed)
341+ parsed, err = url.Parse(candidate)
342+ }
344343 if err != nil {
345344 return []string{fmt.Sprintf("%s must be a valid URL (got: %s)", fieldPath, value)}
346345 }
@@ -334,13 +334,12 @@ func validateWorkspaceNewPaneURL(config *Config) []string {
334 334
335 func validateWorkspaceURLValue(fieldPath, value string) []string {335 func validateWorkspaceURLValue(fieldPath, value string) []string {
336 trimmed := strings.TrimSpace(value)336 trimmed := strings.TrimSpace(value)
337- normalized := domainurl.Normalize(trimmed)337+ candidate := trimmed
338- candidate := normalized
339- if rawParsed, rawErr := url.Parse(trimmed); rawErr == nil && rawParsed.Scheme != "" {
340- candidate = trimmed
341- }
342-
343 parsed, err := url.Parse(candidate)338 parsed, err := url.Parse(candidate)
339+ if err != nil || parsed.Scheme == "" {
340+ candidate = domainurl.Normalize(trimmed)
341+ parsed, err = url.Parse(candidate)
342+ }
344 if err != nil {343 if err != nil {
345 return []string{fmt.Sprintf("%s must be a valid URL (got: %s)", fieldPath, value)}344 return []string{fmt.Sprintf("%s must be a valid URL (got: %s)", fieldPath, value)}
346 }345 }
M internal/ui/app.go +38 -3
@@ -2183,7 +2183,21 @@ func floatingAllocationRect(overlayWidth, overlayHeight, desiredWidth, desiredHe
21832183 return x, y, width, height, true
21842184 }
21852185
2186-//nolint:gosec // GtkOverlay get-child-position provides a valid GtkAllocation pointer owned by GTK.
2186+type gtkAllocationLayout [4]int32
2187+
2188+const gtkAllocationLayoutSize = 16
2189+
2190+var (
2191+ _ [gtkAllocationLayoutSize - int(unsafe.Sizeof(gtkAllocationLayout{}))]struct{}
2192+ _ [int(unsafe.Sizeof(gtkAllocationLayout{})) - gtkAllocationLayoutSize]struct{}
2193+)
2194+
2195+// writeOverlayAllocation writes GTK-owned GtkAllocation fields in place.
2196+// Assumes GTK4 C layout for GdkRectangle/GtkAllocation: four contiguous
2197+// 32-bit signed integers {x, y, width, height} in native endianness.
2198+// If GTK struct layout changes, revisit offsets and writes below.
2199+//
2200+//nolint:gosec // Intentional unsafe pointer math against GTK-owned allocation memory.
21872201 func writeOverlayAllocation(allocationPtr *uintptr, x, y, width, height int) bool {
21882202 if allocationPtr == nil {
21892203 return false
@@ -2604,6 +2618,19 @@ func (a *App) handleFloatingViewportTick(session *floatingWorkspaceSession) bool
26042618 return true
26052619 }
26062620
2621+func (a *App) floatingSessionByPaneID(paneID entity.PaneID) *floatingWorkspaceSession {
2622+ if paneID == "" {
2623+ return nil
2624+ }
2625+ for _, session := range a.floatingSessions {
2626+ if session == nil || session.paneID != paneID {
2627+ continue
2628+ }
2629+ return session
2630+ }
2631+ return nil
2632+}
2633+
26072634 func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) {
26082635 if session == nil || session.overlay == nil || session.resizeWatcherActive {
26092636 return
@@ -2615,11 +2642,19 @@ func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) {
26152642 }
26162643
26172644 session.resizeWatcherActive = true
2645+ paneID := session.paneID
26182646 tickCallback := gtk.TickCallback(func(_ uintptr, _ uintptr, _ uintptr) bool {
2619- keepRunning := a.handleFloatingViewportTick(session)
2620- if !keepRunning {
2647+ liveSession := a.floatingSessionByPaneID(paneID)
2648+ if liveSession == nil {
26212649 session.resizeWatcherActive = false
26222650 session.resizeTickID = 0
2651+ return false
2652+ }
2653+
2654+ keepRunning := a.handleFloatingViewportTick(liveSession)
2655+ if !keepRunning {
2656+ liveSession.resizeWatcherActive = false
2657+ liveSession.resizeTickID = 0
26232658 }
26242659 return keepRunning
26252660 })
@@ -2183,7 +2183,21 @@ func floatingAllocationRect(overlayWidth, overlayHeight, desiredWidth, desiredHe
2183 return x, y, width, height, true2183 return x, y, width, height, true
2184 }2184 }
2185 2185
2186-//nolint:gosec // GtkOverlay get-child-position provides a valid GtkAllocation pointer owned by GTK.2186+type gtkAllocationLayout [4]int32
2187+
2188+const gtkAllocationLayoutSize = 16
2189+
2190+var (
2191+ _ [gtkAllocationLayoutSize - int(unsafe.Sizeof(gtkAllocationLayout{}))]struct{}
2192+ _ [int(unsafe.Sizeof(gtkAllocationLayout{})) - gtkAllocationLayoutSize]struct{}
2193+)
2194+
2195+// writeOverlayAllocation writes GTK-owned GtkAllocation fields in place.
2196+// Assumes GTK4 C layout for GdkRectangle/GtkAllocation: four contiguous
2197+// 32-bit signed integers {x, y, width, height} in native endianness.
2198+// If GTK struct layout changes, revisit offsets and writes below.
2199+//
2200+//nolint:gosec // Intentional unsafe pointer math against GTK-owned allocation memory.
2187 func writeOverlayAllocation(allocationPtr *uintptr, x, y, width, height int) bool {2201 func writeOverlayAllocation(allocationPtr *uintptr, x, y, width, height int) bool {
2188 if allocationPtr == nil {2202 if allocationPtr == nil {
2189 return false2203 return false
@@ -2604,6 +2618,19 @@ func (a *App) handleFloatingViewportTick(session *floatingWorkspaceSession) bool
2604 return true2618 return true
2605 }2619 }
2606 2620
2621+func (a *App) floatingSessionByPaneID(paneID entity.PaneID) *floatingWorkspaceSession {
2622+ if paneID == "" {
2623+ return nil
2624+ }
2625+ for _, session := range a.floatingSessions {
2626+ if session == nil || session.paneID != paneID {
2627+ continue
2628+ }
2629+ return session
2630+ }
2631+ return nil
2632+}
2633+
2607 func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) {2634 func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) {
2608 if session == nil || session.overlay == nil || session.resizeWatcherActive {2635 if session == nil || session.overlay == nil || session.resizeWatcherActive {
2609 return2636 return
@@ -2615,11 +2642,19 @@ func (a *App) startFloatingResizeWatcher(session *floatingWorkspaceSession) {
2615 }2642 }
2616 2643
2617 session.resizeWatcherActive = true2644 session.resizeWatcherActive = true
2645+ paneID := session.paneID
2618 tickCallback := gtk.TickCallback(func(_ uintptr, _ uintptr, _ uintptr) bool {2646 tickCallback := gtk.TickCallback(func(_ uintptr, _ uintptr, _ uintptr) bool {
2619- keepRunning := a.handleFloatingViewportTick(session)2647+ liveSession := a.floatingSessionByPaneID(paneID)
2620- if !keepRunning {2648+ if liveSession == nil {
2621 session.resizeWatcherActive = false2649 session.resizeWatcherActive = false
2622 session.resizeTickID = 02650 session.resizeTickID = 0
2651+ return false
2652+ }
2653+
2654+ keepRunning := a.handleFloatingViewportTick(liveSession)
2655+ if !keepRunning {
2656+ liveSession.resizeWatcherActive = false
2657+ liveSession.resizeTickID = 0
2623 }2658 }
2624 return keepRunning2659 return keepRunning
2625 })2660 })
M internal/ui/component/floating_pane.go +11 -20
@@ -78,6 +78,8 @@ func (fp *FloatingPane) ShowToggle(ctx context.Context) error {
7878 showOmnibox := navigateToBlank || fp.currentURL == defaultFloatingPaneURL
7979 fp.visible = true
8080 fp.omniboxVisible = showOmnibox
81+ // Release lock before Navigate: callbacks may block and must not run while
82+ // holding fp.mu. On error, rollback below restores visibility state.
8183 fp.mu.Unlock()
8284
8385 if navigateToBlank {
@@ -130,7 +132,7 @@ func (fp *FloatingPane) ShowURL(ctx context.Context, url string) error {
130132 }
131133
132134 // Hide hides the pane while preserving web session state.
133-func (fp *FloatingPane) Hide(context.Context) {
135+func (fp *FloatingPane) Hide(_ context.Context) {
134136 fp.mu.Lock()
135137 defer fp.mu.Unlock()
136138 fp.visible = false
@@ -164,26 +166,15 @@ func (fp *FloatingPane) Navigate(ctx context.Context, url string) error {
164166
165167 // Resize recalculates pane dimensions from parent overlay allocation.
166168 func (fp *FloatingPane) Resize() {
167- fp.mu.RLock()
168- parent := fp.parent
169- widthPct := fp.widthPct
170- heightPct := fp.heightPct
171- fallbackWidth := fp.fallbackWidth
172- fallbackHeight := fp.fallbackHeight
173- fp.mu.RUnlock()
174-
175- width, height := CalculateOverlayDimensions(
176- parent,
177- widthPct,
178- heightPct,
179- fallbackWidth,
180- fallbackHeight,
181- )
182-
183169 fp.mu.Lock()
184- fp.calculatedWidth = width
185- fp.calculatedHeight = height
186- fp.mu.Unlock()
170+ defer fp.mu.Unlock()
171+ fp.calculatedWidth, fp.calculatedHeight = CalculateOverlayDimensions(
172+ fp.parent,
173+ fp.widthPct,
174+ fp.heightPct,
175+ fp.fallbackWidth,
176+ fp.fallbackHeight,
177+ )
187178 }
188179
189180 // SetParentOverlay updates the workspace overlay used for sizing calculations.
@@ -78,6 +78,8 @@ func (fp *FloatingPane) ShowToggle(ctx context.Context) error {
78 showOmnibox := navigateToBlank || fp.currentURL == defaultFloatingPaneURL78 showOmnibox := navigateToBlank || fp.currentURL == defaultFloatingPaneURL
79 fp.visible = true79 fp.visible = true
80 fp.omniboxVisible = showOmnibox80 fp.omniboxVisible = showOmnibox
81+ // Release lock before Navigate: callbacks may block and must not run while
82+ // holding fp.mu. On error, rollback below restores visibility state.
81 fp.mu.Unlock()83 fp.mu.Unlock()
82 84
83 if navigateToBlank {85 if navigateToBlank {
@@ -130,7 +132,7 @@ func (fp *FloatingPane) ShowURL(ctx context.Context, url string) error {
130 }132 }
131 133
132 // Hide hides the pane while preserving web session state.134 // Hide hides the pane while preserving web session state.
133-func (fp *FloatingPane) Hide(context.Context) {135+func (fp *FloatingPane) Hide(_ context.Context) {
134 fp.mu.Lock()136 fp.mu.Lock()
135 defer fp.mu.Unlock()137 defer fp.mu.Unlock()
136 fp.visible = false138 fp.visible = false
@@ -164,26 +166,15 @@ func (fp *FloatingPane) Navigate(ctx context.Context, url string) error {
164 166
165 // Resize recalculates pane dimensions from parent overlay allocation.167 // Resize recalculates pane dimensions from parent overlay allocation.
166 func (fp *FloatingPane) Resize() {168 func (fp *FloatingPane) Resize() {
167- fp.mu.RLock()
168- parent := fp.parent
169- widthPct := fp.widthPct
170- heightPct := fp.heightPct
171- fallbackWidth := fp.fallbackWidth
172- fallbackHeight := fp.fallbackHeight
173- fp.mu.RUnlock()
174-
175- width, height := CalculateOverlayDimensions(
176- parent,
177- widthPct,
178- heightPct,
179- fallbackWidth,
180- fallbackHeight,
181- )
182-
183 fp.mu.Lock()169 fp.mu.Lock()
184- fp.calculatedWidth = width170+ defer fp.mu.Unlock()
185- fp.calculatedHeight = height171+ fp.calculatedWidth, fp.calculatedHeight = CalculateOverlayDimensions(
186- fp.mu.Unlock()172+ fp.parent,
173+ fp.widthPct,
174+ fp.heightPct,
175+ fp.fallbackWidth,
176+ fp.fallbackHeight,
177+ )
187 }178 }
188 179
189 // SetParentOverlay updates the workspace overlay used for sizing calculations.180 // SetParentOverlay updates the workspace overlay used for sizing calculations.
M internal/ui/component/floating_pane_test.go +8 -7
@@ -1,10 +1,11 @@
1-package component
1+package component_test
22
33 import (
44 "context"
55 "sync"
66 "testing"
77
8+ "github.com/bnema/dumber/internal/ui/component"
89 layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
910 "github.com/stretchr/testify/assert"
1011 "github.com/stretchr/testify/require"
@@ -16,7 +17,7 @@ func TestFloatingPane_HideShowPreservesState(t *testing.T) {
1617 overlay.EXPECT().GetAllocatedHeight().Return(800).Maybe()
1718
1819 navigated := make([]string, 0)
19- fp := NewFloatingPane(overlay, FloatingPaneOptions{
20+ fp := component.NewFloatingPane(overlay, component.FloatingPaneOptions{
2021 WidthPct: 0.82,
2122 HeightPct: 0.72,
2223 FallbackWidth: 1200,
@@ -44,7 +45,7 @@ func TestFloatingPane_ShowURLLoadsTarget(t *testing.T) {
4445 overlay.EXPECT().GetAllocatedWidth().Return(1000).Maybe()
4546 overlay.EXPECT().GetAllocatedHeight().Return(700).Maybe()
4647
47- fp := NewFloatingPane(overlay, FloatingPaneOptions{
48+ fp := component.NewFloatingPane(overlay, component.FloatingPaneOptions{
4849 WidthPct: 0.82,
4950 HeightPct: 0.72,
5051 FallbackWidth: 1000,
@@ -63,7 +64,7 @@ func TestFloatingPane_ResizeUsesWorkspacePercentages(t *testing.T) {
6364 overlay.EXPECT().GetAllocatedWidth().Return(1000).Maybe()
6465 overlay.EXPECT().GetAllocatedHeight().Return(700).Maybe()
6566
66- fp := NewFloatingPane(overlay, FloatingPaneOptions{
67+ fp := component.NewFloatingPane(overlay, component.FloatingPaneOptions{
6768 WidthPct: 0.82,
6869 HeightPct: 0.72,
6970 FallbackWidth: 1000,
@@ -82,7 +83,7 @@ func TestFloatingPane_FirstToggleOpensBlankAndOmnibox(t *testing.T) {
8283 overlay.EXPECT().GetAllocatedWidth().Return(1200).Maybe()
8384 overlay.EXPECT().GetAllocatedHeight().Return(800).Maybe()
8485
85- fp := NewFloatingPane(overlay, FloatingPaneOptions{
86+ fp := component.NewFloatingPane(overlay, component.FloatingPaneOptions{
8687 WidthPct: 0.82,
8788 HeightPct: 0.72,
8889 FallbackWidth: 1200,
@@ -97,7 +98,7 @@ func TestFloatingPane_FirstToggleOpensBlankAndOmnibox(t *testing.T) {
9798 }
9899
99100 func TestFloatingPane_SetOmniboxVisible(t *testing.T) {
100- fp := NewFloatingPane(nil, FloatingPaneOptions{
101+ fp := component.NewFloatingPane(nil, component.FloatingPaneOptions{
101102 WidthPct: 0.82,
102103 HeightPct: 0.72,
103104 FallbackWidth: 1200,
@@ -120,7 +121,7 @@ func TestFloatingPane_ConcurrentResizeAndParentSwitch(t *testing.T) {
120121 overlayB.EXPECT().GetAllocatedWidth().Return(1000).Maybe()
121122 overlayB.EXPECT().GetAllocatedHeight().Return(700).Maybe()
122123
123- fp := NewFloatingPane(overlayA, FloatingPaneOptions{
124+ fp := component.NewFloatingPane(overlayA, component.FloatingPaneOptions{
124125 WidthPct: 0.82,
125126 HeightPct: 0.72,
126127 FallbackWidth: 1200,
@@ -1,10 +1,11 @@
1-package component1+package component_test
2 2
3 import (3 import (
4 "context"4 "context"
5 "sync"5 "sync"
6 "testing"6 "testing"
7 7
8+ "github.com/bnema/dumber/internal/ui/component"
8 layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"9 layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
9 "github.com/stretchr/testify/assert"10 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
@@ -16,7 +17,7 @@ func TestFloatingPane_HideShowPreservesState(t *testing.T) {
16 overlay.EXPECT().GetAllocatedHeight().Return(800).Maybe()17 overlay.EXPECT().GetAllocatedHeight().Return(800).Maybe()
17 18
18 navigated := make([]string, 0)19 navigated := make([]string, 0)
19- fp := NewFloatingPane(overlay, FloatingPaneOptions{20+ fp := component.NewFloatingPane(overlay, component.FloatingPaneOptions{
20 WidthPct: 0.82,21 WidthPct: 0.82,
21 HeightPct: 0.72,22 HeightPct: 0.72,
22 FallbackWidth: 1200,23 FallbackWidth: 1200,
@@ -44,7 +45,7 @@ func TestFloatingPane_ShowURLLoadsTarget(t *testing.T) {
44 overlay.EXPECT().GetAllocatedWidth().Return(1000).Maybe()45 overlay.EXPECT().GetAllocatedWidth().Return(1000).Maybe()
45 overlay.EXPECT().GetAllocatedHeight().Return(700).Maybe()46 overlay.EXPECT().GetAllocatedHeight().Return(700).Maybe()
46 47
47- fp := NewFloatingPane(overlay, FloatingPaneOptions{48+ fp := component.NewFloatingPane(overlay, component.FloatingPaneOptions{
48 WidthPct: 0.82,49 WidthPct: 0.82,
49 HeightPct: 0.72,50 HeightPct: 0.72,
50 FallbackWidth: 1000,51 FallbackWidth: 1000,
@@ -63,7 +64,7 @@ func TestFloatingPane_ResizeUsesWorkspacePercentages(t *testing.T) {
63 overlay.EXPECT().GetAllocatedWidth().Return(1000).Maybe()64 overlay.EXPECT().GetAllocatedWidth().Return(1000).Maybe()
64 overlay.EXPECT().GetAllocatedHeight().Return(700).Maybe()65 overlay.EXPECT().GetAllocatedHeight().Return(700).Maybe()
65 66
66- fp := NewFloatingPane(overlay, FloatingPaneOptions{67+ fp := component.NewFloatingPane(overlay, component.FloatingPaneOptions{
67 WidthPct: 0.82,68 WidthPct: 0.82,
68 HeightPct: 0.72,69 HeightPct: 0.72,
69 FallbackWidth: 1000,70 FallbackWidth: 1000,
@@ -82,7 +83,7 @@ func TestFloatingPane_FirstToggleOpensBlankAndOmnibox(t *testing.T) {
82 overlay.EXPECT().GetAllocatedWidth().Return(1200).Maybe()83 overlay.EXPECT().GetAllocatedWidth().Return(1200).Maybe()
83 overlay.EXPECT().GetAllocatedHeight().Return(800).Maybe()84 overlay.EXPECT().GetAllocatedHeight().Return(800).Maybe()
84 85
85- fp := NewFloatingPane(overlay, FloatingPaneOptions{86+ fp := component.NewFloatingPane(overlay, component.FloatingPaneOptions{
86 WidthPct: 0.82,87 WidthPct: 0.82,
87 HeightPct: 0.72,88 HeightPct: 0.72,
88 FallbackWidth: 1200,89 FallbackWidth: 1200,
@@ -97,7 +98,7 @@ func TestFloatingPane_FirstToggleOpensBlankAndOmnibox(t *testing.T) {
97 }98 }
98 99
99 func TestFloatingPane_SetOmniboxVisible(t *testing.T) {100 func TestFloatingPane_SetOmniboxVisible(t *testing.T) {
100- fp := NewFloatingPane(nil, FloatingPaneOptions{101+ fp := component.NewFloatingPane(nil, component.FloatingPaneOptions{
101 WidthPct: 0.82,102 WidthPct: 0.82,
102 HeightPct: 0.72,103 HeightPct: 0.72,
103 FallbackWidth: 1200,104 FallbackWidth: 1200,
@@ -120,7 +121,7 @@ func TestFloatingPane_ConcurrentResizeAndParentSwitch(t *testing.T) {
120 overlayB.EXPECT().GetAllocatedWidth().Return(1000).Maybe()121 overlayB.EXPECT().GetAllocatedWidth().Return(1000).Maybe()
121 overlayB.EXPECT().GetAllocatedHeight().Return(700).Maybe()122 overlayB.EXPECT().GetAllocatedHeight().Return(700).Maybe()
122 123
123- fp := NewFloatingPane(overlayA, FloatingPaneOptions{124+ fp := component.NewFloatingPane(overlayA, component.FloatingPaneOptions{
124 WidthPct: 0.82,125 WidthPct: 0.82,
125 HeightPct: 0.72,126 HeightPct: 0.72,
126 FallbackWidth: 1200,127 FallbackWidth: 1200,
M internal/ui/component/workspace_view_test.go +2 -0
@@ -259,6 +259,8 @@ func TestSetWorkspace_SinglePane_CreatesPaneView(t *testing.T) {
259259 }
260260
261261 func TestWorkspaceView_HoverFocusLockState(t *testing.T) {
262+ // Intentional zero-value check: hoverFocusLock is an atomic.Bool and should
263+ // be safe with its language-level zero value before constructor wiring.
262264 var wv component.WorkspaceView
263265
264266 assert.False(t, wv.IsHoverFocusLocked())
@@ -259,6 +259,8 @@ func TestSetWorkspace_SinglePane_CreatesPaneView(t *testing.T) {
259 }259 }
260 260
261 func TestWorkspaceView_HoverFocusLockState(t *testing.T) {261 func TestWorkspaceView_HoverFocusLockState(t *testing.T) {
262+ // Intentional zero-value check: hoverFocusLock is an atomic.Bool and should
263+ // be safe with its language-level zero value before constructor wiring.
262 var wv component.WorkspaceView264 var wv component.WorkspaceView
263 265
264 assert.False(t, wv.IsHoverFocusLocked())266 assert.False(t, wv.IsHoverFocusLocked())
M internal/ui/coordinator/content.go +35 -10
@@ -1063,22 +1063,47 @@ func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID en
10631063 if c.faviconAdapter == nil || uri == "" {
10641064 return
10651065 }
1066+ select {
1067+ case <-ctx.Done():
1068+ return
1069+ default:
1070+ }
10661071
10671072 // Check memory and disk cache (no external fetch)
10681073 texture := c.faviconAdapter.PreloadFromCache(uri)
1074+ select {
1075+ case <-ctx.Done():
1076+ return
1077+ default:
1078+ }
10691079
1070- // Update stacked pane favicon if applicable.
1071- // A nil texture triggers the default icon fallback, which avoids stale favicons.
1072- _, wsView := c.getActiveWS()
1073- if wsView != nil {
1080+ // Update stacked pane favicon on GTK main loop.
1081+ cb := glib.SourceFunc(func(_ uintptr) bool {
1082+ select {
1083+ case <-ctx.Done():
1084+ return false
1085+ default:
1086+ }
1087+ if c.getActiveWS == nil {
1088+ return false
1089+ }
1090+ // A nil texture triggers the default icon fallback, which avoids stale favicons.
1091+ _, wsView := c.getActiveWS()
1092+ if wsView == nil {
1093+ return false
1094+ }
10741095 tr := wsView.TreeRenderer()
1075- if tr != nil {
1076- stackedView := tr.GetStackedViewForPane(string(paneID))
1077- if stackedView != nil {
1078- c.updateStackedPaneFavicon(ctx, stackedView, paneID, texture)
1079- }
1096+ if tr == nil {
1097+ return false
10801098 }
1081- }
1099+ stackedView := tr.GetStackedViewForPane(string(paneID))
1100+ if stackedView == nil {
1101+ return false
1102+ }
1103+ c.updateStackedPaneFavicon(ctx, stackedView, paneID, texture)
1104+ return false
1105+ })
1106+ glib.IdleAdd(&cb, 0)
10821107 }
10831108
10841109 // onLoadCommitted re-applies zoom when page content starts loading and records history.
@@ -1063,22 +1063,47 @@ func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID en
1063 if c.faviconAdapter == nil || uri == "" {1063 if c.faviconAdapter == nil || uri == "" {
1064 return1064 return
1065 }1065 }
1066+ select {
1067+ case <-ctx.Done():
1068+ return
1069+ default:
1070+ }
1066 1071
1067 // Check memory and disk cache (no external fetch)1072 // Check memory and disk cache (no external fetch)
1068 texture := c.faviconAdapter.PreloadFromCache(uri)1073 texture := c.faviconAdapter.PreloadFromCache(uri)
1074+ select {
1075+ case <-ctx.Done():
1076+ return
1077+ default:
1078+ }
1069 1079
1070- // Update stacked pane favicon if applicable.1080+ // Update stacked pane favicon on GTK main loop.
1071- // A nil texture triggers the default icon fallback, which avoids stale favicons.1081+ cb := glib.SourceFunc(func(_ uintptr) bool {
1072- _, wsView := c.getActiveWS()1082+ select {
1073- if wsView != nil {1083+ case <-ctx.Done():
1084+ return false
1085+ default:
1086+ }
1087+ if c.getActiveWS == nil {
1088+ return false
1089+ }
1090+ // A nil texture triggers the default icon fallback, which avoids stale favicons.
1091+ _, wsView := c.getActiveWS()
1092+ if wsView == nil {
1093+ return false
1094+ }
1074 tr := wsView.TreeRenderer()1095 tr := wsView.TreeRenderer()
1075- if tr != nil {1096+ if tr == nil {
1076- stackedView := tr.GetStackedViewForPane(string(paneID))1097+ return false
1077- if stackedView != nil {
1078- c.updateStackedPaneFavicon(ctx, stackedView, paneID, texture)
1079- }
1080 }1098 }
1081- }1099+ stackedView := tr.GetStackedViewForPane(string(paneID))
1100+ if stackedView == nil {
1101+ return false
1102+ }
1103+ c.updateStackedPaneFavicon(ctx, stackedView, paneID, texture)
1104+ return false
1105+ })
1106+ glib.IdleAdd(&cb, 0)
1082 }1107 }
1083 1108
1084 // onLoadCommitted re-applies zoom when page content starts loading and records history.1109 // onLoadCommitted re-applies zoom when page content starts loading and records history.
M internal/ui/coordinator/content_active_override_test.go +23 -0
@@ -8,6 +8,7 @@ import (
88
99 "github.com/bnema/dumber/internal/domain/entity"
1010 "github.com/bnema/dumber/internal/infrastructure/webkit"
11+ "github.com/bnema/dumber/internal/ui/component"
1112 )
1213
1314 func TestContentCoordinator_ActiveWebView_UsesOverride(t *testing.T) {
@@ -42,3 +43,25 @@ func TestContentCoordinator_ActiveWebView_ClearOverrideFallsBack(t *testing.T) {
4243 assert.Nil(t, c.ActiveWebView(context.Background()))
4344 assert.Equal(t, entity.PaneID(""), c.ActivePaneID(context.Background()))
4445 }
46+
47+func TestContentCoordinator_ActiveWebView_ClearOverrideFallsBackToWorkspace(t *testing.T) {
48+ ctx := context.Background()
49+ mainWV := &webkit.WebView{}
50+ mainPane := entity.NewPane(entity.PaneID("main-pane"))
51+ ws := entity.NewWorkspace(entity.WorkspaceID("ws-1"), mainPane)
52+
53+ c := &ContentCoordinator{
54+ webViews: map[entity.PaneID]*webkit.WebView{
55+ mainPane.ID: mainWV,
56+ },
57+ getActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
58+ return ws, nil
59+ },
60+ }
61+
62+ c.SetActivePaneOverride("floating-pane")
63+ c.ClearActivePaneOverride()
64+
65+ assert.Equal(t, mainWV, c.ActiveWebView(ctx))
66+ assert.Equal(t, ws.ActivePaneID, c.ActivePaneID(ctx))
67+}
@@ -8,6 +8,7 @@ import (
8 8
9 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/bnema/dumber/internal/infrastructure/webkit"10 "github.com/bnema/dumber/internal/infrastructure/webkit"
11+ "github.com/bnema/dumber/internal/ui/component"
11 )12 )
12 13
13 func TestContentCoordinator_ActiveWebView_UsesOverride(t *testing.T) {14 func TestContentCoordinator_ActiveWebView_UsesOverride(t *testing.T) {
@@ -42,3 +43,25 @@ func TestContentCoordinator_ActiveWebView_ClearOverrideFallsBack(t *testing.T) {
42 assert.Nil(t, c.ActiveWebView(context.Background()))43 assert.Nil(t, c.ActiveWebView(context.Background()))
43 assert.Equal(t, entity.PaneID(""), c.ActivePaneID(context.Background()))44 assert.Equal(t, entity.PaneID(""), c.ActivePaneID(context.Background()))
44 }45 }
46+
47+func TestContentCoordinator_ActiveWebView_ClearOverrideFallsBackToWorkspace(t *testing.T) {
48+ ctx := context.Background()
49+ mainWV := &webkit.WebView{}
50+ mainPane := entity.NewPane(entity.PaneID("main-pane"))
51+ ws := entity.NewWorkspace(entity.WorkspaceID("ws-1"), mainPane)
52+
53+ c := &ContentCoordinator{
54+ webViews: map[entity.PaneID]*webkit.WebView{
55+ mainPane.ID: mainWV,
56+ },
57+ getActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
58+ return ws, nil
59+ },
60+ }
61+
62+ c.SetActivePaneOverride("floating-pane")
63+ c.ClearActivePaneOverride()
64+
65+ assert.Equal(t, mainWV, c.ActiveWebView(ctx))
66+ assert.Equal(t, ws.ActivePaneID, c.ActivePaneID(ctx))
67+}
M internal/ui/coordinator/navigation.go +8 -1
@@ -3,6 +3,7 @@ package coordinator
33 import (
44 "context"
55 "fmt"
6+ "time"
67
78 "github.com/bnema/dumber/internal/application/usecase"
89 "github.com/bnema/dumber/internal/domain/entity"
@@ -24,6 +25,8 @@ type NavigationCoordinator struct {
2425 omniboxProvider OmniboxProvider
2526 }
2627
28+const faviconPreloadTimeout = 300 * time.Millisecond
29+
2730 // NewNavigationCoordinator creates a new NavigationCoordinator.
2831 func NewNavigationCoordinator(
2932 ctx context.Context,
@@ -74,7 +77,11 @@ func (c *NavigationCoordinator) Navigate(ctx context.Context, url string) error
7477 // Track original URL for cross-domain redirect favicon caching
7578 c.contentCoord.SetNavigationOrigin(activePaneID, url)
7679 // Pre-load cached favicon asynchronously (don't block navigation start)
77- go c.contentCoord.PreloadCachedFavicon(ctx, activePaneID, url)
80+ preloadCtx, cancelPreload := context.WithTimeout(ctx, faviconPreloadTimeout)
81+ go func() {
82+ defer cancelPreload()
83+ c.contentCoord.PreloadCachedFavicon(preloadCtx, activePaneID, url)
84+ }()
7885 }
7986
8087 // Use NavigateUseCase which handles history + zoom
@@ -3,6 +3,7 @@ package coordinator
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "time"
6 7
7 "github.com/bnema/dumber/internal/application/usecase"8 "github.com/bnema/dumber/internal/application/usecase"
8 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
@@ -24,6 +25,8 @@ type NavigationCoordinator struct {
24 omniboxProvider OmniboxProvider25 omniboxProvider OmniboxProvider
25 }26 }
26 27
28+const faviconPreloadTimeout = 300 * time.Millisecond
29+
27 // NewNavigationCoordinator creates a new NavigationCoordinator.30 // NewNavigationCoordinator creates a new NavigationCoordinator.
28 func NewNavigationCoordinator(31 func NewNavigationCoordinator(
29 ctx context.Context,32 ctx context.Context,
@@ -74,7 +77,11 @@ func (c *NavigationCoordinator) Navigate(ctx context.Context, url string) error
74 // Track original URL for cross-domain redirect favicon caching77 // Track original URL for cross-domain redirect favicon caching
75 c.contentCoord.SetNavigationOrigin(activePaneID, url)78 c.contentCoord.SetNavigationOrigin(activePaneID, url)
76 // Pre-load cached favicon asynchronously (don't block navigation start)79 // Pre-load cached favicon asynchronously (don't block navigation start)
77- go c.contentCoord.PreloadCachedFavicon(ctx, activePaneID, url)80+ preloadCtx, cancelPreload := context.WithTimeout(ctx, faviconPreloadTimeout)
81+ go func() {
82+ defer cancelPreload()
83+ c.contentCoord.PreloadCachedFavicon(preloadCtx, activePaneID, url)
84+ }()
78 }85 }
79 86
80 // Use NavigateUseCase which handles history + zoom87 // Use NavigateUseCase which handles history + zoom
M internal/ui/input/global_shortcuts.go +7 -1
@@ -3,6 +3,7 @@ package input
33
44 import (
55 "context"
6+ "fmt"
67 "strings"
78
89 "github.com/bnema/dumber/internal/infrastructure/config"
@@ -100,6 +101,7 @@ func NewGlobalShortcutHandler(
100101 // Map action names to action constants for global shortcuts
101102 actionMap := map[string]Action{
102103 "toggle_floating_pane": ActionToggleFloatingPane,
104+ "toggle-floating-pane": ActionToggleFloatingPane,
103105 "consume_or_expel_left": ActionConsumeOrExpelLeft,
104106 "consume_or_expel_right": ActionConsumeOrExpelRight,
105107 "consume_or_expel_up": ActionConsumeOrExpelUp,
@@ -238,6 +240,10 @@ func formatBinding(binding KeyBinding) string {
238240 if binding.Modifiers&ModAlt != 0 {
239241 parts = append(parts, "alt")
240242 }
241- parts = append(parts, strings.ToLower(gdk.KeyvalName(binding.Keyval)))
243+ keyName := gdk.KeyvalName(binding.Keyval)
244+ if keyName == "" {
245+ keyName = fmt.Sprintf("0x%x", binding.Keyval)
246+ }
247+ parts = append(parts, strings.ToLower(keyName))
242248 return strings.Join(parts, "+")
243249 }
@@ -3,6 +3,7 @@ package input
3 3
4 import (4 import (
5 "context"5 "context"
6+ "fmt"
6 "strings"7 "strings"
7 8
8 "github.com/bnema/dumber/internal/infrastructure/config"9 "github.com/bnema/dumber/internal/infrastructure/config"
@@ -100,6 +101,7 @@ func NewGlobalShortcutHandler(
100 // Map action names to action constants for global shortcuts101 // Map action names to action constants for global shortcuts
101 actionMap := map[string]Action{102 actionMap := map[string]Action{
102 "toggle_floating_pane": ActionToggleFloatingPane,103 "toggle_floating_pane": ActionToggleFloatingPane,
104+ "toggle-floating-pane": ActionToggleFloatingPane,
103 "consume_or_expel_left": ActionConsumeOrExpelLeft,105 "consume_or_expel_left": ActionConsumeOrExpelLeft,
104 "consume_or_expel_right": ActionConsumeOrExpelRight,106 "consume_or_expel_right": ActionConsumeOrExpelRight,
105 "consume_or_expel_up": ActionConsumeOrExpelUp,107 "consume_or_expel_up": ActionConsumeOrExpelUp,
@@ -238,6 +240,10 @@ func formatBinding(binding KeyBinding) string {
238 if binding.Modifiers&ModAlt != 0 {240 if binding.Modifiers&ModAlt != 0 {
239 parts = append(parts, "alt")241 parts = append(parts, "alt")
240 }242 }
241- parts = append(parts, strings.ToLower(gdk.KeyvalName(binding.Keyval)))243+ keyName := gdk.KeyvalName(binding.Keyval)
244+ if keyName == "" {
245+ keyName = fmt.Sprintf("0x%x", binding.Keyval)
246+ }
247+ parts = append(parts, strings.ToLower(keyName))
242 return strings.Join(parts, "+")248 return strings.Join(parts, "+")
243 }249 }
M internal/ui/input/shortcuts.go +4 -0
@@ -334,6 +334,7 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) {
334334 // Map action names to action constants
335335 actionMap := map[string]Action{
336336 "toggle_floating_pane": ActionToggleFloatingPane,
337+ "toggle-floating-pane": ActionToggleFloatingPane,
337338 "close_pane": ActionClosePane,
338339 "next_tab": ActionNextTab,
339340 "previous_tab": ActionPreviousTab,
@@ -442,6 +443,8 @@ func reserveGlobalOnlyShortcutBindings(occupied map[KeyBinding]Action) {
442443 ActionSwitchTabIndex9,
443444 }
444445
446+ // Assumes gdk.KEY_1..gdk.KEY_9 are contiguous (ASCII '1'..'9').
447+ // If that ever changes on a platform, map digits explicitly.
445448 for i, action := range tabIndexActions {
446449 binding := KeyBinding{Keyval: uint(gdk.KEY_1) + uint(i), Modifiers: ModAlt}
447450 if _, exists := occupied[binding]; exists {
@@ -541,6 +544,7 @@ func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[strin
541544
542545 var configActionToAction = map[string]Action{
543546 "toggle_floating_pane": ActionToggleFloatingPane,
547+ "toggle-floating-pane": ActionToggleFloatingPane,
544548
545549 // Tab actions
546550 "new-tab": ActionNewTab,
@@ -334,6 +334,7 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) {
334 // Map action names to action constants334 // Map action names to action constants
335 actionMap := map[string]Action{335 actionMap := map[string]Action{
336 "toggle_floating_pane": ActionToggleFloatingPane,336 "toggle_floating_pane": ActionToggleFloatingPane,
337+ "toggle-floating-pane": ActionToggleFloatingPane,
337 "close_pane": ActionClosePane,338 "close_pane": ActionClosePane,
338 "next_tab": ActionNextTab,339 "next_tab": ActionNextTab,
339 "previous_tab": ActionPreviousTab,340 "previous_tab": ActionPreviousTab,
@@ -442,6 +443,8 @@ func reserveGlobalOnlyShortcutBindings(occupied map[KeyBinding]Action) {
442 ActionSwitchTabIndex9,443 ActionSwitchTabIndex9,
443 }444 }
444 445
446+ // Assumes gdk.KEY_1..gdk.KEY_9 are contiguous (ASCII '1'..'9').
447+ // If that ever changes on a platform, map digits explicitly.
445 for i, action := range tabIndexActions {448 for i, action := range tabIndexActions {
446 binding := KeyBinding{Keyval: uint(gdk.KEY_1) + uint(i), Modifiers: ModAlt}449 binding := KeyBinding{Keyval: uint(gdk.KEY_1) + uint(i), Modifiers: ModAlt}
447 if _, exists := occupied[binding]; exists {450 if _, exists := occupied[binding]; exists {
@@ -541,6 +544,7 @@ func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[strin
541 544
542 var configActionToAction = map[string]Action{545 var configActionToAction = map[string]Action{
543 "toggle_floating_pane": ActionToggleFloatingPane,546 "toggle_floating_pane": ActionToggleFloatingPane,
547+ "toggle-floating-pane": ActionToggleFloatingPane,
544 548
545 // Tab actions549 // Tab actions
546 "new-tab": ActionNewTab,550 "new-tab": ActionNewTab,
M internal/ui/input/shortcuts_test.go +7 -0
@@ -382,3 +382,10 @@ func TestMapConfigAction_ToggleFloatingPane(t *testing.T) {
382382 t.Fatalf("mapConfigAction(toggle_floating_pane) = %s, want %s", action, ActionToggleFloatingPane)
383383 }
384384 }
385+
386+func TestMapConfigAction_ToggleFloatingPaneHyphenAlias(t *testing.T) {
387+ action := mapConfigAction("toggle-floating-pane")
388+ if action != ActionToggleFloatingPane {
389+ t.Fatalf("mapConfigAction(toggle-floating-pane) = %s, want %s", action, ActionToggleFloatingPane)
390+ }
391+}
@@ -382,3 +382,10 @@ func TestMapConfigAction_ToggleFloatingPane(t *testing.T) {
382 t.Fatalf("mapConfigAction(toggle_floating_pane) = %s, want %s", action, ActionToggleFloatingPane)382 t.Fatalf("mapConfigAction(toggle_floating_pane) = %s, want %s", action, ActionToggleFloatingPane)
383 }383 }
384 }384 }
385+
386+func TestMapConfigAction_ToggleFloatingPaneHyphenAlias(t *testing.T) {
387+ action := mapConfigAction("toggle-floating-pane")
388+ if action != ActionToggleFloatingPane {
389+ t.Fatalf("mapConfigAction(toggle-floating-pane) = %s, want %s", action, ActionToggleFloatingPane)
390+ }
391+}
M internal/ui/theme/css.go +0 -4
@@ -997,10 +997,6 @@ func generateFloatingPaneCSS(p Palette) string {
997997 border-color: var(--accent);
998998 }
999999
1000-.single-pane .floating-pane-container.pane-active {
1001- border-color: var(--accent);
1002-}
1003-
10041000 .floating-pane-omnibox {
10051001 background-color: shade(var(--surface-variant), 1.08);
10061002 border-bottom: 0.0625em solid var(--border);
@@ -997,10 +997,6 @@ func generateFloatingPaneCSS(p Palette) string {
997 border-color: var(--accent);997 border-color: var(--accent);
998 }998 }
999 999
1000-.single-pane .floating-pane-container.pane-active {
1001- border-color: var(--accent);
1002-}
1003-
1004 .floating-pane-omnibox {1000 .floating-pane-omnibox {
1005 background-color: shade(var(--surface-variant), 1.08);1001 background-color: shade(var(--surface-variant), 1.08);
1006 border-bottom: 0.0625em solid var(--border);1002 border-bottom: 0.0625em solid var(--border);

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