feat: persist desktop language preference

This commit is contained in:
mirivlad 2026-07-11 02:00:10 +08:00
parent e412cfc45a
commit 15f1f413d0
4 changed files with 134 additions and 2 deletions

View File

@ -1621,6 +1621,7 @@ func (a *App) GetAppSettings() map[string]interface{} {
"currentVaultPath": cfg.CurrentVaultPath, "currentVaultPath": cfg.CurrentVaultPath,
"recentVaults": cfg.RecentVaults, "recentVaults": cfg.RecentVaults,
"theme": cfg.Theme, "theme": cfg.Theme,
"language": cfg.Language,
"devMode": cfg.DevMode, "devMode": cfg.DevMode,
"userPluginsDir": cfg.UserPluginsDir, "userPluginsDir": cfg.UserPluginsDir,
"lastOpenedAt": cfg.LastOpenedAt, "lastOpenedAt": cfg.LastOpenedAt,
@ -1634,18 +1635,33 @@ func (a *App) UpdateAppSettings(patch map[string]interface{}) string {
} }
cfg := &appsettings.Config{} cfg := &appsettings.Config{}
hasConfigPatch := false
if v, ok := patch["theme"].(string); ok && v != "" { if v, ok := patch["theme"].(string); ok && v != "" {
cfg.Theme = v cfg.Theme = v
hasConfigPatch = true
} }
if v, ok := patch["devMode"].(bool); ok { if v, ok := patch["devMode"].(bool); ok {
cfg.DevMode = v cfg.DevMode = v
hasConfigPatch = true
} }
if v, ok := patch["userPluginsDir"].(string); ok && v != "" { if v, ok := patch["userPluginsDir"].(string); ok && v != "" {
cfg.UserPluginsDir = v cfg.UserPluginsDir = v
hasConfigPatch = true
} }
if err := a.appSettings.Update(cfg); err != nil { if hasConfigPatch {
return err.Error() if err := a.appSettings.Update(cfg); err != nil {
return err.Error()
}
}
if value, exists := patch["language"]; exists {
language, ok := value.(string)
if !ok {
return "language must be a string"
}
if err := a.appSettings.UpdateLanguage(language); err != nil {
return err.Error()
}
} }
return "" return ""
} }

View File

@ -1294,6 +1294,34 @@ func assertSyncState(t *testing.T, service *syncsvc.Service, wantDeviceID, wantS
} }
} }
func TestAppSettingsLanguageAPI(t *testing.T) {
settings := appsettings.NewManager(filepath.Join(t.TempDir(), "config.json"))
if err := settings.Load(); err != nil {
t.Fatal(err)
}
if err := settings.Update(&appsettings.Config{Theme: "light", DevMode: true}); err != nil {
t.Fatal(err)
}
app := &App{appSettings: settings}
if got := app.GetAppSettings()["language"]; got != "system" {
t.Fatalf("initial language = %#v, want system", got)
}
if errStr := app.UpdateAppSettings(map[string]interface{}{"language": "ru"}); errStr != "" {
t.Fatalf("UpdateAppSettings language: %s", errStr)
}
got := app.GetAppSettings()
if got["language"] != "ru" || got["theme"] != "light" || got["devMode"] != true {
t.Fatalf("settings after language update = %#v", got)
}
if errStr := app.UpdateAppSettings(map[string]interface{}{"language": "de"}); errStr == "" {
t.Fatal("UpdateAppSettings accepted unsupported language")
}
if got := app.GetAppSettings()["language"]; got != "ru" {
t.Fatalf("language after rejected update = %#v, want ru", got)
}
}
func TestSetCurrentVaultInitializesWorkspaceWhenMissingAtStartup(t *testing.T) { func TestSetCurrentVaultInitializesWorkspaceWhenMissingAtStartup(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
vaultParent := filepath.Join(tmpDir, "vault-parent") vaultParent := filepath.Join(tmpDir, "vault-parent")

View File

@ -21,6 +21,7 @@ type Config struct {
CurrentVaultPath string `json:"currentVaultPath"` CurrentVaultPath string `json:"currentVaultPath"`
RecentVaults []string `json:"recentVaults"` RecentVaults []string `json:"recentVaults"`
Theme string `json:"theme"` Theme string `json:"theme"`
Language string `json:"language"`
DevMode bool `json:"devMode"` DevMode bool `json:"devMode"`
UserPluginsDir string `json:"userPluginsDir"` UserPluginsDir string `json:"userPluginsDir"`
Workbench WorkbenchPreferences `json:"workbench,omitempty"` Workbench WorkbenchPreferences `json:"workbench,omitempty"`
@ -30,6 +31,21 @@ type Config struct {
LastOpenedAt string `json:"lastOpenedAt"` LastOpenedAt string `json:"lastOpenedAt"`
} }
const (
LanguageSystem = "system"
LanguageEnglish = "en"
LanguageRussian = "ru"
)
func validLanguage(language string) bool {
switch language {
case LanguageSystem, LanguageEnglish, LanguageRussian:
return true
default:
return false
}
}
type WorkbenchPreferences struct { type WorkbenchPreferences struct {
DefaultTextEditorProvider string `json:"defaultTextEditorProvider,omitempty"` DefaultTextEditorProvider string `json:"defaultTextEditorProvider,omitempty"`
DefaultMarkdownEditorProvider string `json:"defaultMarkdownEditorProvider,omitempty"` DefaultMarkdownEditorProvider string `json:"defaultMarkdownEditorProvider,omitempty"`
@ -121,6 +137,9 @@ func (m *Manager) Load() error {
if cfg.Theme == "" { if cfg.Theme == "" {
cfg.Theme = "dark" cfg.Theme = "dark"
} }
if !validLanguage(cfg.Language) {
cfg.Language = LanguageSystem
}
if cfg.RecentVaults == nil { if cfg.RecentVaults == nil {
cfg.RecentVaults = []string{} cfg.RecentVaults = []string{}
} }
@ -179,6 +198,9 @@ func (m *Manager) Update(patch *Config) error {
if patch.Theme != "" { if patch.Theme != "" {
m.config.Theme = patch.Theme m.config.Theme = patch.Theme
} }
if patch.Language != "" && validLanguage(patch.Language) {
m.config.Language = patch.Language
}
if patch.UserPluginsDir != "" { if patch.UserPluginsDir != "" {
m.config.UserPluginsDir = patch.UserPluginsDir m.config.UserPluginsDir = patch.UserPluginsDir
} }
@ -200,6 +222,23 @@ func (m *Manager) Update(patch *Config) error {
return m.saveLocked() return m.saveLocked()
} }
// UpdateLanguage validates and persists the installation-local UI language.
func (m *Manager) UpdateLanguage(language string) error {
language = strings.TrimSpace(language)
if !validLanguage(language) {
return fmt.Errorf("unsupported language %q", language)
}
m.mu.Lock()
defer m.mu.Unlock()
if m.config == nil {
m.config = defaultConfig()
}
m.config.Language = language
m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339)
return m.saveLocked()
}
// UpdateSync replaces sync settings without changing unrelated app settings. // UpdateSync replaces sync settings without changing unrelated app settings.
func (m *Manager) UpdateSync(syncSettings SyncSettings) error { func (m *Manager) UpdateSync(syncSettings SyncSettings) error {
m.mu.Lock() m.mu.Lock()
@ -287,6 +326,7 @@ func defaultConfig() *Config {
CurrentVaultPath: "", CurrentVaultPath: "",
RecentVaults: []string{}, RecentVaults: []string{},
Theme: "dark", Theme: "dark",
Language: LanguageSystem,
DevMode: false, DevMode: false,
UserPluginsDir: filepath.Join(os.Getenv("HOME"), ".config", "verstak", "plugins"), UserPluginsDir: filepath.Join(os.Getenv("HOME"), ".config", "verstak", "plugins"),
Workbench: WorkbenchPreferences{}, Workbench: WorkbenchPreferences{},
@ -303,6 +343,7 @@ func copyConfig(c *Config) *Config {
CurrentVaultPath: c.CurrentVaultPath, CurrentVaultPath: c.CurrentVaultPath,
RecentVaults: recent, RecentVaults: recent,
Theme: c.Theme, Theme: c.Theme,
Language: c.Language,
DevMode: c.DevMode, DevMode: c.DevMode,
UserPluginsDir: c.UserPluginsDir, UserPluginsDir: c.UserPluginsDir,
Workbench: c.Workbench, Workbench: c.Workbench,

View File

@ -25,6 +25,53 @@ func TestLoad_DefaultCreation(t *testing.T) {
if cfg.CurrentVaultPath != "" { if cfg.CurrentVaultPath != "" {
t.Errorf("CurrentVaultPath: got %q, want empty", cfg.CurrentVaultPath) t.Errorf("CurrentVaultPath: got %q, want empty", cfg.CurrentVaultPath)
} }
if cfg.Language != "system" {
t.Errorf("Language: got %q, want %q", cfg.Language, "system")
}
}
func TestLoad_MissingLanguageDefaultsToSystem(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"schemaVersion":1,"theme":"dark","devMode":true}`), 0o600); err != nil {
t.Fatal(err)
}
m := NewManager(path)
if err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
if got := m.Get().Language; got != "system" {
t.Fatalf("Language = %q, want system", got)
}
}
func TestUpdateLanguagePersistsAndPreservesUnrelatedSettings(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
m := NewManager(path)
if err := m.Load(); err != nil {
t.Fatal(err)
}
if err := m.Update(&Config{Theme: "light", DevMode: true}); err != nil {
t.Fatal(err)
}
if err := m.UpdateLanguage("ru"); err != nil {
t.Fatalf("UpdateLanguage: %v", err)
}
reloaded := NewManager(path)
if err := reloaded.Load(); err != nil {
t.Fatal(err)
}
cfg := reloaded.Get()
if cfg.Language != "ru" || cfg.Theme != "light" || !cfg.DevMode {
t.Fatalf("settings after language update = %+v", cfg)
}
if err := reloaded.UpdateLanguage("de"); err == nil {
t.Fatal("UpdateLanguage(de) succeeded, want validation error")
}
if got := reloaded.Get().Language; got != "ru" {
t.Fatalf("Language after rejected update = %q, want ru", got)
}
} }
func TestLoad_CorruptConfig(t *testing.T) { func TestLoad_CorruptConfig(t *testing.T) {