From 6efd9d53ecb81d6c33e1362cd6f46a38410e215f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Feb 2026 21:31:07 +0000 Subject: [PATCH 1/6] Add design doc for VS Code Dev Containers plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Describes a TypeScript plugin that detects .devcontainer/devcontainer.json and provides in-editor info panels, lifecycle command execution, and feature/port browsing — all using existing Fresh plugin APIs. https://claude.ai/code/session_01H3A1ru38B68gxZpbt7cSJK --- docs/internal/DEVCONTAINER_PLUGIN_DESIGN.md | 796 ++++++++++++++++++++ 1 file changed, 796 insertions(+) create mode 100644 docs/internal/DEVCONTAINER_PLUGIN_DESIGN.md 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. From 20222a2bc1cc5fe591a33ba9d6941b728754822c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Feb 2026 21:43:19 +0000 Subject: [PATCH 2/6] Add devcontainer plugin for dev container config awareness TypeScript plugin that detects .devcontainer/devcontainer.json and provides: - Status bar summary showing container name, image, features, and ports - Info panel (virtual buffer) with full config details and keybindings - Lifecycle command runner (onCreateCommand, postCreateCommand, etc.) - Feature and port browsing via command palette prompts - Quick open for the devcontainer.json config file - Optional rebuild command via devcontainer CLI Includes JSONC parser for comment/trailing-comma support and i18n translations for en, cs, de, es, fr, ja, ko, and zh-CN. https://claude.ai/code/session_01H3A1ru38B68gxZpbt7cSJK --- .../plugins/devcontainer.i18n.json | 386 +++++++++ crates/fresh-editor/plugins/devcontainer.ts | 763 ++++++++++++++++++ 2 files changed, 1149 insertions(+) create mode 100644 crates/fresh-editor/plugins/devcontainer.i18n.json create mode 100644 crates/fresh-editor/plugins/devcontainer.ts diff --git a/crates/fresh-editor/plugins/devcontainer.i18n.json b/crates/fresh-editor/plugins/devcontainer.i18n.json new file mode 100644 index 000000000..433750672 --- /dev/null +++ b/crates/fresh-editor/plugins/devcontainer.i18n.json @@ -0,0 +1,386 @@ +{ + "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", + + "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.rebuilding": "Rebuilding dev container...", + "status.rebuild_done": "Dev container rebuild complete", + "status.rebuild_failed": "Rebuild failed: %{error}", + + "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": "r: run lifecycle | o: open config | 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", + + "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.rebuilding": "Znovu sestavuji dev container...", + "status.rebuild_done": "Sestaveni dev containeru dokonceno", + "status.rebuild_failed": "Sestaveni selhalo: %{error}", + + "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": "r: spustit lifecycle | o: otevrit konfiguraci | 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", + + "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.rebuilding": "Dev Container wird neu erstellt...", + "status.rebuild_done": "Dev-Container-Neuerstellung abgeschlossen", + "status.rebuild_failed": "Neuerstellung fehlgeschlagen: %{error}", + + "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": "r: Lifecycle ausfuehren | o: Konfiguration oeffnen | 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", + + "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.rebuilding": "Reconstruyendo dev container...", + "status.rebuild_done": "Reconstruccion del dev container completada", + "status.rebuild_failed": "Reconstruccion fallida: %{error}", + + "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": "r: ejecutar lifecycle | o: abrir config | 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", + + "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.rebuilding": "Reconstruction du dev container...", + "status.rebuild_done": "Reconstruction du dev container terminee", + "status.rebuild_failed": "Reconstruction echouee: %{error}", + + "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": "r: executer lifecycle | o: ouvrir config | 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をリビルド", + + "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.rebuilding": "Dev Containerをリビルド中...", + "status.rebuild_done": "Dev Containerのリビルドが完了しました", + "status.rebuild_failed": "リビルド失敗: %{error}", + + "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": "r: ライフサイクル実行 | o: 設定を開く | 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 재빌드", + + "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.rebuilding": "Dev Container 재빌드 중...", + "status.rebuild_done": "Dev Container 재빌드 완료", + "status.rebuild_failed": "재빌드 실패: %{error}", + + "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": "r: 라이프사이클 실행 | o: 설정 열기 | 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", + + "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.rebuilding": "正在重建Dev Container...", + "status.rebuild_done": "Dev Container重建完成", + "status.rebuild_failed": "重建失败: %{error}", + + "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": "r: 运行生命周期 | o: 打开配置 | q: 关闭" + } +} diff --git a/crates/fresh-editor/plugins/devcontainer.ts b/crates/fresh-editor/plugins/devcontainer.ts new file mode 100644 index 000000000..3cedfa353 --- /dev/null +++ b/crates/fresh-editor/plugins/devcontainer.ts @@ -0,0 +1,763 @@ +/// +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 = ""; + +// ============================================================================= +// 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], +}; + +// ============================================================================= +// 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" } }); + } + + // Footer + 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, + }); + } + } + // Footer + else if (line === editor.t("panel.footer")) { + editor.addOverlay(bufferId, "devcontainer", lineStart, lineEnd, { + fg: colors.footer, + italic: true, + }); + } + + byteOffset += lineByteLen + 1; // +1 for newline + } +} + +// ============================================================================= +// Mode Definition +// ============================================================================= + +editor.defineMode( + "devcontainer-info", + "normal", + [ + ["r", "devcontainer_run_lifecycle"], + ["o", "devcontainer_open_config"], + ["q", "devcontainer_close_info"], + ["Escape", "devcontainer_close_info"], + ], + true // read-only +); + +// ============================================================================= +// 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 + const entries = buildInfoEntries(); + cachedContent = entriesToContent(entries); + editor.setVirtualBufferContent(infoPanelBufferId, entries); + applyInfoHighlighting(); + return; + } + + 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); +}; + +globalThis.devcontainer_rebuild = async function (): Promise { + const result = await editor.spawnProcess("which", ["devcontainer"]); + if (result.exit_code !== 0) { + editor.setStatus(editor.t("status.cli_not_found")); + return; + } + editor.setStatus(editor.t("status.rebuilding")); + const rebuild = await editor.spawnProcess( + "devcontainer", + ["rebuild", "--workspace-folder", editor.getCwd()], + ); + if (rebuild.exit_code === 0) { + editor.setStatus(editor.t("status.rebuild_done")); + } else { + editor.setStatus(editor.t("status.rebuild_failed", { error: rebuild.stderr })); + } +}; + +// ============================================================================= +// Event Handlers +// ============================================================================= + +editor.on("prompt_confirmed", "devcontainer_on_lifecycle_confirmed"); + +// ============================================================================= +// 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, + ); +} + +// ============================================================================= +// 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), + }), + ); + + editor.debug("Dev Container plugin initialized: " + name); +} else { + editor.debug("Dev Container plugin: no devcontainer.json found"); +} From 3faeb01380531a563e2e0c4549405084320b929d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Feb 2026 18:31:56 +0000 Subject: [PATCH 3/6] Fix rebuild command, add action popups for CLI install and activation - Fix: Use `devcontainer up --remove-existing-container` instead of the non-existent `devcontainer rebuild` subcommand - Add: Action popup when devcontainer CLI is not found, offering to copy the install command (npm i -g @devcontainers/cli) or dismiss - Add: Activation popup on editor startup when a devcontainer.json is detected, offering Show Info / Open Config / Dismiss - Add i18n strings for all new popups across all 8 locales https://claude.ai/code/session_01H3A1ru38B68gxZpbt7cSJK --- .../plugins/devcontainer.i18n.json | 64 +++++++++++++++++++ crates/fresh-editor/plugins/devcontainer.ts | 64 ++++++++++++++++++- 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/crates/fresh-editor/plugins/devcontainer.i18n.json b/crates/fresh-editor/plugins/devcontainer.i18n.json index 433750672..d7ab670cb 100644 --- a/crates/fresh-editor/plugins/devcontainer.i18n.json +++ b/crates/fresh-editor/plugins/devcontainer.i18n.json @@ -26,10 +26,18 @@ "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}", + "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}). View its configuration or open the config file.", + "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:", @@ -74,10 +82,18 @@ "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}", + "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}). Zobrazte konfiguraci nebo otevrete konfiguracni soubor.", + "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:", @@ -122,10 +138,18 @@ "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}", + "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. Konfiguration anzeigen oder Konfigurationsdatei oeffnen.", + "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:", @@ -170,10 +194,18 @@ "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}", + "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}). Ver su configuracion o abrir el archivo de configuracion.", + "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:", @@ -218,10 +250,18 @@ "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}", + "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. Voir la configuration ou ouvrir le fichier de configuration.", + "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:", @@ -266,10 +306,18 @@ "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}", + "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_show_info": "情報を表示", + "popup.activate_open_config": "設定を開く", + "prompt.run_lifecycle": "ライフサイクルコマンドを実行:", "prompt.features": "Dev Container Features:", "prompt.ports": "転送ポート:", @@ -314,10 +362,18 @@ "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}", + "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_show_info": "정보 표시", + "popup.activate_open_config": "설정 열기", + "prompt.run_lifecycle": "라이프사이클 명령 실행:", "prompt.features": "Dev Container Features:", "prompt.ports": "포워딩된 포트:", @@ -362,10 +418,18 @@ "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}", + "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_show_info": "显示信息", + "popup.activate_open_config": "打开配置", + "prompt.run_lifecycle": "运行生命周期命令:", "prompt.features": "Dev Container Features:", "prompt.ports": "转发端口:", diff --git a/crates/fresh-editor/plugins/devcontainer.ts b/crates/fresh-editor/plugins/devcontainer.ts index 3cedfa353..41b340ede 100644 --- a/crates/fresh-editor/plugins/devcontainer.ts +++ b/crates/fresh-editor/plugins/devcontainer.ts @@ -669,16 +669,63 @@ globalThis.devcontainer_show_ports = function (): void { 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 "show_info": + globalThis.devcontainer_show_info(); + break; + case "open_config": + globalThis.devcontainer_open_config(); + break; + case "dismiss": + case "dismissed": + break; + } + } +}; + globalThis.devcontainer_rebuild = async function (): Promise { const result = await editor.spawnProcess("which", ["devcontainer"]); if (result.exit_code !== 0) { - editor.setStatus(editor.t("status.cli_not_found")); + showCliNotFoundPopup(); return; } editor.setStatus(editor.t("status.rebuilding")); const rebuild = await editor.spawnProcess( "devcontainer", - ["rebuild", "--workspace-folder", editor.getCwd()], + ["up", "--remove-existing-container", "--workspace-folder", editor.getCwd()], ); if (rebuild.exit_code === 0) { editor.setStatus(editor.t("status.rebuild_done")); @@ -692,6 +739,7 @@ globalThis.devcontainer_rebuild = async function (): Promise { // ============================================================================= editor.on("prompt_confirmed", "devcontainer_on_lifecycle_confirmed"); +editor.on("action_popup_result", "devcontainer_on_action_result"); // ============================================================================= // Command Registration @@ -757,6 +805,18 @@ if (findConfig()) { }), ); + // Show activation popup on startup + editor.showActionPopup({ + id: "devcontainer-activate", + title: editor.t("popup.activate_title"), + message: editor.t("popup.activate_message", { name, image }), + actions: [ + { id: "show_info", label: editor.t("popup.activate_show_info") }, + { id: "open_config", label: editor.t("popup.activate_open_config") }, + { id: "dismiss", label: "Dismiss (ESC)" }, + ], + }); + editor.debug("Dev Container plugin initialized: " + name); } else { editor.debug("Dev Container plugin: no devcontainer.json found"); From aad9f2f05e236ec1a9e724d45d39fc60727b67c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Feb 2026 18:34:50 +0000 Subject: [PATCH 4/6] Make Rebuild Container the default action in activation popup On startup, the activation popup now checks for the devcontainer CLI: - If found: offers "Rebuild Container" (default) / "Show Info" / "Dismiss" - If not found: offers "Copy: npm i -g @devcontainers/cli" / "Show Info" / "Dismiss" This mirrors VS Code's behavior of prompting "Reopen in Container" as the primary action when a devcontainer.json is detected. https://claude.ai/code/session_01H3A1ru38B68gxZpbt7cSJK --- .../plugins/devcontainer.i18n.json | 32 +++++++++++---- crates/fresh-editor/plugins/devcontainer.ts | 41 +++++++++++++------ 2 files changed, 53 insertions(+), 20 deletions(-) diff --git a/crates/fresh-editor/plugins/devcontainer.i18n.json b/crates/fresh-editor/plugins/devcontainer.i18n.json index d7ab670cb..f02048c30 100644 --- a/crates/fresh-editor/plugins/devcontainer.i18n.json +++ b/crates/fresh-editor/plugins/devcontainer.i18n.json @@ -34,7 +34,9 @@ "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}). View its configuration or open the config file.", + "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", @@ -90,7 +92,9 @@ "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}). Zobrazte konfiguraci nebo otevrete konfiguracni soubor.", + "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", @@ -146,7 +150,9 @@ "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. Konfiguration anzeigen oder Konfigurationsdatei oeffnen.", + "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", @@ -202,7 +208,9 @@ "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}). Ver su configuracion o abrir el archivo de configuracion.", + "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", @@ -258,7 +266,9 @@ "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. Voir la configuration ou ouvrir le fichier de configuration.", + "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", @@ -314,7 +324,9 @@ "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": "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": "設定を開く", @@ -370,7 +382,9 @@ "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": "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": "설정 열기", @@ -426,7 +440,9 @@ "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": "发现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": "打开配置", diff --git a/crates/fresh-editor/plugins/devcontainer.ts b/crates/fresh-editor/plugins/devcontainer.ts index 41b340ede..1c7a6ffc8 100644 --- a/crates/fresh-editor/plugins/devcontainer.ts +++ b/crates/fresh-editor/plugins/devcontainer.ts @@ -703,12 +703,16 @@ globalThis.devcontainer_on_action_result = function ( } } 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 "open_config": - globalThis.devcontainer_open_config(); - break; case "dismiss": case "dismissed": break; @@ -806,15 +810,28 @@ if (findConfig()) { ); // Show activation popup on startup - editor.showActionPopup({ - id: "devcontainer-activate", - title: editor.t("popup.activate_title"), - message: editor.t("popup.activate_message", { name, image }), - actions: [ - { id: "show_info", label: editor.t("popup.activate_show_info") }, - { id: "open_config", label: editor.t("popup.activate_open_config") }, - { id: "dismiss", label: "Dismiss (ESC)" }, - ], + // 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); From 1a76e2c1a5ac0fd395a1853f415086b6332f8928 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Feb 2026 18:39:00 +0000 Subject: [PATCH 5/6] Add 'Open Terminal' command to exec into running dev container New command palette entry "Dev Container: Open Terminal" that: 1. Checks for the devcontainer CLI (shows install popup if missing) 2. Probes the container with `devcontainer exec ... echo` to verify it's running 3. Opens a Fresh terminal split and sends `devcontainer exec` to get an interactive shell inside the container This completes the host-side workflow: detect -> rebuild -> open terminal. https://claude.ai/code/session_01H3A1ru38B68gxZpbt7cSJK --- .../plugins/devcontainer.i18n.json | 32 +++++++++++++++++++ crates/fresh-editor/plugins/devcontainer.ts | 32 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/crates/fresh-editor/plugins/devcontainer.i18n.json b/crates/fresh-editor/plugins/devcontainer.i18n.json index f02048c30..56513a722 100644 --- a/crates/fresh-editor/plugins/devcontainer.i18n.json +++ b/crates/fresh-editor/plugins/devcontainer.i18n.json @@ -12,6 +12,8 @@ "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", @@ -30,6 +32,8 @@ "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.", @@ -70,6 +74,8 @@ "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", @@ -88,6 +94,8 @@ "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.", @@ -128,6 +136,8 @@ "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", @@ -146,6 +156,8 @@ "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.", @@ -186,6 +198,8 @@ "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", @@ -204,6 +218,8 @@ "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.", @@ -244,6 +260,8 @@ "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", @@ -262,6 +280,8 @@ "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.", @@ -302,6 +322,8 @@ "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が見つかりません", @@ -320,6 +342,8 @@ "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が必要です。インストールコマンドをコピーするか、閉じてください。", @@ -360,6 +384,8 @@ "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을 찾을 수 없습니다", @@ -378,6 +404,8 @@ "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가 필요합니다. 설치 명령을 복사하거나 닫으세요.", @@ -418,6 +446,8 @@ "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", @@ -436,6 +466,8 @@ "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。复制下面的安装命令或关闭。", diff --git a/crates/fresh-editor/plugins/devcontainer.ts b/crates/fresh-editor/plugins/devcontainer.ts index 1c7a6ffc8..abc036872 100644 --- a/crates/fresh-editor/plugins/devcontainer.ts +++ b/crates/fresh-editor/plugins/devcontainer.ts @@ -738,6 +738,32 @@ globalThis.devcontainer_rebuild = async function (): Promise { } }; +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 // ============================================================================= @@ -786,6 +812,12 @@ function registerCommands(): void { "devcontainer_rebuild", null, ); + editor.registerCommand( + "%cmd.open_terminal", + "%cmd.open_terminal_desc", + "devcontainer_open_terminal", + null, + ); } // ============================================================================= From 50c2d5ee090200faeb75253dc4ae4e85ecea2ced Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Feb 2026 18:52:29 +0000 Subject: [PATCH 6/6] Add button-based UI with Tab cycling, Alt+key shortcuts, and streaming rebuild Info panel now has interactive buttons (Run Lifecycle, Open Config, Rebuild, Close) rendered with [bracket] focus indicators. Tab/Shift-Tab cycles between buttons, Enter activates the focused button. Alt+r/Alt+o/Alt+b provide direct keyboard shortcuts. Rebuild command now opens a terminal split streaming live build output instead of blocking silently. https://claude.ai/code/session_01H3A1ru38B68gxZpbt7cSJK --- .../plugins/devcontainer.i18n.json | 16 +- crates/fresh-editor/plugins/devcontainer.ts | 150 ++++++++++++++++-- 2 files changed, 141 insertions(+), 25 deletions(-) diff --git a/crates/fresh-editor/plugins/devcontainer.i18n.json b/crates/fresh-editor/plugins/devcontainer.i18n.json index 56513a722..c12cdd62d 100644 --- a/crates/fresh-editor/plugins/devcontainer.i18n.json +++ b/crates/fresh-editor/plugins/devcontainer.i18n.json @@ -59,7 +59,7 @@ "panel.section_users": "Users", "panel.section_lifecycle": "Lifecycle Commands", "panel.section_host_req": "Host Requirements", - "panel.footer": "r: run lifecycle | o: open config | q: close" + "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", @@ -121,7 +121,7 @@ "panel.section_users": "Uzivatele", "panel.section_lifecycle": "Lifecycle prikazy", "panel.section_host_req": "Pozadavky na hostitele", - "panel.footer": "r: spustit lifecycle | o: otevrit konfiguraci | q: zavrit" + "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", @@ -183,7 +183,7 @@ "panel.section_users": "Benutzer", "panel.section_lifecycle": "Lifecycle-Befehle", "panel.section_host_req": "Hostanforderungen", - "panel.footer": "r: Lifecycle ausfuehren | o: Konfiguration oeffnen | q: Schliessen" + "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", @@ -245,7 +245,7 @@ "panel.section_users": "Usuarios", "panel.section_lifecycle": "Comandos Lifecycle", "panel.section_host_req": "Requisitos del Host", - "panel.footer": "r: ejecutar lifecycle | o: abrir config | q: cerrar" + "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", @@ -307,7 +307,7 @@ "panel.section_users": "Utilisateurs", "panel.section_lifecycle": "Commandes Lifecycle", "panel.section_host_req": "Exigences de l'hote", - "panel.footer": "r: executer lifecycle | o: ouvrir config | q: fermer" + "panel.footer": "Tab: cycler Enter: activer Alt+r: lifecycle Alt+o: ouvrir Alt+b: reconstruire q: fermer" }, "ja": { "cmd.show_info": "Dev Container: 情報を表示", @@ -369,7 +369,7 @@ "panel.section_users": "ユーザー", "panel.section_lifecycle": "ライフサイクルコマンド", "panel.section_host_req": "ホスト要件", - "panel.footer": "r: ライフサイクル実行 | o: 設定を開く | q: 閉じる" + "panel.footer": "Tab: 切替 Enter: 実行 Alt+r: ライフサイクル Alt+o: 設定 Alt+b: リビルド q: 閉じる" }, "ko": { "cmd.show_info": "Dev Container: 정보 표시", @@ -431,7 +431,7 @@ "panel.section_users": "사용자", "panel.section_lifecycle": "라이프사이클 명령", "panel.section_host_req": "호스트 요구사항", - "panel.footer": "r: 라이프사이클 실행 | o: 설정 열기 | q: 닫기" + "panel.footer": "Tab: 전환 Enter: 실행 Alt+r: 라이프사이클 Alt+o: 설정 Alt+b: 재빌드 q: 닫기" }, "zh-CN": { "cmd.show_info": "Dev Container: 显示信息", @@ -493,6 +493,6 @@ "panel.section_users": "用户", "panel.section_lifecycle": "生命周期命令", "panel.section_host_req": "主机要求", - "panel.footer": "r: 运行生命周期 | o: 打开配置 | q: 关闭" + "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 index abc036872..9b34964f1 100644 --- a/crates/fresh-editor/plugins/devcontainer.ts +++ b/crates/fresh-editor/plugins/devcontainer.ts @@ -138,6 +138,24 @@ 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 // ============================================================================= @@ -149,6 +167,9 @@ const colors = { 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], }; // ============================================================================= @@ -379,7 +400,30 @@ function buildInfoEntries(): TextPropertyEntry[] { entries.push({ text: "\n", properties: { type: "blank" } }); } - // Footer + // 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" }, @@ -451,7 +495,13 @@ function applyInfoHighlighting(): void { }); } } - // Footer + // 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, @@ -461,6 +511,49 @@ function applyInfoHighlighting(): void { 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(); } // ============================================================================= @@ -471,14 +564,42 @@ editor.defineMode( "devcontainer-info", "normal", [ - ["r", "devcontainer_run_lifecycle"], - ["o", "devcontainer_open_config"], + ["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 // ============================================================================= @@ -491,13 +612,11 @@ globalThis.devcontainer_show_info = async function (): Promise { if (infoPanelOpen && infoPanelBufferId !== null) { // Already open - refresh content - const entries = buildInfoEntries(); - cachedContent = entriesToContent(entries); - editor.setVirtualBufferContent(infoPanelBufferId, entries); - applyInfoHighlighting(); + updateInfoPanel(); return; } + infoFocus = { type: "button", index: 0 }; const entries = buildInfoEntries(); cachedContent = entriesToContent(entries); @@ -726,16 +845,13 @@ globalThis.devcontainer_rebuild = async function (): Promise { 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")); - const rebuild = await editor.spawnProcess( - "devcontainer", - ["up", "--remove-existing-container", "--workspace-folder", editor.getCwd()], - ); - if (rebuild.exit_code === 0) { - editor.setStatus(editor.t("status.rebuild_done")); - } else { - editor.setStatus(editor.t("status.rebuild_failed", { error: rebuild.stderr })); - } }; globalThis.devcontainer_open_terminal = async function (): Promise {