@@ -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(),