From 8445ea11db50a06728b793e6b3e1bf53adda3b88 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Fri, 10 Jul 2026 08:31:20 +0800 Subject: [PATCH] feat: support permanent vault trash deletion --- docs/PLUGIN_RUNTIME.md | 5 +++- internal/api/app.go | 14 +++++++++++ internal/api/app_test.go | 21 ++++++++++++++++ internal/core/files/service.go | 39 +++++++++++++++++++++++++++-- internal/core/files/service_test.go | 30 +++++++++++++++++++++- internal/core/files/types.go | 2 ++ 6 files changed, 107 insertions(+), 4 deletions(-) diff --git a/docs/PLUGIN_RUNTIME.md b/docs/PLUGIN_RUNTIME.md index 7128882..3b8cf72 100644 --- a/docs/PLUGIN_RUNTIME.md +++ b/docs/PLUGIN_RUNTIME.md @@ -462,6 +462,8 @@ contributions summary. - `files.restoreTrash(trashId, options)` — restores a file/folder from internal trash to its original vault-relative path, or to `options.targetPath`. Conflicts are rejected unless `options.overwrite` is true. +- `files.deleteTrash(trashId)` — permanently removes a file/folder already in + internal trash. This operation cannot be undone. - `files.openExternal(relativePath)` — opens a vault-relative file/folder in the OS default application. - `files.showInFolder(relativePath)` — reveals a vault-relative file/folder in @@ -572,9 +574,10 @@ bundled runtime. Это реальный runtime contract для cooperative bun | `api.files.writeBytes(relativePath, dataBase64, options)` | ✅ Работает | Atomic bounded byte write до 8 MB с явным create/overwrite policy | | `api.files.createFolder(relativePath)` | ✅ Работает | Создаёт vault-relative folder | | `api.files.move(from, to, options)` | ✅ Работает | Move file/folder с conflict и path-policy checks | -| `api.files.trash(relativePath)` | ✅ Работает | Перемещает в internal trash, permanent delete нет | +| `api.files.trash(relativePath)` | ✅ Работает | Перемещает в internal trash и сохраняет metadata исходного объекта | | `api.files.listTrash()` | ✅ Работает | Возвращает metadata internal file trash | | `api.files.restoreTrash(trashId, options)` | ✅ Работает | Восстанавливает из internal trash, conflict-safe по умолчанию | +| `api.files.deleteTrash(trashId)` | ✅ Работает | Необратимо удаляет запись и payload из internal trash | | `api.files.openExternal(relativePath)` | ✅ Работает | Открывает vault file/folder во внешнем приложении, требует `files.openExternal` | | `api.files.showInFolder(relativePath)` | ✅ Работает | Показывает vault file/folder в системном файловом менеджере, требует `files.openExternal` | | `api.workbench.openResource(request)` | ✅ Работает | Routes vault resources to `openProviders` | diff --git a/internal/api/app.go b/internal/api/app.go index 2974ec0..7f3f59b 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -1098,6 +1098,20 @@ func (a *App) RestoreVaultTrash(pluginID, trashID string, options corefiles.Rest return restoredPath, "" } +// DeleteVaultTrash permanently removes an internal trash entry for a plugin with files.delete. +func (a *App) DeleteVaultTrash(pluginID, trashID string) string { + if _, err := a.requirePluginAccess(pluginID, "files.delete"); err != nil { + return err.Error() + } + if a.files == nil { + return "files service not initialized" + } + if err := a.files.DeleteTrashEntry(trashID); err != nil { + return err.Error() + } + return "" +} + // 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 d4bc3c7..43bf079 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -436,6 +436,21 @@ func TestFilesBridgeReadWriteListMoveTrash(t *testing.T) { if _, err := os.Stat(filepath.Join(root, "Docs", "two.txt")); err != nil { t.Fatalf("restored file missing: %v", err) } + + permanent, errStr := app.TrashVaultPath("files.plugin", "Docs/two.txt") + if errStr != "" { + t.Fatalf("TrashVaultPath before permanent delete: %s", errStr) + } + if errStr := app.DeleteVaultTrash("files.plugin", permanent.TrashID); errStr != "" { + t.Fatalf("DeleteVaultTrash: %s", errStr) + } + trashEntries, errStr = app.ListVaultTrash("files.plugin") + if errStr != "" { + t.Fatalf("ListVaultTrash after permanent delete: %s", errStr) + } + if len(trashEntries) != 0 { + t.Fatalf("trash entries after permanent delete = %+v, want none", trashEntries) + } } func TestFilesBridgeWritePublishesFileChangedActivityEvent(t *testing.T) { @@ -707,6 +722,12 @@ func TestFilesBridgePermissions(t *testing.T) { }, wantPhrase: "files.write", }, + { + name: "permanent trash delete requires delete", + perms: []string{"files.read", "files.write"}, + call: func(app *App) string { return app.DeleteVaultTrash("files.plugin", "missing") }, + wantPhrase: "files.delete", + }, { name: "open external requires openExternal", perms: []string{"files.read", "files.write", "files.delete"}, diff --git a/internal/core/files/service.go b/internal/core/files/service.go index d0349ad..4f46dcd 100644 --- a/internal/core/files/service.go +++ b/internal/core/files/service.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "time" "unicode/utf8" @@ -365,20 +366,24 @@ func (s *Service) TrashVaultPath(relativePath string) (TrashResult, error) { if err := os.Rename(full, trashFull); err != nil { return TrashResult{}, err } + originalType := fileTypeFromInfo(info) + size := sizeForType(originalType, info) result := TrashResult{ OriginalPath: rel, TrashPath: trashRel, TrashID: trashID, DeletedAt: deletedAt, + Size: size, } meta := map[string]string{ "originalPath": rel, "trashPath": trashRel, "trashId": trashID, "deletedAt": deletedAt, - "originalType": string(fileTypeFromInfo(info)), + "originalType": string(originalType), "basename": filepath.Base(rel), - "type": string(fileTypeFromInfo(info)), + "type": string(originalType), + "size": strconv.FormatInt(size, 10), } data, err := json.MarshalIndent(meta, "", " ") if err != nil { @@ -423,6 +428,7 @@ func (s *Service) ListTrashEntries() ([]TrashEntry, error) { DeletedAt string `json:"deletedAt"` OriginalType string `json:"originalType"` Basename string `json:"basename"` + Size string `json:"size"` } if err := json.Unmarshal(data, &raw); err != nil { continue @@ -437,6 +443,7 @@ func (s *Service) ListTrashEntries() ([]TrashEntry, error) { DeletedAt: raw.DeletedAt, OriginalType: FileType(raw.OriginalType), Basename: raw.Basename, + Size: parseTrashSize(raw.Size), }) } sort.Slice(entries, func(i, j int) bool { @@ -524,6 +531,21 @@ func (s *Service) RestoreTrashEntry(trashID string, options RestoreOptions) (str return targetRel, nil } +// DeleteTrashEntry permanently removes an internal trash entry. +func (s *Service) DeleteTrashEntry(trashID string) error { + root, err := s.vaultRoot() + if err != nil { + return err + } + if err := validateTrashID(trashID); err != nil { + return err + } + if _, err := readTrashEntry(root, trashID); err != nil { + return err + } + return os.RemoveAll(filepath.Join(root, ".verstak", "trash", "files", trashID)) +} + func readTrashEntry(root, trashID string) (TrashEntry, error) { if err := validateTrashID(trashID); err != nil { return TrashEntry{}, err @@ -542,6 +564,7 @@ func readTrashEntry(root, trashID string) (TrashEntry, error) { DeletedAt string `json:"deletedAt"` OriginalType string `json:"originalType"` Basename string `json:"basename"` + Size string `json:"size"` } if err := json.Unmarshal(data, &raw); err != nil { return TrashEntry{}, err @@ -559,9 +582,21 @@ func readTrashEntry(root, trashID string) (TrashEntry, error) { DeletedAt: raw.DeletedAt, OriginalType: FileType(raw.OriginalType), Basename: raw.Basename, + Size: parseTrashSize(raw.Size), }, nil } +func parseTrashSize(value string) int64 { + if value == "" { + return 0 + } + size, err := strconv.ParseInt(value, 10, 64) + if err != nil || size < 0 { + return 0 + } + return size +} + func validateTrashID(trashID string) error { if trashID == "" || trashID == "." || trashID == ".." || strings.ContainsAny(trashID, "/\\\x00") { return fmt.Errorf("invalid-trash-id") diff --git a/internal/core/files/service_test.go b/internal/core/files/service_test.go index 177880a..8c41f3d 100644 --- a/internal/core/files/service_test.go +++ b/internal/core/files/service_test.go @@ -423,7 +423,7 @@ func TestListTrashEntriesReturnsMetadata(t *testing.T) { 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" { + if entries[0].OriginalPath != "new.txt" || entries[0].TrashPath != newResult.TrashPath || entries[0].OriginalType != FileTypeFile || entries[0].Basename != "new.txt" || entries[0].Size != 3 { t.Fatalf("new trash entry = %+v", entries[0]) } } @@ -467,6 +467,34 @@ func TestRestoreTrashEntryRestoresOriginalPathAndRemovesTrashMetadata(t *testing } } +func TestDeleteTrashEntryRemovesPayloadAndMetadata(t *testing.T) { + s, root := newTestService(t) + if err := os.WriteFile(filepath.Join(root, "delete-permanently.txt"), []byte("remove me"), 0o644); err != nil { + t.Fatal(err) + } + trash, err := s.TrashVaultPath("delete-permanently.txt") + if err != nil { + t.Fatalf("TrashVaultPath: %v", err) + } + + if err := s.DeleteTrashEntry(trash.TrashID); err != nil { + t.Fatalf("DeleteTrashEntry: %v", err) + } + if _, err := os.Stat(filepath.Join(root, ".verstak", "trash", "files", trash.TrashID)); !os.IsNotExist(err) { + t.Fatalf("trash directory should be removed, stat err = %v", err) + } + entries, err := s.ListTrashEntries() + if err != nil { + t.Fatalf("ListTrashEntries: %v", err) + } + if len(entries) != 0 { + t.Fatalf("trash entries after permanent delete = %+v, want none", entries) + } + if err := s.DeleteTrashEntry(trash.TrashID); err == nil || !strings.Contains(err.Error(), "not-found: trash entry") { + t.Fatalf("second DeleteTrashEntry error = %v, want not found", err) + } +} + func TestRestoreTrashEntryConflictAndOverwrite(t *testing.T) { s, root := newTestService(t) if err := os.WriteFile(filepath.Join(root, "conflict.txt"), []byte("trashed"), 0o644); err != nil { diff --git a/internal/core/files/types.go b/internal/core/files/types.go index 25b570d..c2a5ef9 100644 --- a/internal/core/files/types.go +++ b/internal/core/files/types.go @@ -72,6 +72,7 @@ type TrashResult struct { TrashPath string `json:"trashPath"` TrashID string `json:"trashId"` DeletedAt string `json:"deletedAt"` + Size int64 `json:"size"` } type TrashEntry struct { @@ -81,4 +82,5 @@ type TrashEntry struct { DeletedAt string `json:"deletedAt"` OriginalType FileType `json:"originalType"` Basename string `json:"basename"` + Size int64 `json:"size"` }