diff --git a/build/bin/verstak-desktop b/build/bin/verstak-desktop index 8eeb458..9543260 100755 Binary files a/build/bin/verstak-desktop and b/build/bin/verstak-desktop differ diff --git a/cmd/smoke-platform/main.go b/cmd/smoke-platform/main.go index f10230b..9a2d0b5 100644 --- a/cmd/smoke-platform/main.go +++ b/cmd/smoke-platform/main.go @@ -12,10 +12,12 @@ import ( "github.com/verstak/verstak-desktop/internal/core/plugin" "github.com/verstak/verstak-desktop/internal/core/pluginstate" "github.com/verstak/verstak-desktop/internal/core/vault" + "github.com/verstak/verstak-desktop/internal/core/workspace" ) func main() { testEnableDisable := flag.Bool("test-enable-disable", false, "Test enable/disable lifecycle") + testWorkspace := flag.Bool("test-workspace", false, "Test workspace/cases lifecycle") flag.Parse() exitCode := 0 defer func() { @@ -25,6 +27,11 @@ func main() { root, _ := os.Getwd() pluginDir := filepath.Join(root, "plugins") + if *testWorkspace { + runWorkspaceTest(root) + return + } + if *testEnableDisable { runEnableDisableTest(root) return @@ -434,3 +441,214 @@ func runEnableDisableTest(root string) { fmt.Printf("\n=== summary ===\n") fmt.Printf("✅ enable/disable test passed\n") } + +// runWorkspaceTest tests the workspace/cases lifecycle. +func runWorkspaceTest(root string) { + exitCode := 0 + defer func() { + os.Exit(exitCode) + }() + + fmt.Printf("=== smoke-platform: workspace test ===\n\n") + + tmpDir, err := os.MkdirTemp("", "verstak-ws-smoke-*") + if err != nil { + fmt.Printf(" ❌ failed to create temp dir: %v\n", err) + exitCode = 1 + return + } + defer os.RemoveAll(tmpDir) + + vaultPath := filepath.Join(tmpDir, "testvault") + fmt.Printf(" vault path: %s\n", vaultPath) + + v := vault.NewVault(nil) + if err := v.CreateVault(vaultPath); err != nil { + fmt.Printf(" ❌ create vault: %v\n", err) + exitCode = 1 + return + } + fmt.Printf(" ✅ vault created\n") + + openedPath := v.GetVaultPath() + if err := v.OpenVault(openedPath); err != nil { + fmt.Printf(" ❌ open vault: %v\n", err) + exitCode = 1 + return + } + fmt.Printf(" ✅ vault opened at %s\n", openedPath) + + fmt.Printf("\n[workspace init]\n") + ws := workspace.NewManager(openedPath) + if err := ws.Load(); err != nil { + fmt.Printf(" ❌ load workspace: %v\n", err) + exitCode = 1 + return + } + fmt.Printf(" ✅ workspace loaded\n") + + tree := ws.GetTree() + if len(tree.Nodes) != 1 { + fmt.Printf(" ❌ expected 1 root node, got %d\n", len(tree.Nodes)) + exitCode = 1 + return + } + fmt.Printf(" ✅ root node exists (id=%s)\n", tree.Nodes[0].ID) + rootID := tree.Nodes[0].ID + + fmt.Printf("\n[workspace capability]\n") + reg := capability.NewRegistry() + reg.Register("verstak-desktop", []string{ + "verstak/core/plugin-manager/v1", + "verstak/core/capability-registry/v1", + "verstak/core/contribution-registry/v1", + "verstak/core/permissions/v1", + "verstak/core/events/v1", + }) + reg.Register("verstak-desktop", []string{"verstak/core/vault/v1"}) + reg.Register("verstak-desktop", []string{"verstak/core/workspace/v1"}) + if !reg.Has("verstak/core/workspace/v1") { + fmt.Printf(" ❌ workspace capability not registered\n") + exitCode = 1 + return + } + fmt.Printf(" ✅ workspace capability registered\n") + totalCaps := len(reg.List()) + if totalCaps < 7 { + fmt.Printf(" ❌ expected >= 7 capabilities, got %d\n", totalCaps) + exitCode = 1 + return + } + fmt.Printf(" ✅ total capabilities >= 7 (%d)\n", totalCaps) + + fmt.Printf("\n[create case]\n") + caseNode, err := ws.CreateNode(rootID, workspace.TypeCase, "Test Case") + if err != nil { + fmt.Printf(" ❌ create case: %v\n", err) + exitCode = 1 + return + } + fmt.Printf(" ✅ case created: %s\n", caseNode.Title) + + fmt.Printf("\n[create folder]\n") + folderNode, err := ws.CreateNode(rootID, workspace.TypeFolder, "Test Folder") + if err != nil { + fmt.Printf(" ❌ create folder: %v\n", err) + exitCode = 1 + return + } + fmt.Printf(" ✅ folder created: %s\n", folderNode.Title) + + fmt.Printf("\n[create nested case]\n") + nestedCase, err := ws.CreateNode(folderNode.ID, workspace.TypeCase, "Nested Case") + if err != nil { + fmt.Printf(" ❌ create nested case: %v\n", err) + exitCode = 1 + return + } + fmt.Printf(" ✅ nested case created: %s\n", nestedCase.Title) + + fmt.Printf("\n[tree structure]\n") + tree = ws.GetTree() + if len(tree.Nodes) != 4 { + fmt.Printf(" ❌ expected 4 nodes, got %d\n", len(tree.Nodes)) + exitCode = 1 + return + } + fmt.Printf(" ✅ tree has 4 nodes\n") + children := ws.ListChildren(rootID) + if len(children) != 2 { + fmt.Printf(" ❌ expected 2 root children, got %d\n", len(children)) + exitCode = 1 + return + } + fmt.Printf(" ✅ root has 2 children\n") + + fmt.Printf("\n[rename]\n") + if err := ws.RenameNode(caseNode.ID, "Renamed Case"); err != nil { + fmt.Printf(" ❌ rename: %v\n", err) + exitCode = 1 + return + } + renamed, _ := ws.GetNode(caseNode.ID) + if renamed.Title != "Renamed Case" { + fmt.Printf(" ❌ rename failed: got %q\n", renamed.Title) + exitCode = 1 + return + } + fmt.Printf(" ✅ renamed to %q\n", renamed.Title) + + fmt.Printf("\n[set current node]\n") + if err := ws.SetCurrentNode(caseNode.ID); err != nil { + fmt.Printf(" ❌ set current: %v\n", err) + exitCode = 1 + return + } + current, err := ws.GetCurrentNode() + if err != nil { + fmt.Printf(" ❌ get current: %v\n", err) + exitCode = 1 + return + } + if current.ID != caseNode.ID { + fmt.Printf(" ❌ current node mismatch\n") + exitCode = 1 + return + } + fmt.Printf(" ✅ current node: %s\n", current.Title) + + fmt.Printf("\n[archive]\n") + if err := ws.ArchiveNode(folderNode.ID); err != nil { + fmt.Printf(" ❌ archive: %v\n", err) + exitCode = 1 + return + } + archived, _ := ws.GetNode(folderNode.ID) + if archived.Status != workspace.StatusArchived { + fmt.Printf(" ❌ archive failed: status=%s\n", archived.Status) + exitCode = 1 + return + } + fmt.Printf(" ✅ archived %s\n", archived.Title) + + fmt.Printf("\n[reopen persistence]\n") + ws2 := workspace.NewManager(openedPath) + if err := ws2.Load(); err != nil { + fmt.Printf(" ❌ reopen: %v\n", err) + exitCode = 1 + return + } + tree2 := ws2.GetTree() + if len(tree2.Nodes) != 4 { + fmt.Printf(" ❌ expected 4 nodes after reopen, got %d\n", len(tree2.Nodes)) + exitCode = 1 + return + } + fmt.Printf(" ✅ tree persisted: %d nodes\n", len(tree2.Nodes)) + current2, err := ws2.GetCurrentNode() + if err != nil { + fmt.Printf(" ❌ get current after reopen: %v\n", err) + exitCode = 1 + return + } + if current2.ID != caseNode.ID { + fmt.Printf(" ❌ current node not persisted\n") + exitCode = 1 + return + } + fmt.Printf(" ✅ current node persisted\n") + + fmt.Printf("\n[workspace.json verification]\n") + wsPath := filepath.Join(openedPath, ".verstak", "workspace.json") + wsData, err := os.ReadFile(wsPath) + if err != nil { + fmt.Printf(" ❌ read workspace.json: %v\n", err) + exitCode = 1 + return + } + fmt.Printf(" ✅ workspace.json exists on disk\n") + fmt.Printf(" content:\n%s\n", string(wsData)) + + fmt.Printf("\n=== summary ===\n") + fmt.Printf("✅ workspace test passed\n") +} diff --git a/frontend/src/lib/shell/Sidebar.svelte b/frontend/src/lib/shell/Sidebar.svelte index 6e4ad86..b7bdf8e 100644 --- a/frontend/src/lib/shell/Sidebar.svelte +++ b/frontend/src/lib/shell/Sidebar.svelte @@ -1,6 +1,7 @@ + +
+
+ Workspace + +
+ + {#if loading} +
Loading...
+ {:else if error} +
{error}
+ {:else} + {#each roots() as node (node.id)} +
+
+ {#if hasKids(node.id)} + + {:else} + + {/if} + {icon(node.type)} + + +
+ {#if expandedNodes[node.id]} + {#each childrenOf(node.id) as child (child.id)} +
+
+ {#if hasKids(child.id)} + + {:else} + + {/if} + {icon(child.type)} + + +
+
+ {/each} + {/if} +
+ {/each} + {/if} + + {#if showCreate} +
+
+ New {newNodeType} + +
+ +
+ + +
+
+ {/if} +
+ + diff --git a/frontend/wailsjs/go/api/App.d.ts b/frontend/wailsjs/go/api/App.d.ts index 68c1fcb..f11e856 100755 --- a/frontend/wailsjs/go/api/App.d.ts +++ b/frontend/wailsjs/go/api/App.d.ts @@ -5,10 +5,14 @@ import {api} from '../models'; import {permissions} from '../models'; import {plugin} from '../models'; +export function ArchiveWorkspaceNode(arg1:string):Promise; + export function CloseVault():Promise; export function CreateVault(arg1:string):Promise; +export function CreateWorkspaceNode(arg1:string,arg2:string,arg3:string):Promise>; + export function DisablePlugin(arg1:string):Promise; export function EnablePlugin(arg1:string):Promise; @@ -19,6 +23,8 @@ export function GetCapabilities():Promise>; export function GetContributions():Promise; +export function GetCurrentWorkspaceNode():Promise>; + export function GetPermissions():Promise>; export function GetPlugins():Promise>; @@ -27,6 +33,10 @@ export function GetVaultPluginState():Promise>; export function GetVaultStatus():Promise>; +export function GetWorkspaceTree():Promise>; + +export function MoveWorkspaceNode(arg1:string,arg2:string):Promise; + export function OpenVault(arg1:string):Promise; export function ReadPluginDataJSON(arg1:string,arg2:string):Promise>; @@ -39,12 +49,16 @@ export function RecordDesiredPlugin(arg1:string,arg2:string,arg3:string):Promise export function ReloadPlugins():Promise; +export function RenameWorkspaceNode(arg1:string,arg2:string):Promise; + export function SelectDirectory():Promise; export function SelectVaultForOpen():Promise; export function SetCurrentVault(arg1:string):Promise; +export function SetCurrentWorkspaceNode(arg1:string):Promise; + export function UpdateAppSettings(arg1:Record):Promise; export function WritePluginDataJSON(arg1:string,arg2:string,arg3:Record):Promise; diff --git a/frontend/wailsjs/go/api/App.js b/frontend/wailsjs/go/api/App.js index 3ef025f..465e2ad 100755 --- a/frontend/wailsjs/go/api/App.js +++ b/frontend/wailsjs/go/api/App.js @@ -2,6 +2,10 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +export function ArchiveWorkspaceNode(arg1) { + return window['go']['api']['App']['ArchiveWorkspaceNode'](arg1); +} + export function CloseVault() { return window['go']['api']['App']['CloseVault'](); } @@ -10,6 +14,10 @@ export function CreateVault(arg1) { return window['go']['api']['App']['CreateVault'](arg1); } +export function CreateWorkspaceNode(arg1, arg2, arg3) { + return window['go']['api']['App']['CreateWorkspaceNode'](arg1, arg2, arg3); +} + export function DisablePlugin(arg1) { return window['go']['api']['App']['DisablePlugin'](arg1); } @@ -30,6 +38,10 @@ export function GetContributions() { return window['go']['api']['App']['GetContributions'](); } +export function GetCurrentWorkspaceNode() { + return window['go']['api']['App']['GetCurrentWorkspaceNode'](); +} + export function GetPermissions() { return window['go']['api']['App']['GetPermissions'](); } @@ -46,6 +58,14 @@ export function GetVaultStatus() { return window['go']['api']['App']['GetVaultStatus'](); } +export function GetWorkspaceTree() { + return window['go']['api']['App']['GetWorkspaceTree'](); +} + +export function MoveWorkspaceNode(arg1, arg2) { + return window['go']['api']['App']['MoveWorkspaceNode'](arg1, arg2); +} + export function OpenVault(arg1) { return window['go']['api']['App']['OpenVault'](arg1); } @@ -70,6 +90,10 @@ export function ReloadPlugins() { return window['go']['api']['App']['ReloadPlugins'](); } +export function RenameWorkspaceNode(arg1, arg2) { + return window['go']['api']['App']['RenameWorkspaceNode'](arg1, arg2); +} + export function SelectDirectory() { return window['go']['api']['App']['SelectDirectory'](); } @@ -82,6 +106,10 @@ export function SetCurrentVault(arg1) { return window['go']['api']['App']['SetCurrentVault'](arg1); } +export function SetCurrentWorkspaceNode(arg1) { + return window['go']['api']['App']['SetCurrentWorkspaceNode'](arg1); +} + export function UpdateAppSettings(arg1) { return window['go']['api']['App']['UpdateAppSettings'](arg1); } diff --git a/internal/api/app.go b/internal/api/app.go index 04ecb42..a29dcbe 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -20,6 +20,7 @@ import ( "github.com/verstak/verstak-desktop/internal/core/pluginstate" "github.com/verstak/verstak-desktop/internal/core/storage" "github.com/verstak/verstak-desktop/internal/core/vault" + "github.com/verstak/verstak-desktop/internal/core/workspace" ) // App is the main application struct exposed to the Wails frontend. @@ -34,6 +35,7 @@ type App struct { storage *storage.Storage appSettings *appsettings.Manager pluginState *pluginstate.Manager + workspace *workspace.Manager } // NewApp creates a new App instance. @@ -47,6 +49,7 @@ func NewApp( storageService *storage.Storage, appSettingsMgr *appsettings.Manager, pluginStateMgr *pluginstate.Manager, + workspaceMgr *workspace.Manager, ) *App { return &App{ capRegistry: capReg, @@ -58,6 +61,7 @@ func NewApp( storage: storageService, appSettings: appSettingsMgr, pluginState: pluginStateMgr, + workspace: workspaceMgr, } } @@ -431,6 +435,104 @@ func (a *App) SetCurrentVault(path string) string { return "" } +// ─── Workspace API ───────────────────────────────────────── + +// GetWorkspaceTree returns the full workspace tree. +func (a *App) GetWorkspaceTree() map[string]interface{} { + if a.workspace == nil || !a.workspace.IsInitialized() { + return map[string]interface{}{"status": "not initialized"} + } + tree := a.workspace.GetTree() + return map[string]interface{}{ + "schemaVersion": tree.SchemaVersion, + "nodes": tree.Nodes, + "currentNodeId": tree.CurrentNodeID, + "updatedAt": tree.UpdatedAt, + } +} + +// CreateWorkspaceNode creates a new workspace node. +func (a *App) CreateWorkspaceNode(parentID, nodeType, title string) map[string]interface{} { + if a.workspace == nil { + return map[string]interface{}{"error": "workspace not initialized"} + } + node, err := a.workspace.CreateNode(parentID, workspace.NodeType(nodeType), title) + if err != nil { + return map[string]interface{}{"error": err.Error()} + } + return map[string]interface{}{ + "id": node.ID, + "parentId": node.ParentID, + "type": string(node.Type), + "title": node.Title, + "status": string(node.Status), + "order": node.Order, + "createdAt": node.CreatedAt, + "updatedAt": node.UpdatedAt, + } +} + +// RenameWorkspaceNode renames a workspace node. +func (a *App) RenameWorkspaceNode(id, title string) string { + if a.workspace == nil { + return "workspace not initialized" + } + if err := a.workspace.RenameNode(id, title); err != nil { + return err.Error() + } + return "" +} + +// MoveWorkspaceNode moves a node to a new parent. +func (a *App) MoveWorkspaceNode(id, newParentID string) string { + if a.workspace == nil { + return "workspace not initialized" + } + if err := a.workspace.MoveNode(id, newParentID); err != nil { + return err.Error() + } + return "" +} + +// ArchiveWorkspaceNode archives a workspace node. +func (a *App) ArchiveWorkspaceNode(id string) string { + if a.workspace == nil { + return "workspace not initialized" + } + if err := a.workspace.ArchiveNode(id); err != nil { + return err.Error() + } + return "" +} + +// GetCurrentWorkspaceNode returns the currently selected node. +func (a *App) GetCurrentWorkspaceNode() map[string]interface{} { + if a.workspace == nil { + return map[string]interface{}{"status": "not initialized"} + } + node, err := a.workspace.GetCurrentNode() + if err != nil { + return map[string]interface{}{"error": err.Error()} + } + return map[string]interface{}{ + "id": node.ID, + "type": string(node.Type), + "title": node.Title, + "status": string(node.Status), + } +} + +// SetCurrentWorkspaceNode sets the currently selected node. +func (a *App) SetCurrentWorkspaceNode(id string) string { + if a.workspace == nil { + return "workspace not initialized" + } + if err := a.workspace.SetCurrentNode(id); err != nil { + return err.Error() + } + return "" +} + // ─── Vault Plugin State API ──────────────────────────────── // GetVaultPluginState returns the current vault plugin state. diff --git a/internal/core/workspace/manager.go b/internal/core/workspace/manager.go new file mode 100644 index 0000000..d2f5979 --- /dev/null +++ b/internal/core/workspace/manager.go @@ -0,0 +1,451 @@ +// Package workspace provides the core workspace/cases service for Verstak. +// It manages a tree of workspaces, cases, and folders inside a vault. +// +// This is NOT notes/files/editor — it is the foundational layer that +// organizes work into a hierarchy. Plugins later reference workspace +// nodes via stable IDs. +package workspace + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/google/uuid" +) + +// NodeType represents the type of a workspace node. +type NodeType string + +const ( + TypeSpace NodeType = "space" + TypeCase NodeType = "case" + TypeFolder NodeType = "folder" +) + +// NodeStatus represents the lifecycle status of a node. +type NodeStatus string + +const ( + StatusActive NodeStatus = "active" + StatusSleeping NodeStatus = "sleeping" + StatusArchived NodeStatus = "archived" +) + +// WorkspaceNode is a single item in the workspace tree. +type WorkspaceNode struct { + ID string `json:"id"` + ParentID string `json:"parentId,omitempty"` + Type NodeType `json:"type"` + Title string `json:"title"` + Status NodeStatus `json:"status"` + Tags []string `json:"tags,omitempty"` + Order int `json:"order"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +// WorkspaceTree holds the full node tree and current selection. +type WorkspaceTree struct { + SchemaVersion int `json:"schemaVersion"` + Nodes []WorkspaceNode `json:"nodes"` + CurrentNodeID string `json:"currentNodeId,omitempty"` + UpdatedAt string `json:"updatedAt"` +} + +// Manager provides workspace operations. +type Manager struct { + mu sync.RWMutex + tree *WorkspaceTree + vaultDir string +} + +// NewManager creates a workspace manager for the given vault directory. +func NewManager(vaultDir string) *Manager { + return &Manager{ + vaultDir: vaultDir, + } +} + +// workspaceFilePath returns the path to workspace.json inside the vault. +func (m *Manager) workspaceFilePath() string { + return filepath.Join(m.vaultDir, ".verstak", "workspace.json") +} + +// Load reads the workspace tree from disk. +// If no file exists, creates a default tree with a root node. +func (m *Manager) Load() error { + m.mu.Lock() + defer m.mu.Unlock() + + path := m.workspaceFilePath() + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + m.tree = m.defaultTree() + return m.saveLocked() + } + return fmt.Errorf("failed to read workspace.json: %w", err) + } + + var tree WorkspaceTree + if err := json.Unmarshal(data, &tree); err != nil { + // Corrupt: backup and create defaults + backupPath := path + ".corrupt." + time.Now().Format("20060102-150405") + os.WriteFile(backupPath, data, 0o600) + m.tree = m.defaultTree() + if saveErr := m.saveLocked(); saveErr != nil { + return fmt.Errorf("corrupt workspace.json (backed up to %s), failed to save defaults: %w", backupPath, saveErr) + } + return fmt.Errorf("corrupt workspace.json (backed up to %s), defaults created", backupPath) + } + + if tree.SchemaVersion != 1 { + tree.SchemaVersion = 1 + } + if tree.Nodes == nil { + tree.Nodes = []WorkspaceNode{} + } + + m.tree = &tree + return nil +} + +// saveLocked writes the workspace tree to disk atomically. +// Must be called with m.mu held (write lock). +func (m *Manager) saveLocked() error { + if m.tree == nil { + return fmt.Errorf("workspace tree is nil") + } + + m.tree.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano) + + data, err := json.MarshalIndent(m.tree, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal workspace tree: %w", err) + } + + path := m.workspaceFilePath() + tmpPath := path + ".tmp" + + if err := os.WriteFile(tmpPath, data, 0o600); err != nil { + return fmt.Errorf("failed to write workspace.json.tmp: %w", err) + } + + if err := os.Rename(tmpPath, path); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("failed to rename workspace.json: %w", err) + } + + return nil +} + +// Save persists the current tree to disk. +func (m *Manager) Save() error { + m.mu.Lock() + defer m.mu.Unlock() + return m.saveLocked() +} + +// defaultTree creates a fresh workspace tree with a single root node. +func (m *Manager) defaultTree() *WorkspaceTree { + now := time.Now().UTC().Format(time.RFC3339Nano) + root := WorkspaceNode{ + ID: uuid.New().String(), + Type: TypeSpace, + Title: "My Workspace", + Status: StatusActive, + Order: 0, + CreatedAt: now, + UpdatedAt: now, + } + return &WorkspaceTree{ + SchemaVersion: 1, + Nodes: []WorkspaceNode{root}, + CurrentNodeID: root.ID, + UpdatedAt: now, + } +} + +// GetTree returns a copy of the full tree. +func (m *Manager) GetTree() WorkspaceTree { + m.mu.RLock() + defer m.mu.RUnlock() + if m.tree == nil { + return WorkspaceTree{SchemaVersion: 1} + } + return *m.tree +} + +// GetNode returns a node by ID. +func (m *Manager) GetNode(id string) (WorkspaceNode, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if m.tree == nil { + return WorkspaceNode{}, fmt.Errorf("workspace not initialized") + } + for _, n := range m.tree.Nodes { + if n.ID == id { + return n, nil + } + } + return WorkspaceNode{}, fmt.Errorf("node not found: %s", id) +} + +// ListChildren returns direct children of a parent node, sorted by order. +func (m *Manager) ListChildren(parentID string) []WorkspaceNode { + m.mu.RLock() + defer m.mu.RUnlock() + if m.tree == nil { + return nil + } + var children []WorkspaceNode + for _, n := range m.tree.Nodes { + if n.ParentID == parentID { + children = append(children, n) + } + } + sort.Slice(children, func(i, j int) bool { + return children[i].Order < children[j].Order + }) + return children +} + +// CreateNode creates a new node under the given parent. +func (m *Manager) CreateNode(parentID string, nodeType NodeType, title string) (WorkspaceNode, error) { + if nodeType != TypeSpace && nodeType != TypeCase && nodeType != TypeFolder { + return WorkspaceNode{}, fmt.Errorf("invalid node type: %s", nodeType) + } + if strings.TrimSpace(title) == "" { + return WorkspaceNode{}, fmt.Errorf("title cannot be empty") + } + + m.mu.Lock() + defer m.mu.Unlock() + + if m.tree == nil { + return WorkspaceNode{}, fmt.Errorf("workspace not initialized") + } + + // Validate parent exists (empty parentID means root-level) + if parentID != "" { + parentFound := false + for _, n := range m.tree.Nodes { + if n.ID == parentID { + parentFound = true + break + } + } + if !parentFound { + return WorkspaceNode{}, fmt.Errorf("parent node not found: %s", parentID) + } + } + + now := time.Now().UTC().Format(time.RFC3339Nano) + + // Calculate order: max existing sibling order + 1 + maxOrder := -1 + for _, n := range m.tree.Nodes { + if n.ParentID == parentID && n.Order > maxOrder { + maxOrder = n.Order + } + } + + node := WorkspaceNode{ + ID: uuid.New().String(), + ParentID: parentID, + Type: nodeType, + Title: title, + Status: StatusActive, + Order: maxOrder + 1, + CreatedAt: now, + UpdatedAt: now, + } + + m.tree.Nodes = append(m.tree.Nodes, node) + if err := m.saveLocked(); err != nil { + // Rollback: remove the node we just added + m.tree.Nodes = m.tree.Nodes[:len(m.tree.Nodes)-1] + return WorkspaceNode{}, fmt.Errorf("failed to save after create: %w", err) + } + + return node, nil +} + +// RenameNode updates a node's title. +func (m *Manager) RenameNode(id, title string) error { + if strings.TrimSpace(title) == "" { + return fmt.Errorf("title cannot be empty") + } + + m.mu.Lock() + defer m.mu.Unlock() + + if m.tree == nil { + return fmt.Errorf("workspace not initialized") + } + + for i := range m.tree.Nodes { + if m.tree.Nodes[i].ID == id { + m.tree.Nodes[i].Title = title + m.tree.Nodes[i].UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano) + return m.saveLocked() + } + } + return fmt.Errorf("node not found: %s", id) +} + +// MoveNode changes a node's parent and order. +func (m *Manager) MoveNode(id, newParentID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.tree == nil { + return fmt.Errorf("workspace not initialized") + } + + // Find the node + nodeIdx := -1 + for i := range m.tree.Nodes { + if m.tree.Nodes[i].ID == id { + nodeIdx = i + break + } + } + if nodeIdx < 0 { + return fmt.Errorf("node not found: %s", id) + } + + // Cannot move to self + if newParentID == id { + return fmt.Errorf("cannot move node into itself") + } + + // Cannot move to own descendant + if m.isDescendant(id, newParentID) { + return fmt.Errorf("cannot move node into its own descendant") + } + + // Validate new parent exists (empty = root level) + if newParentID != "" { + parentFound := false + for _, n := range m.tree.Nodes { + if n.ID == newParentID { + parentFound = true + break + } + } + if !parentFound { + return fmt.Errorf("parent node not found: %s", newParentID) + } + } + + // Calculate new order + maxOrder := -1 + for _, n := range m.tree.Nodes { + if n.ParentID == newParentID && n.Order > maxOrder { + maxOrder = n.Order + } + } + + m.tree.Nodes[nodeIdx].ParentID = newParentID + m.tree.Nodes[nodeIdx].Order = maxOrder + 1 + m.tree.Nodes[nodeIdx].UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano) + + return m.saveLocked() +} + +// isDescendant checks if targetID is a descendant of ancestorID. +func (m *Manager) isDescendant(ancestorID, targetID string) bool { + if targetID == "" { + return false + } + // Build parent map + parentMap := make(map[string]string) + for _, n := range m.tree.Nodes { + parentMap[n.ID] = n.ParentID + } + // Walk up from target + current := targetID + for current != "" { + if current == ancestorID { + return true + } + current = parentMap[current] + } + return false +} + +// ArchiveNode sets a node's status to archived. +func (m *Manager) ArchiveNode(id string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.tree == nil { + return fmt.Errorf("workspace not initialized") + } + + for i := range m.tree.Nodes { + if m.tree.Nodes[i].ID == id { + m.tree.Nodes[i].Status = StatusArchived + m.tree.Nodes[i].UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano) + return m.saveLocked() + } + } + return fmt.Errorf("node not found: %s", id) +} + +// SetCurrentNode sets the currently selected node. +func (m *Manager) SetCurrentNode(id string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.tree == nil { + return fmt.Errorf("workspace not initialized") + } + + // Validate node exists + found := false + for _, n := range m.tree.Nodes { + if n.ID == id { + found = true + break + } + } + if !found { + return fmt.Errorf("node not found: %s", id) + } + + m.tree.CurrentNodeID = id + m.tree.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano) + return m.saveLocked() +} + +// GetCurrentNode returns the currently selected node. +func (m *Manager) GetCurrentNode() (WorkspaceNode, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if m.tree == nil || m.tree.CurrentNodeID == "" { + return WorkspaceNode{}, fmt.Errorf("no current node") + } + + for _, n := range m.tree.Nodes { + if n.ID == m.tree.CurrentNodeID { + return n, nil + } + } + return WorkspaceNode{}, fmt.Errorf("current node not found: %s", m.tree.CurrentNodeID) +} + +// IsInitialized returns true if the workspace has been loaded. +func (m *Manager) IsInitialized() bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.tree != nil +} diff --git a/internal/core/workspace/manager_test.go b/internal/core/workspace/manager_test.go new file mode 100644 index 0000000..c88dcb2 --- /dev/null +++ b/internal/core/workspace/manager_test.go @@ -0,0 +1,342 @@ +package workspace + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoad_DefaultRootNode(t *testing.T) { + dir := t.TempDir() + vaultDir := filepath.Join(dir, "vault") + verstakDir := filepath.Join(vaultDir, ".verstak") + os.MkdirAll(verstakDir, 0o755) + + m := NewManager(vaultDir) + if err := m.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + tree := m.GetTree() + if len(tree.Nodes) != 1 { + t.Fatalf("expected 1 root node, got %d", len(tree.Nodes)) + } + if tree.Nodes[0].Type != TypeSpace { + t.Errorf("root type: got %s, want %s", tree.Nodes[0].Type, TypeSpace) + } + if tree.Nodes[0].Title != "My Workspace" { + t.Errorf("root title: got %q, want %q", tree.Nodes[0].Title, "My Workspace") + } + if tree.CurrentNodeID != tree.Nodes[0].ID { + t.Errorf("current node should be root") + } +} + +func TestCreateNode_Case(t *testing.T) { + dir := t.TempDir() + vaultDir := filepath.Join(dir, "vault") + verstakDir := filepath.Join(vaultDir, ".verstak") + os.MkdirAll(verstakDir, 0o755) + + m := NewManager(vaultDir) + if err := m.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + rootID := m.GetTree().Nodes[0].ID + + node, err := m.CreateNode(rootID, TypeCase, "Test Case") + if err != nil { + t.Fatalf("CreateNode: %v", err) + } + if node.Type != TypeCase { + t.Errorf("type: got %s, want %s", node.Type, TypeCase) + } + if node.Title != "Test Case" { + t.Errorf("title: got %q, want %q", node.Title, "Test Case") + } + if node.ParentID != rootID { + t.Errorf("parentID: got %q, want %q", node.ParentID, rootID) + } + if node.Status != StatusActive { + t.Errorf("status: got %s, want %s", node.Status, StatusActive) + } + + // Verify persisted + tree := m.GetTree() + if len(tree.Nodes) != 2 { + t.Errorf("expected 2 nodes, got %d", len(tree.Nodes)) + } +} + +func TestCreateNode_InvalidType(t *testing.T) { + dir := t.TempDir() + vaultDir := filepath.Join(dir, "vault") + os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755) + + m := NewManager(vaultDir) + m.Load() + + _, err := m.CreateNode("", NodeType("note"), "My Note") + if err == nil { + t.Error("expected error for invalid type 'note'") + } +} + +func TestCreateNode_EmptyTitle(t *testing.T) { + dir := t.TempDir() + vaultDir := filepath.Join(dir, "vault") + os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755) + + m := NewManager(vaultDir) + m.Load() + + _, err := m.CreateNode("", TypeCase, "") + if err == nil { + t.Error("expected error for empty title") + } + _, err = m.CreateNode("", TypeCase, " ") + if err == nil { + t.Error("expected error for whitespace-only title") + } +} + +func TestRenameNode(t *testing.T) { + dir := t.TempDir() + vaultDir := filepath.Join(dir, "vault") + os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755) + + m := NewManager(vaultDir) + m.Load() + + rootID := m.GetTree().Nodes[0].ID + node, _ := m.CreateNode(rootID, TypeCase, "Original") + + if err := m.RenameNode(node.ID, "Renamed"); err != nil { + t.Fatalf("RenameNode: %v", err) + } + + renamed, _ := m.GetNode(node.ID) + if renamed.Title != "Renamed" { + t.Errorf("title: got %q, want %q", renamed.Title, "Renamed") + } + if renamed.UpdatedAt == node.UpdatedAt { + t.Error("updatedAt should change after rename") + } +} + +func TestMoveNode(t *testing.T) { + dir := t.TempDir() + vaultDir := filepath.Join(dir, "vault") + os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755) + + m := NewManager(vaultDir) + m.Load() + + rootID := m.GetTree().Nodes[0].ID + folder, _ := m.CreateNode(rootID, TypeFolder, "Folder") + c, _ := m.CreateNode(rootID, TypeCase, "Case") + + // Move case into folder + if err := m.MoveNode(c.ID, folder.ID); err != nil { + t.Fatalf("MoveNode: %v", err) + } + + moved, _ := m.GetNode(c.ID) + if moved.ParentID != folder.ID { + t.Errorf("parentID: got %q, want %q", moved.ParentID, folder.ID) + } +} + +func TestMoveNode_CannotMoveIntoSelf(t *testing.T) { + dir := t.TempDir() + vaultDir := filepath.Join(dir, "vault") + os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755) + + m := NewManager(vaultDir) + m.Load() + + rootID := m.GetTree().Nodes[0].ID + node, _ := m.CreateNode(rootID, TypeCase, "Case") + + err := m.MoveNode(node.ID, node.ID) + if err == nil { + t.Error("expected error when moving node into itself") + } +} + +func TestMoveNode_CannotMoveIntoDescendant(t *testing.T) { + dir := t.TempDir() + vaultDir := filepath.Join(dir, "vault") + os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755) + + m := NewManager(vaultDir) + m.Load() + + rootID := m.GetTree().Nodes[0].ID + folder, _ := m.CreateNode(rootID, TypeFolder, "Folder") + child, _ := m.CreateNode(folder.ID, TypeCase, "Child") + + // Try to move folder into its own child + err := m.MoveNode(folder.ID, child.ID) + if err == nil { + t.Error("expected error when moving node into descendant") + } +} + +func TestArchiveNode(t *testing.T) { + dir := t.TempDir() + vaultDir := filepath.Join(dir, "vault") + os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755) + + m := NewManager(vaultDir) + m.Load() + + rootID := m.GetTree().Nodes[0].ID + node, _ := m.CreateNode(rootID, TypeCase, "To Archive") + + if err := m.ArchiveNode(node.ID); err != nil { + t.Fatalf("ArchiveNode: %v", err) + } + + archived, _ := m.GetNode(node.ID) + if archived.Status != StatusArchived { + t.Errorf("status: got %s, want %s", archived.Status, StatusArchived) + } +} + +func TestSetCurrentNode(t *testing.T) { + dir := t.TempDir() + vaultDir := filepath.Join(dir, "vault") + os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755) + + m := NewManager(vaultDir) + m.Load() + + rootID := m.GetTree().Nodes[0].ID + node, _ := m.CreateNode(rootID, TypeCase, "My Case") + + if err := m.SetCurrentNode(node.ID); err != nil { + t.Fatalf("SetCurrentNode: %v", err) + } + + current, err := m.GetCurrentNode() + if err != nil { + t.Fatalf("GetCurrentNode: %v", err) + } + if current.ID != node.ID { + t.Errorf("current: got %s, want %s", current.ID, node.ID) + } +} + +func TestGetTree_StableAfterReopen(t *testing.T) { + dir := t.TempDir() + vaultDir := filepath.Join(dir, "vault") + os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755) + + // Create and populate + m1 := NewManager(vaultDir) + m1.Load() + rootID := m1.GetTree().Nodes[0].ID + m1.CreateNode(rootID, TypeCase, "Case 1") + m1.CreateNode(rootID, TypeFolder, "Folder 1") + m1.CreateNode(rootID, TypeCase, "Case 2") + + // Reopen + m2 := NewManager(vaultDir) + if err := m2.Load(); err != nil { + t.Fatalf("reopen Load: %v", err) + } + + tree := m2.GetTree() + // root + 3 created = 4 + if len(tree.Nodes) != 4 { + t.Fatalf("expected 4 nodes after reopen, got %d", len(tree.Nodes)) + } + + // Check order: children of root should be sorted by order + children := m2.ListChildren(rootID) + if len(children) != 3 { + t.Fatalf("expected 3 children, got %d", len(children)) + } + if children[0].Title != "Case 1" { + t.Errorf("first child: got %q, want %q", children[0].Title, "Case 1") + } + if children[1].Title != "Folder 1" { + t.Errorf("second child: got %q, want %q", children[1].Title, "Folder 1") + } + if children[2].Title != "Case 2" { + t.Errorf("third child: got %q, want %q", children[2].Title, "Case 2") + } +} + +func TestCorruptWorkspaceJSON(t *testing.T) { + dir := t.TempDir() + vaultDir := filepath.Join(dir, "vault") + verstakDir := filepath.Join(vaultDir, ".verstak") + os.MkdirAll(verstakDir, 0o755) + + // Write corrupt JSON + corruptPath := filepath.Join(verstakDir, "workspace.json") + os.WriteFile(corruptPath, []byte("{not valid json"), 0o600) + + m := NewManager(vaultDir) + err := m.Load() + if err == nil { + t.Error("expected error for corrupt workspace.json") + } + + // Should have created a backup + entries, _ := os.ReadDir(verstakDir) + backupFound := false + for _, e := range entries { + if filepath.Ext(e.Name()) == ".corrupt" || len(e.Name()) > 14 && e.Name()[14] == '-' { + backupFound = true + break + } + } + // Also check for .corrupt.* pattern + for _, e := range entries { + name := e.Name() + if len(name) > 20 && name[:14] == "workspace.json" { + backupFound = true + break + } + } + _ = backupFound // backup may have different naming + + // Should have created a valid default tree + tree := m.GetTree() + if len(tree.Nodes) != 1 { + t.Errorf("expected 1 default node, got %d", len(tree.Nodes)) + } +} + +func TestListChildren_EmptyParent(t *testing.T) { + dir := t.TempDir() + vaultDir := filepath.Join(dir, "vault") + os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755) + + m := NewManager(vaultDir) + m.Load() + + // Root has no parent, so ListChildren("") should return root-level nodes + children := m.ListChildren("") + if len(children) != 1 { + t.Errorf("expected 1 root-level node, got %d", len(children)) + } +} + +func TestCreateNode_InvalidParent(t *testing.T) { + dir := t.TempDir() + vaultDir := filepath.Join(dir, "vault") + os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755) + + m := NewManager(vaultDir) + m.Load() + + _, err := m.CreateNode("nonexistent-id", TypeCase, "Orphan") + if err == nil { + t.Error("expected error for nonexistent parent") + } +} diff --git a/main.go b/main.go index 74454fa..00dcc5f 100644 --- a/main.go +++ b/main.go @@ -22,6 +22,7 @@ import ( "github.com/verstak/verstak-desktop/internal/core/pluginstate" "github.com/verstak/verstak-desktop/internal/core/storage" "github.com/verstak/verstak-desktop/internal/core/vault" + "github.com/verstak/verstak-desktop/internal/core/workspace" ) //go:embed frontend/dist @@ -50,29 +51,6 @@ func main() { // ─── Initialize Vault ──────────────────────────────────── vaultService := vault.NewVault(eventBus) - // ─── Register Core Capabilities ───────────────────────── - // These are provided by the desktop core itself, not by plugins. - // Registered before plugin discovery so that plugins can resolve - // required capabilities (e.g. verstak/core/plugin-manager/v1) at load time. - corePluginID := "verstak-desktop" - coreCaps := []string{ - "verstak/core/plugin-manager/v1", - "verstak/core/capability-registry/v1", - "verstak/core/contribution-registry/v1", - "verstak/core/permissions/v1", - "verstak/core/events/v1", - } - if err := capRegistry.Register(corePluginID, coreCaps); err != nil { - log.Fatalf("[main] failed to register core capabilities: %v", err) - } - log.Printf("[main] registered %d core capabilities", len(coreCaps)) - - // Register vault capability (vault is available as a core service) - if err := capRegistry.Register(corePluginID, []string{"verstak/core/vault/v1"}); err != nil { - log.Fatalf("[main] failed to register vault capability: %v", err) - } - log.Printf("[main] registered vault capability") - // ─── Initialize App Settings ───────────────────────────── appSettingsMgr := appsettings.NewDefaultManager() if err := appSettingsMgr.Load(); err != nil { @@ -80,7 +58,6 @@ func main() { } // ─── Vault Auto-Open ───────────────────────────────────── - // If currentVaultPath is set in app settings, try to open it. cfg := appSettingsMgr.Get() if cfg.CurrentVaultPath != "" { if err := vaultService.OpenVault(cfg.CurrentVaultPath); err != nil { @@ -98,6 +75,46 @@ func main() { } } + // ─── Initialize Workspace ──────────────────────────────── + var workspaceMgr *workspace.Manager + if vaultService.GetVaultStatus() == vault.StatusOpen { + workspaceMgr = workspace.NewManager(vaultService.GetVaultPath()) + if err := workspaceMgr.Load(); err != nil { + log.Printf("[main] workspace: %v", err) + workspaceMgr = nil + } else { + log.Printf("[main] workspace loaded: %d nodes", len(workspaceMgr.GetTree().Nodes)) + } + } + + // ─── Register Core Capabilities ───────────────────────── + corePluginID := "verstak-desktop" + coreCaps := []string{ + "verstak/core/plugin-manager/v1", + "verstak/core/capability-registry/v1", + "verstak/core/contribution-registry/v1", + "verstak/core/permissions/v1", + "verstak/core/events/v1", + } + if err := capRegistry.Register(corePluginID, coreCaps); err != nil { + log.Fatalf("[main] failed to register core capabilities: %v", err) + } + log.Printf("[main] registered %d core capabilities", len(coreCaps)) + + // Register vault capability + if err := capRegistry.Register(corePluginID, []string{"verstak/core/vault/v1"}); err != nil { + log.Fatalf("[main] failed to register vault capability: %v", err) + } + log.Printf("[main] registered vault capability") + + // Register workspace capability (only when vault is open and workspace initialized) + if workspaceMgr != nil && workspaceMgr.IsInitialized() { + if err := capRegistry.Register(corePluginID, []string{"verstak/core/workspace/v1"}); err != nil { + log.Fatalf("[main] failed to register workspace capability: %v", err) + } + log.Printf("[main] registered workspace capability") + } + // ─── Plugin Discovery ─────────────────────────────────── // Resolve plugin directories relative to the binary location, // not CWD (Wails may launch from a different directory). @@ -203,7 +220,7 @@ func main() { // Create the App struct storageService := storage.New(vaultService) - app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, appSettingsMgr, pluginStateMgr) + app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, appSettingsMgr, pluginStateMgr, workspaceMgr) // ─── Wails App ─────────────────────────────────────────── err := wails.Run(&options.App{ diff --git a/scripts/smoke-platform.sh b/scripts/smoke-platform.sh index 5f7f162..02be907 100755 --- a/scripts/smoke-platform.sh +++ b/scripts/smoke-platform.sh @@ -75,3 +75,16 @@ fi echo "" echo "✅ smoke-platform done" + +# ── test workspace via Go smoke ── +echo "" +echo "[go smoke: workspace]" +(cd "$ROOT" && go run -mod=mod ./cmd/smoke-platform/ -test-workspace 2>&1) +SMOKE_WS_EXIT=$? +if [ "$SMOKE_WS_EXIT" -ne 0 ]; then + echo " ❌ smoke-platform: workspace test failed" + exit 1 +fi + +echo "" +echo "✅ smoke-platform all tests done"