feat: expose file trash metadata

This commit is contained in:
2026-06-28 17:03:12 +08:00
parent ed4b117a94
commit e5bdaec0aa
9 changed files with 145 additions and 1 deletions
+15
View File
@@ -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 {
+7
View File
@@ -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) {
+59
View File
@@ -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")
+33
View File
@@ -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")
+9
View File
@@ -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"`
}