From 93fc3cc1796505f978c7a612dd427a7fd28a351e Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 18 Apr 2026 08:32:59 +0100 Subject: [PATCH 1/5] feat(gui): gpt-5.4-mini/mature pass 1 - `a39edfde` `Harden stub manager nil receivers` Co-Authored-By: Virgil --- stubs/wails/pkg/application/browser_manager.go | 6 ++++++ .../wails/pkg/application/browser_manager_test.go | 9 +++++++++ stubs/wails/pkg/application/clipboard.go | 15 +++++++++++++++ stubs/wails/pkg/application/clipboard_test.go | 11 +++++++++++ stubs/wails/pkg/application/keybinding.go | 12 ++++++++++++ stubs/wails/pkg/application/keybinding_test.go | 11 +++++++++++ 6 files changed, 64 insertions(+) diff --git a/stubs/wails/pkg/application/browser_manager.go b/stubs/wails/pkg/application/browser_manager.go index 7e014afe..606077b6 100644 --- a/stubs/wails/pkg/application/browser_manager.go +++ b/stubs/wails/pkg/application/browser_manager.go @@ -18,6 +18,9 @@ type BrowserManager struct { // manager.OpenURL("https://lthn.io") // _ = manager.LastURL // "https://lthn.io" func (bm *BrowserManager) OpenURL(url string) error { + if bm == nil { + return nil + } bm.mu.Lock() bm.LastURL = url bm.mu.Unlock() @@ -29,6 +32,9 @@ func (bm *BrowserManager) OpenURL(url string) error { // manager.OpenFile("/home/user/report.pdf") // _ = manager.LastFile // "/home/user/report.pdf" func (bm *BrowserManager) OpenFile(path string) error { + if bm == nil { + return nil + } bm.mu.Lock() bm.LastFile = path bm.mu.Unlock() diff --git a/stubs/wails/pkg/application/browser_manager_test.go b/stubs/wails/pkg/application/browser_manager_test.go index 914512a8..84cab350 100644 --- a/stubs/wails/pkg/application/browser_manager_test.go +++ b/stubs/wails/pkg/application/browser_manager_test.go @@ -59,3 +59,12 @@ func TestBrowserManager_OpenFile_Ugly(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "/tmp/\x00report.txt", manager.LastFile) } + +func TestBrowserManager_NilReceiver_IsSafe(t *testing.T) { + var manager *BrowserManager + + assert.NotPanics(t, func() { + assert.NoError(t, manager.OpenURL("https://example.com")) + assert.NoError(t, manager.OpenFile("/tmp/report.txt")) + }) +} diff --git a/stubs/wails/pkg/application/clipboard.go b/stubs/wails/pkg/application/clipboard.go index db85f35d..a901ea42 100644 --- a/stubs/wails/pkg/application/clipboard.go +++ b/stubs/wails/pkg/application/clipboard.go @@ -17,6 +17,9 @@ type Clipboard struct { // // cb.SetText("copied content") func (c *Clipboard) SetText(text string) bool { + if c == nil { + return false + } c.mu.Lock() c.text = text c.set = true @@ -29,6 +32,9 @@ func (c *Clipboard) SetText(text string) bool { // text, ok := cb.Text() // if !ok { text = "" } func (c *Clipboard) Text() (string, bool) { + if c == nil { + return "", false + } c.mu.RLock() defer c.mu.RUnlock() return c.text, c.set @@ -48,6 +54,9 @@ type ClipboardManager struct { // // manager.SetText("some text") func (cm *ClipboardManager) SetText(text string) bool { + if cm == nil { + return false + } return cm.getClipboard().SetText(text) } @@ -55,11 +64,17 @@ func (cm *ClipboardManager) SetText(text string) bool { // // text, ok := manager.Text() func (cm *ClipboardManager) Text() (string, bool) { + if cm == nil { + return "", false + } return cm.getClipboard().Text() } // getClipboard returns the clipboard instance, creating it if needed. func (cm *ClipboardManager) getClipboard() *Clipboard { + if cm == nil { + return &Clipboard{} + } cm.mu.Lock() defer cm.mu.Unlock() if cm.clipboard == nil { diff --git a/stubs/wails/pkg/application/clipboard_test.go b/stubs/wails/pkg/application/clipboard_test.go index aecb358f..a3bbd805 100644 --- a/stubs/wails/pkg/application/clipboard_test.go +++ b/stubs/wails/pkg/application/clipboard_test.go @@ -71,3 +71,14 @@ func TestClipboardManager_Text_Ugly(t *testing.T) { assert.True(t, present) assert.Equal(t, raw, text) } + +func TestClipboardManager_NilReceiver_IsSafe(t *testing.T) { + var manager *ClipboardManager + + assert.NotPanics(t, func() { + assert.False(t, manager.SetText("hello")) + text, present := manager.Text() + assert.Empty(t, text) + assert.False(t, present) + }) +} diff --git a/stubs/wails/pkg/application/keybinding.go b/stubs/wails/pkg/application/keybinding.go index 5e06fd5f..744084b9 100644 --- a/stubs/wails/pkg/application/keybinding.go +++ b/stubs/wails/pkg/application/keybinding.go @@ -23,6 +23,9 @@ type KeyBindingManager struct { // // manager.Add("CmdOrCtrl+Shift+P", func(w Window) { launchCommandPalette(w) }) func (m *KeyBindingManager) Add(accelerator string, callback func(window Window)) { + if m == nil { + return + } m.mu.Lock() defer m.mu.Unlock() if m.bindings == nil { @@ -35,6 +38,9 @@ func (m *KeyBindingManager) Add(accelerator string, callback func(window Window) // // manager.Remove("CmdOrCtrl+Shift+P") func (m *KeyBindingManager) Remove(accelerator string) { + if m == nil { + return + } m.mu.Lock() defer m.mu.Unlock() delete(m.bindings, accelerator) @@ -44,6 +50,9 @@ func (m *KeyBindingManager) Remove(accelerator string) { // // if manager.Process("CmdOrCtrl+K", window) { return } func (m *KeyBindingManager) Process(accelerator string, window Window) (handled bool) { + if m == nil { + return false + } m.mu.RLock() callback, exists := m.bindings[accelerator] m.mu.RUnlock() @@ -64,6 +73,9 @@ func (m *KeyBindingManager) Process(accelerator string, window Window) (handled // // for _, kb := range manager.GetAll() { log(kb.Accelerator) } func (m *KeyBindingManager) GetAll() []*KeyBinding { + if m == nil { + return nil + } m.mu.RLock() defer m.mu.RUnlock() bindings := make([]*KeyBinding, 0, len(m.bindings)) diff --git a/stubs/wails/pkg/application/keybinding_test.go b/stubs/wails/pkg/application/keybinding_test.go index ed330b1f..785f25bb 100644 --- a/stubs/wails/pkg/application/keybinding_test.go +++ b/stubs/wails/pkg/application/keybinding_test.go @@ -79,3 +79,14 @@ func TestKeyBindingManager_Remove_Ugly(t *testing.T) { assert.Empty(t, manager.GetAll()) } + +func TestKeyBindingManager_NilReceiver_IsSafe(t *testing.T) { + var manager *KeyBindingManager + + assert.NotPanics(t, func() { + manager.Add("CmdOrCtrl+K", func(Window) {}) + manager.Remove("CmdOrCtrl+K") + assert.False(t, manager.Process("CmdOrCtrl+K", nil)) + assert.Nil(t, manager.GetAll()) + }) +} From 5437487b3a3c3fc9d166d6f81620fb7129c483f9 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 18 Apr 2026 08:37:00 +0100 Subject: [PATCH 2/5] feat(gui): gpt-5.4-mini/mature pass 2 - Verified with `go test ./...`. Co-Authored-By: Virgil --- pkg/display/events.go | 9 +++++---- pkg/display/events_test.go | 11 +++++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/pkg/display/events.go b/pkg/display/events.go index 2e191d58..3462cd8d 100644 --- a/pkg/display/events.go +++ b/pkg/display/events.go @@ -260,10 +260,11 @@ func (em *WSEventManager) sendEvent(conn *websocket.Conn, event Event) { // HandleWebSocket handles WebSocket upgrade and connection. func (em *WSEventManager) HandleWebSocket(w http.ResponseWriter, r *http.Request) { - if em == nil { - if w != nil { - http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) - } + if w == nil { + return + } + if em == nil || r == nil { + http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) return } em.mu.RLock() diff --git a/pkg/display/events_test.go b/pkg/display/events_test.go index f2f8709e..012a762e 100644 --- a/pkg/display/events_test.go +++ b/pkg/display/events_test.go @@ -180,6 +180,17 @@ func TestWSEventManager_HandleWebSocket_NilReceiverFailsClosed(t *testing.T) { assert.Equal(t, http.StatusServiceUnavailable, recorder.Code) } +func TestWSEventManager_HandleWebSocket_NilWriterFailsClosed(t *testing.T) { + em := NewWSEventManager() + + req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/events", nil) + req.RemoteAddr = "127.0.0.1:12345" + + assert.NotPanics(t, func() { + em.HandleWebSocket(nil, req) + }) +} + func TestWSEventManager_HandleWebSocket_RejectsAfterClose(t *testing.T) { em := NewWSEventManager() em.Close() From a8d56f01582022c76d99b3215525274cd8640c01 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 18 Apr 2026 08:42:23 +0100 Subject: [PATCH 3/5] feat(gui): gpt-5.4-mini/mature pass 3 Constraint: Co-Authored-By: Virgil --- .core/TODO.md | 1 + stubs/wails/pkg/application/application.go | 9 +++++++++ .../wails/pkg/application/application_test.go | 13 +++++++++++++ stubs/wails/pkg/application/events.go | 12 ++++++++++++ stubs/wails/pkg/application/events_test.go | 18 ++++++++++++++++++ 5 files changed, 53 insertions(+) diff --git a/.core/TODO.md b/.core/TODO.md index e69de29b..fd033dda 100644 --- a/.core/TODO.md +++ b/.core/TODO.md @@ -0,0 +1 @@ +- @bug pkg/window/state.go:26 — window state persistence writes to the default home config path, which breaks restricted test/runtime environments unless the target path is configurable. diff --git a/stubs/wails/pkg/application/application.go b/stubs/wails/pkg/application/application.go index 8b07e8e8..bdefae6c 100644 --- a/stubs/wails/pkg/application/application.go +++ b/stubs/wails/pkg/application/application.go @@ -143,10 +143,16 @@ type WindowEventContext struct { } func (c *WindowEventContext) DroppedFiles() []string { + if c == nil { + return nil + } return append([]string(nil), c.droppedFiles...) } func (c *WindowEventContext) DropTargetDetails() *DropTargetDetails { + if c == nil { + return nil + } if c.dropDetails == nil { return nil } @@ -165,6 +171,9 @@ type WindowEvent struct { } func (e *WindowEvent) Context() *WindowEventContext { + if e == nil { + return nil + } if e.ctx == nil { e.ctx = &WindowEventContext{} } diff --git a/stubs/wails/pkg/application/application_test.go b/stubs/wails/pkg/application/application_test.go index b3bef5ba..f1c748ea 100644 --- a/stubs/wails/pkg/application/application_test.go +++ b/stubs/wails/pkg/application/application_test.go @@ -249,6 +249,19 @@ func TestApplication_WindowEvent_Ugly(t *testing.T) { assert.Equal(t, []string{"file"}, event.Context().DroppedFiles()) } +func TestApplication_WindowEvent_NilReceiver(t *testing.T) { + var event *WindowEvent + + assert.Nil(t, event.Context()) +} + +func TestApplication_WindowEventContext_NilReceiver(t *testing.T) { + var ctx *WindowEventContext + + assert.Empty(t, ctx.DroppedFiles()) + assert.Nil(t, ctx.DropTargetDetails()) +} + func TestApplication_WebviewWindow_Good(t *testing.T) { manager := &WindowManager{} window := manager.NewWithOptions(WebviewWindowOptions{ diff --git a/stubs/wails/pkg/application/events.go b/stubs/wails/pkg/application/events.go index f4b389f8..38907636 100644 --- a/stubs/wails/pkg/application/events.go +++ b/stubs/wails/pkg/application/events.go @@ -45,6 +45,9 @@ func (e *ApplicationEvent) Context() *ApplicationEventContext { // // event.Cancel() func (e *ApplicationEvent) Cancel() { + if e == nil { + return + } e.cancelled.Store(true) } @@ -52,6 +55,9 @@ func (e *ApplicationEvent) Cancel() { // // if event.IsCancelled() { return } func (e *ApplicationEvent) IsCancelled() bool { + if e == nil { + return false + } return e.cancelled.Load() } @@ -69,6 +75,9 @@ type CustomEvent struct { // // event.Cancel() func (e *CustomEvent) Cancel() { + if e == nil { + return + } e.cancelled.Store(true) } @@ -76,6 +85,9 @@ func (e *CustomEvent) Cancel() { // // if event.IsCancelled() { return } func (e *CustomEvent) IsCancelled() bool { + if e == nil { + return false + } return e.cancelled.Load() } diff --git a/stubs/wails/pkg/application/events_test.go b/stubs/wails/pkg/application/events_test.go index fcd433f1..e400dad5 100644 --- a/stubs/wails/pkg/application/events_test.go +++ b/stubs/wails/pkg/application/events_test.go @@ -35,6 +35,15 @@ func TestEvents_CustomEvent_Ugly(t *testing.T) { assert.Equal(t, []any{"a", 1}, event.Data) } +func TestEvents_CustomEvent_NilReceiver(t *testing.T) { + var event *CustomEvent + + assert.NotPanics(t, func() { + event.Cancel() + }) + assert.False(t, event.IsCancelled()) +} + func TestEvents_ApplicationEvent_Good(t *testing.T) { event := &ApplicationEvent{Id: 7, ctx: newApplicationEventContext()} @@ -60,6 +69,15 @@ func TestEvents_ApplicationEvent_Ugly(t *testing.T) { assert.True(t, event.IsCancelled()) } +func TestEvents_ApplicationEvent_NilReceiver(t *testing.T) { + var event *ApplicationEvent + + assert.NotPanics(t, func() { + event.Cancel() + }) + assert.False(t, event.IsCancelled()) +} + func TestEvents_EventManager_Emit_Good(t *testing.T) { manager := newEventManager() calls := 0 From 206696545143acc1a3665b5663ffcf5587f74b87 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 18 Apr 2026 08:49:20 +0100 Subject: [PATCH 4/5] feat(gui): gpt-5.4-mini/mature pass 4 - `dev` now points at `db535805a179ef11a012ff34994036ede2cb150d` Co-Authored-By: Virgil --- .core/TODO.md | 2 +- pkg/window/state.go | 21 +++++++++++++++++++-- pkg/window/state_test.go | 11 +++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/.core/TODO.md b/.core/TODO.md index fd033dda..42905e2f 100644 --- a/.core/TODO.md +++ b/.core/TODO.md @@ -1 +1 @@ -- @bug pkg/window/state.go:26 — window state persistence writes to the default home config path, which breaks restricted test/runtime environments unless the target path is configurable. +- @bug pkg/window/layout.go:29 — layout persistence has the same hard-coded config-dir assumption and needs an explicit file-path override for restricted runtimes. diff --git a/pkg/window/state.go b/pkg/window/state.go index 6a2d3662..b12880eb 100644 --- a/pkg/window/state.go +++ b/pkg/window/state.go @@ -10,6 +10,8 @@ import ( coreio "dappco.re/go/core/io" ) +const windowStateFileEnv = "WINDOW_STATE_FILE" + // WindowState holds the persisted position/size of a window. // JSON tags match existing window_state.json format for backward compat. type WindowState struct { @@ -23,7 +25,7 @@ type WindowState struct { UpdatedAt int64 `json:"updatedAt,omitempty"` } -// StateManager persists window positions to ~/.config/Core/window_state.json. +// StateManager persists window positions to the configured window state file. type StateManager struct { configDir string statePath string @@ -32,8 +34,12 @@ type StateManager struct { saveTimer *time.Timer } -// NewStateManager creates a StateManager loading from the default config directory. +// NewStateManager creates a StateManager loading from the configured default path. +// WINDOW_STATE_FILE overrides the directory-based default when present. func NewStateManager() *StateManager { + if stateFile := core.Env(windowStateFileEnv); stateFile != "" { + return NewStateManagerWithPath(stateFile) + } sm := &StateManager{ states: make(map[string]WindowState), } @@ -55,6 +61,17 @@ func NewStateManagerWithDir(configDir string) *StateManager { return sm } +// NewStateManagerWithPath creates a StateManager loading from a custom state file. +// Useful for tests or restricted runtimes that need an explicit writable target. +func NewStateManagerWithPath(path string) *StateManager { + sm := &StateManager{ + statePath: path, + states: make(map[string]WindowState), + } + sm.load() + return sm +} + func (sm *StateManager) filePath() string { if sm.statePath != "" { return sm.statePath diff --git a/pkg/window/state_test.go b/pkg/window/state_test.go index ce7b398a..34c7cfc9 100644 --- a/pkg/window/state_test.go +++ b/pkg/window/state_test.go @@ -20,6 +20,17 @@ func TestStateManagerState_NewStateManagerWithDir_Good(t *testing.T) { assert.Empty(t, sm.ListStates()) } +func TestStateManagerState_NewStateManagerWithPathEnv_Good(t *testing.T) { + path := filepath.Join(t.TempDir(), "custom", "window_state.json") + t.Setenv(windowStateFileEnv, path) + + sm := NewStateManager() + + require.NotNil(t, sm) + assert.Equal(t, path, sm.filePath()) + assert.Equal(t, filepath.Dir(path), sm.dataDir()) +} + func TestStateManagerState_NewStateManagerWithDir_Bad(t *testing.T) { sm := NewStateManagerWithDir("") From 9713264c918885e4895feeb684e020b441779508 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 18 Apr 2026 08:55:28 +0100 Subject: [PATCH 5/5] feat(gui): gpt-5.4-mini/mature pass 5 Verification: Co-Authored-By: Virgil --- .core/TODO.md | 1 - pkg/window/layout.go | 120 +++++++++++++++++++++++++++++++++----- pkg/window/layout_test.go | 21 +++++++ 3 files changed, 126 insertions(+), 16 deletions(-) diff --git a/.core/TODO.md b/.core/TODO.md index 42905e2f..e69de29b 100644 --- a/.core/TODO.md +++ b/.core/TODO.md @@ -1 +0,0 @@ -- @bug pkg/window/layout.go:29 — layout persistence has the same hard-coded config-dir assumption and needs an explicit file-path override for restricted runtimes. diff --git a/pkg/window/layout.go b/pkg/window/layout.go index 373f4e88..585ffe28 100644 --- a/pkg/window/layout.go +++ b/pkg/window/layout.go @@ -2,6 +2,7 @@ package window import ( + "io/fs" "sync" "time" @@ -10,6 +11,8 @@ import ( coreerr "dappco.re/go/core/log" ) +const layoutFileEnv = "WINDOW_LAYOUT_FILE" + // Layout is a named window arrangement. type Layout struct { Name string `json:"name"` @@ -28,13 +31,18 @@ type LayoutInfo struct { // LayoutManager persists named window arrangements to ~/.config/Core/layouts.json. type LayoutManager struct { - configDir string - layouts map[string]Layout - mu sync.RWMutex + configDir string + layoutPath string + layouts map[string]Layout + mu sync.RWMutex } // NewLayoutManager creates a LayoutManager loading from the default config directory. +// WINDOW_LAYOUT_FILE overrides the directory-based default when present. func NewLayoutManager() *LayoutManager { + if layoutFile := core.Env(layoutFileEnv); layoutFile != "" { + return NewLayoutManagerWithPath(layoutFile) + } lm := &LayoutManager{ layouts: make(map[string]Layout), } @@ -56,36 +64,116 @@ func NewLayoutManagerWithDir(configDir string) *LayoutManager { return lm } +// NewLayoutManagerWithPath creates a LayoutManager loading from a custom layout file. +// Useful for tests and restricted runtimes that need an explicit writable target. +func NewLayoutManagerWithPath(path string) *LayoutManager { + lm := &LayoutManager{ + layoutPath: path, + layouts: make(map[string]Layout), + } + lm.load() + return lm +} + func (lm *LayoutManager) filePath() string { + if lm.layoutPath != "" { + return lm.layoutPath + } return core.JoinPath(lm.configDir, "layouts.json") } +func (lm *LayoutManager) dataDir() string { + if lm.layoutPath != "" { + return core.PathDir(lm.layoutPath) + } + return lm.configDir +} + +// SetPath switches the manager to a custom layout file path. +func (lm *LayoutManager) SetPath(path string) { + if path == "" { + return + } + lm.mu.Lock() + lm.layoutPath = path + lm.layouts = make(map[string]Layout) + lm.mu.Unlock() + lm.load() +} + func (lm *LayoutManager) load() { - if lm.configDir == "" { + if lm.configDir == "" && lm.layoutPath == "" { return } content, err := coreio.Local.Read(lm.filePath()) if err != nil { + if core.Is(err, fs.ErrNotExist) { + return + } + core.Error( + "window layout load failed", + "path", lm.filePath(), + "err", core.E("window.LayoutManager.load", "failed to read window layouts", err), + ) + return + } + loaded := make(map[string]Layout) + if result := core.JSONUnmarshalString(content, &loaded); !result.OK { + if decodeErr, ok := result.Value.(error); ok { + core.Error( + "window layout load failed", + "path", lm.filePath(), + "err", core.E("window.LayoutManager.load", "failed to decode window layouts", decodeErr), + ) + } return } lm.mu.Lock() - defer lm.mu.Unlock() - _ = core.JSONUnmarshalString(content, &lm.layouts) + lm.layouts = loaded + lm.mu.Unlock() } -func (lm *LayoutManager) save() { - if lm.configDir == "" { - return +func (lm *LayoutManager) save() error { + if lm.configDir == "" && lm.layoutPath == "" { + return nil } lm.mu.RLock() - result := core.JSONMarshal(lm.layouts) + filePath := lm.filePath() + layouts := make(map[string]Layout, len(lm.layouts)) + for name, layout := range lm.layouts { + layouts[name] = layout + } + result := core.JSONMarshal(layouts) lm.mu.RUnlock() if !result.OK { - return + marshalErr, _ := result.Value.(error) + core.Error( + "window layout save failed", + "path", filePath, + "err", core.E("window.LayoutManager.save", "failed to encode window layouts", marshalErr), + ) + return core.E("window.LayoutManager.save", "failed to encode window layouts", marshalErr) } data := result.Value.([]byte) - _ = coreio.Local.EnsureDir(lm.configDir) - _ = coreio.Local.Write(lm.filePath(), string(data)) + if dir := lm.dataDir(); dir != "" { + if err := coreio.Local.EnsureDir(dir); err != nil { + core.Error( + "window layout save failed", + "path", filePath, + "err", core.E("window.LayoutManager.save", "failed to create window layout directory", err), + ) + return core.E("window.LayoutManager.save", "failed to create window layout directory", err) + } + } + if err := coreio.Local.Write(filePath, string(data)); err != nil { + core.Error( + "window layout save failed", + "path", filePath, + "err", core.E("window.LayoutManager.save", "failed to write window layouts", err), + ) + return core.E("window.LayoutManager.save", "failed to write window layouts", err) + } + return nil } // SaveLayout creates or updates a named layout. @@ -108,7 +196,9 @@ func (lm *LayoutManager) SaveLayout(name string, windowStates map[string]WindowS } lm.layouts[name] = layout lm.mu.Unlock() - lm.save() + if err := lm.save(); err != nil { + return err + } return nil } @@ -139,5 +229,5 @@ func (lm *LayoutManager) DeleteLayout(name string) { lm.mu.Lock() delete(lm.layouts, name) lm.mu.Unlock() - lm.save() + _ = lm.save() } diff --git a/pkg/window/layout_test.go b/pkg/window/layout_test.go index cc9f2bd4..7d812961 100644 --- a/pkg/window/layout_test.go +++ b/pkg/window/layout_test.go @@ -1,6 +1,8 @@ package window import ( + "os" + "path/filepath" "testing" "time" @@ -57,3 +59,22 @@ func TestLayoutManager_SaveLayout_Ugly(t *testing.T) { assert.Greater(t, second.UpdatedAt, first.UpdatedAt) assert.Equal(t, 1024, second.Windows["main"].Width) } + +func TestLayoutManager_NewLayoutManagerWithPathEnv_Good(t *testing.T) { + path := filepath.Join(t.TempDir(), "custom", "layouts.json") + t.Setenv(layoutFileEnv, path) + + lm := NewLayoutManager() + + require.NotNil(t, lm) + assert.Equal(t, path, lm.filePath()) + assert.Equal(t, filepath.Dir(path), lm.dataDir()) + + require.NoError(t, lm.SaveLayout("coding", map[string]WindowState{ + "main": {Width: 800, Height: 600}, + })) + + content, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(content), `"coding"`) +}