core: Milestone 7b — Files explorer and Default Editor improvements

- Files plugin: richer explorer with breadcrumbs, selection, toolbar actions,
  rename/trash, filter, sorting, hidden/reserved entries filtered
- Default Editor: line numbers, Ctrl+S, markdown toolbar, Edit/Preview/Split,
  markdown preview, Reload/Revert
- E2E tests: 39 passed for files + editor
- Workspace model: correction, naming alignment, compatibility wrappers
- Updated docs: NOTES_FILES_PLUGIN_PLAN.md, PLUGIN_RUNTIME.md
This commit is contained in:
2026-06-20 19:20:13 +08:00
parent 4de5a74a55
commit 0ac473d720
18 changed files with 1956 additions and 1161 deletions
File diff suppressed because it is too large Load Diff
+315 -343
View File
@@ -1,403 +1,375 @@
package workspace
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestLoad_DefaultRootNode(t *testing.T) {
dir := t.TempDir()
vaultDir := filepath.Join(dir, "vault")
verstakDir := filepath.Join(vaultDir, ".verstak")
os.MkdirAll(verstakDir, 0o755)
func TestListWorkspacesReadsTopLevelPhysicalFolders(t *testing.T) {
vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Project"))
mustMkdir(t, filepath.Join(vaultDir, "Test"))
mustMkdir(t, filepath.Join(vaultDir, ".verstak"))
mustMkdir(t, filepath.Join(vaultDir, ".git"))
mustWrite(t, filepath.Join(vaultDir, "readme.md"), "not a workspace")
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))
workspaces, err := m.ListWorkspaces()
if err != nil {
t.Fatalf("ListWorkspaces: %v", err)
}
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")
got := workspaceNames(workspaces)
want := []string{"Project", "Test"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("workspaces = %v, want %v", got, want)
}
}
func TestCreateNode_Case(t *testing.T) {
dir := t.TempDir()
vaultDir := filepath.Join(dir, "vault")
verstakDir := filepath.Join(vaultDir, ".verstak")
os.MkdirAll(verstakDir, 0o755)
func TestListWorkspacesExcludesTopLevelSymlink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation needs extra privileges on Windows")
}
vaultDir := newVaultDir(t)
target := filepath.Join(t.TempDir(), "outside")
mustMkdir(t, target)
if err := os.Symlink(target, filepath.Join(vaultDir, "Linked")); err != nil {
t.Fatalf("Symlink: %v", err)
}
m := NewManager(vaultDir)
workspaces, err := m.ListWorkspaces()
if err != nil {
t.Fatalf("ListWorkspaces: %v", err)
}
if len(workspaces) != 0 {
t.Fatalf("expected symlink workspace to be excluded, got %+v", workspaces)
}
}
func TestLoadDoesNotCreateOrMigrateFoldersFromOldWorkspaceJSON(t *testing.T) {
vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, ".verstak"))
oldTree := `{"schemaVersion":1,"nodes":[{"id":"old","type":"space","title":"Old Tree Workspace","path":"Old Tree Workspace"}],"currentNodeId":"old"}`
mustWrite(t, filepath.Join(vaultDir, ".verstak", "workspace.json"), oldTree)
m := NewManager(vaultDir)
if err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Old Tree Workspace")); !os.IsNotExist(err) {
t.Fatalf("Load created folder from old workspace.json, stat err=%v", err)
}
rootID := m.GetTree().Nodes[0].ID
node, err := m.CreateNode(rootID, TypeCase, "Test Case")
workspaces, err := m.ListWorkspaces()
if err != nil {
t.Fatalf("CreateNode: %v", err)
t.Fatalf("ListWorkspaces: %v", err)
}
if node.Type != TypeCase {
t.Errorf("type: got %s, want %s", node.Type, TypeCase)
if len(workspaces) != 0 {
t.Fatalf("workspace.json tree should not be source of truth, got %+v", workspaces)
}
if node.Title != "Test Case" {
t.Errorf("title: got %q, want %q", node.Title, "Test Case")
}
func TestCreateWorkspaceCreatesFolderDefaultTemplateAndMetadataSnapshot(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
ws, err := m.CreateWorkspace("Project", "")
if err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
if node.ParentID != rootID {
t.Errorf("parentID: got %q, want %q", node.ParentID, rootID)
if ws.Name != "Project" || ws.RootPath != "Project" {
t.Fatalf("workspace = %+v, want Project root", ws)
}
if node.Status != StatusActive {
t.Errorf("status: got %s, want %s", node.Status, StatusActive)
if _, err := os.Stat(filepath.Join(vaultDir, "Project")); err != nil {
t.Fatalf("workspace folder missing: %v", err)
}
if node.Path != filepath.Join("My Workspace", "Test Case") {
t.Errorf("path: got %q, want %q", node.Path, filepath.Join("My Workspace", "Test Case"))
}
if _, err := os.Stat(filepath.Join(vaultDir, node.Path)); err != nil {
t.Fatalf("expected workspace folder to exist: %v", err)
if _, err := os.Stat(filepath.Join(vaultDir, "Project", "Notes", "Overview.md")); err != nil {
t.Fatalf("default template overview missing: %v", err)
}
// Verify persisted
meta, err := m.GetWorkspaceMetadata("Project")
if err != nil {
t.Fatalf("GetWorkspaceMetadata: %v", err)
}
if meta.WorkspaceName != "Project" {
t.Fatalf("metadata workspaceName = %q", meta.WorkspaceName)
}
if meta.CreatedFromTemplate == nil {
t.Fatal("metadata missing createdFromTemplate snapshot")
}
if meta.CreatedFromTemplate.TemplateID != "default" || meta.CreatedFromTemplate.TemplateName == "" || meta.CreatedFromTemplate.TemplateVersion == 0 || meta.CreatedFromTemplate.AppliedAt == "" {
t.Fatalf("bad template snapshot: %+v", meta.CreatedFromTemplate)
}
if !meta.Features["files"] || !meta.Features["notes"] {
t.Fatalf("features = %+v, want files and notes enabled", meta.Features)
}
if meta.Folders["notes"] != "Notes" {
t.Fatalf("folders = %+v, want notes folder", meta.Folders)
}
}
func TestWorkspaceMetadataDoesNotRequireLiveTemplate(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("ClientA", "client-project"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
ClearTemplateRegistryForTest(t)
meta, err := m.GetWorkspaceMetadata("ClientA")
if err != nil {
t.Fatalf("GetWorkspaceMetadata after registry clear: %v", err)
}
if meta.CreatedFromTemplate == nil || meta.CreatedFromTemplate.TemplateID != "client-project" {
t.Fatalf("snapshot not preserved after registry clear: %+v", meta.CreatedFromTemplate)
}
}
func TestMissingMetadataReturnsGenericWorkspaceMetadata(t *testing.T) {
vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Loose"))
m := NewManager(vaultDir)
meta, err := m.GetWorkspaceMetadata("Loose")
if err != nil {
t.Fatalf("GetWorkspaceMetadata: %v", err)
}
if meta.WorkspaceName != "Loose" {
t.Fatalf("workspaceName = %q", meta.WorkspaceName)
}
if meta.CreatedFromTemplate != nil {
t.Fatalf("generic metadata should not invent a template snapshot: %+v", meta.CreatedFromTemplate)
}
if !meta.Features["files"] {
t.Fatalf("generic metadata should enable files at minimum: %+v", meta.Features)
}
}
func TestGetWorkspaceMetadataReturnsCanonicalFolderNameWhenStoredNameIsStale(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("Project", "default"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
data, err := os.ReadFile(m.metadataPath("Project"))
if err != nil {
t.Fatalf("read metadata: %v", err)
}
var meta Metadata
if err := json.Unmarshal(data, &meta); err != nil {
t.Fatalf("unmarshal metadata: %v", err)
}
meta.WorkspaceName = "OldName"
staleData, err := json.MarshalIndent(meta, "", " ")
if err != nil {
t.Fatalf("marshal metadata: %v", err)
}
if err := os.WriteFile(m.metadataPath("Project"), staleData, 0o600); err != nil {
t.Fatalf("write stale metadata: %v", err)
}
got, err := m.GetWorkspaceMetadata("Project")
if err != nil {
t.Fatalf("GetWorkspaceMetadata: %v", err)
}
if got.WorkspaceName != "Project" {
t.Fatalf("workspaceName = %q, want canonical folder name Project", got.WorkspaceName)
}
}
func TestRenameWorkspacePhysicallyRenamesFolderAndMetadata(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("Project", "default"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
if err := m.RenameWorkspace("Project", "Renamed"); err != nil {
t.Fatalf("RenameWorkspace: %v", err)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Project")); !os.IsNotExist(err) {
t.Fatalf("old folder still exists or stat failed unexpectedly: %v", err)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Renamed")); err != nil {
t.Fatalf("renamed folder missing: %v", err)
}
meta, err := m.GetWorkspaceMetadata("Renamed")
if err != nil {
t.Fatalf("metadata after rename: %v", err)
}
if meta.WorkspaceName != "Renamed" {
t.Fatalf("metadata workspaceName = %q, want Renamed", meta.WorkspaceName)
}
if _, err := os.Stat(m.metadataPath("Project")); !os.IsNotExist(err) {
t.Fatalf("old metadata key still exists or stat failed unexpectedly: %v", err)
}
}
func TestTrashWorkspaceMovesFolderToTrashAndRemovesFromList(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("Project", "default"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
result, err := m.TrashWorkspace("Project")
if err != nil {
t.Fatalf("TrashWorkspace: %v", err)
}
if result.OriginalPath != "Project" || result.TrashID == "" || result.TrashPath == "" {
t.Fatalf("bad trash result: %+v", result)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Project")); !os.IsNotExist(err) {
t.Fatalf("workspace still exists after trash, stat err=%v", err)
}
if _, err := os.Stat(filepath.Join(vaultDir, filepath.FromSlash(result.TrashPath))); err != nil {
t.Fatalf("trashed workspace missing: %v", err)
}
workspaces, err := m.ListWorkspaces()
if err != nil {
t.Fatalf("ListWorkspaces: %v", err)
}
if len(workspaces) != 0 {
t.Fatalf("workspace should be removed from list after trash, got %+v", workspaces)
}
}
func TestCreateAndRenameConflictsAreExplicit(t *testing.T) {
vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Existing"))
mustMkdir(t, filepath.Join(vaultDir, "Other"))
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("Existing", ""); err == nil || !strings.Contains(err.Error(), "conflict") {
t.Fatalf("create conflict error = %v, want conflict", err)
}
if err := m.RenameWorkspace("Existing", "Other"); err == nil || !strings.Contains(err.Error(), "conflict") {
t.Fatalf("rename conflict error = %v, want conflict", err)
}
}
func TestInvalidWorkspaceNamesRejected(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
names := []string{"", " ", "A/B", `A\B`, "/abs", `C:\abs`, "..", "a..b", "bad\x00name", ".verstak", ".Verstak", ".git"}
for _, name := range names {
if _, err := m.CreateWorkspace(name, ""); err == nil {
t.Fatalf("CreateWorkspace(%q) succeeded, want invalid name error", name)
}
}
}
func TestCompatibilityTreeIsDerivedFromTopLevelFolders(t *testing.T) {
vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Project"))
mustMkdir(t, filepath.Join(vaultDir, "Project", "Nested"))
mustMkdir(t, filepath.Join(vaultDir, "Test"))
m := NewManager(vaultDir)
if err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
tree := m.GetTree()
if len(tree.Nodes) != 2 {
t.Errorf("expected 2 nodes, got %d", len(tree.Nodes))
t.Fatalf("nodes = %+v, want 2 top-level workspaces", tree.Nodes)
}
if tree.Nodes[0].ID != "Project" || tree.Nodes[0].Title != "Project" || tree.Nodes[0].Path != "" {
t.Fatalf("first compatibility node = %+v, want derived workspace without persisted path mapping", tree.Nodes[0])
}
for _, node := range tree.Nodes {
if node.ParentID != "" {
t.Fatalf("compatibility tree should be flat, got child node %+v", node)
}
if node.ID == "Nested" || node.Title == "Nested" {
t.Fatalf("nested folders must not become workspace nodes: %+v", tree.Nodes)
}
}
}
func TestCreateNode_DuplicateTitlesGetUniquePaths(t *testing.T) {
dir := t.TempDir()
vaultDir := filepath.Join(dir, "vault")
os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755)
func TestMoveNodeCompatibilityDoesNotCreateNestedWorkspaceModel(t *testing.T) {
vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Project"))
mustMkdir(t, filepath.Join(vaultDir, "Test"))
m := NewManager(vaultDir)
if err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
err := m.MoveNode("Project", "Test")
if err == nil || !strings.Contains(err.Error(), "top-level only") {
t.Fatalf("MoveNode error = %v, want top-level only", err)
}
if _, statErr := os.Stat(filepath.Join(vaultDir, "Test", "Project")); !os.IsNotExist(statErr) {
t.Fatalf("MoveNode created nested mapped workspace, stat err=%v", statErr)
}
}
rootID := m.GetTree().Nodes[0].ID
first, err := m.CreateNode(rootID, TypeCase, "SameName")
func TestMetadataFileShape(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("Project", "default"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
data, err := os.ReadFile(m.metadataPath("Project"))
if err != nil {
t.Fatalf("CreateNode first: %v", err)
t.Fatalf("read metadata: %v", err)
}
second, err := m.CreateNode(rootID, TypeCase, "SameName")
if err != nil {
t.Fatalf("CreateNode second: %v", err)
var raw map[string]interface{}
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatalf("metadata JSON: %v", err)
}
if first.Path == second.Path {
t.Fatalf("expected unique paths, got %q", first.Path)
if raw["workspaceName"] != "Project" {
t.Fatalf("workspaceName = %v", raw["workspaceName"])
}
if second.Path != filepath.Join("My Workspace", "SameName (2)") {
t.Errorf("second path: got %q, want %q", second.Path, filepath.Join("My Workspace", "SameName (2)"))
if _, ok := raw["createdFromTemplate"].(map[string]interface{}); !ok {
t.Fatalf("createdFromTemplate missing in raw metadata: %s", data)
}
}
func TestCreateNode_InvalidType(t *testing.T) {
dir := t.TempDir()
vaultDir := filepath.Join(dir, "vault")
os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755)
func newVaultDir(t *testing.T) string {
t.Helper()
vaultDir := filepath.Join(t.TempDir(), "vault")
mustMkdir(t, vaultDir)
mustMkdir(t, filepath.Join(vaultDir, ".verstak", "trash"))
return vaultDir
}
m := NewManager(vaultDir)
m.Load()
_, err := m.CreateNode("", NodeType("note"), "My Note")
if err == nil {
t.Error("expected error for invalid type 'note'")
func mustMkdir(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatalf("MkdirAll(%s): %v", path, err)
}
}
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")
func mustWrite(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll(%s): %v", filepath.Dir(path), err)
}
_, err = m.CreateNode("", TypeCase, " ")
if err == nil {
t.Error("expected error for whitespace-only title")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile(%s): %v", path, err)
}
}
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)
}
if moved.Path != filepath.Join("My Workspace", "Folder", "Case") {
t.Errorf("path: got %q, want %q", moved.Path, filepath.Join("My Workspace", "Folder", "Case"))
}
if _, err := os.Stat(filepath.Join(vaultDir, moved.Path)); err != nil {
t.Fatalf("expected moved folder to exist: %v", err)
}
}
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_SameParentKeepsPath(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")
if err := m.MoveNode(node.ID, rootID); err != nil {
t.Fatalf("MoveNode: %v", err)
}
moved, _ := m.GetNode(node.ID)
if moved.Path != node.Path {
t.Errorf("path changed on same-parent move: got %q, want %q", moved.Path, node.Path)
}
}
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")
func workspaceNames(workspaces []Workspace) []string {
names := make([]string, len(workspaces))
for i, ws := range workspaces {
names[i] = ws.Name
}
return names
}