From 1433890a4cde6e2aac6867ed6329e781728f318a Mon Sep 17 00:00:00 2001 From: Anders Holck Date: Fri, 22 May 2026 21:12:29 +0200 Subject: [PATCH] Add trash: deleted files go to trash, restore or empty --- frontend/src/App.vue | 47 +++++++++++++++++++++++++++++++++--- internal/api/handlers.go | 38 ++++++++++++++++++++++++++++++ internal/api/router.go | 3 +++ internal/files/files.go | 51 ++++++++++++++++++++++++++++++++++++++-- 4 files changed, 134 insertions(+), 5 deletions(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 76ab0ce..ebae66d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -13,13 +13,25 @@ +
+
+ {{ trashItems.length }} item(s) + +
+
+ {{ item.isDir ? '📁' : '📄' }} {{ item.name }} + +
+

Trash is empty

+
@@ -224,6 +236,7 @@ const shareLevel = ref('ro') const shareMsg = ref('') const gitDirty = ref(0) const aiResult = ref('') +const trashItems = ref([]) // Preferences const prefs = ref({ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, defaultMode: 'split', theme: 'dark' }) @@ -385,6 +398,26 @@ async function loadShared() { } } +async function loadTrash() { + try { + trashItems.value = await api('/api/files/trash', {}) + } catch (e) { + trashItems.value = [] + } +} + +async function restoreTrash(name) { + await api('/api/files/trash/restore', { name }) + await loadTrash() + await loadFiles() +} + +async function emptyTrash() { + if (!confirm('Permanently delete all items in trash?')) return + await api('/api/files/trash/empty', {}) + trashItems.value = [] +} + // ─── Preferences ───────────────────────────────────────────────────────────── function savePrefs() { @@ -1041,6 +1074,14 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } .ai-content code { background: var(--code-bg); padding: 2px 6px; border-radius: 3px; } .ai-content ul, .ai-content ol { padding-left: 2em; margin: 0 0 12px; } +.trash-view { padding: 8px; flex: 1; overflow-y: auto; } +.trash-header { display: flex; justify-content: space-between; align-items: center; padding: 8px; font-size: 12px; color: var(--text-muted); } +.empty-trash-btn { background: var(--danger); color: white; border: none; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px; } +.trash-item { display: flex; justify-content: space-between; align-items: center; padding: 6px 8px; border-radius: 4px; font-size: 13px; } +.trash-item:hover { background: var(--bg-hover); } +.trash-item button { background: none; border: none; cursor: pointer; font-size: 14px; } +.trash-empty { color: var(--text-muted); font-size: 13px; text-align: center; padding: 20px; } + /* ─── Responsive ──────────────────────────────────────────────────────────── */ @media (max-width: 768px) { diff --git a/internal/api/handlers.go b/internal/api/handlers.go index b6a324c..8906259 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -286,3 +286,41 @@ func (s *Server) handleSharedFiles(w http.ResponseWriter, r *http.Request) { // For now return empty list writeJSON(w, 200, []files.FileInfo{}) } + +func (s *Server) handleListTrash(w http.ResponseWriter, r *http.Request) { + userID := getUserID(r) + items, err := files.ListTrash(s.dataDir, userID) + if err != nil { + writeJSON(w, 500, map[string]string{"error": "failed to list trash"}) + return + } + if items == nil { + items = []files.FileInfo{} + } + writeJSON(w, 200, items) +} + +func (s *Server) handleRestoreTrash(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + } + if err := decodeBody(r, &req); err != nil || req.Name == "" { + writeJSON(w, 400, map[string]string{"error": "name required"}) + return + } + userID := getUserID(r) + if err := files.RestoreFromTrash(s.dataDir, userID, req.Name); err != nil { + writeJSON(w, 500, map[string]string{"error": "restore failed"}) + return + } + writeJSON(w, 200, map[string]string{"status": "restored"}) +} + +func (s *Server) handleEmptyTrash(w http.ResponseWriter, r *http.Request) { + userID := getUserID(r) + if err := files.EmptyTrash(s.dataDir, userID); err != nil { + writeJSON(w, 500, map[string]string{"error": "empty trash failed"}) + return + } + writeJSON(w, 200, map[string]string{"status": "emptied"}) +} diff --git a/internal/api/router.go b/internal/api/router.go index 06da52a..f165844 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -36,6 +36,9 @@ func NewRouter(db *sql.DB, dataDir, secret string) http.Handler { mux.HandleFunc("POST /api/files/create-folder", s.requireAuth(s.handleCreateFolder)) mux.HandleFunc("POST /api/files/delete", s.requireAuth(s.handleDeleteFile)) mux.HandleFunc("POST /api/files/move", s.requireAuth(s.handleMoveFile)) + mux.HandleFunc("POST /api/files/trash", s.requireAuth(s.handleListTrash)) + mux.HandleFunc("POST /api/files/trash/restore", s.requireAuth(s.handleRestoreTrash)) + mux.HandleFunc("POST /api/files/trash/empty", s.requireAuth(s.handleEmptyTrash)) mux.HandleFunc("POST /api/files/search", s.requireAuth(s.handleSearchFiles)) mux.HandleFunc("POST /api/files/shared", s.requireAuth(s.handleListSharedFiles)) diff --git a/internal/files/files.go b/internal/files/files.go index 25b8229..ee5fc5b 100644 --- a/internal/files/files.go +++ b/internal/files/files.go @@ -1,6 +1,7 @@ package files import ( + "fmt" "os" "path/filepath" "strings" @@ -54,13 +55,56 @@ func CreateFolder(dataDir, userID, relPath string) error { return os.MkdirAll(p, 0755) } -// DeleteFile removes a file or folder for a user. +// DeleteFile moves a file or folder to trash. func DeleteFile(dataDir, userID, relPath string) error { p := safePath(dataDir, userID, relPath) if p == "" { return os.ErrPermission } - return os.RemoveAll(p) + trashDir := filepath.Join(UserDir(dataDir, userID), ".trash") + if err := os.MkdirAll(trashDir, 0755); err != nil { + return err + } + dest := filepath.Join(trashDir, filepath.Base(relPath)) + // If already exists in trash, add timestamp + if _, err := os.Stat(dest); err == nil { + dest = dest + "." + fmt.Sprintf("%d", os.Getpid()) + } + return os.Rename(p, dest) +} + +// ListTrash returns files in the user's trash. +func ListTrash(dataDir, userID string) ([]FileInfo, error) { + trashDir := filepath.Join(UserDir(dataDir, userID), ".trash") + if err := os.MkdirAll(trashDir, 0755); err != nil { + return nil, err + } + entries, err := os.ReadDir(trashDir) + if err != nil { + return nil, err + } + var result []FileInfo + for _, e := range entries { + result = append(result, FileInfo{Name: e.Name(), Path: e.Name(), IsDir: e.IsDir()}) + } + return result, nil +} + +// RestoreFromTrash moves a file from trash back to the user's root. +func RestoreFromTrash(dataDir, userID, name string) error { + trashDir := filepath.Join(UserDir(dataDir, userID), ".trash") + src := filepath.Join(trashDir, name) + dest := filepath.Join(UserDir(dataDir, userID), name) + if strings.Contains(name, "..") { + return os.ErrPermission + } + return os.Rename(src, dest) +} + +// EmptyTrash permanently deletes all files in trash. +func EmptyTrash(dataDir, userID string) error { + trashDir := filepath.Join(UserDir(dataDir, userID), ".trash") + return os.RemoveAll(trashDir) } // MoveFile moves a file or folder to a new path. @@ -93,6 +137,9 @@ func listDir(base, rel string) ([]FileInfo, error) { } var result []FileInfo for _, e := range entries { + if e.Name() == ".trash" || e.Name() == ".git" { + continue + } entryRel := filepath.Join(rel, e.Name()) info := FileInfo{ Name: e.Name(),