Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
87 changes: 87 additions & 0 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
100 changes: 60 additions & 40 deletions static/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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: '会話の名前を変更',
Expand Down Expand Up @@ -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: 'Переименовать беседу',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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: '대화 이름 변경',
Expand Down
15 changes: 15 additions & 0 deletions static/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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
Expand Down
Loading