detached 4991fbe

feat(favorites): add native bookmark shortcuts

commit
4991fbe6b883a99bb2d5f769397769d76881764d
author
brice · · 2026-06-28 16:12 UTC
committer
brice · · 2026-06-28 16:17 UTC
parent
3857325
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +522 -59
M internal/infrastructure/config/defaults.go +12 -11
@@ -322,17 +322,18 @@ func DefaultConfig() *Config {
322322 },
323323 Shortcuts: GlobalShortcutsConfig{
324324 Actions: map[string]ActionBinding{
325- "toggle-floating-pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"},
326- "toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "Toggle History in right split"},
327- "toggle-favorites-systemview": {Keys: []string{}, Desc: "Toggle Favorites in right split"},
328- "toggle-config-systemview": {Keys: []string{}, Desc: "Toggle Config in right split"},
329- "close-pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"},
330- "next-tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"},
331- "previous-tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"},
332- "consume-or-expel-left": {Keys: []string{"alt+["}, Desc: "Consume/expel pane left"},
333- "consume-or-expel-right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"},
334- "consume-or-expel-up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"},
335- "consume-or-expel-down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"},
325+ "toggle-floating-pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"},
326+ "toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "Toggle History in right split"},
327+ "toggle-favorites-systemview": {Keys: []string{"ctrl+b"}, Desc: "Toggle Favorites sidebar"},
328+ "toggle-current-page-favorite": {Keys: []string{"ctrl+d"}, Desc: "Add/remove current page favorite"},
329+ "toggle-config-systemview": {Keys: []string{}, Desc: "Toggle Config in right split"},
330+ "close-pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"},
331+ "next-tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"},
332+ "previous-tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"},
333+ "consume-or-expel-left": {Keys: []string{"alt+["}, Desc: "Consume/expel pane left"},
334+ "consume-or-expel-right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"},
335+ "consume-or-expel-up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"},
336+ "consume-or-expel-down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"},
336337 },
337338 },
338339 FloatingPane: FloatingPaneConfig{
@@ -322,17 +322,18 @@ func DefaultConfig() *Config {
322 },322 },
323 Shortcuts: GlobalShortcutsConfig{323 Shortcuts: GlobalShortcutsConfig{
324 Actions: map[string]ActionBinding{324 Actions: map[string]ActionBinding{
325- "toggle-floating-pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"},325+ "toggle-floating-pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"},
326- "toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "Toggle History in right split"},326+ "toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "Toggle History in right split"},
327- "toggle-favorites-systemview": {Keys: []string{}, Desc: "Toggle Favorites in right split"},327+ "toggle-favorites-systemview": {Keys: []string{"ctrl+b"}, Desc: "Toggle Favorites sidebar"},
328- "toggle-config-systemview": {Keys: []string{}, Desc: "Toggle Config in right split"},328+ "toggle-current-page-favorite": {Keys: []string{"ctrl+d"}, Desc: "Add/remove current page favorite"},
329- "close-pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"},329+ "toggle-config-systemview": {Keys: []string{}, Desc: "Toggle Config in right split"},
330- "next-tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"},330+ "close-pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"},
331- "previous-tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"},331+ "next-tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"},
332- "consume-or-expel-left": {Keys: []string{"alt+["}, Desc: "Consume/expel pane left"},332+ "previous-tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"},
333- "consume-or-expel-right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"},333+ "consume-or-expel-left": {Keys: []string{"alt+["}, Desc: "Consume/expel pane left"},
334- "consume-or-expel-up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"},334+ "consume-or-expel-right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"},
335- "consume-or-expel-down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"},335+ "consume-or-expel-up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"},
336+ "consume-or-expel-down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"},
336 },337 },
337 },338 },
338 FloatingPane: FloatingPaneConfig{339 FloatingPane: FloatingPaneConfig{
M internal/infrastructure/config/defaults_test.go +2 -1
@@ -41,7 +41,8 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
4141
4242 // System view shortcuts are first-class global actions.
4343 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-history-systemview", []string{"ctrl+h"})
44- requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-favorites-systemview", []string{})
44+ requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-favorites-systemview", []string{"ctrl+b"})
45+ requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-current-page-favorite", []string{"ctrl+d"})
4546 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-config-systemview", []string{})
4647
4748 // Old sections (Rendering, Privacy, Performance, Runtime) have been removed from Config.
@@ -41,7 +41,8 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
41 41
42 // System view shortcuts are first-class global actions.42 // System view shortcuts are first-class global actions.
43 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-history-systemview", []string{"ctrl+h"})43 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-history-systemview", []string{"ctrl+h"})
44- requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-favorites-systemview", []string{})44+ requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-favorites-systemview", []string{"ctrl+b"})
45+ requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-current-page-favorite", []string{"ctrl+d"})
45 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-config-systemview", []string{})46 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-config-systemview", []string{})
46 47
47 // Old sections (Rendering, Privacy, Performance, Runtime) have been removed from Config.48 // Old sections (Rendering, Privacy, Performance, Runtime) have been removed from Config.
M internal/ui/app.go +8 -1
@@ -2040,7 +2040,12 @@ func (a *App) hideAndRestoreFocusForBrowserWindow(bw *browserWindow) {
20402040 if bw == nil {
20412041 return
20422042 }
2043- bw.hideHistorySidebar()
2043+ switch bw.activeSidebarKind {
2044+ case nativeSidebarFavorites:
2045+ bw.hideFavoritesSidebar()
2046+ default:
2047+ bw.hideHistorySidebar()
2048+ }
20442049 if wsView := a.activeWorkspaceViewForBrowserWindow(bw); wsView != nil {
20452050 if ws := a.activeWorkspaceForBrowserWindow(bw); ws != nil {
20462051 wsView.FocusPane(ws.ActivePaneID)
@@ -3357,6 +3362,8 @@ func (a *App) wireKeyboardActions() {
33573362 return a.EjectActivePaneToWindow(ctx, paneID)
33583363 })
33593364 a.kbDispatcher.SetOnToggleHistorySidebar(a.toggleHistorySidebarAction)
3365+ a.kbDispatcher.SetOnToggleFavoritesSidebar(a.toggleFavoritesSidebarAction)
3366+ a.kbDispatcher.SetOnToggleCurrentPageFavorite(a.toggleCurrentPageFavoriteAction)
33603367 a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error {
33613368 return a.ToggleFloatingPane(ctx)
33623369 })
@@ -2040,7 +2040,12 @@ func (a *App) hideAndRestoreFocusForBrowserWindow(bw *browserWindow) {
2040 if bw == nil {2040 if bw == nil {
2041 return2041 return
2042 }2042 }
2043- bw.hideHistorySidebar()2043+ switch bw.activeSidebarKind {
2044+ case nativeSidebarFavorites:
2045+ bw.hideFavoritesSidebar()
2046+ default:
2047+ bw.hideHistorySidebar()
2048+ }
2044 if wsView := a.activeWorkspaceViewForBrowserWindow(bw); wsView != nil {2049 if wsView := a.activeWorkspaceViewForBrowserWindow(bw); wsView != nil {
2045 if ws := a.activeWorkspaceForBrowserWindow(bw); ws != nil {2050 if ws := a.activeWorkspaceForBrowserWindow(bw); ws != nil {
2046 wsView.FocusPane(ws.ActivePaneID)2051 wsView.FocusPane(ws.ActivePaneID)
@@ -3357,6 +3362,8 @@ func (a *App) wireKeyboardActions() {
3357 return a.EjectActivePaneToWindow(ctx, paneID)3362 return a.EjectActivePaneToWindow(ctx, paneID)
3358 })3363 })
3359 a.kbDispatcher.SetOnToggleHistorySidebar(a.toggleHistorySidebarAction)3364 a.kbDispatcher.SetOnToggleHistorySidebar(a.toggleHistorySidebarAction)
3365+ a.kbDispatcher.SetOnToggleFavoritesSidebar(a.toggleFavoritesSidebarAction)
3366+ a.kbDispatcher.SetOnToggleCurrentPageFavorite(a.toggleCurrentPageFavoriteAction)
3360 a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error {3367 a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error {
3361 return a.ToggleFloatingPane(ctx)3368 return a.ToggleFloatingPane(ctx)
3362 })3369 })
M internal/ui/browser_window.go +16 -0
@@ -19,6 +19,14 @@ import (
1919 "github.com/bnema/puregotk/v4/glib"
2020 )
2121
22+type nativeSidebarKind string
23+
24+const (
25+ nativeSidebarNone nativeSidebarKind = ""
26+ nativeSidebarHistory nativeSidebarKind = "history"
27+ nativeSidebarFavorites nativeSidebarKind = "favorites"
28+)
29+
2230 type browserWindow struct {
2331 id string
2432 initialURL string
@@ -39,8 +47,10 @@ type browserWindow struct {
3947 permissionDialog port.PermissionDialogPresenter
4048 webrtcIndicator *component.WebRTCPermissionIndicator
4149 historySidebar *component.HistorySidebar
50+ favoritesSidebar *component.FavoritesSidebar
4251 historySidebarReloader historySidebarReloader
4352 sidebarVisible bool
53+ activeSidebarKind nativeSidebarKind
4454 }
4555
4656 func (bw *browserWindow) detachInputForDestroy() {
@@ -73,6 +83,9 @@ func (bw *browserWindow) clearShellState() {
7383 if bw.historySidebar != nil {
7484 bw.historySidebar.Destroy()
7585 }
86+ if bw.favoritesSidebar != nil {
87+ bw.favoritesSidebar.Destroy()
88+ }
7689 if bw.touchpadNavIndicator != nil {
7790 bw.touchpadNavIndicator.Destroy()
7891 }
@@ -91,7 +104,9 @@ func (bw *browserWindow) clearShellState() {
91104 bw.permissionDialog = nil
92105 bw.webrtcIndicator = nil
93106 bw.historySidebar = nil
107+ bw.favoritesSidebar = nil
94108 bw.historySidebarReloader = nil
109+ bw.activeSidebarKind = nativeSidebarNone
95110 }
96111
97112 func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
@@ -106,6 +121,7 @@ func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
106121 bw.initSessionManager(ctx, a)
107122 bw.initTabPicker(ctx, a)
108123 bw.initHistorySidebar(ctx, a)
124+ bw.initFavoritesSidebar(ctx, a)
109125 }
110126
111127 func (bw *browserWindow) initToasterOverlay(a *App) {
@@ -19,6 +19,14 @@ import (
19 "github.com/bnema/puregotk/v4/glib"19 "github.com/bnema/puregotk/v4/glib"
20 )20 )
21 21
22+type nativeSidebarKind string
23+
24+const (
25+ nativeSidebarNone nativeSidebarKind = ""
26+ nativeSidebarHistory nativeSidebarKind = "history"
27+ nativeSidebarFavorites nativeSidebarKind = "favorites"
28+)
29+
22 type browserWindow struct {30 type browserWindow struct {
23 id string31 id string
24 initialURL string32 initialURL string
@@ -39,8 +47,10 @@ type browserWindow struct {
39 permissionDialog port.PermissionDialogPresenter47 permissionDialog port.PermissionDialogPresenter
40 webrtcIndicator *component.WebRTCPermissionIndicator48 webrtcIndicator *component.WebRTCPermissionIndicator
41 historySidebar *component.HistorySidebar49 historySidebar *component.HistorySidebar
50+ favoritesSidebar *component.FavoritesSidebar
42 historySidebarReloader historySidebarReloader51 historySidebarReloader historySidebarReloader
43 sidebarVisible bool52 sidebarVisible bool
53+ activeSidebarKind nativeSidebarKind
44 }54 }
45 55
46 func (bw *browserWindow) detachInputForDestroy() {56 func (bw *browserWindow) detachInputForDestroy() {
@@ -73,6 +83,9 @@ func (bw *browserWindow) clearShellState() {
73 if bw.historySidebar != nil {83 if bw.historySidebar != nil {
74 bw.historySidebar.Destroy()84 bw.historySidebar.Destroy()
75 }85 }
86+ if bw.favoritesSidebar != nil {
87+ bw.favoritesSidebar.Destroy()
88+ }
76 if bw.touchpadNavIndicator != nil {89 if bw.touchpadNavIndicator != nil {
77 bw.touchpadNavIndicator.Destroy()90 bw.touchpadNavIndicator.Destroy()
78 }91 }
@@ -91,7 +104,9 @@ func (bw *browserWindow) clearShellState() {
91 bw.permissionDialog = nil104 bw.permissionDialog = nil
92 bw.webrtcIndicator = nil105 bw.webrtcIndicator = nil
93 bw.historySidebar = nil106 bw.historySidebar = nil
107+ bw.favoritesSidebar = nil
94 bw.historySidebarReloader = nil108 bw.historySidebarReloader = nil
109+ bw.activeSidebarKind = nativeSidebarNone
95 }110 }
96 111
97 func (bw *browserWindow) initChrome(ctx context.Context, a *App) {112 func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
@@ -106,6 +121,7 @@ func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
106 bw.initSessionManager(ctx, a)121 bw.initSessionManager(ctx, a)
107 bw.initTabPicker(ctx, a)122 bw.initTabPicker(ctx, a)
108 bw.initHistorySidebar(ctx, a)123 bw.initHistorySidebar(ctx, a)
124+ bw.initFavoritesSidebar(ctx, a)
109 }125 }
110 126
111 func (bw *browserWindow) initToasterOverlay(a *App) {127 func (bw *browserWindow) initToasterOverlay(a *App) {
A internal/ui/browser_window_favorites_sidebar.go +134 -0
@@ -0,0 +1,134 @@
1+package ui
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/bnema/dumber/internal/ui/component"
12+)
13+
14+// initFavoritesSidebar creates the native favorites sidebar when favorites are configured.
15+func (bw *browserWindow) initFavoritesSidebar(ctx context.Context, a *App) {
16+ if bw == nil || a == nil || bw.mainWindow == nil || a.deps == nil || a.deps.FavoritesUC == nil {
17+ return
18+ }
19+ log := logging.FromContext(ctx)
20+
21+ cfg := a.buildFavoritesSidebarConfig(bw)
22+ sidebar := component.NewFavoritesSidebar(ctx, cfg)
23+ if sidebar == nil {
24+ log.Warn().Msg("failed to create favorites sidebar")
25+ return
26+ }
27+
28+ bw.favoritesSidebar = sidebar
29+ bw.favoritesSidebar.Hide()
30+ bw.applySidebarWidthConfig(a)
31+ log.Debug().Msg("favorites sidebar initialized")
32+}
33+
34+func (a *App) buildFavoritesSidebarConfig(bw *browserWindow) component.FavoritesSidebarConfig {
35+ var favoritesUC port.FavoritesSidebarFavorites
36+ if a.deps != nil {
37+ favoritesUC = a.deps.FavoritesUC
38+ }
39+ return component.FavoritesSidebarConfig{
40+ FavoritesUC: favoritesUC,
41+ OnNavigate: func(navCtx context.Context, url string) error {
42+ return a.navigateFavoritesSidebarSelection(navCtx, bw, url)
43+ },
44+ OnNavigateKeepOpen: func(navCtx context.Context, url string) error {
45+ return a.navigateFavoritesSidebarSelection(navCtx, bw, url)
46+ },
47+ OnOpenInNewPane: func(splitCtx context.Context, url string) error {
48+ if a.wsCoord == nil || !a.hasBrowserWindow(bw) {
49+ return nil
50+ }
51+ a.activateBrowserWindow(bw)
52+ return a.wsCoord.SplitWithURL(splitCtx, usecase.SplitRight, url)
53+ },
54+ OnClose: func() {
55+ a.hideAndRestoreFocusForBrowserWindow(bw)
56+ },
57+ }
58+}
59+
60+func (a *App) navigateFavoritesSidebarSelection(ctx context.Context, bw *browserWindow, url string) error {
61+ if a == nil || bw == nil || !a.hasBrowserWindow(bw) {
62+ return nil
63+ }
64+ return a.navigateFromBrowserWindow(ctx, bw, url)
65+}
66+
67+func (bw *browserWindow) toggleFavoritesSidebar() {
68+ if bw == nil || bw.favoritesSidebar == nil {
69+ return
70+ }
71+ if bw.sidebarVisible && bw.activeSidebarKind == nativeSidebarFavorites {
72+ bw.hideFavoritesSidebar()
73+ return
74+ }
75+ bw.showFavoritesSidebar()
76+}
77+
78+func (bw *browserWindow) showFavoritesSidebar() {
79+ if bw == nil || bw.favoritesSidebar == nil || bw.mainWindow == nil {
80+ return
81+ }
82+ bw.mountNativeSidebar(nativeSidebarFavorites)
83+ bw.favoritesSidebar.Show()
84+ bw.mainWindow.SetSidebarVisible(true)
85+ bw.sidebarVisible = true
86+ bw.activeSidebarKind = nativeSidebarFavorites
87+}
88+
89+func (bw *browserWindow) hideFavoritesSidebar() {
90+ if bw == nil || bw.favoritesSidebar == nil || bw.mainWindow == nil {
91+ return
92+ }
93+ bw.favoritesSidebar.Hide()
94+ bw.mainWindow.SetSidebarVisible(false)
95+ bw.sidebarVisible = false
96+ if bw.activeSidebarKind == nativeSidebarFavorites {
97+ bw.activeSidebarKind = nativeSidebarNone
98+ }
99+}
100+
101+func (a *App) toggleFavoritesSidebarAction(ctx context.Context) error {
102+ bw := a.lastFocusedBrowserWindow()
103+ if bw == nil {
104+ return fmt.Errorf("favorites sidebar unavailable: no focused browser window")
105+ }
106+ if bw.favoritesSidebar == nil {
107+ return fmt.Errorf("favorites sidebar unavailable: native sidebar not initialized")
108+ }
109+ if bw.sidebarVisible && bw.activeSidebarKind == nativeSidebarFavorites {
110+ a.hideAndRestoreFocusForBrowserWindow(bw)
111+ return nil
112+ }
113+ bw.toggleFavoritesSidebar()
114+ return nil
115+}
116+
117+func (a *App) toggleCurrentPageFavoriteAction(ctx context.Context) error {
118+ if a == nil || a.deps == nil || a.deps.FavoritesUC == nil {
119+ return fmt.Errorf("favorites unavailable: usecase not configured")
120+ }
121+ _, wv := a.activeWebViewForBrowserWindow(a.lastFocusedBrowserWindow())
122+ if wv == nil {
123+ return fmt.Errorf("favorites unavailable: no active webview")
124+ }
125+ uri := strings.TrimSpace(wv.URI())
126+ if uri == "" {
127+ return fmt.Errorf("favorites unavailable: active page has no URI")
128+ }
129+ _, err := a.deps.FavoritesUC.Toggle(ctx, uri, wv.Title())
130+ if err != nil {
131+ return err
132+ }
133+ return nil
134+}
@@ -0,0 +1,134 @@
1+package ui
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/bnema/dumber/internal/ui/component"
12+)
13+
14+// initFavoritesSidebar creates the native favorites sidebar when favorites are configured.
15+func (bw *browserWindow) initFavoritesSidebar(ctx context.Context, a *App) {
16+ if bw == nil || a == nil || bw.mainWindow == nil || a.deps == nil || a.deps.FavoritesUC == nil {
17+ return
18+ }
19+ log := logging.FromContext(ctx)
20+
21+ cfg := a.buildFavoritesSidebarConfig(bw)
22+ sidebar := component.NewFavoritesSidebar(ctx, cfg)
23+ if sidebar == nil {
24+ log.Warn().Msg("failed to create favorites sidebar")
25+ return
26+ }
27+
28+ bw.favoritesSidebar = sidebar
29+ bw.favoritesSidebar.Hide()
30+ bw.applySidebarWidthConfig(a)
31+ log.Debug().Msg("favorites sidebar initialized")
32+}
33+
34+func (a *App) buildFavoritesSidebarConfig(bw *browserWindow) component.FavoritesSidebarConfig {
35+ var favoritesUC port.FavoritesSidebarFavorites
36+ if a.deps != nil {
37+ favoritesUC = a.deps.FavoritesUC
38+ }
39+ return component.FavoritesSidebarConfig{
40+ FavoritesUC: favoritesUC,
41+ OnNavigate: func(navCtx context.Context, url string) error {
42+ return a.navigateFavoritesSidebarSelection(navCtx, bw, url)
43+ },
44+ OnNavigateKeepOpen: func(navCtx context.Context, url string) error {
45+ return a.navigateFavoritesSidebarSelection(navCtx, bw, url)
46+ },
47+ OnOpenInNewPane: func(splitCtx context.Context, url string) error {
48+ if a.wsCoord == nil || !a.hasBrowserWindow(bw) {
49+ return nil
50+ }
51+ a.activateBrowserWindow(bw)
52+ return a.wsCoord.SplitWithURL(splitCtx, usecase.SplitRight, url)
53+ },
54+ OnClose: func() {
55+ a.hideAndRestoreFocusForBrowserWindow(bw)
56+ },
57+ }
58+}
59+
60+func (a *App) navigateFavoritesSidebarSelection(ctx context.Context, bw *browserWindow, url string) error {
61+ if a == nil || bw == nil || !a.hasBrowserWindow(bw) {
62+ return nil
63+ }
64+ return a.navigateFromBrowserWindow(ctx, bw, url)
65+}
66+
67+func (bw *browserWindow) toggleFavoritesSidebar() {
68+ if bw == nil || bw.favoritesSidebar == nil {
69+ return
70+ }
71+ if bw.sidebarVisible && bw.activeSidebarKind == nativeSidebarFavorites {
72+ bw.hideFavoritesSidebar()
73+ return
74+ }
75+ bw.showFavoritesSidebar()
76+}
77+
78+func (bw *browserWindow) showFavoritesSidebar() {
79+ if bw == nil || bw.favoritesSidebar == nil || bw.mainWindow == nil {
80+ return
81+ }
82+ bw.mountNativeSidebar(nativeSidebarFavorites)
83+ bw.favoritesSidebar.Show()
84+ bw.mainWindow.SetSidebarVisible(true)
85+ bw.sidebarVisible = true
86+ bw.activeSidebarKind = nativeSidebarFavorites
87+}
88+
89+func (bw *browserWindow) hideFavoritesSidebar() {
90+ if bw == nil || bw.favoritesSidebar == nil || bw.mainWindow == nil {
91+ return
92+ }
93+ bw.favoritesSidebar.Hide()
94+ bw.mainWindow.SetSidebarVisible(false)
95+ bw.sidebarVisible = false
96+ if bw.activeSidebarKind == nativeSidebarFavorites {
97+ bw.activeSidebarKind = nativeSidebarNone
98+ }
99+}
100+
101+func (a *App) toggleFavoritesSidebarAction(ctx context.Context) error {
102+ bw := a.lastFocusedBrowserWindow()
103+ if bw == nil {
104+ return fmt.Errorf("favorites sidebar unavailable: no focused browser window")
105+ }
106+ if bw.favoritesSidebar == nil {
107+ return fmt.Errorf("favorites sidebar unavailable: native sidebar not initialized")
108+ }
109+ if bw.sidebarVisible && bw.activeSidebarKind == nativeSidebarFavorites {
110+ a.hideAndRestoreFocusForBrowserWindow(bw)
111+ return nil
112+ }
113+ bw.toggleFavoritesSidebar()
114+ return nil
115+}
116+
117+func (a *App) toggleCurrentPageFavoriteAction(ctx context.Context) error {
118+ if a == nil || a.deps == nil || a.deps.FavoritesUC == nil {
119+ return fmt.Errorf("favorites unavailable: usecase not configured")
120+ }
121+ _, wv := a.activeWebViewForBrowserWindow(a.lastFocusedBrowserWindow())
122+ if wv == nil {
123+ return fmt.Errorf("favorites unavailable: no active webview")
124+ }
125+ uri := strings.TrimSpace(wv.URI())
126+ if uri == "" {
127+ return fmt.Errorf("favorites unavailable: active page has no URI")
128+ }
129+ _, err := a.deps.FavoritesUC.Toggle(ctx, uri, wv.Title())
130+ if err != nil {
131+ return err
132+ }
133+ return nil
134+}
A internal/ui/browser_window_favorites_sidebar_test.go +208 -0
@@ -0,0 +1,208 @@
1+package ui
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/ui/component"
11+ contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
12+ "github.com/bnema/dumber/internal/ui/window"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestBrowserWindow_FavoritesSidebarToggle_CoexistsWithHistorySidebar(t *testing.T) {
18+ bw := &browserWindow{
19+ id: "test-window",
20+ mainWindow: &window.MainWindow{},
21+ historySidebar: &component.HistorySidebar{},
22+ favoritesSidebar: &component.FavoritesSidebar{},
23+ }
24+
25+ bw.showHistorySidebar()
26+ assert.True(t, bw.sidebarVisible)
27+ assert.Equal(t, nativeSidebarHistory, bw.activeSidebarKind)
28+
29+ bw.toggleFavoritesSidebar()
30+ assert.True(t, bw.sidebarVisible)
31+ assert.Equal(t, nativeSidebarFavorites, bw.activeSidebarKind)
32+
33+ bw.toggleHistorySidebar()
34+ assert.True(t, bw.sidebarVisible)
35+ assert.Equal(t, nativeSidebarHistory, bw.activeSidebarKind)
36+
37+ bw.toggleHistorySidebar()
38+ assert.False(t, bw.sidebarVisible)
39+ assert.Equal(t, nativeSidebarNone, bw.activeSidebarKind)
40+}
41+
42+func TestApp_ToggleFavoritesSidebarActionHidesActiveFavoritesSidebar(t *testing.T) {
43+ bw := &browserWindow{
44+ id: "w",
45+ mainWindow: &window.MainWindow{},
46+ favoritesSidebar: &component.FavoritesSidebar{},
47+ sidebarVisible: true,
48+ activeSidebarKind: nativeSidebarFavorites,
49+ }
50+ app := &App{browserWindows: map[string]*browserWindow{bw.id: bw}, lastFocusedWindowID: bw.id}
51+
52+ require.NoError(t, app.toggleFavoritesSidebarAction(context.Background()))
53+
54+ assert.False(t, bw.sidebarVisible)
55+ assert.Equal(t, nativeSidebarNone, bw.activeSidebarKind)
56+}
57+
58+func TestFavoritesSidebarConfigOnCloseHidesFavoritesSidebar(t *testing.T) {
59+ bw := &browserWindow{
60+ id: "w",
61+ mainWindow: &window.MainWindow{},
62+ favoritesSidebar: &component.FavoritesSidebar{},
63+ sidebarVisible: true,
64+ activeSidebarKind: nativeSidebarFavorites,
65+ }
66+ app := &App{browserWindows: map[string]*browserWindow{bw.id: bw}, lastFocusedWindowID: bw.id}
67+
68+ cfg := app.buildFavoritesSidebarConfig(bw)
69+ require.NotNil(t, cfg.OnClose)
70+ cfg.OnClose()
71+
72+ assert.False(t, bw.sidebarVisible)
73+ assert.Equal(t, nativeSidebarNone, bw.activeSidebarKind)
74+}
75+
76+func TestApp_ToggleFavoritesSidebarActionErrorsWhenUnavailable(t *testing.T) {
77+ app := &App{browserWindows: map[string]*browserWindow{"w": {id: "w"}}, lastFocusedWindowID: "w"}
78+ err := app.toggleFavoritesSidebarAction(context.Background())
79+ require.Error(t, err)
80+ assert.ErrorContains(t, err, "favorites sidebar unavailable")
81+}
82+
83+func TestApp_ToggleCurrentPageFavoriteTogglesActiveWebViewURI(t *testing.T) {
84+ ctx := context.Background()
85+ tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
86+ tabs := entity.NewTabList()
87+ tabs.Add(tab)
88+ tabs.SetActive(tab.ID)
89+ bw := &browserWindow{id: "w", tabs: tabs}
90+
91+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
92+ wv := &recordingWebView{id: 1, loadURILastURI: "https://example.com/page"}
93+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), wv)
94+
95+ favRepo := newMemoryFavoriteRepo()
96+ app := &App{
97+ deps: &Dependencies{FavoritesUC: usecase.NewManageFavoritesUseCase(favRepo, &memoryTagRepo{})},
98+ browserWindows: map[string]*browserWindow{bw.id: bw},
99+ lastFocusedWindowID: bw.id,
100+ contentCoord: contentCoord,
101+ workspaceViews: map[entity.TabID]*component.WorkspaceView{tab.ID: {}},
102+ }
103+
104+ require.NoError(t, app.toggleCurrentPageFavoriteAction(ctx))
105+ assert.Len(t, favRepo.byURL, 1)
106+ _, ok := favRepo.byURL["https://example.com/page"]
107+ assert.True(t, ok)
108+
109+ require.NoError(t, app.toggleCurrentPageFavoriteAction(ctx))
110+ assert.Empty(t, favRepo.byURL)
111+}
112+
113+func TestApp_ToggleCurrentPageFavoriteErrorCases(t *testing.T) {
114+ ctx := context.Background()
115+ assert.ErrorContains(t, (&App{}).toggleCurrentPageFavoriteAction(ctx), "usecase not configured")
116+
117+ app := &App{deps: &Dependencies{FavoritesUC: usecase.NewManageFavoritesUseCase(newMemoryFavoriteRepo(), &memoryTagRepo{})}}
118+ assert.ErrorContains(t, app.toggleCurrentPageFavoriteAction(ctx), "no active webview")
119+
120+ tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
121+ tabs := entity.NewTabList()
122+ tabs.Add(tab)
123+ tabs.SetActive(tab.ID)
124+ bw := &browserWindow{id: "w", tabs: tabs}
125+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
126+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), &recordingWebView{id: 2})
127+ app = &App{
128+ deps: &Dependencies{FavoritesUC: usecase.NewManageFavoritesUseCase(newMemoryFavoriteRepo(), &memoryTagRepo{})},
129+ browserWindows: map[string]*browserWindow{bw.id: bw},
130+ lastFocusedWindowID: bw.id,
131+ contentCoord: contentCoord,
132+ workspaceViews: map[entity.TabID]*component.WorkspaceView{tab.ID: {}},
133+ }
134+ assert.ErrorContains(t, app.toggleCurrentPageFavoriteAction(ctx), "active page has no URI")
135+}
136+
137+type memoryFavoriteRepo struct {
138+ nextID entity.FavoriteID
139+ byID map[entity.FavoriteID]*entity.Favorite
140+ byURL map[string]*entity.Favorite
141+ err error
142+}
143+
144+func newMemoryFavoriteRepo() *memoryFavoriteRepo {
145+ return &memoryFavoriteRepo{nextID: 1, byID: map[entity.FavoriteID]*entity.Favorite{}, byURL: map[string]*entity.Favorite{}}
146+}
147+
148+func (r *memoryFavoriteRepo) Save(_ context.Context, fav *entity.Favorite) error {
149+ if r.err != nil {
150+ return r.err
151+ }
152+ if fav.ID == 0 {
153+ fav.ID = r.nextID
154+ r.nextID++
155+ }
156+ cp := *fav
157+ r.byID[fav.ID] = &cp
158+ r.byURL[fav.URL] = &cp
159+ return nil
160+}
161+func (r *memoryFavoriteRepo) FindByID(context.Context, entity.FavoriteID) (*entity.Favorite, error) {
162+ return nil, r.err
163+}
164+func (r *memoryFavoriteRepo) FindByURL(_ context.Context, url string) (*entity.Favorite, error) {
165+ return r.byURL[url], r.err
166+}
167+func (r *memoryFavoriteRepo) GetAll(context.Context) ([]*entity.Favorite, error) { return nil, r.err }
168+func (r *memoryFavoriteRepo) GetByTag(context.Context, entity.TagID) ([]*entity.Favorite, error) {
169+ return nil, r.err
170+}
171+func (r *memoryFavoriteRepo) GetByShortcut(context.Context, int) (*entity.Favorite, error) {
172+ return nil, r.err
173+}
174+func (r *memoryFavoriteRepo) UpdatePosition(context.Context, entity.FavoriteID, int) error {
175+ return r.err
176+}
177+func (r *memoryFavoriteRepo) SetShortcut(context.Context, entity.FavoriteID, *int) error {
178+ return r.err
179+}
180+func (r *memoryFavoriteRepo) Delete(_ context.Context, id entity.FavoriteID) error {
181+ if r.err != nil {
182+ return r.err
183+ }
184+ fav := r.byID[id]
185+ if fav == nil {
186+ return errors.New("favorite not found")
187+ }
188+ delete(r.byID, id)
189+ delete(r.byURL, fav.URL)
190+ return nil
191+}
192+
193+type memoryTagRepo struct{}
194+
195+func (*memoryTagRepo) Save(context.Context, *entity.Tag) error { return nil }
196+func (*memoryTagRepo) FindByID(context.Context, entity.TagID) (*entity.Tag, error) { return nil, nil }
197+func (*memoryTagRepo) FindByName(context.Context, string) (*entity.Tag, error) { return nil, nil }
198+func (*memoryTagRepo) GetAll(context.Context) ([]*entity.Tag, error) { return nil, nil }
199+func (*memoryTagRepo) AssignToFavorite(context.Context, entity.TagID, entity.FavoriteID) error {
200+ return nil
201+}
202+func (*memoryTagRepo) RemoveFromFavorite(context.Context, entity.TagID, entity.FavoriteID) error {
203+ return nil
204+}
205+func (*memoryTagRepo) GetForFavorite(context.Context, entity.FavoriteID) ([]*entity.Tag, error) {
206+ return nil, nil
207+}
208+func (*memoryTagRepo) Delete(context.Context, entity.TagID) error { return nil }
@@ -0,0 +1,208 @@
1+package ui
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/ui/component"
11+ contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
12+ "github.com/bnema/dumber/internal/ui/window"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestBrowserWindow_FavoritesSidebarToggle_CoexistsWithHistorySidebar(t *testing.T) {
18+ bw := &browserWindow{
19+ id: "test-window",
20+ mainWindow: &window.MainWindow{},
21+ historySidebar: &component.HistorySidebar{},
22+ favoritesSidebar: &component.FavoritesSidebar{},
23+ }
24+
25+ bw.showHistorySidebar()
26+ assert.True(t, bw.sidebarVisible)
27+ assert.Equal(t, nativeSidebarHistory, bw.activeSidebarKind)
28+
29+ bw.toggleFavoritesSidebar()
30+ assert.True(t, bw.sidebarVisible)
31+ assert.Equal(t, nativeSidebarFavorites, bw.activeSidebarKind)
32+
33+ bw.toggleHistorySidebar()
34+ assert.True(t, bw.sidebarVisible)
35+ assert.Equal(t, nativeSidebarHistory, bw.activeSidebarKind)
36+
37+ bw.toggleHistorySidebar()
38+ assert.False(t, bw.sidebarVisible)
39+ assert.Equal(t, nativeSidebarNone, bw.activeSidebarKind)
40+}
41+
42+func TestApp_ToggleFavoritesSidebarActionHidesActiveFavoritesSidebar(t *testing.T) {
43+ bw := &browserWindow{
44+ id: "w",
45+ mainWindow: &window.MainWindow{},
46+ favoritesSidebar: &component.FavoritesSidebar{},
47+ sidebarVisible: true,
48+ activeSidebarKind: nativeSidebarFavorites,
49+ }
50+ app := &App{browserWindows: map[string]*browserWindow{bw.id: bw}, lastFocusedWindowID: bw.id}
51+
52+ require.NoError(t, app.toggleFavoritesSidebarAction(context.Background()))
53+
54+ assert.False(t, bw.sidebarVisible)
55+ assert.Equal(t, nativeSidebarNone, bw.activeSidebarKind)
56+}
57+
58+func TestFavoritesSidebarConfigOnCloseHidesFavoritesSidebar(t *testing.T) {
59+ bw := &browserWindow{
60+ id: "w",
61+ mainWindow: &window.MainWindow{},
62+ favoritesSidebar: &component.FavoritesSidebar{},
63+ sidebarVisible: true,
64+ activeSidebarKind: nativeSidebarFavorites,
65+ }
66+ app := &App{browserWindows: map[string]*browserWindow{bw.id: bw}, lastFocusedWindowID: bw.id}
67+
68+ cfg := app.buildFavoritesSidebarConfig(bw)
69+ require.NotNil(t, cfg.OnClose)
70+ cfg.OnClose()
71+
72+ assert.False(t, bw.sidebarVisible)
73+ assert.Equal(t, nativeSidebarNone, bw.activeSidebarKind)
74+}
75+
76+func TestApp_ToggleFavoritesSidebarActionErrorsWhenUnavailable(t *testing.T) {
77+ app := &App{browserWindows: map[string]*browserWindow{"w": {id: "w"}}, lastFocusedWindowID: "w"}
78+ err := app.toggleFavoritesSidebarAction(context.Background())
79+ require.Error(t, err)
80+ assert.ErrorContains(t, err, "favorites sidebar unavailable")
81+}
82+
83+func TestApp_ToggleCurrentPageFavoriteTogglesActiveWebViewURI(t *testing.T) {
84+ ctx := context.Background()
85+ tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
86+ tabs := entity.NewTabList()
87+ tabs.Add(tab)
88+ tabs.SetActive(tab.ID)
89+ bw := &browserWindow{id: "w", tabs: tabs}
90+
91+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
92+ wv := &recordingWebView{id: 1, loadURILastURI: "https://example.com/page"}
93+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), wv)
94+
95+ favRepo := newMemoryFavoriteRepo()
96+ app := &App{
97+ deps: &Dependencies{FavoritesUC: usecase.NewManageFavoritesUseCase(favRepo, &memoryTagRepo{})},
98+ browserWindows: map[string]*browserWindow{bw.id: bw},
99+ lastFocusedWindowID: bw.id,
100+ contentCoord: contentCoord,
101+ workspaceViews: map[entity.TabID]*component.WorkspaceView{tab.ID: {}},
102+ }
103+
104+ require.NoError(t, app.toggleCurrentPageFavoriteAction(ctx))
105+ assert.Len(t, favRepo.byURL, 1)
106+ _, ok := favRepo.byURL["https://example.com/page"]
107+ assert.True(t, ok)
108+
109+ require.NoError(t, app.toggleCurrentPageFavoriteAction(ctx))
110+ assert.Empty(t, favRepo.byURL)
111+}
112+
113+func TestApp_ToggleCurrentPageFavoriteErrorCases(t *testing.T) {
114+ ctx := context.Background()
115+ assert.ErrorContains(t, (&App{}).toggleCurrentPageFavoriteAction(ctx), "usecase not configured")
116+
117+ app := &App{deps: &Dependencies{FavoritesUC: usecase.NewManageFavoritesUseCase(newMemoryFavoriteRepo(), &memoryTagRepo{})}}
118+ assert.ErrorContains(t, app.toggleCurrentPageFavoriteAction(ctx), "no active webview")
119+
120+ tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
121+ tabs := entity.NewTabList()
122+ tabs.Add(tab)
123+ tabs.SetActive(tab.ID)
124+ bw := &browserWindow{id: "w", tabs: tabs}
125+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
126+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), &recordingWebView{id: 2})
127+ app = &App{
128+ deps: &Dependencies{FavoritesUC: usecase.NewManageFavoritesUseCase(newMemoryFavoriteRepo(), &memoryTagRepo{})},
129+ browserWindows: map[string]*browserWindow{bw.id: bw},
130+ lastFocusedWindowID: bw.id,
131+ contentCoord: contentCoord,
132+ workspaceViews: map[entity.TabID]*component.WorkspaceView{tab.ID: {}},
133+ }
134+ assert.ErrorContains(t, app.toggleCurrentPageFavoriteAction(ctx), "active page has no URI")
135+}
136+
137+type memoryFavoriteRepo struct {
138+ nextID entity.FavoriteID
139+ byID map[entity.FavoriteID]*entity.Favorite
140+ byURL map[string]*entity.Favorite
141+ err error
142+}
143+
144+func newMemoryFavoriteRepo() *memoryFavoriteRepo {
145+ return &memoryFavoriteRepo{nextID: 1, byID: map[entity.FavoriteID]*entity.Favorite{}, byURL: map[string]*entity.Favorite{}}
146+}
147+
148+func (r *memoryFavoriteRepo) Save(_ context.Context, fav *entity.Favorite) error {
149+ if r.err != nil {
150+ return r.err
151+ }
152+ if fav.ID == 0 {
153+ fav.ID = r.nextID
154+ r.nextID++
155+ }
156+ cp := *fav
157+ r.byID[fav.ID] = &cp
158+ r.byURL[fav.URL] = &cp
159+ return nil
160+}
161+func (r *memoryFavoriteRepo) FindByID(context.Context, entity.FavoriteID) (*entity.Favorite, error) {
162+ return nil, r.err
163+}
164+func (r *memoryFavoriteRepo) FindByURL(_ context.Context, url string) (*entity.Favorite, error) {
165+ return r.byURL[url], r.err
166+}
167+func (r *memoryFavoriteRepo) GetAll(context.Context) ([]*entity.Favorite, error) { return nil, r.err }
168+func (r *memoryFavoriteRepo) GetByTag(context.Context, entity.TagID) ([]*entity.Favorite, error) {
169+ return nil, r.err
170+}
171+func (r *memoryFavoriteRepo) GetByShortcut(context.Context, int) (*entity.Favorite, error) {
172+ return nil, r.err
173+}
174+func (r *memoryFavoriteRepo) UpdatePosition(context.Context, entity.FavoriteID, int) error {
175+ return r.err
176+}
177+func (r *memoryFavoriteRepo) SetShortcut(context.Context, entity.FavoriteID, *int) error {
178+ return r.err
179+}
180+func (r *memoryFavoriteRepo) Delete(_ context.Context, id entity.FavoriteID) error {
181+ if r.err != nil {
182+ return r.err
183+ }
184+ fav := r.byID[id]
185+ if fav == nil {
186+ return errors.New("favorite not found")
187+ }
188+ delete(r.byID, id)
189+ delete(r.byURL, fav.URL)
190+ return nil
191+}
192+
193+type memoryTagRepo struct{}
194+
195+func (*memoryTagRepo) Save(context.Context, *entity.Tag) error { return nil }
196+func (*memoryTagRepo) FindByID(context.Context, entity.TagID) (*entity.Tag, error) { return nil, nil }
197+func (*memoryTagRepo) FindByName(context.Context, string) (*entity.Tag, error) { return nil, nil }
198+func (*memoryTagRepo) GetAll(context.Context) ([]*entity.Tag, error) { return nil, nil }
199+func (*memoryTagRepo) AssignToFavorite(context.Context, entity.TagID, entity.FavoriteID) error {
200+ return nil
201+}
202+func (*memoryTagRepo) RemoveFromFavorite(context.Context, entity.TagID, entity.FavoriteID) error {
203+ return nil
204+}
205+func (*memoryTagRepo) GetForFavorite(context.Context, entity.FavoriteID) ([]*entity.Tag, error) {
206+ return nil, nil
207+}
208+func (*memoryTagRepo) Delete(context.Context, entity.TagID) error { return nil }
M internal/ui/browser_window_history_sidebar.go +30 -2
@@ -34,6 +34,7 @@ func (bw *browserWindow) initHistorySidebar(ctx context.Context, a *App) {
3434 bw.historySidebar.Hide()
3535 bw.mainWindow.SetSidebarVisible(false)
3636 bw.sidebarVisible = false
37+ bw.activeSidebarKind = nativeSidebarNone
3738
3839 // Apply sidebar width from config, falling back to the default 320px.
3940 // The width is clamped to [280, 380] by SetSidebarWidth internally.
@@ -84,7 +85,7 @@ func (bw *browserWindow) toggleHistorySidebar() {
8485 return
8586 }
8687
87- if bw.sidebarVisible {
88+ if bw.sidebarVisible && bw.activeSidebarKind == nativeSidebarHistory {
8889 bw.hideHistorySidebar()
8990 } else {
9091 bw.showHistorySidebar()
@@ -97,9 +98,11 @@ func (bw *browserWindow) showHistorySidebar() {
9798 if bw == nil || bw.historySidebar == nil || bw.mainWindow == nil {
9899 return
99100 }
101+ bw.mountNativeSidebar(nativeSidebarHistory)
100102 bw.historySidebar.Show()
101103 bw.mainWindow.SetSidebarVisible(true)
102104 bw.sidebarVisible = true
105+ bw.activeSidebarKind = nativeSidebarHistory
103106 }
104107
105108 // hideHistorySidebar hides the sidebar. Callers should also restore focus
@@ -111,6 +114,31 @@ func (bw *browserWindow) hideHistorySidebar() {
111114 bw.historySidebar.Hide()
112115 bw.mainWindow.SetSidebarVisible(false)
113116 bw.sidebarVisible = false
117+ if bw.activeSidebarKind == nativeSidebarHistory {
118+ bw.activeSidebarKind = nativeSidebarNone
119+ }
120+}
121+
122+func (bw *browserWindow) mountNativeSidebar(kind nativeSidebarKind) {
123+ if bw == nil || bw.mainWindow == nil || bw.activeSidebarKind == kind {
124+ return
125+ }
126+ if bw.historySidebar != nil {
127+ bw.historySidebar.Hide()
128+ }
129+ if bw.favoritesSidebar != nil {
130+ bw.favoritesSidebar.Hide()
131+ }
132+ switch kind {
133+ case nativeSidebarHistory:
134+ if bw.historySidebar != nil {
135+ bw.mainWindow.SetSidebarWidget(bw.historySidebar.Widget())
136+ }
137+ case nativeSidebarFavorites:
138+ if bw.favoritesSidebar != nil {
139+ bw.mainWindow.SetSidebarWidget(bw.favoritesSidebar.Widget())
140+ }
141+ }
114142 }
115143
116144 // historySidebarWidthConfig extracts the config-backed sidebar width and
@@ -143,7 +171,7 @@ func (a *App) toggleHistorySidebarAction(ctx context.Context) error {
143171 if bw.historySidebar == nil {
144172 return fmt.Errorf("history sidebar unavailable: native sidebar not initialized")
145173 }
146- if bw.sidebarVisible {
174+ if bw.sidebarVisible && bw.activeSidebarKind == nativeSidebarHistory {
147175 a.hideAndRestoreFocusForBrowserWindow(bw)
148176 return nil
149177 }
@@ -34,6 +34,7 @@ func (bw *browserWindow) initHistorySidebar(ctx context.Context, a *App) {
34 bw.historySidebar.Hide()34 bw.historySidebar.Hide()
35 bw.mainWindow.SetSidebarVisible(false)35 bw.mainWindow.SetSidebarVisible(false)
36 bw.sidebarVisible = false36 bw.sidebarVisible = false
37+ bw.activeSidebarKind = nativeSidebarNone
37 38
38 // Apply sidebar width from config, falling back to the default 320px.39 // Apply sidebar width from config, falling back to the default 320px.
39 // The width is clamped to [280, 380] by SetSidebarWidth internally.40 // The width is clamped to [280, 380] by SetSidebarWidth internally.
@@ -84,7 +85,7 @@ func (bw *browserWindow) toggleHistorySidebar() {
84 return85 return
85 }86 }
86 87
87- if bw.sidebarVisible {88+ if bw.sidebarVisible && bw.activeSidebarKind == nativeSidebarHistory {
88 bw.hideHistorySidebar()89 bw.hideHistorySidebar()
89 } else {90 } else {
90 bw.showHistorySidebar()91 bw.showHistorySidebar()
@@ -97,9 +98,11 @@ func (bw *browserWindow) showHistorySidebar() {
97 if bw == nil || bw.historySidebar == nil || bw.mainWindow == nil {98 if bw == nil || bw.historySidebar == nil || bw.mainWindow == nil {
98 return99 return
99 }100 }
101+ bw.mountNativeSidebar(nativeSidebarHistory)
100 bw.historySidebar.Show()102 bw.historySidebar.Show()
101 bw.mainWindow.SetSidebarVisible(true)103 bw.mainWindow.SetSidebarVisible(true)
102 bw.sidebarVisible = true104 bw.sidebarVisible = true
105+ bw.activeSidebarKind = nativeSidebarHistory
103 }106 }
104 107
105 // hideHistorySidebar hides the sidebar. Callers should also restore focus108 // hideHistorySidebar hides the sidebar. Callers should also restore focus
@@ -111,6 +114,31 @@ func (bw *browserWindow) hideHistorySidebar() {
111 bw.historySidebar.Hide()114 bw.historySidebar.Hide()
112 bw.mainWindow.SetSidebarVisible(false)115 bw.mainWindow.SetSidebarVisible(false)
113 bw.sidebarVisible = false116 bw.sidebarVisible = false
117+ if bw.activeSidebarKind == nativeSidebarHistory {
118+ bw.activeSidebarKind = nativeSidebarNone
119+ }
120+}
121+
122+func (bw *browserWindow) mountNativeSidebar(kind nativeSidebarKind) {
123+ if bw == nil || bw.mainWindow == nil || bw.activeSidebarKind == kind {
124+ return
125+ }
126+ if bw.historySidebar != nil {
127+ bw.historySidebar.Hide()
128+ }
129+ if bw.favoritesSidebar != nil {
130+ bw.favoritesSidebar.Hide()
131+ }
132+ switch kind {
133+ case nativeSidebarHistory:
134+ if bw.historySidebar != nil {
135+ bw.mainWindow.SetSidebarWidget(bw.historySidebar.Widget())
136+ }
137+ case nativeSidebarFavorites:
138+ if bw.favoritesSidebar != nil {
139+ bw.mainWindow.SetSidebarWidget(bw.favoritesSidebar.Widget())
140+ }
141+ }
114 }142 }
115 143
116 // historySidebarWidthConfig extracts the config-backed sidebar width and144 // historySidebarWidthConfig extracts the config-backed sidebar width and
@@ -143,7 +171,7 @@ func (a *App) toggleHistorySidebarAction(ctx context.Context) error {
143 if bw.historySidebar == nil {171 if bw.historySidebar == nil {
144 return fmt.Errorf("history sidebar unavailable: native sidebar not initialized")172 return fmt.Errorf("history sidebar unavailable: native sidebar not initialized")
145 }173 }
146- if bw.sidebarVisible {174+ if bw.sidebarVisible && bw.activeSidebarKind == nativeSidebarHistory {
147 a.hideAndRestoreFocusForBrowserWindow(bw)175 a.hideAndRestoreFocusForBrowserWindow(bw)
148 return nil176 return nil
149 }177 }
M internal/ui/dispatcher/keyboard.go +39 -20
@@ -36,25 +36,27 @@ type KeyboardActions struct {
3636
3737 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.
3838 type KeyboardDispatcher struct {
39- actions KeyboardActions
40- wsCoord *coordinator.WorkspaceCoordinator
41- navCoord *coordinator.NavigationCoordinator
42- zoomUC *usecase.ManageZoomUseCase
43- copyURLUC *usecase.CopyURLUseCase
44- actionHandlers map[input.Action]func(ctx context.Context) error
45- onQuit func()
46- onFindOpen func(ctx context.Context) error
47- onFindNext func(ctx context.Context) error
48- onFindPrev func(ctx context.Context) error
49- onFindClose func(ctx context.Context) error
50- activePaneID func(ctx context.Context) entity.PaneID
51- onSessionOpen func(ctx context.Context, paneID entity.PaneID) error
52- onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error
53- onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error
54- onEjectPaneToWindow func(ctx context.Context, paneID entity.PaneID) error
55- onToggleHistorySidebar func(ctx context.Context) error
56- onToggleFloating func(ctx context.Context) error
57- onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error
39+ actions KeyboardActions
40+ wsCoord *coordinator.WorkspaceCoordinator
41+ navCoord *coordinator.NavigationCoordinator
42+ zoomUC *usecase.ManageZoomUseCase
43+ copyURLUC *usecase.CopyURLUseCase
44+ actionHandlers map[input.Action]func(ctx context.Context) error
45+ onQuit func()
46+ onFindOpen func(ctx context.Context) error
47+ onFindNext func(ctx context.Context) error
48+ onFindPrev func(ctx context.Context) error
49+ onFindClose func(ctx context.Context) error
50+ activePaneID func(ctx context.Context) entity.PaneID
51+ onSessionOpen func(ctx context.Context, paneID entity.PaneID) error
52+ onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error
53+ onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error
54+ onEjectPaneToWindow func(ctx context.Context, paneID entity.PaneID) error
55+ onToggleHistorySidebar func(ctx context.Context) error
56+ onToggleFavoritesSidebar func(ctx context.Context) error
57+ onToggleCurrentFavorite func(ctx context.Context) error
58+ onToggleFloating func(ctx context.Context) error
59+ onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error
5860 }
5961
6062 // NewKeyboardDispatcher creates a new KeyboardDispatcher.
@@ -128,6 +130,14 @@ func (d *KeyboardDispatcher) SetOnToggleHistorySidebar(fn func(ctx context.Conte
128130 d.onToggleHistorySidebar = fn
129131 }
130132
133+func (d *KeyboardDispatcher) SetOnToggleFavoritesSidebar(fn func(ctx context.Context) error) {
134+ d.onToggleFavoritesSidebar = fn
135+}
136+
137+func (d *KeyboardDispatcher) SetOnToggleCurrentPageFavorite(fn func(ctx context.Context) error) {
138+ d.onToggleCurrentFavorite = fn
139+}
140+
131141 func (d *KeyboardDispatcher) SetOnToggleFloatingPane(fn func(ctx context.Context) error) {
132142 d.onToggleFloating = fn
133143 }
@@ -262,7 +272,16 @@ func (d *KeyboardDispatcher) initActionHandlers() {
262272 return d.onToggleHistorySidebar(ctx)
263273 },
264274 input.ActionToggleFavoritesSystemView: func(ctx context.Context) error {
265- return d.wsCoord.ToggleSystemViewRight(ctx, favoritesSystemViewURL)
275+ if d.onToggleFavoritesSidebar == nil {
276+ return fmt.Errorf("favorites sidebar unavailable: toggle handler not wired")
277+ }
278+ return d.onToggleFavoritesSidebar(ctx)
279+ },
280+ input.ActionToggleCurrentPageFavorite: func(ctx context.Context) error {
281+ if d.onToggleCurrentFavorite == nil {
282+ return fmt.Errorf("favorites unavailable: toggle current page handler not wired")
283+ }
284+ return d.onToggleCurrentFavorite(ctx)
266285 },
267286 input.ActionToggleConfigSystemView: func(ctx context.Context) error {
268287 return d.wsCoord.ToggleSystemViewRight(ctx, configSystemViewURL)
@@ -36,25 +36,27 @@ type KeyboardActions struct {
36 36
37 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.37 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.
38 type KeyboardDispatcher struct {38 type KeyboardDispatcher struct {
39- actions KeyboardActions39+ actions KeyboardActions
40- wsCoord *coordinator.WorkspaceCoordinator40+ wsCoord *coordinator.WorkspaceCoordinator
41- navCoord *coordinator.NavigationCoordinator41+ navCoord *coordinator.NavigationCoordinator
42- zoomUC *usecase.ManageZoomUseCase42+ zoomUC *usecase.ManageZoomUseCase
43- copyURLUC *usecase.CopyURLUseCase43+ copyURLUC *usecase.CopyURLUseCase
44- actionHandlers map[input.Action]func(ctx context.Context) error44+ actionHandlers map[input.Action]func(ctx context.Context) error
45- onQuit func()45+ onQuit func()
46- onFindOpen func(ctx context.Context) error46+ onFindOpen func(ctx context.Context) error
47- onFindNext func(ctx context.Context) error47+ onFindNext func(ctx context.Context) error
48- onFindPrev func(ctx context.Context) error48+ onFindPrev func(ctx context.Context) error
49- onFindClose func(ctx context.Context) error49+ onFindClose func(ctx context.Context) error
50- activePaneID func(ctx context.Context) entity.PaneID50+ activePaneID func(ctx context.Context) entity.PaneID
51- onSessionOpen func(ctx context.Context, paneID entity.PaneID) error51+ onSessionOpen func(ctx context.Context, paneID entity.PaneID) error
52- onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error52+ onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error
53- onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error53+ onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error
54- onEjectPaneToWindow func(ctx context.Context, paneID entity.PaneID) error54+ onEjectPaneToWindow func(ctx context.Context, paneID entity.PaneID) error
55- onToggleHistorySidebar func(ctx context.Context) error55+ onToggleHistorySidebar func(ctx context.Context) error
56- onToggleFloating func(ctx context.Context) error56+ onToggleFavoritesSidebar func(ctx context.Context) error
57- onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error57+ onToggleCurrentFavorite func(ctx context.Context) error
58+ onToggleFloating func(ctx context.Context) error
59+ onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error
58 }60 }
59 61
60 // NewKeyboardDispatcher creates a new KeyboardDispatcher.62 // NewKeyboardDispatcher creates a new KeyboardDispatcher.
@@ -128,6 +130,14 @@ func (d *KeyboardDispatcher) SetOnToggleHistorySidebar(fn func(ctx context.Conte
128 d.onToggleHistorySidebar = fn130 d.onToggleHistorySidebar = fn
129 }131 }
130 132
133+func (d *KeyboardDispatcher) SetOnToggleFavoritesSidebar(fn func(ctx context.Context) error) {
134+ d.onToggleFavoritesSidebar = fn
135+}
136+
137+func (d *KeyboardDispatcher) SetOnToggleCurrentPageFavorite(fn func(ctx context.Context) error) {
138+ d.onToggleCurrentFavorite = fn
139+}
140+
131 func (d *KeyboardDispatcher) SetOnToggleFloatingPane(fn func(ctx context.Context) error) {141 func (d *KeyboardDispatcher) SetOnToggleFloatingPane(fn func(ctx context.Context) error) {
132 d.onToggleFloating = fn142 d.onToggleFloating = fn
133 }143 }
@@ -262,7 +272,16 @@ func (d *KeyboardDispatcher) initActionHandlers() {
262 return d.onToggleHistorySidebar(ctx)272 return d.onToggleHistorySidebar(ctx)
263 },273 },
264 input.ActionToggleFavoritesSystemView: func(ctx context.Context) error {274 input.ActionToggleFavoritesSystemView: func(ctx context.Context) error {
265- return d.wsCoord.ToggleSystemViewRight(ctx, favoritesSystemViewURL)275+ if d.onToggleFavoritesSidebar == nil {
276+ return fmt.Errorf("favorites sidebar unavailable: toggle handler not wired")
277+ }
278+ return d.onToggleFavoritesSidebar(ctx)
279+ },
280+ input.ActionToggleCurrentPageFavorite: func(ctx context.Context) error {
281+ if d.onToggleCurrentFavorite == nil {
282+ return fmt.Errorf("favorites unavailable: toggle current page handler not wired")
283+ }
284+ return d.onToggleCurrentFavorite(ctx)
266 },285 },
267 input.ActionToggleConfigSystemView: func(ctx context.Context) error {286 input.ActionToggleConfigSystemView: func(ctx context.Context) error {
268 return d.wsCoord.ToggleSystemViewRight(ctx, configSystemViewURL)287 return d.wsCoord.ToggleSystemViewRight(ctx, configSystemViewURL)
M internal/ui/dispatcher/keyboard_test.go +40 -0
@@ -115,6 +115,46 @@ func TestKeyboardDispatcher_ToggleHistorySidebarSetThenUnsetReturnsError(t *test
115115 assert.ErrorContains(t, err, "history sidebar unavailable")
116116 }
117117
118+func TestKeyboardDispatcher_ToggleFavoritesSidebarCallsCallbackAndPropagatesErrors(t *testing.T) {
119+ ctx := context.Background()
120+ d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
121+
122+ missingErr := d.Dispatch(ctx, input.ActionToggleFavoritesSystemView)
123+ require.Error(t, missingErr)
124+ assert.ErrorContains(t, missingErr, "favorites sidebar unavailable")
125+
126+ wantErr := fmt.Errorf("favorites failed")
127+ d.SetOnToggleFavoritesSidebar(func(context.Context) error { return wantErr })
128+ err := d.Dispatch(ctx, input.ActionToggleFavoritesSystemView)
129+ require.Error(t, err)
130+ assert.ErrorIs(t, err, wantErr)
131+
132+ var called bool
133+ d.SetOnToggleFavoritesSidebar(func(context.Context) error { called = true; return nil })
134+ require.NoError(t, d.Dispatch(ctx, input.ActionToggleFavoritesSystemView))
135+ assert.True(t, called)
136+}
137+
138+func TestKeyboardDispatcher_ToggleCurrentPageFavoriteCallsCallbackAndPropagatesErrors(t *testing.T) {
139+ ctx := context.Background()
140+ d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
141+
142+ missingErr := d.Dispatch(ctx, input.ActionToggleCurrentPageFavorite)
143+ require.Error(t, missingErr)
144+ assert.ErrorContains(t, missingErr, "toggle current page handler not wired")
145+
146+ wantErr := fmt.Errorf("toggle failed")
147+ d.SetOnToggleCurrentPageFavorite(func(context.Context) error { return wantErr })
148+ err := d.Dispatch(ctx, input.ActionToggleCurrentPageFavorite)
149+ require.Error(t, err)
150+ assert.ErrorIs(t, err, wantErr)
151+
152+ var called bool
153+ d.SetOnToggleCurrentPageFavorite(func(context.Context) error { called = true; return nil })
154+ require.NoError(t, d.Dispatch(ctx, input.ActionToggleCurrentPageFavorite))
155+ assert.True(t, called)
156+}
157+
118158 func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) {
119159 ctx := context.Background()
120160 activePaneID := entity.PaneID("pane-1")
@@ -115,6 +115,46 @@ func TestKeyboardDispatcher_ToggleHistorySidebarSetThenUnsetReturnsError(t *test
115 assert.ErrorContains(t, err, "history sidebar unavailable")115 assert.ErrorContains(t, err, "history sidebar unavailable")
116 }116 }
117 117
118+func TestKeyboardDispatcher_ToggleFavoritesSidebarCallsCallbackAndPropagatesErrors(t *testing.T) {
119+ ctx := context.Background()
120+ d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
121+
122+ missingErr := d.Dispatch(ctx, input.ActionToggleFavoritesSystemView)
123+ require.Error(t, missingErr)
124+ assert.ErrorContains(t, missingErr, "favorites sidebar unavailable")
125+
126+ wantErr := fmt.Errorf("favorites failed")
127+ d.SetOnToggleFavoritesSidebar(func(context.Context) error { return wantErr })
128+ err := d.Dispatch(ctx, input.ActionToggleFavoritesSystemView)
129+ require.Error(t, err)
130+ assert.ErrorIs(t, err, wantErr)
131+
132+ var called bool
133+ d.SetOnToggleFavoritesSidebar(func(context.Context) error { called = true; return nil })
134+ require.NoError(t, d.Dispatch(ctx, input.ActionToggleFavoritesSystemView))
135+ assert.True(t, called)
136+}
137+
138+func TestKeyboardDispatcher_ToggleCurrentPageFavoriteCallsCallbackAndPropagatesErrors(t *testing.T) {
139+ ctx := context.Background()
140+ d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
141+
142+ missingErr := d.Dispatch(ctx, input.ActionToggleCurrentPageFavorite)
143+ require.Error(t, missingErr)
144+ assert.ErrorContains(t, missingErr, "toggle current page handler not wired")
145+
146+ wantErr := fmt.Errorf("toggle failed")
147+ d.SetOnToggleCurrentPageFavorite(func(context.Context) error { return wantErr })
148+ err := d.Dispatch(ctx, input.ActionToggleCurrentPageFavorite)
149+ require.Error(t, err)
150+ assert.ErrorIs(t, err, wantErr)
151+
152+ var called bool
153+ d.SetOnToggleCurrentPageFavorite(func(context.Context) error { called = true; return nil })
154+ require.NoError(t, d.Dispatch(ctx, input.ActionToggleCurrentPageFavorite))
155+ assert.True(t, called)
156+}
157+
118 func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) {158 func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) {
119 ctx := context.Background()159 ctx := context.Background()
120 activePaneID := entity.PaneID("pane-1")160 activePaneID := entity.PaneID("pane-1")
M internal/ui/input/global_shortcuts.go +19 -16
@@ -484,22 +484,24 @@ func appendGlobalShortcutEventFields(evt *zerolog.Event, binding KeyBinding, act
484484
485485 func globalShortcutActionMap() map[string]Action {
486486 return map[string]Action{
487- "toggle_floating_pane": ActionToggleFloatingPane,
488- "toggle-floating-pane": ActionToggleFloatingPane,
489- "consume_or_expel_left": ActionConsumeOrExpelLeft,
490- "consume-or-expel-left": ActionConsumeOrExpelLeft,
491- "consume_or_expel_right": ActionConsumeOrExpelRight,
492- "consume-or-expel-right": ActionConsumeOrExpelRight,
493- "consume_or_expel_up": ActionConsumeOrExpelUp,
494- "consume-or-expel-up": ActionConsumeOrExpelUp,
495- "consume_or_expel_down": ActionConsumeOrExpelDown,
496- "consume-or-expel-down": ActionConsumeOrExpelDown,
497- "toggle_history_systemview": ActionToggleHistorySystemView,
498- "toggle-history-systemview": ActionToggleHistorySystemView,
499- "toggle_favorites_systemview": ActionToggleFavoritesSystemView,
500- "toggle-favorites-systemview": ActionToggleFavoritesSystemView,
501- "toggle_config_systemview": ActionToggleConfigSystemView,
502- "toggle-config-systemview": ActionToggleConfigSystemView,
487+ "toggle_floating_pane": ActionToggleFloatingPane,
488+ "toggle-floating-pane": ActionToggleFloatingPane,
489+ "consume_or_expel_left": ActionConsumeOrExpelLeft,
490+ "consume-or-expel-left": ActionConsumeOrExpelLeft,
491+ "consume_or_expel_right": ActionConsumeOrExpelRight,
492+ "consume-or-expel-right": ActionConsumeOrExpelRight,
493+ "consume_or_expel_up": ActionConsumeOrExpelUp,
494+ "consume-or-expel-up": ActionConsumeOrExpelUp,
495+ "consume_or_expel_down": ActionConsumeOrExpelDown,
496+ "consume-or-expel-down": ActionConsumeOrExpelDown,
497+ "toggle_history_systemview": ActionToggleHistorySystemView,
498+ "toggle-history-systemview": ActionToggleHistorySystemView,
499+ "toggle_favorites_systemview": ActionToggleFavoritesSystemView,
500+ "toggle-favorites-systemview": ActionToggleFavoritesSystemView,
501+ "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
502+ "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
503+ "toggle_config_systemview": ActionToggleConfigSystemView,
504+ "toggle-config-systemview": ActionToggleConfigSystemView,
503505 }
504506 }
505507
@@ -711,6 +713,7 @@ func isRepeatedGlobalShortcutSuppressed(action Action) bool {
711713 ActionToggleFloatingPane,
712714 ActionToggleHistorySystemView,
713715 ActionToggleFavoritesSystemView,
716+ ActionToggleCurrentPageFavorite,
714717 ActionToggleConfigSystemView,
715718 ActionCopyURL,
716719 ActionConsumeOrExpelLeft,
@@ -484,22 +484,24 @@ func appendGlobalShortcutEventFields(evt *zerolog.Event, binding KeyBinding, act
484 484
485 func globalShortcutActionMap() map[string]Action {485 func globalShortcutActionMap() map[string]Action {
486 return map[string]Action{486 return map[string]Action{
487- "toggle_floating_pane": ActionToggleFloatingPane,487+ "toggle_floating_pane": ActionToggleFloatingPane,
488- "toggle-floating-pane": ActionToggleFloatingPane,488+ "toggle-floating-pane": ActionToggleFloatingPane,
489- "consume_or_expel_left": ActionConsumeOrExpelLeft,489+ "consume_or_expel_left": ActionConsumeOrExpelLeft,
490- "consume-or-expel-left": ActionConsumeOrExpelLeft,490+ "consume-or-expel-left": ActionConsumeOrExpelLeft,
491- "consume_or_expel_right": ActionConsumeOrExpelRight,491+ "consume_or_expel_right": ActionConsumeOrExpelRight,
492- "consume-or-expel-right": ActionConsumeOrExpelRight,492+ "consume-or-expel-right": ActionConsumeOrExpelRight,
493- "consume_or_expel_up": ActionConsumeOrExpelUp,493+ "consume_or_expel_up": ActionConsumeOrExpelUp,
494- "consume-or-expel-up": ActionConsumeOrExpelUp,494+ "consume-or-expel-up": ActionConsumeOrExpelUp,
495- "consume_or_expel_down": ActionConsumeOrExpelDown,495+ "consume_or_expel_down": ActionConsumeOrExpelDown,
496- "consume-or-expel-down": ActionConsumeOrExpelDown,496+ "consume-or-expel-down": ActionConsumeOrExpelDown,
497- "toggle_history_systemview": ActionToggleHistorySystemView,497+ "toggle_history_systemview": ActionToggleHistorySystemView,
498- "toggle-history-systemview": ActionToggleHistorySystemView,498+ "toggle-history-systemview": ActionToggleHistorySystemView,
499- "toggle_favorites_systemview": ActionToggleFavoritesSystemView,499+ "toggle_favorites_systemview": ActionToggleFavoritesSystemView,
500- "toggle-favorites-systemview": ActionToggleFavoritesSystemView,500+ "toggle-favorites-systemview": ActionToggleFavoritesSystemView,
501- "toggle_config_systemview": ActionToggleConfigSystemView,501+ "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
502- "toggle-config-systemview": ActionToggleConfigSystemView,502+ "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
503+ "toggle_config_systemview": ActionToggleConfigSystemView,
504+ "toggle-config-systemview": ActionToggleConfigSystemView,
503 }505 }
504 }506 }
505 507
@@ -711,6 +713,7 @@ func isRepeatedGlobalShortcutSuppressed(action Action) bool {
711 ActionToggleFloatingPane,713 ActionToggleFloatingPane,
712 ActionToggleHistorySystemView,714 ActionToggleHistorySystemView,
713 ActionToggleFavoritesSystemView,715 ActionToggleFavoritesSystemView,
716+ ActionToggleCurrentPageFavorite,
714 ActionToggleConfigSystemView,717 ActionToggleConfigSystemView,
715 ActionCopyURL,718 ActionCopyURL,
716 ActionConsumeOrExpelLeft,719 ActionConsumeOrExpelLeft,
M internal/ui/input/global_shortcuts_test.go +1 -0
@@ -231,6 +231,7 @@ func TestGlobalShortcutHandlerSuppressesRepeatedAdditionalOneShotUIActions(t *te
231231 ActionOpenDevTools,
232232 ActionToggleFloatingPane,
233233 ActionToggleFavoritesSystemView,
234+ ActionToggleCurrentPageFavorite,
234235 ActionToggleConfigSystemView,
235236 ActionCopyURL,
236237 ActionConsumeOrExpelLeft,
@@ -231,6 +231,7 @@ func TestGlobalShortcutHandlerSuppressesRepeatedAdditionalOneShotUIActions(t *te
231 ActionOpenDevTools,231 ActionOpenDevTools,
232 ActionToggleFloatingPane,232 ActionToggleFloatingPane,
233 ActionToggleFavoritesSystemView,233 ActionToggleFavoritesSystemView,
234+ ActionToggleCurrentPageFavorite,
234 ActionToggleConfigSystemView,235 ActionToggleConfigSystemView,
235 ActionCopyURL,236 ActionCopyURL,
236 ActionConsumeOrExpelLeft,237 ActionConsumeOrExpelLeft,
M internal/ui/input/shortcuts.go +11 -8
@@ -190,6 +190,7 @@ const (
190190 ActionToggleFloatingPane Action = "toggle_floating_pane"
191191 ActionToggleHistorySystemView Action = "toggle_history_systemview"
192192 ActionToggleFavoritesSystemView Action = "toggle_favorites_systemview"
193+ ActionToggleCurrentPageFavorite Action = "toggle_current_page_favorite"
193194 ActionToggleConfigSystemView Action = "toggle_config_systemview"
194195
195196 // Clipboard
@@ -564,14 +565,16 @@ func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[strin
564565 }
565566
566567 var configActionToAction = map[string]Action{
567- "toggle_floating_pane": ActionToggleFloatingPane,
568- "toggle-floating-pane": ActionToggleFloatingPane,
569- "toggle_history_systemview": ActionToggleHistorySystemView,
570- "toggle-history-systemview": ActionToggleHistorySystemView,
571- "toggle_favorites_systemview": ActionToggleFavoritesSystemView,
572- "toggle-favorites-systemview": ActionToggleFavoritesSystemView,
573- "toggle_config_systemview": ActionToggleConfigSystemView,
574- "toggle-config-systemview": ActionToggleConfigSystemView,
568+ "toggle_floating_pane": ActionToggleFloatingPane,
569+ "toggle-floating-pane": ActionToggleFloatingPane,
570+ "toggle_history_systemview": ActionToggleHistorySystemView,
571+ "toggle-history-systemview": ActionToggleHistorySystemView,
572+ "toggle_favorites_systemview": ActionToggleFavoritesSystemView,
573+ "toggle-favorites-systemview": ActionToggleFavoritesSystemView,
574+ "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
575+ "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
576+ "toggle_config_systemview": ActionToggleConfigSystemView,
577+ "toggle-config-systemview": ActionToggleConfigSystemView,
575578
576579 // Tab actions
577580 "new_tab": ActionNewTab,
@@ -190,6 +190,7 @@ const (
190 ActionToggleFloatingPane Action = "toggle_floating_pane"190 ActionToggleFloatingPane Action = "toggle_floating_pane"
191 ActionToggleHistorySystemView Action = "toggle_history_systemview"191 ActionToggleHistorySystemView Action = "toggle_history_systemview"
192 ActionToggleFavoritesSystemView Action = "toggle_favorites_systemview"192 ActionToggleFavoritesSystemView Action = "toggle_favorites_systemview"
193+ ActionToggleCurrentPageFavorite Action = "toggle_current_page_favorite"
193 ActionToggleConfigSystemView Action = "toggle_config_systemview"194 ActionToggleConfigSystemView Action = "toggle_config_systemview"
194 195
195 // Clipboard196 // Clipboard
@@ -564,14 +565,16 @@ func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[strin
564 }565 }
565 566
566 var configActionToAction = map[string]Action{567 var configActionToAction = map[string]Action{
567- "toggle_floating_pane": ActionToggleFloatingPane,568+ "toggle_floating_pane": ActionToggleFloatingPane,
568- "toggle-floating-pane": ActionToggleFloatingPane,569+ "toggle-floating-pane": ActionToggleFloatingPane,
569- "toggle_history_systemview": ActionToggleHistorySystemView,570+ "toggle_history_systemview": ActionToggleHistorySystemView,
570- "toggle-history-systemview": ActionToggleHistorySystemView,571+ "toggle-history-systemview": ActionToggleHistorySystemView,
571- "toggle_favorites_systemview": ActionToggleFavoritesSystemView,572+ "toggle_favorites_systemview": ActionToggleFavoritesSystemView,
572- "toggle-favorites-systemview": ActionToggleFavoritesSystemView,573+ "toggle-favorites-systemview": ActionToggleFavoritesSystemView,
573- "toggle_config_systemview": ActionToggleConfigSystemView,574+ "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
574- "toggle-config-systemview": ActionToggleConfigSystemView,575+ "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
576+ "toggle_config_systemview": ActionToggleConfigSystemView,
577+ "toggle-config-systemview": ActionToggleConfigSystemView,
575 578
576 // Tab actions579 // Tab actions
577 "new_tab": ActionNewTab,580 "new_tab": ActionNewTab,
M internal/ui/input/shortcuts_test.go +2 -0
@@ -402,6 +402,7 @@ func TestMapConfigAction_ToggleSystemViews(t *testing.T) {
402402 }{
403403 {name: "toggle_history_systemview", want: ActionToggleHistorySystemView},
404404 {name: "toggle_favorites_systemview", want: ActionToggleFavoritesSystemView},
405+ {name: "toggle_current_page_favorite", want: ActionToggleCurrentPageFavorite},
405406 {name: "toggle_config_systemview", want: ActionToggleConfigSystemView},
406407 }
407408
@@ -421,6 +422,7 @@ func TestMapConfigAction_ToggleSystemViewsHyphenAlias(t *testing.T) {
421422 }{
422423 {name: "toggle-history-systemview", want: ActionToggleHistorySystemView},
423424 {name: "toggle-favorites-systemview", want: ActionToggleFavoritesSystemView},
425+ {name: "toggle-current-page-favorite", want: ActionToggleCurrentPageFavorite},
424426 {name: "toggle-config-systemview", want: ActionToggleConfigSystemView},
425427 }
426428
@@ -402,6 +402,7 @@ func TestMapConfigAction_ToggleSystemViews(t *testing.T) {
402 }{402 }{
403 {name: "toggle_history_systemview", want: ActionToggleHistorySystemView},403 {name: "toggle_history_systemview", want: ActionToggleHistorySystemView},
404 {name: "toggle_favorites_systemview", want: ActionToggleFavoritesSystemView},404 {name: "toggle_favorites_systemview", want: ActionToggleFavoritesSystemView},
405+ {name: "toggle_current_page_favorite", want: ActionToggleCurrentPageFavorite},
405 {name: "toggle_config_systemview", want: ActionToggleConfigSystemView},406 {name: "toggle_config_systemview", want: ActionToggleConfigSystemView},
406 }407 }
407 408
@@ -421,6 +422,7 @@ func TestMapConfigAction_ToggleSystemViewsHyphenAlias(t *testing.T) {
421 }{422 }{
422 {name: "toggle-history-systemview", want: ActionToggleHistorySystemView},423 {name: "toggle-history-systemview", want: ActionToggleHistorySystemView},
423 {name: "toggle-favorites-systemview", want: ActionToggleFavoritesSystemView},424 {name: "toggle-favorites-systemview", want: ActionToggleFavoritesSystemView},
425+ {name: "toggle-current-page-favorite", want: ActionToggleCurrentPageFavorite},
424 {name: "toggle-config-systemview", want: ActionToggleConfigSystemView},426 {name: "toggle-config-systemview", want: ActionToggleConfigSystemView},
425 }427 }
426 428

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