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:
@@ -115,9 +115,10 @@ func (v *Vault) CreateVault(path string) error {
|
||||
return fmt.Errorf("failed to write vault.json: %w", err)
|
||||
}
|
||||
|
||||
// Create workspace.json with root node
|
||||
// Create the initial physical workspace folder. Workspace listing is still
|
||||
// sourced from top-level vault folders, not from .verstak metadata.
|
||||
wsMgr := workspace.NewManager(vaultDir)
|
||||
if err := wsMgr.Load(); err != nil {
|
||||
if _, err := wsMgr.CreateWorkspace("Workspace", "default"); err != nil {
|
||||
return fmt.Errorf("failed to create workspace: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -259,44 +259,14 @@ func TestCreateVault_CreatesWorkspace(t *testing.T) {
|
||||
t.Fatalf("CreateVault: %v", err)
|
||||
}
|
||||
|
||||
wsPath := filepath.Join(v.GetVaultPath(), ".verstak", "workspace.json")
|
||||
data, err := os.ReadFile(wsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("workspace.json not found: %v", err)
|
||||
if _, err := os.Stat(filepath.Join(v.GetVaultPath(), "Workspace")); err != nil {
|
||||
t.Fatalf("Workspace folder not found: %v", err)
|
||||
}
|
||||
|
||||
var ws struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Nodes []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
ParentID string `json:"parentId"`
|
||||
} `json:"nodes"`
|
||||
CurrentNodeID string `json:"currentNodeId"`
|
||||
if _, err := os.Stat(filepath.Join(v.GetVaultPath(), "Workspace", "Notes", "Overview.md")); err != nil {
|
||||
t.Fatalf("default workspace overview not found: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &ws); err != nil {
|
||||
t.Fatalf("failed to parse workspace.json: %v", err)
|
||||
}
|
||||
|
||||
if ws.SchemaVersion != 1 {
|
||||
t.Errorf("schemaVersion: got %d, want 1", ws.SchemaVersion)
|
||||
}
|
||||
if len(ws.Nodes) != 1 {
|
||||
t.Fatalf("expected 1 root node, got %d", len(ws.Nodes))
|
||||
}
|
||||
if ws.Nodes[0].Type != "space" {
|
||||
t.Errorf("root type: got %q, want %q", ws.Nodes[0].Type, "space")
|
||||
}
|
||||
if ws.Nodes[0].Title != "My Workspace" {
|
||||
t.Errorf("root title: got %q, want %q", ws.Nodes[0].Title, "My Workspace")
|
||||
}
|
||||
if ws.Nodes[0].Status != "active" {
|
||||
t.Errorf("root status: got %q, want %q", ws.Nodes[0].Status, "active")
|
||||
}
|
||||
if ws.CurrentNodeID != ws.Nodes[0].ID {
|
||||
t.Errorf("currentNodeId should be root node id")
|
||||
if _, err := os.Stat(filepath.Join(v.GetVaultPath(), ".verstak", "workspace.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("workspace.json should not be created as workspace source of truth, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,27 +287,11 @@ func TestOpenVault_WorkspaceLoads(t *testing.T) {
|
||||
t.Fatalf("OpenVault: %v", err)
|
||||
}
|
||||
|
||||
wsPath := filepath.Join(v.GetVaultPath(), ".verstak", "workspace.json")
|
||||
data, err := os.ReadFile(wsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("workspace.json not found after reopen: %v", err)
|
||||
if _, err := os.Stat(filepath.Join(v.GetVaultPath(), "Workspace")); err != nil {
|
||||
t.Fatalf("Workspace folder should still exist after reopen: %v", err)
|
||||
}
|
||||
|
||||
var ws struct {
|
||||
Nodes []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
} `json:"nodes"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &ws); err != nil {
|
||||
t.Fatalf("failed to parse workspace.json: %v", err)
|
||||
}
|
||||
if len(ws.Nodes) != 1 {
|
||||
t.Fatalf("expected 1 node after reopen, got %d", len(ws.Nodes))
|
||||
}
|
||||
if ws.Nodes[0].Type != "space" {
|
||||
t.Errorf("root type after reopen: got %q, want %q", ws.Nodes[0].Type, "space")
|
||||
if _, err := os.Stat(filepath.Join(v.GetVaultPath(), ".verstak", "workspace.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("OpenVault should not create workspace.json, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+665
-531
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user