diff --git a/crates/fresh-editor/plugins/devcontainer.i18n.json b/crates/fresh-editor/plugins/devcontainer.i18n.json
new file mode 100644
index 000000000..c12cdd62d
--- /dev/null
+++ b/crates/fresh-editor/plugins/devcontainer.i18n.json
@@ -0,0 +1,498 @@
+{
+ "en": {
+ "cmd.show_info": "Dev Container: Show Info",
+ "cmd.show_info_desc": "Show dev container configuration in an info panel",
+ "cmd.open_config": "Dev Container: Open Config",
+ "cmd.open_config_desc": "Open devcontainer.json in the editor",
+ "cmd.run_lifecycle": "Dev Container: Run Lifecycle Command",
+ "cmd.run_lifecycle_desc": "Pick and run a devcontainer lifecycle command",
+ "cmd.show_features": "Dev Container: Show Features",
+ "cmd.show_features_desc": "List installed dev container features",
+ "cmd.show_ports": "Dev Container: Show Ports",
+ "cmd.show_ports_desc": "Show configured port forwards",
+ "cmd.rebuild": "Dev Container: Rebuild",
+ "cmd.rebuild_desc": "Rebuild the dev container using the devcontainer CLI",
+ "cmd.open_terminal": "Dev Container: Open Terminal",
+ "cmd.open_terminal_desc": "Open a terminal inside the running dev container",
+
+ "status.detected": "Dev Container: %{name} (%{image}) - %{features} features, %{ports} ports",
+ "status.no_config": "No devcontainer.json found",
+ "status.panel_opened": "Dev Container info panel opened",
+ "status.panel_closed": "Dev Container info panel closed",
+ "status.no_lifecycle": "No lifecycle commands defined",
+ "status.no_features": "No features configured",
+ "status.no_ports": "No ports configured",
+ "status.running": "Running %{name}...",
+ "status.running_sub": "Running %{name} (%{label})...",
+ "status.completed": "%{name} completed successfully",
+ "status.failed": "%{name} failed (exit %{code})",
+ "status.failed_sub": "%{name} (%{label}) failed (exit %{code})",
+ "status.cli_not_found": "devcontainer CLI not found. Install with: npm i -g @devcontainers/cli",
+ "status.copied_install": "Copied: %{cmd}",
+ "status.rebuilding": "Rebuilding dev container...",
+ "status.rebuild_done": "Dev container rebuild complete",
+ "status.rebuild_failed": "Rebuild failed: %{error}",
+ "status.container_not_running": "No running dev container found. Run 'Dev Container: Rebuild' first.",
+ "status.terminal_opened": "Terminal opened inside dev container",
+
+ "popup.cli_title": "Dev Container CLI Not Found",
+ "popup.cli_message": "The devcontainer CLI is needed for rebuild. Copy the install command below, or dismiss.",
+ "popup.activate_title": "Dev Container Detected",
+ "popup.activate_message": "Found dev container \"%{name}\" (%{image}). Rebuild the container, or view its configuration.",
+ "popup.activate_message_no_cli": "Found dev container \"%{name}\" (%{image}). Install the devcontainer CLI to rebuild, or view the configuration.",
+ "popup.activate_rebuild": "Rebuild Container",
+ "popup.activate_show_info": "Show Info",
+ "popup.activate_open_config": "Open Config",
+
+ "prompt.run_lifecycle": "Run lifecycle command:",
+ "prompt.features": "Dev Container Features:",
+ "prompt.ports": "Forwarded Ports:",
+
+ "panel.header": "Dev Container: %{name}",
+ "panel.section_image": "Image",
+ "panel.section_build": "Build",
+ "panel.section_compose": "Docker Compose",
+ "panel.section_features": "Features",
+ "panel.section_ports": "Ports",
+ "panel.section_env": "Environment",
+ "panel.section_mounts": "Mounts",
+ "panel.section_users": "Users",
+ "panel.section_lifecycle": "Lifecycle Commands",
+ "panel.section_host_req": "Host Requirements",
+ "panel.footer": "Tab: cycle buttons Enter: activate Alt+r: run Alt+o: open Alt+b: rebuild q: close"
+ },
+ "cs": {
+ "cmd.show_info": "Dev Container: Zobrazit info",
+ "cmd.show_info_desc": "Zobrazit konfiguraci dev containeru v informacnim panelu",
+ "cmd.open_config": "Dev Container: Otevrit konfiguraci",
+ "cmd.open_config_desc": "Otevrit devcontainer.json v editoru",
+ "cmd.run_lifecycle": "Dev Container: Spustit lifecycle prikaz",
+ "cmd.run_lifecycle_desc": "Vybrat a spustit lifecycle prikaz devcontaineru",
+ "cmd.show_features": "Dev Container: Zobrazit features",
+ "cmd.show_features_desc": "Vypsat nainstalovane features dev containeru",
+ "cmd.show_ports": "Dev Container: Zobrazit porty",
+ "cmd.show_ports_desc": "Zobrazit nakonfigurovane presmerovani portu",
+ "cmd.rebuild": "Dev Container: Sestavit znovu",
+ "cmd.rebuild_desc": "Znovu sestavit dev container pomoci devcontainer CLI",
+ "cmd.open_terminal": "Dev Container: Otevrit terminal",
+ "cmd.open_terminal_desc": "Otevrit terminal uvnitr beziciho dev containeru",
+
+ "status.detected": "Dev Container: %{name} (%{image}) - %{features} features, %{ports} portu",
+ "status.no_config": "devcontainer.json nenalezen",
+ "status.panel_opened": "Informacni panel Dev Containeru otevren",
+ "status.panel_closed": "Informacni panel Dev Containeru zavren",
+ "status.no_lifecycle": "Zadne lifecycle prikazy nejsou definovany",
+ "status.no_features": "Zadne features nejsou nakonfigurovany",
+ "status.no_ports": "Zadne porty nejsou nakonfigurovany",
+ "status.running": "Spoustim %{name}...",
+ "status.running_sub": "Spoustim %{name} (%{label})...",
+ "status.completed": "%{name} uspesne dokonceno",
+ "status.failed": "%{name} selhalo (navratovy kod %{code})",
+ "status.failed_sub": "%{name} (%{label}) selhalo (navratovy kod %{code})",
+ "status.cli_not_found": "devcontainer CLI nenalezeno. Nainstalujte: npm i -g @devcontainers/cli",
+ "status.copied_install": "Zkopirovano: %{cmd}",
+ "status.rebuilding": "Znovu sestavuji dev container...",
+ "status.rebuild_done": "Sestaveni dev containeru dokonceno",
+ "status.rebuild_failed": "Sestaveni selhalo: %{error}",
+ "status.container_not_running": "Zadny bezici dev container nenalezen. Nejprve spustte 'Dev Container: Sestavit znovu'.",
+ "status.terminal_opened": "Terminal otevren uvnitr dev containeru",
+
+ "popup.cli_title": "Dev Container CLI nenalezeno",
+ "popup.cli_message": "CLI devcontainer je potrebny pro znovu sestaveni. Zkopirujte instalacni prikaz nize, nebo zavrete.",
+ "popup.activate_title": "Dev Container detekovano",
+ "popup.activate_message": "Nalezeno dev container \"%{name}\" (%{image}). Znovu sestavte container nebo zobrazte konfiguraci.",
+ "popup.activate_message_no_cli": "Nalezeno dev container \"%{name}\" (%{image}). Nainstalujte devcontainer CLI pro sestaveni nebo zobrazte konfiguraci.",
+ "popup.activate_rebuild": "Znovu sestavit container",
+ "popup.activate_show_info": "Zobrazit info",
+ "popup.activate_open_config": "Otevrit konfiguraci",
+
+ "prompt.run_lifecycle": "Spustit lifecycle prikaz:",
+ "prompt.features": "Dev Container Features:",
+ "prompt.ports": "Presmerovane porty:",
+
+ "panel.header": "Dev Container: %{name}",
+ "panel.section_image": "Image",
+ "panel.section_build": "Build",
+ "panel.section_compose": "Docker Compose",
+ "panel.section_features": "Features",
+ "panel.section_ports": "Porty",
+ "panel.section_env": "Promenne prostredi",
+ "panel.section_mounts": "Pripojeni",
+ "panel.section_users": "Uzivatele",
+ "panel.section_lifecycle": "Lifecycle prikazy",
+ "panel.section_host_req": "Pozadavky na hostitele",
+ "panel.footer": "Tab: prepnout Enter: aktivovat Alt+r: lifecycle Alt+o: otevrit Alt+b: sestavit q: zavrit"
+ },
+ "de": {
+ "cmd.show_info": "Dev Container: Info anzeigen",
+ "cmd.show_info_desc": "Dev-Container-Konfiguration im Infopanel anzeigen",
+ "cmd.open_config": "Dev Container: Konfiguration oeffnen",
+ "cmd.open_config_desc": "devcontainer.json im Editor oeffnen",
+ "cmd.run_lifecycle": "Dev Container: Lifecycle-Befehl ausfuehren",
+ "cmd.run_lifecycle_desc": "Einen Devcontainer-Lifecycle-Befehl auswaehlen und ausfuehren",
+ "cmd.show_features": "Dev Container: Features anzeigen",
+ "cmd.show_features_desc": "Installierte Dev-Container-Features auflisten",
+ "cmd.show_ports": "Dev Container: Ports anzeigen",
+ "cmd.show_ports_desc": "Konfigurierte Portweiterleitungen anzeigen",
+ "cmd.rebuild": "Dev Container: Neu erstellen",
+ "cmd.rebuild_desc": "Dev Container mit devcontainer CLI neu erstellen",
+ "cmd.open_terminal": "Dev Container: Terminal oeffnen",
+ "cmd.open_terminal_desc": "Terminal im laufenden Dev Container oeffnen",
+
+ "status.detected": "Dev Container: %{name} (%{image}) - %{features} Features, %{ports} Ports",
+ "status.no_config": "Keine devcontainer.json gefunden",
+ "status.panel_opened": "Dev-Container-Infopanel geoeffnet",
+ "status.panel_closed": "Dev-Container-Infopanel geschlossen",
+ "status.no_lifecycle": "Keine Lifecycle-Befehle definiert",
+ "status.no_features": "Keine Features konfiguriert",
+ "status.no_ports": "Keine Ports konfiguriert",
+ "status.running": "%{name} wird ausgefuehrt...",
+ "status.running_sub": "%{name} (%{label}) wird ausgefuehrt...",
+ "status.completed": "%{name} erfolgreich abgeschlossen",
+ "status.failed": "%{name} fehlgeschlagen (Exit-Code %{code})",
+ "status.failed_sub": "%{name} (%{label}) fehlgeschlagen (Exit-Code %{code})",
+ "status.cli_not_found": "devcontainer CLI nicht gefunden. Installieren mit: npm i -g @devcontainers/cli",
+ "status.copied_install": "Kopiert: %{cmd}",
+ "status.rebuilding": "Dev Container wird neu erstellt...",
+ "status.rebuild_done": "Dev-Container-Neuerstellung abgeschlossen",
+ "status.rebuild_failed": "Neuerstellung fehlgeschlagen: %{error}",
+ "status.container_not_running": "Kein laufender Dev Container gefunden. Fuehren Sie zuerst 'Dev Container: Neu erstellen' aus.",
+ "status.terminal_opened": "Terminal im Dev Container geoeffnet",
+
+ "popup.cli_title": "Dev Container CLI nicht gefunden",
+ "popup.cli_message": "Das devcontainer CLI wird fuer die Neuerstellung benoetigt. Kopieren Sie den Installationsbefehl oder schliessen Sie.",
+ "popup.activate_title": "Dev Container erkannt",
+ "popup.activate_message": "Dev Container \"%{name}\" (%{image}) gefunden. Container neu erstellen oder Konfiguration anzeigen.",
+ "popup.activate_message_no_cli": "Dev Container \"%{name}\" (%{image}) gefunden. Installieren Sie das devcontainer CLI zum Neuerstellen oder sehen Sie die Konfiguration.",
+ "popup.activate_rebuild": "Container neu erstellen",
+ "popup.activate_show_info": "Info anzeigen",
+ "popup.activate_open_config": "Konfiguration oeffnen",
+
+ "prompt.run_lifecycle": "Lifecycle-Befehl ausfuehren:",
+ "prompt.features": "Dev-Container-Features:",
+ "prompt.ports": "Weitergeleitete Ports:",
+
+ "panel.header": "Dev Container: %{name}",
+ "panel.section_image": "Image",
+ "panel.section_build": "Build",
+ "panel.section_compose": "Docker Compose",
+ "panel.section_features": "Features",
+ "panel.section_ports": "Ports",
+ "panel.section_env": "Umgebungsvariablen",
+ "panel.section_mounts": "Einhaengungen",
+ "panel.section_users": "Benutzer",
+ "panel.section_lifecycle": "Lifecycle-Befehle",
+ "panel.section_host_req": "Hostanforderungen",
+ "panel.footer": "Tab: Wechseln Enter: Aktivieren Alt+r: Lifecycle Alt+o: Oeffnen Alt+b: Erstellen q: Schliessen"
+ },
+ "es": {
+ "cmd.show_info": "Dev Container: Mostrar Info",
+ "cmd.show_info_desc": "Mostrar configuracion del dev container en un panel informativo",
+ "cmd.open_config": "Dev Container: Abrir Config",
+ "cmd.open_config_desc": "Abrir devcontainer.json en el editor",
+ "cmd.run_lifecycle": "Dev Container: Ejecutar Comando Lifecycle",
+ "cmd.run_lifecycle_desc": "Seleccionar y ejecutar un comando lifecycle del devcontainer",
+ "cmd.show_features": "Dev Container: Mostrar Features",
+ "cmd.show_features_desc": "Listar features instaladas del dev container",
+ "cmd.show_ports": "Dev Container: Mostrar Puertos",
+ "cmd.show_ports_desc": "Mostrar redirecciones de puertos configuradas",
+ "cmd.rebuild": "Dev Container: Reconstruir",
+ "cmd.rebuild_desc": "Reconstruir el dev container usando el CLI devcontainer",
+ "cmd.open_terminal": "Dev Container: Abrir Terminal",
+ "cmd.open_terminal_desc": "Abrir un terminal dentro del dev container en ejecucion",
+
+ "status.detected": "Dev Container: %{name} (%{image}) - %{features} features, %{ports} puertos",
+ "status.no_config": "No se encontro devcontainer.json",
+ "status.panel_opened": "Panel informativo de Dev Container abierto",
+ "status.panel_closed": "Panel informativo de Dev Container cerrado",
+ "status.no_lifecycle": "No hay comandos lifecycle definidos",
+ "status.no_features": "No hay features configuradas",
+ "status.no_ports": "No hay puertos configurados",
+ "status.running": "Ejecutando %{name}...",
+ "status.running_sub": "Ejecutando %{name} (%{label})...",
+ "status.completed": "%{name} completado exitosamente",
+ "status.failed": "%{name} fallo (codigo de salida %{code})",
+ "status.failed_sub": "%{name} (%{label}) fallo (codigo de salida %{code})",
+ "status.cli_not_found": "CLI devcontainer no encontrado. Instalar con: npm i -g @devcontainers/cli",
+ "status.copied_install": "Copiado: %{cmd}",
+ "status.rebuilding": "Reconstruyendo dev container...",
+ "status.rebuild_done": "Reconstruccion del dev container completada",
+ "status.rebuild_failed": "Reconstruccion fallida: %{error}",
+ "status.container_not_running": "No se encontro un dev container en ejecucion. Ejecute 'Dev Container: Reconstruir' primero.",
+ "status.terminal_opened": "Terminal abierto dentro del dev container",
+
+ "popup.cli_title": "CLI Dev Container no encontrado",
+ "popup.cli_message": "El CLI devcontainer es necesario para reconstruir. Copie el comando de instalacion o descarte.",
+ "popup.activate_title": "Dev Container detectado",
+ "popup.activate_message": "Se encontro dev container \"%{name}\" (%{image}). Reconstruir el container o ver la configuracion.",
+ "popup.activate_message_no_cli": "Se encontro dev container \"%{name}\" (%{image}). Instale el CLI devcontainer para reconstruir o vea la configuracion.",
+ "popup.activate_rebuild": "Reconstruir container",
+ "popup.activate_show_info": "Mostrar info",
+ "popup.activate_open_config": "Abrir config",
+
+ "prompt.run_lifecycle": "Ejecutar comando lifecycle:",
+ "prompt.features": "Features de Dev Container:",
+ "prompt.ports": "Puertos redirigidos:",
+
+ "panel.header": "Dev Container: %{name}",
+ "panel.section_image": "Imagen",
+ "panel.section_build": "Build",
+ "panel.section_compose": "Docker Compose",
+ "panel.section_features": "Features",
+ "panel.section_ports": "Puertos",
+ "panel.section_env": "Variables de Entorno",
+ "panel.section_mounts": "Montajes",
+ "panel.section_users": "Usuarios",
+ "panel.section_lifecycle": "Comandos Lifecycle",
+ "panel.section_host_req": "Requisitos del Host",
+ "panel.footer": "Tab: ciclar Enter: activar Alt+r: lifecycle Alt+o: abrir Alt+b: reconstruir q: cerrar"
+ },
+ "fr": {
+ "cmd.show_info": "Dev Container: Afficher les infos",
+ "cmd.show_info_desc": "Afficher la configuration du dev container dans un panneau",
+ "cmd.open_config": "Dev Container: Ouvrir la config",
+ "cmd.open_config_desc": "Ouvrir devcontainer.json dans l'editeur",
+ "cmd.run_lifecycle": "Dev Container: Executer commande lifecycle",
+ "cmd.run_lifecycle_desc": "Choisir et executer une commande lifecycle du devcontainer",
+ "cmd.show_features": "Dev Container: Afficher les features",
+ "cmd.show_features_desc": "Lister les features installees du dev container",
+ "cmd.show_ports": "Dev Container: Afficher les ports",
+ "cmd.show_ports_desc": "Afficher les redirections de ports configurees",
+ "cmd.rebuild": "Dev Container: Reconstruire",
+ "cmd.rebuild_desc": "Reconstruire le dev container avec le CLI devcontainer",
+ "cmd.open_terminal": "Dev Container: Ouvrir un terminal",
+ "cmd.open_terminal_desc": "Ouvrir un terminal dans le dev container en cours d'execution",
+
+ "status.detected": "Dev Container: %{name} (%{image}) - %{features} features, %{ports} ports",
+ "status.no_config": "Aucun devcontainer.json trouve",
+ "status.panel_opened": "Panneau d'info Dev Container ouvert",
+ "status.panel_closed": "Panneau d'info Dev Container ferme",
+ "status.no_lifecycle": "Aucune commande lifecycle definie",
+ "status.no_features": "Aucune feature configuree",
+ "status.no_ports": "Aucun port configure",
+ "status.running": "Execution de %{name}...",
+ "status.running_sub": "Execution de %{name} (%{label})...",
+ "status.completed": "%{name} termine avec succes",
+ "status.failed": "%{name} echoue (code de sortie %{code})",
+ "status.failed_sub": "%{name} (%{label}) echoue (code de sortie %{code})",
+ "status.cli_not_found": "CLI devcontainer introuvable. Installer avec: npm i -g @devcontainers/cli",
+ "status.copied_install": "Copie: %{cmd}",
+ "status.rebuilding": "Reconstruction du dev container...",
+ "status.rebuild_done": "Reconstruction du dev container terminee",
+ "status.rebuild_failed": "Reconstruction echouee: %{error}",
+ "status.container_not_running": "Aucun dev container en cours d'execution. Executez d'abord 'Dev Container: Reconstruire'.",
+ "status.terminal_opened": "Terminal ouvert dans le dev container",
+
+ "popup.cli_title": "CLI Dev Container introuvable",
+ "popup.cli_message": "Le CLI devcontainer est necessaire pour la reconstruction. Copiez la commande d'installation ou fermez.",
+ "popup.activate_title": "Dev Container detecte",
+ "popup.activate_message": "Dev container \"%{name}\" (%{image}) detecte. Reconstruire le container ou voir la configuration.",
+ "popup.activate_message_no_cli": "Dev container \"%{name}\" (%{image}) detecte. Installez le CLI devcontainer pour reconstruire ou consultez la configuration.",
+ "popup.activate_rebuild": "Reconstruire le container",
+ "popup.activate_show_info": "Afficher les infos",
+ "popup.activate_open_config": "Ouvrir la config",
+
+ "prompt.run_lifecycle": "Executer commande lifecycle:",
+ "prompt.features": "Features Dev Container:",
+ "prompt.ports": "Ports rediriges:",
+
+ "panel.header": "Dev Container: %{name}",
+ "panel.section_image": "Image",
+ "panel.section_build": "Build",
+ "panel.section_compose": "Docker Compose",
+ "panel.section_features": "Features",
+ "panel.section_ports": "Ports",
+ "panel.section_env": "Variables d'environnement",
+ "panel.section_mounts": "Montages",
+ "panel.section_users": "Utilisateurs",
+ "panel.section_lifecycle": "Commandes Lifecycle",
+ "panel.section_host_req": "Exigences de l'hote",
+ "panel.footer": "Tab: cycler Enter: activer Alt+r: lifecycle Alt+o: ouvrir Alt+b: reconstruire q: fermer"
+ },
+ "ja": {
+ "cmd.show_info": "Dev Container: 情報を表示",
+ "cmd.show_info_desc": "Dev Container設定を情報パネルに表示",
+ "cmd.open_config": "Dev Container: 設定を開く",
+ "cmd.open_config_desc": "devcontainer.jsonをエディタで開く",
+ "cmd.run_lifecycle": "Dev Container: ライフサイクルコマンドを実行",
+ "cmd.run_lifecycle_desc": "devcontainerのライフサイクルコマンドを選択して実行",
+ "cmd.show_features": "Dev Container: Featuresを表示",
+ "cmd.show_features_desc": "インストール済みのDev Container Featuresを一覧表示",
+ "cmd.show_ports": "Dev Container: ポートを表示",
+ "cmd.show_ports_desc": "設定済みのポート転送を表示",
+ "cmd.rebuild": "Dev Container: リビルド",
+ "cmd.rebuild_desc": "devcontainer CLIを使用してDev Containerをリビルド",
+ "cmd.open_terminal": "Dev Container: ターミナルを開く",
+ "cmd.open_terminal_desc": "実行中のDev Containerにターミナルを開く",
+
+ "status.detected": "Dev Container: %{name} (%{image}) - %{features}個のfeature, %{ports}個のポート",
+ "status.no_config": "devcontainer.jsonが見つかりません",
+ "status.panel_opened": "Dev Container情報パネルを開きました",
+ "status.panel_closed": "Dev Container情報パネルを閉じました",
+ "status.no_lifecycle": "ライフサイクルコマンドが定義されていません",
+ "status.no_features": "Featureが設定されていません",
+ "status.no_ports": "ポートが設定されていません",
+ "status.running": "%{name}を実行中...",
+ "status.running_sub": "%{name} (%{label})を実行中...",
+ "status.completed": "%{name}が正常に完了しました",
+ "status.failed": "%{name}が失敗しました(終了コード%{code})",
+ "status.failed_sub": "%{name} (%{label})が失敗しました(終了コード%{code})",
+ "status.cli_not_found": "devcontainer CLIが見つかりません。インストール: npm i -g @devcontainers/cli",
+ "status.copied_install": "コピーしました: %{cmd}",
+ "status.rebuilding": "Dev Containerをリビルド中...",
+ "status.rebuild_done": "Dev Containerのリビルドが完了しました",
+ "status.rebuild_failed": "リビルド失敗: %{error}",
+ "status.container_not_running": "実行中のDev Containerが見つかりません。まず「Dev Container: リビルド」を実行してください。",
+ "status.terminal_opened": "Dev Container内にターミナルを開きました",
+
+ "popup.cli_title": "Dev Container CLIが見つかりません",
+ "popup.cli_message": "リビルドにはdevcontainer CLIが必要です。インストールコマンドをコピーするか、閉じてください。",
+ "popup.activate_title": "Dev Containerを検出しました",
+ "popup.activate_message": "Dev container \"%{name}\" (%{image}) が見つかりました。コンテナをリビルドするか、設定を表示します。",
+ "popup.activate_message_no_cli": "Dev container \"%{name}\" (%{image}) が見つかりました。リビルドにはdevcontainer CLIをインストールしてください。",
+ "popup.activate_rebuild": "コンテナをリビルド",
+ "popup.activate_show_info": "情報を表示",
+ "popup.activate_open_config": "設定を開く",
+
+ "prompt.run_lifecycle": "ライフサイクルコマンドを実行:",
+ "prompt.features": "Dev Container Features:",
+ "prompt.ports": "転送ポート:",
+
+ "panel.header": "Dev Container: %{name}",
+ "panel.section_image": "イメージ",
+ "panel.section_build": "ビルド",
+ "panel.section_compose": "Docker Compose",
+ "panel.section_features": "Features",
+ "panel.section_ports": "ポート",
+ "panel.section_env": "環境変数",
+ "panel.section_mounts": "マウント",
+ "panel.section_users": "ユーザー",
+ "panel.section_lifecycle": "ライフサイクルコマンド",
+ "panel.section_host_req": "ホスト要件",
+ "panel.footer": "Tab: 切替 Enter: 実行 Alt+r: ライフサイクル Alt+o: 設定 Alt+b: リビルド q: 閉じる"
+ },
+ "ko": {
+ "cmd.show_info": "Dev Container: 정보 표시",
+ "cmd.show_info_desc": "Dev Container 설정을 정보 패널에 표시",
+ "cmd.open_config": "Dev Container: 설정 열기",
+ "cmd.open_config_desc": "편집기에서 devcontainer.json 열기",
+ "cmd.run_lifecycle": "Dev Container: 라이프사이클 명령 실행",
+ "cmd.run_lifecycle_desc": "devcontainer 라이프사이클 명령을 선택하여 실행",
+ "cmd.show_features": "Dev Container: Features 표시",
+ "cmd.show_features_desc": "설치된 Dev Container Features 목록",
+ "cmd.show_ports": "Dev Container: 포트 표시",
+ "cmd.show_ports_desc": "구성된 포트 포워딩 표시",
+ "cmd.rebuild": "Dev Container: 재빌드",
+ "cmd.rebuild_desc": "devcontainer CLI를 사용하여 Dev Container 재빌드",
+ "cmd.open_terminal": "Dev Container: 터미널 열기",
+ "cmd.open_terminal_desc": "실행 중인 Dev Container에서 터미널 열기",
+
+ "status.detected": "Dev Container: %{name} (%{image}) - %{features}개 feature, %{ports}개 포트",
+ "status.no_config": "devcontainer.json을 찾을 수 없습니다",
+ "status.panel_opened": "Dev Container 정보 패널이 열렸습니다",
+ "status.panel_closed": "Dev Container 정보 패널이 닫혔습니다",
+ "status.no_lifecycle": "라이프사이클 명령이 정의되지 않았습니다",
+ "status.no_features": "구성된 feature가 없습니다",
+ "status.no_ports": "구성된 포트가 없습니다",
+ "status.running": "%{name} 실행 중...",
+ "status.running_sub": "%{name} (%{label}) 실행 중...",
+ "status.completed": "%{name}이(가) 성공적으로 완료되었습니다",
+ "status.failed": "%{name} 실패 (종료 코드 %{code})",
+ "status.failed_sub": "%{name} (%{label}) 실패 (종료 코드 %{code})",
+ "status.cli_not_found": "devcontainer CLI를 찾을 수 없습니다. 설치: npm i -g @devcontainers/cli",
+ "status.copied_install": "복사됨: %{cmd}",
+ "status.rebuilding": "Dev Container 재빌드 중...",
+ "status.rebuild_done": "Dev Container 재빌드 완료",
+ "status.rebuild_failed": "재빌드 실패: %{error}",
+ "status.container_not_running": "실행 중인 Dev Container를 찾을 수 없습니다. 먼저 'Dev Container: 재빌드'를 실행하세요.",
+ "status.terminal_opened": "Dev Container 내에서 터미널이 열렸습니다",
+
+ "popup.cli_title": "Dev Container CLI를 찾을 수 없습니다",
+ "popup.cli_message": "재빌드에는 devcontainer CLI가 필요합니다. 설치 명령을 복사하거나 닫으세요.",
+ "popup.activate_title": "Dev Container 감지됨",
+ "popup.activate_message": "Dev container \"%{name}\" (%{image})을(를) 찾았습니다. 컨테이너를 재빌드하거나 설정을 확인하세요.",
+ "popup.activate_message_no_cli": "Dev container \"%{name}\" (%{image})을(를) 찾았습니다. 재빌드하려면 devcontainer CLI를 설치하세요.",
+ "popup.activate_rebuild": "컨테이너 재빌드",
+ "popup.activate_show_info": "정보 표시",
+ "popup.activate_open_config": "설정 열기",
+
+ "prompt.run_lifecycle": "라이프사이클 명령 실행:",
+ "prompt.features": "Dev Container Features:",
+ "prompt.ports": "포워딩된 포트:",
+
+ "panel.header": "Dev Container: %{name}",
+ "panel.section_image": "이미지",
+ "panel.section_build": "빌드",
+ "panel.section_compose": "Docker Compose",
+ "panel.section_features": "Features",
+ "panel.section_ports": "포트",
+ "panel.section_env": "환경 변수",
+ "panel.section_mounts": "마운트",
+ "panel.section_users": "사용자",
+ "panel.section_lifecycle": "라이프사이클 명령",
+ "panel.section_host_req": "호스트 요구사항",
+ "panel.footer": "Tab: 전환 Enter: 실행 Alt+r: 라이프사이클 Alt+o: 설정 Alt+b: 재빌드 q: 닫기"
+ },
+ "zh-CN": {
+ "cmd.show_info": "Dev Container: 显示信息",
+ "cmd.show_info_desc": "在信息面板中显示Dev Container配置",
+ "cmd.open_config": "Dev Container: 打开配置",
+ "cmd.open_config_desc": "在编辑器中打开devcontainer.json",
+ "cmd.run_lifecycle": "Dev Container: 运行生命周期命令",
+ "cmd.run_lifecycle_desc": "选择并运行devcontainer生命周期命令",
+ "cmd.show_features": "Dev Container: 显示Features",
+ "cmd.show_features_desc": "列出已安装的Dev Container Features",
+ "cmd.show_ports": "Dev Container: 显示端口",
+ "cmd.show_ports_desc": "显示已配置的端口转发",
+ "cmd.rebuild": "Dev Container: 重建",
+ "cmd.rebuild_desc": "使用devcontainer CLI重建Dev Container",
+ "cmd.open_terminal": "Dev Container: 打开终端",
+ "cmd.open_terminal_desc": "在运行中的Dev Container中打开终端",
+
+ "status.detected": "Dev Container: %{name} (%{image}) - %{features}个feature, %{ports}个端口",
+ "status.no_config": "未找到devcontainer.json",
+ "status.panel_opened": "Dev Container信息面板已打开",
+ "status.panel_closed": "Dev Container信息面板已关闭",
+ "status.no_lifecycle": "未定义生命周期命令",
+ "status.no_features": "未配置feature",
+ "status.no_ports": "未配置端口",
+ "status.running": "正在运行%{name}...",
+ "status.running_sub": "正在运行%{name} (%{label})...",
+ "status.completed": "%{name}成功完成",
+ "status.failed": "%{name}失败(退出码%{code})",
+ "status.failed_sub": "%{name} (%{label})失败(退出码%{code})",
+ "status.cli_not_found": "未找到devcontainer CLI。安装命令: npm i -g @devcontainers/cli",
+ "status.copied_install": "已复制: %{cmd}",
+ "status.rebuilding": "正在重建Dev Container...",
+ "status.rebuild_done": "Dev Container重建完成",
+ "status.rebuild_failed": "重建失败: %{error}",
+ "status.container_not_running": "未找到运行中的Dev Container。请先运行「Dev Container: 重建」。",
+ "status.terminal_opened": "已在Dev Container中打开终端",
+
+ "popup.cli_title": "未找到Dev Container CLI",
+ "popup.cli_message": "重建需要devcontainer CLI。复制下面的安装命令或关闭。",
+ "popup.activate_title": "检测到Dev Container",
+ "popup.activate_message": "发现Dev container \"%{name}\" (%{image})。重建容器或查看配置。",
+ "popup.activate_message_no_cli": "发现Dev container \"%{name}\" (%{image})。安装devcontainer CLI以重建,或查看配置。",
+ "popup.activate_rebuild": "重建容器",
+ "popup.activate_show_info": "显示信息",
+ "popup.activate_open_config": "打开配置",
+
+ "prompt.run_lifecycle": "运行生命周期命令:",
+ "prompt.features": "Dev Container Features:",
+ "prompt.ports": "转发端口:",
+
+ "panel.header": "Dev Container: %{name}",
+ "panel.section_image": "镜像",
+ "panel.section_build": "构建",
+ "panel.section_compose": "Docker Compose",
+ "panel.section_features": "Features",
+ "panel.section_ports": "端口",
+ "panel.section_env": "环境变量",
+ "panel.section_mounts": "挂载",
+ "panel.section_users": "用户",
+ "panel.section_lifecycle": "生命周期命令",
+ "panel.section_host_req": "主机要求",
+ "panel.footer": "Tab: 切换 Enter: 执行 Alt+r: 生命周期 Alt+o: 打开 Alt+b: 重建 q: 关闭"
+ }
+}
diff --git a/crates/fresh-editor/plugins/devcontainer.ts b/crates/fresh-editor/plugins/devcontainer.ts
new file mode 100644
index 000000000..9b34964f1
--- /dev/null
+++ b/crates/fresh-editor/plugins/devcontainer.ts
@@ -0,0 +1,988 @@
+///
+const editor = getEditor();
+
+/**
+ * Dev Container Plugin
+ *
+ * Detects .devcontainer/devcontainer.json configurations and provides:
+ * - Status bar summary of the container environment
+ * - Info panel showing image, features, ports, env vars, lifecycle commands
+ * - Lifecycle command runner via command palette
+ * - Quick open for the devcontainer.json config file
+ */
+
+// =============================================================================
+// Types
+// =============================================================================
+
+interface DevContainerConfig {
+ name?: string;
+ image?: string;
+ build?: {
+ dockerfile?: string;
+ context?: string;
+ args?: Record;
+ target?: string;
+ cacheFrom?: string | string[];
+ };
+ dockerComposeFile?: string | string[];
+ service?: string;
+ features?: Record>;
+ forwardPorts?: (number | string)[];
+ portsAttributes?: Record;
+ appPort?: number | string | (number | string)[];
+ containerEnv?: Record;
+ remoteEnv?: Record;
+ containerUser?: string;
+ remoteUser?: string;
+ mounts?: (string | MountConfig)[];
+ initializeCommand?: LifecycleCommand;
+ onCreateCommand?: LifecycleCommand;
+ updateContentCommand?: LifecycleCommand;
+ postCreateCommand?: LifecycleCommand;
+ postStartCommand?: LifecycleCommand;
+ postAttachCommand?: LifecycleCommand;
+ customizations?: Record;
+ runArgs?: string[];
+ workspaceFolder?: string;
+ workspaceMount?: string;
+ shutdownAction?: string;
+ overrideCommand?: boolean;
+ init?: boolean;
+ privileged?: boolean;
+ capAdd?: string[];
+ securityOpt?: string[];
+ hostRequirements?: {
+ cpus?: number;
+ memory?: string;
+ storage?: string;
+ gpu?: boolean | string | { cores?: number; memory?: string };
+ };
+}
+
+type LifecycleCommand = string | string[] | Record;
+
+interface PortAttributes {
+ label?: string;
+ protocol?: string;
+ onAutoForward?: string;
+ requireLocalPort?: boolean;
+ elevateIfNeeded?: boolean;
+}
+
+interface MountConfig {
+ type?: string;
+ source?: string;
+ target?: string;
+}
+
+// =============================================================================
+// JSONC Parser
+// =============================================================================
+
+/**
+ * Strip JSON with Comments (JSONC) to plain JSON.
+ * Handles single-line comments (//), multi-line comments, and trailing commas.
+ */
+function stripJsonc(text: string): string {
+ let result = "";
+ let i = 0;
+ let inString = false;
+
+ while (i < text.length) {
+ if (inString) {
+ if (text[i] === "\\" && i + 1 < text.length) {
+ result += text[i] + text[i + 1];
+ i += 2;
+ continue;
+ }
+ if (text[i] === '"') {
+ inString = false;
+ }
+ result += text[i];
+ } else if (text[i] === '"') {
+ inString = true;
+ result += text[i];
+ } else if (text[i] === "/" && i + 1 < text.length && text[i + 1] === "/") {
+ // Single-line comment: skip to end of line
+ while (i < text.length && text[i] !== "\n") {
+ i++;
+ }
+ continue;
+ } else if (text[i] === "/" && i + 1 < text.length && text[i + 1] === "*") {
+ // Multi-line comment: skip to closing */
+ i += 2;
+ while (i < text.length - 1 && !(text[i] === "*" && text[i + 1] === "/")) {
+ i++;
+ }
+ i += 2;
+ continue;
+ } else {
+ result += text[i];
+ }
+ i++;
+ }
+
+ // Remove trailing commas before } or ]
+ return result.replace(/,\s*([}\]])/g, "$1");
+}
+
+// =============================================================================
+// State
+// =============================================================================
+
+let config: DevContainerConfig | null = null;
+let configPath: string | null = null;
+let infoPanelBufferId: number | null = null;
+let infoPanelSplitId: number | null = null;
+let infoPanelOpen = false;
+let cachedContent = "";
+
+// Focus state for info panel buttons (Tab navigation like pkg.ts)
+type InfoFocusTarget = { type: "button"; index: number };
+
+interface InfoButton {
+ id: string;
+ label: string;
+ command: string;
+}
+
+const infoButtons: InfoButton[] = [
+ { id: "run", label: "Run Lifecycle", command: "devcontainer_run_lifecycle" },
+ { id: "open", label: "Open Config", command: "devcontainer_open_config" },
+ { id: "rebuild", label: "Rebuild", command: "devcontainer_rebuild" },
+ { id: "close", label: "Close", command: "devcontainer_close_info" },
+];
+
+let infoFocus: InfoFocusTarget = { type: "button", index: 0 };
+
+// =============================================================================
+// Colors
+// =============================================================================
+
+const colors = {
+ heading: [255, 200, 100] as [number, number, number],
+ key: [100, 200, 255] as [number, number, number],
+ value: [200, 200, 200] as [number, number, number],
+ feature: [150, 255, 150] as [number, number, number],
+ port: [255, 180, 100] as [number, number, number],
+ footer: [120, 120, 120] as [number, number, number],
+ button: [180, 180, 190] as [number, number, number],
+ buttonFocused: [255, 255, 255] as [number, number, number],
+ buttonFocusedBg: [60, 110, 180] as [number, number, number],
+};
+
+// =============================================================================
+// Config Discovery
+// =============================================================================
+
+function findConfig(): boolean {
+ const cwd = editor.getCwd();
+
+ // Priority 1: .devcontainer/devcontainer.json
+ const primary = editor.pathJoin(cwd, ".devcontainer", "devcontainer.json");
+ const primaryContent = editor.readFile(primary);
+ if (primaryContent !== null) {
+ try {
+ config = JSON.parse(stripJsonc(primaryContent));
+ configPath = primary;
+ return true;
+ } catch {
+ editor.debug("devcontainer: failed to parse " + primary);
+ }
+ }
+
+ // Priority 2: .devcontainer.json
+ const secondary = editor.pathJoin(cwd, ".devcontainer.json");
+ const secondaryContent = editor.readFile(secondary);
+ if (secondaryContent !== null) {
+ try {
+ config = JSON.parse(stripJsonc(secondaryContent));
+ configPath = secondary;
+ return true;
+ } catch {
+ editor.debug("devcontainer: failed to parse " + secondary);
+ }
+ }
+
+ // Priority 3: .devcontainer//devcontainer.json
+ const dcDir = editor.pathJoin(cwd, ".devcontainer");
+ if (editor.fileExists(dcDir)) {
+ const entries = editor.readDir(dcDir);
+ for (const entry of entries) {
+ if (entry.is_dir) {
+ const subConfig = editor.pathJoin(dcDir, entry.name, "devcontainer.json");
+ const subContent = editor.readFile(subConfig);
+ if (subContent !== null) {
+ try {
+ config = JSON.parse(stripJsonc(subContent));
+ configPath = subConfig;
+ return true;
+ } catch {
+ editor.debug("devcontainer: failed to parse " + subConfig);
+ }
+ }
+ }
+ }
+ }
+
+ return false;
+}
+
+// =============================================================================
+// Formatting Helpers
+// =============================================================================
+
+function formatLifecycleCommand(cmd: LifecycleCommand): string {
+ if (typeof cmd === "string") return cmd;
+ if (Array.isArray(cmd)) return cmd.join(" ");
+ return Object.entries(cmd)
+ .map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(" ") : v}`)
+ .join("; ");
+}
+
+function formatMount(mount: string | MountConfig): string {
+ if (typeof mount === "string") return mount;
+ const parts: string[] = [];
+ if (mount.source) parts.push(mount.source);
+ parts.push("->");
+ if (mount.target) parts.push(mount.target);
+ if (mount.type) parts.push(`(${mount.type})`);
+ return parts.join(" ");
+}
+
+function getImageSummary(): string {
+ if (!config) return "unknown";
+ if (config.image) return config.image;
+ if (config.build?.dockerfile) return "Dockerfile: " + config.build.dockerfile;
+ if (config.dockerComposeFile) return "Compose";
+ return "unknown";
+}
+
+// =============================================================================
+// Info Panel
+// =============================================================================
+
+function buildInfoEntries(): TextPropertyEntry[] {
+ if (!config) return [];
+
+ const entries: TextPropertyEntry[] = [];
+
+ // Header
+ const name = config.name ?? "unnamed";
+ entries.push({
+ text: editor.t("panel.header", { name }) + "\n",
+ properties: { type: "heading" },
+ });
+ entries.push({ text: "\n", properties: { type: "blank" } });
+
+ // Image / Build / Compose
+ if (config.image) {
+ entries.push({ text: editor.t("panel.section_image") + "\n", properties: { type: "heading" } });
+ entries.push({ text: " " + config.image + "\n", properties: { type: "value" } });
+ entries.push({ text: "\n", properties: { type: "blank" } });
+ } else if (config.build?.dockerfile) {
+ entries.push({ text: editor.t("panel.section_build") + "\n", properties: { type: "heading" } });
+ entries.push({ text: " dockerfile: " + config.build.dockerfile + "\n", properties: { type: "value" } });
+ if (config.build.context) {
+ entries.push({ text: " context: " + config.build.context + "\n", properties: { type: "value" } });
+ }
+ if (config.build.target) {
+ entries.push({ text: " target: " + config.build.target + "\n", properties: { type: "value" } });
+ }
+ entries.push({ text: "\n", properties: { type: "blank" } });
+ } else if (config.dockerComposeFile) {
+ entries.push({ text: editor.t("panel.section_compose") + "\n", properties: { type: "heading" } });
+ const files = Array.isArray(config.dockerComposeFile)
+ ? config.dockerComposeFile.join(", ")
+ : config.dockerComposeFile;
+ entries.push({ text: " files: " + files + "\n", properties: { type: "value" } });
+ if (config.service) {
+ entries.push({ text: " service: " + config.service + "\n", properties: { type: "value" } });
+ }
+ entries.push({ text: "\n", properties: { type: "blank" } });
+ }
+
+ // Features
+ if (config.features && Object.keys(config.features).length > 0) {
+ entries.push({ text: editor.t("panel.section_features") + "\n", properties: { type: "heading" } });
+ for (const [id, opts] of Object.entries(config.features)) {
+ entries.push({ text: " + " + id + "\n", properties: { type: "feature", id } });
+ if (typeof opts === "object" && opts !== null) {
+ const optStr = Object.entries(opts as Record)
+ .map(([k, v]) => `${k} = ${JSON.stringify(v)}`)
+ .join(", ");
+ if (optStr) {
+ entries.push({ text: " " + optStr + "\n", properties: { type: "feature-opts" } });
+ }
+ }
+ }
+ entries.push({ text: "\n", properties: { type: "blank" } });
+ }
+
+ // Ports
+ if (config.forwardPorts && config.forwardPorts.length > 0) {
+ entries.push({ text: editor.t("panel.section_ports") + "\n", properties: { type: "heading" } });
+ for (const port of config.forwardPorts) {
+ const attrs = config.portsAttributes?.[String(port)];
+ const proto = attrs?.protocol ?? "tcp";
+ let detail = ` ${port} -> ${proto}`;
+ if (attrs?.label) detail += ` (${attrs.label})`;
+ if (attrs?.onAutoForward) detail += ` [${attrs.onAutoForward}]`;
+ entries.push({ text: detail + "\n", properties: { type: "port", port: String(port) } });
+ }
+ entries.push({ text: "\n", properties: { type: "blank" } });
+ }
+
+ // Environment
+ const allEnv: Record = {};
+ if (config.containerEnv) Object.assign(allEnv, config.containerEnv);
+ if (config.remoteEnv) Object.assign(allEnv, config.remoteEnv);
+ const envKeys = Object.keys(allEnv);
+ if (envKeys.length > 0) {
+ entries.push({ text: editor.t("panel.section_env") + "\n", properties: { type: "heading" } });
+ for (const k of envKeys) {
+ entries.push({ text: ` ${k} = ${allEnv[k]}\n`, properties: { type: "env" } });
+ }
+ entries.push({ text: "\n", properties: { type: "blank" } });
+ }
+
+ // Mounts
+ if (config.mounts && config.mounts.length > 0) {
+ entries.push({ text: editor.t("panel.section_mounts") + "\n", properties: { type: "heading" } });
+ for (const mount of config.mounts) {
+ entries.push({ text: " " + formatMount(mount) + "\n", properties: { type: "mount" } });
+ }
+ entries.push({ text: "\n", properties: { type: "blank" } });
+ }
+
+ // Users
+ if (config.containerUser || config.remoteUser) {
+ entries.push({ text: editor.t("panel.section_users") + "\n", properties: { type: "heading" } });
+ if (config.containerUser) {
+ entries.push({ text: " containerUser: " + config.containerUser + "\n", properties: { type: "value" } });
+ }
+ if (config.remoteUser) {
+ entries.push({ text: " remoteUser: " + config.remoteUser + "\n", properties: { type: "value" } });
+ }
+ entries.push({ text: "\n", properties: { type: "blank" } });
+ }
+
+ // Lifecycle Commands
+ const lifecycle: [string, LifecycleCommand | undefined][] = [
+ ["initializeCommand", config.initializeCommand],
+ ["onCreateCommand", config.onCreateCommand],
+ ["updateContentCommand", config.updateContentCommand],
+ ["postCreateCommand", config.postCreateCommand],
+ ["postStartCommand", config.postStartCommand],
+ ["postAttachCommand", config.postAttachCommand],
+ ];
+ const defined = lifecycle.filter(([, v]) => v !== undefined);
+ if (defined.length > 0) {
+ entries.push({ text: editor.t("panel.section_lifecycle") + "\n", properties: { type: "heading" } });
+ for (const [cmdName, cmd] of defined) {
+ entries.push({
+ text: ` ${cmdName}: ${formatLifecycleCommand(cmd!)}\n`,
+ properties: { type: "lifecycle", command: cmdName },
+ });
+ }
+ entries.push({ text: "\n", properties: { type: "blank" } });
+ }
+
+ // Host Requirements
+ if (config.hostRequirements) {
+ const hr = config.hostRequirements;
+ entries.push({ text: editor.t("panel.section_host_req") + "\n", properties: { type: "heading" } });
+ if (hr.cpus) entries.push({ text: ` cpus: ${hr.cpus}\n`, properties: { type: "value" } });
+ if (hr.memory) entries.push({ text: ` memory: ${hr.memory}\n`, properties: { type: "value" } });
+ if (hr.storage) entries.push({ text: ` storage: ${hr.storage}\n`, properties: { type: "value" } });
+ if (hr.gpu) entries.push({ text: ` gpu: ${JSON.stringify(hr.gpu)}\n`, properties: { type: "value" } });
+ entries.push({ text: "\n", properties: { type: "blank" } });
+ }
+
+ // Separator before buttons
+ entries.push({
+ text: "─".repeat(40) + "\n",
+ properties: { type: "separator" },
+ });
+
+ // Action buttons row (Tab-navigable, like pkg.ts)
+ entries.push({ text: " ", properties: { type: "spacer" } });
+ for (let i = 0; i < infoButtons.length; i++) {
+ const btn = infoButtons[i];
+ const focused = infoFocus.index === i;
+ const leftBracket = focused ? "[" : " ";
+ const rightBracket = focused ? "]" : " ";
+ entries.push({
+ text: `${leftBracket} ${btn.label} ${rightBracket}`,
+ properties: { type: "button", focused, btnIndex: i },
+ });
+ if (i < infoButtons.length - 1) {
+ entries.push({ text: " ", properties: { type: "spacer" } });
+ }
+ }
+ entries.push({ text: "\n", properties: { type: "newline" } });
+
+ // Help line
+ entries.push({
+ text: editor.t("panel.footer") + "\n",
+ properties: { type: "footer" },
+ });
+
+ return entries;
+}
+
+function entriesToContent(entries: TextPropertyEntry[]): string {
+ return entries.map((e) => e.text).join("");
+}
+
+function applyInfoHighlighting(): void {
+ if (infoPanelBufferId === null) return;
+ const bufferId = infoPanelBufferId;
+
+ editor.clearNamespace(bufferId, "devcontainer");
+
+ const content = cachedContent;
+ if (!content) return;
+
+ const lines = content.split("\n");
+ let byteOffset = 0;
+
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
+ const line = lines[lineIdx];
+ const lineStart = byteOffset;
+ const lineByteLen = editor.utf8ByteLength(line);
+ const lineEnd = lineStart + lineByteLen;
+
+ // Heading lines (sections)
+ if (
+ line.startsWith("Dev Container:") ||
+ line === editor.t("panel.section_image") ||
+ line === editor.t("panel.section_build") ||
+ line === editor.t("panel.section_compose") ||
+ line === editor.t("panel.section_features") ||
+ line === editor.t("panel.section_ports") ||
+ line === editor.t("panel.section_env") ||
+ line === editor.t("panel.section_mounts") ||
+ line === editor.t("panel.section_users") ||
+ line === editor.t("panel.section_lifecycle") ||
+ line === editor.t("panel.section_host_req")
+ ) {
+ editor.addOverlay(bufferId, "devcontainer", lineStart, lineEnd, {
+ fg: colors.heading,
+ bold: true,
+ });
+ }
+ // Feature lines
+ else if (line.startsWith(" + ")) {
+ editor.addOverlay(bufferId, "devcontainer", lineStart, lineEnd, {
+ fg: colors.feature,
+ });
+ }
+ // Port lines
+ else if (line.match(/^\s+\d+\s*->/)) {
+ editor.addOverlay(bufferId, "devcontainer", lineStart, lineEnd, {
+ fg: colors.port,
+ });
+ }
+ // Key = value lines (env vars)
+ else if (line.match(/^\s+\w+\s*=/)) {
+ const eqIdx = line.indexOf("=");
+ if (eqIdx > 0) {
+ const keyEnd = lineStart + editor.utf8ByteLength(line.substring(0, eqIdx));
+ editor.addOverlay(bufferId, "devcontainer", lineStart, keyEnd, {
+ fg: colors.key,
+ });
+ }
+ }
+ // Separator
+ else if (line.match(/^─+$/)) {
+ editor.addOverlay(bufferId, "devcontainer", lineStart, lineEnd, {
+ fg: colors.footer,
+ });
+ }
+ // Footer help line
+ else if (line === editor.t("panel.footer")) {
+ editor.addOverlay(bufferId, "devcontainer", lineStart, lineEnd, {
+ fg: colors.footer,
+ italic: true,
+ });
+ }
+
+ byteOffset += lineByteLen + 1; // +1 for newline
+ }
+
+ // Apply button highlighting using entry-based scanning
+ // We need to walk entries to find button text positions in the content
+ applyButtonHighlighting();
+}
+
+function applyButtonHighlighting(): void {
+ if (infoPanelBufferId === null) return;
+ const bufferId = infoPanelBufferId;
+
+ // Re-scan entries to find button positions
+ const entries = buildInfoEntries();
+ let byteOffset = 0;
+
+ for (const entry of entries) {
+ const props = entry.properties as Record;
+ const len = editor.utf8ByteLength(entry.text);
+
+ if (props.type === "button") {
+ const focused = props.focused as boolean;
+ if (focused) {
+ editor.addOverlay(bufferId, "devcontainer", byteOffset, byteOffset + len, {
+ fg: colors.buttonFocused,
+ bg: colors.buttonFocusedBg,
+ bold: true,
+ });
+ } else {
+ editor.addOverlay(bufferId, "devcontainer", byteOffset, byteOffset + len, {
+ fg: colors.button,
+ });
+ }
+ }
+
+ byteOffset += len;
+ }
+}
+
+function updateInfoPanel(): void {
+ if (infoPanelBufferId === null) return;
+ const entries = buildInfoEntries();
+ cachedContent = entriesToContent(entries);
+ editor.setVirtualBufferContent(infoPanelBufferId, entries);
+ applyInfoHighlighting();
+}
+
+// =============================================================================
+// Mode Definition
+// =============================================================================
+
+editor.defineMode(
+ "devcontainer-info",
+ "normal",
+ [
+ ["Tab", "devcontainer_next_button"],
+ ["S-Tab", "devcontainer_prev_button"],
+ ["Return", "devcontainer_activate_button"],
+ ["M-r", "devcontainer_run_lifecycle"],
+ ["M-o", "devcontainer_open_config"],
+ ["M-b", "devcontainer_rebuild"],
+ ["q", "devcontainer_close_info"],
+ ["Escape", "devcontainer_close_info"],
+ ],
+ true // read-only
+);
+
+// =============================================================================
+// Info Panel Button Navigation
+// =============================================================================
+
+globalThis.devcontainer_next_button = function (): void {
+ if (!infoPanelOpen) return;
+ infoFocus = { type: "button", index: (infoFocus.index + 1) % infoButtons.length };
+ updateInfoPanel();
+};
+
+globalThis.devcontainer_prev_button = function (): void {
+ if (!infoPanelOpen) return;
+ infoFocus = { type: "button", index: (infoFocus.index - 1 + infoButtons.length) % infoButtons.length };
+ updateInfoPanel();
+};
+
+globalThis.devcontainer_activate_button = function (): void {
+ if (!infoPanelOpen) return;
+ const btn = infoButtons[infoFocus.index];
+ if (btn && globalThis[btn.command]) {
+ globalThis[btn.command]();
+ }
+};
+
+// =============================================================================
+// Commands
+// =============================================================================
+
+globalThis.devcontainer_show_info = async function (): Promise {
+ if (!config) {
+ editor.setStatus(editor.t("status.no_config"));
+ return;
+ }
+
+ if (infoPanelOpen && infoPanelBufferId !== null) {
+ // Already open - refresh content
+ updateInfoPanel();
+ return;
+ }
+
+ infoFocus = { type: "button", index: 0 };
+ const entries = buildInfoEntries();
+ cachedContent = entriesToContent(entries);
+
+ const result = await editor.createVirtualBufferInSplit({
+ name: "*Dev Container*",
+ mode: "devcontainer-info",
+ readOnly: true,
+ showLineNumbers: false,
+ showCursors: true,
+ editingDisabled: true,
+ lineWrap: true,
+ ratio: 0.4,
+ direction: "horizontal",
+ entries: entries,
+ });
+
+ if (result !== null) {
+ infoPanelOpen = true;
+ infoPanelBufferId = result.bufferId;
+ infoPanelSplitId = result.splitId;
+ applyInfoHighlighting();
+ editor.setStatus(editor.t("status.panel_opened"));
+ }
+};
+
+globalThis.devcontainer_close_info = function (): void {
+ if (!infoPanelOpen) return;
+
+ if (infoPanelSplitId !== null) {
+ editor.closeSplit(infoPanelSplitId);
+ }
+ if (infoPanelBufferId !== null) {
+ editor.closeBuffer(infoPanelBufferId);
+ }
+
+ infoPanelOpen = false;
+ infoPanelBufferId = null;
+ infoPanelSplitId = null;
+ editor.setStatus(editor.t("status.panel_closed"));
+};
+
+globalThis.devcontainer_open_config = function (): void {
+ if (configPath) {
+ editor.openFile(configPath, null, null);
+ } else {
+ editor.setStatus(editor.t("status.no_config"));
+ }
+};
+
+globalThis.devcontainer_run_lifecycle = function (): void {
+ if (!config) {
+ editor.setStatus(editor.t("status.no_config"));
+ return;
+ }
+
+ const lifecycle: [string, LifecycleCommand | undefined][] = [
+ ["onCreateCommand", config.onCreateCommand],
+ ["updateContentCommand", config.updateContentCommand],
+ ["postCreateCommand", config.postCreateCommand],
+ ["postStartCommand", config.postStartCommand],
+ ["postAttachCommand", config.postAttachCommand],
+ ];
+
+ const defined = lifecycle.filter(([, v]) => v !== undefined);
+ if (defined.length === 0) {
+ editor.setStatus(editor.t("status.no_lifecycle"));
+ return;
+ }
+
+ const suggestions: PromptSuggestion[] = defined.map(([name, cmd]) => ({
+ text: name,
+ description: formatLifecycleCommand(cmd!),
+ value: name,
+ }));
+
+ editor.startPrompt(editor.t("prompt.run_lifecycle"), "devcontainer-lifecycle");
+ editor.setPromptSuggestions(suggestions);
+};
+
+globalThis.devcontainer_on_lifecycle_confirmed = async function (data: {
+ prompt_type: string;
+ value: string;
+}): Promise {
+ if (data.prompt_type !== "devcontainer-lifecycle") return;
+
+ const cmdName = data.value;
+ if (!config || !cmdName) return;
+
+ const cmd = (config as Record)[cmdName] as LifecycleCommand | undefined;
+ if (!cmd) return;
+
+ if (typeof cmd === "string") {
+ editor.setStatus(editor.t("status.running", { name: cmdName }));
+ const result = await editor.spawnProcess("sh", ["-c", cmd], editor.getCwd());
+ if (result.exit_code === 0) {
+ editor.setStatus(editor.t("status.completed", { name: cmdName }));
+ } else {
+ editor.setStatus(editor.t("status.failed", { name: cmdName, code: String(result.exit_code) }));
+ }
+ } else if (Array.isArray(cmd)) {
+ const [bin, ...args] = cmd;
+ editor.setStatus(editor.t("status.running", { name: cmdName }));
+ const result = await editor.spawnProcess(bin, args, editor.getCwd());
+ if (result.exit_code === 0) {
+ editor.setStatus(editor.t("status.completed", { name: cmdName }));
+ } else {
+ editor.setStatus(editor.t("status.failed", { name: cmdName, code: String(result.exit_code) }));
+ }
+ } else {
+ // Object form: run each named sub-command sequentially
+ for (const [label, subcmd] of Object.entries(cmd)) {
+ editor.setStatus(editor.t("status.running_sub", { name: cmdName, label }));
+ let bin: string;
+ let args: string[];
+ if (Array.isArray(subcmd)) {
+ [bin, ...args] = subcmd;
+ } else {
+ bin = "sh";
+ args = ["-c", subcmd as string];
+ }
+ const result = await editor.spawnProcess(bin, args, editor.getCwd());
+ if (result.exit_code !== 0) {
+ editor.setStatus(editor.t("status.failed_sub", { name: cmdName, label, code: String(result.exit_code) }));
+ return;
+ }
+ }
+ editor.setStatus(editor.t("status.completed", { name: cmdName }));
+ }
+};
+
+globalThis.devcontainer_show_features = function (): void {
+ if (!config || !config.features || Object.keys(config.features).length === 0) {
+ editor.setStatus(editor.t("status.no_features"));
+ return;
+ }
+
+ const suggestions: PromptSuggestion[] = Object.entries(config.features).map(([id, opts]) => {
+ let desc = "";
+ if (typeof opts === "object" && opts !== null) {
+ desc = Object.entries(opts as Record)
+ .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
+ .join(", ");
+ } else if (typeof opts === "string") {
+ desc = opts;
+ }
+ return { text: id, description: desc || "(default options)" };
+ });
+
+ editor.startPrompt(editor.t("prompt.features"), "devcontainer-features");
+ editor.setPromptSuggestions(suggestions);
+};
+
+globalThis.devcontainer_show_ports = function (): void {
+ if (!config || !config.forwardPorts || config.forwardPorts.length === 0) {
+ editor.setStatus(editor.t("status.no_ports"));
+ return;
+ }
+
+ const suggestions: PromptSuggestion[] = config.forwardPorts.map((port) => {
+ const attrs = config!.portsAttributes?.[String(port)];
+ const proto = attrs?.protocol ?? "tcp";
+ let desc = proto;
+ if (attrs?.label) desc += ` - ${attrs.label}`;
+ if (attrs?.onAutoForward) desc += ` (${attrs.onAutoForward})`;
+ return { text: String(port), description: desc };
+ });
+
+ editor.startPrompt(editor.t("prompt.ports"), "devcontainer-ports");
+ editor.setPromptSuggestions(suggestions);
+};
+
+const INSTALL_COMMAND = "npm i -g @devcontainers/cli";
+
+interface ActionPopupResultData {
+ popup_id: string;
+ action_id: string;
+}
+
+function showCliNotFoundPopup(): void {
+ editor.showActionPopup({
+ id: "devcontainer-cli-help",
+ title: editor.t("popup.cli_title"),
+ message: editor.t("popup.cli_message"),
+ actions: [
+ { id: "copy_install", label: "Copy: " + INSTALL_COMMAND },
+ { id: "dismiss", label: "Dismiss (ESC)" },
+ ],
+ });
+}
+
+globalThis.devcontainer_on_action_result = function (
+ data: ActionPopupResultData,
+): void {
+ if (data.popup_id === "devcontainer-cli-help") {
+ switch (data.action_id) {
+ case "copy_install":
+ editor.setClipboard(INSTALL_COMMAND);
+ editor.setStatus(editor.t("status.copied_install", { cmd: INSTALL_COMMAND }));
+ break;
+ case "dismiss":
+ case "dismissed":
+ break;
+ }
+ } else if (data.popup_id === "devcontainer-activate") {
+ switch (data.action_id) {
+ case "rebuild":
+ globalThis.devcontainer_rebuild();
+ break;
+ case "copy_install":
+ editor.setClipboard(INSTALL_COMMAND);
+ editor.setStatus(editor.t("status.copied_install", { cmd: INSTALL_COMMAND }));
+ break;
+ case "show_info":
+ globalThis.devcontainer_show_info();
+ break;
+ case "dismiss":
+ case "dismissed":
+ break;
+ }
+ }
+};
+
+globalThis.devcontainer_rebuild = async function (): Promise {
+ const result = await editor.spawnProcess("which", ["devcontainer"]);
+ if (result.exit_code !== 0) {
+ showCliNotFoundPopup();
+ return;
+ }
+
+ // Open a terminal to stream the rebuild output live
+ const cwd = editor.getCwd();
+ const term = await editor.createTerminal({ direction: "horizontal", ratio: 0.4, focus: true });
+ const rebuildCmd = `devcontainer up --remove-existing-container --workspace-folder ${JSON.stringify(cwd)}; echo ""; echo "--- Rebuild finished (exit: $?) ---"\n`;
+ editor.sendTerminalInput(term.terminalId, rebuildCmd);
+ editor.setStatus(editor.t("status.rebuilding"));
+};
+
+globalThis.devcontainer_open_terminal = async function (): Promise {
+ const cliCheck = await editor.spawnProcess("which", ["devcontainer"]);
+ if (cliCheck.exit_code !== 0) {
+ showCliNotFoundPopup();
+ return;
+ }
+
+ // Check if a container is running for this workspace
+ const cwd = editor.getCwd();
+ const upCheck = await editor.spawnProcess(
+ "devcontainer",
+ ["exec", "--workspace-folder", cwd, "echo", "__devcontainer_ok__"],
+ );
+
+ if (upCheck.exit_code !== 0 || !upCheck.stdout.includes("__devcontainer_ok__")) {
+ editor.setStatus(editor.t("status.container_not_running"));
+ return;
+ }
+
+ // Open a terminal and send the exec command into it
+ const term = await editor.createTerminal({ direction: "vertical", ratio: 0.5, focus: true });
+ const execCmd = `devcontainer exec --workspace-folder ${JSON.stringify(cwd)} /bin/sh -c 'exec \${SHELL:-/bin/sh}'\n`;
+ editor.sendTerminalInput(term.terminalId, execCmd);
+ editor.setStatus(editor.t("status.terminal_opened"));
+};
+
+// =============================================================================
+// Event Handlers
+// =============================================================================
+
+editor.on("prompt_confirmed", "devcontainer_on_lifecycle_confirmed");
+editor.on("action_popup_result", "devcontainer_on_action_result");
+
+// =============================================================================
+// Command Registration
+// =============================================================================
+
+function registerCommands(): void {
+ editor.registerCommand(
+ "%cmd.show_info",
+ "%cmd.show_info_desc",
+ "devcontainer_show_info",
+ null,
+ );
+ editor.registerCommand(
+ "%cmd.open_config",
+ "%cmd.open_config_desc",
+ "devcontainer_open_config",
+ null,
+ );
+ editor.registerCommand(
+ "%cmd.run_lifecycle",
+ "%cmd.run_lifecycle_desc",
+ "devcontainer_run_lifecycle",
+ null,
+ );
+ editor.registerCommand(
+ "%cmd.show_features",
+ "%cmd.show_features_desc",
+ "devcontainer_show_features",
+ null,
+ );
+ editor.registerCommand(
+ "%cmd.show_ports",
+ "%cmd.show_ports_desc",
+ "devcontainer_show_ports",
+ null,
+ );
+ editor.registerCommand(
+ "%cmd.rebuild",
+ "%cmd.rebuild_desc",
+ "devcontainer_rebuild",
+ null,
+ );
+ editor.registerCommand(
+ "%cmd.open_terminal",
+ "%cmd.open_terminal_desc",
+ "devcontainer_open_terminal",
+ null,
+ );
+}
+
+// =============================================================================
+// Initialization
+// =============================================================================
+
+if (findConfig()) {
+ registerCommands();
+
+ const name = config!.name ?? "unnamed";
+ const image = getImageSummary();
+ const featureCount = config!.features ? Object.keys(config!.features).length : 0;
+ const portCount = config!.forwardPorts?.length ?? 0;
+
+ editor.setStatus(
+ editor.t("status.detected", {
+ name,
+ image,
+ features: String(featureCount),
+ ports: String(portCount),
+ }),
+ );
+
+ // Show activation popup on startup
+ // Check if devcontainer CLI is available to decide which actions to offer
+ const cliCheck = editor.spawnProcess("which", ["devcontainer"]);
+ cliCheck.then((result) => {
+ const hasCli = result.exit_code === 0;
+ const actions: Array<{ id: string; label: string }> = [];
+
+ if (hasCli) {
+ actions.push({ id: "rebuild", label: editor.t("popup.activate_rebuild") });
+ } else {
+ actions.push({ id: "copy_install", label: "Copy: " + INSTALL_COMMAND });
+ }
+ actions.push({ id: "show_info", label: editor.t("popup.activate_show_info") });
+ actions.push({ id: "dismiss", label: "Dismiss (ESC)" });
+
+ editor.showActionPopup({
+ id: "devcontainer-activate",
+ title: editor.t("popup.activate_title"),
+ message: hasCli
+ ? editor.t("popup.activate_message", { name, image })
+ : editor.t("popup.activate_message_no_cli", { name, image }),
+ actions,
+ });
+ });
+
+ editor.debug("Dev Container plugin initialized: " + name);
+} else {
+ editor.debug("Dev Container plugin: no devcontainer.json found");
+}
diff --git a/docs/internal/DEVCONTAINER_PLUGIN_DESIGN.md b/docs/internal/DEVCONTAINER_PLUGIN_DESIGN.md
new file mode 100644
index 000000000..a3324c0be
--- /dev/null
+++ b/docs/internal/DEVCONTAINER_PLUGIN_DESIGN.md
@@ -0,0 +1,796 @@
+# VS Code Dev Containers Plugin Design
+
+## Overview
+
+This document describes the design for a Fresh plugin that detects VS Code Dev Container configurations (`.devcontainer/devcontainer.json`) and provides in-editor support for working with containerized development environments. The plugin surfaces devcontainer metadata, lifecycle commands, port forwarding info, and feature listings — all within Fresh's existing plugin UI patterns.
+
+## Goals
+
+1. **Configuration Awareness**: Parse and display `devcontainer.json` settings so developers can understand their container environment without leaving the editor
+2. **Lifecycle Command Access**: Expose devcontainer lifecycle scripts (onCreateCommand, postCreateCommand, etc.) as runnable commands from the command palette
+3. **Feature Browsing**: List installed Dev Container Features with their options and documentation links
+4. **Port Forwarding Visibility**: Show configured port forwards and their attributes in a discoverable panel
+5. **Zero Dependencies**: Pure TypeScript plugin using Fresh's existing `spawnProcess` API — no external tooling required beyond what's already in the container
+
+## Non-Goals
+
+- **Container orchestration**: This plugin does not build, start, or stop containers. That is the job of the `devcontainer` CLI or VS Code. Fresh runs *inside* an already-running container.
+- **Feature installation**: Adding/removing Dev Container Features requires rebuilding the container image, which is outside Fresh's scope.
+- **Docker/Compose management**: No direct Docker socket interaction.
+- **Replacing the devcontainer CLI**: The plugin complements, not replaces, existing tooling.
+
+## Background: Dev Container Specification
+
+The [Dev Container specification](https://containers.dev/) defines a standard for enriching containers with development-specific metadata. Key concepts:
+
+- **`devcontainer.json`**: Configuration file placed in `.devcontainer/devcontainer.json` (or `.devcontainer.json`, or `.devcontainer//devcontainer.json`) that defines image, features, lifecycle scripts, ports, environment variables, and tool customizations.
+- **Features**: Self-contained, shareable units of installation code (e.g., `ghcr.io/devcontainers/features/rust:1`). Each feature has a `devcontainer-feature.json` manifest with options, install scripts, and metadata.
+- **Lifecycle Scripts**: Ordered hooks that run at container creation and startup:
+ 1. `initializeCommand` — runs on the host before container creation
+ 2. `onCreateCommand` — runs once when container is first created
+ 3. `updateContentCommand` — runs when new content is available
+ 4. `postCreateCommand` — runs after container creation completes
+ 5. `postStartCommand` — runs each time the container starts
+ 6. `postAttachCommand` — runs each time a tool attaches
+- **Customizations**: Tool-specific settings under `customizations.` (e.g., `customizations.vscode.extensions`).
+
+---
+
+## Architecture
+
+```
+┌──────────────────────────────────────────────────────────────────────┐
+│ Fresh Editor (running inside dev container) │
+│ │
+│ ┌────────────────────────────────────────────────────────────────┐ │
+│ │ devcontainer.ts Plugin │ │
+│ │ │ │
+│ │ ┌──────────────┐ ┌──────────────┐ ┌─────────────────────┐ │ │
+│ │ │ Config Parser │ │ Lifecycle │ │ Panel Renderer │ │ │
+│ │ │ (JSON + JSONC)│ │ Runner │ │ (virtual buffer) │ │ │
+│ │ └──────┬───────┘ └──────┬───────┘ └──────────┬──────────┘ │ │
+│ │ │ │ │ │ │
+│ │ └────────┬────────┴──────────────────────┘ │ │
+│ │ │ │ │
+│ │ editor.spawnProcess() │ │
+│ │ editor.readFile() │ │
+│ │ editor.createVirtualBufferInSplit() │ │
+│ └────────────────────────────────────────────────────────────────┘ │
+│ │
+│ Filesystem: │
+│ ├── .devcontainer/devcontainer.json │
+│ ├── .devcontainer/docker-compose.yml (optional) │
+│ └── .devcontainer/Dockerfile (optional) │
+└──────────────────────────────────────────────────────────────────────┘
+```
+
+The plugin operates entirely within Fresh's TypeScript plugin runtime (QuickJS). It reads configuration files from disk using `editor.readFile()`, runs lifecycle commands via `editor.spawnProcess()`, and displays information using virtual buffers and status bar messages.
+
+---
+
+## User Flows
+
+### Flow 1: Automatic Detection on Startup
+
+When Fresh opens a workspace containing a `.devcontainer/` directory:
+
+1. Plugin's `on_loaded` hook fires
+2. Plugin searches for `devcontainer.json` in priority order:
+ - `.devcontainer/devcontainer.json`
+ - `.devcontainer.json`
+ - `.devcontainer//devcontainer.json` (first match)
+3. If found, parse the config and display a brief status message:
+ ```
+ Dev Container: rust-dev (mcr.microsoft.com/devcontainers/rust:1) • 3 features • 2 ports
+ ```
+4. Register all command palette commands
+
+If no devcontainer config is found, the plugin remains dormant — no commands registered, no status messages.
+
+### Flow 2: View Container Info Panel
+
+User invokes command palette → "Dev Container: Show Info":
+
+```
+┌─ Dev Container: rust-dev ────────────────────────────────────────────┐
+│ │
+│ Image │
+│ mcr.microsoft.com/devcontainers/rust:1-bookworm │
+│ │
+│ Features │
+│ ✓ ghcr.io/devcontainers/features/rust:1 │
+│ version = "1.91.0" │
+│ ✓ ghcr.io/devcontainers/features/node:1 │
+│ version = "lts" │
+│ ✓ ghcr.io/devcontainers-contrib/features/apt-packages:1 │
+│ packages = "pkg-config,libssl-dev" │
+│ │
+│ Ports │
+│ 8080 → http (label: "Web App", onAutoForward: notify) │
+│ 5432 → tcp (label: "PostgreSQL", onAutoForward: silent) │
+│ │
+│ Environment │
+│ CARGO_HOME = /usr/local/cargo │
+│ RUST_LOG = debug │
+│ │
+│ Mounts │
+│ cargo-cache → /usr/local/cargo (volume) │
+│ │
+│ Users │
+│ containerUser: vscode │
+│ remoteUser: vscode │
+│ │
+│ Lifecycle Commands │
+│ onCreateCommand: cargo build │
+│ postCreateCommand: cargo test --no-run │
+│ postStartCommand: cargo watch -x check │
+│ │
+│ [r] Run lifecycle command [o] Open devcontainer.json [q] Close │
+└───────────────────────────────────────────────────────────────────────┘
+```
+
+This is rendered in a virtual buffer via `editor.createVirtualBufferInSplit()`, following the same pattern as `diagnostics_panel.ts` and `git_log.ts`.
+
+### Flow 3: Run Lifecycle Command
+
+User invokes command palette → "Dev Container: Run Lifecycle Command":
+
+```
+┌─ Run Lifecycle Command ──────────────────────────────────────────────┐
+│ Select a lifecycle command to run: │
+│ │
+│ > onCreateCommand: cargo build │
+│ postCreateCommand: cargo test --no-run │
+│ postStartCommand: cargo watch -x check │
+└───────────────────────────────────────────────────────────────────────┘
+```
+
+On selection, the command runs via `editor.spawnProcess()` in a terminal split, showing live output. This mirrors how `git_log.ts` spawns git processes.
+
+### Flow 4: Open Configuration File
+
+User invokes command palette → "Dev Container: Open Config":
+
+Opens `.devcontainer/devcontainer.json` in a new buffer. If multiple configs exist (subfolders), show a picker first.
+
+---
+
+## Configuration Parsing
+
+### JSONC Support
+
+`devcontainer.json` uses JSON with Comments (JSONC). The plugin includes a minimal JSONC stripper that removes:
+- Single-line comments (`//`)
+- Multi-line comments (`/* */`)
+- Trailing commas
+
+This is sufficient for parsing without adding a full JSONC parser dependency.
+
+```typescript
+function stripJsonc(text: string): string {
+ let result = "";
+ let i = 0;
+ let inString = false;
+ while (i < text.length) {
+ if (inString) {
+ if (text[i] === "\\" && i + 1 < text.length) {
+ result += text[i] + text[i + 1];
+ i += 2;
+ continue;
+ }
+ if (text[i] === '"') inString = false;
+ result += text[i];
+ } else if (text[i] === '"') {
+ inString = true;
+ result += text[i];
+ } else if (text[i] === "/" && text[i + 1] === "/") {
+ while (i < text.length && text[i] !== "\n") i++;
+ continue;
+ } else if (text[i] === "/" && text[i + 1] === "*") {
+ i += 2;
+ while (i < text.length - 1 && !(text[i] === "*" && text[i + 1] === "/")) i++;
+ i += 2;
+ continue;
+ } else {
+ result += text[i];
+ }
+ i++;
+ }
+ // Remove trailing commas before } or ]
+ return result.replace(/,\s*([}\]])/g, "$1");
+}
+```
+
+### Parsed Configuration Type
+
+```typescript
+interface DevContainerConfig {
+ name?: string;
+
+ // Image / Dockerfile / Compose
+ image?: string;
+ build?: {
+ dockerfile?: string;
+ context?: string;
+ args?: Record;
+ target?: string;
+ cacheFrom?: string | string[];
+ };
+ dockerComposeFile?: string | string[];
+ service?: string;
+
+ // Features
+ features?: Record>;
+
+ // Ports
+ forwardPorts?: (number | string)[];
+ portsAttributes?: Record;
+ appPort?: number | string | (number | string)[];
+
+ // Environment
+ containerEnv?: Record;
+ remoteEnv?: Record;
+
+ // Users
+ containerUser?: string;
+ remoteUser?: string;
+
+ // Mounts
+ mounts?: (string | MountConfig)[];
+
+ // Lifecycle
+ initializeCommand?: LifecycleCommand;
+ onCreateCommand?: LifecycleCommand;
+ updateContentCommand?: LifecycleCommand;
+ postCreateCommand?: LifecycleCommand;
+ postStartCommand?: LifecycleCommand;
+ postAttachCommand?: LifecycleCommand;
+
+ // Customizations
+ customizations?: Record;
+
+ // Runtime
+ runArgs?: string[];
+ workspaceFolder?: string;
+ workspaceMount?: string;
+ shutdownAction?: "none" | "stopContainer" | "stopCompose";
+ overrideCommand?: boolean;
+ init?: boolean;
+ privileged?: boolean;
+ capAdd?: string[];
+ securityOpt?: string[];
+
+ // Host requirements
+ hostRequirements?: {
+ cpus?: number;
+ memory?: string;
+ storage?: string;
+ gpu?: boolean | string | { cores?: number; memory?: string };
+ };
+}
+
+type LifecycleCommand = string | string[] | Record;
+
+interface PortAttributes {
+ label?: string;
+ protocol?: "http" | "https";
+ onAutoForward?: "notify" | "openBrowser" | "openBrowserOnce" | "openPreview" | "silent" | "ignore";
+ requireLocalPort?: boolean;
+ elevateIfNeeded?: boolean;
+}
+
+interface MountConfig {
+ type: "bind" | "volume" | "tmpfs";
+ source: string;
+ target: string;
+}
+```
+
+---
+
+## Command Palette Commands
+
+| Command | Description |
+|---------|-------------|
+| `Dev Container: Show Info` | Open info panel in virtual buffer split |
+| `Dev Container: Run Lifecycle Command` | Pick and run a lifecycle script |
+| `Dev Container: Open Config` | Open devcontainer.json in editor |
+| `Dev Container: Show Features` | List installed features with options |
+| `Dev Container: Show Ports` | Display port forwarding configuration |
+| `Dev Container: Show Environment` | Display container/remote env vars |
+| `Dev Container: Rebuild` | Run `devcontainer rebuild` if CLI available |
+
+Commands are only registered when a `devcontainer.json` is detected in the workspace.
+
+---
+
+## Implementation Details
+
+### Plugin Entry Point
+
+**New file**: `crates/fresh-editor/plugins/devcontainer.ts`
+
+```typescript
+///
+
+// ─── Config Discovery ────────────────────────────────────────────────
+
+const CONFIG_PATHS = [
+ ".devcontainer/devcontainer.json",
+ ".devcontainer.json",
+];
+
+let config: DevContainerConfig | null = null;
+let configPath: string | null = null;
+
+async function findConfig(): Promise {
+ const cwd = editor.getCwd();
+
+ for (const rel of CONFIG_PATHS) {
+ const full = `${cwd}/${rel}`;
+ try {
+ const text = await editor.readFile(full);
+ config = JSON.parse(stripJsonc(text));
+ configPath = full;
+ return;
+ } catch {
+ // not found, try next
+ }
+ }
+
+ // Check for subdirectory configs: .devcontainer//devcontainer.json
+ try {
+ const result = await editor.spawnProcess("ls", [
+ "-d", `${cwd}/.devcontainer/*/devcontainer.json`
+ ]);
+ if (result.exit_code === 0) {
+ const first = result.stdout.trim().split("\n")[0];
+ if (first) {
+ const text = await editor.readFile(first);
+ config = JSON.parse(stripJsonc(text));
+ configPath = first;
+ }
+ }
+ } catch {
+ // no subdirectory configs
+ }
+}
+
+// ─── Startup ─────────────────────────────────────────────────────────
+
+editor.on("on_loaded", async () => {
+ await findConfig();
+ if (!config) return;
+
+ registerCommands();
+
+ const featureCount = config.features ? Object.keys(config.features).length : 0;
+ const portCount = config.forwardPorts?.length ?? 0;
+ const name = config.name ?? "unnamed";
+ const image = config.image ?? config.build?.dockerfile ?? "compose";
+
+ editor.setStatus(
+ `Dev Container: ${name} (${image}) • ${featureCount} features • ${portCount} ports`
+ );
+});
+```
+
+### Info Panel Rendering
+
+Uses the virtual buffer pattern from `diagnostics_panel.ts`:
+
+```typescript
+async function showInfoPanel(): Promise {
+ if (!config) return;
+
+ const lines: string[] = [];
+ const overlays: Overlay[] = [];
+ let line = 0;
+
+ function heading(text: string) {
+ overlays.push({ line, style: "bold", text });
+ lines.push(text);
+ line++;
+ }
+
+ function entry(key: string, value: string) {
+ lines.push(` ${key}: ${value}`);
+ line++;
+ }
+
+ function blank() {
+ lines.push("");
+ line++;
+ }
+
+ // Header
+ heading(`Dev Container: ${config.name ?? "unnamed"}`);
+ blank();
+
+ // Image / Build
+ if (config.image) {
+ heading("Image");
+ entry("image", config.image);
+ blank();
+ } else if (config.build?.dockerfile) {
+ heading("Build");
+ entry("dockerfile", config.build.dockerfile);
+ if (config.build.context) entry("context", config.build.context);
+ if (config.build.target) entry("target", config.build.target);
+ blank();
+ } else if (config.dockerComposeFile) {
+ heading("Docker Compose");
+ const files = Array.isArray(config.dockerComposeFile)
+ ? config.dockerComposeFile.join(", ")
+ : config.dockerComposeFile;
+ entry("files", files);
+ if (config.service) entry("service", config.service);
+ blank();
+ }
+
+ // Features
+ if (config.features && Object.keys(config.features).length > 0) {
+ heading("Features");
+ for (const [id, opts] of Object.entries(config.features)) {
+ if (typeof opts === "object" && opts !== null) {
+ const optStr = Object.entries(opts)
+ .map(([k, v]) => `${k} = ${JSON.stringify(v)}`)
+ .join(", ");
+ lines.push(` ✓ ${id}`);
+ line++;
+ if (optStr) {
+ lines.push(` ${optStr}`);
+ line++;
+ }
+ } else {
+ lines.push(` ✓ ${id}`);
+ line++;
+ }
+ }
+ blank();
+ }
+
+ // Ports
+ if (config.forwardPorts && config.forwardPorts.length > 0) {
+ heading("Ports");
+ for (const port of config.forwardPorts) {
+ const attrs = config.portsAttributes?.[String(port)];
+ const label = attrs?.label ? ` (label: "${attrs.label}")` : "";
+ const proto = attrs?.protocol ?? "tcp";
+ lines.push(` ${port} → ${proto}${label}`);
+ line++;
+ }
+ blank();
+ }
+
+ // Environment
+ const allEnv = { ...config.containerEnv, ...config.remoteEnv };
+ if (Object.keys(allEnv).length > 0) {
+ heading("Environment");
+ for (const [k, v] of Object.entries(allEnv)) {
+ entry(k, v);
+ }
+ blank();
+ }
+
+ // Lifecycle Commands
+ const lifecycle: [string, LifecycleCommand | undefined][] = [
+ ["initializeCommand", config.initializeCommand],
+ ["onCreateCommand", config.onCreateCommand],
+ ["updateContentCommand", config.updateContentCommand],
+ ["postCreateCommand", config.postCreateCommand],
+ ["postStartCommand", config.postStartCommand],
+ ["postAttachCommand", config.postAttachCommand],
+ ];
+ const defined = lifecycle.filter(([, v]) => v !== undefined);
+ if (defined.length > 0) {
+ heading("Lifecycle Commands");
+ for (const [name, cmd] of defined) {
+ entry(name, formatLifecycleCommand(cmd!));
+ }
+ blank();
+ }
+
+ // Users
+ if (config.containerUser || config.remoteUser) {
+ heading("Users");
+ if (config.containerUser) entry("containerUser", config.containerUser);
+ if (config.remoteUser) entry("remoteUser", config.remoteUser);
+ blank();
+ }
+
+ const content = lines.join("\n");
+ editor.createVirtualBufferInSplit(
+ "devcontainer-info",
+ content,
+ "Dev Container Info",
+ { overlays, readOnly: true }
+ );
+}
+
+function formatLifecycleCommand(cmd: LifecycleCommand): string {
+ if (typeof cmd === "string") return cmd;
+ if (Array.isArray(cmd)) return cmd.join(" ");
+ return Object.entries(cmd)
+ .map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(" ") : v}`)
+ .join("; ");
+}
+```
+
+### Lifecycle Command Runner
+
+```typescript
+async function runLifecycleCommand(): Promise {
+ if (!config) return;
+
+ const lifecycle: [string, LifecycleCommand | undefined][] = [
+ ["onCreateCommand", config.onCreateCommand],
+ ["updateContentCommand", config.updateContentCommand],
+ ["postCreateCommand", config.postCreateCommand],
+ ["postStartCommand", config.postStartCommand],
+ ["postAttachCommand", config.postAttachCommand],
+ ];
+
+ const defined = lifecycle.filter(([, v]) => v !== undefined);
+ if (defined.length === 0) {
+ editor.setStatus("No lifecycle commands defined");
+ return;
+ }
+
+ const items = defined.map(([name, cmd]) => ({
+ label: name,
+ description: formatLifecycleCommand(cmd!),
+ }));
+
+ editor.startPrompt("Run lifecycle command:", "devcontainer-lifecycle");
+ editor.setPromptSuggestions(items);
+}
+
+// Handle selection
+editor.on("prompt_selection_changed", (ctx) => {
+ if (ctx.promptId !== "devcontainer-lifecycle") return;
+ // Preview: show full command in status bar
+ if (ctx.selection) {
+ editor.setStatus(`Will run: ${ctx.selection.description}`);
+ }
+});
+
+async function executeLifecycleCommand(name: string): Promise {
+ const cmd = (config as any)?.[name];
+ if (!cmd) return;
+
+ if (typeof cmd === "string") {
+ editor.setStatus(`Running ${name}...`);
+ const result = await editor.spawnProcess("sh", ["-c", cmd]);
+ if (result.exit_code === 0) {
+ editor.setStatus(`${name} completed successfully`);
+ } else {
+ editor.setStatus(`${name} failed (exit ${result.exit_code})`);
+ }
+ } else if (Array.isArray(cmd)) {
+ const [bin, ...args] = cmd;
+ editor.setStatus(`Running ${name}...`);
+ const result = await editor.spawnProcess(bin, args);
+ if (result.exit_code === 0) {
+ editor.setStatus(`${name} completed successfully`);
+ } else {
+ editor.setStatus(`${name} failed (exit ${result.exit_code})`);
+ }
+ } else {
+ // Object form: run each named command sequentially
+ for (const [label, subcmd] of Object.entries(cmd)) {
+ editor.setStatus(`Running ${name} (${label})...`);
+ const c = Array.isArray(subcmd) ? subcmd : ["sh", "-c", subcmd];
+ const [bin, ...args] = c;
+ const result = await editor.spawnProcess(bin, args);
+ if (result.exit_code !== 0) {
+ editor.setStatus(`${name} (${label}) failed (exit ${result.exit_code})`);
+ return;
+ }
+ }
+ editor.setStatus(`${name} completed successfully`);
+ }
+}
+```
+
+### Command Registration
+
+```typescript
+function registerCommands(): void {
+ editor.registerCommand(
+ "devcontainer_show_info",
+ "Dev Container: Show Info",
+ "devcontainer_show_info",
+ "normal"
+ );
+ editor.registerCommand(
+ "devcontainer_run_lifecycle",
+ "Dev Container: Run Lifecycle Command",
+ "devcontainer_run_lifecycle",
+ "normal"
+ );
+ editor.registerCommand(
+ "devcontainer_open_config",
+ "Dev Container: Open Config",
+ "devcontainer_open_config",
+ "normal"
+ );
+ editor.registerCommand(
+ "devcontainer_show_features",
+ "Dev Container: Show Features",
+ "devcontainer_show_features",
+ "normal"
+ );
+ editor.registerCommand(
+ "devcontainer_show_ports",
+ "Dev Container: Show Ports",
+ "devcontainer_show_ports",
+ "normal"
+ );
+ editor.registerCommand(
+ "devcontainer_rebuild",
+ "Dev Container: Rebuild",
+ "devcontainer_rebuild",
+ "normal"
+ );
+}
+
+// Command handlers
+globalThis.devcontainer_show_info = showInfoPanel;
+globalThis.devcontainer_run_lifecycle = runLifecycleCommand;
+globalThis.devcontainer_open_config = () => {
+ if (configPath) editor.openFile(configPath);
+};
+globalThis.devcontainer_rebuild = async () => {
+ const result = await editor.spawnProcess("which", ["devcontainer"]);
+ if (result.exit_code !== 0) {
+ editor.setStatus("devcontainer CLI not found. Install with: npm i -g @devcontainers/cli");
+ return;
+ }
+ editor.setStatus("Rebuilding dev container...");
+ await editor.spawnProcess("devcontainer", ["rebuild", "--workspace-folder", editor.getCwd()]);
+};
+```
+
+---
+
+## Internationalization
+
+Following Fresh's i18n convention, the plugin includes a companion `devcontainer.i18n.json`:
+
+```json
+{
+ "en": {
+ "status_detected": "Dev Container: {name} ({image}) • {features} features • {ports} ports",
+ "no_config": "No devcontainer.json found",
+ "running": "Running {name}...",
+ "completed": "{name} completed successfully",
+ "failed": "{name} failed (exit {code})",
+ "cli_not_found": "devcontainer CLI not found. Install with: npm i -g @devcontainers/cli"
+ }
+}
+```
+
+---
+
+## Files to Create
+
+| File | Purpose |
+|------|---------|
+| `crates/fresh-editor/plugins/devcontainer.ts` | Main plugin implementation |
+| `crates/fresh-editor/plugins/devcontainer.i18n.json` | Internationalization strings |
+
+No Rust code changes required. The plugin uses only existing plugin APIs.
+
+---
+
+## Alternative Designs Considered
+
+### Alternative 1: Rust-native Config Parser
+
+**Approach**: Parse `devcontainer.json` in Rust and expose it via a new plugin API.
+
+**Pros**: Faster parsing, type-safe, could integrate with editor core features.
+
+**Cons**: Adds Rust code for a niche feature, couples devcontainer awareness to the editor core, requires editor updates for devcontainer spec changes.
+
+**Verdict**: Rejected. A TypeScript plugin is the right granularity — it can evolve independently of editor releases and follows Fresh's extension philosophy.
+
+### Alternative 2: Full devcontainer CLI Wrapper
+
+**Approach**: Shell out to `devcontainer read-configuration` for parsed config instead of parsing JSON ourselves.
+
+**Pros**: Handles all edge cases (variable substitution, feature merging, image label metadata).
+
+**Cons**: Requires `devcontainer` CLI to be installed (it often isn't inside the container itself), adds ~2s startup latency for the CLI invocation, and makes the plugin useless in environments without the CLI.
+
+**Verdict**: Rejected. Direct JSON parsing covers the common case. A future enhancement could optionally use the CLI when available for full config resolution.
+
+### Alternative 3: LSP-based Approach
+
+**Approach**: Use a devcontainer JSON Schema LSP server for validation and completion.
+
+**Pros**: Get validation, completion, and hover docs for free.
+
+**Cons**: Orthogonal to the plugin's purpose (which is displaying info, not editing the config). JSON schema validation can be added independently via Fresh's existing JSON LSP support.
+
+**Verdict**: Out of scope, but complementary. Users can already get JSON schema validation by configuring the JSON LSP with the devcontainer schema URL.
+
+---
+
+## Testing Strategy
+
+### Unit Tests
+
+- JSONC stripping: comments, trailing commas, edge cases
+- Config parsing: all property types (image, Dockerfile, Compose)
+- Lifecycle command formatting: string, array, and object forms
+- Port attribute rendering
+
+### E2E Tests
+
+Using `EditorTestHarness` with a temp directory containing `.devcontainer/devcontainer.json`:
+
+```rust
+#[test]
+fn test_devcontainer_plugin_detects_config() {
+ let mut harness = EditorTestHarness::new(120, 40).unwrap();
+ harness.copy_plugin("devcontainer");
+
+ // Create devcontainer.json fixture
+ let dc_dir = harness.files.path().join(".devcontainer");
+ std::fs::create_dir_all(&dc_dir).unwrap();
+ std::fs::write(
+ dc_dir.join("devcontainer.json"),
+ r#"{ "name": "test", "image": "ubuntu:22.04" }"#,
+ ).unwrap();
+
+ harness.open_directory(harness.files.path()).unwrap();
+ harness.wait_for_plugins().unwrap();
+ harness.render().unwrap();
+
+ harness.assert_screen_contains("Dev Container: test");
+}
+```
+
+### Manual Testing
+
+1. Open a project with `.devcontainer/devcontainer.json`
+2. Verify status bar shows container info
+3. Run "Dev Container: Show Info" from command palette
+4. Run a lifecycle command and verify output
+5. Test with various config shapes (image-only, Dockerfile, Compose)
+6. Test with JSONC comments and trailing commas
+
+---
+
+## Implementation Phases
+
+### Phase 1: Core Detection & Info Panel
+- [ ] JSONC parser
+- [ ] Config file discovery
+- [ ] Config type definitions and parsing
+- [ ] Info panel virtual buffer
+- [ ] Status bar message on detection
+- [ ] "Open Config" command
+
+### Phase 2: Lifecycle Commands
+- [ ] Lifecycle command picker prompt
+- [ ] Command execution (string, array, object forms)
+- [ ] Output display in terminal split
+
+### Phase 3: Polish
+- [ ] i18n support
+- [ ] Rebuild command (optional devcontainer CLI integration)
+- [ ] E2E tests
+- [ ] Handle workspace reloads / config file changes
+
+---
+
+## Open Questions
+
+1. **Config file watching**: Should the plugin re-parse `devcontainer.json` when it changes on disk? Fresh has file-watching infrastructure, but the added complexity may not be worth it for a config file that rarely changes during a session.
+
+2. **Variable substitution**: `devcontainer.json` supports `${localEnv:VAR}` and `${containerEnv:VAR}` template variables. Should the plugin resolve these? Initial implementation can show them as-is and add resolution later.
+
+3. **Multiple configurations**: When `.devcontainer/` contains multiple subdirectories (each with its own `devcontainer.json`), should the plugin show a picker or auto-detect which one is active? The spec doesn't define "active" — that's determined by the tool that created the container.