From cf4bed3d354c1056c9cce16de1bee8a62ed0dbb2 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 8 Jun 2026 20:55:15 -0700 Subject: [PATCH] =?UTF-8?q?Release=20v0.51.339=20=E2=80=94=20Release=20LC?= =?UTF-8?q?=20(targeted=20workspace=20create=20actions,=20#3855=20+=20#385?= =?UTF-8?q?8)=20(#3865)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3855 (@b3nw): workspace context menus (root/dir/file rows) gain New File/New Folder targeting the clicked location instead of always S.currentDir. promptNewFile/ promptNewFolder take a targetDir param; prompt title names the target. i18n complete (13 locales). Live-verified end-to-end: New File from src/ menu creates inside src/ (on disk), not root. Opus SHIP-safe (backend safe_resolve is the enforced trust boundary; frontend join is naive but backend-gated). Context menu screenshot-approved. #3858 (@b3nw): adds cancelLabel:t('status_no') ('No') to the post-folder-create 'Add as space?' confirm (was an unlabeled 'Cancel'). Both rebased onto master; combined cleanly (both touch promptNewFolder, no conflict). Suite 8349, ESLint/scope-undef/ruff CLEAN. greptile flags evaluated → stale/resolved. Co-authored-by: b3nw --- CHANGELOG.md | 8 ++ static/i18n.js | 65 +++++++++++++++ static/ui.js | 81 ++++++++++++++----- ...est_issue1786_workspace_heading_actions.py | 16 +++- tests/test_profile_default_workspace_823.py | 2 +- tests/test_workspace_blank_page_fix.py | 6 +- .../test_workspace_context_menu_and_rename.py | 74 +++++++++++++++++ 7 files changed, 226 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98531e9f..fe406d3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ ## [Unreleased] +## [v0.51.339] — 2026-06-09 — Release LC (targeted workspace create actions) + +### Added +- **Create files and folders where you right-click in the workspace.** The workspace context menus (root heading, directory rows, file rows) now offer **New File** / **New Folder** that target the thing you clicked — a directory row creates inside that directory, a file row creates alongside it — instead of always targeting the current directory. The create prompt names the target (e.g. "New file name in `src`:"). (#3855, #3843, @b3nw) + +### Fixed +- **The "Add as space?" prompt after creating a folder now has a clear "No" option** instead of an unlabeled cancel. (#3858, #3856, @b3nw) + ## [v0.51.338] — 2026-06-09 — Release LB (saved prompts library) ### Added diff --git a/static/i18n.js b/static/i18n.js index 8725dc6c..09d4dea7 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -449,6 +449,11 @@ const LOCALES = { session_link_copied: 'Conversation link copied to clipboard', session_link_copy_failed: 'Failed to copy conversation link: ', new_file_prompt: 'New file name (e.g. notes.md):', + new_file: 'New File', + new_folder: 'New Folder', + workspace_root: 'workspace root', + new_file_prompt_title: 'New file name in {0}:', + new_folder_prompt_title: 'New folder name in {0}:', project_name_prompt: 'Project name:', created: 'Created ', create_failed: 'Create failed: ', @@ -1839,6 +1844,11 @@ const LOCALES = { session_link_copied: 'Link conversazione copiato negli appunti', session_link_copy_failed: 'Impossibile copiare il link della conversazione: ', new_file_prompt: 'Nuovo nome file (es. note.md):', + new_file: 'Nuovo file', + new_folder: 'Nuova cartella', + workspace_root: 'root del workspace', + new_file_prompt_title: 'Nuovo nome file in {0}:', + new_folder_prompt_title: 'Nuovo nome cartella in {0}:', project_name_prompt: 'Nome progetto:', created: 'Creato ', create_failed: 'Creazione fallita: ', @@ -3221,6 +3231,11 @@ const LOCALES = { session_link_copied: '会話リンクをクリップボードにコピーしました', session_link_copy_failed: '会話リンクをコピーできませんでした: ', new_file_prompt: '新しいファイル名 (例: notes.md):', + new_file: '新規ファイル', + new_folder: '新規フォルダ', + workspace_root: 'ワークスペースのルート', + new_file_prompt_title: '{0} の新しいファイル名:', + new_folder_prompt_title: '{0} の新しいフォルダ名:', project_name_prompt: 'プロジェクト名:', created: '作成しました: ', create_failed: '作成失敗: ', @@ -4528,6 +4543,11 @@ const LOCALES = { session_link_copied: 'Ссылка на беседу скопирована в буфер обмена', session_link_copy_failed: 'Не удалось скопировать ссылку на беседу: ', new_file_prompt: 'Имя нового файла (например, notes.md):', + new_file: 'Новый файл', + new_folder: 'Новая папка', + workspace_root: 'корень рабочей области', + new_file_prompt_title: 'Имя нового файла в {0}:', + new_folder_prompt_title: 'Имя новой папки в {0}:', project_name_prompt: 'Имя проекта:', created: 'Создано ', create_failed: 'Не удалось создать: ', @@ -5827,6 +5847,11 @@ const LOCALES = { session_link_copied: 'Enlace de conversación copiado al portapapeles', session_link_copy_failed: 'No se pudo copiar el enlace de conversación: ', new_file_prompt: 'Nombre del archivo nuevo (p. ej. notes.md):', + new_file: 'Nuevo archivo', + new_folder: 'Nueva carpeta', + workspace_root: 'raíz del espacio de trabajo', + new_file_prompt_title: 'Nombre del archivo nuevo en {0}:', + new_folder_prompt_title: 'Nombre de la carpeta nueva en {0}:', created: 'Creado ', create_failed: 'Error al crear: ', new_folder_prompt: 'Nombre de la carpeta nueva:', @@ -7130,6 +7155,11 @@ const LOCALES = { session_link_copied: 'Unterhaltungslink in die Zwischenablage kopiert', session_link_copy_failed: 'Unterhaltungslink konnte nicht kopiert werden: ', new_file_prompt: 'Neuer Dateiname (z.B. notes.md):', + new_file: 'Neue Datei', + new_folder: 'Neuer Ordner', + workspace_root: 'Arbeitsbereich-Root', + new_file_prompt_title: 'Neuer Dateiname in {0}:', + new_folder_prompt_title: 'Neuer Ordnername in {0}:', project_name_prompt: 'Projektname:', created: 'Erstellt ', create_failed: 'Erstellen fehlgeschlagen: ', @@ -8486,6 +8516,11 @@ const LOCALES = { session_link_copied: '对话链接已复制到剪贴板', session_link_copy_failed: '无法复制对话链接:', new_file_prompt: '新文件名(例如 notes.md):', + new_file: '新建文件', + new_folder: '新建文件夹', + workspace_root: '工作区根目录', + new_file_prompt_title: '在 {0} 中新建文件名:', + new_folder_prompt_title: '在 {0} 中新建文件夹名称:', project_name_prompt: '项目名称:', created: '已创建 ', create_failed: '创建失败:', @@ -9881,6 +9916,11 @@ const LOCALES = { session_link_copied: '對話連結已複製到剪貼簿', session_link_copy_failed: '無法複製對話連結:', new_file_prompt: '新檔案名稱(例如 notes.md):', + new_file: '新建檔案', + new_folder: '新建資料夾', + workspace_root: '工作區根目錄', + new_file_prompt_title: '在 {0} 中新建檔案名稱:', + new_folder_prompt_title: '在 {0} 中新建資料夾名稱:', created: '已建立 ', create_failed: '建立失敗:', new_folder_prompt: '新資料夾名稱:', @@ -11157,6 +11197,11 @@ const LOCALES = { session_link_copied: 'Link da conversa copiado para a área de transferência', session_link_copy_failed: 'Falha ao copiar link da conversa: ', new_file_prompt: 'Nome do novo arquivo (ex: notes.md):', + new_file: 'Novo arquivo', + new_folder: 'Nova pasta', + workspace_root: 'raiz do espaço de trabalho', + new_file_prompt_title: 'Nome do novo arquivo em {0}:', + new_folder_prompt_title: 'Nome da nova pasta em {0}:', project_name_prompt: 'Nome do projeto:', created: 'Criado ', create_failed: 'Falha ao criar: ', @@ -12435,6 +12480,11 @@ const LOCALES = { session_link_copied: '대화 링크가 클립보드에 복사되었습니다', session_link_copy_failed: '대화 링크를 복사하지 못했습니다: ', new_file_prompt: 'New file name (e.g. notes.md):', + new_file: '새 파일', + new_folder: '새 폴더', + workspace_root: '워크스페이스 루트', + new_file_prompt_title: '{0}의 새 파일 이름:', + new_folder_prompt_title: '{0}의 새 폴더 이름:', project_name_prompt: 'Project name:', created: '생성됨: ', create_failed: '생성 실패: ', @@ -13746,6 +13796,11 @@ const LOCALES = { session_link_copied: 'Lien de conversation copié dans le presse-papiers', session_link_copy_failed: 'Impossible de copier le lien de conversation : ', new_file_prompt: 'Nouveau nom de fichier (par exemple notes.md) :', + new_file: 'Nouveau fichier', + new_folder: 'Nouveau dossier', + workspace_root: 'racine de l\'espace de travail', + new_file_prompt_title: 'Nouveau nom de fichier dans {0} :', + new_folder_prompt_title: 'Nouveau nom de dossier dans {0} :', project_name_prompt: 'Nom du projet :', created: 'Créé', create_failed: 'Échec de la création :', @@ -15136,6 +15191,11 @@ const LOCALES = { session_link_copied: 'Konuşma bağlantısı panoya kopyalandı', session_link_copy_failed: 'Konuşma bağlantısı kopyalanamadı: ', new_file_prompt: 'Yeni dosya adı (örn. Notes.md):', + new_file: 'Yeni Dosya', + new_folder: 'Yeni Klasör', + workspace_root: 'çalışma alanı kökü', + new_file_prompt_title: '{0} içindeki yeni dosya adı:', + new_folder_prompt_title: '{0} içindeki yeni klasör adı:', project_name_prompt: 'Proje adı:', created: 'Oluşturuldu', create_failed: 'Oluşturma başarısız oldu:', @@ -16529,6 +16589,11 @@ const LOCALES = { session_link_copied: 'Link do konwersacji skopiowany do schowka', session_link_copy_failed: 'Nie udało się skopiować linku do konwersacji: ', new_file_prompt: 'Nazwa nowego pliku (np. notes.md):', + new_file: 'Nowy plik', + new_folder: 'Nowy folder', + workspace_root: 'katalog główny obszaru roboczego', + new_file_prompt_title: 'Nazwa nowego pliku w {0}:', + new_folder_prompt_title: 'Nazwa nowego folderu w {0}:', project_name_prompt: 'Nazwa projektu:', created: 'Utworzono ', create_failed: 'Tworzenie nie powiodło się: ', diff --git a/static/ui.js b/static/ui.js index d9fb1dac..abbe3d34 100644 --- a/static/ui.js +++ b/static/ui.js @@ -10585,6 +10585,16 @@ function _copyTextWithFallback(text, successMsg, failurePrefix){ return Promise.resolve(); } +function _workspaceCreateTargetLabel(targetDir){ + return targetDir && targetDir !== '.' ? targetDir : t('workspace_root'); +} + +function _workspaceJoinTargetPath(targetDir, name){ + const cleanName=String(name||'').trim(); + if(!cleanName) return ''; + return (!targetDir||targetDir==='.') ? cleanName : `${targetDir}/${cleanName}`; +} + function _showWorkspaceRootContextMenu(e){ document.querySelectorAll('.file-ctx-menu').forEach(el=>el.remove()); const menu=document.createElement('div'); @@ -10594,6 +10604,20 @@ function _showWorkspaceRootContextMenu(e){ menu.style.left=(e.clientX+160>vw?e.clientX-170:e.clientX)+'px'; menu.style.top=(e.clientY+80>vh?e.clientY-80:e.clientY)+'px'; + menu.appendChild(_workspaceContextMenuItem(t('new_file'),async()=>{ + menu.remove(); + await promptNewFile('.'); + })); + + menu.appendChild(_workspaceContextMenuItem(t('new_folder'),async()=>{ + menu.remove(); + await promptNewFolder('.'); + })); + + const createSep=document.createElement('hr'); + createSep.style.cssText='border:none;border-top:1px solid var(--border);margin:4px 0;'; + menu.appendChild(createSep); + menu.appendChild(_workspaceContextMenuItem(t('reveal_in_finder'),async()=>{ menu.remove(); try{await api('/api/file/reveal',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:'.'})});} @@ -10930,6 +10954,21 @@ function _showFileContextMenu(e, item){ 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'; + const targetDir=item.type==='dir' ? item.path : _workspaceParentDir(item.path); + + menu.appendChild(_workspaceContextMenuItem(t('new_file'),async()=>{ + menu.remove(); + await promptNewFile(targetDir); + })); + + menu.appendChild(_workspaceContextMenuItem(t('new_folder'),async()=>{ + menu.remove(); + await promptNewFolder(targetDir); + })); + + const createSep=document.createElement('hr'); + createSep.style.cssText='border:none;border-top:1px solid var(--border);margin:4px 0;'; + menu.appendChild(createSep); // Rename const renameItem=document.createElement('div'); @@ -10977,10 +11016,6 @@ function _showFileContextMenu(e, item){ await navigator.clipboard.writeText(abs); showToast(t('path_copied')); }catch(clipErr){ - // Fallback for browsers where Clipboard API is gated (older Safari, - // non-secure contexts). Use the legacy execCommand path against a - // hidden textarea — this is the same pattern boot.js uses for the - // "Copy" buttons on code blocks. const ta=document.createElement('textarea'); ta.value=abs; ta.style.cssText='position:fixed;left:-9999px;top:-9999px;'; @@ -10998,8 +11033,6 @@ function _showFileContextMenu(e, item){ }; menu.appendChild(copyPathItem); - // Download as zip — only for directories. Streams the folder contents - // through /api/folder/download which builds the zip on the fly. if(item.type==='dir'){ const dlItem=document.createElement('div'); dlItem.textContent=t('download_folder'); @@ -11015,7 +11048,6 @@ function _showFileContextMenu(e, item){ menu.appendChild(dlItem); } - // Divider + Delete const sep=document.createElement('hr'); sep.style.cssText='border:none;border-top:1px solid var(--border);margin:4px 0;'; menu.appendChild(sep); @@ -11077,9 +11109,7 @@ async function deleteWorkspaceFile(relPath, name){ }catch(e){setStatus(t('delete_failed')+e.message);} } -async function promptNewFile(){ - // If no active session but a default workspace is configured, auto-create - // a session bound to it so workspace actions work on the blank new-chat page. +async function promptNewFile(targetDir = S.currentDir || '.'){ if(!S.session){ const ws=(typeof S._profileDefaultWorkspace==='string'&&S._profileDefaultWorkspace)||''; if(!ws) return; @@ -11089,19 +11119,24 @@ async function promptNewFile(){ }catch(e){setStatus(t('create_failed')+e.message);return;} } if(!S.session)return; - const name=await showPromptDialog({title:t('new_file_prompt'),placeholder:'filename.txt',confirmLabel:t('create')}); - if(!name||!name.trim())return; - const relPath=S.currentDir==='.'?name.trim():(S.currentDir+'/'+name.trim()); + const targetLabel=_workspaceCreateTargetLabel(targetDir); + const name=await showPromptDialog({ + title:t('new_file_prompt_title', targetLabel), + placeholder:'filename.txt', + confirmLabel:t('create') + }); + if(!name||!name.trim()) return; + const relPath=_workspaceJoinTargetPath(targetDir,name); try{ await api('/api/file/create',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:relPath,content:''})}); showToast(t('created')+name.trim()); + delete S._dirCache[targetDir || '.']; await loadDir(S.currentDir); openFile(relPath); }catch(e){setStatus(t('create_failed')+e.message);} } -async function promptNewFolder(){ - // Same auto-create-session logic as promptNewFile for the blank page. +async function promptNewFolder(targetDir = S.currentDir || '.'){ if(!S.session){ const ws=(typeof S._profileDefaultWorkspace==='string'&&S._profileDefaultWorkspace)||''; if(!ws) return; @@ -11111,20 +11146,26 @@ async function promptNewFolder(){ }catch(e){setStatus(t('folder_create_failed')+e.message);return;} } if(!S.session)return; - const name=await showPromptDialog({title:t('new_folder_prompt'),placeholder:'folder-name',confirmLabel:t('create')}); - if(!name||!name.trim())return; - const relPath=S.currentDir==='.'?name.trim():(S.currentDir+'/'+name.trim()); + const targetLabel=_workspaceCreateTargetLabel(targetDir); + const name=await showPromptDialog({ + title:t('new_folder_prompt_title', targetLabel), + placeholder:'folder-name', + confirmLabel:t('create') + }); + if(!name||!name.trim()) return; + const relPath=_workspaceJoinTargetPath(targetDir,name); try{ await api('/api/file/create-dir',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:relPath})}); showToast(t('folder_created')+name.trim()); + delete S._dirCache[targetDir || '.']; await loadDir(S.currentDir); - // Offer to add the new folder as a space (#782) - const absPath=S.session.workspace?((S.currentDir==='.'?S.session.workspace:S.session.workspace+'/'+S.currentDir)+'/'+name.trim()):null; + const absPath=S.session.workspace?(targetDir==='.'?`${S.session.workspace}/${name.trim()}`:`${S.session.workspace}/${targetDir}/${name.trim()}`):null; if(absPath){ const addAsSpace=await showConfirmDialog({ title:t('folder_add_as_space_title'), message:t('folder_add_as_space_msg'), confirmLabel:t('folder_add_as_space_btn'), + cancelLabel:t('status_no'), focusCancel:true }); if(addAsSpace){ diff --git a/tests/test_issue1786_workspace_heading_actions.py b/tests/test_issue1786_workspace_heading_actions.py index 718fe0bf..31f452f6 100644 --- a/tests/test_issue1786_workspace_heading_actions.py +++ b/tests/test_issue1786_workspace_heading_actions.py @@ -13,12 +13,16 @@ def test_workspace_heading_is_interactive_root_control(): assert "loadDir('.')" in UI_JS -def test_workspace_heading_context_menu_exposes_root_reveal_and_copy_path(): - """Right-clicking the heading should expose root-scoped Reveal and Copy path actions.""" +def test_workspace_heading_context_menu_exposes_root_actions(): + """Right-clicking the heading should expose root-scoped create and utility actions.""" assert "_showWorkspaceRootContextMenu" in UI_JS + assert "promptNewFile('.')" in UI_JS + assert "promptNewFolder('.')" in UI_JS assert "'/api/file/reveal'" in UI_JS assert "'/api/file/path'" in UI_JS assert "path:'.'" in UI_JS.replace(" ", "") + assert "new_file_prompt" in UI_JS + assert "new_folder_prompt" in UI_JS assert "copy_file_path" in UI_JS assert "reveal_in_finder" in UI_JS @@ -41,3 +45,11 @@ def test_workspace_heading_affordance_requires_workspace(): guard_idx = UI_JS.find("if(!(S.session&&S.session.workspace)) return;", context_idx) prevent_idx = UI_JS.find("e.preventDefault()", context_idx) assert context_idx < guard_idx < prevent_idx + + +def test_new_folder_add_as_workspace_prompt_uses_no_for_cancel_label(): + """The post-create-folder workspace prompt should offer an explicit 'No' option.""" + assert "title:t('folder_add_as_space_title')" in UI_JS + assert "message:t('folder_add_as_space_msg')" in UI_JS + assert "confirmLabel:t('folder_add_as_space_btn')" in UI_JS + assert "cancelLabel:t('status_no')" in UI_JS diff --git a/tests/test_profile_default_workspace_823.py b/tests/test_profile_default_workspace_823.py index ecb597a6..b1c62ec4 100644 --- a/tests/test_profile_default_workspace_823.py +++ b/tests/test_profile_default_workspace_823.py @@ -126,7 +126,7 @@ class TestBlankPageAfterSessionDelete: """promptNewFile on blank page reads _profileDefaultWorkspace which must be non-null even after a newSession() + deleteSession() cycle.""" src = read('static/ui.js') - m = re.search(r'async function promptNewFile\(\)\{.*?\n\}', src, re.DOTALL) + m = re.search(r'async function promptNewFile\([^)]*\)\{.*?\n\}', src, re.DOTALL) assert m, "promptNewFile not found" fn = m.group(0) assert '_profileDefaultWorkspace' in fn, ( diff --git a/tests/test_workspace_blank_page_fix.py b/tests/test_workspace_blank_page_fix.py index 21db4d89..1234e41e 100644 --- a/tests/test_workspace_blank_page_fix.py +++ b/tests/test_workspace_blank_page_fix.py @@ -77,7 +77,7 @@ class TestPromptNewFileNoSession: def test_prompt_new_file_auto_creates_session(self): src = read('static/ui.js') - m = re.search(r'async function promptNewFile\(\)\{.*?\n\}', src, re.DOTALL) + m = re.search(r'async function promptNewFile\([^)]*\)\{.*?\n\}', src, re.DOTALL) assert m, "promptNewFile not found" fn = m.group(0) # Must have auto-create path (not just early return when no session) @@ -92,7 +92,7 @@ class TestPromptNewFileNoSession: def test_prompt_new_folder_auto_creates_session(self): src = read('static/ui.js') - m = re.search(r'async function promptNewFolder\(\)\{.*?\n\}', src, re.DOTALL) + m = re.search(r'async function promptNewFolder\([^)]*\)\{.*?\n\}', src, re.DOTALL) assert m, "promptNewFolder not found" fn = m.group(0) assert '_profileDefaultWorkspace' in fn, ( @@ -105,7 +105,7 @@ class TestPromptNewFileNoSession: def test_prompt_new_file_still_returns_early_without_default(self): """If no default workspace, the function should return early (not crash).""" src = read('static/ui.js') - m = re.search(r'async function promptNewFile\(\)\{.*?\n\}', src, re.DOTALL) + m = re.search(r'async function promptNewFile\([^)]*\)\{.*?\n\}', src, re.DOTALL) assert m fn = m.group(0) # Must have a guard for empty workspace diff --git a/tests/test_workspace_context_menu_and_rename.py b/tests/test_workspace_context_menu_and_rename.py index 89fb770f..790c9566 100644 --- a/tests/test_workspace_context_menu_and_rename.py +++ b/tests/test_workspace_context_menu_and_rename.py @@ -259,5 +259,79 @@ class ShowPromptDialogPrefillTests(unittest.TestCase): ) +# --------------------------------------------------------------------------- +# (c) Targeted workspace create actions from context menus +# --------------------------------------------------------------------------- +class WorkspaceTargetedCreateContextMenuTests(unittest.TestCase): + """Pin the target-aware create affordances for workspace root and tree rows. + + The maintainers still have open product questions about whether file-row + context menus should create in the parent directory or whether create + actions should be dir/root only. The current proposal intentionally keeps + the decision isolated in target-dir helper wiring so changing course later + is cheap. + """ + + def setUp(self): + self.src = _read(UI_JS) + + def _slice_function(self, name: str) -> str: + m = re.search(rf"function\s+{re.escape(name)}\b[^{{]*\{{", self.src) + self.assertIsNotNone(m, f"Could not find {name}()") + start = m.start() + depth = 0 + end = start + for i, ch in enumerate(self.src[start:], start=start): + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + end = i + 1 + break + return self.src[start:end] + + def test_prompt_new_file_signature_and_defaults(self): + body = self._slice_function("promptNewFile") + self.assertRegex(body, r"function\s+promptNewFile\(\s*targetDir\s*=\s*S\.currentDir\s*\|\|\s*'\.'\s*\)") + + def test_prompt_new_folder_signature_and_defaults(self): + body = self._slice_function("promptNewFolder") + self.assertRegex(body, r"function\s+promptNewFolder\(\s*targetDir\s*=\s*S\.currentDir\s*\|\|\s*'\.'\s*\)") + + def test_prompt_new_file_posts_to_create_endpoint(self): + body = self._slice_function("promptNewFile") + self.assertIn("showPromptDialog", body) + self.assertIn("/api/file/create", body) + self.assertIn("_workspaceCreateTargetLabel", body) + self.assertIn("_workspaceJoinTargetPath", body) + self.assertIn("delete S._dirCache[targetDir || '.'];", body) + + def test_prompt_new_folder_posts_to_create_dir_endpoint(self): + body = self._slice_function("promptNewFolder") + self.assertIn("showPromptDialog", body) + self.assertIn("/api/file/create-dir", body) + self.assertIn("_workspaceCreateTargetLabel", body) + self.assertIn("_workspaceJoinTargetPath", body) + self.assertIn("delete S._dirCache[targetDir || '.'];", body) + + def test_dead_workspace_session_helper_removed(self): + self.assertNotIn("function _ensureWorkspaceSessionForCreate", self.src) + + def test_workspace_root_context_menu_wires_targeted_create_actions(self): + body = self._slice_function("_showWorkspaceRootContextMenu") + self.assertIn("promptNewFile('.')", body) + self.assertIn("promptNewFolder('.')", body) + + def test_file_context_menu_computes_target_directory_for_create_actions(self): + body = self._slice_function("_showFileContextMenu") + self.assertRegex( + body, + r"const\s+targetDir\s*=\s*item\.type===['\"]dir['\"]\s*\?\s*item\.path\s*:\s*_workspaceParentDir\(item\.path\)", + ) + self.assertIn("promptNewFile(targetDir)", body) + self.assertIn("promptNewFolder(targetDir)", body) + + if __name__ == "__main__": unittest.main()