diff --git a/frontend/src/lib/plugin-host/VerstakPluginAPI.js b/frontend/src/lib/plugin-host/VerstakPluginAPI.js index d2dc7c0..dd42c93 100644 --- a/frontend/src/lib/plugin-host/VerstakPluginAPI.js +++ b/frontend/src/lib/plugin-host/VerstakPluginAPI.js @@ -266,6 +266,12 @@ export function createPluginAPI(pluginId) { return App.TrashVaultPath(pluginId, relativePath); }); }, + listTrash: function() { + assertActive('files.listTrash'); + return callBackend(pluginId, 'files.listTrash', function() { + return App.ListVaultTrash(pluginId); + }); + }, openExternal: function(relativePath) { assertActive('files.openExternal(' + relativePath + ')'); return callBackendErrorString(pluginId, 'files.openExternal(' + relativePath + ')', function() { diff --git a/frontend/src/lib/test/wails-mock.js b/frontend/src/lib/test/wails-mock.js index a2538cc..183298b 100644 --- a/frontend/src/lib/test/wails-mock.js +++ b/frontend/src/lib/test/wails-mock.js @@ -218,6 +218,7 @@ }; var vaultFiles = makeDefaultVaultFiles(); var externalOpens = []; + var trashEntries = []; window.__wailsMockExternalOpens = []; var workspaceTree = makeDefaultWorkspaceTree(); var reloadResponseMode = 'tuple'; @@ -1342,9 +1343,17 @@ if (!vaultFiles[norm.path]) return Promise.resolve([{}, 'not-found: ' + norm.path]); var trashId = 'mock-' + Date.now() + '-' + Math.random().toString(16).slice(2); var trashPath = '.verstak/trash/files/' + trashId + '/' + baseName(norm.path); + var originalType = vaultFiles[norm.path].type || 'file'; var moving = Object.keys(vaultFiles).filter(function (path) { return path === norm.path || path.indexOf(norm.path + '/') === 0; }); moving.forEach(function (path) { delete vaultFiles[path]; }); - return Promise.resolve([{ originalPath: norm.path, trashPath: trashPath, trashId: trashId, deletedAt: new Date().toISOString() }, '']); + var entry = { originalPath: norm.path, trashPath: trashPath, trashId: trashId, deletedAt: new Date().toISOString(), originalType: originalType, basename: baseName(norm.path) }; + trashEntries.unshift(entry); + return Promise.resolve([entry, '']); + }, + ListVaultTrash: function (pluginId) { + var err = requirePluginPermission(pluginId, 'files.delete'); + if (err) return Promise.resolve([[], err]); + return Promise.resolve([trashEntries.slice(), '']); }, OpenVaultPathExternal: function (pluginId, relativePath) { var err = requirePluginPermission(pluginId, 'files.openExternal'); diff --git a/frontend/wailsjs/go/api/App.d.ts b/frontend/wailsjs/go/api/App.d.ts index aa4206a..8e29cb9 100755 --- a/frontend/wailsjs/go/api/App.d.ts +++ b/frontend/wailsjs/go/api/App.d.ts @@ -126,6 +126,8 @@ export function SubscribePluginEvent(arg1:string,arg2:string):Promise; export function TrashVaultPath(arg1:string,arg2:string):Promise; +export function ListVaultTrash(arg1:string):Promise; + export function TrashWorkspace(arg1:string):Promise; export function UpdateAppSettings(arg1:Record):Promise; diff --git a/frontend/wailsjs/go/api/App.js b/frontend/wailsjs/go/api/App.js index 7d8ef3c..1ec8637 100755 --- a/frontend/wailsjs/go/api/App.js +++ b/frontend/wailsjs/go/api/App.js @@ -238,6 +238,10 @@ export function TrashVaultPath(arg1, arg2) { return window['go']['api']['App']['TrashVaultPath'](arg1, arg2); } +export function ListVaultTrash(arg1) { + return window['go']['api']['App']['ListVaultTrash'](arg1); +} + export function TrashWorkspace(arg1) { return window['go']['api']['App']['TrashWorkspace'](arg1); } diff --git a/internal/api/app.go b/internal/api/app.go index 1e58423..9130bdd 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -972,6 +972,21 @@ func (a *App) TrashVaultPath(pluginID, relativePath string) (corefiles.TrashResu return result, "" } +// ListVaultTrash returns trash metadata entries for a plugin with files.delete. +func (a *App) ListVaultTrash(pluginID string) ([]corefiles.TrashEntry, string) { + if _, err := a.requirePluginAccess(pluginID, "files.delete"); err != nil { + return nil, err.Error() + } + if a.files == nil { + return nil, "files service not initialized" + } + entries, err := a.files.ListTrashEntries() + if err != nil { + return nil, err.Error() + } + return entries, "" +} + // OpenVaultPathExternal opens a vault-relative file or folder in the OS default app. func (a *App) OpenVaultPathExternal(pluginID, relativePath string) string { if _, err := a.requirePluginAccess(pluginID, "files.openExternal"); err != nil { diff --git a/internal/api/app_test.go b/internal/api/app_test.go index dba51e4..ab6a801 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -388,6 +388,13 @@ func TestFilesBridgeReadWriteListMoveTrash(t *testing.T) { if _, err := os.Stat(filepath.Join(root, trash.TrashPath)); err != nil { t.Fatalf("trash path missing: %v", err) } + trashEntries, errStr := app.ListVaultTrash("files.plugin") + if errStr != "" { + t.Fatalf("ListVaultTrash: %s", errStr) + } + if len(trashEntries) != 1 || trashEntries[0].OriginalPath != "Docs/two.txt" || trashEntries[0].TrashID != trash.TrashID { + t.Fatalf("trash entries = %+v, want Docs/two.txt", trashEntries) + } } func TestFilesBridgeWritePublishesFileChangedActivityEvent(t *testing.T) { diff --git a/internal/core/files/service.go b/internal/core/files/service.go index 980202d..b4f8516 100644 --- a/internal/core/files/service.go +++ b/internal/core/files/service.go @@ -7,6 +7,7 @@ import ( "mime" "os" "path/filepath" + "sort" "strings" "time" "unicode/utf8" @@ -337,6 +338,64 @@ func (s *Service) TrashVaultPath(relativePath string) (TrashResult, error) { return result, nil } +func (s *Service) ListTrashEntries() ([]TrashEntry, error) { + root, err := s.vaultRoot() + if err != nil { + return nil, err + } + trashRoot := filepath.Join(root, ".verstak", "trash", "files") + dirs, err := os.ReadDir(trashRoot) + if err != nil { + if os.IsNotExist(err) { + return []TrashEntry{}, nil + } + return nil, err + } + + entries := make([]TrashEntry, 0, len(dirs)) + for _, dir := range dirs { + if !dir.IsDir() { + continue + } + data, err := os.ReadFile(filepath.Join(trashRoot, dir.Name(), "metadata.json")) + if err != nil { + if os.IsNotExist(err) { + continue + } + return nil, err + } + var raw struct { + OriginalPath string `json:"originalPath"` + TrashPath string `json:"trashPath"` + TrashID string `json:"trashId"` + DeletedAt string `json:"deletedAt"` + OriginalType string `json:"originalType"` + Basename string `json:"basename"` + } + if err := json.Unmarshal(data, &raw); err != nil { + continue + } + if raw.OriginalPath == "" || raw.TrashPath == "" || raw.TrashID == "" || raw.DeletedAt == "" { + continue + } + entries = append(entries, TrashEntry{ + OriginalPath: raw.OriginalPath, + TrashPath: raw.TrashPath, + TrashID: raw.TrashID, + DeletedAt: raw.DeletedAt, + OriginalType: FileType(raw.OriginalType), + Basename: raw.Basename, + }) + } + sort.Slice(entries, func(i, j int) bool { + if entries[i].DeletedAt != entries[j].DeletedAt { + return entries[i].DeletedAt > entries[j].DeletedAt + } + return entries[i].TrashID > entries[j].TrashID + }) + return entries, nil +} + func (s *Service) vaultRoot() (string, error) { if s == nil || s.vault == nil { return "", fmt.Errorf("vault-not-initialized") diff --git a/internal/core/files/service_test.go b/internal/core/files/service_test.go index eba3c8a..2aa8e39 100644 --- a/internal/core/files/service_test.go +++ b/internal/core/files/service_test.go @@ -301,6 +301,39 @@ func TestTrashVaultPathMovesToReservedTrashAndHidesFromList(t *testing.T) { } } +func TestListTrashEntriesReturnsMetadata(t *testing.T) { + s, root := newTestService(t) + if err := os.WriteFile(filepath.Join(root, "old.txt"), []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "new.txt"), []byte("new"), 0o644); err != nil { + t.Fatal(err) + } + + oldResult, err := s.TrashVaultPath("old.txt") + if err != nil { + t.Fatalf("trash old: %v", err) + } + newResult, err := s.TrashVaultPath("new.txt") + if err != nil { + t.Fatalf("trash new: %v", err) + } + + entries, err := s.ListTrashEntries() + if err != nil { + t.Fatalf("ListTrashEntries: %v", err) + } + if len(entries) != 2 { + t.Fatalf("trash entries count = %d, want 2: %+v", len(entries), entries) + } + if entries[0].TrashID != newResult.TrashID || entries[1].TrashID != oldResult.TrashID { + t.Fatalf("trash entries order = %+v, want newest first", entries) + } + if entries[0].OriginalPath != "new.txt" || entries[0].TrashPath != newResult.TrashPath || entries[0].OriginalType != FileTypeFile || entries[0].Basename != "new.txt" { + t.Fatalf("new trash entry = %+v", entries[0]) + } +} + func TestSymlinkEscapeRejected(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("symlink creation requires privileges on many Windows test environments") diff --git a/internal/core/files/types.go b/internal/core/files/types.go index 622b7aa..d19b8e5 100644 --- a/internal/core/files/types.go +++ b/internal/core/files/types.go @@ -60,3 +60,12 @@ type TrashResult struct { TrashID string `json:"trashId"` DeletedAt string `json:"deletedAt"` } + +type TrashEntry struct { + OriginalPath string `json:"originalPath"` + TrashPath string `json:"trashPath"` + TrashID string `json:"trashId"` + DeletedAt string `json:"deletedAt"` + OriginalType FileType `json:"originalType"` + Basename string `json:"basename"` +}