// pkg/window/layout.go package window import ( "encoding/json" "os" "path/filepath" "sort" "sync" "time" coreio "forge.lthn.ai/core/go-io" coreerr "forge.lthn.ai/core/go-log" ) // 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 } // NewLayoutManagerWithDir creates a LayoutManager loading from a custom config directory. // Useful for testing or when the default config directory is not appropriate. func NewLayoutManagerWithDir(configDir string) *LayoutManager { lm := &LayoutManager{ configDir: configDir, layouts: make(map[string]Layout), } lm.load() return lm } func (lm *LayoutManager) filePath() string { return filepath.Join(lm.configDir, "layouts.json") } func (lm *LayoutManager) load() { if lm.configDir == "" { return } content, err := coreio.Local.Read(lm.filePath()) if err != nil { return } lm.mu.Lock() defer lm.mu.Unlock() _ = json.Unmarshal([]byte(content), &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 } _ = coreio.Local.EnsureDir(lm.configDir) _ = coreio.Local.Write(lm.filePath(), string(data)) } // SaveLayout creates or updates a named layout. func (lm *LayoutManager) SaveLayout(name string, windowStates map[string]WindowState) error { if name == "" { return coreerr.E("window.LayoutManager.SaveLayout", "layout name cannot be empty", nil) } 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, }) } sort.Slice(infos, func(i, j int) bool { return infos[i].Name < infos[j].Name }) 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() }