Add trash: deleted files go to trash, restore or empty
This commit is contained in:
+44
-3
@@ -13,13 +13,25 @@
|
||||
<input v-model="searchQuery" placeholder="Search files..." @input="filterFiles" />
|
||||
</div>
|
||||
<div class="sidebar-nav">
|
||||
<button :class="{active: view === 'files'}" @click="view = 'files'">📄 My Files</button>
|
||||
<button :class="{active: view === 'files'}" @click="view = 'files'">📄 Files</button>
|
||||
<button :class="{active: view === 'shared'}" @click="view = 'shared'">🤝 Shared</button>
|
||||
<button :class="{active: view === 'prefs'}" @click="view = 'prefs'">⚙️ Preferences</button>
|
||||
<button v-if="isAdmin" :class="{active: view === 'admin'}" @click="view = 'admin'">👤 Admin</button>
|
||||
<button :class="{active: view === 'trash'}" @click="view = 'trash'; loadTrash()">🗑️ Trash</button>
|
||||
<button :class="{active: view === 'prefs'}" @click="view = 'prefs'">⚙️</button>
|
||||
<button v-if="isAdmin" :class="{active: view === 'admin'}" @click="view = 'admin'">👤</button>
|
||||
</div>
|
||||
<FileTree v-if="view === 'files'" :files="filteredFiles" :selected="currentFile" @select="openFile" @delete="deleteItem" @move="moveFile" />
|
||||
<FileTree v-if="view === 'shared'" :files="sharedFiles" :selected="currentFile" @select="openFile" @delete="deleteItem" @move="moveFile" />
|
||||
<div v-if="view === 'trash'" class="trash-view">
|
||||
<div class="trash-header">
|
||||
<span>{{ trashItems.length }} item(s)</span>
|
||||
<button v-if="trashItems.length" @click="emptyTrash" class="empty-trash-btn">Empty Trash</button>
|
||||
</div>
|
||||
<div v-for="item in trashItems" :key="item.name" class="trash-item">
|
||||
<span>{{ item.isDir ? '📁' : '📄' }} {{ item.name }}</span>
|
||||
<button @click="restoreTrash(item.name)" title="Restore">↩️</button>
|
||||
</div>
|
||||
<p v-if="!trashItems.length" class="trash-empty">Trash is empty</p>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="editor-area" v-if="view === 'files' || view === 'shared'">
|
||||
<div class="toolbar">
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
+49
-2
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user