diff --git a/frontend/wailsjs/go/api/App.d.ts b/frontend/wailsjs/go/api/App.d.ts index c9f04e5..5e8465d 100755 --- a/frontend/wailsjs/go/api/App.d.ts +++ b/frontend/wailsjs/go/api/App.d.ts @@ -144,8 +144,12 @@ export function RenameWorkspaceNode(arg1:string,arg2:string):Promise; export function RestoreVaultTrash(arg1:string,arg2:string,arg3:files.RestoreOptions):Promise; +export function RestoreWorkspaceTrash(arg1:string,arg2:string):Promise; + export function SelectDirectory():Promise; +export function PurgeWorkspaceTrash(arg1:string):Promise; + export function SelectVaultForOpen():Promise; export function SetCurrentVault(arg1:string):Promise; diff --git a/frontend/wailsjs/go/api/App.js b/frontend/wailsjs/go/api/App.js index f7aeb93..49c29e0 100755 --- a/frontend/wailsjs/go/api/App.js +++ b/frontend/wailsjs/go/api/App.js @@ -274,10 +274,18 @@ export function RestoreVaultTrash(arg1, arg2, arg3) { return window['go']['api']['App']['RestoreVaultTrash'](arg1, arg2, arg3); } +export function RestoreWorkspaceTrash(arg1, arg2) { + return window['go']['api']['App']['RestoreWorkspaceTrash'](arg1, arg2); +} + export function SelectDirectory() { return window['go']['api']['App']['SelectDirectory'](); } +export function PurgeWorkspaceTrash(arg1) { + return window['go']['api']['App']['PurgeWorkspaceTrash'](arg1); +} + export function SelectVaultForOpen() { return window['go']['api']['App']['SelectVaultForOpen'](); } diff --git a/internal/api/app.go b/internal/api/app.go index 94fd336..3bb1c12 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -52,6 +52,8 @@ const maxBrowserInboxCaptures = 100 const workspaceCreatedEventName = "workspace.created" const workspaceRenamedEventName = "workspace.renamed" const workspaceTrashedEventName = "workspace.trashed" +const workspaceRestoredEventName = "workspace.restored" +const workspacePurgedEventName = "workspace.purged" const workspaceSelectedEventName = "workspace.selected" // App is the main application struct exposed to the Wails frontend. @@ -2036,6 +2038,47 @@ func (a *App) TrashWorkspace(name string) (workspace.TrashResult, string) { return result, "" } +// RestoreWorkspaceTrash restores a trashed workspace and publishes its durable identity. +func (a *App) RestoreWorkspaceTrash(trashID, targetName string) (workspace.Workspace, string) { + if a.workspace == nil { + return workspace.Workspace{}, "workspace not initialized" + } + restored, err := a.workspace.RestoreWorkspaceTrash(trashID, targetName) + if err != nil { + return workspace.Workspace{}, err.Error() + } + a.publishWorkspaceLifecycleEvent(workspaceRestoredEventName, map[string]interface{}{ + "operation": "restore", + "workspaceId": restored.ID, + "workspaceRootPath": restored.RootPath, + "workspaceName": restored.Name, + "trashId": trashID, + }) + return restored, "" +} + +// PurgeWorkspaceTrash permanently removes a trashed workspace and publishes its former identity. +func (a *App) PurgeWorkspaceTrash(trashID string) string { + if a.workspace == nil { + return "workspace not initialized" + } + identity, err := a.workspace.GetWorkspaceTrashIdentity(trashID) + if err != nil { + return err.Error() + } + if err := a.workspace.PurgeWorkspaceTrash(trashID); err != nil { + return err.Error() + } + a.publishWorkspaceLifecycleEvent(workspacePurgedEventName, map[string]interface{}{ + "operation": "purge", + "workspaceId": identity.WorkspaceID, + "workspaceRootPath": identity.RootPath, + "workspaceName": identity.RootPath, + "trashId": trashID, + }) + return "" +} + // GetWorkspaceMetadata returns metadata or a generic fallback for a workspace. func (a *App) GetWorkspaceMetadata(name string) (workspace.Metadata, string) { if a.workspace == nil { @@ -2069,9 +2112,15 @@ func (a *App) GetCurrentWorkspace() map[string]interface{} { if err != nil { return map[string]interface{}{"error": err.Error()} } + identity, err := a.workspace.GetWorkspaceIdentity(node.Name) + if err != nil { + return map[string]interface{}{"error": err.Error()} + } return map[string]interface{}{ - "name": node.Name, - "rootPath": node.RootPath, + "id": identity.WorkspaceID, + "workspaceId": identity.WorkspaceID, + "name": node.Name, + "rootPath": node.RootPath, } } diff --git a/internal/api/app_test.go b/internal/api/app_test.go index 7a21c0b..21e23a2 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -1826,6 +1826,10 @@ func TestWorkspaceAPIPublishesLifecycleEvents(t *testing.T) { if errStr := app.SetCurrentWorkspace("Project"); errStr != "" { t.Fatalf("SetCurrentWorkspace: %s", errStr) } + currentID, ok := app.GetCurrentWorkspace()["workspaceId"].(string) + if !ok || currentID == "" { + t.Fatalf("GetCurrentWorkspace workspaceId = %#v", app.GetCurrentWorkspace()["workspaceId"]) + } if errStr := app.RenameWorkspace("Project", "Renamed"); errStr != "" { t.Fatalf("RenameWorkspace: %s", errStr) } @@ -1891,6 +1895,46 @@ func TestWorkspaceIdentityAPIListsAndRepairsDuplicates(t *testing.T) { } } +func TestWorkspaceTrashRestoreAndPurgePublishIdentityLifecycle(t *testing.T) { + app, vaultDir := newFilesTestApp(t, []string{"files.read"}) + app.workspace = workspace.NewManager(vaultDir) + app.eventBus = events.NewBus() + if err := app.workspace.Load(); err != nil { + t.Fatal(err) + } + received := map[string]map[string]interface{}{} + for _, eventName := range []string{"workspace.restored", "workspace.purged"} { + name := eventName + app.eventBus.Subscribe(name, func(event events.Event) { + received[name], _ = event.Payload.(map[string]interface{}) + }) + } + if _, errStr := app.CreateWorkspace("Client", "default"); errStr != "" { + t.Fatalf("CreateWorkspace: %s", errStr) + } + trashed, errStr := app.TrashWorkspace("Client") + if errStr != "" { + t.Fatalf("TrashWorkspace: %s", errStr) + } + restored, errStr := app.RestoreWorkspaceTrash(trashed.TrashID, "Client-Restored") + if errStr != "" { + t.Fatalf("RestoreWorkspaceTrash: %s", errStr) + } + if got := received["workspace.restored"]["workspaceId"]; got != restored.ID { + t.Fatalf("restored workspaceId = %#v, want %q", got, restored.ID) + } + trashed, errStr = app.TrashWorkspace("Client-Restored") + if errStr != "" { + t.Fatalf("TrashWorkspace restored: %s", errStr) + } + if errStr := app.PurgeWorkspaceTrash(trashed.TrashID); errStr != "" { + t.Fatalf("PurgeWorkspaceTrash: %s", errStr) + } + if got := received["workspace.purged"]["workspaceId"]; got != restored.ID { + t.Fatalf("purged workspaceId = %#v, want %q", got, restored.ID) + } +} + func TestMoveWorkspaceNodeCompatibilityIsUnsupported(t *testing.T) { app, vaultDir := newFilesTestApp(t, []string{"files.read"}) app.workspace = workspace.NewManager(vaultDir) diff --git a/internal/core/workspace/manager.go b/internal/core/workspace/manager.go index 41ae554..77e1a24 100644 --- a/internal/core/workspace/manager.go +++ b/internal/core/workspace/manager.go @@ -490,14 +490,22 @@ func writeWorkspaceIdentity(workspacePath string) (string, error) { } func ensureWorkspaceIdentity(workspacePath string) (string, error) { - markerPath := filepath.Join(workspacePath, filepath.FromSlash(workspaceIdentityRelativePath)) - data, err := os.ReadFile(markerPath) + workspaceID, err := readWorkspaceIdentity(workspacePath) if os.IsNotExist(err) { return writeWorkspaceIdentity(workspacePath) } if err != nil { return "", err } + return workspaceID, nil +} + +func readWorkspaceIdentity(workspacePath string) (string, error) { + markerPath := filepath.Join(workspacePath, filepath.FromSlash(workspaceIdentityRelativePath)) + data, err := os.ReadFile(markerPath) + if err != nil { + return "", err + } var marker workspaceIdentityMarker if err := json.Unmarshal(data, &marker); err != nil { return "", err @@ -508,6 +516,33 @@ func ensureWorkspaceIdentity(workspacePath string) (string, error) { return marker.WorkspaceID, nil } +// GetWorkspaceTrashIdentity reads a trashed workspace identity without restoring it. +func (m *Manager) GetWorkspaceTrashIdentity(trashID string) (WorkspaceIdentity, error) { + trashID = strings.TrimSpace(trashID) + if err := validateWorkspaceTrashID(trashID); err != nil { + return WorkspaceIdentity{}, err + } + trashDir := filepath.Join(m.vaultDir, ".verstak", "trash", "workspaces", trashID) + data, err := os.ReadFile(filepath.Join(trashDir, "metadata.json")) + if err != nil { + return WorkspaceIdentity{}, err + } + var trashMeta struct { + OriginalPath string `json:"originalPath"` + } + if err := json.Unmarshal(data, &trashMeta); err != nil { + return WorkspaceIdentity{}, err + } + if err := validateWorkspaceName(trashMeta.OriginalPath); err != nil { + return WorkspaceIdentity{}, err + } + workspaceID, err := readWorkspaceIdentity(filepath.Join(trashDir, trashMeta.OriginalPath)) + if err != nil { + return WorkspaceIdentity{}, err + } + return WorkspaceIdentity{WorkspaceID: workspaceID, RootPath: trashMeta.OriginalPath, State: "trashed"}, nil +} + // ListWorkspaceTemplates returns selectable built-ins in their presentation order. func (m *Manager) ListWorkspaceTemplates() []WorkspaceTemplate { templates := make([]WorkspaceTemplate, 0, len(builtInTemplates)) @@ -647,6 +682,83 @@ func (m *Manager) TrashWorkspace(name string) (TrashResult, error) { return result, nil } +// RestoreWorkspaceTrash restores a trashed workspace under targetName without changing its identity. +func (m *Manager) RestoreWorkspaceTrash(trashID, targetName string) (Workspace, error) { + trashID = strings.TrimSpace(trashID) + targetName = strings.TrimSpace(targetName) + if err := validateWorkspaceTrashID(trashID); err != nil { + return Workspace{}, err + } + if err := validateWorkspaceName(targetName); err != nil { + return Workspace{}, err + } + trashDir := filepath.Join(m.vaultDir, ".verstak", "trash", "workspaces", trashID) + data, err := os.ReadFile(filepath.Join(trashDir, "metadata.json")) + if err != nil { + return Workspace{}, err + } + var trashMeta struct { + OriginalPath string `json:"originalPath"` + } + if err := json.Unmarshal(data, &trashMeta); err != nil { + return Workspace{}, err + } + if err := validateWorkspaceName(trashMeta.OriginalPath); err != nil { + return Workspace{}, err + } + payloadPath := filepath.Join(trashDir, trashMeta.OriginalPath) + if err := ensureExistingWorkspaceDir(payloadPath, trashMeta.OriginalPath); err != nil { + return Workspace{}, err + } + targetPath := filepath.Join(m.vaultDir, targetName) + if _, err := os.Lstat(targetPath); err == nil { + return Workspace{}, fmt.Errorf("conflict: %s", targetName) + } else if !os.IsNotExist(err) { + return Workspace{}, err + } + if err := os.Rename(payloadPath, targetPath); err != nil { + return Workspace{}, err + } + restored := true + defer func() { + if restored { + _ = os.Rename(targetPath, payloadPath) + } + }() + if err := moveIfExists(filepath.Join(trashDir, "workspace.metadata.json"), m.metadataPath(targetName)); err != nil { + return Workspace{}, err + } + workspaceID, err := ensureWorkspaceIdentity(targetPath) + if err != nil { + return Workspace{}, err + } + if err := os.RemoveAll(trashDir); err != nil { + return Workspace{}, err + } + restored = false + return Workspace{ID: workspaceID, Name: targetName, RootPath: targetName}, nil +} + +func validateWorkspaceTrashID(trashID string) error { + if trashID == "" || strings.ContainsAny(trashID, `/\\`) || filepath.Clean(trashID) != trashID { + return fmt.Errorf("invalid workspace trash ID") + } + return nil +} + +// PurgeWorkspaceTrash permanently removes a workspace trash entry. +func (m *Manager) PurgeWorkspaceTrash(trashID string) error { + trashID = strings.TrimSpace(trashID) + if err := validateWorkspaceTrashID(trashID); err != nil { + return err + } + trashDir := filepath.Join(m.vaultDir, ".verstak", "trash", "workspaces", trashID) + if _, err := os.Stat(filepath.Join(trashDir, "metadata.json")); err != nil { + return err + } + return os.RemoveAll(trashDir) +} + // GetWorkspaceMetadata returns stored metadata or safe generic metadata. func (m *Manager) GetWorkspaceMetadata(name string) (Metadata, error) { name = strings.TrimSpace(name) diff --git a/internal/core/workspace/manager_test.go b/internal/core/workspace/manager_test.go index 66b3c19..1797ec0 100644 --- a/internal/core/workspace/manager_test.go +++ b/internal/core/workspace/manager_test.go @@ -412,6 +412,47 @@ func TestRepairWorkspaceIdentityRegeneratesCopiedMarker(t *testing.T) { } } +func TestRestoreWorkspaceTrashPreservesIdentity(t *testing.T) { + vaultDir := newVaultDir(t) + m := NewManager(vaultDir) + created, err := m.CreateWorkspace("Client", "default") + if err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + trashed, err := m.TrashWorkspace("Client") + if err != nil { + t.Fatalf("TrashWorkspace: %v", err) + } + restored, err := m.RestoreWorkspaceTrash(trashed.TrashID, "Client-Restored") + if err != nil { + t.Fatalf("RestoreWorkspaceTrash: %v", err) + } + if restored.Name != "Client-Restored" || restored.ID != created.ID { + t.Fatalf("restored = %+v, want name Client-Restored and ID %q", restored, created.ID) + } + if _, err := os.Stat(filepath.Join(vaultDir, "Client-Restored", ".verstak", "workspace.json")); err != nil { + t.Fatalf("restored marker: %v", err) + } +} + +func TestPurgeWorkspaceTrashRemovesPayload(t *testing.T) { + vaultDir := newVaultDir(t) + m := NewManager(vaultDir) + if _, err := m.CreateWorkspace("Client", "default"); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + trashed, err := m.TrashWorkspace("Client") + if err != nil { + t.Fatalf("TrashWorkspace: %v", err) + } + if err := m.PurgeWorkspaceTrash(trashed.TrashID); err != nil { + t.Fatalf("PurgeWorkspaceTrash: %v", err) + } + if _, err := os.Stat(filepath.Join(vaultDir, ".verstak", "trash", "workspaces", trashed.TrashID)); !os.IsNotExist(err) { + t.Fatalf("workspace trash remains, stat err=%v", err) + } +} + func TestCreateAndRenameConflictsAreExplicit(t *testing.T) { vaultDir := newVaultDir(t) mustMkdir(t, filepath.Join(vaultDir, "Existing"))