#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 <b3nw@duck.com>
This commit is contained in:
1 parent
c6ac6aa5c9
commit
cf4bed3d35
7 files changed
+226
-26
No files matched your search
@@ -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
|
||||
|
||||
@@ -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ę: ',
|
||||
|
||||
+61
-20
@@ -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){
|
||||
|
||||
@@ -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
|
||||
@@ -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, (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user