feat: add workspace/cases core capability

This commit is contained in:
mirivlad 2026-06-17 12:22:52 +08:00
parent 6eecf5d005
commit 5c9ae7f93b
11 changed files with 1404 additions and 25 deletions

Binary file not shown.

View File

@ -12,10 +12,12 @@ import (
"github.com/verstak/verstak-desktop/internal/core/plugin" "github.com/verstak/verstak-desktop/internal/core/plugin"
"github.com/verstak/verstak-desktop/internal/core/pluginstate" "github.com/verstak/verstak-desktop/internal/core/pluginstate"
"github.com/verstak/verstak-desktop/internal/core/vault" "github.com/verstak/verstak-desktop/internal/core/vault"
"github.com/verstak/verstak-desktop/internal/core/workspace"
) )
func main() { func main() {
testEnableDisable := flag.Bool("test-enable-disable", false, "Test enable/disable lifecycle") testEnableDisable := flag.Bool("test-enable-disable", false, "Test enable/disable lifecycle")
testWorkspace := flag.Bool("test-workspace", false, "Test workspace/cases lifecycle")
flag.Parse() flag.Parse()
exitCode := 0 exitCode := 0
defer func() { defer func() {
@ -25,6 +27,11 @@ func main() {
root, _ := os.Getwd() root, _ := os.Getwd()
pluginDir := filepath.Join(root, "plugins") pluginDir := filepath.Join(root, "plugins")
if *testWorkspace {
runWorkspaceTest(root)
return
}
if *testEnableDisable { if *testEnableDisable {
runEnableDisableTest(root) runEnableDisableTest(root)
return return
@ -434,3 +441,214 @@ func runEnableDisableTest(root string) {
fmt.Printf("\n=== summary ===\n") fmt.Printf("\n=== summary ===\n")
fmt.Printf("✅ enable/disable test passed\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")
}

View File

@ -1,6 +1,7 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App'; import * as App from '../../../wailsjs/go/api/App';
import WorkspaceTree from './WorkspaceTree.svelte';
let plugins = []; let plugins = [];
let vaultStatus = { status: 'unknown', path: '', vaultId: '' }; let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
@ -75,6 +76,10 @@
</div> </div>
{/if} {/if}
{#if vaultOpen}
<WorkspaceTree />
{/if}
<div class="sidebar-footer"> <div class="sidebar-footer">
{#if vaultStatus.status !== 'unknown'} {#if vaultStatus.status !== 'unknown'}
<span class="vault-indicator" class:vault-open={vaultStatus.status === 'open'} class:vault-closed={vaultStatus.status !== 'open'}> <span class="vault-indicator" class:vault-open={vaultStatus.status === 'open'} class:vault-closed={vaultStatus.status !== 'open'}>
@ -129,6 +134,13 @@
margin-top: 0.25rem; margin-top: 0.25rem;
} }
:global(workspace-tree) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.section-label { .section-label {
color: #666; color: #666;
font-size: 0.7rem; font-size: 0.7rem;

View File

@ -0,0 +1,182 @@
<script>
import { onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
let nodes = [];
let currentNodeId = '';
let loading = true;
let error = '';
let expandedNodes = {};
let showCreate = false;
let newNodeTitle = '';
let newNodeParentId = '';
let newNodeType = 'case';
let creating = false;
onMount(async () => {
await loadTree();
});
async function loadTree() {
loading = true;
error = '';
try {
const result = await App.GetWorkspaceTree();
if (result.status === 'not initialized') {
nodes = [];
currentNodeId = '';
} else {
nodes = result.nodes || [];
currentNodeId = result.currentNodeId || '';
const root = nodes.find(n => !n.parentId);
if (root) expandedNodes[root.id] = true;
}
} catch (e) {
error = String(e);
}
loading = false;
}
function childrenOf(parentId) {
return nodes.filter(n => n.parentId === parentId).sort((a, b) => a.order - b.order);
}
function roots() {
return nodes.filter(n => !n.parentId).sort((a, b) => a.order - b.order);
}
function toggle(id) {
expandedNodes[id] = !expandedNodes[id];
expandedNodes = expandedNodes;
}
function hasKids(id) {
return nodes.some(n => n.parentId === id);
}
function icon(type) {
if (type === 'space') return '\u{1F310}';
if (type === 'case') return '\u{1F4CB}';
if (type === 'folder') return '\u{1F4C1}';
return '\u{1F4C4}';
}
async function selectNode(id) {
const err = await App.SetCurrentWorkspaceNode(id);
if (err) { error = err; return; }
currentNodeId = id;
}
function openCreate(parentId, type) {
newNodeParentId = parentId;
newNodeType = type;
newNodeTitle = '';
showCreate = true;
}
async function doCreate() {
if (!newNodeTitle.trim()) return;
creating = true;
const res = await App.CreateWorkspaceNode(newNodeParentId, newNodeType, newNodeTitle.trim());
if (res.error) { error = res.error; creating = false; return; }
showCreate = false;
creating = false;
await loadTree();
expandedNodes[newNodeParentId] = true;
expandedNodes = expandedNodes;
}
function cancelCreate() {
showCreate = false;
newNodeTitle = '';
}
</script>
<div class="wt">
<div class="wt-header">
<span class="wt-title">Workspace</span>
<button class="wt-btn" on:click={() => openCreate('', 'space')} type="button">+</button>
</div>
{#if loading}
<div class="wt-loading">Loading...</div>
{:else if error}
<div class="wt-error">{error}</div>
{:else}
{#each roots() as node (node.id)}
<div class="wt-node">
<div class="wt-row" class:selected={node.id === currentNodeId}>
{#if hasKids(node.id)}
<button class="wt-expand" on:click={() => toggle(node.id)} type="button">{expandedNodes[node.id] ? '\u25BE' : '\u25B8'}</button>
{:else}
<span class="wt-expand-spacer"></span>
{/if}
<span class="wt-icon">{icon(node.type)}</span>
<button class="wt-label" on:click={() => selectNode(node.id)} type="button">{node.title}</button>
<button class="wt-btn wt-btn-small" on:click={() => openCreate(node.id, 'case')} type="button">+</button>
</div>
{#if expandedNodes[node.id]}
{#each childrenOf(node.id) as child (child.id)}
<div class="wt-node wt-child">
<div class="wt-row" class:selected={child.id === currentNodeId}>
{#if hasKids(child.id)}
<button class="wt-expand" on:click={() => toggle(child.id)} type="button">{expandedNodes[child.id] ? '\u25BE' : '\u25B8'}</button>
{:else}
<span class="wt-expand-spacer"></span>
{/if}
<span class="wt-icon">{icon(child.type)}</span>
<button class="wt-label" on:click={() => selectNode(child.id)} type="button">{child.title}</button>
<button class="wt-btn wt-btn-small" on:click={() => openCreate(child.id, 'folder')} type="button">+</button>
</div>
</div>
{/each}
{/if}
</div>
{/each}
{/if}
{#if showCreate}
<div class="wt-create">
<div class="wt-create-header">
<span>New {newNodeType}</span>
<button class="wt-btn" on:click={cancelCreate} type="button">{'\u2715'}</button>
</div>
<input type="text" bind:value={newNodeTitle} placeholder="Name..." disabled={creating} />
<div class="wt-create-actions">
<button class="wt-btn-primary" on:click={doCreate} type="button" disabled={creating || !newNodeTitle.trim()}>{creating ? '...' : 'Create'}</button>
<button class="wt-btn" on:click={cancelCreate} type="button" disabled={creating}>Cancel</button>
</div>
</div>
{/if}
</div>
<style>
.wt { display: flex; flex-direction: column; flex: 1; overflow: hidden; position: relative; }
.wt-header { display: flex; align-items: center; justify-content: space-between; padding: 0.4rem 0.6rem; border-bottom: 1px solid #0f3460; }
.wt-title { color: #a0a0b8; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
.wt-btn { background: none; border: none; color: #666; cursor: pointer; font-size: 0.85rem; padding: 0.1rem 0.3rem; border-radius: 3px; }
.wt-btn:hover { color: #4ecca3; background: rgba(78,204,163,0.1); }
.wt-btn-small { font-size: 0.7rem; opacity: 0; }
.wt-row:hover .wt-btn-small { opacity: 1; }
.wt-loading, .wt-error { padding: 0.5rem; font-size: 0.75rem; color: #666; }
.wt-error { color: #e94560; }
.wt-node { }
.wt-row { display: flex; align-items: center; gap: 0.2rem; padding: 0.2rem 0.4rem; }
.wt-row:hover { background: rgba(15,52,96,0.4); }
.wt-row.selected { background: rgba(78,204,163,0.1); }
.wt-expand { width: 1rem; height: 1rem; display: flex; align-items: center; justify-content: center; font-size: 0.65rem; color: #666; background: none; border: none; cursor: pointer; padding: 0; }
.wt-expand:hover { color: #e0e0f0; }
.wt-expand-spacer { width: 1rem; flex-shrink: 0; }
.wt-icon { font-size: 0.8rem; flex-shrink: 0; }
.wt-label { flex: 1; background: none; border: none; color: #e0e0f0; font-size: 0.78rem; text-align: left; cursor: pointer; padding: 0.1rem 0.2rem; border-radius: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wt-label:hover { color: #4ecca3; }
.wt-child .wt-row { padding-left: 1.2rem; }
.wt-create { position: absolute; bottom: 0; left: 0; right: 0; background: #16213e; border-top: 1px solid #0f3460; padding: 0.6rem; z-index: 10; }
.wt-create-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.4rem; color: #a0a0b8; font-size: 0.7rem; text-transform: uppercase; }
.wt-create input { width: 100%; background: #0f3460; border: 1px solid #1a3a5c; color: #e0e0f0; padding: 0.35rem 0.5rem; border-radius: 4px; font-size: 0.8rem; margin-bottom: 0.4rem; box-sizing: border-box; }
.wt-create input:focus { outline: none; border-color: #4ecca3; }
.wt-create-actions { display: flex; gap: 0.4rem; justify-content: flex-end; }
.wt-btn-primary { background: #4ecca3; color: #1a1a2e; border: none; padding: 0.3rem 0.6rem; border-radius: 4px; cursor: pointer; font-size: 0.75rem; font-weight: 600; }
.wt-btn-primary:hover:not(:disabled) { background: #3dbb92; }
.wt-btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
</style>

View File

@ -5,10 +5,14 @@ import {api} from '../models';
import {permissions} from '../models'; import {permissions} from '../models';
import {plugin} from '../models'; import {plugin} from '../models';
export function ArchiveWorkspaceNode(arg1:string):Promise<string>;
export function CloseVault():Promise<void>; export function CloseVault():Promise<void>;
export function CreateVault(arg1:string):Promise<void>; export function CreateVault(arg1:string):Promise<void>;
export function CreateWorkspaceNode(arg1:string,arg2:string,arg3:string):Promise<Record<string, any>>;
export function DisablePlugin(arg1:string):Promise<string>; export function DisablePlugin(arg1:string):Promise<string>;
export function EnablePlugin(arg1:string):Promise<string>; export function EnablePlugin(arg1:string):Promise<string>;
@ -19,6 +23,8 @@ export function GetCapabilities():Promise<Array<capability.Entry>>;
export function GetContributions():Promise<api.ContributionSummary>; export function GetContributions():Promise<api.ContributionSummary>;
export function GetCurrentWorkspaceNode():Promise<Record<string, any>>;
export function GetPermissions():Promise<Array<permissions.Entry>>; export function GetPermissions():Promise<Array<permissions.Entry>>;
export function GetPlugins():Promise<Array<plugin.Plugin>>; export function GetPlugins():Promise<Array<plugin.Plugin>>;
@ -27,6 +33,10 @@ export function GetVaultPluginState():Promise<Record<string, any>>;
export function GetVaultStatus():Promise<Record<string, string>>; export function GetVaultStatus():Promise<Record<string, string>>;
export function GetWorkspaceTree():Promise<Record<string, any>>;
export function MoveWorkspaceNode(arg1:string,arg2:string):Promise<string>;
export function OpenVault(arg1:string):Promise<void>; export function OpenVault(arg1:string):Promise<void>;
export function ReadPluginDataJSON(arg1:string,arg2:string):Promise<Record<string, any>>; export function ReadPluginDataJSON(arg1:string,arg2:string):Promise<Record<string, any>>;
@ -39,12 +49,16 @@ export function RecordDesiredPlugin(arg1:string,arg2:string,arg3:string):Promise
export function ReloadPlugins():Promise<number|string>; export function ReloadPlugins():Promise<number|string>;
export function RenameWorkspaceNode(arg1:string,arg2:string):Promise<string>;
export function SelectDirectory():Promise<string>; export function SelectDirectory():Promise<string>;
export function SelectVaultForOpen():Promise<string>; export function SelectVaultForOpen():Promise<string>;
export function SetCurrentVault(arg1:string):Promise<string>; export function SetCurrentVault(arg1:string):Promise<string>;
export function SetCurrentWorkspaceNode(arg1:string):Promise<string>;
export function UpdateAppSettings(arg1:Record<string, any>):Promise<string>; export function UpdateAppSettings(arg1:Record<string, any>):Promise<string>;
export function WritePluginDataJSON(arg1:string,arg2:string,arg3:Record<string, any>):Promise<string>; export function WritePluginDataJSON(arg1:string,arg2:string,arg3:Record<string, any>):Promise<string>;

View File

@ -2,6 +2,10 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT // This file is automatically generated. DO NOT EDIT
export function ArchiveWorkspaceNode(arg1) {
return window['go']['api']['App']['ArchiveWorkspaceNode'](arg1);
}
export function CloseVault() { export function CloseVault() {
return window['go']['api']['App']['CloseVault'](); return window['go']['api']['App']['CloseVault']();
} }
@ -10,6 +14,10 @@ export function CreateVault(arg1) {
return window['go']['api']['App']['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) { export function DisablePlugin(arg1) {
return window['go']['api']['App']['DisablePlugin'](arg1); return window['go']['api']['App']['DisablePlugin'](arg1);
} }
@ -30,6 +38,10 @@ export function GetContributions() {
return window['go']['api']['App']['GetContributions'](); return window['go']['api']['App']['GetContributions']();
} }
export function GetCurrentWorkspaceNode() {
return window['go']['api']['App']['GetCurrentWorkspaceNode']();
}
export function GetPermissions() { export function GetPermissions() {
return window['go']['api']['App']['GetPermissions'](); return window['go']['api']['App']['GetPermissions']();
} }
@ -46,6 +58,14 @@ export function GetVaultStatus() {
return window['go']['api']['App']['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) { export function OpenVault(arg1) {
return window['go']['api']['App']['OpenVault'](arg1); return window['go']['api']['App']['OpenVault'](arg1);
} }
@ -70,6 +90,10 @@ export function ReloadPlugins() {
return window['go']['api']['App']['ReloadPlugins'](); return window['go']['api']['App']['ReloadPlugins']();
} }
export function RenameWorkspaceNode(arg1, arg2) {
return window['go']['api']['App']['RenameWorkspaceNode'](arg1, arg2);
}
export function SelectDirectory() { export function SelectDirectory() {
return window['go']['api']['App']['SelectDirectory'](); return window['go']['api']['App']['SelectDirectory']();
} }
@ -82,6 +106,10 @@ export function SetCurrentVault(arg1) {
return window['go']['api']['App']['SetCurrentVault'](arg1); return window['go']['api']['App']['SetCurrentVault'](arg1);
} }
export function SetCurrentWorkspaceNode(arg1) {
return window['go']['api']['App']['SetCurrentWorkspaceNode'](arg1);
}
export function UpdateAppSettings(arg1) { export function UpdateAppSettings(arg1) {
return window['go']['api']['App']['UpdateAppSettings'](arg1); return window['go']['api']['App']['UpdateAppSettings'](arg1);
} }

View File

@ -20,6 +20,7 @@ import (
"github.com/verstak/verstak-desktop/internal/core/pluginstate" "github.com/verstak/verstak-desktop/internal/core/pluginstate"
"github.com/verstak/verstak-desktop/internal/core/storage" "github.com/verstak/verstak-desktop/internal/core/storage"
"github.com/verstak/verstak-desktop/internal/core/vault" "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. // App is the main application struct exposed to the Wails frontend.
@ -34,6 +35,7 @@ type App struct {
storage *storage.Storage storage *storage.Storage
appSettings *appsettings.Manager appSettings *appsettings.Manager
pluginState *pluginstate.Manager pluginState *pluginstate.Manager
workspace *workspace.Manager
} }
// NewApp creates a new App instance. // NewApp creates a new App instance.
@ -47,6 +49,7 @@ func NewApp(
storageService *storage.Storage, storageService *storage.Storage,
appSettingsMgr *appsettings.Manager, appSettingsMgr *appsettings.Manager,
pluginStateMgr *pluginstate.Manager, pluginStateMgr *pluginstate.Manager,
workspaceMgr *workspace.Manager,
) *App { ) *App {
return &App{ return &App{
capRegistry: capReg, capRegistry: capReg,
@ -58,6 +61,7 @@ func NewApp(
storage: storageService, storage: storageService,
appSettings: appSettingsMgr, appSettings: appSettingsMgr,
pluginState: pluginStateMgr, pluginState: pluginStateMgr,
workspace: workspaceMgr,
} }
} }
@ -431,6 +435,104 @@ func (a *App) SetCurrentVault(path string) string {
return "" 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 ──────────────────────────────── // ─── Vault Plugin State API ────────────────────────────────
// GetVaultPluginState returns the current vault plugin state. // GetVaultPluginState returns the current vault plugin state.

View File

@ -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
}

View File

@ -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")
}
}

67
main.go
View File

@ -22,6 +22,7 @@ import (
"github.com/verstak/verstak-desktop/internal/core/pluginstate" "github.com/verstak/verstak-desktop/internal/core/pluginstate"
"github.com/verstak/verstak-desktop/internal/core/storage" "github.com/verstak/verstak-desktop/internal/core/storage"
"github.com/verstak/verstak-desktop/internal/core/vault" "github.com/verstak/verstak-desktop/internal/core/vault"
"github.com/verstak/verstak-desktop/internal/core/workspace"
) )
//go:embed frontend/dist //go:embed frontend/dist
@ -50,29 +51,6 @@ func main() {
// ─── Initialize Vault ──────────────────────────────────── // ─── Initialize Vault ────────────────────────────────────
vaultService := vault.NewVault(eventBus) 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 ───────────────────────────── // ─── Initialize App Settings ─────────────────────────────
appSettingsMgr := appsettings.NewDefaultManager() appSettingsMgr := appsettings.NewDefaultManager()
if err := appSettingsMgr.Load(); err != nil { if err := appSettingsMgr.Load(); err != nil {
@ -80,7 +58,6 @@ func main() {
} }
// ─── Vault Auto-Open ───────────────────────────────────── // ─── Vault Auto-Open ─────────────────────────────────────
// If currentVaultPath is set in app settings, try to open it.
cfg := appSettingsMgr.Get() cfg := appSettingsMgr.Get()
if cfg.CurrentVaultPath != "" { if cfg.CurrentVaultPath != "" {
if err := vaultService.OpenVault(cfg.CurrentVaultPath); err != nil { 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 ─────────────────────────────────── // ─── Plugin Discovery ───────────────────────────────────
// Resolve plugin directories relative to the binary location, // Resolve plugin directories relative to the binary location,
// not CWD (Wails may launch from a different directory). // not CWD (Wails may launch from a different directory).
@ -203,7 +220,7 @@ func main() {
// Create the App struct // Create the App struct
storageService := storage.New(vaultService) 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 ─────────────────────────────────────────── // ─── Wails App ───────────────────────────────────────────
err := wails.Run(&options.App{ err := wails.Run(&options.App{

View File

@ -75,3 +75,16 @@ fi
echo "" echo ""
echo "✅ smoke-platform done" 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"