feat(window): add StateManager with JSON persistence
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ad3c63f093
commit
3a4a2fc508
2 changed files with 259 additions and 5 deletions
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue