feat: publish workspace trash lifecycle
This commit is contained in:
parent
5280d245ee
commit
e1a6b2af64
|
|
@ -144,8 +144,12 @@ export function RenameWorkspaceNode(arg1:string,arg2:string):Promise<string>;
|
||||||
|
|
||||||
export function RestoreVaultTrash(arg1:string,arg2:string,arg3:files.RestoreOptions):Promise<string|string>;
|
export function RestoreVaultTrash(arg1:string,arg2:string,arg3:files.RestoreOptions):Promise<string|string>;
|
||||||
|
|
||||||
|
export function RestoreWorkspaceTrash(arg1:string,arg2:string):Promise<workspace.Workspace|string>;
|
||||||
|
|
||||||
export function SelectDirectory():Promise<string>;
|
export function SelectDirectory():Promise<string>;
|
||||||
|
|
||||||
|
export function PurgeWorkspaceTrash(arg1:string):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>;
|
||||||
|
|
|
||||||
|
|
@ -274,10 +274,18 @@ export function RestoreVaultTrash(arg1, arg2, arg3) {
|
||||||
return window['go']['api']['App']['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() {
|
export function SelectDirectory() {
|
||||||
return window['go']['api']['App']['SelectDirectory']();
|
return window['go']['api']['App']['SelectDirectory']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PurgeWorkspaceTrash(arg1) {
|
||||||
|
return window['go']['api']['App']['PurgeWorkspaceTrash'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SelectVaultForOpen() {
|
export function SelectVaultForOpen() {
|
||||||
return window['go']['api']['App']['SelectVaultForOpen']();
|
return window['go']['api']['App']['SelectVaultForOpen']();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@ const maxBrowserInboxCaptures = 100
|
||||||
const workspaceCreatedEventName = "workspace.created"
|
const workspaceCreatedEventName = "workspace.created"
|
||||||
const workspaceRenamedEventName = "workspace.renamed"
|
const workspaceRenamedEventName = "workspace.renamed"
|
||||||
const workspaceTrashedEventName = "workspace.trashed"
|
const workspaceTrashedEventName = "workspace.trashed"
|
||||||
|
const workspaceRestoredEventName = "workspace.restored"
|
||||||
|
const workspacePurgedEventName = "workspace.purged"
|
||||||
const workspaceSelectedEventName = "workspace.selected"
|
const workspaceSelectedEventName = "workspace.selected"
|
||||||
|
|
||||||
// App is the main application struct exposed to the Wails frontend.
|
// 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, ""
|
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.
|
// GetWorkspaceMetadata returns metadata or a generic fallback for a workspace.
|
||||||
func (a *App) GetWorkspaceMetadata(name string) (workspace.Metadata, string) {
|
func (a *App) GetWorkspaceMetadata(name string) (workspace.Metadata, string) {
|
||||||
if a.workspace == nil {
|
if a.workspace == nil {
|
||||||
|
|
@ -2069,9 +2112,15 @@ func (a *App) GetCurrentWorkspace() map[string]interface{} {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return map[string]interface{}{"error": err.Error()}
|
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{}{
|
return map[string]interface{}{
|
||||||
"name": node.Name,
|
"id": identity.WorkspaceID,
|
||||||
"rootPath": node.RootPath,
|
"workspaceId": identity.WorkspaceID,
|
||||||
|
"name": node.Name,
|
||||||
|
"rootPath": node.RootPath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1826,6 +1826,10 @@ func TestWorkspaceAPIPublishesLifecycleEvents(t *testing.T) {
|
||||||
if errStr := app.SetCurrentWorkspace("Project"); errStr != "" {
|
if errStr := app.SetCurrentWorkspace("Project"); errStr != "" {
|
||||||
t.Fatalf("SetCurrentWorkspace: %s", 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 != "" {
|
if errStr := app.RenameWorkspace("Project", "Renamed"); errStr != "" {
|
||||||
t.Fatalf("RenameWorkspace: %s", 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) {
|
func TestMoveWorkspaceNodeCompatibilityIsUnsupported(t *testing.T) {
|
||||||
app, vaultDir := newFilesTestApp(t, []string{"files.read"})
|
app, vaultDir := newFilesTestApp(t, []string{"files.read"})
|
||||||
app.workspace = workspace.NewManager(vaultDir)
|
app.workspace = workspace.NewManager(vaultDir)
|
||||||
|
|
|
||||||
|
|
@ -490,14 +490,22 @@ func writeWorkspaceIdentity(workspacePath string) (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureWorkspaceIdentity(workspacePath string) (string, error) {
|
func ensureWorkspaceIdentity(workspacePath string) (string, error) {
|
||||||
markerPath := filepath.Join(workspacePath, filepath.FromSlash(workspaceIdentityRelativePath))
|
workspaceID, err := readWorkspaceIdentity(workspacePath)
|
||||||
data, err := os.ReadFile(markerPath)
|
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return writeWorkspaceIdentity(workspacePath)
|
return writeWorkspaceIdentity(workspacePath)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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
|
var marker workspaceIdentityMarker
|
||||||
if err := json.Unmarshal(data, &marker); err != nil {
|
if err := json.Unmarshal(data, &marker); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|
@ -508,6 +516,33 @@ func ensureWorkspaceIdentity(workspacePath string) (string, error) {
|
||||||
return marker.WorkspaceID, nil
|
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.
|
// ListWorkspaceTemplates returns selectable built-ins in their presentation order.
|
||||||
func (m *Manager) ListWorkspaceTemplates() []WorkspaceTemplate {
|
func (m *Manager) ListWorkspaceTemplates() []WorkspaceTemplate {
|
||||||
templates := make([]WorkspaceTemplate, 0, len(builtInTemplates))
|
templates := make([]WorkspaceTemplate, 0, len(builtInTemplates))
|
||||||
|
|
@ -647,6 +682,83 @@ func (m *Manager) TrashWorkspace(name string) (TrashResult, error) {
|
||||||
return result, nil
|
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.
|
// GetWorkspaceMetadata returns stored metadata or safe generic metadata.
|
||||||
func (m *Manager) GetWorkspaceMetadata(name string) (Metadata, error) {
|
func (m *Manager) GetWorkspaceMetadata(name string) (Metadata, error) {
|
||||||
name = strings.TrimSpace(name)
|
name = strings.TrimSpace(name)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestCreateAndRenameConflictsAreExplicit(t *testing.T) {
|
||||||
vaultDir := newVaultDir(t)
|
vaultDir := newVaultDir(t)
|
||||||
mustMkdir(t, filepath.Join(vaultDir, "Existing"))
|
mustMkdir(t, filepath.Join(vaultDir, "Existing"))
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue