(null)
/** Whether a file drag hovers the tree (drives the portaled drop zone). */
const [dropOver, setDropOver] = useState(false)
/** The directory a drag is hovering right now (null = body, drop to root). */
@@ -272,6 +283,63 @@ export function FileTree(props: {
loadDir(dir)
}, [loadDir])
+ /**
+ * Settle the tree after one rename/delete landed at `prefix`: drop every
+ * cached level at or under the old path, collapse the now-stale expanded
+ * directories below it, and reload the immediate parent so the new shape
+ * shows without a full refresh tick.
+ */
+ const pruneTree = (prefix: string): void => {
+ const parent = parentOf(prefix)
+ const under = (key: string): boolean => key === prefix || key.startsWith(`${prefix}/`) || key.startsWith(`${prefix}\\`)
+ for (const key of Object.keys(dataRef.current)) {
+ if (under(key)) delete dataRef.current[key]
+ }
+ if (parent !== prefix) delete dataRef.current[parent]
+ setData({ ...dataRef.current })
+ for (const dir of expanded) {
+ if (under(dir)) onToggle(dir)
+ }
+ if (parent !== prefix) retryDir(parent)
+ }
+
+ /** Client-side twin of the server's name rule (the server re-validates). */
+ const validRename = (name: string): boolean =>
+ name !== '' && name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\')
+
+ /** Commit the inline rename: trim, no-op guard, then fs.rename + settle. */
+ const commitRename = (path: string, raw: string): void => {
+ setRenaming(null)
+ const name = raw.trim()
+ if (cwd === undefined || name === baseName(path) || !validRename(name)) {
+ if (name !== baseName(path) && !validRename(name)) setActionError(t('renameInvalid'))
+ return
+ }
+ api.fsRename({ sessionId, cwd }, path, name)
+ .then((result) => {
+ setActionError(null)
+ pruneTree(path)
+ onPathRenamed?.(path, result.path)
+ })
+ .catch((error: unknown) => {
+ setActionError(error instanceof Error ? error.message : String(error))
+ })
+ }
+
+ /** Run the confirmed delete: fs.remove + settle + close affected tabs. */
+ const performDelete = (target: { path: string; isDir: boolean }): void => {
+ if (cwd === undefined) return
+ api.fsRemove({ sessionId, cwd }, target.path)
+ .then(() => {
+ setActionError(null)
+ pruneTree(target.path)
+ onPathDeleted?.(target.path, target.isDir)
+ })
+ .catch((error: unknown) => {
+ setActionError(error instanceof Error ? error.message : String(error))
+ })
+ }
+
// The caller's refresh tick wipes the cache (declared BEFORE the load
// effect so the reload below sees the empty cache).
const lastTick = useRef(refreshTick)
@@ -452,6 +520,58 @@ export function FileTree(props: {
const expandedSet = useMemo(() => new Set(expanded), [expanded])
const revealedSet = useMemo(() => new Set(revealed), [revealed])
+ /**
+ * Focus + select the rename editor once, on mount. The callback identity
+ * must stay STABLE (useCallback []): an inline arrow re-runs on every
+ * render (detach null → attach el), and focus()/select() mid-keystroke
+ * would reselect the whole buffer while typing. A plain , not the
+ * primitives Input — that one forwards no ref, and focus+select is the
+ * entire point here.
+ */
+ const renameInputRef = useCallback((el: HTMLInputElement | null): void => {
+ if (el !== null) {
+ el.focus()
+ el.select()
+ }
+ }, [])
+
+ /** The inline rename editor replacing one row (dirs and files alike):
+ * same indent/icon/height for a seamless swap, Enter/blur commits,
+ * Escape cancels, IME composition keys never reach the handlers (the
+ * shared isImeComposition guard). */
+ const renderRenameRow = (entry: FsEntry, depth: number): ReactNode => (
+
+ {entry.isDir
+ ? (expandedSet.has(entry.path) ? : )
+ : }
+ {
+ setRenaming(prev => prev === null ? prev : { ...prev, value: event.target.value })
+ }}
+ onKeyDown={(event) => {
+ if (isImeComposition(event)) return
+ if (event.key === 'Enter') {
+ event.preventDefault()
+ commitRename(entry.path, renaming?.value ?? '')
+ } else if (event.key === 'Escape') {
+ event.preventDefault()
+ setRenaming(null)
+ }
+ }}
+ onBlur={() => { commitRename(entry.path, renaming?.value ?? '') }}
+ />
+
+ )
+
const renderLevel = (dir: string, depth: number): ReactNode => {
const level = data[dir]
if (level === undefined) {
@@ -476,6 +596,10 @@ export function FileTree(props: {
}
const entries = level.entries ?? []
return entries.map(entry => {
+ // The row being renamed renders as its editor (no button semantics:
+ // an editor is not a click target — and a nested interactive inside
+ // role="button" would be invalid anyway).
+ if (renaming?.path === entry.path) return renderRenameRow(entry, depth)
if (entry.isDir) {
const isOpen = expandedSet.has(entry.path)
return (
@@ -556,6 +680,22 @@ export function FileTree(props: {
{t('noSession')}
) : (
<>
+ {/* The last mutation failure (rename/delete): dismissable, raw
+ server text — the same show-the-truth policy the fence notice
+ uses. Any later action or a fresh attempt clears it. */}
+ {actionError !== null && (
+
+ {actionError}
+ { setActionError(null) }}
+ >
+
+
+
+ )}
},
{ id: 'absolute', label: t('copyAbsolute'), icon: },
+ // Explorer mutations close the menu; the workspace ROOT row is the
+ // session itself — never renamable or deletable (server double-guards).
+ ...(rowMenu !== null && rowMenu.path !== cwd
+ ? [
+ { id: 'mutate-sep', type: 'separator' } as MenuEntry,
+ { id: 'rename', label: t('rename'), icon: },
+ { id: 'delete', label: t('delete'), icon: , danger: true },
+ ]
+ : []),
]}
onSelect={(id) => {
const target = rowMenu
@@ -697,6 +846,14 @@ export function FileTree(props: {
fileInputRef.current?.click()
return
}
+ if (id === 'rename') {
+ setRenaming({ path: target.path, value: baseName(target.path) })
+ return
+ }
+ if (id === 'delete') {
+ setConfirmDelete({ path: target.path, isDir: target.isDir, name: baseName(target.path) })
+ return
+ }
copyPath(
id === 'relative' ? relativeTo(cwd ?? '', target.path) : target.path,
target.path,
@@ -708,6 +865,36 @@ export function FileTree(props: {
getAnchorRect={() => (rowMenu === null ? null : new DOMRect(rowMenu.x, rowMenu.y, 0, 0))}
anchor={ }
/>
+
+ {/* The delete confirmation: destructive and permanent (no host trash),
+ so it always lands here first — the same Cancel/Confirm shape the
+ git lens uses for discard/revert/cherry-pick. */}
+ { setConfirmDelete(null) }}
+ title={confirmDelete === null ? '' : t('deleteTitle', { name: confirmDelete.name })}
+ closeLabel={t('cancel')}
+ footer={(
+ <>
+ { setConfirmDelete(null) }}>{t('cancel')}
+ {
+ const pending = confirmDelete
+ if (pending === null) return
+ setConfirmDelete(null)
+ performDelete(pending)
+ }}
+ >
+ {t('delete')}
+
+ >
+ )}
+ >
+
+ {confirmDelete === null ? '' : t(confirmDelete.isDir ? 'deleteDescDir' : 'deleteDescFile')}
+
+
)
}
diff --git a/src/client/TreePanel.tsx b/src/client/TreePanel.tsx
index 942291f..e0b0309 100644
--- a/src/client/TreePanel.tsx
+++ b/src/client/TreePanel.tsx
@@ -63,11 +63,15 @@ export function TreePanel(props: {
onOpenWith?: (targetId: string, path: string) => void
onToggleOpenWithPin?: (targetId: string) => void
onReferenceFile: (path: string, isDir: boolean) => void
+ /** A tree rename landed (passed through to FileTree for tab retargeting). */
+ onPathRenamed?: (oldPath: string, newPath: string) => void
+ /** A tree delete landed (passed through to FileTree for tab closing). */
+ onPathDeleted?: (path: string, isDir: boolean) => void
/** Full-window presentation: the panel fills its host instead of docking
* at a fixed width. */
full?: boolean
}) {
- const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, full } = props
+ const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathDeleted, full } = props
const [query, setQuery] = useState('')
const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
const [error, setError] = useState(null)
@@ -252,6 +256,8 @@ export function TreePanel(props: {
onOpenWith={onOpenWith}
onToggleOpenWithPin={onToggleOpenWithPin}
onReferenceFile={onReferenceFile}
+ onPathRenamed={onPathRenamed}
+ onPathDeleted={onPathDeleted}
refreshTick={refreshTick}
onUploadRequest={startUpload}
busy={busy}
diff --git a/src/client/api.ts b/src/client/api.ts
index e780f28..3235a6f 100644
--- a/src/client/api.ts
+++ b/src/client/api.ts
@@ -278,6 +278,15 @@ export const api = {
call('fs.read', scopePayload(scope, { path }), signal),
fsWrite: (scope: SessionScope, path: string, content: string) =>
call<{ ok: true }>('fs.write', scopePayload(scope, { path, content })),
+ /** Rename one tree row within its directory (single-segment name; the
+ * server refuses existing destinations, the workspace root, and — while
+ * the fence is armed — anything resolving outside the workspace). */
+ fsRename: (scope: SessionScope, path: string, name: string) =>
+ call<{ path: string }>('fs.rename', scopePayload(scope, { path, name })),
+ /** Permanently delete one tree row (recursive for directories; a symlink
+ * row unlinks the link only). The UI confirms before calling this. */
+ fsRemove: (scope: SessionScope, path: string) =>
+ call<{ path: string }>('fs.remove', scopePayload(scope, { path })),
/** Upload one file's raw bytes into `dir` (keeps the folder tree via
* `relativePath`); the host streams it under the session workspace. */
uploadFile: (scope: SessionScope, dir: string, relativePath: string, body: Blob, signal?: AbortSignal) =>
diff --git a/src/client/ime-guard.ts b/src/client/ime-guard.ts
index 1ee5b40..9cfdca8 100644
--- a/src/client/ime-guard.ts
+++ b/src/client/ime-guard.ts
@@ -25,9 +25,12 @@
* legacy signal engines emit without isComposing.
*/
-/** The pure decision: is this keyboard event part of an IME composition? */
-export function isImeComposition(event: { isComposing: boolean; keyCode: number }): boolean {
- return event.isComposing || event.keyCode === 229
+/** The pure decision: is this keyboard event part of an IME composition?
+ * `isComposing` is optional on the input: React's synthetic KeyboardEvent
+ * type does not declare it (the DOM event always carries it), and the
+ * keyCode 229 fallback covers exactly those callers. */
+export function isImeComposition(event: { isComposing?: boolean; keyCode: number }): boolean {
+ return event.isComposing === true || event.keyCode === 229
}
/**
diff --git a/src/client/layout.css b/src/client/layout.css
index 0e0d923..f497840 100644
--- a/src/client/layout.css
+++ b/src/client/layout.css
@@ -131,8 +131,31 @@ body[data-dsh-sidebar-dragging] #root [data-dsh-center-col],
* closes on the other's outside pointerdown).
*
* No max-height/overflow scroll cap here on purpose: overflow clipping
- * would cut the submenu's ::before hover bridge (the 10px corridor that
- * keeps itemWrap's mouseleave from closing the card mid-crossing). */
+ * would cut the submenu's ::before hover bridge (the corridor that keeps
+ * itemWrap's mouseleave from closing the card mid-crossing). */
+
+/* Visual tether, part 1 — the parent row stays lit while its submenu is
+ * open. The primitive only styles :hover, so once the pointer crossed into
+ * the side card the parent row's fill cleared and the floating card read as
+ * unrelated. The Menu marks the open parent row with aria-expanded="true"
+ * (host-rendered, stable), so the same hover token re-applies for the
+ * submenu's whole lifetime. */
+body[data-dsh-sidebar-submenu] div[role="menu"] button[aria-expanded="true"] {
+ background: var(--dsw-alias-interactive-bg-hover);
+}
+
+/* Visual tether, part 2 — the side card hugs its parent (10px → 4px gap),
+ * with the ::before hover corridor narrowed to match. Ordered BEFORE the
+ * flip overrides below so their left/right flips still win. */
+body[data-dsh-sidebar-submenu] div[role="menu"] div[role="menu"] {
+ left: calc(100% + 4px);
+}
+
+body[data-dsh-sidebar-submenu] div[role="menu"] div[role="menu"]::before {
+ left: -4px;
+ width: 4px;
+}
+
body[data-dsh-sidebar-submenu~="down"] div[role="menu"] div[role="menu"] {
top: -4px;
bottom: auto;
@@ -140,11 +163,11 @@ body[data-dsh-sidebar-submenu~="down"] div[role="menu"] div[role="menu"] {
body[data-dsh-sidebar-submenu~="left"] div[role="menu"] div[role="menu"] {
left: auto;
- right: calc(100% + 10px);
+ right: calc(100% + 4px);
}
/* The hover bridge follows the card to the left side of the parent row. */
body[data-dsh-sidebar-submenu~="left"] div[role="menu"] div[role="menu"]::before {
left: auto;
- right: -10px;
+ right: -4px;
}
diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts
index 79dbd85..0862344 100644
--- a/src/client/locales-ar.ts
+++ b/src/client/locales-ar.ts
@@ -199,6 +199,13 @@ export const ar: Record = {
addToConversation: 'إضافة إلى المحادثة',
copyRelative: 'نسخ المسار النسبي',
copyAbsolute: 'نسخ المسار المطلق',
+ rename: 'إعادة تسمية',
+ renameInvalid: 'لا يمكن أن يكون الاسم فارغًا أو يحتوي على فواصل مسار.',
+ delete: 'حذف',
+ deleteTitle: 'حذف «{name}»؟',
+ deleteDescFile: 'سيتم حذف هذا الملف نهائيًا. لا يمكن التراجع عن هذا الإجراء.',
+ deleteDescDir: 'سيتم حذف هذا الدليل وكل محتوياته نهائيًا. لا يمكن التراجع عن هذا الإجراء.',
+ dismiss: 'إغلاق',
download: 'تنزيل',
uploadFiles: 'رفع ملفات',
uploadFolder: 'رفع مجلد',
diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts
index a3c022c..7a9419f 100644
--- a/src/client/locales-de.ts
+++ b/src/client/locales-de.ts
@@ -184,6 +184,13 @@ export const de: Record = {
addToConversation: 'Zur Unterhaltung hinzufügen',
copyRelative: 'Relativen Pfad kopieren',
copyAbsolute: 'Absoluten Pfad kopieren',
+ rename: 'Umbenennen',
+ renameInvalid: 'Der Name darf nicht leer sein und keine Pfadtrenner enthalten.',
+ delete: 'Löschen',
+ deleteTitle: '„{name}“ löschen?',
+ deleteDescFile: 'Dadurch wird die Datei dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.',
+ deleteDescDir: 'Dadurch werden das Verzeichnis und dessen gesamter Inhalt dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.',
+ dismiss: 'Schließen',
download: 'Herunterladen',
uploadFiles: 'Dateien hochladen',
uploadFolder: 'Ordner hochladen',
diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts
index ffc56bc..b11cb3f 100644
--- a/src/client/locales-fr.ts
+++ b/src/client/locales-fr.ts
@@ -191,6 +191,13 @@ export const fr: Record = {
addToConversation: 'Ajouter à la discussion',
copyRelative: 'Copier le chemin relatif',
copyAbsolute: 'Copier le chemin absolu',
+ rename: 'Renommer',
+ renameInvalid: 'Le nom ne peut pas être vide ni contenir de séparateurs de chemin.',
+ delete: 'Supprimer',
+ deleteTitle: 'Supprimer « {name} » ?',
+ deleteDescFile: 'Ce fichier sera définitivement supprimé. Cette action est irréversible.',
+ deleteDescDir: 'Ce répertoire et tout son contenu seront définitivement supprimés. Cette action est irréversible.',
+ dismiss: 'Fermer',
download: 'Télécharger',
uploadFiles: 'Importer des fichiers',
uploadFolder: 'Importer un dossier',
diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts
index 940aad0..2f4e9b5 100644
--- a/src/client/locales-hi.ts
+++ b/src/client/locales-hi.ts
@@ -198,6 +198,13 @@ export const hi: Record = {
addToConversation: 'वार्तालाप में जोड़ें',
copyRelative: 'सापेक्ष पथ कॉपी',
copyAbsolute: 'निरपेक्ष पथ कॉपी',
+ rename: 'नाम बदलें',
+ renameInvalid: 'नाम खाली नहीं हो सकता और उसमें पथ विभाजक नहीं हो सकते।',
+ delete: 'हटाएँ',
+ deleteTitle: '“{name}” हटाएँ?',
+ deleteDescFile: 'यह फ़ाइल स्थायी रूप से हटा दी जाएगी। इसे पूर्ववत नहीं किया जा सकता।',
+ deleteDescDir: 'यह निर्देशिका और उसकी सारी सामग्री स्थायी रूप से हटा दी जाएगी। इसे पूर्ववत नहीं किया जा सकता।',
+ dismiss: 'बंद करें',
download: 'डाउनलोड',
uploadFiles: 'फ़ाइलें अपलोड करें',
uploadFolder: 'फ़ोल्डर अपलोड करें',
diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts
index 0f30760..95015a7 100644
--- a/src/client/locales-id.ts
+++ b/src/client/locales-id.ts
@@ -196,6 +196,13 @@ export const id: Record = {
addToConversation: 'Tambahkan ke obrolan',
copyRelative: 'Salin path relatif',
copyAbsolute: 'Salin path absolut',
+ rename: 'Ganti nama',
+ renameInvalid: 'Nama tidak boleh kosong atau mengandung pemisah jalur.',
+ delete: 'Hapus',
+ deleteTitle: 'Hapus "{name}"?',
+ deleteDescFile: 'Ini menghapus file secara permanen. Tidak dapat dibatalkan.',
+ deleteDescDir: 'Ini menghapus direktori dan seluruh isinya secara permanen. Tidak dapat dibatalkan.',
+ dismiss: 'Tutup',
download: 'Unduh',
uploadFiles: 'Unggah berkas',
uploadFolder: 'Unggah folder',
diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts
index aa84920..c5bf3d9 100644
--- a/src/client/locales-it.ts
+++ b/src/client/locales-it.ts
@@ -189,6 +189,13 @@ export const it: Record = {
addToConversation: 'Aggiungi alla conversazione',
copyRelative: 'Copia percorso relativo',
copyAbsolute: 'Copia percorso assoluto',
+ rename: 'Rinomina',
+ renameInvalid: 'Il nome non può essere vuoto o contenere separatori di percorso.',
+ delete: 'Elimina',
+ deleteTitle: 'Eliminare "{name}"?',
+ deleteDescFile: 'Il file verrà eliminato definitivamente. Questa azione non può essere annullata.',
+ deleteDescDir: 'La directory e tutto il suo contenuto verranno eliminati definitivamente. Questa azione non può essere annullata.',
+ dismiss: 'Chiudi',
download: 'Scarica',
uploadFiles: 'Carica file',
uploadFolder: 'Carica cartella',
diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts
index 7d2adef..751e8eb 100644
--- a/src/client/locales-ja.ts
+++ b/src/client/locales-ja.ts
@@ -198,6 +198,13 @@ export const ja: Record = {
addToConversation: '会話に追加',
copyRelative: '相対パスをコピー',
copyAbsolute: '絶対パスをコピー',
+ rename: '名前の変更',
+ renameInvalid: '名前は空にできず、パス区切り文字を含められません。',
+ delete: '削除',
+ deleteTitle: '「{name}」を削除しますか?',
+ deleteDescFile: 'このファイルを完全に削除します。元に戻せません。',
+ deleteDescDir: 'このディレクトリと内容物をすべて完全に削除します。元に戻せません。',
+ dismiss: '閉じる',
download: 'ダウンロード',
uploadFiles: 'ファイルをアップロード',
uploadFolder: 'フォルダーをアップロード',
diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts
index 3895115..2ade5d4 100644
--- a/src/client/locales-ko.ts
+++ b/src/client/locales-ko.ts
@@ -190,6 +190,13 @@ export const ko: Record = {
addToConversation: '대화에 추가',
copyRelative: '상대 경로 복사',
copyAbsolute: '절대 경로 복사',
+ rename: '이름 바꾸기',
+ renameInvalid: '이름은 비워 둘 수 없으며 경로 구분 기호를 포함할 수 없습니다.',
+ delete: '삭제',
+ deleteTitle: '"{name}"을(를) 삭제할까요?',
+ deleteDescFile: '이 파일이 영구적으로 삭제됩니다. 되돌릴 수 없습니다.',
+ deleteDescDir: '이 디렉터리와 모든 내용이 영구적으로 삭제됩니다. 되돌릴 수 없습니다.',
+ dismiss: '닫기',
download: '다운로드',
uploadFiles: '파일 업로드',
uploadFolder: '폴더 업로드',
diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts
index 5b7dd2a..daf5e04 100644
--- a/src/client/locales-nl.ts
+++ b/src/client/locales-nl.ts
@@ -196,6 +196,13 @@ export const nl: Record = {
addToConversation: 'Aan conversatie toevoegen',
copyRelative: 'Relatief pad kopiëren',
copyAbsolute: 'Absoluut pad kopiëren',
+ rename: 'Naam wijzigen',
+ renameInvalid: 'De naam mag niet leeg zijn of padscheidingstekens bevatten.',
+ delete: 'Verwijderen',
+ deleteTitle: '"{name}" verwijderen?',
+ deleteDescFile: 'Dit verwijdert het bestand permanent. Dit kan niet ongedaan worden gemaakt.',
+ deleteDescDir: 'Dit verwijdert de map en alles erin permanent. Dit kan niet ongedaan worden gemaakt.',
+ dismiss: 'Sluiten',
download: 'Downloaden',
uploadFiles: 'Bestanden uploaden',
uploadFolder: 'Map uploaden',
diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts
index 4b672bf..5428dd4 100644
--- a/src/client/locales-pl.ts
+++ b/src/client/locales-pl.ts
@@ -200,6 +200,13 @@ export const pl: Record = {
addToConversation: 'Dodaj do rozmowy',
copyRelative: 'Kopiuj ścieżkę względną',
copyAbsolute: 'Kopiuj ścieżkę bezwzględną',
+ rename: 'Zmień nazwę',
+ renameInvalid: 'Nazwa nie może być pusta ani zawierać separatorów ścieżki.',
+ delete: 'Usuń',
+ deleteTitle: 'Usunąć „{name}”?',
+ deleteDescFile: 'Spowoduje to trwałe usunięcie pliku. Tej operacji nie można cofnąć.',
+ deleteDescDir: 'Spowoduje to trwałe usunięcie katalogu i całej jego zawartości. Tej operacji nie można cofnąć.',
+ dismiss: 'Zamknij',
download: 'Pobierz',
uploadFiles: 'Wgraj pliki',
uploadFolder: 'Wgraj folder',
diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts
index 401e63a..4ce3b6e 100644
--- a/src/client/locales-pt.ts
+++ b/src/client/locales-pt.ts
@@ -181,6 +181,13 @@ export const pt: Record = {
addToConversation: 'Adicionar à conversa',
copyRelative: 'Copiar caminho relativo',
copyAbsolute: 'Copiar caminho absoluto',
+ rename: 'Renomear',
+ renameInvalid: 'O nome não pode ser vazio nem conter separadores de caminho.',
+ delete: 'Excluir',
+ deleteTitle: 'Excluir "{name}"?',
+ deleteDescFile: 'Isso exclui o arquivo permanentemente. Esta ação não pode ser desfeita.',
+ deleteDescDir: 'Isso exclui o diretório e todo o seu conteúdo permanentemente. Esta ação não pode ser desfeita.',
+ dismiss: 'Fechar',
download: 'Baixar',
uploadFiles: 'Enviar arquivos',
uploadFolder: 'Enviar pasta',
diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts
index 1d5068b..8487cd1 100644
--- a/src/client/locales-ru.ts
+++ b/src/client/locales-ru.ts
@@ -194,6 +194,13 @@ export const ru: Record = {
addToConversation: 'Добавить в диалог',
copyRelative: 'Копировать относительный путь',
copyAbsolute: 'Копировать абсолютный путь',
+ rename: 'Переименовать',
+ renameInvalid: 'Имя не может быть пустым или содержать разделители пути.',
+ delete: 'Удалить',
+ deleteTitle: 'Удалить «{name}»?',
+ deleteDescFile: 'Файл будет удалён безвозвратно. Это действие нельзя отменить.',
+ deleteDescDir: 'Каталог и всё его содержимое будут удалены безвозвратно. Это действие нельзя отменить.',
+ dismiss: 'Закрыть',
download: 'Скачать',
uploadFiles: 'Загрузить файлы',
uploadFolder: 'Загрузить папку',
diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts
index 72280cd..6a446aa 100644
--- a/src/client/locales-sv.ts
+++ b/src/client/locales-sv.ts
@@ -181,6 +181,13 @@ export const sv: Record = {
addToConversation: 'Lägg till i konversation',
copyRelative: 'Kopiera relativ sökväg',
copyAbsolute: 'Kopiera absolut sökväg',
+ rename: 'Byt namn',
+ renameInvalid: 'Namnet får inte vara tomt eller innehålla sökvägsavgränsare.',
+ delete: 'Ta bort',
+ deleteTitle: 'Ta bort "{name}"?',
+ deleteDescFile: 'Detta tar bort filen permanent. Det går inte att ångra.',
+ deleteDescDir: 'Detta tar bort katalogen och allt i den permanent. Det går inte att ångra.',
+ dismiss: 'Stäng',
download: 'Ladda ner',
uploadFiles: 'Ladda upp filer',
uploadFolder: 'Ladda upp mapp',
diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts
index de718d2..936d476 100644
--- a/src/client/locales-th.ts
+++ b/src/client/locales-th.ts
@@ -198,6 +198,13 @@ export const th: Record = {
addToConversation: 'เพิ่มไปยังแชท',
copyRelative: 'คัดลอกพาธสัมพัทธ์',
copyAbsolute: 'คัดลอกพาธ absolute',
+ rename: 'เปลี่ยนชื่อ',
+ renameInvalid: 'ชื่อต้องไม่ว่างเปล่าและต้องไม่มีตัวคั่นเส้นทาง',
+ delete: 'ลบ',
+ deleteTitle: 'ลบ "{name}" หรือไม่',
+ deleteDescFile: 'การดำเนินการนี้จะลบไฟล์อย่างถาวร และไม่สามารถย้อนกลับได้',
+ deleteDescDir: 'การดำเนินการนี้จะลบไดเรกทอรีและเนื้อหาทั้งหมดอย่างถาวร และไม่สามารถย้อนกลับได้',
+ dismiss: 'ปิด',
download: 'ดาวน์โหลด',
uploadFiles: 'อัปโหลดไฟล์',
uploadFolder: 'อัปโหลดโฟลเดอร์',
diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts
index abe14b0..45a5de4 100644
--- a/src/client/locales-tr.ts
+++ b/src/client/locales-tr.ts
@@ -198,6 +198,13 @@ export const tr: Record = {
addToConversation: 'Sohbete ekle',
copyRelative: 'Göreli yolu kopyala',
copyAbsolute: 'Mutlak yolu kopyala',
+ rename: 'Yeniden adlandır',
+ renameInvalid: 'Ad boş olamaz veya yol ayırıcıları içeremez.',
+ delete: 'Sil',
+ deleteTitle: '"{name}" silinsin mi?',
+ deleteDescFile: 'Bu, dosyayı kalıcı olarak siler. Bu işlem geri alınamaz.',
+ deleteDescDir: 'Bu, dizini ve tüm içeriğini kalıcı olarak siler. Bu işlem geri alınamaz.',
+ dismiss: 'Kapat',
download: 'İndir',
uploadFiles: 'Dosyaları yükle',
uploadFolder: 'Klasör yükle',
diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts
index c08d162..3878a90 100644
--- a/src/client/locales-vi.ts
+++ b/src/client/locales-vi.ts
@@ -198,6 +198,13 @@ export const vi: Record = {
addToConversation: 'Thêm vào cuộc trò chuyện',
copyRelative: 'Sao chép đường dẫn tương đối',
copyAbsolute: 'Sao chép đường dẫn tuyệt đối',
+ rename: 'Đổi tên',
+ renameInvalid: 'Tên không được để trống hoặc chứa dấu phân tách đường dẫn.',
+ delete: 'Xóa',
+ deleteTitle: 'Xóa "{name}"?',
+ deleteDescFile: 'Thao tác này sẽ xóa vĩnh viễn tệp. Không thể hoàn tác.',
+ deleteDescDir: 'Thao tác này sẽ xóa vĩnh viễn thư mục và toàn bộ nội dung của nó. Không thể hoàn tác.',
+ dismiss: 'Đóng',
download: 'Tải xuống',
uploadFiles: 'Tải tệp lên',
uploadFolder: 'Tải thư mục lên',
diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts
index 6771f6b..e378960 100644
--- a/src/client/locales-zh-HK.ts
+++ b/src/client/locales-zh-HK.ts
@@ -213,6 +213,13 @@ export const zhHK: Record = {
addToConversation: '新增到對話',
copyRelative: '複製相對位址',
copyAbsolute: '複製絕對位址',
+ rename: '重新命名',
+ renameInvalid: '名稱不可為空,且不可包含路徑分隔符。',
+ delete: '刪除',
+ deleteTitle: '刪除「{name}」?',
+ deleteDescFile: '將永久刪除該檔案,此操作無法復原。',
+ deleteDescDir: '將永久刪除該目錄及其全部內容,此操作無法復原。',
+ dismiss: '關閉',
download: '下載',
uploadFiles: '上傳檔案',
uploadFolder: '上傳資料夾',
diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts
index 940c7fe..f76d1c5 100644
--- a/src/client/locales-zh-MO.ts
+++ b/src/client/locales-zh-MO.ts
@@ -213,6 +213,13 @@ export const zhMO: Record = {
addToConversation: '新增到對話',
copyRelative: '複製相對位址',
copyAbsolute: '複製絕對位址',
+ rename: '重新命名',
+ renameInvalid: '名稱不可為空,且不可包含路徑分隔符。',
+ delete: '刪除',
+ deleteTitle: '刪除「{name}」?',
+ deleteDescFile: '將永久刪除該檔案,此操作無法復原。',
+ deleteDescDir: '將永久刪除該目錄及其全部內容,此操作無法復原。',
+ dismiss: '關閉',
download: '下載',
uploadFiles: '上傳檔案',
uploadFolder: '上傳資料夾',
diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts
index 6872455..c23ed9a 100644
--- a/src/client/locales-zh-TW.ts
+++ b/src/client/locales-zh-TW.ts
@@ -213,6 +213,13 @@ export const zhTW: Record = {
addToConversation: '新增到對話',
copyRelative: '複製相對位址',
copyAbsolute: '複製絕對位址',
+ rename: '重新命名',
+ renameInvalid: '名稱不可為空,且不可包含路徑分隔符。',
+ delete: '刪除',
+ deleteTitle: '刪除「{name}」?',
+ deleteDescFile: '將永久刪除該檔案,此操作無法復原。',
+ deleteDescDir: '將永久刪除該目錄及其全部內容,此操作無法復原。',
+ dismiss: '關閉',
download: '下載',
uploadFiles: '上傳檔案',
uploadFolder: '上傳資料夾',
diff --git a/src/client/locales.ts b/src/client/locales.ts
index 45a5a27..7a58901 100644
--- a/src/client/locales.ts
+++ b/src/client/locales.ts
@@ -205,6 +205,13 @@ export const zh = {
addToConversation: '添加到对话',
copyRelative: '复制相对地址',
copyAbsolute: '复制绝对地址',
+ rename: '重命名',
+ renameInvalid: '名称不能为空,且不能包含路径分隔符。',
+ delete: '删除',
+ deleteTitle: '删除「{name}」?',
+ deleteDescFile: '将永久删除该文件,此操作不可撤销。',
+ deleteDescDir: '将永久删除该目录及其全部内容,此操作不可撤销。',
+ dismiss: '关闭',
download: '下载',
uploadFiles: '上传文件',
uploadFolder: '上传文件夹',
@@ -634,6 +641,13 @@ export const en: Record = {
addToConversation: 'Add to conversation',
copyRelative: 'Copy relative path',
copyAbsolute: 'Copy absolute path',
+ rename: 'Rename',
+ renameInvalid: 'The name cannot be empty or contain path separators.',
+ delete: 'Delete',
+ deleteTitle: 'Delete "{name}"?',
+ deleteDescFile: 'This permanently deletes the file. This cannot be undone.',
+ deleteDescDir: 'This permanently deletes the directory and everything inside it. This cannot be undone.',
+ dismiss: 'Dismiss',
download: 'Download',
uploadFiles: 'Upload files',
uploadFolder: 'Upload folder',
diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css
index d15d13b..3694a6b 100644
--- a/src/client/sidebar.module.css
+++ b/src/client/sidebar.module.css
@@ -1038,6 +1038,83 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
white-space: nowrap;
}
+/* ── Inline rename editor ─────────────────────────────────────────────────
+ The row swaps its name span for a plain input (same row metrics: 34px
+ height, 6px gap, xxs type); only tokens, no hardcoded colors. */
+.explorerRenaming {
+ cursor: text;
+}
+
+.explorerRenameInput {
+ box-sizing: border-box;
+ flex: 1 1 auto;
+ min-width: 0;
+ height: 24px;
+ padding: 0 6px;
+ border: 1px solid var(--dsw-alias-state-business-primary);
+ border-radius: 6px;
+ background: var(--dsw-alias-bg-layer-1);
+ color: var(--dsw-alias-label-primary);
+ font: var(--dsw-font-xxs-12);
+}
+
+.explorerRenameInput:focus {
+ outline: none;
+ border-color: var(--dsw-alias-state-business-primary);
+}
+
+/* The dismissable mutation-failure strip above the root row (rename/delete
+ refusals — raw server text, same policy as the fence notice). */
+.explorerActionError {
+ display: flex;
+ align-items: flex-start;
+ gap: 6px;
+ box-sizing: border-box;
+ width: 100%;
+ margin-bottom: 4px;
+ padding: 6px 8px;
+ border: 1px solid var(--dsw-alias-state-error-primary);
+ border-radius: 8px;
+ background: var(--dsw-alias-bg-layer-1);
+}
+
+.explorerActionErrorText {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow-wrap: anywhere;
+ font: var(--dsw-font-xxs-12);
+ color: var(--dsw-alias-state-error-primary);
+}
+
+.explorerActionErrorClose {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+ padding: 0;
+ border: none;
+ border-radius: 5px;
+ background: transparent;
+ color: var(--dsw-alias-label-tertiary);
+ cursor: pointer;
+}
+
+.explorerActionErrorClose:hover {
+ background: var(--dsw-alias-interactive-bg-hover);
+ color: var(--dsw-alias-label-primary);
+}
+
+/* The delete-confirmation modal's description paragraph (the git lens's
+ .gitConfirmDesc sibling — same quiet single-question look). */
+.explorerConfirmDesc {
+ margin: 0;
+ font: var(--dsw-font-s-14);
+ color: var(--dsw-alias-label-primary);
+ overflow-wrap: anywhere;
+}
+
/* The hover-revealed @-reference button at the row's far right (and the
transient "copied" label that replaces it after a copy). The only auto
margin in the row absorbs the filename's spare width, so a short name
diff --git a/src/client/tree-mutations.ts b/src/client/tree-mutations.ts
new file mode 100644
index 0000000..8b1719c
--- /dev/null
+++ b/src/client/tree-mutations.ts
@@ -0,0 +1,50 @@
+/**
+ * Open-tab reconciliation after a file-tree mutation (rename/delete).
+ *
+ * The tree owns the rows; the TABS live in the sidebar state (either split
+ * tree, the bottom panel, or free windows). A rename must retarget every
+ * tab whose `path` is the renamed file (the editor content survives and
+ * later saves land on the new path); a delete must close every tab at or
+ * under the removed path — files and anything inside a removed directory
+ * (a stale tab's next save would fail against a missing path). Both ride
+ * the SERVICE paths (`updateTab`/`closeTab`) rather than direct state
+ * reducers so the registered lifecycle callbacks fire like any other tab
+ * mutation.
+ */
+import type { Context } from '../context-types.ts'
+import { baseName } from './FileTree.tsx'
+import { allLeaves, type SidebarSnapshot, type SidebarStore, type SidebarTab } from './state.ts'
+import { isWithinWorkspace } from './paths.ts'
+
+/** Every open tab that carries a file path (either split tree, the bottom
+ * panel, and free windows — a floating tab is as open as a docked one). */
+function pathTabsOf(snapshot: SidebarSnapshot): SidebarTab[] {
+ const state = snapshot.state
+ if (state === undefined) return []
+ const tabs: SidebarTab[] = []
+ for (const leaf of allLeaves(state.splits)) tabs.push(...leaf.tabs)
+ for (const leaf of allLeaves(state.bottomSplits)) tabs.push(...leaf.tabs)
+ for (const float of state.floats) tabs.push(float.tab)
+ return tabs.filter(tab => tab.path !== undefined)
+}
+
+/** Retarget tabs after `oldPath` became `newPath` (title follows the new
+ * base name, matching how openFile titles editor tabs). */
+export function retargetPathTabs(ctx: Context, store: SidebarStore, oldPath: string, newPath: string): void {
+ const service = ctx.get('betterSidebar')
+ if (service === undefined) return
+ for (const tab of pathTabsOf(store.getSnapshot())) {
+ if (tab.path === oldPath) service.updateTab(tab.id, { path: newPath, title: baseName(newPath) })
+ }
+}
+
+/** Close tabs at or under the removed `target` (a directory takes its whole
+ * subtree of open files with it). */
+export function closePathTabs(ctx: Context, store: SidebarStore, target: string): void {
+ const service = ctx.get('betterSidebar')
+ if (service === undefined) return
+ for (const tab of pathTabsOf(store.getSnapshot())) {
+ const path = tab.path
+ if (path !== undefined && (path === target || isWithinWorkspace(target, path))) service.closeTab(tab.id)
+ }
+}
diff --git a/src/fs-operations.ts b/src/fs-operations.ts
index fc6a48d..4908a30 100644
--- a/src/fs-operations.ts
+++ b/src/fs-operations.ts
@@ -9,14 +9,22 @@
* to a uniquely named temp sibling
* and are renamed into place, so a failed, aborted, or oversized upload never
* leaves a partial file at the target path.
+ *
+ * The tree's rename/delete (below) are link-aware: existence and containment
+ * are verified against the fully resolved target (a symlink pointing outside
+ * the workspace is refused while the fence is armed), but the operation
+ * itself addresses the lexical row path — renaming or deleting a symlink
+ * row renames/unlinks the LINK, never its target, matching what the tree
+ * row visually names (VS Code semantics).
*/
import { randomUUID } from 'node:crypto'
import { once } from 'node:events'
import { createWriteStream } from 'node:fs'
-import { mkdir, rename, rm, stat } from 'node:fs/promises'
+import { access, lstat, mkdir, realpath, rename, rm, stat, unlink } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
-import { requireAbsolute } from './fs-tree.ts'
+import { isWithin, requireAbsolute } from './fs-tree.ts'
import { ensureWorkspacePath, ensureWorkspaceWritePath } from './path-security.ts'
+import { resolveSessionPath } from './session-path.ts'
import { SidebarError } from './wire.ts'
/** Inputs of one upload: the session scope plus the request body stream. */
@@ -94,3 +102,117 @@ export async function writeWorkspaceUpload(input: WorkspaceUploadInput): Promise
throw error
}
}
+
+/** Inputs of one tree-row rename. */
+export interface WorkspaceRenameInput {
+ /** The session workspace root; the renamed entry must stay inside it. */
+ cwd: string
+ /** Absolute path of the row as the tree displays it (may be a symlink). */
+ path: string
+ /** The new base name (single segment — rename never moves across directories). */
+ name: string
+ /** Whether workspace containment is enforced (the `workspaceFence` setting; on by default). */
+ fence?: boolean
+}
+
+/** Resolve one existing entry for a link-aware mutation: the lexical row path
+ * plus its fully resolved real target (fence-checked). ENOENT becomes an
+ * fs-error, mirroring path-security's resolveRealPath semantics. */
+async function resolveEntry(
+ cwd: string,
+ target: string,
+ fence: boolean,
+): Promise<{ absolute: string; real: string; realCwd: string }> {
+ const absolute = requireAbsolute(resolveSessionPath(cwd, target))
+ let real: string
+ let realCwd: string
+ try {
+ ;[realCwd, real] = await Promise.all([realpath(cwd), realpath(absolute)])
+ } catch (error) {
+ throw new SidebarError('fs-error', `cannot resolve "${target}": ${error instanceof Error ? error.message : String(error)}`, 400)
+ }
+ if (fence && !isWithin(realCwd, real)) {
+ throw new SidebarError('forbidden', `path "${target}" is outside workspace`, 403)
+ }
+ return { absolute, real, realCwd }
+}
+
+/** Whether a path exists (ENOENT → false; other failures propagate). */
+async function pathExists(target: string): Promise {
+ try {
+ await access(target)
+ return true
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
+ throw error
+ }
+}
+
+/**
+ * Rename one tree row within its directory: `path` → `/`.
+ * The new name must be a single path segment (this is rename, not move);
+ * an existing destination is refused (POSIX rename would clobber it
+ * silently); the workspace root itself is never renamable; a symlink row
+ * renames the link, not its target. A no-op rename (same name) succeeds
+ * without touching the filesystem.
+ *
+ * @throws SidebarError with a wire code for shape, containment, existence
+ * and root failures.
+ */
+export async function renameWorkspaceEntry(input: WorkspaceRenameInput): Promise<{ path: string }> {
+ const { cwd, path, name, fence = true } = input
+ if (name === '' || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
+ throw new SidebarError('bad-request', 'name must be a single path segment', 400)
+ }
+ const { absolute, real, realCwd } = await resolveEntry(cwd, path, fence)
+ if (real === realCwd) {
+ throw new SidebarError('fs-error', 'cannot rename the workspace root', 400)
+ }
+ if (basename(absolute) === name) return { path: absolute }
+ const destination = join(dirname(absolute), name)
+ const safeDestination = await ensureWorkspaceWritePath(cwd, destination, fence)
+ if (await pathExists(safeDestination)) {
+ throw new SidebarError('fs-error', `"${name}" already exists`, 409)
+ }
+ try {
+ await rename(absolute, safeDestination)
+ } catch (error) {
+ throw new SidebarError('fs-error', `cannot rename "${path}" to "${name}": ${error instanceof Error ? error.message : String(error)}`, 400)
+ }
+ return { path: safeDestination }
+}
+
+/** Inputs of one tree-row delete. */
+export interface WorkspaceRemoveInput {
+ /** The session workspace root; the removed entry must stay inside it. */
+ cwd: string
+ /** Absolute path of the row as the tree displays it (may be a symlink). */
+ path: string
+ /** Whether workspace containment is enforced (the `workspaceFence` setting; on by default). */
+ fence?: boolean
+}
+
+/**
+ * Delete one tree row permanently (there is no trash on the host): files are
+ * unlinked, directories removed recursively, a symlink row unlinks the LINK
+ * only (lstat decides, so a link to a directory does not recurse into its
+ * target). The workspace root itself is never removable.
+ *
+ * @throws SidebarError with a wire code for containment, existence and
+ * root failures.
+ */
+export async function removeWorkspaceEntry(input: WorkspaceRemoveInput): Promise<{ path: string }> {
+ const { cwd, path, fence = true } = input
+ const { absolute, real, realCwd } = await resolveEntry(cwd, path, fence)
+ if (real === realCwd) {
+ throw new SidebarError('fs-error', 'cannot remove the workspace root', 400)
+ }
+ try {
+ const info = await lstat(absolute)
+ if (info.isDirectory()) await rm(absolute, { recursive: true })
+ else await unlink(absolute)
+ } catch (error) {
+ throw new SidebarError('fs-error', `cannot remove "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
+ }
+ return { path: absolute }
+}
diff --git a/src/index.ts b/src/index.ts
index 85c53d6..51d76ba 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -31,7 +31,7 @@ import {
} from './config.ts'
import { parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
import { resolveSessionPath } from './session-path.ts'
-import { writeWorkspaceUpload } from './fs-operations.ts'
+import { renameWorkspaceEntry, removeWorkspaceEntry, writeWorkspaceUpload } from './fs-operations.ts'
import { ensureWorkspacePath, ensureWorkspaceWritePath } from './path-security.ts'
import { searchFiles } from './fs-search.ts'
import { decodeHtmlUrl } from './html-route.ts'
@@ -369,6 +369,28 @@ function buildApi(
}
return { ok: true }
},
+ // The tree row's rename: single-segment name, destination-existence and
+ // workspace-root refusals, link-aware (renames the row, not its target).
+ // fs-operations.ts owns the containment and shape rules.
+ 'fs.rename': async (payload) => {
+ const { cwd } = await cwdOf(payload)
+ return renameWorkspaceEntry({
+ cwd,
+ path: requireString(payload, 'path'),
+ name: requireString(payload, 'name'),
+ fence: fenceEnabledOf(getSettings),
+ })
+ },
+ // The tree row's delete (permanent — the host has no trash): recursive
+ // for directories, unlinks a symlink row without touching its target.
+ 'fs.remove': async (payload) => {
+ const { cwd } = await cwdOf(payload)
+ return removeWorkspaceEntry({
+ cwd,
+ path: requireString(payload, 'path'),
+ fence: fenceEnabledOf(getSettings),
+ })
+ },
'git.worktrees': async (payload) => {
const { cwd } = await gitCwdOf(payload)
const selected = selectedRepoOf(payload)
diff --git a/tests/file-tree-rename-delete.spec.tsx b/tests/file-tree-rename-delete.spec.tsx
new file mode 100644
index 0000000..8d29687
--- /dev/null
+++ b/tests/file-tree-rename-delete.spec.tsx
@@ -0,0 +1,230 @@
+/**
+ * FileTree explorer mutations: the context menu's rename (inline row editor)
+ * and delete (confirmation modal) — menu-item visibility (never on the
+ * workspace root row), the commit/cancel flows, the API calls with the row
+ * path, the tab-reconciliation callbacks, and the failure strip.
+ */
+// @vitest-environment jsdom
+import { beforeAll, afterEach, describe, expect, it, vi } from 'vitest'
+import { createElement } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { act } from 'react-dom/test-utils'
+import { FileTree } from '../src/client/FileTree.tsx'
+import { createSidebarStore } from '../src/client/state.ts'
+
+// The act() environment flag (React 18.2 reads it before flushing effects).
+import { setupReactAct } from './test-utils.ts'
+setupReactAct()
+
+// vitest 4.1.11+ follows the OS locale; pin en-US so menu copy is English.
+beforeAll(() => {
+ Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
+})
+
+// vi.mock factories are hoisted above every declaration, so the shared
+// mocks ride vi.hoisted (mockClear in afterEach keeps the implementations).
+const { fsRename, fsRemove } = vi.hoisted(() => ({
+ fsRename: vi.fn(async () => ({ path: '/tmp/renamed.ts' })),
+ fsRemove: vi.fn(async () => ({ path: '/tmp/a.ts' })),
+}))
+
+vi.mock('../src/client/api.ts', () => ({
+ api: {
+ fsTree: async () => ({
+ entries: [
+ { name: 'a.ts', path: '/tmp/a.ts', isDir: false },
+ { name: 'sub', path: '/tmp/sub', isDir: true },
+ ],
+ }),
+ fsRename,
+ fsRemove,
+ },
+ downloadUrl: () => '/sidebar/file',
+ isOutsideWorkspaceMessage: () => false,
+}))
+
+interface Harness {
+ container: HTMLDivElement
+ onPathRenamed: ReturnType
+ onPathDeleted: ReturnType
+ unmount: () => void
+}
+
+async function mountTree(): Promise {
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root: Root = createRoot(container)
+ const onPathRenamed = vi.fn()
+ const onPathDeleted = vi.fn()
+ await act(async () => {
+ root.render(createElement(FileTree, {
+ sessionId: 's1',
+ cwd: '/tmp',
+ store: createSidebarStore(),
+ expanded: [],
+ revealed: [],
+ onToggle: () => {},
+ onOpenFile: () => {},
+ onOpenFileNewTab: () => {},
+ onOpenFileSide: () => {},
+ onReferenceFile: () => {},
+ onPathRenamed,
+ onPathDeleted,
+ refreshTick: 0,
+ onUploadRequest: () => {},
+ busy: false,
+ }))
+ })
+ return {
+ container,
+ onPathRenamed,
+ onPathDeleted,
+ unmount: () => { act(() => { root.unmount() }) },
+ }
+}
+
+/** One tree row by its displayed name (the root row included; it carries no
+ * role="button", so the query rides the row class). */
+function rowByName(container: HTMLDivElement, name: string): HTMLElement {
+ const row = [...container.querySelectorAll('[class*="explorerRow"]')]
+ .find(el => el.querySelector('[class*="explorerName"]')?.textContent === name)
+ if (row === undefined) throw new Error(`row "${name}" not found`)
+ return row
+}
+
+function openMenu(container: HTMLDivElement, name: string): void {
+ const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 20, clientY: 30 })
+ act(() => { rowByName(container, name).dispatchEvent(event) })
+}
+
+function menuLabels(): string[] {
+ return [...document.querySelectorAll('[role="menuitem"]')].map(item => item.textContent ?? '')
+}
+
+function clickMenuitem(label: string): void {
+ const item = [...document.querySelectorAll('[role="menuitem"]')].find(el => el.textContent === label)
+ if (item === undefined) throw new Error(`menuitem "${label}" not found`)
+ act(() => { item.click() })
+}
+
+/** Set a controlled input's value the React way (native setter + input). */
+function setNativeValue(el: HTMLInputElement, value: string): void {
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
+ if (setter === undefined) throw new Error('no native value setter')
+ setter.call(el, value)
+ el.dispatchEvent(new Event('input', { bubbles: true }))
+}
+
+function pressKey(el: HTMLElement, key: string): void {
+ el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
+}
+
+let harness: Harness
+afterEach(() => {
+ harness.unmount()
+ document.body.innerHTML = ''
+ fsRename.mockClear()
+ fsRemove.mockClear()
+})
+
+describe('FileTree rename/delete menu items', () => {
+ it('offers rename and delete on file and directory rows, never on the root', async () => {
+ harness = await mountTree()
+ openMenu(harness.container, 'a.ts')
+ expect(menuLabels()).toContain('Rename')
+ expect(menuLabels()).toContain('Delete')
+ openMenu(harness.container, 'sub')
+ expect(menuLabels()).toContain('Rename')
+ // The workspace root row (cwd itself) is the session — no mutations.
+ openMenu(harness.container, 'tmp')
+ expect(menuLabels()).not.toContain('Rename')
+ expect(menuLabels()).not.toContain('Delete')
+ })
+})
+
+describe('FileTree inline rename', () => {
+ it('replaces the row with a prefilled editor; Enter commits via fsRename + onPathRenamed', async () => {
+ harness = await mountTree()
+ openMenu(harness.container, 'a.ts')
+ clickMenuitem('Rename')
+ const input = harness.container.querySelector('input[class*="explorerRenameInput"]')
+ expect(input).not.toBeNull()
+ expect(input!.value).toBe('a.ts')
+ setNativeValue(input!, 'b.ts')
+ await act(async () => { pressKey(input!, 'Enter') })
+ expect(fsRename).toHaveBeenCalledWith({ sessionId: 's1', cwd: '/tmp' }, '/tmp/a.ts', 'b.ts')
+ expect(harness.onPathRenamed).toHaveBeenCalledWith('/tmp/a.ts', '/tmp/renamed.ts')
+ // The editor row is gone (the commit closed it).
+ expect(harness.container.querySelector('input[class*="explorerRenameInput"]')).toBeNull()
+ })
+
+ it('Escape cancels without touching the API', async () => {
+ harness = await mountTree()
+ openMenu(harness.container, 'a.ts')
+ clickMenuitem('Rename')
+ const input = harness.container.querySelector('input[class*="explorerRenameInput"]')!
+ setNativeValue(input, 'b.ts')
+ await act(async () => { pressKey(input, 'Escape') })
+ expect(fsRename).not.toHaveBeenCalled()
+ expect(harness.onPathRenamed).not.toHaveBeenCalled()
+ expect(harness.container.querySelector('input[class*="explorerRenameInput"]')).toBeNull()
+ })
+
+ it('rejects separator names client-side with the error strip, no API call', async () => {
+ harness = await mountTree()
+ openMenu(harness.container, 'a.ts')
+ clickMenuitem('Rename')
+ const input = harness.container.querySelector('input[class*="explorerRenameInput"]')!
+ setNativeValue(input, 'a/b.ts')
+ await act(async () => { pressKey(input, 'Enter') })
+ expect(fsRename).not.toHaveBeenCalled()
+ const alert = harness.container.querySelector('[role="alert"]')
+ expect(alert?.textContent).toContain('path separators')
+ })
+})
+
+describe('FileTree delete confirmation', () => {
+ it('confirms through the modal, then calls fsRemove + onPathDeleted', async () => {
+ harness = await mountTree()
+ openMenu(harness.container, 'a.ts')
+ clickMenuitem('Delete')
+ const dialog = document.querySelector('[role="dialog"]')
+ expect(dialog?.textContent).toContain('Delete "a.ts"?')
+ expect(dialog?.textContent).toContain('permanently deletes the file')
+ const confirm = [...document.querySelectorAll('button')].find(el => el.textContent === 'Delete' && el.closest('[role="dialog"]') !== null)!
+ await act(async () => { confirm.click() })
+ expect(fsRemove).toHaveBeenCalledWith({ sessionId: 's1', cwd: '/tmp' }, '/tmp/a.ts')
+ expect(harness.onPathDeleted).toHaveBeenCalledWith('/tmp/a.ts', false)
+ expect(document.querySelector('[role="dialog"]')).toBeNull()
+ })
+
+ it('the directory copy names the recursive wipe', async () => {
+ harness = await mountTree()
+ openMenu(harness.container, 'sub')
+ clickMenuitem('Delete')
+ const dialog = document.querySelector('[role="dialog"]')
+ expect(dialog?.textContent).toContain('everything inside it')
+ })
+
+ it('cancel closes the modal without calling anything', async () => {
+ harness = await mountTree()
+ openMenu(harness.container, 'a.ts')
+ clickMenuitem('Delete')
+ const cancel = [...document.querySelectorAll('button')].find(el => el.textContent === 'Cancel')!
+ act(() => { cancel.click() })
+ expect(fsRemove).not.toHaveBeenCalled()
+ expect(document.querySelector('[role="dialog"]')).toBeNull()
+ })
+
+ it('a server failure lands in the dismissable strip', async () => {
+ fsRemove.mockRejectedValueOnce(new Error('boom: refused'))
+ harness = await mountTree()
+ openMenu(harness.container, 'a.ts')
+ clickMenuitem('Delete')
+ const confirm = [...document.querySelectorAll('button')].find(el => el.textContent === 'Delete' && el.closest('[role="dialog"]') !== null)!
+ await act(async () => { confirm.click() })
+ const alert = harness.container.querySelector('[role="alert"]')
+ expect(alert?.textContent).toContain('boom: refused')
+ expect(harness.onPathDeleted).not.toHaveBeenCalled()
+ })
+})
diff --git a/tests/fs-operations.spec.ts b/tests/fs-operations.spec.ts
index fb31399..d90ebbc 100644
--- a/tests/fs-operations.spec.ts
+++ b/tests/fs-operations.spec.ts
@@ -1,8 +1,8 @@
import { afterAll, describe, expect, it } from 'vitest'
-import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync } from 'node:fs'
+import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
-import { writeWorkspaceUpload } from '../src/fs-operations.ts'
+import { renameWorkspaceEntry, removeWorkspaceEntry, writeWorkspaceUpload } from '../src/fs-operations.ts'
/** The test workspace root (each suite gets its own temp tree). */
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-upload-'))
@@ -150,3 +150,92 @@ describe('writeWorkspaceUpload', () => {
expect(readFileSync(target, 'utf8')).toBe('original')
})
})
+
+describe('renameWorkspaceEntry', () => {
+ it('renames a file within its directory', async () => {
+ writeFileSync(join(root, 'old-name.txt'), 'x')
+ const { path } = await renameWorkspaceEntry({ cwd: root, path: join(root, 'old-name.txt'), name: 'new-name.txt' })
+ // The returned path is CANONICAL (realpath-resolved ancestors — on macOS
+ // the tmpdir's /var becomes /private/var), so assert the shape, not the
+ // lexical spelling.
+ expect(path.endsWith('new-name.txt')).toBe(true)
+ expect(existsSync(join(root, 'old-name.txt'))).toBe(false)
+ expect(existsSync(join(root, 'new-name.txt'))).toBe(true)
+ })
+
+ it('renames a directory (recursive content moves with it)', async () => {
+ mkdirSync(join(root, 'olddir/nested'), { recursive: true })
+ writeFileSync(join(root, 'olddir/nested/deep.txt'), 'x')
+ await renameWorkspaceEntry({ cwd: root, path: join(root, 'olddir'), name: 'newdir' })
+ expect(readFileSync(join(root, 'newdir/nested/deep.txt'), 'utf8')).toBe('x')
+ })
+
+ it('is a no-op for the same name (destination-exists refusal must not bite)', async () => {
+ writeFileSync(join(root, 'same.txt'), 'x')
+ await renameWorkspaceEntry({ cwd: root, path: join(root, 'same.txt'), name: 'same.txt' })
+ expect(existsSync(join(root, 'same.txt'))).toBe(true)
+ })
+
+ it('refuses single-segment violations (empty, dot, traversal, separators)', async () => {
+ writeFileSync(join(root, 'r.txt'), 'x')
+ for (const name of ['', '.', '..', 'a/b', 'a\\b']) {
+ await expect(renameWorkspaceEntry({ cwd: root, path: join(root, 'r.txt'), name }))
+ .rejects.toMatchObject({ code: 'bad-request' })
+ }
+ })
+
+ it('refuses an existing destination instead of clobbering it', async () => {
+ writeFileSync(join(root, 'src.txt'), 'src')
+ writeFileSync(join(root, 'dst.txt'), 'dst')
+ await expect(renameWorkspaceEntry({ cwd: root, path: join(root, 'src.txt'), name: 'dst.txt' }))
+ .rejects.toMatchObject({ code: 'fs-error', status: 409 })
+ expect(readFileSync(join(root, 'dst.txt'), 'utf8')).toBe('dst')
+ })
+
+ it('refuses the workspace root and missing sources', async () => {
+ await expect(renameWorkspaceEntry({ cwd: root, path: root, name: 'nope' }))
+ .rejects.toMatchObject({ code: 'fs-error' })
+ await expect(renameWorkspaceEntry({ cwd: root, path: join(root, 'missing.txt'), name: 'x' }))
+ .rejects.toMatchObject({ code: 'fs-error' })
+ })
+
+ it('renames a symlink ROW, not its target', async () => {
+ writeFileSync(join(root, 'target.txt'), 't')
+ symlinkSync(join(root, 'target.txt'), join(root, 'alias.txt'))
+ await renameWorkspaceEntry({ cwd: root, path: join(root, 'alias.txt'), name: 'alias2.txt' })
+ expect(existsSync(join(root, 'alias.txt'))).toBe(false)
+ expect(existsSync(join(root, 'alias2.txt'))).toBe(true)
+ expect(readFileSync(join(root, 'target.txt'), 'utf8')).toBe('t')
+ })
+})
+
+describe('removeWorkspaceEntry', () => {
+ it('unlinks a file', async () => {
+ writeFileSync(join(root, 'gone.txt'), 'x')
+ await removeWorkspaceEntry({ cwd: root, path: join(root, 'gone.txt') })
+ expect(existsSync(join(root, 'gone.txt'))).toBe(false)
+ })
+
+ it('removes a directory recursively', async () => {
+ mkdirSync(join(root, 'tree/sub'), { recursive: true })
+ writeFileSync(join(root, 'tree/sub/leaf.txt'), 'x')
+ await removeWorkspaceEntry({ cwd: root, path: join(root, 'tree') })
+ expect(existsSync(join(root, 'tree'))).toBe(false)
+ })
+
+ it('unlinks a symlink row without touching (or recursing into) its target', async () => {
+ mkdirSync(join(root, 'realdir'), { recursive: true })
+ writeFileSync(join(root, 'realdir/keep.txt'), 'x')
+ symlinkSync(join(root, 'realdir'), join(root, 'linkdir'))
+ await removeWorkspaceEntry({ cwd: root, path: join(root, 'linkdir') })
+ expect(existsSync(join(root, 'linkdir'))).toBe(false)
+ expect(readFileSync(join(root, 'realdir/keep.txt'), 'utf8')).toBe('x')
+ })
+
+ it('refuses the workspace root and missing paths', async () => {
+ await expect(removeWorkspaceEntry({ cwd: root, path: root }))
+ .rejects.toMatchObject({ code: 'fs-error' })
+ await expect(removeWorkspaceEntry({ cwd: root, path: join(root, 'no-such.txt') }))
+ .rejects.toMatchObject({ code: 'fs-error' })
+ })
+})
diff --git a/tests/menu-flip.spec.tsx b/tests/menu-flip.spec.tsx
index 4ae7b72..d6281e2 100644
--- a/tests/menu-flip.spec.tsx
+++ b/tests/menu-flip.spec.tsx
@@ -24,6 +24,18 @@ describe('layout.css contract', () => {
expect(css).toContain(`body[${SUBMENU_FLIP_ATTR}~="left"] div[role="menu"] div[role="menu"]`)
expect(css).toContain(`body[${SUBMENU_FLIP_ATTR}~="left"] div[role="menu"] div[role="menu"]::before`)
})
+
+ it('keeps the submenu visual tether rules (parent highlight + tight gap)', () => {
+ // Part 1 keeps the parent row lit while its submenu is open (the Menu
+ // marks it aria-expanded); part 2 tightens the card gap to 4px with the
+ // hover corridor narrowed to match. Losing either sends the submenu back
+ // to reading as an unrelated floating card.
+ const css = readFileSync('src/client/layout.css', 'utf8')
+ expect(css).toContain(`body[${SUBMENU_FLIP_ATTR}] div[role="menu"] button[aria-expanded="true"]`)
+ expect(css).toContain('left: calc(100% + 4px)')
+ expect(css).toContain('right: calc(100% + 4px)')
+ expect(css).toContain('width: 4px')
+ })
})
describe('submenuFlipTokens', () => {