diff --git a/internal/infrastructure/config/loader_actions_test.go b/internal/infrastructure/config/loader_actions_test.go index 12c8967d..dfbdc7e1 100644 --- a/internal/infrastructure/config/loader_actions_test.go +++ b/internal/infrastructure/config/loader_actions_test.go @@ -71,3 +71,36 @@ desc = "Custom stack pane" stack := cfg.Workspace.PaneMode.Actions["stack-pane"] assert.Equal(t, "Custom stack pane", stack.Desc) } + +func TestManagerLoad_UpgradesObsoleteFavoritesShortcutDefaultInMemory(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share")) + t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + + configFile, err := GetConfigFile() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755)) + require.NoError(t, os.WriteFile(configFile, []byte(` +[workspace.shortcuts.actions.toggle-favorites-systemview] +keys = [] +desc = "Toggle Favorites in right split" +`), 0o644)) + + mgr, err := NewManager() + require.NoError(t, err) + require.NoError(t, mgr.Load()) + + cfg := mgr.Get() + require.NotNil(t, cfg) + favorites := cfg.Workspace.Shortcuts.Actions["toggle-favorites-systemview"] + assert.Equal(t, []string{"ctrl+b"}, favorites.Keys) + assert.Equal(t, "Toggle Favorites sidebar", favorites.Desc) + currentPage, ok := cfg.Workspace.Shortcuts.Actions["toggle-current-page-favorite"] + require.True(t, ok) + expectedCurrentPage := DefaultConfig().Workspace.Shortcuts.Actions["toggle-current-page-favorite"] + assert.Equal(t, expectedCurrentPage.Keys, currentPage.Keys) + assert.Equal(t, expectedCurrentPage.Desc, currentPage.Desc) +} diff --git a/internal/infrastructure/config/migrate.go b/internal/infrastructure/config/migrate.go index 3ea166bd..b0e87416 100644 --- a/internal/infrastructure/config/migrate.go +++ b/internal/infrastructure/config/migrate.go @@ -58,17 +58,22 @@ func (m *Migrator) CheckMigration() (*port.MigrationResult, error) { return nil, nil } - // Get user-defined keys from the TOML file - userKeys, err := m.getUserConfigKeys(configFile) + userKeysWithValues, err := m.getUserConfigKeysWithValues(configFile) if err != nil { return nil, fmt.Errorf("failed to parse user config: %w", err) } + userKeys := make(map[string]bool, len(userKeysWithValues)) + for key := range userKeysWithValues { + userKeys[key] = true + } + // Get all default keys defaultKeys := m.getAllDefaultKeys() // Find missing keys (in defaults but not in user config) missingKeys := m.findMissingKeys(defaultKeys, userKeys) + missingKeys = appendUniqueKeys(missingKeys, m.detectMissingDefaultActions(userKeysWithValues)) if len(missingKeys) == 0 { return nil, nil @@ -854,11 +859,17 @@ func (m *Migrator) detectMissingDefaultActions(userKeysWithValues map[string]any } for actionName := range actionMap.actions { - if _, exists := userActions[actionName]; exists { + if actionValue, exists := userActions[actionName]; exists { + if m.isObsoleteDefaultAction(actionMap.key, actionName, actionValue) { + missing = append(missing, actionMap.key+"."+actionName) + } continue } legacyActionName := strings.ReplaceAll(actionName, "-", "_") - if _, exists := userActions[legacyActionName]; exists { + if actionValue, exists := userActions[legacyActionName]; exists { + if m.isObsoleteDefaultAction(actionMap.key, actionName, actionValue) { + missing = append(missing, actionMap.key+"."+actionName) + } continue } missing = append(missing, actionMap.key+"."+actionName) @@ -883,11 +894,18 @@ func (m *Migrator) mergeMissingDefaultActions(rawConfig map[string]any) { } for actionName, defaultAction := range actionMap.actions { - if _, exists := userActions[actionName]; exists { + if actionValue, exists := userActions[actionName]; exists { + if m.isObsoleteDefaultAction(actionMap.key, actionName, actionValue) { + userActions[actionName] = defaultAction + } continue } legacyActionName := strings.ReplaceAll(actionName, "-", "_") - if _, exists := userActions[legacyActionName]; exists { + if actionValue, exists := userActions[legacyActionName]; exists { + if m.isObsoleteDefaultAction(actionMap.key, actionName, actionValue) { + delete(userActions, legacyActionName) + userActions[actionName] = defaultAction + } continue } userActions[actionName] = defaultAction @@ -897,6 +915,83 @@ func (m *Migrator) mergeMissingDefaultActions(rawConfig map[string]any) { } } +func (m *Migrator) isObsoleteDefaultAction(actionMapKey, actionName string, actionValue any) bool { + if actionMapKey != "workspace.shortcuts.actions" || actionName != "toggle-favorites-systemview" { + return false + } + binding, ok := m.actionBindingFromAny(actionValue) + if !ok { + return false + } + return len(binding.Keys) == 0 && strings.TrimSpace(binding.Desc) == "Toggle Favorites in right split" +} + +func (m *Migrator) actionBindingFromAny(value any) (entity.ActionBinding, bool) { + switch v := value.(type) { + case entity.ActionBinding: + return v, true + case map[string]any: + return actionBindingFromMap(v) + default: + mapping, ok := m.toStringAnyMap(value) + if !ok { + return entity.ActionBinding{}, false + } + return actionBindingFromMap(mapping) + } +} + +func actionBindingFromMap(m map[string]any) (entity.ActionBinding, bool) { + var binding entity.ActionBinding + if keysValue, exists := m["keys"]; exists { + keys, ok := stringSliceFromAny(keysValue) + if !ok { + return entity.ActionBinding{}, false + } + binding.Keys = keys + } + if descValue, exists := m["desc"]; exists { + desc, ok := descValue.(string) + if !ok { + return entity.ActionBinding{}, false + } + binding.Desc = desc + } + return binding, true +} + +func stringSliceFromAny(value any) ([]string, bool) { + switch v := value.(type) { + case []string: + return append([]string(nil), v...), true + case []any: + out := make([]string, 0, len(v)) + for _, item := range v { + s, ok := item.(string) + if !ok { + return nil, false + } + out = append(out, s) + } + return out, true + default: + rv := reflect.ValueOf(value) + if rv.Kind() != reflect.Slice { + return nil, false + } + out := make([]string, 0, rv.Len()) + for i := 0; i < rv.Len(); i++ { + item := rv.Index(i).Interface() + s, ok := item.(string) + if !ok { + return nil, false + } + out = append(out, s) + } + return out, true + } +} + func appendUniqueKeys(base, extra []string) []string { if len(extra) == 0 { return base diff --git a/internal/infrastructure/config/migrate_test.go b/internal/infrastructure/config/migrate_test.go index b98cbb1c..d71726bf 100644 --- a/internal/infrastructure/config/migrate_test.go +++ b/internal/infrastructure/config/migrate_test.go @@ -1282,3 +1282,65 @@ section: assert.InDelta(t, 42.0, section["number"].(float64), 0.001) }) } + +func TestMigrator_ReportsAndMigratesObsoleteFavoritesShortcutDefault(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share")) + t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + + configFile, err := GetConfigFile() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755)) + require.NoError(t, os.WriteFile(configFile, []byte(` +[workspace.shortcuts.actions.toggle-favorites-systemview] +keys = [] +desc = "Toggle Favorites in right split" +`), 0o644)) + + migrator := NewMigrator() + result, err := migrator.CheckMigration() + require.NoError(t, err) + require.NotNil(t, result) + assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.toggle-favorites-systemview") + assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.toggle-current-page-favorite") + + changes, err := migrator.DetectChanges() + require.NoError(t, err) + assertContainsChange(t, changes, port.KeyChangeAdded, "", "workspace.shortcuts.actions.toggle-favorites-systemview") + assertContainsChange(t, changes, port.KeyChangeAdded, "", "workspace.shortcuts.actions.toggle-current-page-favorite") + + applied, err := migrator.Migrate() + require.NoError(t, err) + assert.Contains(t, applied, "workspace.shortcuts.actions.toggle-favorites-systemview") + assert.Contains(t, applied, "workspace.shortcuts.actions.toggle-current-page-favorite") + + raw, err := migrator.readRawConfig(configFile) + require.NoError(t, err) + workspace, ok := raw["workspace"].(map[string]any) + require.True(t, ok) + shortcuts, ok := workspace["shortcuts"].(map[string]any) + require.True(t, ok) + actions, ok := shortcuts["actions"].(map[string]any) + require.True(t, ok) + + expectedActions := DefaultConfig().Workspace.Shortcuts.Actions + favorites, ok := actions["toggle-favorites-systemview"].(map[string]any) + require.True(t, ok) + assert.Equal(t, []any{"ctrl+b"}, favorites["keys"]) + assert.Equal(t, expectedActions["toggle-favorites-systemview"].Desc, favorites["desc"]) + currentPage, ok := actions["toggle-current-page-favorite"].(map[string]any) + require.True(t, ok) + assert.Equal(t, []any{"ctrl+d"}, currentPage["keys"]) + assert.Equal(t, expectedActions["toggle-current-page-favorite"].Desc, currentPage["desc"]) + + mgr, err := NewManager() + require.NoError(t, err) + require.NoError(t, mgr.Load()) + cfg := mgr.Get() + require.NotNil(t, cfg) + assert.Equal(t, []string{"ctrl+b"}, cfg.Workspace.Shortcuts.Actions["toggle-favorites-systemview"].Keys) + assert.Equal(t, []string{"ctrl+d"}, cfg.Workspace.Shortcuts.Actions["toggle-current-page-favorite"].Keys) +} diff --git a/internal/ui/browser_window_favorites_sidebar.go b/internal/ui/browser_window_favorites_sidebar.go index a3547ab6..850423e7 100644 --- a/internal/ui/browser_window_favorites_sidebar.go +++ b/internal/ui/browser_window_favorites_sidebar.go @@ -127,10 +127,16 @@ func (a *App) toggleCurrentPageFavoriteAction(ctx context.Context) error { if uri == "" { return fmt.Errorf("favorites unavailable: active page has no URI") } - _, err := a.deps.FavoritesUC.Toggle(ctx, uri, wv.Title()) + result, err := a.deps.FavoritesUC.Toggle(ctx, uri, wv.Title()) if err != nil { return err } + if result != nil && strings.TrimSpace(result.Message) != "" { + a.showToastOnLastFocusedBrowserWindow(ctx, result.Message, component.ToastSuccess, + component.WithDuration(component.ToastBriefDurationMs), + component.WithPosition(component.ToastPositionBottomRight), + ) + } a.reloadVisibleFavoritesSidebars("current-page-favorite-toggle") return nil }