diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a080547d9..bc43a97a6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Added + +- **Issue #2735** by @munim — "Open in VS Code" action in workspace file browser. Right-clicking any file, folder, or the workspace root now shows an **Open in VS Code** menu item alongside the existing Reveal in File Manager action. The action calls a new `POST /api/file/open-vscode` endpoint which resolves the workspace-relative path via `safe_resolve`, then launches VS Code via `subprocess.Popen` (fire-and-forget, consistent with `_handle_file_reveal`). The endpoint resolves the executable via `shutil.which()` first, then falls back to a hardcoded list of common install locations (macOS: `/usr/local/bin/code` and the app-bundle CLI; Linux: `/usr/bin/code`, `/snap/bin/code`; Windows: `%LOCALAPPDATA%\Programs\Microsoft VS Code\bin\code.cmd` and the `%PROGRAMFILES%` variants) so the action works even when the server process inherits a minimal PATH. Configurable via a new optional `vscode` block in `config.yaml`: `command` overrides the default `code` executable; `host_path_prefix` + `container_path_prefix` enable Docker/container host-path translation. If the command cannot be found anywhere, a descriptive error is returned instead of a bare OS error. i18n keys `open_in_vscode` and `open_in_vscode_failed` added with full translations in all 10 locales (it, ja, ru, es, de, zh-CN, zh-TW, pt, ko). New test file `tests/test_2735_open_in_vscode.py` (26 tests) pins source wiring, command-resolution logic, i18n completeness, translated strings, and live endpoint error paths. + ## [v0.51.118] — 2026-05-22 — Release CP (stage-pr2773 — 1-PR hotfix — v0.51.117 brick fix: chat input restored) ### Fixed diff --git a/api/routes.py b/api/routes.py index efc76b062c..c37f7f941e 100644 --- a/api/routes.py +++ b/api/routes.py @@ -5447,6 +5447,9 @@ def handle_post(handler, parsed) -> bool: if parsed.path == "/api/file/path": return _handle_file_path(handler, body) + if parsed.path == "/api/file/open-vscode": + return _handle_file_open_vscode(handler, body) + # ── Workspace management (POST) ── if parsed.path == "/api/workspaces/add": return _handle_workspace_add(handler, body) @@ -9518,6 +9521,90 @@ def _handle_file_path(handler, body): return bad(handler, _sanitize_error(e)) +def _handle_file_open_vscode(handler, body): + """Open a workspace file or folder in VS Code (#2735). + + Reads optional ``vscode`` config block from config.yaml: + + vscode: + command: code # executable on PATH; defaults to "code" + host_path_prefix: /home/user/projects # Docker host path + container_path_prefix: /app/workspace # matching container path + + If ``host_path_prefix`` and ``container_path_prefix`` are both set, + paths that begin with ``container_path_prefix`` are translated to the + host prefix before being handed to VS Code. This lets users running + Hermes WebUI inside Docker still open files in their local editor. + """ + try: + require(body, "session_id", "path") + except ValueError as e: + return bad(handler, str(e)) + try: + s = get_session(body["session_id"]) + except KeyError: + return bad(handler, "Session not found", 404) + try: + target = safe_resolve(Path(s.workspace), body["path"]) + if not target.exists(): + return bad(handler, f"File not found: {target}", 404) + + target_str = str(target) + + # Optional Docker host/container path translation + from api.config import get_config as _get_cfg # noqa: PLC0415 + vscode_cfg = _get_cfg().get("vscode", {}) + if not isinstance(vscode_cfg, dict): + vscode_cfg = {} + container_prefix = vscode_cfg.get("container_path_prefix", "") + host_prefix = vscode_cfg.get("host_path_prefix", "") + if container_prefix and host_prefix and target_str.startswith(container_prefix): + target_str = host_prefix + target_str[len(container_prefix):] + + cmd = vscode_cfg.get("command", "code") + # Resolve the command to an absolute path so subprocess.Popen finds it + # even when the server process inherits a minimal PATH (e.g. when + # launched via start.sh on macOS where /usr/local/bin may be absent). + resolved_cmd = shutil.which(cmd) + if resolved_cmd is None: + # Try common VS Code installation paths as fallback. + # macOS: /usr/local/bin/code (symlink) or app bundle CLI + # Linux: /usr/bin/code or snap + # Windows: user-install under %LOCALAPPDATA%, system-install under %PROGRAMFILES% + _local_app_data = os.environ.get("LOCALAPPDATA", "") + _prog_files = os.environ.get("PROGRAMFILES", "C:\\Program Files") + _prog_files_x86 = os.environ.get("PROGRAMFILES(X86)", "C:\\Program Files (x86)") + _vscode_fallbacks = [ + # macOS + "/usr/local/bin/code", + "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code", + # Linux + "/usr/bin/code", + "/snap/bin/code", + # Windows (user install) + os.path.join(_local_app_data, "Programs", "Microsoft VS Code", "bin", "code.cmd"), + # Windows (system install) + os.path.join(_prog_files, "Microsoft VS Code", "bin", "code.cmd"), + os.path.join(_prog_files_x86, "Microsoft VS Code", "bin", "code.cmd"), + ] + for fb in _vscode_fallbacks: + if fb and Path(fb).exists(): + resolved_cmd = fb + break + if resolved_cmd is None: + return bad( + handler, + f"VS Code command not found: {cmd!r}. " + "Install VS Code and ensure the 'code' CLI is on PATH, " + "or set vscode.command in config.yaml to the full path.", + ) + subprocess.Popen([resolved_cmd, target_str]) + + return j(handler, {"ok": True, "path": body["path"]}) + except (ValueError, PermissionError, OSError) as e: + return bad(handler, _sanitize_error(e)) + + def _handle_workspace_add(handler, body): # Strip surrounding paired quotes BEFORE any further processing — macOS # Finder's "Copy as Pathname" wraps paths in single quotes, and users diff --git a/static/i18n.js b/static/i18n.js index dcda7f3513..0f785a314b 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -402,10 +402,12 @@ const LOCALES = { rename_prompt: 'New name:', deleted: 'Deleted ', delete_failed: 'Delete failed: ', - reveal_in_finder: 'Reveal in File Manager', - reveal_failed: 'Failed to reveal: ', - copy_file_path: 'Copy file path', - download_folder: 'Download Folder', + reveal_in_finder: 'Reveal in File Manager', + reveal_failed: 'Failed to reveal: ', + copy_file_path: 'Copy file path', + open_in_vscode: 'Open in VS Code', + open_in_vscode_failed: 'Failed to open in VS Code: ', + download_folder: 'Download Folder', path_copied: 'File path copied to clipboard', path_copy_failed: 'Failed to copy path: ', session_rename: 'Rename conversation', @@ -1636,10 +1638,12 @@ const LOCALES = { rename_prompt: 'Nuovo nome:', deleted: 'Eliminato ', delete_failed: 'Eliminazione fallita: ', - reveal_in_finder: 'Mostra nel File Manager', - reveal_failed: 'Mostra fallito: ', - copy_file_path: 'Copia percorso file', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: 'Mostra nel File Manager', + reveal_failed: 'Mostra fallito: ', + copy_file_path: 'Copia percorso file', + open_in_vscode: 'Apri in VS Code', + open_in_vscode_failed: 'Apertura in VS Code fallita: ', + download_folder: 'Download Folder', // TODO: translate path_copied: 'Percorso file copiato negli appunti', path_copy_failed: 'Copia percorso fallita: ', session_rename: 'Rinomina conversazione', @@ -2862,10 +2866,12 @@ const LOCALES = { rename_prompt: '新しい名前:', deleted: '削除しました: ', delete_failed: '削除失敗: ', - reveal_in_finder: 'ファイルマネージャーで表示', - reveal_failed: '表示に失敗しました: ', - copy_file_path: 'ファイルパスをコピー', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: 'ファイルマネージャーで表示', + reveal_failed: '表示に失敗しました: ', + copy_file_path: 'ファイルパスをコピー', + open_in_vscode: 'VS Codeで開く', + open_in_vscode_failed: 'VS Codeで開けませんでした: ', + download_folder: 'Download Folder', // TODO: translate path_copied: 'ファイルパスをクリップボードにコピーしました', path_copy_failed: 'パスのコピーに失敗しました: ', session_rename: '会話の名前を変更', @@ -4014,10 +4020,12 @@ const LOCALES = { rename_prompt: 'Новое имя:', deleted: 'Удалено ', delete_failed: 'Не удалось удалить: ', - reveal_in_finder: 'Показать в файловом менеджере', - reveal_failed: 'Не удалось открыть: ', - copy_file_path: 'Копировать путь к файлу', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: 'Показать в файловом менеджере', + reveal_failed: 'Не удалось открыть: ', + copy_file_path: 'Копировать путь к файлу', + open_in_vscode: 'Открыть в VS Code', + open_in_vscode_failed: 'Не удалось открыть в VS Code: ', + download_folder: 'Download Folder', // TODO: translate path_copied: 'Путь к файлу скопирован в буфер обмена', path_copy_failed: 'Не удалось скопировать путь: ', session_rename: 'Переименовать беседу', @@ -5159,10 +5167,12 @@ const LOCALES = { rename_prompt: 'Nuevo nombre:', deleted: 'Eliminado ', delete_failed: 'Error al eliminar: ', - reveal_in_finder: 'Mostrar en el gestor de archivos', - reveal_failed: 'Error al mostrar: ', - copy_file_path: 'Copiar ruta del archivo', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: 'Mostrar en el gestor de archivos', + reveal_failed: 'Error al mostrar: ', + copy_file_path: 'Copiar ruta del archivo', + open_in_vscode: 'Abrir en VS Code', + open_in_vscode_failed: 'Error al abrir en VS Code: ', + download_folder: 'Download Folder', // TODO: translate path_copied: 'Ruta del archivo copiada al portapapeles', path_copy_failed: 'Error al copiar la ruta: ', session_rename: 'Renombrar conversación', @@ -6307,10 +6317,12 @@ const LOCALES = { rename_prompt: 'Neuer Name:', deleted: 'Gelöscht ', delete_failed: 'Löschen fehlgeschlagen: ', - reveal_in_finder: 'Im Dateimanager anzeigen', - reveal_failed: 'Anzeige fehlgeschlagen: ', - copy_file_path: 'Dateipfad kopieren', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: 'Im Dateimanager anzeigen', + reveal_failed: 'Anzeige fehlgeschlagen: ', + copy_file_path: 'Dateipfad kopieren', + open_in_vscode: 'In VS Code öffnen', + open_in_vscode_failed: 'In VS Code öffnen fehlgeschlagen: ', + download_folder: 'Download Folder', // TODO: translate path_copied: 'Dateipfad in die Zwischenablage kopiert', path_copy_failed: 'Pfad konnte nicht kopiert werden: ', session_rename: 'Unterhaltung umbenennen', @@ -7507,10 +7519,12 @@ const LOCALES = { rename_prompt: '新名称:', deleted: '已删除 ', delete_failed: '删除失败:', - reveal_in_finder: '在文件管理器中显示', - reveal_failed: '显示失败:', - copy_file_path: '\u590d\u5236\u6587\u4ef6\u8def\u5f84', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: '在文件管理器中显示', + reveal_failed: '显示失败:', + copy_file_path: '\u590d\u5236\u6587\u4ef6\u8def\u5f84', + open_in_vscode: '在VS Code中打开', + open_in_vscode_failed: '在VS Code中打开失败:', + download_folder: 'Download Folder', // TODO: translate path_copied: '\u6587\u4ef6\u8def\u5f84\u5df2\u590d\u5236\u5230\u526a\u8d34\u677f', path_copy_failed: '\u590d\u5236\u8def\u5f84\u5931\u8d25\uff1a', session_rename: '\u91cd\u547d\u540d\u5bf9\u8bdd', @@ -8576,10 +8590,12 @@ const LOCALES = { rename_prompt: '新名稱:', deleted: '\u5df2\u522a\u9664 ', delete_failed: '\u522a\u9664\u5931\u6557\uff1a', - reveal_in_finder: '\u5728\u6a94\u6848\u7ba1\u7406\u54e1\u4e2d\u986f\u793a', - reveal_failed: '\u986f\u793a\u5931\u6557\uff1a', - copy_file_path: '\u8907\u88fd\u6a94\u6848\u8def\u5f91', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: '\u5728\u6a94\u6848\u7ba1\u7406\u54e1\u4e2d\u986f\u793a', + reveal_failed: '\u986f\u793a\u5931\u6557\uff1a', + copy_file_path: '\u8907\u88fd\u6a94\u6848\u8def\u5f91', + open_in_vscode: '在VS Code中開啟', + open_in_vscode_failed: '在VS Code中開啟失敗:', + download_folder: 'Download Folder', // TODO: translate path_copied: '\u6a94\u6848\u8def\u5f91\u5df2\u8907\u88fd\u5230\u526a\u8cbc\u7c3f', path_copy_failed: '\u8907\u88fd\u8def\u5f91\u5931\u6557\uff1a', session_rename: '\u91cd\u65b0\u547d\u540d\u5c0d\u8a71', @@ -9888,10 +9904,12 @@ const LOCALES = { delete_confirm: (name) => `Excluir ${name}?`, deleted: 'Excluído ', delete_failed: 'Falha ao excluir: ', - reveal_in_finder: 'Mostrar no gerenciador de arquivos', - reveal_failed: 'Falha ao mostrar: ', - copy_file_path: 'Copiar caminho do arquivo', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: 'Mostrar no gerenciador de arquivos', + reveal_failed: 'Falha ao mostrar: ', + copy_file_path: 'Copiar caminho do arquivo', + open_in_vscode: 'Abrir no VS Code', + open_in_vscode_failed: 'Falha ao abrir no VS Code: ', + download_folder: 'Download Folder', // TODO: translate path_copied: 'Caminho do arquivo copiado para a área de transferência', path_copy_failed: 'Falha ao copiar caminho: ', session_rename: 'Renomear conversa', @@ -11012,10 +11030,12 @@ const LOCALES = { rename_prompt: '새 이름:', deleted: '삭제됨: ', delete_failed: '삭제 실패: ', - reveal_in_finder: '파일 관리자에서 열기', - reveal_failed: '표시 실패: ', - copy_file_path: '파일 경로 복사', - download_folder: 'Download Folder', // TODO: translate + reveal_in_finder: '파일 관리자에서 열기', + reveal_failed: '표시 실패: ', + copy_file_path: '파일 경로 복사', + open_in_vscode: 'VS Code에서 열기', + open_in_vscode_failed: 'VS Code에서 열기 실패: ', + download_folder: 'Download Folder', // TODO: translate path_copied: '파일 경로가 클립보드에 복사되었습니다', path_copy_failed: '경로 복사 실패: ', session_rename: '대화 이름 변경', diff --git a/static/ui.js b/static/ui.js index 3737fff89c..1fbc853931 100644 --- a/static/ui.js +++ b/static/ui.js @@ -7918,6 +7918,12 @@ function _showWorkspaceRootContextMenu(e){ catch(err){showToast(t('reveal_failed')+(err.message||err));} })); + menu.appendChild(_workspaceContextMenuItem(t('open_in_vscode'),async()=>{ + menu.remove(); + try{await api('/api/file/open-vscode',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:'.'})});} + catch(err){showToast(t('open_in_vscode_failed')+(err.message||err));} + })); + menu.appendChild(_workspaceContextMenuItem(t('copy_file_path'),async()=>{ menu.remove(); try{ @@ -8163,6 +8169,15 @@ function _showFileContextMenu(e, item){ revealItem.onclick=async()=>{menu.remove();try{await api('/api/file/reveal',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})});}catch(err){showToast(t('reveal_failed')+(err.message||err));}}; menu.appendChild(revealItem); + // Open in VS Code (#2735) + const vscodeItem=document.createElement('div'); + vscodeItem.textContent=t('open_in_vscode'); + vscodeItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);'; + vscodeItem.onmouseenter=()=>vscodeItem.style.background='var(--hover-bg)'; + vscodeItem.onmouseleave=()=>vscodeItem.style.background=''; + vscodeItem.onclick=async()=>{menu.remove();try{await api('/api/file/open-vscode',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})});}catch(err){showToast(t('open_in_vscode_failed')+(err.message||err));}}; + menu.appendChild(vscodeItem); + // Copy file path — resolves the absolute on-disk path on the server (so the // user gets the full /home/.../workspace/foo.py rather than the relative // path the file tree shows) and writes it to the OS clipboard. Useful for diff --git a/tests/test_2735_open_in_vscode.py b/tests/test_2735_open_in_vscode.py new file mode 100644 index 0000000000..dbf2fb8c7d --- /dev/null +++ b/tests/test_2735_open_in_vscode.py @@ -0,0 +1,342 @@ +"""Tests for issue #2735 — "Open in VS Code" action for workspace files/folders. + +Pins three layers: + +1. **Source wiring** — the dispatch entry, handler structure, and menu items + exist in the correct files. + +2. **i18n completeness** — both new keys (``open_in_vscode`` and + ``open_in_vscode_failed``) are present in every locale block. + +3. **Live endpoint behaviour** — error paths (missing fields, unknown session, + missing file, path traversal) behave correctly against the test server. + +The success path (VS Code actually opening) is not covered here because it +requires VS Code to be installed on the CI host. The subprocess call is +intentionally fire-and-forget (matching ``_handle_file_reveal``), so its +failure is surfaced via the OSError catch and a 400 response. That +observable is tested in ``TestOpenInVsCodeEndpointBehaviour``. +""" +from __future__ import annotations + +import json +import pathlib +import re +import sys +import urllib.error +import urllib.request + +ROOT = pathlib.Path(__file__).resolve().parent.parent +ROUTES = ROOT / "api" / "routes.py" +UI = ROOT / "static" / "ui.js" +I18N = ROOT / "static" / "i18n.js" + +sys.path.insert(0, str(pathlib.Path(__file__).parent)) +from conftest import TEST_BASE # noqa: E402 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Source-level wiring +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestOpenInVsCodeBackendWiring: + def test_route_dispatch_entry_present(self): + """Dispatcher must route /api/file/open-vscode to the handler.""" + src = ROUTES.read_text(encoding="utf-8") + assert 'parsed.path == "/api/file/open-vscode"' in src + + def test_handler_function_defined(self): + src = ROUTES.read_text(encoding="utf-8") + assert "def _handle_file_open_vscode(handler, body):" in src + + def test_handler_uses_safe_resolve(self): + """Handler must use safe_resolve to prevent path traversal.""" + src = ROUTES.read_text(encoding="utf-8") + m = re.search( + r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )", + src, + re.DOTALL, + ) + assert m, "_handle_file_open_vscode body not found" + body = m.group(0) + assert "safe_resolve(Path(s.workspace)" in body + + def test_handler_checks_existence(self): + """Handler must require the target to exist (unlike copy-path).""" + src = ROUTES.read_text(encoding="utf-8") + m = re.search( + r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )", + src, + re.DOTALL, + ) + assert m + body = m.group(0) + assert "exists()" in body + + def test_handler_reads_vscode_config(self): + """Handler must read the optional ``vscode`` config block.""" + src = ROUTES.read_text(encoding="utf-8") + m = re.search( + r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )", + src, + re.DOTALL, + ) + assert m + body = m.group(0) + assert 'get("vscode"' in body + + def test_handler_defaults_to_code_command(self): + """Default executable must be ``code`` when config is absent.""" + src = ROUTES.read_text(encoding="utf-8") + m = re.search( + r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )", + src, + re.DOTALL, + ) + assert m + body = m.group(0) + assert '"code"' in body + + def test_handler_supports_path_prefix_mapping(self): + """Handler must support container_path_prefix / host_path_prefix + so Docker users can map container paths to host paths.""" + src = ROUTES.read_text(encoding="utf-8") + m = re.search( + r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )", + src, + re.DOTALL, + ) + assert m + body = m.group(0) + assert "container_path_prefix" in body + assert "host_path_prefix" in body + + def test_handler_uses_subprocess_popen(self): + """Handler must use subprocess.Popen (async, non-blocking) consistent + with _handle_file_reveal.""" + src = ROUTES.read_text(encoding="utf-8") + m = re.search( + r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )", + src, + re.DOTALL, + ) + assert m + body = m.group(0) + assert "subprocess.Popen(" in body + + def test_handler_resolves_command_via_shutil_which(self): + """Handler must use shutil.which() to find the command so it works + even when the server's inherited PATH is minimal (e.g. macOS launch + via start.sh where /usr/local/bin may be absent from the subprocess + PATH).""" + src = ROUTES.read_text(encoding="utf-8") + m = re.search( + r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )", + src, + re.DOTALL, + ) + assert m + body = m.group(0) + assert "shutil.which(" in body + + def test_handler_has_vscode_fallback_paths(self): + """Handler must try common VS Code paths when shutil.which fails, + covering macOS (/usr/local/bin/code), Linux (/snap/bin/code), and + Windows (%LOCALAPPDATA%\\Programs\\Microsoft VS Code).""" + src = ROUTES.read_text(encoding="utf-8") + m = re.search( + r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )", + src, + re.DOTALL, + ) + assert m + body = m.group(0) + assert "/usr/local/bin/code" in body # macOS + assert "/snap/bin/code" in body # Linux snap + assert "Microsoft VS Code" in body # Windows + + def test_handler_returns_helpful_error_when_not_found(self): + """When code command is not found anywhere, handler must return a + descriptive error instead of a bare OSError message.""" + src = ROUTES.read_text(encoding="utf-8") + m = re.search( + r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )", + src, + re.DOTALL, + ) + assert m + body = m.group(0) + assert "VS Code command not found" in body + + +class TestOpenInVsCodeFrontendWiring: + def test_file_context_menu_has_vscode_item(self): + """_showFileContextMenu must include the Open in VS Code action.""" + src = UI.read_text(encoding="utf-8") + assert "t('open_in_vscode')" in src + assert "/api/file/open-vscode" in src + + def test_workspace_root_context_menu_has_vscode_item(self): + """_showWorkspaceRootContextMenu must also include the VS Code action.""" + src = UI.read_text(encoding="utf-8") + # Both the file and root menus call the same endpoint; verify at least + # two references in the file so we know both call sites exist. + assert src.count("/api/file/open-vscode") >= 2 + + def test_vscode_item_uses_hover_bg(self): + """VS Code menu item must use var(--hover-bg), not var(--hover) or + any other undefined variable.""" + src = UI.read_text(encoding="utf-8") + # Confirm the item is wired with the correct variable — count hover-bg + # usages; as long as our item follows the pattern the suite is green. + assert "var(--hover-bg)" in src + + def test_vscode_failure_toast_uses_i18n_key(self): + """Error toast on VS Code open failure must use the translatable key.""" + src = UI.read_text(encoding="utf-8") + assert "t('open_in_vscode_failed')" in src + + def test_vscode_item_guards_err_message(self): + """Error handler must guard against non-Error objects with + (err.message||err) consistent with reveal handler.""" + src = UI.read_text(encoding="utf-8") + # Find the open-vscode call site and check for the guard pattern near it. + idx = src.find("/api/file/open-vscode") + assert idx != -1 + # Look in a window around the first call site. + window = src[max(0, idx - 200) : idx + 500] + assert "(err.message||err)" in window or "(err.message || err)" in window + + +class TestOpenInVsCodeI18n: + """Both new translation keys must be present in every locale block.""" + + LOCALES = [ + # (locale tag, sample anchor key: value) + ("en", "reveal_in_finder: 'Reveal in File Manager'"), + ("it", "reveal_in_finder: 'Mostra nel File Manager'"), + ("ja", "reveal_in_finder: 'ファイルマネージャーで表示'"), + ("ru", "reveal_in_finder: 'Показать в файловом менеджере'"), + ("es", "reveal_in_finder: 'Mostrar en el gestor de archivos'"), + ("de", "reveal_in_finder: 'Im Dateimanager anzeigen'"), + ("zh-CN", "reveal_in_finder: '在文件管理器中显示'"), + ("pt", "reveal_in_finder: 'Mostrar no gerenciador de arquivos'"), + ("ko", "reveal_in_finder: '파일 관리자에서 열기'"), + ] + + def test_open_in_vscode_key_count(self): + """open_in_vscode key must appear exactly once per locale (10 total).""" + src = I18N.read_text(encoding="utf-8") + count = src.count("open_in_vscode:") + assert count == 10, ( + f"Expected 10 open_in_vscode: entries (one per locale), found {count}" + ) + + def test_open_in_vscode_failed_key_count(self): + """open_in_vscode_failed key must appear exactly once per locale (10 total).""" + src = I18N.read_text(encoding="utf-8") + count = src.count("open_in_vscode_failed:") + assert count == 10, ( + f"Expected 10 open_in_vscode_failed: entries (one per locale), found {count}" + ) + + def test_english_translation_not_a_placeholder(self): + """English locale must have a human-readable string, not a TODO.""" + src = I18N.read_text(encoding="utf-8") + assert "open_in_vscode: 'Open in VS Code'" in src + assert "open_in_vscode_failed: 'Failed to open in VS Code: '" in src + + def test_non_english_locales_translated(self): + """Non-English locales must have real translations, not TODO stubs.""" + src = I18N.read_text(encoding="utf-8") + # Spot-check a selection of locales — none of these should be TODO stubs. + assert "open_in_vscode: 'Apri in VS Code'" in src # it + assert "open_in_vscode: 'VS Codeで開く'" in src # ja + assert "open_in_vscode: 'Открыть в VS Code'" in src # ru + assert "open_in_vscode: 'Abrir en VS Code'" in src # es + assert "open_in_vscode: 'In VS Code öffnen'" in src # de + assert "open_in_vscode: 'VS Code에서 열기'" in src # ko + + def test_keys_adjacent_to_reveal_block(self): + """New keys must appear near the reveal/copy block so locale coverage + is easy to spot in code review.""" + src = I18N.read_text(encoding="utf-8") + # In the English block, open_in_vscode must appear between + # copy_file_path and download_folder. + copy_idx = src.index("copy_file_path: 'Copy file path'") + dl_idx = src.index("download_folder: 'Download Folder'", copy_idx) + vscode_idx = src.index("open_in_vscode: 'Open in VS Code'", copy_idx) + assert copy_idx < vscode_idx < dl_idx, ( + "open_in_vscode key must appear between copy_file_path and " + "download_folder in the English locale block" + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Live endpoint behaviour +# ═══════════════════════════════════════════════════════════════════════════════ + + +def _post(path, body=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request( + TEST_BASE + path, + data=data, + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status + except urllib.error.HTTPError as e: + return json.loads(e.read()), e.code + + +class TestOpenInVsCodeEndpointBehaviour: + def _new_session(self): + body, status = _post("/api/session/new", {}) + assert status == 200, body + return body["session"]["session_id"] + + def test_missing_session_id_returns_400(self): + body, status = _post("/api/file/open-vscode", {"path": "."}) + assert status == 400, body + assert "session_id" in body.get("error", "") + + def test_missing_path_returns_400(self): + sid = self._new_session() + body, status = _post("/api/file/open-vscode", {"session_id": sid}) + assert status == 400, body + assert "path" in body.get("error", "") + + def test_unknown_session_returns_404(self): + body, status = _post( + "/api/file/open-vscode", + {"session_id": "nonexistent-session-xyz", "path": "."}, + ) + assert status == 404, body + assert "session" in body.get("error", "").lower() + + def test_missing_file_returns_404_with_path(self): + """Attempting to open a file that does not exist must return 404 and + include the resolved path in the error (mirrors _handle_file_reveal + behaviour introduced in #1764).""" + sid = self._new_session() + body, status = _post( + "/api/file/open-vscode", + {"session_id": sid, "path": "does-not-exist-2735.txt"}, + ) + assert status == 404, body + err = body.get("error", "") + assert "does-not-exist-2735.txt" in err, ( + f"404 message must include the resolved path, got: {err!r}" + ) + + def test_path_traversal_rejected(self): + """Handler must reject paths that escape the workspace root.""" + sid = self._new_session() + body, status = _post( + "/api/file/open-vscode", + {"session_id": sid, "path": "../../../../../../etc/passwd"}, + ) + assert status == 400, body