From ae50bef795bc48950900224a71f3270e25161d5c Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Tue, 17 Mar 2026 07:07:01 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20workspace=20snapshot=20persistence=20?= =?UTF-8?q?=E2=80=94=20save/load=20layout=20+=20clean=20shutdown=20detecti?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/app/workspace.go | 66 ++++++++++++++++++++++++++++++ internal/app/workspace_test.go | 74 ++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 internal/app/workspace.go create mode 100644 internal/app/workspace_test.go diff --git a/internal/app/workspace.go b/internal/app/workspace.go new file mode 100644 index 0000000..3b5be4b --- /dev/null +++ b/internal/app/workspace.go @@ -0,0 +1,66 @@ +package app + +import ( + "encoding/json" + + "github.com/vstockwell/wraith/internal/settings" +) + +type WorkspaceSnapshot struct { + Tabs []WorkspaceTab `json:"tabs"` + SidebarWidth int `json:"sidebarWidth"` + SidebarMode string `json:"sidebarMode"` + ActiveTab int `json:"activeTab"` +} + +type WorkspaceTab struct { + ConnectionID int64 `json:"connectionId"` + Protocol string `json:"protocol"` + Position int `json:"position"` +} + +type WorkspaceService struct { + settings *settings.SettingsService +} + +func NewWorkspaceService(s *settings.SettingsService) *WorkspaceService { + return &WorkspaceService{settings: s} +} + +// Save serializes the workspace snapshot to settings +func (w *WorkspaceService) Save(snapshot *WorkspaceSnapshot) error { + data, err := json.Marshal(snapshot) + if err != nil { + return err + } + return w.settings.Set("workspace_snapshot", string(data)) +} + +// Load reads the last saved workspace snapshot +func (w *WorkspaceService) Load() (*WorkspaceSnapshot, error) { + data, err := w.settings.Get("workspace_snapshot") + if err != nil || data == "" { + return nil, nil // no saved workspace + } + var snapshot WorkspaceSnapshot + if err := json.Unmarshal([]byte(data), &snapshot); err != nil { + return nil, err + } + return &snapshot, nil +} + +// MarkCleanShutdown saves a flag indicating clean exit +func (w *WorkspaceService) MarkCleanShutdown() error { + return w.settings.Set("clean_shutdown", "true") +} + +// WasCleanShutdown checks if last exit was clean +func (w *WorkspaceService) WasCleanShutdown() bool { + val, _ := w.settings.Get("clean_shutdown") + return val == "true" +} + +// ClearCleanShutdown removes the clean shutdown flag (called on startup) +func (w *WorkspaceService) ClearCleanShutdown() error { + return w.settings.Delete("clean_shutdown") +} diff --git a/internal/app/workspace_test.go b/internal/app/workspace_test.go new file mode 100644 index 0000000..2f6906f --- /dev/null +++ b/internal/app/workspace_test.go @@ -0,0 +1,74 @@ +package app + +import ( + "path/filepath" + "testing" + + "github.com/vstockwell/wraith/internal/db" + "github.com/vstockwell/wraith/internal/settings" +) + +func setupWorkspaceService(t *testing.T) *WorkspaceService { + t.Helper() + d, err := db.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + if err := db.Migrate(d); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { d.Close() }) + return NewWorkspaceService(settings.NewSettingsService(d)) +} + +func TestSaveAndLoadWorkspace(t *testing.T) { + svc := setupWorkspaceService(t) + snapshot := &WorkspaceSnapshot{ + Tabs: []WorkspaceTab{ + {ConnectionID: 1, Protocol: "ssh", Position: 0}, + {ConnectionID: 5, Protocol: "rdp", Position: 1}, + }, + SidebarWidth: 240, + SidebarMode: "connections", + ActiveTab: 0, + } + if err := svc.Save(snapshot); err != nil { + t.Fatal(err) + } + loaded, err := svc.Load() + if err != nil { + t.Fatal(err) + } + if len(loaded.Tabs) != 2 { + t.Errorf("len(Tabs) = %d, want 2", len(loaded.Tabs)) + } + if loaded.SidebarWidth != 240 { + t.Errorf("SidebarWidth = %d, want 240", loaded.SidebarWidth) + } +} + +func TestLoadEmptyWorkspace(t *testing.T) { + svc := setupWorkspaceService(t) + loaded, err := svc.Load() + if err != nil { + t.Fatal(err) + } + if loaded != nil { + t.Error("should return nil for empty workspace") + } +} + +func TestCleanShutdownFlag(t *testing.T) { + svc := setupWorkspaceService(t) + if svc.WasCleanShutdown() { + t.Error("should not be clean initially") + } + svc.MarkCleanShutdown() + if !svc.WasCleanShutdown() { + t.Error("should be clean after marking") + } + svc.ClearCleanShutdown() + if svc.WasCleanShutdown() { + t.Error("should not be clean after clearing") + } +}