diff --git a/internal/adapters/ui/handlers.go b/internal/adapters/ui/handlers.go index 897e053..20870e8 100644 --- a/internal/adapters/ui/handlers.go +++ b/internal/adapters/ui/handlers.go @@ -118,6 +118,7 @@ func (t *tui) handleSortToggle() { t.sortMode = t.sortMode.ToggleField() t.showStatusTemp("Sort: " + t.sortMode.String()) t.updateListTitle() + t.persistSortMode() t.refreshServerList() } @@ -125,6 +126,7 @@ func (t *tui) handleSortReverse() { t.sortMode = t.sortMode.Reverse() t.showStatusTemp("Sort: " + t.sortMode.String()) t.updateListTitle() + t.persistSortMode() t.refreshServerList() } diff --git a/internal/adapters/ui/settings.go b/internal/adapters/ui/settings.go new file mode 100644 index 0000000..fbc00a7 --- /dev/null +++ b/internal/adapters/ui/settings.go @@ -0,0 +1,116 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ui + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + + "go.uber.org/zap" +) + +type settingsManager struct { + filePath string + logger *zap.SugaredLogger +} + +type uiSettings struct { + SortMode SortMode `json:"sort_mode,omitempty"` +} + +func newSettingsManager(logger *zap.SugaredLogger) *settingsManager { + home, err := os.UserHomeDir() + if err != nil { + logger.Warnw("failed to determine home directory for settings", "error", err) + return nil + } + + return &settingsManager{ + filePath: filepath.Join(home, ".lazyssh", "settings.json"), + logger: logger, + } +} + +func (m *settingsManager) LoadSortMode() (SortMode, error) { + if m == nil { + return SortByAliasAsc, errors.New("nil settings manager") + } + + settings, err := m.load() + if err != nil { + return SortByAliasAsc, err + } + + if !settings.SortMode.valid() { + return SortByAliasAsc, nil + } + + return settings.SortMode, nil +} + +func (m *settingsManager) SaveSortMode(mode SortMode) error { + if m == nil { + return errors.New("nil settings manager") + } + + settings, err := m.load() + if err != nil { + return err + } + + settings.SortMode = mode + return m.save(settings) +} + +func (m *settingsManager) load() (uiSettings, error) { + var settings uiSettings + + data, err := os.ReadFile(m.filePath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return settings, nil + } + return settings, err + } + + if len(data) == 0 { + return settings, nil + } + + if err := json.Unmarshal(data, &settings); err != nil { + // Handle unmarshal errors (e.g., old string format vs new int format) + // by returning default settings. This allows automatic migration from + // old format to new format when SaveSortMode is called. + m.logger.Warnw("failed to parse settings file, using defaults", "error", err, "path", m.filePath) + return uiSettings{}, nil + } + + return settings, nil +} + +func (m *settingsManager) save(settings uiSettings) error { + if err := os.MkdirAll(filepath.Dir(m.filePath), 0o750); err != nil { + return err + } + + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return err + } + + return os.WriteFile(m.filePath, data, 0o600) +} diff --git a/internal/adapters/ui/sort.go b/internal/adapters/ui/sort.go index 58bc1e7..f9bddc9 100644 --- a/internal/adapters/ui/sort.go +++ b/internal/adapters/ui/sort.go @@ -78,6 +78,18 @@ func (m SortMode) Reverse() SortMode { } } +func (m SortMode) valid() bool { + switch m { + case SortByAliasAsc, + SortByAliasDesc, + SortByLastSeenAsc, + SortByLastSeenDesc: + return true + default: + return false + } +} + // sortServersForUI sorts servers according to the rules required by the UI. // Pinned servers are always at the top, ordered by pinned date (newest first). // Unpinned servers are sorted by the selected mode. "Never" (zero time) goes to diff --git a/internal/adapters/ui/tui.go b/internal/adapters/ui/tui.go index d938e6f..84e5d24 100644 --- a/internal/adapters/ui/tui.go +++ b/internal/adapters/ui/tui.go @@ -34,6 +34,7 @@ type tui struct { app *tview.Application serverService ports.ServerService + settings *settingsManager header *AppHeader searchBar *SearchBar @@ -55,6 +56,7 @@ func NewTUI(logger *zap.SugaredLogger, ss ports.ServerService, version, commit s serverService: ss, version: version, commit: commit, + settings: newSettingsManager(logger), } } @@ -65,7 +67,7 @@ func (t *tui) Run() error { } }() t.app.EnableMouse(true) - t.initializeTheme().buildComponents().buildLayout().bindEvents().loadInitialData() + t.initializeTheme().buildComponents().loadPreferences().buildLayout().bindEvents().loadInitialData() t.app.SetRoot(t.root, true) t.logger.Infow("starting TUI application", "version", t.version, "commit", t.commit) if err := t.app.Run(); err != nil { @@ -107,6 +109,20 @@ func (t *tui) buildComponents() *tui { return t } +func (t *tui) loadPreferences() *tui { + if t.settings == nil { + return t + } + + if mode, err := t.settings.LoadSortMode(); err == nil { + t.sortMode = mode + } else { + t.logger.Warnw("failed to load sort mode preference", "error", err) + } + + return t +} + func (t *tui) buildLayout() *tui { t.left = tview.NewFlex().SetDirection(tview.FlexRow). AddItem(t.searchBar, 3, 0, false). @@ -145,3 +161,13 @@ func (t *tui) updateListTitle() { t.serverList.SetTitle(" Servers — Sort: " + t.sortMode.String() + " ") } } + +func (t *tui) persistSortMode() { + if t.settings == nil { + return + } + + if err := t.settings.SaveSortMode(t.sortMode); err != nil { + t.logger.Warnw("failed to save sort mode preference", "error", err) + } +}