feat: add workspace/cases core capability
This commit is contained in:
parent
6eecf5d005
commit
5c9ae7f93b
Binary file not shown.
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
67
main.go
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue