feat(#1104): workspace directory CRUD — delete, rename, context menu

The file tree already supported file rename (double-click), file delete
(button), and create file/folder.  This adds the missing directory
operations:

Backend:
- _handle_file_delete now supports directories when recursive=true
  (uses shutil.rmtree instead of blocking with an error)

Frontend:
- Right-click context menu on all file/directory items with Rename
  and Delete options (follows the project context menu pattern)
- Directory delete button (x) with confirmation dialog
- _inlineRenameFileItem() for renaming dirs via context menu prompt
- Expanded-dir cache is updated on rename/delete to stay consistent
- Context menu auto-positions within viewport bounds

i18n: delete_dir_confirm, rename_title, rename_prompt in all 7 locales

Closes #1104
This commit is contained in:
bergeouss
2026-04-28 10:59:49 +00:00
committed by Hermes Agent
parent 03b7714f65
commit 38df294af9
3 changed files with 105 additions and 3 deletions
+6 -2
View File
@@ -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))
+21
View File
@@ -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):',
+78 -1
View File
@@ -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});