From 3a4a2fc50815452113244c0c81d722b864d16e25 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Mar 2026 12:07:28 +0000 Subject: [PATCH] feat(window): add StateManager with JSON persistence Co-Authored-By: Claude Opus 4.6 --- pkg/window/state.go | 186 +++++++++++++++++++++++++++++++++++++- pkg/window/window_test.go | 78 ++++++++++++++++ 2 files changed, 259 insertions(+), 5 deletions(-) diff --git a/pkg/window/state.go b/pkg/window/state.go index ecb87eb..f6784aa 100644 --- a/pkg/window/state.go +++ b/pkg/window/state.go @@ -1,11 +1,187 @@ // pkg/window/state.go package window -// StateManager persists window positions to disk. -// Full implementation in Task 3. -type StateManager struct{} +import ( + "encoding/json" + "os" + "path/filepath" + "sync" + "time" +) -func NewStateManager() *StateManager { return &StateManager{} } +// WindowState holds the persisted position/size of a window. +// JSON tags match existing window_state.json format for backward compat. +type WindowState struct { + X int `json:"x,omitempty"` + Y int `json:"y,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Maximized bool `json:"maximized,omitempty"` + Screen string `json:"screen,omitempty"` + URL string `json:"url,omitempty"` + UpdatedAt int64 `json:"updatedAt,omitempty"` +} + +// StateManager persists window positions to ~/.config/Core/window_state.json. +type StateManager struct { + configDir string + states map[string]WindowState + mu sync.RWMutex + saveTimer *time.Timer +} + +// NewStateManager creates a StateManager loading from the default config directory. +func NewStateManager() *StateManager { + sm := &StateManager{ + states: make(map[string]WindowState), + } + configDir, err := os.UserConfigDir() + if err == nil { + sm.configDir = filepath.Join(configDir, "Core") + } + sm.load() + return sm +} + +func (sm *StateManager) filePath() string { + return filepath.Join(sm.configDir, "window_state.json") +} + +func (sm *StateManager) load() { + if sm.configDir == "" { + return + } + data, err := os.ReadFile(sm.filePath()) + if err != nil { + return + } + sm.mu.Lock() + defer sm.mu.Unlock() + _ = json.Unmarshal(data, &sm.states) +} + +func (sm *StateManager) save() { + if sm.configDir == "" { + return + } + sm.mu.RLock() + data, err := json.MarshalIndent(sm.states, "", " ") + sm.mu.RUnlock() + if err != nil { + return + } + _ = os.MkdirAll(sm.configDir, 0o755) + _ = os.WriteFile(sm.filePath(), data, 0o644) +} + +func (sm *StateManager) scheduleSave() { + if sm.saveTimer != nil { + sm.saveTimer.Stop() + } + sm.saveTimer = time.AfterFunc(500*time.Millisecond, sm.save) +} + +// GetState returns the saved state for a window name. +func (sm *StateManager) GetState(name string) (WindowState, bool) { + sm.mu.RLock() + defer sm.mu.RUnlock() + s, ok := sm.states[name] + return s, ok +} + +// SetState saves state for a window name (debounced disk write). +func (sm *StateManager) SetState(name string, state WindowState) { + state.UpdatedAt = time.Now().UnixMilli() + sm.mu.Lock() + sm.states[name] = state + sm.mu.Unlock() + sm.scheduleSave() +} + +// UpdatePosition updates only the position fields. +func (sm *StateManager) UpdatePosition(name string, x, y int) { + sm.mu.Lock() + s := sm.states[name] + s.X = x + s.Y = y + s.UpdatedAt = time.Now().UnixMilli() + sm.states[name] = s + sm.mu.Unlock() + sm.scheduleSave() +} + +// UpdateSize updates only the size fields. +func (sm *StateManager) UpdateSize(name string, width, height int) { + sm.mu.Lock() + s := sm.states[name] + s.Width = width + s.Height = height + s.UpdatedAt = time.Now().UnixMilli() + sm.states[name] = s + sm.mu.Unlock() + sm.scheduleSave() +} + +// UpdateMaximized updates the maximized flag. +func (sm *StateManager) UpdateMaximized(name string, maximized bool) { + sm.mu.Lock() + s := sm.states[name] + s.Maximized = maximized + s.UpdatedAt = time.Now().UnixMilli() + sm.states[name] = s + sm.mu.Unlock() + sm.scheduleSave() +} + +// CaptureState snapshots the current state from a PlatformWindow. +func (sm *StateManager) CaptureState(pw PlatformWindow) { + x, y := pw.Position() + w, h := pw.Size() + sm.SetState(pw.Name(), WindowState{ + X: x, Y: y, Width: w, Height: h, + Maximized: pw.IsMaximised(), + }) +} // ApplyState restores saved position/size to a Window descriptor. -func (sm *StateManager) ApplyState(w *Window) {} +func (sm *StateManager) ApplyState(w *Window) { + s, ok := sm.GetState(w.Name) + if !ok { + return + } + if s.Width > 0 { + w.Width = s.Width + } + if s.Height > 0 { + w.Height = s.Height + } + w.X = s.X + w.Y = s.Y +} + +// ListStates returns all stored window names. +func (sm *StateManager) ListStates() []string { + sm.mu.RLock() + defer sm.mu.RUnlock() + names := make([]string, 0, len(sm.states)) + for name := range sm.states { + names = append(names, name) + } + return names +} + +// Clear removes all stored states. +func (sm *StateManager) Clear() { + sm.mu.Lock() + sm.states = make(map[string]WindowState) + sm.mu.Unlock() + sm.scheduleSave() +} + +// ForceSync writes state to disk immediately. +func (sm *StateManager) ForceSync() { + if sm.saveTimer != nil { + sm.saveTimer.Stop() + } + sm.save() +} diff --git a/pkg/window/window_test.go b/pkg/window/window_test.go index 0a2e543..00d300c 100644 --- a/pkg/window/window_test.go +++ b/pkg/window/window_test.go @@ -141,3 +141,81 @@ func TestManager_Remove_Good(t *testing.T) { _, ok := m.Get("temp") assert.False(t, ok) } + +// --- StateManager Tests --- + +// newTestStateManager creates a clean StateManager with a temp dir for testing. +func newTestStateManager(t *testing.T) *StateManager { + return &StateManager{ + configDir: t.TempDir(), + states: make(map[string]WindowState), + } +} + +func TestStateManager_SetGet_Good(t *testing.T) { + sm := newTestStateManager(t) + state := WindowState{X: 100, Y: 200, Width: 800, Height: 600, Maximized: false} + sm.SetState("main", state) + got, ok := sm.GetState("main") + assert.True(t, ok) + assert.Equal(t, 100, got.X) + assert.Equal(t, 800, got.Width) +} + +func TestStateManager_SetGet_Bad(t *testing.T) { + sm := newTestStateManager(t) + _, ok := sm.GetState("nonexistent") + assert.False(t, ok) +} + +func TestStateManager_CaptureState_Good(t *testing.T) { + sm := newTestStateManager(t) + w := &mockWindow{name: "cap", x: 50, y: 60, width: 1024, height: 768, maximised: true} + sm.CaptureState(w) + got, ok := sm.GetState("cap") + assert.True(t, ok) + assert.Equal(t, 50, got.X) + assert.Equal(t, 1024, got.Width) + assert.True(t, got.Maximized) +} + +func TestStateManager_ApplyState_Good(t *testing.T) { + sm := newTestStateManager(t) + sm.SetState("win", WindowState{X: 10, Y: 20, Width: 640, Height: 480}) + w := &Window{Name: "win", Width: 1280, Height: 800} + sm.ApplyState(w) + assert.Equal(t, 10, w.X) + assert.Equal(t, 20, w.Y) + assert.Equal(t, 640, w.Width) + assert.Equal(t, 480, w.Height) +} + +func TestStateManager_ListStates_Good(t *testing.T) { + sm := newTestStateManager(t) + sm.SetState("a", WindowState{Width: 100}) + sm.SetState("b", WindowState{Width: 200}) + names := sm.ListStates() + assert.Len(t, names, 2) +} + +func TestStateManager_Clear_Good(t *testing.T) { + sm := newTestStateManager(t) + sm.SetState("a", WindowState{Width: 100}) + sm.Clear() + names := sm.ListStates() + assert.Empty(t, names) +} + +func TestStateManager_Persistence_Good(t *testing.T) { + dir := t.TempDir() + sm1 := &StateManager{configDir: dir, states: make(map[string]WindowState)} + sm1.SetState("persist", WindowState{X: 42, Y: 84, Width: 500, Height: 300}) + sm1.ForceSync() + + sm2 := &StateManager{configDir: dir, states: make(map[string]WindowState)} + sm2.load() + got, ok := sm2.GetState("persist") + assert.True(t, ok) + assert.Equal(t, 42, got.X) + assert.Equal(t, 500, got.Width) +}