detached 25e4660

Merge pull request #334 from bnema/fix/favorites-shortcuts-noop

fix(favorites): migrate sidebar shortcuts
commit
25e46605e0fd6e595eaa9f59d3c7daf13d1c749f
author
bnema · · 2026-08-12 04:37 UTC
committer
GitHub · · 2026-08-12 04:37 UTC
merge of
6e335e7 + 142d625 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
14 files · +322 -39
M docs/reference/keybindings.md +1 -1
@@ -168,7 +168,7 @@ keys = ["alt+f"]
168168 [workspace.shortcuts.actions.toggle-history-systemview]
169169 keys = ["ctrl+h"]
170170
171-[workspace.shortcuts.actions.toggle-favorites-systemview]
171+[workspace.shortcuts.actions.toggle-favorites-sidebar]
172172 keys = ["ctrl+b"]
173173
174174 [workspace.shortcuts.actions.toggle-current-page-favorite]
@@ -168,7 +168,7 @@ keys = ["alt+f"]
168 [workspace.shortcuts.actions.toggle-history-systemview]168 [workspace.shortcuts.actions.toggle-history-systemview]
169 keys = ["ctrl+h"]169 keys = ["ctrl+h"]
170 170
171-[workspace.shortcuts.actions.toggle-favorites-systemview]171+[workspace.shortcuts.actions.toggle-favorites-sidebar]
172 keys = ["ctrl+b"]172 keys = ["ctrl+b"]
173 173
174 [workspace.shortcuts.actions.toggle-current-page-favorite]174 [workspace.shortcuts.actions.toggle-current-page-favorite]
M internal/infrastructure/config/defaults.go +1 -1
@@ -340,7 +340,7 @@ func DefaultConfig() *Config {
340340 Actions: map[string]ActionBinding{
341341 "toggle-floating-pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"},
342342 "toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "Toggle History in right split"},
343- "toggle-favorites-systemview": {Keys: []string{"ctrl+b"}, Desc: "Toggle Favorites sidebar"},
343+ "toggle-favorites-sidebar": {Keys: []string{"ctrl+b"}, Desc: "Toggle Favorites sidebar"},
344344 "toggle-current-page-favorite": {Keys: []string{"ctrl+d"}, Desc: "Add/remove current page favorite"},
345345 "toggle-config-systemview": {Keys: []string{}, Desc: "Toggle Config in right split"},
346346 "close-pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"},
@@ -340,7 +340,7 @@ func DefaultConfig() *Config {
340 Actions: map[string]ActionBinding{340 Actions: map[string]ActionBinding{
341 "toggle-floating-pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"},341 "toggle-floating-pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"},
342 "toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "Toggle History in right split"},342 "toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "Toggle History in right split"},
343- "toggle-favorites-systemview": {Keys: []string{"ctrl+b"}, Desc: "Toggle Favorites sidebar"},343+ "toggle-favorites-sidebar": {Keys: []string{"ctrl+b"}, Desc: "Toggle Favorites sidebar"},
344 "toggle-current-page-favorite": {Keys: []string{"ctrl+d"}, Desc: "Add/remove current page favorite"},344 "toggle-current-page-favorite": {Keys: []string{"ctrl+d"}, Desc: "Add/remove current page favorite"},
345 "toggle-config-systemview": {Keys: []string{}, Desc: "Toggle Config in right split"},345 "toggle-config-systemview": {Keys: []string{}, Desc: "Toggle Config in right split"},
346 "close-pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"},346 "close-pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"},
M internal/infrastructure/config/defaults_test.go +1 -1
@@ -41,7 +41,7 @@ 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{"ctrl+b"})
44+ requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-favorites-sidebar", []string{"ctrl+b"})
4545 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-current-page-favorite", []string{"ctrl+d"})
4646 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-config-systemview", []string{})
4747
@@ -41,7 +41,7 @@ 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{"ctrl+b"})44+ requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-favorites-sidebar", []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-current-page-favorite", []string{"ctrl+d"})
46 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-config-systemview", []string{})46 requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-config-systemview", []string{})
47 47
M internal/infrastructure/config/loader.go +0 -4
@@ -175,10 +175,6 @@ func (m *Manager) transformLegacyConfig() {
175175 transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
176176 }
177177
178- // Merge newly added default actions into in-memory config so existing persisted
179- // action maps keep working before the user writes a migrated config to disk.
180- configMigrator.mergeMissingDefaultActions(rawConfig)
181-
182178 // Apply transformed config back to viper
183179 for key, value := range rawConfig {
184180 m.viper.Set(key, value)
@@ -175,10 +175,6 @@ func (m *Manager) transformLegacyConfig() {
175 transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)175 transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
176 }176 }
177 177
178- // Merge newly added default actions into in-memory config so existing persisted
179- // action maps keep working before the user writes a migrated config to disk.
180- configMigrator.mergeMissingDefaultActions(rawConfig)
181-
182 // Apply transformed config back to viper178 // Apply transformed config back to viper
183 for key, value := range rawConfig {179 for key, value := range rawConfig {
184 m.viper.Set(key, value)180 m.viper.Set(key, value)
M internal/infrastructure/config/loader_actions_test.go +59 -4
@@ -36,7 +36,7 @@ touchpad_navigation_max_vertical_ratio = 2.0
3636 assert.InDelta(t, 2.0, cfg.Engine.CEF.Input.TouchpadNavigationMaxVerticalRatio, 0.001)
3737 }
3838
39-func TestManagerLoad_MergesMissingPaneModeActionsInMemory(t *testing.T) {
39+func TestManagerLoad_DoesNotMergeMissingPaneModeActionsInMemory(t *testing.T) {
4040 home := t.TempDir()
4141 t.Setenv("HOME", home)
4242 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
@@ -64,10 +64,65 @@ desc = "Custom stack pane"
6464 cfg := mgr.Get()
6565 require.NotNil(t, cfg)
6666
67- eject, ok := cfg.Workspace.PaneMode.Actions["eject-pane-to-window"]
68- require.True(t, ok)
69- assert.Equal(t, []string{"w"}, eject.Keys)
67+ _, ok := cfg.Workspace.PaneMode.Actions["eject-pane-to-window"]
68+ assert.False(t, ok)
7069
7170 stack := cfg.Workspace.PaneMode.Actions["stack-pane"]
7271 assert.Equal(t, "Custom stack pane", stack.Desc)
7372 }
73+
74+func TestManagerLoad_DoesNotMigrateLegacyFavoritesShortcut(t *testing.T) {
75+ home := t.TempDir()
76+ t.Setenv("HOME", home)
77+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
78+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
79+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
80+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
81+
82+ configFile, err := GetConfigFile()
83+ require.NoError(t, err)
84+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
85+ require.NoError(t, os.WriteFile(configFile, []byte(`
86+[workspace.shortcuts.actions.toggle-favorites-systemview]
87+keys = []
88+desc = "Custom favorites shortcut"
89+`), 0o644))
90+
91+ mgr, err := NewManager()
92+ require.NoError(t, err)
93+ require.NoError(t, mgr.Load())
94+
95+ favorites := mgr.Get().Workspace.Shortcuts.Actions["toggle-favorites-systemview"]
96+ assert.Empty(t, favorites.Keys)
97+ assert.Equal(t, "Custom favorites shortcut", favorites.Desc)
98+}
99+
100+func TestManagerLoad_DoesNotMigrateLegacyFavoritesShortcutDefault(t *testing.T) {
101+ home := t.TempDir()
102+ t.Setenv("HOME", home)
103+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
104+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
105+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
106+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
107+
108+ configFile, err := GetConfigFile()
109+ require.NoError(t, err)
110+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
111+ require.NoError(t, os.WriteFile(configFile, []byte(`
112+[workspace.shortcuts.actions.toggle-favorites-systemview]
113+keys = []
114+desc = "Toggle Favorites in right split"
115+`), 0o644))
116+
117+ mgr, err := NewManager()
118+ require.NoError(t, err)
119+ require.NoError(t, mgr.Load())
120+
121+ cfg := mgr.Get()
122+ require.NotNil(t, cfg)
123+ favorites := cfg.Workspace.Shortcuts.Actions["toggle-favorites-systemview"]
124+ assert.Empty(t, favorites.Keys)
125+ assert.Equal(t, "Toggle Favorites in right split", favorites.Desc)
126+ _, ok := cfg.Workspace.Shortcuts.Actions["toggle-favorites-sidebar"]
127+ assert.False(t, ok)
128+}
@@ -36,7 +36,7 @@ touchpad_navigation_max_vertical_ratio = 2.0
36 assert.InDelta(t, 2.0, cfg.Engine.CEF.Input.TouchpadNavigationMaxVerticalRatio, 0.001)36 assert.InDelta(t, 2.0, cfg.Engine.CEF.Input.TouchpadNavigationMaxVerticalRatio, 0.001)
37 }37 }
38 38
39-func TestManagerLoad_MergesMissingPaneModeActionsInMemory(t *testing.T) {39+func TestManagerLoad_DoesNotMergeMissingPaneModeActionsInMemory(t *testing.T) {
40 home := t.TempDir()40 home := t.TempDir()
41 t.Setenv("HOME", home)41 t.Setenv("HOME", home)
42 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))42 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
@@ -64,10 +64,65 @@ desc = "Custom stack pane"
64 cfg := mgr.Get()64 cfg := mgr.Get()
65 require.NotNil(t, cfg)65 require.NotNil(t, cfg)
66 66
67- eject, ok := cfg.Workspace.PaneMode.Actions["eject-pane-to-window"]67+ _, ok := cfg.Workspace.PaneMode.Actions["eject-pane-to-window"]
68- require.True(t, ok)68+ assert.False(t, ok)
69- assert.Equal(t, []string{"w"}, eject.Keys)
70 69
71 stack := cfg.Workspace.PaneMode.Actions["stack-pane"]70 stack := cfg.Workspace.PaneMode.Actions["stack-pane"]
72 assert.Equal(t, "Custom stack pane", stack.Desc)71 assert.Equal(t, "Custom stack pane", stack.Desc)
73 }72 }
73+
74+func TestManagerLoad_DoesNotMigrateLegacyFavoritesShortcut(t *testing.T) {
75+ home := t.TempDir()
76+ t.Setenv("HOME", home)
77+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
78+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
79+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
80+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
81+
82+ configFile, err := GetConfigFile()
83+ require.NoError(t, err)
84+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
85+ require.NoError(t, os.WriteFile(configFile, []byte(`
86+[workspace.shortcuts.actions.toggle-favorites-systemview]
87+keys = []
88+desc = "Custom favorites shortcut"
89+`), 0o644))
90+
91+ mgr, err := NewManager()
92+ require.NoError(t, err)
93+ require.NoError(t, mgr.Load())
94+
95+ favorites := mgr.Get().Workspace.Shortcuts.Actions["toggle-favorites-systemview"]
96+ assert.Empty(t, favorites.Keys)
97+ assert.Equal(t, "Custom favorites shortcut", favorites.Desc)
98+}
99+
100+func TestManagerLoad_DoesNotMigrateLegacyFavoritesShortcutDefault(t *testing.T) {
101+ home := t.TempDir()
102+ t.Setenv("HOME", home)
103+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
104+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
105+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
106+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
107+
108+ configFile, err := GetConfigFile()
109+ require.NoError(t, err)
110+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
111+ require.NoError(t, os.WriteFile(configFile, []byte(`
112+[workspace.shortcuts.actions.toggle-favorites-systemview]
113+keys = []
114+desc = "Toggle Favorites in right split"
115+`), 0o644))
116+
117+ mgr, err := NewManager()
118+ require.NoError(t, err)
119+ require.NoError(t, mgr.Load())
120+
121+ cfg := mgr.Get()
122+ require.NotNil(t, cfg)
123+ favorites := cfg.Workspace.Shortcuts.Actions["toggle-favorites-systemview"]
124+ assert.Empty(t, favorites.Keys)
125+ assert.Equal(t, "Toggle Favorites in right split", favorites.Desc)
126+ _, ok := cfg.Workspace.Shortcuts.Actions["toggle-favorites-sidebar"]
127+ assert.False(t, ok)
128+}
M internal/infrastructure/config/migrate.go +127 -13
@@ -25,11 +25,6 @@ type Migrator struct {
2525 defaultConfig *Config
2626 }
2727
28-// configMigrator reuses the default-action metadata needed during config load.
29-// It is immutable after initialization; per-command migration still constructs
30-// explicit Migrator instances when callers need independent state.
31-var configMigrator = NewMigrator()
32-
3328 // NewMigrator creates a new Migrator instance.
3429 func NewMigrator() *Migrator {
3530 v := viper.New()
@@ -58,17 +53,22 @@ func (m *Migrator) CheckMigration() (*port.MigrationResult, error) {
5853 return nil, nil
5954 }
6055
61- // Get user-defined keys from the TOML file
62- userKeys, err := m.getUserConfigKeys(configFile)
56+ userKeysWithValues, err := m.getUserConfigKeysWithValues(configFile)
6357 if err != nil {
6458 return nil, fmt.Errorf("failed to parse user config: %w", err)
6559 }
6660
61+ userKeys := make(map[string]bool, len(userKeysWithValues))
62+ for key := range userKeysWithValues {
63+ userKeys[key] = true
64+ }
65+
6766 // Get all default keys
6867 defaultKeys := m.getAllDefaultKeys()
6968
7069 // Find missing keys (in defaults but not in user config)
7170 missingKeys := m.findMissingKeys(defaultKeys, userKeys)
71+ missingKeys = appendUniqueKeys(missingKeys, m.detectMissingDefaultActions(userKeysWithValues))
7272
7373 if len(missingKeys) == 0 {
7474 return nil, nil
@@ -119,13 +119,16 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) {
119119 userKeySet[k] = true
120120 }
121121
122- legacyBrowsingContextRenames := m.detectLegacyBrowsingContextRenames(rawUserKeysWithValues)
122+ legacyRenames := append(
123+ m.detectLegacyBrowsingContextRenames(rawUserKeysWithValues),
124+ m.detectLegacyFavoritesSidebarRename(userKeysWithValues)...,
125+ )
123126 var changes []port.KeyChange
124- changes = append(changes, legacyBrowsingContextRenames...)
127+ changes = append(changes, legacyRenames...)
125128
126- handledLegacyDeprecatedKeys := make(map[string]bool, len(legacyBrowsingContextRenames))
127- handledLegacyMissingKeys := make(map[string]bool, len(legacyBrowsingContextRenames))
128- for _, change := range legacyBrowsingContextRenames {
129+ handledLegacyDeprecatedKeys := make(map[string]bool, len(legacyRenames))
130+ handledLegacyMissingKeys := make(map[string]bool, len(legacyRenames))
131+ for _, change := range legacyRenames {
129132 handledLegacyDeprecatedKeys[change.OldKey] = true
130133 handledLegacyMissingKeys[change.NewKey] = true
131134 }
@@ -444,10 +447,11 @@ func (m *Migrator) Migrate() ([]string, error) {
444447 }
445448 }
446449
447- // Remove deprecated and renamed keys from the raw config
450+ // Remove deprecated and renamed keys from the raw config.
448451 for key := range keysToRemove {
449452 m.deleteNestedKey(rawConfig, key)
450453 }
454+ m.upgradeLegacyFavoritesSidebarDefault(rawConfig)
451455 m.mergeMissingDefaultActions(rawConfig)
452456
453457 // Create a new Viper instance with defaults for added keys
@@ -723,6 +727,31 @@ func (m *Migrator) getRawUserConfigKeysWithValues(configFile string) (map[string
723727 return result, nil
724728 }
725729
730+func (m *Migrator) detectLegacyFavoritesSidebarRename(userKeys map[string]any) []port.KeyChange {
731+ const (
732+ actionsKey = "workspace.shortcuts.actions"
733+ legacyName = "toggle-favorites-systemview"
734+ newName = "toggle-favorites-sidebar"
735+ )
736+
737+ actionsValue, ok := userKeys[actionsKey]
738+ if !ok {
739+ return nil
740+ }
741+ actions, ok := m.toStringAnyMap(actionsValue)
742+ if !ok || actions[legacyName] == nil || actions[newName] != nil {
743+ return nil
744+ }
745+
746+ return []port.KeyChange{{
747+ Type: port.KeyChangeRenamed,
748+ OldKey: actionsKey + "." + legacyName,
749+ NewKey: actionsKey + "." + newName,
750+ OldValue: m.formatValue(actions[legacyName]),
751+ NewValue: m.formatValue(m.defaultConfig.Workspace.Shortcuts.Actions[newName]),
752+ }}
753+}
754+
726755 func (m *Migrator) detectLegacyBrowsingContextRenames(rawUserKeys map[string]any) []port.KeyChange {
727756 type renamePair struct {
728757 oldKey string
@@ -895,6 +924,91 @@ func (m *Migrator) mergeMissingDefaultActions(rawConfig map[string]any) {
895924 }
896925 }
897926
927+func (m *Migrator) upgradeLegacyFavoritesSidebarDefault(rawConfig map[string]any) {
928+ const (
929+ actionsKey = "workspace.shortcuts.actions"
930+ actionName = "toggle-favorites-sidebar"
931+ )
932+
933+ actionsValue := m.getNestedValue(rawConfig, actionsKey)
934+ actions, ok := m.toStringAnyMap(actionsValue)
935+ if !ok {
936+ return
937+ }
938+ binding, ok := m.actionBindingFromAny(actions[actionName])
939+ if !ok || !reflect.DeepEqual(binding, entity.ActionBinding{Keys: []string{}, Desc: "Toggle Favorites in right split"}) {
940+ return
941+ }
942+ actions[actionName] = m.defaultConfig.Workspace.Shortcuts.Actions[actionName]
943+ m.setNestedValue(rawConfig, actionsKey, actions)
944+}
945+
946+func (m *Migrator) actionBindingFromAny(value any) (entity.ActionBinding, bool) {
947+ switch v := value.(type) {
948+ case entity.ActionBinding:
949+ return v, true
950+ case map[string]any:
951+ return actionBindingFromMap(v)
952+ default:
953+ mapping, ok := m.toStringAnyMap(value)
954+ if !ok {
955+ return entity.ActionBinding{}, false
956+ }
957+ return actionBindingFromMap(mapping)
958+ }
959+}
960+
961+func actionBindingFromMap(m map[string]any) (entity.ActionBinding, bool) {
962+ var binding entity.ActionBinding
963+ if keysValue, exists := m["keys"]; exists {
964+ keys, ok := stringSliceFromAny(keysValue)
965+ if !ok {
966+ return entity.ActionBinding{}, false
967+ }
968+ binding.Keys = keys
969+ }
970+ if descValue, exists := m["desc"]; exists {
971+ desc, ok := descValue.(string)
972+ if !ok {
973+ return entity.ActionBinding{}, false
974+ }
975+ binding.Desc = desc
976+ }
977+ return binding, true
978+}
979+
980+func stringSliceFromAny(value any) ([]string, bool) {
981+ switch v := value.(type) {
982+ case []string:
983+ return append([]string(nil), v...), true
984+ case []any:
985+ out := make([]string, 0, len(v))
986+ for _, item := range v {
987+ s, ok := item.(string)
988+ if !ok {
989+ return nil, false
990+ }
991+ out = append(out, s)
992+ }
993+ return out, true
994+ default:
995+ rv := reflect.ValueOf(value)
996+ if rv.Kind() != reflect.Slice {
997+ return nil, false
998+ }
999+ out := make([]string, 0, rv.Len())
1000+ for i := 0; i < rv.Len(); i++ {
1001+ item := rv.Index(i).Interface()
1002+ s, ok := item.(string)
1003+ if !ok {
1004+ return nil, false
1005+ }
1006+ out = append(out, s)
1007+ }
1008+ return out, true
1009+ }
1010+}
1011+
8981012 func appendUniqueKeys(base, extra []string) []string {
8991013 if len(extra) == 0 {
9001014 return base
@@ -25,11 +25,6 @@ type Migrator struct {
25 defaultConfig *Config25 defaultConfig *Config
26 }26 }
27 27
28-// configMigrator reuses the default-action metadata needed during config load.
29-// It is immutable after initialization; per-command migration still constructs
30-// explicit Migrator instances when callers need independent state.
31-var configMigrator = NewMigrator()
32-
33 // NewMigrator creates a new Migrator instance.28 // NewMigrator creates a new Migrator instance.
34 func NewMigrator() *Migrator {29 func NewMigrator() *Migrator {
35 v := viper.New()30 v := viper.New()
@@ -58,17 +53,22 @@ func (m *Migrator) CheckMigration() (*port.MigrationResult, error) {
58 return nil, nil53 return nil, nil
59 }54 }
60 55
61- // Get user-defined keys from the TOML file56+ userKeysWithValues, err := m.getUserConfigKeysWithValues(configFile)
62- userKeys, err := m.getUserConfigKeys(configFile)
63 if err != nil {57 if err != nil {
64 return nil, fmt.Errorf("failed to parse user config: %w", err)58 return nil, fmt.Errorf("failed to parse user config: %w", err)
65 }59 }
66 60
61+ userKeys := make(map[string]bool, len(userKeysWithValues))
62+ for key := range userKeysWithValues {
63+ userKeys[key] = true
64+ }
65+
67 // Get all default keys66 // Get all default keys
68 defaultKeys := m.getAllDefaultKeys()67 defaultKeys := m.getAllDefaultKeys()
69 68
70 // Find missing keys (in defaults but not in user config)69 // Find missing keys (in defaults but not in user config)
71 missingKeys := m.findMissingKeys(defaultKeys, userKeys)70 missingKeys := m.findMissingKeys(defaultKeys, userKeys)
71+ missingKeys = appendUniqueKeys(missingKeys, m.detectMissingDefaultActions(userKeysWithValues))
72 72
73 if len(missingKeys) == 0 {73 if len(missingKeys) == 0 {
74 return nil, nil74 return nil, nil
@@ -119,13 +119,16 @@ func (m *Migrator) DetectChanges() ([]port.KeyChange, error) {
119 userKeySet[k] = true119 userKeySet[k] = true
120 }120 }
121 121
122- legacyBrowsingContextRenames := m.detectLegacyBrowsingContextRenames(rawUserKeysWithValues)122+ legacyRenames := append(
123+ m.detectLegacyBrowsingContextRenames(rawUserKeysWithValues),
124+ m.detectLegacyFavoritesSidebarRename(userKeysWithValues)...,
125+ )
123 var changes []port.KeyChange126 var changes []port.KeyChange
124- changes = append(changes, legacyBrowsingContextRenames...)127+ changes = append(changes, legacyRenames...)
125 128
126- handledLegacyDeprecatedKeys := make(map[string]bool, len(legacyBrowsingContextRenames))129+ handledLegacyDeprecatedKeys := make(map[string]bool, len(legacyRenames))
127- handledLegacyMissingKeys := make(map[string]bool, len(legacyBrowsingContextRenames))130+ handledLegacyMissingKeys := make(map[string]bool, len(legacyRenames))
128- for _, change := range legacyBrowsingContextRenames {131+ for _, change := range legacyRenames {
129 handledLegacyDeprecatedKeys[change.OldKey] = true132 handledLegacyDeprecatedKeys[change.OldKey] = true
130 handledLegacyMissingKeys[change.NewKey] = true133 handledLegacyMissingKeys[change.NewKey] = true
131 }134 }
@@ -444,10 +447,11 @@ func (m *Migrator) Migrate() ([]string, error) {
444 }447 }
445 }448 }
446 449
447- // Remove deprecated and renamed keys from the raw config450+ // Remove deprecated and renamed keys from the raw config.
448 for key := range keysToRemove {451 for key := range keysToRemove {
449 m.deleteNestedKey(rawConfig, key)452 m.deleteNestedKey(rawConfig, key)
450 }453 }
454+ m.upgradeLegacyFavoritesSidebarDefault(rawConfig)
451 m.mergeMissingDefaultActions(rawConfig)455 m.mergeMissingDefaultActions(rawConfig)
452 456
453 // Create a new Viper instance with defaults for added keys457 // Create a new Viper instance with defaults for added keys
@@ -723,6 +727,31 @@ func (m *Migrator) getRawUserConfigKeysWithValues(configFile string) (map[string
723 return result, nil727 return result, nil
724 }728 }
725 729
730+func (m *Migrator) detectLegacyFavoritesSidebarRename(userKeys map[string]any) []port.KeyChange {
731+ const (
732+ actionsKey = "workspace.shortcuts.actions"
733+ legacyName = "toggle-favorites-systemview"
734+ newName = "toggle-favorites-sidebar"
735+ )
736+
737+ actionsValue, ok := userKeys[actionsKey]
738+ if !ok {
739+ return nil
740+ }
741+ actions, ok := m.toStringAnyMap(actionsValue)
742+ if !ok || actions[legacyName] == nil || actions[newName] != nil {
743+ return nil
744+ }
745+
746+ return []port.KeyChange{{
747+ Type: port.KeyChangeRenamed,
748+ OldKey: actionsKey + "." + legacyName,
749+ NewKey: actionsKey + "." + newName,
750+ OldValue: m.formatValue(actions[legacyName]),
751+ NewValue: m.formatValue(m.defaultConfig.Workspace.Shortcuts.Actions[newName]),
752+ }}
753+}
754+
726 func (m *Migrator) detectLegacyBrowsingContextRenames(rawUserKeys map[string]any) []port.KeyChange {755 func (m *Migrator) detectLegacyBrowsingContextRenames(rawUserKeys map[string]any) []port.KeyChange {
727 type renamePair struct {756 type renamePair struct {
728 oldKey string757 oldKey string
@@ -895,6 +924,91 @@ func (m *Migrator) mergeMissingDefaultActions(rawConfig map[string]any) {
895 }924 }
896 }925 }
897 926
927+func (m *Migrator) upgradeLegacyFavoritesSidebarDefault(rawConfig map[string]any) {
928+ const (
929+ actionsKey = "workspace.shortcuts.actions"
930+ actionName = "toggle-favorites-sidebar"
931+ )
932+
933+ actionsValue := m.getNestedValue(rawConfig, actionsKey)
934+ actions, ok := m.toStringAnyMap(actionsValue)
935+ if !ok {
936+ return
937+ }
938+ binding, ok := m.actionBindingFromAny(actions[actionName])
939+ if !ok || !reflect.DeepEqual(binding, entity.ActionBinding{Keys: []string{}, Desc: "Toggle Favorites in right split"}) {
940+ return
941+ }
942+ actions[actionName] = m.defaultConfig.Workspace.Shortcuts.Actions[actionName]
943+ m.setNestedValue(rawConfig, actionsKey, actions)
944+}
945+
946+func (m *Migrator) actionBindingFromAny(value any) (entity.ActionBinding, bool) {
947+ switch v := value.(type) {
948+ case entity.ActionBinding:
949+ return v, true
950+ case map[string]any:
951+ return actionBindingFromMap(v)
952+ default:
953+ mapping, ok := m.toStringAnyMap(value)
954+ if !ok {
955+ return entity.ActionBinding{}, false
956+ }
957+ return actionBindingFromMap(mapping)
958+ }
959+}
960+
961+func actionBindingFromMap(m map[string]any) (entity.ActionBinding, bool) {
962+ var binding entity.ActionBinding
963+ if keysValue, exists := m["keys"]; exists {
964+ keys, ok := stringSliceFromAny(keysValue)
965+ if !ok {
966+ return entity.ActionBinding{}, false
967+ }
968+ binding.Keys = keys
969+ }
970+ if descValue, exists := m["desc"]; exists {
971+ desc, ok := descValue.(string)
972+ if !ok {
973+ return entity.ActionBinding{}, false
974+ }
975+ binding.Desc = desc
976+ }
977+ return binding, true
978+}
979+
980+func stringSliceFromAny(value any) ([]string, bool) {
981+ switch v := value.(type) {
982+ case []string:
983+ return append([]string(nil), v...), true
984+ case []any:
985+ out := make([]string, 0, len(v))
986+ for _, item := range v {
987+ s, ok := item.(string)
988+ if !ok {
989+ return nil, false
990+ }
991+ out = append(out, s)
992+ }
993+ return out, true
994+ default:
995+ rv := reflect.ValueOf(value)
996+ if rv.Kind() != reflect.Slice {
997+ return nil, false
998+ }
999+ out := make([]string, 0, rv.Len())
1000+ for i := 0; i < rv.Len(); i++ {
1001+ item := rv.Index(i).Interface()
1002+ s, ok := item.(string)
1003+ if !ok {
1004+ return nil, false
1005+ }
1006+ out = append(out, s)
1007+ }
1008+ return out, true
1009+ }
1010+}
1011+
898 func appendUniqueKeys(base, extra []string) []string {1012 func appendUniqueKeys(base, extra []string) []string {
899 if len(extra) == 0 {1013 if len(extra) == 0 {
900 return base1014 return base
M internal/infrastructure/config/migrate_test.go +111 -0
@@ -1282,3 +1282,114 @@ section:
12821282 assert.InDelta(t, 42.0, section["number"].(float64), 0.001)
12831283 })
12841284 }
1285+
1286+func TestMigrator_Migrate_RenamesCustomFavoritesSidebarShortcut(t *testing.T) {
1287+ home := t.TempDir()
1288+ t.Setenv("HOME", home)
1289+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1290+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1291+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1292+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1293+
1294+ configFile, err := GetConfigFile()
1295+ require.NoError(t, err)
1296+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1297+ require.NoError(t, os.WriteFile(configFile, []byte(`
1298+[workspace.shortcuts.actions.toggle-favorites-systemview]
1299+keys = []
1300+desc = "Custom favorites shortcut"
1301+`), 0o644))
1302+
1303+ migrator := NewMigrator()
1304+ result, err := migrator.CheckMigration()
1305+ require.NoError(t, err)
1306+ require.NotNil(t, result)
1307+ assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.toggle-favorites-sidebar")
1308+
1309+ changes, err := migrator.DetectChanges()
1310+ require.NoError(t, err)
1311+ assertContainsChange(t, changes, port.KeyChangeRenamed,
1312+ "workspace.shortcuts.actions.toggle-favorites-systemview",
1313+ "workspace.shortcuts.actions.toggle-favorites-sidebar")
1314+
1315+ applied, err := migrator.Migrate()
1316+ require.NoError(t, err)
1317+ assert.Contains(t, applied, "workspace.shortcuts.actions.toggle-favorites-systemview -> workspace.shortcuts.actions.toggle-favorites-sidebar")
1318+
1319+ mgr, err := NewManager()
1320+ require.NoError(t, err)
1321+ require.NoError(t, mgr.Load())
1322+ _, ok := mgr.Get().Workspace.Shortcuts.Actions["toggle-favorites-systemview"]
1323+ assert.False(t, ok)
1324+ favorites := mgr.Get().Workspace.Shortcuts.Actions["toggle-favorites-sidebar"]
1325+ assert.Empty(t, favorites.Keys)
1326+ assert.Equal(t, "Custom favorites shortcut", favorites.Desc)
1327+}
1328+
1329+func TestMigrator_Migrate_UpgradesLegacyFavoritesSidebarDefault(t *testing.T) {
1330+ home := t.TempDir()
1331+ t.Setenv("HOME", home)
1332+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1333+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1334+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1335+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1336+
1337+ configFile, err := GetConfigFile()
1338+ require.NoError(t, err)
1339+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1340+ require.NoError(t, os.WriteFile(configFile, []byte(`
1341+[workspace.shortcuts.actions.toggle-favorites-systemview]
1342+keys = []
1343+desc = "Toggle Favorites in right split"
1344+`), 0o644))
1345+
1346+ migrator := NewMigrator()
1347+ result, err := migrator.CheckMigration()
1348+ require.NoError(t, err)
1349+ require.NotNil(t, result)
1350+ assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.toggle-favorites-sidebar")
1351+ assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.toggle-current-page-favorite")
1352+
1353+ changes, err := migrator.DetectChanges()
1354+ require.NoError(t, err)
1355+ assertContainsChange(t, changes, port.KeyChangeRenamed,
1356+ "workspace.shortcuts.actions.toggle-favorites-systemview",
1357+ "workspace.shortcuts.actions.toggle-favorites-sidebar")
1358+ assertContainsChange(t, changes, port.KeyChangeAdded, "", "workspace.shortcuts.actions.toggle-current-page-favorite")
1359+
1360+ applied, err := migrator.Migrate()
1361+ require.NoError(t, err)
1362+ assert.Contains(t, applied, "workspace.shortcuts.actions.toggle-favorites-systemview -> workspace.shortcuts.actions.toggle-favorites-sidebar")
1363+ assert.Contains(t, applied, "workspace.shortcuts.actions.toggle-current-page-favorite")
1364+
1365+ raw, err := migrator.readRawConfig(configFile)
1366+ require.NoError(t, err)
1367+ workspace, ok := raw["workspace"].(map[string]any)
1368+ require.True(t, ok)
1369+ shortcuts, ok := workspace["shortcuts"].(map[string]any)
1370+ require.True(t, ok)
1371+ actions, ok := shortcuts["actions"].(map[string]any)
1372+ require.True(t, ok)
1373+
1374+ expectedActions := DefaultConfig().Workspace.Shortcuts.Actions
1375+ _, ok = actions["toggle-favorites-systemview"]
1376+ assert.False(t, ok)
1377+ favorites, ok := actions["toggle-favorites-sidebar"].(map[string]any)
1378+ require.True(t, ok)
1379+ assert.Equal(t, []any{"ctrl+b"}, favorites["keys"])
1380+ assert.Equal(t, expectedActions["toggle-favorites-sidebar"].Desc, favorites["desc"])
1381+ currentPage, ok := actions["toggle-current-page-favorite"].(map[string]any)
1382+ require.True(t, ok)
1383+ assert.Equal(t, []any{"ctrl+d"}, currentPage["keys"])
1384+ assert.Equal(t, expectedActions["toggle-current-page-favorite"].Desc, currentPage["desc"])
1385+
1386+ mgr, err := NewManager()
1387+ require.NoError(t, err)
1388+ require.NoError(t, mgr.Load())
1389+ cfg := mgr.Get()
1390+ require.NotNil(t, cfg)
1391+ _, ok = cfg.Workspace.Shortcuts.Actions["toggle-favorites-systemview"]
1392+ assert.False(t, ok)
1393+ assert.Equal(t, []string{"ctrl+b"}, cfg.Workspace.Shortcuts.Actions["toggle-favorites-sidebar"].Keys)
1394+ assert.Equal(t, []string{"ctrl+d"}, cfg.Workspace.Shortcuts.Actions["toggle-current-page-favorite"].Keys)
1395+}
@@ -1282,3 +1282,114 @@ section:
1282 assert.InDelta(t, 42.0, section["number"].(float64), 0.001)1282 assert.InDelta(t, 42.0, section["number"].(float64), 0.001)
1283 })1283 })
1284 }1284 }
1285+
1286+func TestMigrator_Migrate_RenamesCustomFavoritesSidebarShortcut(t *testing.T) {
1287+ home := t.TempDir()
1288+ t.Setenv("HOME", home)
1289+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1290+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1291+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1292+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1293+
1294+ configFile, err := GetConfigFile()
1295+ require.NoError(t, err)
1296+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1297+ require.NoError(t, os.WriteFile(configFile, []byte(`
1298+[workspace.shortcuts.actions.toggle-favorites-systemview]
1299+keys = []
1300+desc = "Custom favorites shortcut"
1301+`), 0o644))
1302+
1303+ migrator := NewMigrator()
1304+ result, err := migrator.CheckMigration()
1305+ require.NoError(t, err)
1306+ require.NotNil(t, result)
1307+ assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.toggle-favorites-sidebar")
1308+
1309+ changes, err := migrator.DetectChanges()
1310+ require.NoError(t, err)
1311+ assertContainsChange(t, changes, port.KeyChangeRenamed,
1312+ "workspace.shortcuts.actions.toggle-favorites-systemview",
1313+ "workspace.shortcuts.actions.toggle-favorites-sidebar")
1314+
1315+ applied, err := migrator.Migrate()
1316+ require.NoError(t, err)
1317+ assert.Contains(t, applied, "workspace.shortcuts.actions.toggle-favorites-systemview -> workspace.shortcuts.actions.toggle-favorites-sidebar")
1318+
1319+ mgr, err := NewManager()
1320+ require.NoError(t, err)
1321+ require.NoError(t, mgr.Load())
1322+ _, ok := mgr.Get().Workspace.Shortcuts.Actions["toggle-favorites-systemview"]
1323+ assert.False(t, ok)
1324+ favorites := mgr.Get().Workspace.Shortcuts.Actions["toggle-favorites-sidebar"]
1325+ assert.Empty(t, favorites.Keys)
1326+ assert.Equal(t, "Custom favorites shortcut", favorites.Desc)
1327+}
1328+
1329+func TestMigrator_Migrate_UpgradesLegacyFavoritesSidebarDefault(t *testing.T) {
1330+ home := t.TempDir()
1331+ t.Setenv("HOME", home)
1332+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
1333+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
1334+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
1335+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
1336+
1337+ configFile, err := GetConfigFile()
1338+ require.NoError(t, err)
1339+ require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755))
1340+ require.NoError(t, os.WriteFile(configFile, []byte(`
1341+[workspace.shortcuts.actions.toggle-favorites-systemview]
1342+keys = []
1343+desc = "Toggle Favorites in right split"
1344+`), 0o644))
1345+
1346+ migrator := NewMigrator()
1347+ result, err := migrator.CheckMigration()
1348+ require.NoError(t, err)
1349+ require.NotNil(t, result)
1350+ assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.toggle-favorites-sidebar")
1351+ assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.toggle-current-page-favorite")
1352+
1353+ changes, err := migrator.DetectChanges()
1354+ require.NoError(t, err)
1355+ assertContainsChange(t, changes, port.KeyChangeRenamed,
1356+ "workspace.shortcuts.actions.toggle-favorites-systemview",
1357+ "workspace.shortcuts.actions.toggle-favorites-sidebar")
1358+ assertContainsChange(t, changes, port.KeyChangeAdded, "", "workspace.shortcuts.actions.toggle-current-page-favorite")
1359+
1360+ applied, err := migrator.Migrate()
1361+ require.NoError(t, err)
1362+ assert.Contains(t, applied, "workspace.shortcuts.actions.toggle-favorites-systemview -> workspace.shortcuts.actions.toggle-favorites-sidebar")
1363+ assert.Contains(t, applied, "workspace.shortcuts.actions.toggle-current-page-favorite")
1364+
1365+ raw, err := migrator.readRawConfig(configFile)
1366+ require.NoError(t, err)
1367+ workspace, ok := raw["workspace"].(map[string]any)
1368+ require.True(t, ok)
1369+ shortcuts, ok := workspace["shortcuts"].(map[string]any)
1370+ require.True(t, ok)
1371+ actions, ok := shortcuts["actions"].(map[string]any)
1372+ require.True(t, ok)
1373+
1374+ expectedActions := DefaultConfig().Workspace.Shortcuts.Actions
1375+ _, ok = actions["toggle-favorites-systemview"]
1376+ assert.False(t, ok)
1377+ favorites, ok := actions["toggle-favorites-sidebar"].(map[string]any)
1378+ require.True(t, ok)
1379+ assert.Equal(t, []any{"ctrl+b"}, favorites["keys"])
1380+ assert.Equal(t, expectedActions["toggle-favorites-sidebar"].Desc, favorites["desc"])
1381+ currentPage, ok := actions["toggle-current-page-favorite"].(map[string]any)
1382+ require.True(t, ok)
1383+ assert.Equal(t, []any{"ctrl+d"}, currentPage["keys"])
1384+ assert.Equal(t, expectedActions["toggle-current-page-favorite"].Desc, currentPage["desc"])
1385+
1386+ mgr, err := NewManager()
1387+ require.NoError(t, err)
1388+ require.NoError(t, mgr.Load())
1389+ cfg := mgr.Get()
1390+ require.NotNil(t, cfg)
1391+ _, ok = cfg.Workspace.Shortcuts.Actions["toggle-favorites-systemview"]
1392+ assert.False(t, ok)
1393+ assert.Equal(t, []string{"ctrl+b"}, cfg.Workspace.Shortcuts.Actions["toggle-favorites-sidebar"].Keys)
1394+ assert.Equal(t, []string{"ctrl+d"}, cfg.Workspace.Shortcuts.Actions["toggle-current-page-favorite"].Keys)
1395+}
M internal/ui/browser_window_favorites_sidebar.go +9 -2
@@ -119,7 +119,8 @@ func (a *App) toggleCurrentPageFavoriteAction(ctx context.Context) error {
119119 if a == nil || a.deps == nil || a.deps.FavoritesUC == nil {
120120 return fmt.Errorf("favorites unavailable: usecase not configured")
121121 }
122- _, wv := a.activeWebViewForBrowserWindow(a.lastFocusedBrowserWindow())
122+ bw := a.lastFocusedBrowserWindow()
123+ _, wv := a.activeWebViewForBrowserWindow(bw)
123124 if wv == nil {
124125 return fmt.Errorf("favorites unavailable: no active webview")
125126 }
@@ -127,10 +128,16 @@ func (a *App) toggleCurrentPageFavoriteAction(ctx context.Context) error {
127128 if uri == "" {
128129 return fmt.Errorf("favorites unavailable: active page has no URI")
129130 }
130- _, err := a.deps.FavoritesUC.Toggle(ctx, uri, wv.Title())
131+ result, err := a.deps.FavoritesUC.Toggle(ctx, uri, wv.Title())
131132 if err != nil {
132133 return err
133134 }
135+ if result != nil && strings.TrimSpace(result.Message) != "" {
136+ a.showToastOnBrowserWindow(ctx, bw, result.Message, component.ToastSuccess,
137+ component.WithDuration(component.ToastBriefDurationMs),
138+ component.WithPosition(component.ToastPositionBottomRight),
139+ )
140+ }
134141 a.reloadVisibleFavoritesSidebars("current-page-favorite-toggle")
135142 return nil
136143 }
@@ -119,7 +119,8 @@ func (a *App) toggleCurrentPageFavoriteAction(ctx context.Context) error {
119 if a == nil || a.deps == nil || a.deps.FavoritesUC == nil {119 if a == nil || a.deps == nil || a.deps.FavoritesUC == nil {
120 return fmt.Errorf("favorites unavailable: usecase not configured")120 return fmt.Errorf("favorites unavailable: usecase not configured")
121 }121 }
122- _, wv := a.activeWebViewForBrowserWindow(a.lastFocusedBrowserWindow())122+ bw := a.lastFocusedBrowserWindow()
123+ _, wv := a.activeWebViewForBrowserWindow(bw)
123 if wv == nil {124 if wv == nil {
124 return fmt.Errorf("favorites unavailable: no active webview")125 return fmt.Errorf("favorites unavailable: no active webview")
125 }126 }
@@ -127,10 +128,16 @@ func (a *App) toggleCurrentPageFavoriteAction(ctx context.Context) error {
127 if uri == "" {128 if uri == "" {
128 return fmt.Errorf("favorites unavailable: active page has no URI")129 return fmt.Errorf("favorites unavailable: active page has no URI")
129 }130 }
130- _, err := a.deps.FavoritesUC.Toggle(ctx, uri, wv.Title())131+ result, err := a.deps.FavoritesUC.Toggle(ctx, uri, wv.Title())
131 if err != nil {132 if err != nil {
132 return err133 return err
133 }134 }
135+ if result != nil && strings.TrimSpace(result.Message) != "" {
136+ a.showToastOnBrowserWindow(ctx, bw, result.Message, component.ToastSuccess,
137+ component.WithDuration(component.ToastBriefDurationMs),
138+ component.WithPosition(component.ToastPositionBottomRight),
139+ )
140+ }
134 a.reloadVisibleFavoritesSidebars("current-page-favorite-toggle")141 a.reloadVisibleFavoritesSidebars("current-page-favorite-toggle")
135 return nil142 return nil
136 }143 }
M internal/ui/dispatcher/keyboard.go +1 -1
@@ -295,7 +295,7 @@ func (d *KeyboardDispatcher) initActionHandlers() {
295295 }
296296 return d.onToggleHistorySidebar(ctx)
297297 },
298- input.ActionToggleFavoritesSystemView: func(ctx context.Context) error {
298+ input.ActionToggleFavoritesSidebar: func(ctx context.Context) error {
299299 if d.onToggleFavoritesSidebar == nil {
300300 return fmt.Errorf("favorites sidebar unavailable: toggle handler not wired")
301301 }
@@ -295,7 +295,7 @@ func (d *KeyboardDispatcher) initActionHandlers() {
295 }295 }
296 return d.onToggleHistorySidebar(ctx)296 return d.onToggleHistorySidebar(ctx)
297 },297 },
298- input.ActionToggleFavoritesSystemView: func(ctx context.Context) error {298+ input.ActionToggleFavoritesSidebar: func(ctx context.Context) error {
299 if d.onToggleFavoritesSidebar == nil {299 if d.onToggleFavoritesSidebar == nil {
300 return fmt.Errorf("favorites sidebar unavailable: toggle handler not wired")300 return fmt.Errorf("favorites sidebar unavailable: toggle handler not wired")
301 }301 }
M internal/ui/dispatcher/keyboard_test.go +3 -3
@@ -122,19 +122,19 @@ func TestKeyboardDispatcher_ToggleFavoritesSidebarCallsCallbackAndPropagatesErro
122122 ctx := context.Background()
123123 d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
124124
125- missingErr := d.Dispatch(ctx, input.ActionToggleFavoritesSystemView)
125+ missingErr := d.Dispatch(ctx, input.ActionToggleFavoritesSidebar)
126126 require.Error(t, missingErr)
127127 require.ErrorContains(t, missingErr, "favorites sidebar unavailable")
128128
129129 wantErr := fmt.Errorf("favorites failed")
130130 d.SetOnToggleFavoritesSidebar(func(context.Context) error { return wantErr })
131- err := d.Dispatch(ctx, input.ActionToggleFavoritesSystemView)
131+ err := d.Dispatch(ctx, input.ActionToggleFavoritesSidebar)
132132 require.Error(t, err)
133133 require.ErrorIs(t, err, wantErr)
134134
135135 var called bool
136136 d.SetOnToggleFavoritesSidebar(func(context.Context) error { called = true; return nil })
137- require.NoError(t, d.Dispatch(ctx, input.ActionToggleFavoritesSystemView))
137+ require.NoError(t, d.Dispatch(ctx, input.ActionToggleFavoritesSidebar))
138138 assert.True(t, called)
139139 }
140140
@@ -122,19 +122,19 @@ func TestKeyboardDispatcher_ToggleFavoritesSidebarCallsCallbackAndPropagatesErro
122 ctx := context.Background()122 ctx := context.Background()
123 d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })123 d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
124 124
125- missingErr := d.Dispatch(ctx, input.ActionToggleFavoritesSystemView)125+ missingErr := d.Dispatch(ctx, input.ActionToggleFavoritesSidebar)
126 require.Error(t, missingErr)126 require.Error(t, missingErr)
127 require.ErrorContains(t, missingErr, "favorites sidebar unavailable")127 require.ErrorContains(t, missingErr, "favorites sidebar unavailable")
128 128
129 wantErr := fmt.Errorf("favorites failed")129 wantErr := fmt.Errorf("favorites failed")
130 d.SetOnToggleFavoritesSidebar(func(context.Context) error { return wantErr })130 d.SetOnToggleFavoritesSidebar(func(context.Context) error { return wantErr })
131- err := d.Dispatch(ctx, input.ActionToggleFavoritesSystemView)131+ err := d.Dispatch(ctx, input.ActionToggleFavoritesSidebar)
132 require.Error(t, err)132 require.Error(t, err)
133 require.ErrorIs(t, err, wantErr)133 require.ErrorIs(t, err, wantErr)
134 134
135 var called bool135 var called bool
136 d.SetOnToggleFavoritesSidebar(func(context.Context) error { called = true; return nil })136 d.SetOnToggleFavoritesSidebar(func(context.Context) error { called = true; return nil })
137- require.NoError(t, d.Dispatch(ctx, input.ActionToggleFavoritesSystemView))137+ require.NoError(t, d.Dispatch(ctx, input.ActionToggleFavoritesSidebar))
138 assert.True(t, called)138 assert.True(t, called)
139 }139 }
140 140
M internal/ui/input/global_shortcuts.go +3 -3
@@ -516,8 +516,8 @@ func globalShortcutActionMap() map[string]Action {
516516 "consume-or-expel-down": ActionConsumeOrExpelDown,
517517 "toggle_history_systemview": ActionToggleHistorySystemView,
518518 "toggle-history-systemview": ActionToggleHistorySystemView,
519- "toggle_favorites_systemview": ActionToggleFavoritesSystemView,
520- "toggle-favorites-systemview": ActionToggleFavoritesSystemView,
519+ "toggle_favorites_sidebar": ActionToggleFavoritesSidebar,
520+ "toggle-favorites-sidebar": ActionToggleFavoritesSidebar,
521521 "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
522522 "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
523523 "toggle_config_systemview": ActionToggleConfigSystemView,
@@ -733,7 +733,7 @@ func isRepeatedGlobalShortcutSuppressed(action Action) bool {
733733 ActionToggleFullscreen,
734734 ActionToggleFloatingPane,
735735 ActionToggleHistorySystemView,
736- ActionToggleFavoritesSystemView,
736+ ActionToggleFavoritesSidebar,
737737 ActionToggleCurrentPageFavorite,
738738 ActionToggleConfigSystemView,
739739 ActionCopyURL,
@@ -516,8 +516,8 @@ func globalShortcutActionMap() map[string]Action {
516 "consume-or-expel-down": ActionConsumeOrExpelDown,516 "consume-or-expel-down": ActionConsumeOrExpelDown,
517 "toggle_history_systemview": ActionToggleHistorySystemView,517 "toggle_history_systemview": ActionToggleHistorySystemView,
518 "toggle-history-systemview": ActionToggleHistorySystemView,518 "toggle-history-systemview": ActionToggleHistorySystemView,
519- "toggle_favorites_systemview": ActionToggleFavoritesSystemView,519+ "toggle_favorites_sidebar": ActionToggleFavoritesSidebar,
520- "toggle-favorites-systemview": ActionToggleFavoritesSystemView,520+ "toggle-favorites-sidebar": ActionToggleFavoritesSidebar,
521 "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,521 "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
522 "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,522 "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
523 "toggle_config_systemview": ActionToggleConfigSystemView,523 "toggle_config_systemview": ActionToggleConfigSystemView,
@@ -733,7 +733,7 @@ func isRepeatedGlobalShortcutSuppressed(action Action) bool {
733 ActionToggleFullscreen,733 ActionToggleFullscreen,
734 ActionToggleFloatingPane,734 ActionToggleFloatingPane,
735 ActionToggleHistorySystemView,735 ActionToggleHistorySystemView,
736- ActionToggleFavoritesSystemView,736+ ActionToggleFavoritesSidebar,
737 ActionToggleCurrentPageFavorite,737 ActionToggleCurrentPageFavorite,
738 ActionToggleConfigSystemView,738 ActionToggleConfigSystemView,
739 ActionCopyURL,739 ActionCopyURL,
M internal/ui/input/global_shortcuts_test.go +1 -1
@@ -251,7 +251,7 @@ func TestGlobalShortcutHandlerSuppressesRepeatedAdditionalOneShotUIActions(t *te
251251 ActionFindPrev,
252252 ActionOpenDevTools,
253253 ActionToggleFloatingPane,
254- ActionToggleFavoritesSystemView,
254+ ActionToggleFavoritesSidebar,
255255 ActionToggleCurrentPageFavorite,
256256 ActionToggleConfigSystemView,
257257 ActionCopyURL,
@@ -251,7 +251,7 @@ func TestGlobalShortcutHandlerSuppressesRepeatedAdditionalOneShotUIActions(t *te
251 ActionFindPrev,251 ActionFindPrev,
252 ActionOpenDevTools,252 ActionOpenDevTools,
253 ActionToggleFloatingPane,253 ActionToggleFloatingPane,
254- ActionToggleFavoritesSystemView,254+ ActionToggleFavoritesSidebar,
255 ActionToggleCurrentPageFavorite,255 ActionToggleCurrentPageFavorite,
256 ActionToggleConfigSystemView,256 ActionToggleConfigSystemView,
257 ActionCopyURL,257 ActionCopyURL,
M internal/ui/input/shortcuts.go +3 -3
@@ -190,7 +190,7 @@ const (
190190 ActionToggleFullscreen Action = "toggle_fullscreen"
191191 ActionToggleFloatingPane Action = "toggle_floating_pane"
192192 ActionToggleHistorySystemView Action = "toggle_history_systemview"
193- ActionToggleFavoritesSystemView Action = "toggle_favorites_systemview"
193+ ActionToggleFavoritesSidebar Action = "toggle_favorites_sidebar"
194194 ActionToggleCurrentPageFavorite Action = "toggle_current_page_favorite"
195195 ActionToggleConfigSystemView Action = "toggle_config_systemview"
196196
@@ -608,8 +608,8 @@ var configActionToAction = map[string]Action{
608608 "toggle-floating-pane": ActionToggleFloatingPane,
609609 "toggle_history_systemview": ActionToggleHistorySystemView,
610610 "toggle-history-systemview": ActionToggleHistorySystemView,
611- "toggle_favorites_systemview": ActionToggleFavoritesSystemView,
612- "toggle-favorites-systemview": ActionToggleFavoritesSystemView,
611+ "toggle_favorites_sidebar": ActionToggleFavoritesSidebar,
612+ "toggle-favorites-sidebar": ActionToggleFavoritesSidebar,
613613 "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
614614 "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
615615 "toggle_config_systemview": ActionToggleConfigSystemView,
@@ -190,7 +190,7 @@ const (
190 ActionToggleFullscreen Action = "toggle_fullscreen"190 ActionToggleFullscreen Action = "toggle_fullscreen"
191 ActionToggleFloatingPane Action = "toggle_floating_pane"191 ActionToggleFloatingPane Action = "toggle_floating_pane"
192 ActionToggleHistorySystemView Action = "toggle_history_systemview"192 ActionToggleHistorySystemView Action = "toggle_history_systemview"
193- ActionToggleFavoritesSystemView Action = "toggle_favorites_systemview"193+ ActionToggleFavoritesSidebar Action = "toggle_favorites_sidebar"
194 ActionToggleCurrentPageFavorite Action = "toggle_current_page_favorite"194 ActionToggleCurrentPageFavorite Action = "toggle_current_page_favorite"
195 ActionToggleConfigSystemView Action = "toggle_config_systemview"195 ActionToggleConfigSystemView Action = "toggle_config_systemview"
196 196
@@ -608,8 +608,8 @@ var configActionToAction = map[string]Action{
608 "toggle-floating-pane": ActionToggleFloatingPane,608 "toggle-floating-pane": ActionToggleFloatingPane,
609 "toggle_history_systemview": ActionToggleHistorySystemView,609 "toggle_history_systemview": ActionToggleHistorySystemView,
610 "toggle-history-systemview": ActionToggleHistorySystemView,610 "toggle-history-systemview": ActionToggleHistorySystemView,
611- "toggle_favorites_systemview": ActionToggleFavoritesSystemView,611+ "toggle_favorites_sidebar": ActionToggleFavoritesSidebar,
612- "toggle-favorites-systemview": ActionToggleFavoritesSystemView,612+ "toggle-favorites-sidebar": ActionToggleFavoritesSidebar,
613 "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,613 "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
614 "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,614 "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
615 "toggle_config_systemview": ActionToggleConfigSystemView,615 "toggle_config_systemview": ActionToggleConfigSystemView,
M internal/ui/input/shortcuts_test.go +2 -2
@@ -403,7 +403,7 @@ func TestMapConfigAction_ToggleSystemViews(t *testing.T) {
403403 want Action
404404 }{
405405 {name: "toggle_history_systemview", want: ActionToggleHistorySystemView},
406- {name: "toggle_favorites_systemview", want: ActionToggleFavoritesSystemView},
406+ {name: "toggle_favorites_sidebar", want: ActionToggleFavoritesSidebar},
407407 {name: "toggle_current_page_favorite", want: ActionToggleCurrentPageFavorite},
408408 {name: "toggle_config_systemview", want: ActionToggleConfigSystemView},
409409 }
@@ -423,7 +423,7 @@ func TestMapConfigAction_ToggleSystemViewsHyphenAlias(t *testing.T) {
423423 want Action
424424 }{
425425 {name: "toggle-history-systemview", want: ActionToggleHistorySystemView},
426- {name: "toggle-favorites-systemview", want: ActionToggleFavoritesSystemView},
426+ {name: "toggle-favorites-sidebar", want: ActionToggleFavoritesSidebar},
427427 {name: "toggle-current-page-favorite", want: ActionToggleCurrentPageFavorite},
428428 {name: "toggle-config-systemview", want: ActionToggleConfigSystemView},
429429 }
@@ -403,7 +403,7 @@ func TestMapConfigAction_ToggleSystemViews(t *testing.T) {
403 want Action403 want Action
404 }{404 }{
405 {name: "toggle_history_systemview", want: ActionToggleHistorySystemView},405 {name: "toggle_history_systemview", want: ActionToggleHistorySystemView},
406- {name: "toggle_favorites_systemview", want: ActionToggleFavoritesSystemView},406+ {name: "toggle_favorites_sidebar", want: ActionToggleFavoritesSidebar},
407 {name: "toggle_current_page_favorite", want: ActionToggleCurrentPageFavorite},407 {name: "toggle_current_page_favorite", want: ActionToggleCurrentPageFavorite},
408 {name: "toggle_config_systemview", want: ActionToggleConfigSystemView},408 {name: "toggle_config_systemview", want: ActionToggleConfigSystemView},
409 }409 }
@@ -423,7 +423,7 @@ func TestMapConfigAction_ToggleSystemViewsHyphenAlias(t *testing.T) {
423 want Action423 want Action
424 }{424 }{
425 {name: "toggle-history-systemview", want: ActionToggleHistorySystemView},425 {name: "toggle-history-systemview", want: ActionToggleHistorySystemView},
426- {name: "toggle-favorites-systemview", want: ActionToggleFavoritesSystemView},426+ {name: "toggle-favorites-sidebar", want: ActionToggleFavoritesSidebar},
427 {name: "toggle-current-page-favorite", want: ActionToggleCurrentPageFavorite},427 {name: "toggle-current-page-favorite", want: ActionToggleCurrentPageFavorite},
428 {name: "toggle-config-systemview", want: ActionToggleConfigSystemView},428 {name: "toggle-config-systemview", want: ActionToggleConfigSystemView},
429 }429 }

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