Release v0.51.339 — Release LC (targeted workspace create actions, #3855 + #3858) (#3865)

#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:
nesquena-hermesandb3nw authored and GitHub committed 2026-06-08 20:55:15 -07:00
1 parent c6ac6aa5c9
commit cf4bed3d35
7 files changed
+226 -26

No files matched your search

+8
View File
@@ -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
+65
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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, (
+3 -3
View File
@@ -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()