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.