diff --git a/api/routes.py b/api/routes.py index b0d7bdd5..e001a271 100644 --- a/api/routes.py +++ b/api/routes.py @@ -8,6 +8,7 @@ import json import logging import os import queue +import shutil import sys import threading import time @@ -3153,8 +3154,11 @@ def _handle_file_delete(handler, body): if not target.exists(): return bad(handler, "File not found", 404) if target.is_dir(): - return bad(handler, "Cannot delete directories via this endpoint") - target.unlink() + if not body.get("recursive"): + return bad(handler, "Set recursive=true to delete directories") + shutil.rmtree(target) + else: + target.unlink() return j(handler, {"ok": True, "path": body["path"]}) except (ValueError, PermissionError) as e: return bad(handler, _sanitize_error(e)) diff --git a/static/i18n.js b/static/i18n.js index 480b8445..0eac638d 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -219,6 +219,9 @@ const LOCALES = { rename_failed: 'Rename failed: ', delete_title: 'Delete', delete_confirm: (name) => `Delete ${name}?`, + delete_dir_confirm: (name) => `Delete folder "${name}" and all its contents?`, + rename_title: 'Rename', + rename_prompt: 'New name:', deleted: 'Deleted ', delete_failed: 'Delete failed: ', new_file_prompt: 'New file name (e.g. notes.md):', @@ -840,6 +843,9 @@ const LOCALES = { rename_failed: 'Не удалось переименовать: ', delete_title: 'Удалить', delete_confirm: (name) => `Удалить ${name}?`, + delete_dir_confirm: (name) => `Удалить папку "${name}" и всё её содержимое?`, + rename_title: 'Переименовать', + rename_prompt: 'Новое имя:', deleted: 'Удалено ', delete_failed: 'Не удалось удалить: ', new_file_prompt: 'Имя нового файла (например, notes.md):', @@ -1458,6 +1464,9 @@ const LOCALES = { rename_failed: 'Error al renombrar: ', delete_title: 'Eliminar', delete_confirm: (name) => `¿Eliminar ${name}?`, + delete_dir_confirm: (name) => `¿Eliminar carpeta "${name}" y todo su contenido?`, + rename_title: 'Renombrar', + rename_prompt: 'Nuevo nombre:', deleted: 'Eliminado ', delete_failed: 'Error al eliminar: ', new_file_prompt: 'Nombre del archivo nuevo (p. ej. notes.md):', @@ -2080,6 +2089,9 @@ const LOCALES = { rename_failed: 'Umbenennen fehlgeschlagen: ', delete_title: 'Löschen', delete_confirm: (name) => `${name} löschen?`, + delete_dir_confirm: (name) => `Ordner "${name}" und gesamten Inhalt löschen?`, + rename_title: 'Umbenennen', + rename_prompt: 'Neuer Name:', deleted: 'Gelöscht ', delete_failed: 'Löschen fehlgeschlagen: ', new_file_prompt: 'Neuer Dateiname (z.B. notes.md):', @@ -2479,6 +2491,9 @@ const LOCALES = { rename_failed: '\u91cd\u547d\u540d\u5931\u8d25\uff1a', delete_title: '\u5220\u9664', delete_confirm: (name) => `\u8981\u5220\u9664 ${name} \u5417\uff1f`, + delete_dir_confirm: (name) => `删除文件夹 "${name}" 及其所有内容?`, + rename_title: '重命名', + rename_prompt: '新名称:', deleted: '\u5df2\u5220\u9664 ', delete_failed: '\u5220\u9664\u5931\u8d25\uff1a', new_file_prompt: '\u65b0\u6587\u4ef6\u540d\uff08\u4f8b\u5982 notes.md\uff09\uff1a', @@ -3064,6 +3079,9 @@ const LOCALES = { rename_failed: '\u91cd\u547d\u540d\u5931\u6557\uff1a', delete_title: '\u522a\u9664', delete_confirm: (name) => `\u8981\u522a\u9664 ${name} \u55ce\uff1f`, + delete_dir_confirm: (name) => `刪除資料夾 "${name}" 及其所有內容?`, + rename_title: '重新命名', + rename_prompt: '新名稱:', deleted: '\u5df2\u522a\u9664 ', delete_failed: '\u522a\u9664\u5931\u6557\uff1a', new_file_prompt: '\u65b0\u6587\u4ef6\u540d\uff08\u4f8b\u5982 notes.md\uff09\uff1a', @@ -3824,6 +3842,9 @@ const LOCALES = { rename_failed: 'Rename failed: ', delete_title: '삭제', delete_confirm: (name) => `${name}을(를) 삭제할까요?`, + delete_dir_confirm: (name) => `"${name}" 폴더와 모든 내용을 삭제할까요?`, + rename_title: '이름 바꾸기', + rename_prompt: '새 이름:', deleted: '삭제됨: ', delete_failed: '삭제 실패: ', new_file_prompt: 'New file name (e.g. notes.md):', diff --git a/static/ui.js b/static/ui.js index c5e3fdb1..b5d8b279 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3352,6 +3352,7 @@ function _renderTreeItems(container, entries, depth){ const el=document.createElement('div');el.className='file-item'; el.style.paddingLeft=(8+depth*16)+'px'; el.setAttribute('draggable','true'); + el.oncontextmenu=(e)=>{e.preventDefault();e.stopPropagation();_showFileContextMenu(e,item);}; el.ondragstart=(e)=>{e.dataTransfer.setData('application/ws-path',item.path);e.dataTransfer.setData('application/ws-type',item.type);e.dataTransfer.effectAllowed='copy';}; if(item.type==='dir'){ @@ -3418,12 +3419,17 @@ function _renderTreeItems(container, entries, depth){ el.appendChild(sizeEl); } - // Delete button -- for files + // Delete button -- for files and directories if(item.type==='file'){ const del=document.createElement('button'); del.className='file-del-btn';del.title=t('delete_title');del.textContent='\u00d7'; del.onclick=async(e)=>{e.stopPropagation();await deleteWorkspaceFile(item.path,item.name);}; el.appendChild(del); + }else if(item.type==='dir'){ + const del=document.createElement('button'); + del.className='file-del-btn';del.title=t('delete_title');del.textContent='\u00d7'; + del.onclick=async(e)=>{e.stopPropagation();await deleteWorkspaceDir(item.path,item.name);}; + el.appendChild(del); } if(item.type==='dir'){ @@ -3469,6 +3475,77 @@ function _renderTreeItems(container, entries, depth){ } } +async function deleteWorkspaceDir(relPath, name){ + if(!S.session)return; + const ok=await showConfirmDialog({title:t('delete_dir_confirm',name),message:'',confirmLabel:'Delete',danger:true,focusCancel:true}); + if(!ok)return; + try{ + await api('/api/file/delete',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:relPath,recursive:true})}); + showToast(t('deleted')+name); + // Remove from expanded dirs cache + if(S._expandedDirs){S._expandedDirs.delete(relPath);if(typeof _saveExpandedDirs==='function')_saveExpandedDirs();} + delete S._dirCache[relPath]; + await loadDir(S.currentDir); + }catch(e){setStatus(t('delete_failed')+e.message);} +} + +function _showFileContextMenu(e, item){ + document.querySelectorAll('.file-ctx-menu').forEach(el=>el.remove()); + const menu=document.createElement('div'); + menu.className='file-ctx-menu'; + menu.style.cssText='position:fixed;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:6px 0;z-index:9999;min-width:140px;box-shadow:0 4px 16px rgba(0,0,0,.35);'; + // Keep menu within viewport + const vw=window.innerWidth,vh=window.innerHeight; + menu.style.left=(e.clientX+140>vw?e.clientX-150:e.clientX)+'px'; + menu.style.top=(e.clientY+100>vh?e.clientY-100:e.clientY)+'px'; + + // Rename + const renameItem=document.createElement('div'); + renameItem.textContent=t('rename_title'); + renameItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);'; + renameItem.onmouseenter=()=>renameItem.style.background='var(--hover)'; + renameItem.onmouseleave=()=>renameItem.style.background=''; + renameItem.onclick=()=>{menu.remove();_inlineRenameFileItem(item);}; + menu.appendChild(renameItem); + + // Divider + Delete + const sep=document.createElement('hr'); + sep.style.cssText='border:none;border-top:1px solid var(--border);margin:4px 0;'; + menu.appendChild(sep); + const delItem=document.createElement('div'); + delItem.textContent=t('delete_title'); + delItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--error,#e94560);'; + delItem.onmouseenter=()=>delItem.style.background='var(--hover)'; + delItem.onmouseleave=()=>delItem.style.background=''; + delItem.onclick=()=>{menu.remove();if(item.type==='dir')deleteWorkspaceDir(item.path,item.name);else deleteWorkspaceFile(item.path,item.name);}; + menu.appendChild(delItem); + + document.body.appendChild(menu); + const dismiss=()=>{menu.remove();document.removeEventListener('click',dismiss);}; + setTimeout(()=>document.addEventListener('click',dismiss),0); +} + +async function _inlineRenameFileItem(item){ + if(!S.session)return; + const newName=await showPromptDialog({message:t('rename_prompt'),defaultValue:item.name,placeholder:item.name,confirmLabel:t('rename_title')}); + if(!newName||newName===item.name)return; + try{ + await api('/api/file/rename',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path,new_name:newName})}); + showToast(t('renamed_to')+newName); + // Update expanded dirs cache key if renaming a directory + if(item.type==='dir'&&S._expandedDirs){ + S._expandedDirs.delete(item.path); + const parent=item.path.includes('/')?item.path.substring(0,item.path.lastIndexOf('/')):'.'; + const newPath=parent==='.'?newName:parent+'/'+newName; + S._expandedDirs.add(newPath); + if(S._dirCache[item.path]){S._dirCache[newPath]=S._dirCache[item.path];delete S._dirCache[item.path];} + if(typeof _saveExpandedDirs==='function')_saveExpandedDirs(); + } + delete S._dirCache[S.currentDir]; + await loadDir(S.currentDir); + }catch(err){showToast(t('rename_failed')+err.message);} +} + async function deleteWorkspaceFile(relPath, name){ if(!S.session)return; const _delFile=await showConfirmDialog({title:t('delete_confirm',name),message:'',confirmLabel:'Delete',danger:true,focusCancel:true});