diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..57fe0d95 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +# Dockerfile.preview build context exclusions +# Heroku builds from git, but these further reduce context size + +# Version control +.git +.github + +# Development environments +.venv +.pytest_cache +.specify +.claude +.vscode + +# Local node_modules (rebuilt inside Docker) +node_modules +**/node_modules + +# Build artifacts (rebuilt inside Docker) +**/dist +**/*.vsix + +# Documentation not needed at runtime +docs +specs +*.md +!preview/workspace/WELCOME.md + +# Test files +**/__tests__ +**/*.test.* +**/*.spec.* +**/playwright-report +**/test-results diff --git a/.specify/099-browser-extension-preview/evidence/code-server-screenshot.png b/.specify/099-browser-extension-preview/evidence/code-server-screenshot.png new file mode 100644 index 00000000..6b540b2e Binary files /dev/null and b/.specify/099-browser-extension-preview/evidence/code-server-screenshot.png differ diff --git a/Dockerfile.preview b/Dockerfile.preview new file mode 100644 index 00000000..6796f09e --- /dev/null +++ b/Dockerfile.preview @@ -0,0 +1,90 @@ +# Browser-based VS Code Extension Preview +# Runs code-server with the Debrief extension and sample data pre-installed. +# +# Build: docker build -t debrief-preview -f Dockerfile.preview . +# Run: docker run -p 8080:8080 -e PORT=8080 debrief-preview +# +# Heroku Review Apps: heroku.yml points here. Heroku sets $PORT automatically. + +# --------------------------------------------------------------------------- +# Stage 1: Build the VS Code extension .vsix from source +# --------------------------------------------------------------------------- +FROM node:20-slim AS vsix-builder + +RUN npm install -g pnpm@9 + +WORKDIR /build + +# Copy workspace config and all source upfront +# (Heroku has no Docker layer cache, so separate COPY-for-cache is pointless) +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY shared/schemas/ ./shared/schemas/ +COPY shared/components/ ./shared/components/ +COPY shared/utils/ ./shared/utils/ +COPY shared/config-ts/package.json ./shared/config-ts/ +COPY services/session-state/ ./services/session-state/ +COPY apps/vscode/ ./apps/vscode/ +COPY apps/loader/package.json ./apps/loader/ +COPY apps/web-shell/package.json ./apps/web-shell/ + +# Install, build, package, then delete node_modules — all in ONE layer. +# Docker commits layer diffs: creating + deleting node_modules in the same +# RUN means Docker never snapshots the ~500MB node_modules directory. +# This prevents OOM on Heroku's constrained build dynos. +RUN pnpm install --filter debrief-vscode... --no-frozen-lockfile && \ + pnpm --filter debrief-vscode... build && \ + cd apps/vscode && npx @vscode/vsce package --no-dependencies && \ + cd /build && \ + rm -rf node_modules + +# --------------------------------------------------------------------------- +# Stage 2: Final image with code-server + Python services + extension +# --------------------------------------------------------------------------- +FROM codercom/code-server:latest + +USER root + +# Install Python 3.11 and system dependencies for Debrief services +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3.11 \ + python3.11-venv \ + python3-pip \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Install uv for Python package management +RUN curl -LsSf https://astral.sh/uv/install.sh | sh +ENV PATH="/root/.local/bin:$PATH" + +# Copy Python service sources +COPY services/io /workspace/services/io +COPY services/stac /workspace/services/stac +COPY services/calc /workspace/services/calc +COPY shared/schemas /workspace/shared/schemas + +# Install Python services in a virtual environment +WORKDIR /workspace +RUN uv venv /opt/debrief-venv && \ + VIRTUAL_ENV=/opt/debrief-venv uv pip install \ + ./shared/schemas \ + ./services/io \ + ./services/stac \ + ./services/calc +ENV PATH="/opt/debrief-venv/bin:$PATH" + +# Copy preview workspace with sample data +COPY preview/workspace /workspace/preview +COPY preview/entrypoint.sh /opt/entrypoint.sh +RUN chmod +x /opt/entrypoint.sh + +# Keep the .vsix in the image — the entrypoint installs it at runtime. +# code-server's --install-extension at build time writes to extensions.json, +# but this manifest can be lost at runtime (known issue: coder/code-server#7326). +# Installing at container startup guarantees the extensions.json is fresh. +USER coder +COPY --chown=coder:coder --from=vsix-builder /build/apps/vscode/*.vsix /opt/debrief.vsix + +ENV PORT=8080 +EXPOSE 8080 + +ENTRYPOINT ["/opt/entrypoint.sh"] diff --git a/Taskfile.yml b/Taskfile.yml index 2a7dc648..0658207e 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -93,6 +93,28 @@ tasks: - task: lint - task: test + preview:build: + desc: "Build the preview container (code-server + Debrief extension)" + preconditions: + - sh: command -v docker + msg: "Docker not found. Install Docker to build the preview container." + cmds: + - docker build -t debrief-preview -f Dockerfile.preview . + + preview:run: + desc: "Run the preview container locally (http://localhost:8080)" + preconditions: + - sh: docker image inspect debrief-preview >/dev/null 2>&1 + msg: "Preview image not built. Run 'task preview:build' first." + cmds: + - docker run --rm -p 8080:8080 -e PORT=8080 debrief-preview + + preview:package: + desc: "Package the VS Code extension as .vsix" + deps: [build] + cmds: + - cd apps/vscode && npx vsce package --no-dependencies + clean: desc: "Remove build artifacts and caches" cmds: diff --git a/app.json b/app.json new file mode 100644 index 00000000..369750ce --- /dev/null +++ b/app.json @@ -0,0 +1,17 @@ +{ + "name": "debrief-preview", + "description": "Browser-based VS Code extension preview for Debrief PRs", + "stack": "container", + "formation": { + "web": { + "quantity": 1, + "size": "basic" + } + }, + "environments": { + "review": { + "scripts": {}, + "addons": [] + } + } +} diff --git a/apps/vscode/.vscodeignore b/apps/vscode/.vscodeignore new file mode 100644 index 00000000..c10e42ff --- /dev/null +++ b/apps/vscode/.vscodeignore @@ -0,0 +1,12 @@ +.vscode/** +src/** +e2e/** +**/*.test.ts +**/*.spec.ts +tsconfig.json +vitest.config.ts +playwright.config.ts +.eslintrc.* +.prettierrc* +CHANGELOG.md +node_modules/** diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 81161fb6..90a01403 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -24,6 +24,7 @@ "directory": "apps/vscode" }, "activationEvents": [ + "onStartupFinished", "onFileSystem:stac", "onView:debrief.stacExplorer", "onCommand:debrief.openPlot" diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index 3c116b52..4f451cd4 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -30,7 +30,8 @@ import { createRestoreActivitiesCommand } from './commands/restoreActivities'; let mapPanel: MapPanel | undefined; export async function activate(context: vscode.ExtensionContext): Promise { - // Extension activation begins + // Diagnostic: log to console so it's visible in browser Developer Tools (F12) + console.log('[Debrief] activate() called'); // Create shared output channel for cross-ecosystem diagnostics (ARCHITECTURE.md) const outputChannel = vscode.window.createOutputChannel('Debrief'); @@ -52,13 +53,31 @@ export async function activate(context: vscode.ExtensionContext): Promise }) ); - // Initialize activity bar service early (before tree providers) - // This hides non-essential activities on first activation - const activityBarService = new ActivityBarService(context); - await activityBarService.applyDefaults(); + // ── Phase 1: Initialize services (with resilience) ───────────────────── + // Services are wrapped in try-catch so a failure in one doesn't prevent + // view providers from registering (Phase 2). This is critical for + // code-server where the filesystem environment may differ from desktop. + + let configService: ConfigService; + try { + configService = new ConfigService(); + } catch (err) { + console.error('[Debrief] ConfigService failed to initialize:', err); + outputChannel.appendLine(`[startup] ConfigService init failed: ${err instanceof Error ? err.message : String(err)}`); + // Create a minimal fallback so extension can still show views + configService = Object.create(ConfigService.prototype) as ConfigService; + Object.assign(configService, { + config: { stores: [], preferences: {} }, + configWatcher: null, + changeListeners: [], + getStores: () => [], + getStore: () => undefined, + getRecentPlots: () => [], + onConfigChange: () => () => {}, + dispose: () => {}, + }); + } - // Initialize services - const configService = new ConfigService(); const stacService = new StacService(); const calcService = new CalcService(context, () => mapPanel); const recentPlotsService = new RecentPlotsService(context); @@ -74,6 +93,75 @@ export async function activate(context: vscode.ExtensionContext): Promise calcService.setOutputChannel(outputChannel); ioService.setOutputChannel(outputChannel); + console.log('[Debrief] services initialized'); + outputChannel.appendLine('[startup] services initialized'); + + // ── Phase 2: Register view providers EARLY ───────────────────────────── + // This must happen before any async work that could fail, so the Debrief + // activity bar icons and views always appear in the UI. + + // Initialize ToolMatchAdapter with feature kind lookup (Feature: 038) + const getFeatureKind = (featureId: string): string | undefined => { + const panel = mapPanel; + if (!panel) { + return undefined; + } + return panel.getFeatureKind(featureId); + }; + const toolMatchAdapter = new ToolMatchAdapter([], getFeatureKind); + + const stacTreeProvider = new StacTreeProvider(configService, stacService); + const toolsTreeProvider = new ToolsTreeProvider(calcService, toolMatchAdapter); + const layersTreeProvider = new LayersTreeProvider(sessionManager); + const outlineProvider = new OutlineProvider(); + const timeRangeProvider = new TimeRangeViewProvider(context.extensionUri, sessionManager); + + const activityPanelProvider = new ActivityPanelViewProvider( + context.extensionUri, + sessionManager, + toolMatchAdapter, + calcService + ); + + const logPanelProvider = new LogPanelViewProvider( + context.extensionUri, + context, + sessionManager + ); + logPanelProvider.setResultIdRegistry(resultIdRegistry); + + // Register all view providers — this is what makes views appear in the UI + context.subscriptions.push( + vscode.window.registerTreeDataProvider('debrief.stacExplorer', stacTreeProvider), + vscode.window.registerWebviewViewProvider('debrief.activityPanel', activityPanelProvider), + vscode.window.registerWebviewViewProvider('debrief.logPanel', logPanelProvider) + ); + + // Register outline provider for selection + context.subscriptions.push( + vscode.languages.registerDocumentSymbolProvider( + { scheme: 'stac' }, + outlineProvider + ) + ); + + console.log('[Debrief] view providers registered'); + outputChannel.appendLine('[startup] view providers registered'); + + // ── Phase 3: Activity bar, context, filesystem, commands ─────────────── + + // Initialize activity bar service (shows one-time prompt) + try { + const activityBarService = new ActivityBarService(context); + await activityBarService.applyDefaults(); + + // Register activity bar restore command + context.subscriptions.push(createRestoreActivitiesCommand(activityBarService)); + } catch (err) { + console.error('[Debrief] ActivityBarService failed:', err); + outputChannel.appendLine(`[startup] ActivityBarService failed: ${err instanceof Error ? err.message : String(err)}`); + } + // Configure MCP server port from settings (Feature: 029 - Phase 5) const mcpConfig = vscode.workspace.getConfiguration('debrief'); const mcpPort = mcpConfig.get('mcp.port', 3001); @@ -137,19 +225,6 @@ export async function activate(context: vscode.ExtensionContext): Promise }) ); - // Initialize ToolMatchAdapter with feature kind lookup (Feature: 038) - // This function looks up the 'kind' property of features from the map panel - const getFeatureKind = (featureId: string): string | undefined => { - const panel = mapPanel; - if (!panel) { - return undefined; - } - return panel.getFeatureKind(featureId); - }; - - // Create ToolMatchAdapter - tools will be loaded when calcService connects - const toolMatchAdapter = new ToolMatchAdapter([], getFeatureKind); - // Set noStores context — positive flag so welcome is hidden before activation // (undefined = falsy = welcome hidden; true = no stores, show welcome) const updateNoStores = (): void => { @@ -167,43 +242,6 @@ export async function activate(context: vscode.ExtensionContext): Promise await vscode.commands.executeCommand('setContext', 'debrief.storesReady', true); configService.onConfigChange(() => updateNoStores()); - // Register tree providers - const stacTreeProvider = new StacTreeProvider(configService, stacService); - const toolsTreeProvider = new ToolsTreeProvider(calcService, toolMatchAdapter); - const layersTreeProvider = new LayersTreeProvider(sessionManager); - const outlineProvider = new OutlineProvider(); - const timeRangeProvider = new TimeRangeViewProvider(context.extensionUri, sessionManager); - - // Register unified activity panel (Feature: 047) - const activityPanelProvider = new ActivityPanelViewProvider( - context.extensionUri, - sessionManager, - toolMatchAdapter, - calcService - ); - - // Register Log Panel (Feature: 072-log-panel) - const logPanelProvider = new LogPanelViewProvider( - context.extensionUri, - context, - sessionManager - ); - logPanelProvider.setResultIdRegistry(resultIdRegistry); - - context.subscriptions.push( - vscode.window.registerTreeDataProvider('debrief.stacExplorer', stacTreeProvider), - vscode.window.registerWebviewViewProvider('debrief.activityPanel', activityPanelProvider), - vscode.window.registerWebviewViewProvider('debrief.logPanel', logPanelProvider) - ); - - // Register outline provider for selection - context.subscriptions.push( - vscode.languages.registerDocumentSymbolProvider( - { scheme: 'stac' }, - outlineProvider - ) - ); - // Track selection subscription for cleanup when session changes (Feature: 038) let selectionUnsubscribe: (() => void) | undefined; @@ -276,9 +314,6 @@ export async function activate(context: vscode.ExtensionContext): Promise ); context.subscriptions.push(...commands); - // Register activity bar restore command - context.subscriptions.push(createRestoreActivitiesCommand(activityBarService)); - // Set initial context await vscode.commands.executeCommand('setContext', 'debrief.plotOpen', false); await vscode.commands.executeCommand('setContext', 'debrief.mapFocused', false); @@ -289,6 +324,8 @@ export async function activate(context: vscode.ExtensionContext): Promise // Restore previously-open plots (Feature: 052) void openPlotsService.restoreOpenPlots(); + // ── Phase 4: Background Python service checks ────────────────────────── + // Check Python service availability and update status indicator pythonStatus.text = '$(sync~spin) Python'; pythonStatus.tooltip = 'Checking Python services…'; @@ -345,7 +382,8 @@ export async function activate(context: vscode.ExtensionContext): Promise outputChannel.appendLine(`[startup] debrief-calc: check failed — ${err instanceof Error ? err.message : String(err)}`); }); - // Extension activation complete + console.log('[Debrief] activation complete'); + outputChannel.appendLine('[startup] activation complete'); } /** diff --git a/apps/vscode/src/panels/catalogOverviewPanel.ts b/apps/vscode/src/panels/catalogOverviewPanel.ts index cfe9c02c..edabb21c 100644 --- a/apps/vscode/src/panels/catalogOverviewPanel.ts +++ b/apps/vscode/src/panels/catalogOverviewPanel.ts @@ -234,13 +234,14 @@ export class CatalogOverviewPanel { ); const cspSource = webview.cspSource; + const nonce = getNonce(); return ` - + Catalog Overview