From ff44b8c6548504c62f220aebac469f7b5f9794ed Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Mar 2026 12:08:17 +0000 Subject: [PATCH] feat(window): add LayoutManager with JSON persistence Co-Authored-By: Claude Opus 4.6 --- pkg/window/layout.go | 132 ++++++++++++++++++++++++++++++++++++-- pkg/window/window_test.go | 47 ++++++++++++++ 2 files changed, 175 insertions(+), 4 deletions(-) diff --git a/pkg/window/layout.go b/pkg/window/layout.go index db411b0..578af63 100644 --- a/pkg/window/layout.go +++ b/pkg/window/layout.go @@ -1,8 +1,132 @@ // pkg/window/layout.go package window -// LayoutManager persists named window arrangements. -// Full implementation in Task 4. -type LayoutManager struct{} +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) -func NewLayoutManager() *LayoutManager { return &LayoutManager{} } +// Layout is a named window arrangement. +type Layout struct { + Name string `json:"name"` + Windows map[string]WindowState `json:"windows"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` +} + +// LayoutInfo is a summary of a layout. +type LayoutInfo struct { + Name string `json:"name"` + WindowCount int `json:"windowCount"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` +} + +// LayoutManager persists named window arrangements to ~/.config/Core/layouts.json. +type LayoutManager struct { + configDir string + layouts map[string]Layout + mu sync.RWMutex +} + +// NewLayoutManager creates a LayoutManager loading from the default config directory. +func NewLayoutManager() *LayoutManager { + lm := &LayoutManager{ + layouts: make(map[string]Layout), + } + configDir, err := os.UserConfigDir() + if err == nil { + lm.configDir = filepath.Join(configDir, "Core") + } + lm.load() + return lm +} + +func (lm *LayoutManager) filePath() string { + return filepath.Join(lm.configDir, "layouts.json") +} + +func (lm *LayoutManager) load() { + if lm.configDir == "" { + return + } + data, err := os.ReadFile(lm.filePath()) + if err != nil { + return + } + lm.mu.Lock() + defer lm.mu.Unlock() + _ = json.Unmarshal(data, &lm.layouts) +} + +func (lm *LayoutManager) save() { + if lm.configDir == "" { + return + } + lm.mu.RLock() + data, err := json.MarshalIndent(lm.layouts, "", " ") + lm.mu.RUnlock() + if err != nil { + return + } + _ = os.MkdirAll(lm.configDir, 0o755) + _ = os.WriteFile(lm.filePath(), data, 0o644) +} + +// SaveLayout creates or updates a named layout. +func (lm *LayoutManager) SaveLayout(name string, windowStates map[string]WindowState) error { + if name == "" { + return fmt.Errorf("layout name cannot be empty") + } + now := time.Now().UnixMilli() + lm.mu.Lock() + existing, exists := lm.layouts[name] + layout := Layout{ + Name: name, + Windows: windowStates, + UpdatedAt: now, + } + if exists { + layout.CreatedAt = existing.CreatedAt + } else { + layout.CreatedAt = now + } + lm.layouts[name] = layout + lm.mu.Unlock() + lm.save() + return nil +} + +// GetLayout returns a layout by name. +func (lm *LayoutManager) GetLayout(name string) (Layout, bool) { + lm.mu.RLock() + defer lm.mu.RUnlock() + l, ok := lm.layouts[name] + return l, ok +} + +// ListLayouts returns info summaries for all layouts. +func (lm *LayoutManager) ListLayouts() []LayoutInfo { + lm.mu.RLock() + defer lm.mu.RUnlock() + infos := make([]LayoutInfo, 0, len(lm.layouts)) + for _, l := range lm.layouts { + infos = append(infos, LayoutInfo{ + Name: l.Name, WindowCount: len(l.Windows), + CreatedAt: l.CreatedAt, UpdatedAt: l.UpdatedAt, + }) + } + return infos +} + +// DeleteLayout removes a layout by name. +func (lm *LayoutManager) DeleteLayout(name string) { + lm.mu.Lock() + delete(lm.layouts, name) + lm.mu.Unlock() + lm.save() +} diff --git a/pkg/window/window_test.go b/pkg/window/window_test.go index 00d300c..7e0bc50 100644 --- a/pkg/window/window_test.go +++ b/pkg/window/window_test.go @@ -219,3 +219,50 @@ func TestStateManager_Persistence_Good(t *testing.T) { assert.Equal(t, 42, got.X) assert.Equal(t, 500, got.Width) } + +// --- LayoutManager Tests --- + +// newTestLayoutManager creates a clean LayoutManager with a temp dir for testing. +func newTestLayoutManager(t *testing.T) *LayoutManager { + return &LayoutManager{ + configDir: t.TempDir(), + layouts: make(map[string]Layout), + } +} + +func TestLayoutManager_SaveGet_Good(t *testing.T) { + lm := newTestLayoutManager(t) + states := map[string]WindowState{ + "editor": {X: 0, Y: 0, Width: 960, Height: 1080}, + "terminal": {X: 960, Y: 0, Width: 960, Height: 1080}, + } + err := lm.SaveLayout("coding", states) + require.NoError(t, err) + + layout, ok := lm.GetLayout("coding") + assert.True(t, ok) + assert.Equal(t, "coding", layout.Name) + assert.Len(t, layout.Windows, 2) +} + +func TestLayoutManager_GetLayout_Bad(t *testing.T) { + lm := newTestLayoutManager(t) + _, ok := lm.GetLayout("nonexistent") + assert.False(t, ok) +} + +func TestLayoutManager_ListLayouts_Good(t *testing.T) { + lm := newTestLayoutManager(t) + _ = lm.SaveLayout("a", map[string]WindowState{}) + _ = lm.SaveLayout("b", map[string]WindowState{}) + layouts := lm.ListLayouts() + assert.Len(t, layouts, 2) +} + +func TestLayoutManager_DeleteLayout_Good(t *testing.T) { + lm := newTestLayoutManager(t) + _ = lm.SaveLayout("temp", map[string]WindowState{}) + lm.DeleteLayout("temp") + _, ok := lm.GetLayout("temp") + assert.False(t, ok) +}