feat: support permanent vault trash deletion

This commit is contained in:
mirivlad 2026-07-10 08:31:20 +08:00
parent e5f5d4d33f
commit 8445ea11db
6 changed files with 107 additions and 4 deletions

View File

@ -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` |

View File

@ -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 {

View File

@ -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"},

View File

@ -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")

View File

@ -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 {

View File

@ -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"`
}