mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 03:00:23 +00:00
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:
+6
-2
@@ -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))
|
||||
|
||||
@@ -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
@@ -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});
|
||||
|
||||
Reference in New Issue
Block a user