From 835f509e17dd881d5875aa894b67ec886f7ac954 Mon Sep 17 00:00:00 2001 From: Megan Mott Date: Tue, 19 May 2026 15:45:27 -0700 Subject: [PATCH 1/4] add in changes --- .../DeploymentPlanViewController.ts | 13 +- .../controllers/LocalPlanViewController.ts | 45 +- .../extension/openDeploymentPlanView.ts | 33 +- .../views/DeploymentPlanView.tsx | 25 +- .../copilotOnRails/views/LocalPlanView.tsx | 400 ++++++++++++++++-- .../views/styles/localPlanView.scss | 72 ++++ .../utils/parseDeploymentPlanMarkdown.ts | 84 +++- .../views/utils/parseLocalPlanMarkdown.ts | 7 + 8 files changed, 616 insertions(+), 63 deletions(-) diff --git a/src/webviews/copilotOnRails/extension/controllers/DeploymentPlanViewController.ts b/src/webviews/copilotOnRails/extension/controllers/DeploymentPlanViewController.ts index 94163aaf..fcc8d5a5 100644 --- a/src/webviews/copilotOnRails/extension/controllers/DeploymentPlanViewController.ts +++ b/src/webviews/copilotOnRails/extension/controllers/DeploymentPlanViewController.ts @@ -14,17 +14,21 @@ import { getCopilotOnRailsBundleLocation } from "../copilotOnRailsBundleLocation export type { DeploymentPlanViewConfiguration, DeploymentPlanViewStrings }; export class DeploymentPlanViewController extends WebviewController { + private latestPlanData: DeploymentPlanData; private sourceFileUri: vscode.Uri | undefined; constructor(planData: DeploymentPlanData, strings: DeploymentPlanViewStrings, sourceFileUri?: vscode.Uri) { super(ext.context, strings.title, 'deploymentPlanView', { strings }, ViewColumn.Active, undefined, getCopilotOnRailsBundleLocation()); + this.latestPlanData = planData; this.sourceFileUri = sourceFileUri; + void this.postDeploymentPlanData(); + this.panel.webview.onDidReceiveMessage((message: { command: string; data?: unknown; prompt?: string }) => { switch (message.command) { case 'ready': - void this.panel.webview.postMessage({ command: 'setDeploymentPlanData', data: planData }); + void this.postDeploymentPlanData(); break; case 'approve': this.panel.dispose(); @@ -49,13 +53,18 @@ export class DeploymentPlanViewController extends WebviewController { + await this.panel.webview.postMessage({ command: 'setDeploymentPlanData', data: this.latestPlanData }); + } + private openSourceFile(): void { if (!this.sourceFileUri) { void vscode.window.showWarningMessage( diff --git a/src/webviews/copilotOnRails/extension/controllers/LocalPlanViewController.ts b/src/webviews/copilotOnRails/extension/controllers/LocalPlanViewController.ts index 7dc3f0e0..bf6c0f8f 100644 --- a/src/webviews/copilotOnRails/extension/controllers/LocalPlanViewController.ts +++ b/src/webviews/copilotOnRails/extension/controllers/LocalPlanViewController.ts @@ -18,7 +18,7 @@ export class LocalPlanViewController extends WebviewController { + this.panel.webview.onDidReceiveMessage((message: { command: string; data?: LocalPlanData; prompt?: string; originalCode?: string; newCode?: string; language?: string }) => { switch (message.command) { case 'ready': void this.panel.webview.postMessage({ command: 'setLocalPlanData', data: planData }); @@ -46,6 +46,9 @@ export class LocalPlanViewController extends WebviewController { + if (typeof originalCode !== 'string' || typeof newCode !== 'string') { + void this.panel.webview.postMessage({ command: 'codeBlockUpdateError', error: vscode.l10n.t('Invalid edit payload.') }); + return; + } + if (originalCode === newCode) { + return; + } + if (!this.sourceFileUri) { + void this.panel.webview.postMessage({ command: 'codeBlockUpdateError', error: vscode.l10n.t('The plan file location is unknown, so the change could not be saved.') }); + return; + } + + try { + const raw = Buffer.from(await vscode.workspace.fs.readFile(this.sourceFileUri)).toString('utf-8'); + const usesCRLF = raw.includes('\r\n'); + const normalized = raw.replace(/\r\n/g, '\n'); + + const firstIdx = normalized.indexOf(originalCode); + if (firstIdx === -1) { + void this.panel.webview.postMessage({ command: 'codeBlockUpdateError', error: vscode.l10n.t("Couldn't locate the original block in the plan file. It may have changed.") }); + return; + } + if (normalized.indexOf(originalCode, firstIdx + 1) !== -1) { + void this.panel.webview.postMessage({ command: 'codeBlockUpdateError', error: vscode.l10n.t('The original block appears more than once in the plan file. Edit the file directly to resolve the ambiguity.') }); + return; + } + + const updated = normalized.slice(0, firstIdx) + newCode + normalized.slice(firstIdx + originalCode.length); + const finalContent = usesCRLF ? updated.replace(/\n/g, '\r\n') : updated; + await vscode.workspace.fs.writeFile(this.sourceFileUri, Buffer.from(finalContent, 'utf-8')); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + void this.panel.webview.postMessage({ + command: 'codeBlockUpdateError', + error: vscode.l10n.t('Saving the change failed: {0}', errorMessage), + }); + } + } } diff --git a/src/webviews/copilotOnRails/extension/openDeploymentPlanView.ts b/src/webviews/copilotOnRails/extension/openDeploymentPlanView.ts index b2ff5a34..8ef89208 100644 --- a/src/webviews/copilotOnRails/extension/openDeploymentPlanView.ts +++ b/src/webviews/copilotOnRails/extension/openDeploymentPlanView.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from "vscode"; +import { ext } from "../../../extensionVariables"; import type { DeploymentPlanData } from "../views/utils/deploymentPlanTypes"; import { parseDeploymentPlanMarkdown } from "../views/utils/parseDeploymentPlanMarkdown"; import { DeploymentPlanViewController, type DeploymentPlanViewStrings } from "./controllers/DeploymentPlanViewController"; @@ -42,8 +43,8 @@ function getDeploymentPlanViewStrings(): DeploymentPlanViewStrings { cancelButton: vscode.l10n.t('Cancel'), submitEditsButton: vscode.l10n.t('Submit'), noDiagramAvailable: vscode.l10n.t('No diagram available'), - parseFailureTitle: vscode.l10n.t("We couldn't render this plan"), - parseFailureFallbackMessage: vscode.l10n.t("The deployment plan couldn't be rendered as a structured view. The generated markdown didn't match the expected layout."), + parseFailureTitle: vscode.l10n.t('We couldn\u2019t render this plan'), + parseFailureFallbackMessage: vscode.l10n.t('The deployment plan couldn\u2019t be rendered as a structured view. The generated markdown didn\u2019t match the expected layout.'), parseFailureFileLabel: vscode.l10n.t('Plan file'), openPlanFileButton: vscode.l10n.t('Open plan file'), }; @@ -58,7 +59,18 @@ export function openDeploymentPlanView(uri: vscode.Uri): void { } export function openDeploymentPlanViewWithContent(content: string, sourceFileUri?: vscode.Uri): void { + void openDeploymentPlanViewWithContentAsync(content, sourceFileUri); +} + +async function openDeploymentPlanViewWithContentAsync(content: string, sourceFileUri?: vscode.Uri): Promise { const planData = tryParseDeploymentPlan(content, sourceFileUri); + const liveSubscriptions = await getAvailableAzureSubscriptions(); + if (liveSubscriptions) { + planData.availableSubscriptions = liveSubscriptions; + if (planData.subscription && !liveSubscriptions.includes(planData.subscription)) { + planData.subscription = ''; + } + } if (currentDeploymentPlanViewController) { currentDeploymentPlanViewController.updateDeploymentPlanData(planData, sourceFileUri); @@ -75,6 +87,19 @@ export function openDeploymentPlanViewWithContent(content: string, sourceFileUri }); } +async function getAvailableAzureSubscriptions(): Promise { + try { + const provider = await ext.subscriptionProviderFactory(); + const subs = await provider.getAvailableSubscriptions({ filter: false }); + if (subs.length === 0) { + return undefined; + } + return Array.from(new Set(subs.map(s => s.name))).sort((a, b) => a.localeCompare(b)); + } catch { + return undefined; + } +} + function tryParseDeploymentPlan(content: string, sourceFileUri: vscode.Uri | undefined): DeploymentPlanData { let parsed: DeploymentPlanData | undefined; let errorMessage: string | undefined; @@ -103,7 +128,7 @@ function tryParseDeploymentPlan(content: string, sourceFileUri: vscode.Uri | und decisions: parsed?.decisions ?? { headers: [], rows: [] }, resources: parsed?.resources ?? { headers: [], rows: [] }, parseError: { - message: errorMessage ?? vscode.l10n.t("The deployment plan couldn't be rendered as a structured view. The generated markdown didn't match the expected layout."), + message: errorMessage ?? vscode.l10n.t('The deployment plan couldn\u2019t be rendered as a structured view. The generated markdown didn\u2019t match the expected layout.'), fileLabel: sourceFileUri ? vscode.workspace.asRelativePath(sourceFileUri) : undefined, }, }; @@ -112,7 +137,7 @@ function tryParseDeploymentPlan(content: string, sourceFileUri: vscode.Uri | und } export async function openDeploymentPlanViewFromWorkspace(): Promise { - const files = await vscode.workspace.findFiles('**/.azure/plan.md', '**/node_modules/**', 10); + const files = await vscode.workspace.findFiles('**/.azure/deployment-plan.md', '**/node_modules/**', 10); if (files.length === 0) { void vscode.window.showInformationMessage(vscode.l10n.t('No deployment plan markdown files found in the workspace.')); return; diff --git a/src/webviews/copilotOnRails/views/DeploymentPlanView.tsx b/src/webviews/copilotOnRails/views/DeploymentPlanView.tsx index e485f1c3..bc6e4f67 100644 --- a/src/webviews/copilotOnRails/views/DeploymentPlanView.tsx +++ b/src/webviews/copilotOnRails/views/DeploymentPlanView.tsx @@ -374,21 +374,6 @@ export const DeploymentPlanView = (): JSX.Element => { -
-

{strings.architectureDiagramHeading}

- -
- -
-

{strings.workspaceScanHeading}

- -
- -
-

{strings.decisionsHeading}

- -
-

{strings.azureResourcesHeading}

{ onSkuChange={handleResourceSkuChange} />
+ +
+

{strings.architectureDiagramHeading}

+ +
+ +
+

{strings.workspaceScanHeading}

+ +
{drawerOpen && !isAwaitingRevision && ( diff --git a/src/webviews/copilotOnRails/views/LocalPlanView.tsx b/src/webviews/copilotOnRails/views/LocalPlanView.tsx index 1294ef39..3e8ba8c1 100644 --- a/src/webviews/copilotOnRails/views/LocalPlanView.tsx +++ b/src/webviews/copilotOnRails/views/LocalPlanView.tsx @@ -3,11 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Button, CounterBadge, Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle, Spinner, Textarea } from '@fluentui/react-components'; +import { Button, CounterBadge, Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle, Input, Spinner, Textarea } from '@fluentui/react-components'; import { CheckmarkRegular, CommentEditRegular, DismissRegular, DocumentRegular, SendRegular, WarningRegular } from '@fluentui/react-icons'; import { WebviewContext } from '@microsoft/vscode-azext-webview/webview'; +import * as jsoncParser from 'jsonc-parser'; import mermaid from 'mermaid'; -import { useCallback, useContext, useEffect, useMemo, useRef, useState, type JSX } from 'react'; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type JSX } from 'react'; import './styles/localPlanView.scss'; import { type LocalPlanContent, type LocalPlanData, type LocalPlanSection } from './utils/parseLocalPlanMarkdown'; @@ -26,6 +27,29 @@ mermaid.initialize({ let mermaidIdCounter = 0; const alwaysExpandedSections = new Set(['project analysis', 'prerequisites', 'scan results']); +const defaultOpenSections = new Set(['architecture diagram']); +const editableCodeSections = new Set(['launch configuration']); + +interface CodeEditNoteContextValue { + addCodeEditNote: (language: string, oldCode: string, newCode: string) => void; +} +const CodeEditNoteContext = createContext({ + addCodeEditNote: () => { /* no-op */ }, +}); + +function buildCodeEditNote(language: string, _oldCode: string, newCode: string): string { + const langLabel = language?.trim() ? language.trim() : 'code'; + const fence = `\`\`\`${language?.trim() ?? ''}`; + return [ + `I directly edited the ${langLabel} block in the Launch Configuration section. The plan file now contains:`, + '', + fence, + newCode, + '```', + '', + 'Please make sure any generated files (.vscode/launch.json, .vscode/tasks.json, etc.) and any related plan content stay consistent with this updated block.', + ].join('\n'); +} interface FeedbackItem { id: string; @@ -58,6 +82,7 @@ export const LocalPlanView = (): JSX.Element => { const [drawerOpen, setDrawerOpen] = useState(false); const [isAwaitingRevision, setIsAwaitingRevision] = useState(false); const [confirmSubmitOpen, setConfirmSubmitOpen] = useState(false); + const [codeEditError, setCodeEditError] = useState(null); const { vscodeApi } = useContext(WebviewContext); const hasEdits = useMemo( @@ -79,6 +104,8 @@ export const LocalPlanView = (): JSX.Element => { setDrawerOpen(false); } else if (message?.command === 'revisionComplete') { setIsAwaitingRevision(false); + } else if (message?.command === 'codeBlockUpdateError') { + setCodeEditError(typeof message.error === 'string' ? message.error : 'Failed to save changes to the plan file.'); } }; window.addEventListener('message', handler); @@ -101,6 +128,17 @@ export const LocalPlanView = (): JSX.Element => { setFeedbackItems(prev => prev.filter(i => i.id !== id)); }, []); + const addCodeEditNote = useCallback((language: string, oldCode: string, newCode: string) => { + const text = buildCodeEditNote(language, oldCode, newCode); + setFeedbackItems(prev => [...prev, { id: nextId(), text }]); + setDrawerOpen(true); + }, []); + + const codeEditContextValue = useMemo( + () => ({ addCodeEditNote }), + [addCodeEditNote], + ); + const handleAddNote = useCallback(() => { const text = freeformDraft.trim(); if (!text) { @@ -168,9 +206,6 @@ export const LocalPlanView = (): JSX.Element => {
{plan.status && plan.status !== 'Unknown' && {plan.status}}
- {plan.headerNote && ( -

- )}

{drawerOpen && !isAwaitingRevision && ( @@ -246,6 +286,12 @@ export const LocalPlanView = (): JSX.Element => { onCancel={() => setConfirmSubmitOpen(false)} onSubmit={handleSubmitFeedback} /> + + setCodeEditError(null)} + /> ); }; @@ -367,8 +413,8 @@ const SubmitEditsDialog = ({ open, editCount, onCancel, onSubmit }: SubmitEditsD ); -const SectionCard = ({ section, collapsible }: { section: LocalPlanSection; collapsible: boolean }): JSX.Element => { - const [open, setOpen] = useState(!collapsible); +const SectionCard = ({ section, collapsible, defaultOpen, codeEditable }: { section: LocalPlanSection; collapsible: boolean; defaultOpen: boolean; codeEditable: boolean }): JSX.Element => { + const [open, setOpen] = useState(defaultOpen); return (
@@ -382,7 +428,7 @@ const SectionCard = ({ section, collapsible }: { section: LocalPlanSection; coll {open && (
{section.content.map((item, i) => ( - + ))}
)} @@ -392,10 +438,45 @@ const SectionCard = ({ section, collapsible }: { section: LocalPlanSection; coll function isHiddenSection(title: string): boolean { const lower = title.toLowerCase(); - return lower === 'execution checklist' || lower === 'manual tests'; + return lower === 'execution checklist' + || lower === 'manual tests' + || lower === 'table of contents' + || lower === 'debug configuration checklist' + || lower === 'limited support' + || lower === 'convenience scripts' + || lower === 'api test collections' + || lower === 'migrations'; } -const ContentBlock = ({ item }: { item: LocalPlanContent }): JSX.Element | null => { +function isHiddenSubsection(title: string): boolean { + const lower = title.toLowerCase(); + return lower === 'task configuration' + || lower === 'build configuration' + || lower === 'task/build configuration' + || lower === 'task / build configuration' + || lower === 'tasks configuration' + || lower === 'build task' + || lower === 'build tasks'; +} + +function isFlattenedSubsection(title: string): boolean { + const lower = title.toLowerCase(); + return lower === 'debug configuration' + || lower === 'launch configuration' + || lower === 'launch configurations' + || lower === 'debug/launch configuration' + || lower === 'debug / launch configuration' + || lower === 'debug configurations'; +} + +function sectionSortOrder(title: string): number { + const lower = title.toLowerCase(); + if (lower === 'prerequisites') { return 0; } + if (lower === 'architecture diagram') { return 1; } + return 2; +} + +const ContentBlock = ({ item, codeEditable }: { item: LocalPlanContent; codeEditable: boolean }): JSX.Element | null => { switch (item.type) { case 'table': return ; @@ -403,6 +484,9 @@ const ContentBlock = ({ item }: { item: LocalPlanContent }): JSX.Element | null if (item.language?.toLowerCase() === 'mermaid') { return ; } + if (codeEditable) { + return ; + } return ; case 'bulletList': return ; @@ -411,7 +495,17 @@ const ContentBlock = ({ item }: { item: LocalPlanContent }): JSX.Element | null case 'paragraph': return

; case 'subsection': - return ; + if (isHiddenSubsection(item.title)) { return null; } + if (isFlattenedSubsection(item.title)) { + return ( + <> + {item.content.map((child, i) => ( + + ))} + + ); + } + return ; } }; @@ -435,6 +529,262 @@ const CodeBlock = ({ language, code }: { language: string; code: string }): JSX.

); +const EditableCodeBlock = ({ language, code }: { language: string; code: string }): JSX.Element => { + const parsed = useMemo(() => safeParseJson(code), [code]); + const entries = useMemo(() => extractLaunchEntries(parsed), [parsed]); + + if (entries.length === 0) { + return ; + } + + return ( +
+
    + {entries.map((entry) => ( + + ))} +
+
+ ); +}; + +interface LaunchEntry { + kind: 'configuration' | 'compound' | 'task'; + index: number; + raw: Record; +} + +function safeParseJson(code: string): unknown { + try { + return JSON.parse(code); + } catch { + try { + const errors: jsoncParser.ParseError[] = []; + const result = jsoncParser.parse(code, errors, { allowTrailingComma: true, disallowComments: false }); + if (errors.length > 0) { + return undefined; + } + return result; + } catch { + return undefined; + } + } +} + +function extractLaunchEntries(parsed: unknown): LaunchEntry[] { + if (!parsed || typeof parsed !== 'object') { return []; } + const obj = parsed as Record; + const entries: LaunchEntry[] = []; + if (Array.isArray(obj.configurations)) { + (obj.configurations as unknown[]).forEach((raw, index) => { + if (raw && typeof raw === 'object') { + entries.push({ kind: 'configuration', index, raw: raw as Record }); + } + }); + } + if (Array.isArray(obj.compounds)) { + (obj.compounds as unknown[]).forEach((raw, index) => { + if (raw && typeof raw === 'object') { + entries.push({ kind: 'compound', index, raw: raw as Record }); + } + }); + } + if (entries.length === 0 && Array.isArray(obj.tasks)) { + (obj.tasks as unknown[]).forEach((raw, index) => { + if (raw && typeof raw === 'object') { + entries.push({ kind: 'task', index, raw: raw as Record }); + } + }); + } + return entries; +} + +const LaunchConfigItem = ({ entry, originalCode, language }: { entry: LaunchEntry; originalCode: string; language: string }): JSX.Element => { + const { vscodeApi } = useContext(WebviewContext); + const { addCodeEditNote } = useContext(CodeEditNoteContext); + + const nameKey = entry.kind === 'task' ? 'label' : 'name'; + const arrayKey = entry.kind === 'task' ? 'tasks' : entry.kind === 'compound' ? 'compounds' : 'configurations'; + const currentName = String(entry.raw?.[nameKey] ?? ''); + const description = entry.kind === 'task' + ? describeTask(entry.raw) + : entry.kind === 'compound' + ? describeCompound(entry.raw) + : describeConfig(entry.raw); + + const [draft, setDraft] = useState(currentName); + const focusedRef = useRef(false); + + useEffect(() => { + if (!focusedRef.current) { + setDraft(currentName); + } + }, [currentName]); + + const commit = useCallback((next: string) => { + const trimmed = next.trim(); + if (!trimmed || trimmed === currentName) { + setDraft(currentName); + return; + } + const edits = jsoncParser.modify(originalCode, [arrayKey, entry.index, nameKey], trimmed, { + formattingOptions: { insertSpaces: true, tabSize: 4, eol: '\n' }, + }); + const newCode = jsoncParser.applyEdits(originalCode, edits); + if (!newCode || newCode === originalCode) { + setDraft(currentName); + return; + } + vscodeApi.postMessage({ + command: 'updateCodeBlock', + originalCode, + language, + newCode, + }); + addCodeEditNote(language, originalCode, newCode); + }, [currentName, arrayKey, entry.index, nameKey, originalCode, language, vscodeApi, addCodeEditNote]); + + const inputId = `launchConfigName-${entry.kind}-${entry.index}`; + + return ( +
  • +
    + + setDraft(data.value)} + onFocus={() => { focusedRef.current = true; }} + onBlur={(e) => { + focusedRef.current = false; + commit(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + (e.target as HTMLInputElement).blur(); + } else if (e.key === 'Escape') { + e.preventDefault(); + setDraft(currentName); + (e.target as HTMLInputElement).blur(); + } + }} + className='launchConfigNameInput' + /> +
    +

    {description}

    +
  • + ); +}; + +function describeConfig(c: Record): string { + const type = typeof c.type === 'string' ? c.type.toLowerCase() : ''; + const request = typeof c.request === 'string' ? c.request.toLowerCase() : ''; + const parts: string[] = []; + + if (type === 'chrome' || type === 'msedge' || type === 'pwa-chrome' || type === 'pwa-msedge') { + const browser = type.includes('msedge') ? 'Edge' : 'Chrome'; + const url = typeof c.url === 'string' ? c.url : undefined; + parts.push(url ? `Opens ${browser} at ${url} with the debugger attached.` : `Launches ${browser} with the debugger attached.`); + } else if (type === 'node' || type === 'pwa-node') { + if (request === 'attach') { + parts.push(`Attaches the Node.js debugger${typeof c.port === 'number' ? ` on port ${c.port}` : ''}.`); + } else { + const program = typeof c.program === 'string' ? c.program : ''; + parts.push(`Launches and debugs Node.js${program ? ` (${program})` : ''}.`); + } + } else if (type === 'coreclr' || type === 'clr') { + parts.push('Attaches the .NET debugger to the running process.'); + } else if (type === 'python' || type === 'debugpy') { + parts.push(`Attaches the Python debugger${typeof c.port === 'number' ? ` on port ${c.port}` : ''}.`); + } else if (type === 'java') { + parts.push(`Attaches the Java debugger${typeof c.port === 'number' ? ` on port ${c.port}` : ''}.`); + } else if (type === 'go') { + parts.push('Attaches the Delve (Go) debugger.'); + } else if (type) { + parts.push(`Runs a "${type}" debug session${request ? ` (${request})` : ''}.`); + } else { + parts.push('Debug configuration.'); + } + + if (typeof c.preLaunchTask === 'string' && c.preLaunchTask) { + parts.push(`Triggers task "${c.preLaunchTask}" before launching.`); + } + return parts.join(' '); +} + +function describeTask(t: Record): string { + const parts: string[] = []; + const cmd = typeof t.command === 'string' ? t.command : ''; + const type = typeof t.type === 'string' ? t.type : ''; + + if (cmd.startsWith('docker compose') || cmd.startsWith('docker-compose')) { + parts.push('Starts the local emulators via Docker Compose.'); + } else if (type === 'shell' && cmd) { + const preview = cmd.length > 80 ? `${cmd.slice(0, 77)}\u2026` : cmd; + parts.push(`Shell task: \`${preview}\``); + } else if (type === 'npm' && typeof t.script === 'string') { + parts.push(`Runs npm script: \`${t.script}\`.`); + } else if (type) { + parts.push(`Runs a "${type}" task.`); + } else { + parts.push('Build task.'); + } + + if (Array.isArray(t.dependsOn) && t.dependsOn.length > 0) { + parts.push(`Depends on: ${t.dependsOn.map((d) => `"${String(d)}"`).join(', ')}.`); + } else if (typeof t.dependsOn === 'string' && t.dependsOn) { + parts.push(`Depends on: "${t.dependsOn}".`); + } + return parts.join(' '); +} + +function describeCompound(c: Record): string { + const parts: string[] = ['Compound launch configuration.']; + if (Array.isArray(c.configurations) && c.configurations.length > 0) { + const names = c.configurations + .map((entry) => { + if (typeof entry === 'string') { return entry; } + if (entry && typeof entry === 'object') { + const name = (entry as Record).name; + if (typeof name === 'string') { return name; } + } + return undefined; + }) + .filter((n): n is string => !!n); + if (names.length > 0) { + parts.push(`Launches ${names.map((n) => `"${n}"`).join(', ')} together.`); + } + } + if (typeof c.preLaunchTask === 'string' && c.preLaunchTask) { + parts.push(`Triggers task "${c.preLaunchTask}" before launching.`); + } + if (c.stopAll === true) { + parts.push('Stopping one stops all of them.'); + } + return parts.join(' '); +} + +const CodeEditErrorDialog = ({ open, message, onClose }: { open: boolean; message: string; onClose: () => void }): JSX.Element => ( + { if (!data.open) { onClose(); } }}> + + + Couldn't save changes + {message} + + + + + + +); + const MermaidBlock = ({ code }: { code: string }): JSX.Element => { const ref = useRef(null); const [error, setError] = useState(null); @@ -479,7 +829,7 @@ const BlockquoteBlock = ({ text }: { text: string }): JSX.Element => (
    ); -const SubsectionBlock = ({ title, content }: { title: string; content: LocalPlanContent[] }): JSX.Element => { +const SubsectionBlock = ({ title, content, codeEditable }: { title: string; content: LocalPlanContent[]; codeEditable: boolean }): JSX.Element => { const [open, setOpen] = useState(false); return ( @@ -491,7 +841,7 @@ const SubsectionBlock = ({ title, content }: { title: string; content: LocalPlan {open && (
    {content.map((item, i) => ( - + ))}
    )} diff --git a/src/webviews/copilotOnRails/views/styles/localPlanView.scss b/src/webviews/copilotOnRails/views/styles/localPlanView.scss index 38b1d0d2..be4a6331 100644 --- a/src/webviews/copilotOnRails/views/styles/localPlanView.scss +++ b/src/webviews/copilotOnRails/views/styles/localPlanView.scss @@ -289,6 +289,78 @@ white-space: pre; } } + + &.editableCodeBlock { + .codeBlockEditButton { + position: absolute; + top: 4px; + right: 6px; + z-index: 1; + } + } + } + + .launchConfigSummary { + background: var(--vscode-editor-background); + border: 1px solid var(--vscode-widget-border); + border-radius: 4px; + overflow: hidden; + } + + .launchConfigList { + list-style: none; + margin: 0; + padding: 0; + } + + .launchConfigItem { + padding: 12px 14px; + border-bottom: 1px solid var(--vscode-widget-border); + + &:last-child { + border-bottom: none; + } + + .launchConfigNameRow { + display: flex; + align-items: center; + gap: 10px; + } + + .launchConfigNameLabel { + flex: 0 0 auto; + font-weight: 600; + font-size: 0.9em; + color: var(--vscode-foreground); + } + + .launchConfigNameInput { + flex: 0 1 auto; + width: auto; + min-width: 0; + max-width: 320px; + + input { + font-size: 0.9em; + padding-top: 2px; + padding-bottom: 2px; + } + } + + .launchConfigDescription { + margin: 6px 0 0; + font-size: 0.85em; + color: var(--vscode-descriptionForeground); + line-height: 1.4; + + code { + font-family: var(--vscode-editor-font-family), monospace; + font-size: 0.92em; + padding: 0 4px; + background: var(--vscode-textCodeBlock-background); + border-radius: 3px; + } + } } .mermaidDiagram { diff --git a/src/webviews/copilotOnRails/views/utils/parseDeploymentPlanMarkdown.ts b/src/webviews/copilotOnRails/views/utils/parseDeploymentPlanMarkdown.ts index 2a97fdce..3c437b00 100644 --- a/src/webviews/copilotOnRails/views/utils/parseDeploymentPlanMarkdown.ts +++ b/src/webviews/copilotOnRails/views/utils/parseDeploymentPlanMarkdown.ts @@ -39,23 +39,28 @@ import { type DeploymentPlanData, type DeploymentPlanTable } from "./deploymentP */ export function parseDeploymentPlanMarkdown(markdown: string): DeploymentPlanData { const lines = markdown.replace(/\r\n/g, '\n').split('\n'); + const requirements = extractAttributeValueTable(findSectionByName(extractNamedSections(lines), ['Requirements'])); const status = extractMetadata(lines, 'Status') ?? 'Unknown'; const mode = extractMetadata(lines, 'Mode') ?? 'Unknown'; - const subscription = extractMetadata(lines, 'Subscription') ?? 'Unknown'; - const rawLocation = extractMetadata(lines, 'Location') ?? 'Unknown'; + const subscription = extractMetadata(lines, 'Subscription') ?? requirements['Subscription'] ?? 'Unknown'; + const rawLocation = extractMetadata(lines, 'Location') ?? requirements['Location'] ?? 'Unknown'; // Parse location: "East US (`eastus`)" → name="East US", code="eastus" - const locationMatch = rawLocation.match(/^(.+?)\s*\(`?([^`)]+)`?\)\s*$/); + const locationMatch = rawLocation.match(/^(.+?)\s*\(`?([a-z0-9]+)`?\)\s*$/i); const location = locationMatch ? locationMatch[1].trim() : rawLocation; const locationCode = locationMatch ? locationMatch[2].trim() : extractMetadata(lines, 'LocationCode') ?? 'unknown'; const sections = extractNamedSections(lines); - const mermaidDiagram = extractMermaidBlock(sections['Architecture Diagram'] ?? []); - const workspaceScan = extractTable(sections['Workspace Scan'] ?? []); - const decisions = extractTable(sections['Decisions'] ?? []); - const resources = extractTable(sections['Azure Resources'] ?? []); + // Support alternate section headings for compatibility with user-authored plans + const mermaidDiagram = extractMermaidBlock(findSectionByName(sections, ['Architecture Diagram', 'Architecture'])); + + const workspaceScan = extractTable(findSectionByName(sections, ['Workspace Scan', 'Components Detected'])); + + const decisions = extractTable(findSectionByName(sections, ['Decisions', 'Recipe Selection'])); + + const resources = extractTable(findSectionByName(sections, ['Service Mapping', 'Azure Resources', 'Provisioning Limit Checklist'])); // Provide placeholder dropdown options when values are unknown const availableSubscriptions = subscription === 'Unknown' @@ -92,8 +97,8 @@ export function parseDeploymentPlanMarkdown(markdown: string): DeploymentPlanDat function extractMetadata(lines: string[], key: string): string | undefined { for (const line of lines) { - // Match both **Key**: value and **Key:** value - const match = line.match(new RegExp(`^\\*\\*${key}:?\\*\\*:?\\s*(.+)$`)); + // Match both **Key**: value and **Key:** value, optionally inside a markdown blockquote. + const match = line.match(new RegExp(`^>?\\s*\\*\\*${key}:?\\*\\*:?\\s*(.+)$`)); if (match) { return match[1].trim(); } @@ -101,20 +106,67 @@ function extractMetadata(lines: string[], key: string): string | undefined { return undefined; } +function findSectionByName(sections: Record, names: string[]): string[] { + const normalized = new Map(Object.entries(sections).map(([name, value]) => [normalizeSectionName(name), value])); + for (const name of names) { + const match = normalized.get(normalizeSectionName(name)); + if (match) { + return match; + } + } + return []; +} + +function normalizeSectionName(name: string): string { + return name.replace(/^\d+\.\s+/, '').trim().toLowerCase(); +} + +function extractAttributeValueTable(lines: string[]): Record { + const table = extractTable(lines); + if (table.headers.length < 2) { + return {}; + } + + const values: Record = {}; + for (const row of table.rows) { + const key = row[0]?.trim(); + const value = row[1]?.trim(); + if (key && value) { + values[key] = value; + } + } + return values; +} + function extractNamedSections(lines: string[]): Record { const sections: Record = {}; - let currentTitle: string | undefined; + let currentH2: string | undefined; + let currentH3: string | undefined; for (const line of lines) { - const sectionMatch = line.match(/^##\s+(?:\d+\.\s+)?(.+)$/); - if (sectionMatch) { - currentTitle = sectionMatch[1].trim(); - sections[currentTitle] = []; + const h2Match = line.match(/^##\s+(?:\d+\.\s+)?(.+)$/); + if (h2Match) { + currentH2 = h2Match[1].trim(); + currentH3 = undefined; + sections[currentH2] = []; continue; } - if (currentTitle) { - sections[currentTitle].push(line); + const h3Match = line.match(/^###\s+(?:\d+\.\s+)?(.+)$/); + if (h3Match) { + currentH3 = h3Match[1].trim(); + sections[currentH3] = []; + if (currentH2) { + sections[currentH2].push(line); + } + continue; + } + + if (currentH3) { + sections[currentH3].push(line); + } + if (currentH2) { + sections[currentH2].push(line); } } diff --git a/src/webviews/copilotOnRails/views/utils/parseLocalPlanMarkdown.ts b/src/webviews/copilotOnRails/views/utils/parseLocalPlanMarkdown.ts index d1dcc7b8..f3531212 100644 --- a/src/webviews/copilotOnRails/views/utils/parseLocalPlanMarkdown.ts +++ b/src/webviews/copilotOnRails/views/utils/parseLocalPlanMarkdown.ts @@ -118,6 +118,13 @@ function parseContent(lines: string[], start: number, end: number): LocalPlanCon continue; } + // Skip raw HTML wrappers (e.g.
    /) — they're presentation hints + // for the source markdown, not content the structured view should render. + if (/^<\/?(details|summary)\b/i.test(trimmed)) { + i++; + continue; + } + // Sub-section heading (###) const subMatch = trimmed.match(/^###\s+(.+)$/); if (subMatch) { From 94763433e3607a87eb3ae9e5b92cfcfe3973c530 Mon Sep 17 00:00:00 2001 From: Megan Mott Date: Wed, 20 May 2026 10:42:53 -0700 Subject: [PATCH 2/4] add tooltip and explanation to the feedback --- .../extension/openDeploymentPlanView.ts | 2 + .../views/DeploymentPlanView.tsx | 49 +++++++++---------- .../copilotOnRails/views/LocalPlanView.tsx | 49 +++++++++---------- .../copilotOnRails/views/ScaffoldPlanView.tsx | 49 +++++++++---------- .../views/styles/deploymentPlanView.scss | 9 ++++ .../views/styles/localPlanView.scss | 9 ++++ .../views/styles/scaffoldPlanView.scss | 9 ++++ .../views/utils/viewConfigTypes.ts | 2 + 8 files changed, 100 insertions(+), 78 deletions(-) diff --git a/src/webviews/copilotOnRails/extension/openDeploymentPlanView.ts b/src/webviews/copilotOnRails/extension/openDeploymentPlanView.ts index 8ef89208..193099b7 100644 --- a/src/webviews/copilotOnRails/extension/openDeploymentPlanView.ts +++ b/src/webviews/copilotOnRails/extension/openDeploymentPlanView.ts @@ -26,6 +26,8 @@ function getDeploymentPlanViewStrings(): DeploymentPlanViewStrings { azureResourcesHeading: vscode.l10n.t('Azure Resources'), approveButton: vscode.l10n.t('Approve'), feedbackButtonAriaLabel: vscode.l10n.t('Feedback'), + feedbackButtonTooltip: vscode.l10n.t('Request changes to the plan before approving'), + feedbackDrawerInfoTooltip: vscode.l10n.t('Your feedback will be sent to Copilot as a prompt. Copilot will revise the plan and update the file. The updated plan will reload here for your final approval.'), revisingBanner: vscode.l10n.t('Copilot is revising the plan…'), requestChangesHeading: vscode.l10n.t('Request changes'), feedbackDrawerAriaLabel: vscode.l10n.t('Plan feedback'), diff --git a/src/webviews/copilotOnRails/views/DeploymentPlanView.tsx b/src/webviews/copilotOnRails/views/DeploymentPlanView.tsx index bc6e4f67..0f05a166 100644 --- a/src/webviews/copilotOnRails/views/DeploymentPlanView.tsx +++ b/src/webviews/copilotOnRails/views/DeploymentPlanView.tsx @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Button, CounterBadge, Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle, Spinner, Textarea } from '@fluentui/react-components'; +import { Button, CounterBadge, Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle, Spinner, Textarea, Tooltip } from '@fluentui/react-components'; import { CheckmarkRegular, CommentEditRegular, DismissRegular, DocumentRegular, SendRegular, WarningRegular } from '@fluentui/react-icons'; import { useConfiguration, WebviewContext } from '@microsoft/vscode-azext-webview/webview'; import mermaid from 'mermaid'; @@ -287,25 +287,27 @@ export const DeploymentPlanView = (): JSX.Element => {
    -
    +

    {strings.feedbackDrawerInfoTooltip}

    - {items.length === 0 && ( -

    - {strings.drawerHint} -

    - )} - {items.length > 0 && (
      {items.map(item => ( diff --git a/src/webviews/copilotOnRails/views/LocalPlanView.tsx b/src/webviews/copilotOnRails/views/LocalPlanView.tsx index 3e8ba8c1..297ac9fe 100644 --- a/src/webviews/copilotOnRails/views/LocalPlanView.tsx +++ b/src/webviews/copilotOnRails/views/LocalPlanView.tsx @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Button, CounterBadge, Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle, Input, Spinner, Textarea } from '@fluentui/react-components'; +import { Button, CounterBadge, Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle, Input, Spinner, Textarea, Tooltip } from '@fluentui/react-components'; import { CheckmarkRegular, CommentEditRegular, DismissRegular, DocumentRegular, SendRegular, WarningRegular } from '@fluentui/react-icons'; import { WebviewContext } from '@microsoft/vscode-azext-webview/webview'; import * as jsoncParser from 'jsonc-parser'; @@ -208,25 +208,27 @@ export const LocalPlanView = (): JSX.Element => {
    -
    +

    Your feedback will be sent to Copilot as a prompt. Copilot will revise the plan and update the file. The updated plan will reload here for your final approval.

    - {items.length === 0 && ( -

    - Add a free-form note for Copilot describing the changes you'd like to see in this plan. -

    - )} - {items.length > 0 && (
      {items.map(item => ( diff --git a/src/webviews/copilotOnRails/views/ScaffoldPlanView.tsx b/src/webviews/copilotOnRails/views/ScaffoldPlanView.tsx index 017c3899..69416dc5 100644 --- a/src/webviews/copilotOnRails/views/ScaffoldPlanView.tsx +++ b/src/webviews/copilotOnRails/views/ScaffoldPlanView.tsx @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Button, CounterBadge, Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle, Spinner, Textarea } from '@fluentui/react-components'; +import { Button, CounterBadge, Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle, Spinner, Textarea, Tooltip } from '@fluentui/react-components'; import { CheckmarkRegular, CommentEditRegular, DismissRegular, DocumentRegular, SendRegular, WarningRegular } from '@fluentui/react-icons'; import { WebviewContext } from '@microsoft/vscode-azext-webview/webview'; import { useCallback, useContext, useEffect, useMemo, useRef, useState, type JSX } from 'react'; @@ -276,25 +276,27 @@ export const ScaffoldPlanView = (): JSX.Element => {
    -
    +

    Your feedback will be sent to Copilot as a prompt. Copilot will revise the plan and update the file. The updated plan will reload here for your final approval.

    - {items.length === 0 && ( -

    - Change any dropdown in the plan to capture a suggested edit here, or add a free-form note below. -

    - )} - {items.length > 0 && (
      {items.map(item => ( diff --git a/src/webviews/copilotOnRails/views/styles/deploymentPlanView.scss b/src/webviews/copilotOnRails/views/styles/deploymentPlanView.scss index ce961292..0661953b 100644 --- a/src/webviews/copilotOnRails/views/styles/deploymentPlanView.scss +++ b/src/webviews/copilotOnRails/views/styles/deploymentPlanView.scss @@ -361,6 +361,15 @@ color: var(--vscode-descriptionForeground); } + .drawerInfo { + margin: 0; + padding: 6px 14px 8px; + font-size: 0.75em; + line-height: 1.4; + color: var(--vscode-descriptionForeground); + border-bottom: 1px solid var(--vscode-widget-border); + } + .feedbackList { list-style: none; margin: 0; diff --git a/src/webviews/copilotOnRails/views/styles/localPlanView.scss b/src/webviews/copilotOnRails/views/styles/localPlanView.scss index be4a6331..8f32e9da 100644 --- a/src/webviews/copilotOnRails/views/styles/localPlanView.scss +++ b/src/webviews/copilotOnRails/views/styles/localPlanView.scss @@ -556,6 +556,15 @@ color: var(--vscode-descriptionForeground); } + .drawerInfo { + margin: 0; + padding: 6px 14px 8px; + font-size: 0.75em; + line-height: 1.4; + color: var(--vscode-descriptionForeground); + border-bottom: 1px solid var(--vscode-widget-border); + } + .feedbackList { list-style: none; margin: 0; diff --git a/src/webviews/copilotOnRails/views/styles/scaffoldPlanView.scss b/src/webviews/copilotOnRails/views/styles/scaffoldPlanView.scss index 6eaea705..25730c90 100644 --- a/src/webviews/copilotOnRails/views/styles/scaffoldPlanView.scss +++ b/src/webviews/copilotOnRails/views/styles/scaffoldPlanView.scss @@ -451,6 +451,15 @@ color: var(--vscode-descriptionForeground); } + .drawerInfo { + margin: 0; + padding: 6px 14px 8px; + font-size: 0.75em; + line-height: 1.4; + color: var(--vscode-descriptionForeground); + border-bottom: 1px solid var(--vscode-widget-border); + } + .feedbackList { list-style: none; margin: 0; diff --git a/src/webviews/copilotOnRails/views/utils/viewConfigTypes.ts b/src/webviews/copilotOnRails/views/utils/viewConfigTypes.ts index 72cb3fd7..efed2ea5 100644 --- a/src/webviews/copilotOnRails/views/utils/viewConfigTypes.ts +++ b/src/webviews/copilotOnRails/views/utils/viewConfigTypes.ts @@ -25,6 +25,8 @@ export type DeploymentPlanViewStrings = { azureResourcesHeading: string; approveButton: string; feedbackButtonAriaLabel: string; + feedbackButtonTooltip: string; + feedbackDrawerInfoTooltip: string; revisingBanner: string; requestChangesHeading: string; feedbackDrawerAriaLabel: string; From 10b7d57a0faabcef10dafea6ad44bc438fca4a15 Mon Sep 17 00:00:00 2001 From: Megan Mott Date: Fri, 29 May 2026 09:13:07 -0700 Subject: [PATCH 3/4] major local view changes and add tree view back --- package.json | 13 +- resources/agents/azure-local-debug.agent.md | 4 +- src/constants.ts | 2 + src/extension.ts | 77 +- .../AzureProjectProgressTreeDataProvider.ts | 238 +++ src/tree/project/projectPlanFiles.ts | 26 + .../controllers/LocalPlanViewController.ts | 58 +- .../extension/createProjectWithCopilot.ts | 2 +- .../extension/openLocalPlanView.ts | 2 +- .../views/DeploymentPlanView.tsx | 2 + .../copilotOnRails/views/LocalPlanView.tsx | 1321 ++++++++++++++++- .../copilotOnRails/views/ScaffoldPlanView.tsx | 2 + .../views/components/StageProgress.tsx | 46 + .../views/styles/localPlanView.scss | 161 ++ .../views/styles/stageProgress.scss | 98 ++ .../views/utils/parseLocalPlanMarkdown.ts | 47 +- 16 files changed, 2013 insertions(+), 86 deletions(-) create mode 100644 src/tree/project/AzureProjectProgressTreeDataProvider.ts create mode 100644 src/tree/project/projectPlanFiles.ts create mode 100644 src/webviews/copilotOnRails/views/components/StageProgress.tsx create mode 100644 src/webviews/copilotOnRails/views/styles/stageProgress.scss diff --git a/package.json b/package.json index 01085d35..6bea9b25 100644 --- a/package.json +++ b/package.json @@ -431,11 +431,6 @@ "name": "Workspace", "visibility": "visible" }, - { - "id": "azureProject", - "name": "%azureProject.viewName%", - "visibility": "visible" - }, { "id": "azureTenantsView", "name": "Accounts & Tenants", @@ -455,6 +450,14 @@ "icon": "$(azure)", "type": "tree" } + ], + "explorer": [ + { + "id": "azureProject", + "name": "%azureProject.viewName%", + "visibility": "visible", + "when": "ms-azuretools.vscode-azureresourcegroups.hasProjectPlanFiles == true || ms-azuretools.vscode-azureresourcegroups.isEmptyWorkspace == true" + } ] }, "viewsWelcome": [ diff --git a/resources/agents/azure-local-debug.agent.md b/resources/agents/azure-local-debug.agent.md index 595d877f..c409ee8e 100644 --- a/resources/agents/azure-local-debug.agent.md +++ b/resources/agents/azure-local-debug.agent.md @@ -10,7 +10,7 @@ tools: [vscode, run_vscode_command, tool_search, execute, read, agent, browser, ### Step A — open the local-dev plan preview (MANDATORY, do not skip) -The **moment** you finish writing `local-development-plan.md` — before you say anything else, before you ask the user for approval, before any handoff — you **must** call the `run_vscode_command` tool with: +The **moment** you finish writing `vscode-debug-plan.md` — before you say anything else, before you ask the user for approval, before any handoff — you **must** call the `run_vscode_command` tool with: ```json { "commandId": "azureResourceGroups.openLocalPlanView", "name": "Open Local Development Plan View" } @@ -51,7 +51,7 @@ That skill is the canonical, mandatory source for this phase. Treat it as your o ## Your deliverable -A workspace configured for one-keystroke local debugging — `docker-compose.yml` for Azure emulators, `.vscode/launch.json` and `.vscode/tasks.json` wired up, and a `local-development-plan.md` documenting the setup. +A workspace configured for one-keystroke local debugging — `docker-compose.yml` for Azure emulators, `.vscode/launch.json` and `.vscode/tasks.json` wired up, and a `vscode-debug-plan.md` documenting the setup. ## Prerequisites diff --git a/src/constants.ts b/src/constants.ts index da450673..37d82b28 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -9,4 +9,6 @@ export const contributesKey = 'x-azResources'; export const ungroupedId = 'group/ungrouped'; export const showHiddenTypesSettingKey = 'showHiddenTypes'; export const hasFocusedGroupContextKey = 'ms-azuretools.vscode-azureresourcegroups.hasFocusedGroup'; +export const hasProjectPlanFilesContextKey = 'ms-azuretools.vscode-azureresourcegroups.hasProjectPlanFiles'; +export const isEmptyWorkspaceContextKey = 'ms-azuretools.vscode-azureresourcegroups.isEmptyWorkspace'; export const canFocusContextValue = 'canFocus'; diff --git a/src/extension.ts b/src/extension.ts index 2ead8d3c..db307a66 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -32,6 +32,7 @@ import { deleteResourceGroupV2 } from './commands/deleteResourceGroup/v2/deleteR import { registerCommands } from './commands/registerCommands'; import { TagFileSystem } from './commands/tags/TagFileSystem'; import { registerTagDiagnostics } from './commands/tags/registerTagDiagnostics'; +import { hasProjectPlanFilesContextKey, isEmptyWorkspaceContextKey } from './constants'; import { registerExportAuthRecordOnSessionChange } from './exportAuthRecord'; import { ext } from './extensionVariables'; import { AzureResourcesApiInternal } from './hostapi.v2.internal'; @@ -48,6 +49,8 @@ import { AzureResourceBranchDataProviderManager } from './tree/azure/AzureResour import { DefaultAzureResourceBranchDataProvider } from './tree/azure/DefaultAzureResourceBranchDataProvider'; import { registerAzureTree } from './tree/azure/registerAzureTree'; import { registerFocusTree } from './tree/azure/registerFocusTree'; +import { AzureProjectProgressTreeDataProvider } from './tree/project/AzureProjectProgressTreeDataProvider'; +import { getProjectPlanFiles } from './tree/project/projectPlanFiles'; import { TenantDefaultBranchDataProvider } from './tree/tenants/TenantDefaultBranchDataProvider'; import { TenantResourceBranchDataProviderManager } from './tree/tenants/TenantResourceBranchDataProviderManager'; import { registerTenantTree } from './tree/tenants/registerTenantTree'; @@ -68,6 +71,7 @@ export async function activate(context: vscode.ExtensionContext, perfStats: { lo registerUIExtensionVariables(ext); registerAzureUtilsExtensionVariables(ext); + await registerProjectPlanFilesContext(context); const refreshAzureTreeEmitter = new vscode.EventEmitter(); context.subscriptions.push(refreshAzureTreeEmitter); @@ -165,10 +169,8 @@ export async function activate(context: vscode.ExtensionContext, perfStats: { lo refreshEvent: refreshWorkspaceTreeEmitter.event, }); - context.subscriptions.push(vscode.window.registerTreeDataProvider('azureProject', { - getChildren: () => [], - getTreeItem: () => { throw new Error('azureProject view has no tree items.'); }, - })); + const azureProjectProgressTreeDataProvider = new AzureProjectProgressTreeDataProvider(context); + context.subscriptions.push(vscode.window.registerTreeDataProvider('azureProject', azureProjectProgressTreeDataProvider)); const tenantResourcesBranchDataItemCache = new BranchDataItemCache(); registerTenantTree(context, { @@ -326,3 +328,70 @@ export async function activate(context: vscode.ExtensionContext, perfStats: { lo export function deactivate(): void { ext.diagnosticWatcher?.dispose(); } + +async function registerProjectPlanFilesContext(context: vscode.ExtensionContext): Promise { + const update = async (): Promise => { + const files = await getProjectPlanFiles(); + + await vscode.commands.executeCommand('setContext', hasProjectPlanFilesContextKey, files.hasProjectPlan || files.hasLocalDevelopmentPlan || files.hasDeploymentPlan); + await vscode.commands.executeCommand('setContext', isEmptyWorkspaceContextKey, await isWorkspaceEmpty()); + }; + + context.subscriptions.push(vscode.workspace.onDidChangeWorkspaceFolders(() => { + void update(); + })); + context.subscriptions.push(vscode.workspace.onDidCreateFiles(() => { + void update(); + })); + context.subscriptions.push(vscode.workspace.onDidDeleteFiles(() => { + void update(); + })); + context.subscriptions.push(vscode.workspace.onDidRenameFiles(() => { + void update(); + })); + + const watchers = [ + vscode.workspace.createFileSystemWatcher('**/project-plan.md'), + vscode.workspace.createFileSystemWatcher('**/vscode-debug-plan.md'), + vscode.workspace.createFileSystemWatcher('**/.azure/deployment-plan.md'), + ]; + + for (const watcher of watchers) { + watcher.onDidCreate(() => { + void update(); + }); + watcher.onDidDelete(() => { + void update(); + }); + watcher.onDidChange(() => { + void update(); + }); + context.subscriptions.push(watcher); + } + + await update(); +} + +async function isWorkspaceEmpty(): Promise { + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) { + return false; + } + + // Entries that don't count as "real" project content. + const ignored = new Set(['.git', '.vscode', '.azure', '.github']); + + for (const folder of folders) { + try { + const entries = await vscode.workspace.fs.readDirectory(folder.uri); + const meaningful = entries.filter(([name]) => !ignored.has(name)); + if (meaningful.length === 0) { + return true; + } + } catch { + // Ignore unreadable folders (e.g. permission errors) and keep checking the rest. + } + } + + return false; +} diff --git a/src/tree/project/AzureProjectProgressTreeDataProvider.ts b/src/tree/project/AzureProjectProgressTreeDataProvider.ts new file mode 100644 index 00000000..7abd0cc0 --- /dev/null +++ b/src/tree/project/AzureProjectProgressTreeDataProvider.ts @@ -0,0 +1,238 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { getProjectPlanFiles } from './projectPlanFiles'; + +type ProgressState = 'completed' | 'current' | 'notStarted'; + +interface StageNode { + readonly kind: 'stage'; + readonly id: string; + readonly label: string; + readonly stepNumber: number; + readonly state: ProgressState; + readonly hasPlanFile: boolean; + readonly openPlanCommandId: string; + readonly startCommandId: string; +} + +interface ActionNode { + readonly kind: 'action'; + readonly id: string; + readonly label: string; + readonly description?: string; + readonly iconName: string; + readonly commandId?: string; +} + +type ProgressNode = StageNode | ActionNode; + +export class AzureProjectProgressTreeDataProvider implements vscode.TreeDataProvider, vscode.Disposable { + private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event; + + private readonly disposables: vscode.Disposable[] = []; + + constructor(context: vscode.ExtensionContext) { + this.disposables.push(vscode.workspace.onDidChangeWorkspaceFolders(() => this.refresh())); + this.disposables.push(vscode.workspace.onDidCreateFiles(() => this.refresh())); + this.disposables.push(vscode.workspace.onDidDeleteFiles(() => this.refresh())); + this.disposables.push(vscode.workspace.onDidRenameFiles(() => this.refresh())); + + const watchers = [ + vscode.workspace.createFileSystemWatcher('**/project-plan.md'), + vscode.workspace.createFileSystemWatcher('**/vscode-debug-plan.md'), + vscode.workspace.createFileSystemWatcher('**/.azure/deployment-plan.md'), + ]; + + for (const watcher of watchers) { + watcher.onDidCreate(() => this.refresh()); + watcher.onDidDelete(() => this.refresh()); + watcher.onDidChange(() => this.refresh()); + this.disposables.push(watcher); + } + + context.subscriptions.push(this); + } + + dispose(): void { + this.onDidChangeTreeDataEmitter.dispose(); + for (const disposable of this.disposables) { + disposable.dispose(); + } + } + + refresh(): void { + this.onDidChangeTreeDataEmitter.fire(); + } + + getTreeItem(element: ProgressNode): vscode.TreeItem { + if (element.kind === 'stage') { + const item = new vscode.TreeItem( + vscode.l10n.t('{0}. {1}', element.stepNumber.toString(), element.label), + vscode.TreeItemCollapsibleState.Expanded, + ); + item.id = element.id; + item.description = toStageDescription(element.state, element.hasPlanFile); + item.iconPath = new vscode.ThemeIcon(toStageIconName(element.id)); + return item; + } + + const actionItem = new vscode.TreeItem(element.label, vscode.TreeItemCollapsibleState.None); + actionItem.id = element.id; + actionItem.iconPath = new vscode.ThemeIcon(element.iconName); + + if (element.commandId) { + actionItem.command = { + command: element.commandId, + title: '', + }; + } + + return actionItem; + } + + async getChildren(element?: ProgressNode): Promise { + if (!element) { + return this.getStageNodes(); + } + + if (element.kind === 'stage') { + return this.getActionNodes(element); + } + + return []; + } + + private async getStageNodes(): Promise { + const files = await getProjectPlanFiles(); + + // When no plan files exist, return no nodes so VS Code renders the + // configured viewsWelcome content (the "Create New Project With Copilot" button). + if (!files.hasProjectPlan && !files.hasLocalDevelopmentPlan && !files.hasDeploymentPlan) { + return []; + } + + const currentStep = files.hasDeploymentPlan ? 2 : files.hasLocalDevelopmentPlan ? 1 : 0; + + return [ + { + kind: 'stage', + id: 'azureProject.stage.projectCreation', + label: vscode.l10n.t('Project Creation'), + stepNumber: 1, + state: getState(0, currentStep), + hasPlanFile: files.hasProjectPlan, + openPlanCommandId: 'azureResourceGroups.openPlanView', + startCommandId: 'azureResourceGroups.createProjectWithCopilot', + }, + { + kind: 'stage', + id: 'azureProject.stage.localDevelopment', + label: vscode.l10n.t('Local Development'), + stepNumber: 2, + state: getState(1, currentStep), + hasPlanFile: files.hasLocalDevelopmentPlan, + openPlanCommandId: 'azureResourceGroups.openLocalPlanView', + startCommandId: 'azureResourceGroups.startLocalDevelopment', + }, + { + kind: 'stage', + id: 'azureProject.stage.deployment', + label: vscode.l10n.t('Deployment'), + stepNumber: 3, + state: getState(2, currentStep), + hasPlanFile: files.hasDeploymentPlan, + openPlanCommandId: 'azureResourceGroups.openDeployPlanView', + startCommandId: 'azureResourceGroups.startDeployment', + }, + ]; + } + + private getActionNodes(stage: StageNode): ActionNode[] { + const actions: ActionNode[] = []; + + if (stage.hasPlanFile) { + actions.push({ + kind: 'action', + id: `${stage.id}.openPlan`, + label: vscode.l10n.t('Open plan'), + iconName: 'go-to-file', + commandId: stage.openPlanCommandId, + }); + } + + if (!stage.hasPlanFile) { + actions.push({ + kind: 'action', + id: `${stage.id}.start`, + label: vscode.l10n.t('Run this stage'), + iconName: 'run', + commandId: stage.startCommandId, + }); + } + + if (stage.hasPlanFile && stage.state === 'current') { + actions.push({ + kind: 'action', + id: `${stage.id}.continue`, + label: vscode.l10n.t('Continue stage'), + iconName: 'run', + commandId: stage.startCommandId, + }); + } + + if (actions.length === 0) { + actions.push({ + kind: 'action', + id: `${stage.id}.none`, + label: vscode.l10n.t('No actions available'), + iconName: 'info', + }); + } + + return actions; + } +} + +function getState(stepIndex: number, currentStep: number): ProgressState { + if (stepIndex < currentStep) { + return 'completed'; + } + + if (stepIndex === currentStep) { + return 'current'; + } + + return 'notStarted'; +} + +function toStageDescription(state: ProgressState, _hasPlanFile: boolean): string { + return toStateText(state); +} + +function toStateText(state: ProgressState): string { + switch (state) { + case 'completed': + return vscode.l10n.t('Completed'); + case 'current': + return vscode.l10n.t('Current'); + default: + return vscode.l10n.t('Not started'); + } +} + +function toStageIconName(stageId: string): string { + if (stageId.includes('projectCreation')) { + return 'new-file'; + } + + if (stageId.includes('localDevelopment')) { + return 'terminal'; + } + + return 'rocket'; +} diff --git a/src/tree/project/projectPlanFiles.ts b/src/tree/project/projectPlanFiles.ts new file mode 100644 index 00000000..356d9f0a --- /dev/null +++ b/src/tree/project/projectPlanFiles.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +export interface ProjectPlanFiles { + hasProjectPlan: boolean; + hasLocalDevelopmentPlan: boolean; + hasDeploymentPlan: boolean; +} + +export async function getProjectPlanFiles(): Promise { + const [projectPlanFiles, localDevelopmentPlanFiles, deploymentPlanFiles] = await Promise.all([ + vscode.workspace.findFiles('**/project-plan.md', '**/node_modules/**', 1), + vscode.workspace.findFiles('**/vscode-debug-plan.md', '**/node_modules/**', 1), + vscode.workspace.findFiles('**/.azure/deployment-plan.md', '**/node_modules/**', 1), + ]); + + return { + hasProjectPlan: projectPlanFiles.length > 0, + hasLocalDevelopmentPlan: localDevelopmentPlanFiles.length > 0, + hasDeploymentPlan: deploymentPlanFiles.length > 0, + }; +} diff --git a/src/webviews/copilotOnRails/extension/controllers/LocalPlanViewController.ts b/src/webviews/copilotOnRails/extension/controllers/LocalPlanViewController.ts index bf6c0f8f..a47b0ec2 100644 --- a/src/webviews/copilotOnRails/extension/controllers/LocalPlanViewController.ts +++ b/src/webviews/copilotOnRails/extension/controllers/LocalPlanViewController.ts @@ -18,7 +18,7 @@ export class LocalPlanViewController extends WebviewController { + this.panel.webview.onDidReceiveMessage((message: { command: string; data?: LocalPlanData; prompt?: string; originalCode?: string; newCode?: string; language?: string; requestId?: string; lineStart?: number; lineEnd?: number; newText?: string }) => { switch (message.command) { case 'ready': void this.panel.webview.postMessage({ command: 'setLocalPlanData', data: planData }); @@ -47,7 +47,10 @@ export class LocalPlanViewController extends WebviewController { + private async updateCodeBlock(requestId: string | undefined, originalCode: string | undefined, newCode: string | undefined): Promise { if (typeof originalCode !== 'string' || typeof newCode !== 'string') { - void this.panel.webview.postMessage({ command: 'codeBlockUpdateError', error: vscode.l10n.t('Invalid edit payload.') }); + void this.panel.webview.postMessage({ command: 'codeBlockUpdateError', requestId, error: vscode.l10n.t('Invalid edit payload.') }); return; } if (originalCode === newCode) { + void this.panel.webview.postMessage({ command: 'codeBlockUpdated', requestId }); return; } if (!this.sourceFileUri) { - void this.panel.webview.postMessage({ command: 'codeBlockUpdateError', error: vscode.l10n.t('The plan file location is unknown, so the change could not be saved.') }); + void this.panel.webview.postMessage({ command: 'codeBlockUpdateError', requestId, error: vscode.l10n.t('The plan file location is unknown, so the change could not be saved.') }); return; } @@ -91,21 +95,61 @@ export class LocalPlanViewController extends WebviewController { + if (typeof lineStart !== 'number' || typeof lineEnd !== 'number' || typeof newText !== 'string' || lineStart < 0 || lineEnd < lineStart) { + void this.panel.webview.postMessage({ command: 'planLinesUpdateError', requestId, error: vscode.l10n.t('Invalid edit payload.') }); + return; + } + if (!this.sourceFileUri) { + void this.panel.webview.postMessage({ command: 'planLinesUpdateError', requestId, error: vscode.l10n.t('The plan file location is unknown, so the change could not be saved.') }); + return; + } + + try { + const raw = Buffer.from(await vscode.workspace.fs.readFile(this.sourceFileUri)).toString('utf-8'); + const usesCRLF = raw.includes('\r\n'); + const lines = raw.replace(/\r\n/g, '\n').split('\n'); + + if (lineEnd >= lines.length) { + void this.panel.webview.postMessage({ command: 'planLinesUpdateError', requestId, error: vscode.l10n.t('The plan file has changed since it was loaded. Reload the plan and try again.') }); + return; + } + + const replacement = newText.split('\n'); + const updatedLines = [...lines.slice(0, lineStart), ...replacement, ...lines.slice(lineEnd + 1)]; + let updated = updatedLines.join('\n'); + if (usesCRLF) { + updated = updated.replace(/\n/g, '\r\n'); + } + await vscode.workspace.fs.writeFile(this.sourceFileUri, Buffer.from(updated, 'utf-8')); + void this.panel.webview.postMessage({ command: 'planLinesUpdated', requestId }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + void this.panel.webview.postMessage({ + command: 'planLinesUpdateError', + requestId, error: vscode.l10n.t('Saving the change failed: {0}', errorMessage), }); } diff --git a/src/webviews/copilotOnRails/extension/createProjectWithCopilot.ts b/src/webviews/copilotOnRails/extension/createProjectWithCopilot.ts index dfbeb97d..1a40af39 100644 --- a/src/webviews/copilotOnRails/extension/createProjectWithCopilot.ts +++ b/src/webviews/copilotOnRails/extension/createProjectWithCopilot.ts @@ -13,7 +13,7 @@ const deploy = vscode.l10n.t('Deploy'); export async function createProjectWithCopilot(_context: IActionContext): Promise { switch (true) { // Local Development => Deploy - case await hasCompletedPhase('**/local-development-plan.md', 'implemented'): { + case await hasCompletedPhase('**/vscode-debug-plan.md', 'implemented'): { const choice = await vscode.window.showInformationMessage( vscode.l10n.t('We detected a previous Copilot session with a completed local debug configuration. Would you like to deploy this project?'), { modal: true }, diff --git a/src/webviews/copilotOnRails/extension/openLocalPlanView.ts b/src/webviews/copilotOnRails/extension/openLocalPlanView.ts index 5565bfc4..0d8172a2 100644 --- a/src/webviews/copilotOnRails/extension/openLocalPlanView.ts +++ b/src/webviews/copilotOnRails/extension/openLocalPlanView.ts @@ -61,7 +61,7 @@ function tryParseLocalPlan(content: string, sourceFileUri: vscode.Uri | undefine } export async function openLocalPlanViewFromWorkspace(): Promise { - const files = await vscode.workspace.findFiles('**/local-development-plan.md', '**/node_modules/**', 10); + const files = await vscode.workspace.findFiles('**/vscode-debug-plan.md', '**/node_modules/**', 10); if (files.length === 0) { void vscode.window.showInformationMessage(vscode.l10n.t('No local plan markdown files found in the workspace.')); return; diff --git a/src/webviews/copilotOnRails/views/DeploymentPlanView.tsx b/src/webviews/copilotOnRails/views/DeploymentPlanView.tsx index 0f05a166..e31e9011 100644 --- a/src/webviews/copilotOnRails/views/DeploymentPlanView.tsx +++ b/src/webviews/copilotOnRails/views/DeploymentPlanView.tsx @@ -8,6 +8,7 @@ import { CheckmarkRegular, CommentEditRegular, DismissRegular, DocumentRegular, import { useConfiguration, WebviewContext } from '@microsoft/vscode-azext-webview/webview'; import mermaid from 'mermaid'; import { useCallback, useContext, useEffect, useMemo, useRef, useState, type JSX } from 'react'; +import { StageProgress } from './components/StageProgress'; import './styles/deploymentPlanView.scss'; import { type DeploymentPlanData, type DeploymentPlanTable } from './utils/deploymentPlanTypes'; import { type DeploymentPlanViewConfiguration, type DeploymentPlanViewStrings } from './utils/viewConfigTypes'; @@ -276,6 +277,7 @@ export const DeploymentPlanView = (): JSX.Element => { return (
      +
      diff --git a/src/webviews/copilotOnRails/views/LocalPlanView.tsx b/src/webviews/copilotOnRails/views/LocalPlanView.tsx index 297ac9fe..f983e5d4 100644 --- a/src/webviews/copilotOnRails/views/LocalPlanView.tsx +++ b/src/webviews/copilotOnRails/views/LocalPlanView.tsx @@ -3,12 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Button, CounterBadge, Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle, Input, Spinner, Textarea, Tooltip } from '@fluentui/react-components'; +import { Button, Checkbox, CounterBadge, Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle, Dropdown, Input, Option, Spinner, Textarea, Tooltip } from '@fluentui/react-components'; import { CheckmarkRegular, CommentEditRegular, DismissRegular, DocumentRegular, SendRegular, WarningRegular } from '@fluentui/react-icons'; import { WebviewContext } from '@microsoft/vscode-azext-webview/webview'; import * as jsoncParser from 'jsonc-parser'; import mermaid from 'mermaid'; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type JSX } from 'react'; +import { StageProgress } from './components/StageProgress'; import './styles/localPlanView.scss'; import { type LocalPlanContent, type LocalPlanData, type LocalPlanSection } from './utils/parseLocalPlanMarkdown'; @@ -30,11 +31,81 @@ const alwaysExpandedSections = new Set(['project analysis', 'prerequisites', 'sc const defaultOpenSections = new Set(['architecture diagram']); const editableCodeSections = new Set(['launch configuration']); +const COMPOUND_LAUNCH_NAME_REGEX = /^Compound Launch Config Name:\s*✏️\s*\*\*([^*]+?)\*\*\s*$/u; +const GENERATE_TOGGLE_REGEX = /^\*\*Generate:\*\*\s*\[([ x])\]\s*$/u; +const GENERATE_HEADER = 'generate'; + +function isDatabaseConfigSection(title: string | undefined): boolean { + return (title ?? '').toLowerCase().trim() === 'database configuration'; +} + +function isPortRegistrySection(title: string | undefined): boolean { + return (title ?? '').toLowerCase().trim() === 'port registry'; +} + +function isGenerationOptionsSection(title: string | undefined): boolean { + return (title ?? '').toLowerCase().trim() === 'generation options'; +} + +function isServicesSection(title: string | undefined): boolean { + return (title ?? '').toLowerCase().trim() === 'services'; +} + +function isEmulatorsSection(title: string | undefined): boolean { + return (title ?? '').toLowerCase().trim() === 'emulators'; +} + +function isConnectionStringsSection(title: string | undefined): boolean { + const lower = (title ?? '').toLowerCase().trim(); + return lower === 'connection strings' || lower === 'connection string'; +} + +function isExistingConfigurationSection(title: string | undefined): boolean { + const lower = (title ?? '').toLowerCase().trim(); + return lower === 'existing configuration' || lower === 'existing config'; +} + +function findGenerateColumnIdx(headers: string[]): number { + return headers.findIndex(h => h.toLowerCase().trim() === GENERATE_HEADER); +} + +function findLaunchConfigNameColumnIdx(headers: string[]): number { + return headers.findIndex(h => h.toLowerCase().trim() === 'launch config name'); +} + +function buildTableRowMarkdown(cells: string[]): string { + return `| ${cells.map(c => c.trim()).join(' | ')} |`; +} + +interface PlanSectionContextValue { + sectionTitle: string; + subsectionTitle?: string; +} +const PlanSectionContext = createContext({ sectionTitle: '' }); + +interface PlanLineEditContextValue { + submitPlanEdit: (lineStart: number, lineEnd: number, newText: string) => Promise; + showEditError: (message: string) => void; + setSourceFeedback: (sourceKey: string, text: string | null) => void; +} +const PlanLineEditContext = createContext({ + submitPlanEdit: async () => { /* no-op */ }, + showEditError: () => { /* no-op */ }, + setSourceFeedback: () => { /* no-op */ }, +}); + +type ParagraphBlock = Extract; +type TableBlock = Extract; + interface CodeEditNoteContextValue { addCodeEditNote: (language: string, oldCode: string, newCode: string) => void; + updateCodeBlock: (originalCode: string, newCode: string, language: string) => Promise; + showCodeEditError: (message: string) => void; } const CodeEditNoteContext = createContext({ addCodeEditNote: () => { /* no-op */ }, + updateCodeBlock: async () => { /* no-op */ }, + showCodeEditError: () => { /* no-op */ }, }); function buildCodeEditNote(language: string, _oldCode: string, newCode: string): string { @@ -54,6 +125,7 @@ function buildCodeEditNote(language: string, _oldCode: string, newCode: string): interface FeedbackItem { id: string; text: string; + sourceKey?: string; } let feedbackIdCounter = 0; @@ -65,7 +137,7 @@ function buildFeedbackPrompt(items: FeedbackItem[]): string { .filter(t => t.length > 2); const lines: string[] = [ - 'Please revise the local development plan based on my feedback and update local-development-plan.md.', + 'Please revise the local development plan based on my feedback and update vscode-debug-plan.md.', 'Keep existing sections unchanged unless a change below implies otherwise. Wait for my approval after updating the file.', '', ]; @@ -84,6 +156,8 @@ export const LocalPlanView = (): JSX.Element => { const [confirmSubmitOpen, setConfirmSubmitOpen] = useState(false); const [codeEditError, setCodeEditError] = useState(null); const { vscodeApi } = useContext(WebviewContext); + const pendingCodeBlockUpdatesRef = useRef void; reject: (message: string) => void }>>(new Map()); + const pendingPlanLineEditsRef = useRef void; reject: (message: string) => void }>>(new Map()); const hasEdits = useMemo( () => feedbackItems.length > 0 || freeformDraft.trim().length > 0, @@ -104,13 +178,63 @@ export const LocalPlanView = (): JSX.Element => { setDrawerOpen(false); } else if (message?.command === 'revisionComplete') { setIsAwaitingRevision(false); + } else if (message?.command === 'codeBlockUpdated') { + const requestId = typeof message.requestId === 'string' ? message.requestId : undefined; + if (!requestId) { + return; + } + const pending = pendingCodeBlockUpdatesRef.current.get(requestId); + if (!pending) { + return; + } + pendingCodeBlockUpdatesRef.current.delete(requestId); + pending.resolve(); } else if (message?.command === 'codeBlockUpdateError') { - setCodeEditError(typeof message.error === 'string' ? message.error : 'Failed to save changes to the plan file.'); + const errorMessage = typeof message.error === 'string' ? message.error : 'Failed to save changes to the plan file.'; + const requestId = typeof message.requestId === 'string' ? message.requestId : undefined; + if (requestId) { + const pending = pendingCodeBlockUpdatesRef.current.get(requestId); + if (pending) { + pendingCodeBlockUpdatesRef.current.delete(requestId); + pending.reject(errorMessage); + return; + } + } + setCodeEditError(errorMessage); + } else if (message?.command === 'planLinesUpdated') { + const requestId = typeof message.requestId === 'string' ? message.requestId : undefined; + if (!requestId) { + return; + } + const pending = pendingPlanLineEditsRef.current.get(requestId); + if (!pending) { + return; + } + pendingPlanLineEditsRef.current.delete(requestId); + pending.resolve(); + } else if (message?.command === 'planLinesUpdateError') { + const errorMessage = typeof message.error === 'string' ? message.error : 'Failed to save changes to the plan file.'; + const requestId = typeof message.requestId === 'string' ? message.requestId : undefined; + if (requestId) { + const pending = pendingPlanLineEditsRef.current.get(requestId); + if (pending) { + pendingPlanLineEditsRef.current.delete(requestId); + pending.reject(errorMessage); + return; + } + } + setCodeEditError(errorMessage); } }; window.addEventListener('message', handler); vscodeApi.postMessage({ command: 'ready' }); - return () => window.removeEventListener('message', handler); + return () => { + window.removeEventListener('message', handler); + pendingCodeBlockUpdatesRef.current.forEach((pending) => pending.reject('The edit request was cancelled before completion.')); + pendingCodeBlockUpdatesRef.current.clear(); + pendingPlanLineEditsRef.current.forEach((pending) => pending.reject('The edit request was cancelled before completion.')); + pendingPlanLineEditsRef.current.clear(); + }; }, []); const handleApprove = useCallback(() => { @@ -134,9 +258,59 @@ export const LocalPlanView = (): JSX.Element => { setDrawerOpen(true); }, []); + const showCodeEditError = useCallback((message: string) => { + setCodeEditError(message); + }, []); + + const updateCodeBlock = useCallback((originalCode: string, newCode: string, language: string): Promise => { + const requestId = `code-update-${Date.now()}-${Math.random().toString(36).slice(2)}`; + return new Promise((resolve, reject) => { + pendingCodeBlockUpdatesRef.current.set(requestId, { resolve, reject }); + vscodeApi.postMessage({ + command: 'updateCodeBlock', + requestId, + originalCode, + language, + newCode, + }); + }); + }, [vscodeApi]); + const codeEditContextValue = useMemo( - () => ({ addCodeEditNote }), - [addCodeEditNote], + () => ({ addCodeEditNote, updateCodeBlock, showCodeEditError }), + [addCodeEditNote, updateCodeBlock, showCodeEditError], + ); + + const submitPlanEdit = useCallback((lineStart: number, lineEnd: number, newText: string): Promise => { + const requestId = `plan-edit-${Date.now()}-${Math.random().toString(36).slice(2)}`; + return new Promise((resolve, reject) => { + pendingPlanLineEditsRef.current.set(requestId, { resolve, reject }); + vscodeApi.postMessage({ + command: 'updatePlanLines', + requestId, + lineStart, + lineEnd, + newText, + }); + }); + }, [vscodeApi]); + + const setSourceFeedback = useCallback((sourceKey: string, text: string | null) => { + setFeedbackItems(prev => { + const filtered = prev.filter(i => i.sourceKey !== sourceKey); + if (text === null) { + return filtered; + } + return [...filtered, { id: nextId(), sourceKey, text }]; + }); + if (text !== null) { + setDrawerOpen(true); + } + }, []); + + const planLineEditContextValue = useMemo( + () => ({ submitPlanEdit, showEditError: showCodeEditError, setSourceFeedback }), + [submitPlanEdit, showCodeEditError, setSourceFeedback], ); const handleAddNote = useCallback(() => { @@ -198,6 +372,7 @@ export const LocalPlanView = (): JSX.Element => { return (
      +
      @@ -249,7 +424,7 @@ export const LocalPlanView = (): JSX.Element => { )} {plan.sections - .filter((s) => !isHiddenSection(s.title)) + .filter((s) => !shouldHideSection(s)) .sort((a, b) => sectionSortOrder(a.title) - sectionSortOrder(b.title)) .map((section, i) => { const lower = section.title.toLowerCase(); @@ -258,12 +433,16 @@ export const LocalPlanView = (): JSX.Element => { const codeEditable = editableCodeSections.has(lower); return ( - + + + + + ); })} @@ -424,7 +603,9 @@ const SectionCard = ({ section, collapsible, defaultOpen, codeEditable }: { sect
      {open && (
      - {section.content.map((item, i) => ( + {isEmulatorsSection(section.title) ? ( + + ) : section.content.map((item, i) => ( ))}
      @@ -439,12 +620,28 @@ function isHiddenSection(title: string): boolean { || lower === 'manual tests' || lower === 'table of contents' || lower === 'debug configuration checklist' - || lower === 'limited support' || lower === 'convenience scripts' || lower === 'api test collections' || lower === 'migrations'; } +function hasDetectedFeatures(content: LocalPlanContent[]): boolean { + for (const item of content) { + if (item.type === 'bulletList' && item.items.length > 0) { return true; } + if (item.type === 'table' && item.rows.length > 0) { return true; } + if (item.type === 'subsection' && hasDetectedFeatures(item.content)) { return true; } + } + return false; +} + +function shouldHideSection(section: LocalPlanSection): boolean { + if (isHiddenSection(section.title)) { return true; } + if (section.title.toLowerCase().trim() === 'limited support') { + return !hasDetectedFeatures(section.content); + } + return false; +} + function isHiddenSubsection(title: string): boolean { const lower = title.toLowerCase(); return lower === 'task configuration' @@ -474,8 +671,30 @@ function sectionSortOrder(title: string): number { } const ContentBlock = ({ item, codeEditable }: { item: LocalPlanContent; codeEditable: boolean }): JSX.Element | null => { + const sectionCtx = useContext(PlanSectionContext); switch (item.type) { case 'table': + if (isDatabaseConfigSection(sectionCtx.sectionTitle)) { + return ; + } + if (isPortRegistrySection(sectionCtx.sectionTitle)) { + return ; + } + if (isGenerationOptionsSection(sectionCtx.sectionTitle) && findGenerateColumnIdx(item.headers) >= 0) { + return ; + } + if (isServicesSection(sectionCtx.sectionTitle)) { + if (findGenerateColumnIdx(item.headers) >= 0) { + return ; + } + return ; + } + if (isConnectionStringsSection(sectionCtx.sectionTitle)) { + return ; + } + if (isExistingConfigurationSection(sectionCtx.sectionTitle)) { + return ; + } return ; case 'codeBlock': if (item.language?.toLowerCase() === 'mermaid') { @@ -489,8 +708,17 @@ const ContentBlock = ({ item, codeEditable }: { item: LocalPlanContent; codeEdit return ; case 'blockquote': return ; - case 'paragraph': + case 'paragraph': { + const compoundMatch = item.text.match(COMPOUND_LAUNCH_NAME_REGEX); + if (compoundMatch) { + return ; + } + const generateMatch = item.text.match(GENERATE_TOGGLE_REGEX); + if (generateMatch && isGenerationOptionsSection(sectionCtx.sectionTitle)) { + return ; + } return

      ; + } case 'subsection': if (isHiddenSubsection(item.title)) { return null; } if (isFlattenedSubsection(item.title)) { @@ -519,6 +747,953 @@ const TableAsList = ({ rows }: { rows: string[][] }): JSX.Element => (

    ); +const DataTable = ({ headers, rows }: { headers: string[]; rows: string[][] }): JSX.Element => ( +
    + + + + {headers.map((h, hi) => ( + + + + {rows.map((row, ri) => ( + + {row.map((cell, ci) => ( + + ))} + +
    + ))} +
    + ))} +
    +
    +); + +const ServicesTable = ({ table }: { table: TableBlock }): JSX.Element => { + const { setSourceFeedback, submitPlanEdit, showEditError } = useContext(PlanLineEditContext); + const generateIdx = findGenerateColumnIdx(table.headers); + const launchNameIdx = findLaunchConfigNameColumnIdx(table.headers); + const originalStates = useMemo( + () => table.rows.map(r => /^yes$/i.test((r[generateIdx] ?? '').trim())), + [table.rows, generateIdx], + ); + const [states, setStates] = useState(originalStates); + const originalLaunchNames = useMemo( + () => table.rows.map(r => (r[launchNameIdx] ?? '').trim()), + [table.rows, launchNameIdx], + ); + const [launchNameDrafts, setLaunchNameDrafts] = useState(originalLaunchNames); + const [savingRow, setSavingRow] = useState(null); + const focusedRowRef = useRef(null); + + useEffect(() => { + setStates(originalStates); + }, [originalStates]); + + useEffect(() => { + if (focusedRowRef.current === null) { + setLaunchNameDrafts(originalLaunchNames); + } + }, [originalLaunchNames]); + + const toggleRow = useCallback((rowIdx: number) => { + const next = !states[rowIdx]; + setStates(prev => { const arr = prev.slice(); arr[rowIdx] = next; return arr; }); + const rowLabel = (table.rows[rowIdx][launchNameIdx >= 0 ? launchNameIdx : 0] ?? '').trim() || `row ${rowIdx + 1}`; + const sourceKey = `services-generate-${table.lineStart}-${rowIdx}`; + if (next === originalStates[rowIdx]) { + setSourceFeedback(sourceKey, null); + return; + } + const desired = next ? 'Yes' : 'No'; + const text = `In the **Services** table, set **Generate** to **${desired}** for **${rowLabel}**.`; + setSourceFeedback(sourceKey, text); + }, [states, table, launchNameIdx, originalStates, setSourceFeedback]); + + const commitLaunchName = useCallback((rowIdx: number, next: string) => { + const trimmed = next.trim(); + const original = originalLaunchNames[rowIdx]; + if (!trimmed) { + setLaunchNameDrafts(prev => { + const arr = prev.slice(); + arr[rowIdx] = original; + return arr; + }); + return; + } + if (trimmed === original) { + return; + } + const newRow = table.rows[rowIdx].slice(); + newRow[launchNameIdx] = trimmed; + const newLine = buildTableRowMarkdown(newRow); + const lineIdx = table.rowLines[rowIdx]; + setSavingRow(rowIdx); + submitPlanEdit(lineIdx, lineIdx, newLine) + .catch((err: unknown) => { + setLaunchNameDrafts(prev => { + const arr = prev.slice(); + arr[rowIdx] = original; + return arr; + }); + const message = err instanceof Error ? err.message : String(err); + showEditError(message); + }) + .finally(() => setSavingRow(null)); + }, [originalLaunchNames, table, launchNameIdx, submitPlanEdit, showEditError]); + + return ( +
    + + + + {table.headers.map((h, hi) => ( + + + + {table.rows.map((row, ri) => ( + + {row.map((cell, ci) => { + if (ci === generateIdx) { + return ( + + ); + } + if (ci === launchNameIdx) { + return ( + + ); + } + return + ))} + +
    + ))} +
    + toggleRow(ri)} + /> + + { + setLaunchNameDrafts(prev => { + const arr = prev.slice(); + arr[ri] = data.value; + return arr; + }); + }} + onFocus={() => { focusedRowRef.current = ri; }} + onBlur={(e) => { + focusedRowRef.current = null; + commitLaunchName(ri, e.target.value); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + (e.target as HTMLInputElement).blur(); + } else if (e.key === 'Escape') { + e.preventDefault(); + setLaunchNameDrafts(prev => { + const arr = prev.slice(); + arr[ri] = originalLaunchNames[ri]; + return arr; + }); + (e.target as HTMLInputElement).blur(); + } + }} + /> + ; + })} +
    +
    + ); +}; + +interface EmulatorInfo { + subsectionTitle: string; + port?: string; + connection?: string; + image?: { absoluteLine: number; prefix: string; value: string }; + dataDir?: { absoluteLine: number; prefix: string; suffix: string; value: string }; +} + +function parseEmulators(content: LocalPlanContent[]): EmulatorInfo[] { + const emulators: EmulatorInfo[] = []; + for (const item of content) { + if (item.type !== 'subsection') { continue; } + const info: EmulatorInfo = { subsectionTitle: item.title }; + for (const child of item.content) { + if (child.type === 'bulletList') { + for (const bullet of child.items) { + const portMatch = bullet.match(/^\*\*Port:\*\*\s*`?([^`]+?)`?\s*$/i); + if (portMatch) { info.port = portMatch[1].trim(); continue; } + const connMatch = bullet.match(/^\*\*Connection:\*\*\s*`?(.+?)`?\s*$/i); + if (connMatch) { info.connection = connMatch[1].trim(); } + } + } else if (child.type === 'codeBlock' && /^ya?ml$/i.test(child.language ?? '')) { + const codeLines = child.code.split('\n'); + const codeStartLine = child.lineStart + 1; + for (let li = 0; li < codeLines.length; li++) { + const ln = codeLines[li]; + if (!info.image) { + const imgMatch = ln.match(/^(\s*image:\s*)(\S.*)$/); + if (imgMatch) { + info.image = { + absoluteLine: codeStartLine + li, + prefix: imgMatch[1], + value: imgMatch[2].trim(), + }; + continue; + } + } + if (!info.dataDir) { + const volMatch = ln.match(/^(\s*-\s+)(\.{1,2}\/[^:\s]+)(:.+)$/); + if (volMatch) { + info.dataDir = { + absoluteLine: codeStartLine + li, + prefix: volMatch[1], + value: volMatch[2], + suffix: volMatch[3], + }; + } + } + } + } + } + emulators.push(info); + } + return emulators; +} + +function findEmulatorImageColumnIdx(headers: string[]): number { + const lower = headers.map(h => h.toLowerCase().trim()); + const exact = ['image', 'image (version tag)', 'docker image', 'container image']; + for (const name of exact) { + const idx = lower.indexOf(name); + if (idx >= 0) { return idx; } + } + return lower.findIndex(h => h.startsWith('image')); +} + +function findEmulatorDataDirColumnIdx(headers: string[]): number { + const lower = headers.map(h => h.toLowerCase().trim()); + const exact = ['data directory', 'data dir', 'data-directory', 'volume', 'data path']; + for (const name of exact) { + const idx = lower.indexOf(name); + if (idx >= 0) { return idx; } + } + return lower.findIndex(h => h.includes('data') && (h.includes('dir') || h.includes('path'))); +} + +function findVariableNameColumnIdx(headers: string[]): number { + const lower = headers.map(h => h.toLowerCase().trim()); + const exact = ['variable name', 'env var', 'environment variable', 'var name', 'variable']; + for (const name of exact) { + const idx = lower.indexOf(name); + if (idx >= 0) { return idx; } + } + return lower.findIndex(h => h.includes('variable') || h.includes('env')); +} + +const ConnectionStringsTable = ({ table }: { table: TableBlock }): JSX.Element => { + const { submitPlanEdit, showEditError } = useContext(PlanLineEditContext); + const varNameIdx = findVariableNameColumnIdx(table.headers); + const originalNames = useMemo( + () => table.rows.map(r => (varNameIdx >= 0 ? (r[varNameIdx] ?? '').trim() : '')), + [table.rows, varNameIdx], + ); + const [drafts, setDrafts] = useState(originalNames); + const [savingRow, setSavingRow] = useState(null); + const focusedRowRef = useRef(null); + + useEffect(() => { + if (focusedRowRef.current === null) { setDrafts(originalNames); } + }, [originalNames]); + + const commitName = useCallback((rowIdx: number, next: string) => { + const trimmed = next.trim(); + const original = originalNames[rowIdx]; + if (varNameIdx < 0) { return; } + if (!trimmed) { + setDrafts(prev => { const arr = prev.slice(); arr[rowIdx] = original; return arr; }); + return; + } + if (trimmed === original) { return; } + const newRow = table.rows[rowIdx].slice(); + newRow[varNameIdx] = trimmed; + const newLine = buildTableRowMarkdown(newRow); + const lineIdx = table.rowLines[rowIdx]; + setSavingRow(rowIdx); + submitPlanEdit(lineIdx, lineIdx, newLine) + .catch((err: unknown) => { + setDrafts(prev => { const arr = prev.slice(); arr[rowIdx] = original; return arr; }); + showEditError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => setSavingRow(null)); + }, [originalNames, table, varNameIdx, submitPlanEdit, showEditError]); + + if (varNameIdx < 0) { + return ; + } + + return ( +
    + + + + {table.headers.map((h, hi) => ( + + + + {table.rows.map((row, ri) => ( + + {row.map((cell, ci) => { + if (ci === varNameIdx) { + return ( + + ); + } + return + ))} + +
    + ))} +
    + setDrafts(prev => { const arr = prev.slice(); arr[ri] = data.value; return arr; })} + onFocus={() => { focusedRowRef.current = ri; }} + onBlur={(e) => { + focusedRowRef.current = null; + commitName(ri, e.target.value); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + (e.target as HTMLInputElement).blur(); + } else if (e.key === 'Escape') { + e.preventDefault(); + setDrafts(prev => { const arr = prev.slice(); arr[ri] = originalNames[ri]; return arr; }); + (e.target as HTMLInputElement).blur(); + } + }} + /> + ; + })} +
    +
    + ); +}; + +const EXISTING_CONFIG_ACTIONS = ['Merge', 'Create', 'Skip'] as const; + +function findActionColumnIdx(headers: string[]): number { + const lower = headers.map(h => h.toLowerCase().trim()); + return lower.indexOf('action'); +} + +function normalizeAction(value: string): string { + const trimmed = value.trim(); + const match = EXISTING_CONFIG_ACTIONS.find(o => o.toLowerCase() === trimmed.toLowerCase()); + return match ?? trimmed; +} + +const ExistingConfigurationTable = ({ table }: { table: TableBlock }): JSX.Element => { + const { submitPlanEdit, showEditError } = useContext(PlanLineEditContext); + const actionIdx = findActionColumnIdx(table.headers); + const originalActions = useMemo( + () => table.rows.map(r => (actionIdx >= 0 ? normalizeAction(r[actionIdx] ?? '') : '')), + [table.rows, actionIdx], + ); + const [actions, setActions] = useState(originalActions); + const [savingRow, setSavingRow] = useState(null); + + useEffect(() => { + setActions(originalActions); + }, [originalActions]); + + const commitAction = useCallback((rowIdx: number, next: string) => { + const original = originalActions[rowIdx]; + if (next === original) { return; } + setActions(prev => { const arr = prev.slice(); arr[rowIdx] = next; return arr; }); + const newRow = table.rows[rowIdx].slice(); + newRow[actionIdx] = next; + const newLine = buildTableRowMarkdown(newRow); + const lineIdx = table.rowLines[rowIdx]; + setSavingRow(rowIdx); + submitPlanEdit(lineIdx, lineIdx, newLine) + .catch((err: unknown) => { + setActions(prev => { const arr = prev.slice(); arr[rowIdx] = original; return arr; }); + showEditError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => setSavingRow(null)); + }, [originalActions, table, actionIdx, submitPlanEdit, showEditError]); + + if (actionIdx < 0) { + return ; + } + + return ( +
    + + + + {table.headers.map((h, hi) => ( + + + + {table.rows.map((row, ri) => ( + + {row.map((cell, ci) => { + if (ci === actionIdx) { + const current = actions[ri] ?? ''; + return ( + + ); + } + return + ))} + +
    + ))} +
    + { + const picked = data.optionValue; + if (picked) { commitAction(ri, picked); } + }} + > + {EXISTING_CONFIG_ACTIONS.map(opt => ( + + ))} + + ; + })} +
    +
    + ); +}; + +const EmulatorsTable = ({ section, codeEditable }: { section: LocalPlanSection; codeEditable: boolean }): JSX.Element => { + const tableBlock = section.content.find((c): c is TableBlock => c.type === 'table'); + if (tableBlock) { + return ; + } + return ; +}; + +const EmulatorsMarkdownTable = ({ table }: { table: TableBlock }): JSX.Element => { + const { submitPlanEdit, showEditError } = useContext(PlanLineEditContext); + const imageIdx = findEmulatorImageColumnIdx(table.headers); + const dataDirIdx = findEmulatorDataDirColumnIdx(table.headers); + const originalImages = useMemo( + () => table.rows.map(r => (imageIdx >= 0 ? (r[imageIdx] ?? '').trim() : '')), + [table.rows, imageIdx], + ); + const originalDataDirs = useMemo( + () => table.rows.map(r => (dataDirIdx >= 0 ? (r[dataDirIdx] ?? '').trim() : '')), + [table.rows, dataDirIdx], + ); + const [imageDrafts, setImageDrafts] = useState(originalImages); + const [dataDirDrafts, setDataDirDrafts] = useState(originalDataDirs); + const [savingRow, setSavingRow] = useState(null); + const focusedImageRef = useRef(null); + const focusedDataDirRef = useRef(null); + + useEffect(() => { + if (focusedImageRef.current === null) { setImageDrafts(originalImages); } + }, [originalImages]); + useEffect(() => { + if (focusedDataDirRef.current === null) { setDataDirDrafts(originalDataDirs); } + }, [originalDataDirs]); + + const commitColumn = useCallback((rowIdx: number, columnIdx: number, next: string, originals: string[], revert: (rowIdx: number, value: string) => void) => { + const trimmed = next.trim(); + const original = originals[rowIdx]; + if (!trimmed) { + revert(rowIdx, original); + return; + } + if (trimmed === original) { return; } + const newRow = table.rows[rowIdx].slice(); + newRow[columnIdx] = trimmed; + const newLine = buildTableRowMarkdown(newRow); + const lineIdx = table.rowLines[rowIdx]; + setSavingRow(rowIdx); + submitPlanEdit(lineIdx, lineIdx, newLine) + .catch((err: unknown) => { + revert(rowIdx, original); + showEditError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => setSavingRow(null)); + }, [table, submitPlanEdit, showEditError]); + + const revertImage = useCallback((rowIdx: number, value: string) => { + setImageDrafts(prev => { const arr = prev.slice(); arr[rowIdx] = value; return arr; }); + }, []); + const revertDataDir = useCallback((rowIdx: number, value: string) => { + setDataDirDrafts(prev => { const arr = prev.slice(); arr[rowIdx] = value; return arr; }); + }, []); + + return ( +
    + + + + {table.headers.map((h, hi) => ( + + + + {table.rows.map((row, ri) => ( + + {row.map((cell, ci) => { + if (ci === imageIdx) { + return ( + + ); + } + if (ci === dataDirIdx) { + return ( + + ); + } + return + ))} + +
    + ))} +
    + setImageDrafts(prev => { const arr = prev.slice(); arr[ri] = data.value; return arr; })} + onFocus={() => { focusedImageRef.current = ri; }} + onBlur={(e) => { + focusedImageRef.current = null; + commitColumn(ri, imageIdx, e.target.value, originalImages, revertImage); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + (e.target as HTMLInputElement).blur(); + } else if (e.key === 'Escape') { + e.preventDefault(); + revertImage(ri, originalImages[ri]); + (e.target as HTMLInputElement).blur(); + } + }} + /> + + setDataDirDrafts(prev => { const arr = prev.slice(); arr[ri] = data.value; return arr; })} + onFocus={() => { focusedDataDirRef.current = ri; }} + onBlur={(e) => { + focusedDataDirRef.current = null; + commitColumn(ri, dataDirIdx, e.target.value, originalDataDirs, revertDataDir); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + (e.target as HTMLInputElement).blur(); + } else if (e.key === 'Escape') { + e.preventDefault(); + revertDataDir(ri, originalDataDirs[ri]); + (e.target as HTMLInputElement).blur(); + } + }} + /> + ; + })} +
    +
    + ); +}; + +const EmulatorsSubsectionTable = ({ section, codeEditable }: { section: LocalPlanSection; codeEditable: boolean }): JSX.Element => { + const { submitPlanEdit, showEditError } = useContext(PlanLineEditContext); + const emulators = useMemo(() => parseEmulators(section.content), [section.content]); + const originalImages = useMemo(() => emulators.map(e => e.image?.value ?? ''), [emulators]); + const originalDataDirs = useMemo(() => emulators.map(e => e.dataDir?.value ?? ''), [emulators]); + const [imageDrafts, setImageDrafts] = useState(originalImages); + const [dataDirDrafts, setDataDirDrafts] = useState(originalDataDirs); + const [savingImageRow, setSavingImageRow] = useState(null); + const [savingDataDirRow, setSavingDataDirRow] = useState(null); + const focusedImageRef = useRef(null); + const focusedDataDirRef = useRef(null); + + useEffect(() => { + if (focusedImageRef.current === null) { setImageDrafts(originalImages); } + }, [originalImages]); + useEffect(() => { + if (focusedDataDirRef.current === null) { setDataDirDrafts(originalDataDirs); } + }, [originalDataDirs]); + + const commitImage = useCallback((rowIdx: number, next: string) => { + const trimmed = next.trim(); + const original = originalImages[rowIdx]; + const target = emulators[rowIdx]?.image; + if (!target) { return; } + if (!trimmed) { + setImageDrafts(prev => { const arr = prev.slice(); arr[rowIdx] = original; return arr; }); + return; + } + if (trimmed === original) { return; } + const newLine = `${target.prefix}${trimmed}`; + setSavingImageRow(rowIdx); + submitPlanEdit(target.absoluteLine, target.absoluteLine, newLine) + .catch((err: unknown) => { + setImageDrafts(prev => { const arr = prev.slice(); arr[rowIdx] = original; return arr; }); + showEditError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => setSavingImageRow(null)); + }, [originalImages, emulators, submitPlanEdit, showEditError]); + + const commitDataDir = useCallback((rowIdx: number, next: string) => { + const trimmed = next.trim(); + const original = originalDataDirs[rowIdx]; + const target = emulators[rowIdx]?.dataDir; + if (!target) { return; } + if (!trimmed) { + setDataDirDrafts(prev => { const arr = prev.slice(); arr[rowIdx] = original; return arr; }); + return; + } + if (trimmed === original) { return; } + const newLine = `${target.prefix}${trimmed}${target.suffix}`; + setSavingDataDirRow(rowIdx); + submitPlanEdit(target.absoluteLine, target.absoluteLine, newLine) + .catch((err: unknown) => { + setDataDirDrafts(prev => { const arr = prev.slice(); arr[rowIdx] = original; return arr; }); + showEditError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => setSavingDataDirRow(null)); + }, [originalDataDirs, emulators, submitPlanEdit, showEditError]); + + if (emulators.length === 0) { + return ( + <> + {section.content.map((item, i) => ( + + ))} + + ); + } + + return ( +
    + + + + + + + + + + + + {emulators.map((emu, ri) => ( + + + + + + + ))} + +
    EmulatorImageData DirectoryPortConnection
    + + {emu.image ? ( + setImageDrafts(prev => { const arr = prev.slice(); arr[ri] = data.value; return arr; })} + onFocus={() => { focusedImageRef.current = ri; }} + onBlur={(e) => { + focusedImageRef.current = null; + commitImage(ri, e.target.value); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + (e.target as HTMLInputElement).blur(); + } else if (e.key === 'Escape') { + e.preventDefault(); + setImageDrafts(prev => { const arr = prev.slice(); arr[ri] = originalImages[ri]; return arr; }); + (e.target as HTMLInputElement).blur(); + } + }} + /> + ) : } + + {emu.dataDir ? ( + setDataDirDrafts(prev => { const arr = prev.slice(); arr[ri] = data.value; return arr; })} + onFocus={() => { focusedDataDirRef.current = ri; }} + onBlur={(e) => { + focusedDataDirRef.current = null; + commitDataDir(ri, e.target.value); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + (e.target as HTMLInputElement).blur(); + } else if (e.key === 'Escape') { + e.preventDefault(); + setDataDirDrafts(prev => { const arr = prev.slice(); arr[ri] = originalDataDirs[ri]; return arr; }); + (e.target as HTMLInputElement).blur(); + } + }} + /> + ) : } + {emu.port ? {emu.port} : }{emu.connection ? {emu.connection} : }
    +
    + ); +}; + +const EditableLaunchName = ({ paragraph, currentName }: { paragraph: ParagraphBlock; currentName: string }): JSX.Element => { + const { submitPlanEdit, showEditError } = useContext(PlanLineEditContext); + const [draft, setDraft] = useState(currentName); + const [isSaving, setIsSaving] = useState(false); + const focusedRef = useRef(false); + + useEffect(() => { + if (!focusedRef.current) { + setDraft(currentName); + } + }, [currentName]); + + const commit = useCallback((next: string) => { + const trimmed = next.trim(); + if (!trimmed) { + setDraft(currentName); + return; + } + if (trimmed === currentName) { + return; + } + const newLine = `Compound Launch Config Name: ✏️ **${trimmed}**`; + setIsSaving(true); + submitPlanEdit(paragraph.lineStart, paragraph.lineEnd, newLine) + .catch((err: unknown) => { + setDraft(currentName); + const message = err instanceof Error ? err.message : String(err); + showEditError(message); + }) + .finally(() => setIsSaving(false)); + }, [paragraph.lineStart, paragraph.lineEnd, currentName, submitPlanEdit, showEditError]); + + return ( +

    + Compound Launch Config Name + setDraft(data.value)} + onFocus={() => { focusedRef.current = true; }} + onBlur={(e) => { + focusedRef.current = false; + commit(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + (e.target as HTMLInputElement).blur(); + } else if (e.key === 'Escape') { + e.preventDefault(); + setDraft(currentName); + (e.target as HTMLInputElement).blur(); + } + }} + /> +

    + ); +}; + +const EditableGenerateToggleParagraph = ({ paragraph, checked }: { paragraph: ParagraphBlock; checked: boolean }): JSX.Element => { + const { submitPlanEdit, showEditError } = useContext(PlanLineEditContext); + const [current, setCurrent] = useState(checked); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { setCurrent(checked); }, [checked]); + + const toggle = useCallback(() => { + const next = !current; + const newLine = `**Generate:** [${next ? 'x' : ' '}]`; + setCurrent(next); + setIsSaving(true); + submitPlanEdit(paragraph.lineStart, paragraph.lineEnd, newLine) + .catch((err: unknown) => { + setCurrent(!next); + const message = err instanceof Error ? err.message : String(err); + showEditError(message); + }) + .finally(() => setIsSaving(false)); + }, [current, paragraph.lineStart, paragraph.lineEnd, submitPlanEdit, showEditError]); + + return ( +
    + toggle()} + label='Generate' + /> +
    + ); +}; + +const EditableValueTable = ({ table, valueColumnIdx }: { table: TableBlock; valueColumnIdx: number }): JSX.Element => { + const { submitPlanEdit, showEditError } = useContext(PlanLineEditContext); + const [drafts, setDrafts] = useState(() => table.rows.map(r => (r[valueColumnIdx] ?? '').trim())); + const [savingRow, setSavingRow] = useState(null); + const focusedRowRef = useRef(null); + + useEffect(() => { + if (focusedRowRef.current === null) { + setDrafts(table.rows.map(r => (r[valueColumnIdx] ?? '').trim())); + } + }, [table.rows, valueColumnIdx]); + + const commitRow = useCallback((rowIdx: number, next: string) => { + const trimmed = next.trim(); + const original = (table.rows[rowIdx][valueColumnIdx] ?? '').trim(); + if (trimmed === original) { + return; + } + const newRow = table.rows[rowIdx].slice(); + newRow[valueColumnIdx] = trimmed; + const newLine = buildTableRowMarkdown(newRow); + const lineIdx = table.rowLines[rowIdx]; + setSavingRow(rowIdx); + submitPlanEdit(lineIdx, lineIdx, newLine) + .catch((err: unknown) => { + setDrafts(prev => { + const arr = prev.slice(); + arr[rowIdx] = original; + return arr; + }); + const message = err instanceof Error ? err.message : String(err); + showEditError(message); + }) + .finally(() => setSavingRow(null)); + }, [table, valueColumnIdx, submitPlanEdit, showEditError]); + + return ( +
      + {table.rows.map((row, ri) => { + const otherColumns = row.filter((_, idx) => idx !== valueColumnIdx).join(' — ').trim(); + const labelHtml = otherColumns ? `${formatInline(otherColumns)}` : ''; + return ( +
    • + {labelHtml && } + { + setDrafts(prev => { + const arr = prev.slice(); + arr[ri] = data.value; + return arr; + }); + }} + onFocus={() => { focusedRowRef.current = ri; }} + onBlur={(e) => { + focusedRowRef.current = null; + commitRow(ri, e.target.value); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + (e.target as HTMLInputElement).blur(); + } else if (e.key === 'Escape') { + e.preventDefault(); + setDrafts(prev => { + const arr = prev.slice(); + arr[ri] = (table.rows[ri][valueColumnIdx] ?? '').trim(); + return arr; + }); + (e.target as HTMLInputElement).blur(); + } + }} + className='editableValueInput' + /> +
    • + ); + })} +
    + ); +}; + +const EditableGenerateTable = ({ table }: { table: TableBlock }): JSX.Element => { + const { submitPlanEdit, showEditError } = useContext(PlanLineEditContext); + const generateIdx = findGenerateColumnIdx(table.headers); + const [states, setStates] = useState(() => table.rows.map(r => /\[\s*x\s*\]/i.test(r[generateIdx] ?? ''))); + const [savingRow, setSavingRow] = useState(null); + + useEffect(() => { + setStates(table.rows.map(r => /\[\s*x\s*\]/i.test(r[generateIdx] ?? ''))); + }, [table.rows, generateIdx]); + + const toggleRow = useCallback((rowIdx: number) => { + const next = !states[rowIdx]; + const newRow = table.rows[rowIdx].slice(); + newRow[generateIdx] = next ? '[x]' : '[ ]'; + const newLine = buildTableRowMarkdown(newRow); + const lineIdx = table.rowLines[rowIdx]; + setStates(prev => { const arr = prev.slice(); arr[rowIdx] = next; return arr; }); + setSavingRow(rowIdx); + submitPlanEdit(lineIdx, lineIdx, newLine) + .catch((err: unknown) => { + setStates(prev => { const arr = prev.slice(); arr[rowIdx] = !next; return arr; }); + const message = err instanceof Error ? err.message : String(err); + showEditError(message); + }) + .finally(() => setSavingRow(null)); + }, [states, table, generateIdx, submitPlanEdit, showEditError]); + + return ( +
      + {table.rows.map((row, ri) => { + const labelParts = row.filter((_, idx) => idx !== generateIdx); + const head = formatInline(labelParts[0] ?? ''); + const tail = labelParts.slice(1).map(p => formatInline(p)).join(' — '); + const html = tail ? `${head}: ${tail}` : `${head}`; + return ( +
    • + toggleRow(ri)} + /> + +
    • + ); + })} +
    + ); +}; + const CodeBlock = ({ language, code }: { language: string; code: string }): JSX.Element => (
    {language && {language}} @@ -527,11 +1702,60 @@ const CodeBlock = ({ language, code }: { language: string; code: string }): JSX. ); const EditableCodeBlock = ({ language, code }: { language: string; code: string }): JSX.Element => { - const parsed = useMemo(() => safeParseJson(code), [code]); + const [currentCode, setCurrentCode] = useState(code); + const [isSaving, setIsSaving] = useState(false); + const { addCodeEditNote, updateCodeBlock, showCodeEditError } = useContext(CodeEditNoteContext); + + useEffect(() => { + setCurrentCode(code); + setIsSaving(false); + }, [code]); + + const parsed = useMemo(() => safeParseJson(currentCode), [currentCode]); const entries = useMemo(() => extractLaunchEntries(parsed), [parsed]); + const commitEntryName = useCallback(async (entry: LaunchEntry, currentName: string, nextName: string): Promise<'applied' | 'reset'> => { + const trimmed = nextName.trim(); + if (!trimmed || trimmed === currentName) { + return 'reset'; + } + + const nameKey = entry.kind === 'task' ? 'label' : 'name'; + const arrayKey = entry.kind === 'task' ? 'tasks' : entry.kind === 'compound' ? 'compounds' : 'configurations'; + + let newCode: string; + try { + const edits = jsoncParser.modify(currentCode, [arrayKey, entry.index, nameKey], trimmed, { + formattingOptions: { insertSpaces: true, tabSize: 4, eol: '\n' }, + }); + newCode = jsoncParser.applyEdits(currentCode, edits); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + showCodeEditError(`Couldn't update this launch configuration: ${message}`); + return 'reset'; + } + + if (!newCode || newCode === currentCode) { + return 'reset'; + } + + setIsSaving(true); + try { + await updateCodeBlock(currentCode, newCode, language); + setCurrentCode(newCode); + addCodeEditNote(language, currentCode, newCode); + return 'applied'; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + showCodeEditError(message); + return 'reset'; + } finally { + setIsSaving(false); + } + }, [currentCode, language, updateCodeBlock, addCodeEditNote, showCodeEditError]); + if (entries.length === 0) { - return ; + return ; } return ( @@ -541,8 +1765,8 @@ const EditableCodeBlock = ({ language, code }: { language: string; code: string ))} @@ -601,12 +1825,12 @@ function extractLaunchEntries(parsed: unknown): LaunchEntry[] { return entries; } -const LaunchConfigItem = ({ entry, originalCode, language }: { entry: LaunchEntry; originalCode: string; language: string }): JSX.Element => { - const { vscodeApi } = useContext(WebviewContext); - const { addCodeEditNote } = useContext(CodeEditNoteContext); - +const LaunchConfigItem = ({ entry, onCommitName, disabled }: { + entry: LaunchEntry; + onCommitName: (entry: LaunchEntry, currentName: string, nextName: string) => Promise<'applied' | 'reset'>; + disabled: boolean; +}): JSX.Element => { const nameKey = entry.kind === 'task' ? 'label' : 'name'; - const arrayKey = entry.kind === 'task' ? 'tasks' : entry.kind === 'compound' ? 'compounds' : 'configurations'; const currentName = String(entry.raw?.[nameKey] ?? ''); const description = entry.kind === 'task' ? describeTask(entry.raw) @@ -624,27 +1848,14 @@ const LaunchConfigItem = ({ entry, originalCode, language }: { entry: LaunchEntr }, [currentName]); const commit = useCallback((next: string) => { - const trimmed = next.trim(); - if (!trimmed || trimmed === currentName) { - setDraft(currentName); - return; - } - const edits = jsoncParser.modify(originalCode, [arrayKey, entry.index, nameKey], trimmed, { - formattingOptions: { insertSpaces: true, tabSize: 4, eol: '\n' }, - }); - const newCode = jsoncParser.applyEdits(originalCode, edits); - if (!newCode || newCode === originalCode) { + void onCommitName(entry, currentName, next).then((result) => { + if (result !== 'applied') { + setDraft(currentName); + } + }).catch(() => { setDraft(currentName); - return; - } - vscodeApi.postMessage({ - command: 'updateCodeBlock', - originalCode, - language, - newCode, }); - addCodeEditNote(language, originalCode, newCode); - }, [currentName, arrayKey, entry.index, nameKey, originalCode, language, vscodeApi, addCodeEditNote]); + }, [entry, currentName, onCommitName]); const inputId = `launchConfigName-${entry.kind}-${entry.index}`; @@ -656,6 +1867,7 @@ const LaunchConfigItem = ({ entry, originalCode, language }: { entry: LaunchEntr id={inputId} size='small' value={draft} + disabled={disabled} onChange={(_, data) => setDraft(data.value)} onFocus={() => { focusedRef.current = true; }} onBlur={(e) => { @@ -828,6 +2040,7 @@ const BlockquoteBlock = ({ text }: { text: string }): JSX.Element => ( const SubsectionBlock = ({ title, content, codeEditable }: { title: string; content: LocalPlanContent[]; codeEditable: boolean }): JSX.Element => { const [open, setOpen] = useState(false); + const parentSection = useContext(PlanSectionContext); return (
    @@ -836,23 +2049,31 @@ const SubsectionBlock = ({ title, content, codeEditable }: { title: string; cont

    {title}

    {open && ( -
    - {content.map((item, i) => ( - - ))} -
    + +
    + {content.map((item, i) => ( + + ))} +
    +
    )}
    ); }; function formatInline(text: string): string { - return escapeHtml(text) + return escapeHtml(stripPencilIcons(text)) .replace(/`([^`]+)`/g, '$1') .replace(/\*\*(.+?)\*\*/g, '$1') .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); } +function stripPencilIcons(text: string): string { + // Remove the pencil emoji (with or without VS16 variation selector) and any + // surrounding whitespace so the rendered view stays clean. + return text.replace(/\s*\u270F\uFE0F?\s*/gu, ' ').replace(/\s{2,}/g, ' ').trim(); +} + function escapeHtml(text: string): string { return text .replace(/&/g, '&') diff --git a/src/webviews/copilotOnRails/views/ScaffoldPlanView.tsx b/src/webviews/copilotOnRails/views/ScaffoldPlanView.tsx index 69416dc5..589719cd 100644 --- a/src/webviews/copilotOnRails/views/ScaffoldPlanView.tsx +++ b/src/webviews/copilotOnRails/views/ScaffoldPlanView.tsx @@ -7,6 +7,7 @@ import { Button, CounterBadge, Dialog, DialogActions, DialogBody, DialogContent, import { CheckmarkRegular, CommentEditRegular, DismissRegular, DocumentRegular, SendRegular, WarningRegular } from '@fluentui/react-icons'; import { WebviewContext } from '@microsoft/vscode-azext-webview/webview'; import { useCallback, useContext, useEffect, useMemo, useRef, useState, type JSX } from 'react'; +import { StageProgress } from './components/StageProgress'; import './styles/scaffoldPlanView.scss'; import { type PlanContent, type PlanData, type PlanSection, type TreeNode } from './utils/parseScaffoldPlanMarkdown'; @@ -265,6 +266,7 @@ export const ScaffoldPlanView = (): JSX.Element => { return (
    +
    diff --git a/src/webviews/copilotOnRails/views/components/StageProgress.tsx b/src/webviews/copilotOnRails/views/components/StageProgress.tsx new file mode 100644 index 00000000..f9c975d0 --- /dev/null +++ b/src/webviews/copilotOnRails/views/components/StageProgress.tsx @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type JSX } from 'react'; +import '../styles/stageProgress.scss'; + +interface StageProgressProps { + currentStage: 0 | 1 | 2; +} + +const stages = ['Project Scaffolding', 'Local Development', 'Deployment'] as const; + +export const StageProgress = ({ currentStage }: StageProgressProps): JSX.Element => { + const stageSegmentPercent = 100 / stages.length; + const currentStageOffsetPercent = (currentStage / stages.length) * 100; + + return ( +
    +
    +
    +
    +
    + +
    + {stages.map((label, idx) => { + const state = idx < currentStage ? 'completed' : idx === currentStage ? 'current' : 'upcoming'; + return ( +
    + + {label} +
    + ); + })} +
    +
    +
    + ); +}; diff --git a/src/webviews/copilotOnRails/views/styles/localPlanView.scss b/src/webviews/copilotOnRails/views/styles/localPlanView.scss index 8f32e9da..70c4ebfb 100644 --- a/src/webviews/copilotOnRails/views/styles/localPlanView.scss +++ b/src/webviews/copilotOnRails/views/styles/localPlanView.scss @@ -28,6 +28,34 @@ border-color: var(--vscode-button-border, var(--vscode-contrastBorder, var(--vscode-button-foreground))) !important; } +// Fluent UI's Dropdown listbox is portal-mounted on document.body and renders +// without VS Code theme tokens, so its surface appears transparent over the +// page. Give it an opaque background, border, and elevated z-index so the +// options are legible. +.fui-Listbox { + background-color: var(--vscode-dropdown-background, var(--vscode-editorWidget-background)) !important; + color: var(--vscode-dropdown-foreground, var(--vscode-foreground)) !important; + border: 1px solid var(--vscode-dropdown-border, var(--vscode-editorWidget-border, var(--vscode-focusBorder))) !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.45) !important; + z-index: 1000; +} + +.fui-Option { + color: var(--vscode-dropdown-foreground, var(--vscode-foreground)) !important; + background-color: transparent !important; + + &:hover, + &[data-highlighted='true'] { + background-color: var(--vscode-list-hoverBackground) !important; + color: var(--vscode-list-hoverForeground, var(--vscode-foreground)) !important; + } + + &[aria-selected='true'] { + background-color: var(--vscode-list-activeSelectionBackground) !important; + color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground)) !important; + } +} + .localPlanView { display: flex; flex-direction: column; @@ -388,6 +416,84 @@ } } + .dataTableWrapper { + width: 100%; + overflow-x: auto; + } + + .dataTable { + width: 100%; + border-collapse: collapse; + font-size: 0.78em; + line-height: 1.5; + color: var(--vscode-descriptionForeground); + + th, + td { + text-align: left; + padding: 6px 10px; + border-bottom: 1px solid var(--vscode-panel-border, var(--vscode-editorWidget-border, rgba(128, 128, 128, 0.35))); + vertical-align: top; + white-space: nowrap; + } + + th { + color: var(--vscode-foreground); + font-weight: 600; + background: var(--vscode-editorWidget-background, transparent); + border-bottom: 1px solid var(--vscode-focusBorder, var(--vscode-panel-border, rgba(128, 128, 128, 0.5))); + } + + tbody tr:nth-child(even) td { + background: var(--vscode-list-hoverBackground, transparent); + } + + tbody tr:last-child td { + border-bottom: none; + } + + .dataTableCheckboxCell { + width: 1%; + text-align: center; + white-space: nowrap; + } + + .dataTableInputCell { + min-width: 180px; + + .fui-Input { + width: 100%; + } + } + + .dataTableDropdownCell { + min-width: 120px; + + .fui-Dropdown { + min-width: 100px; + width: 100%; + } + } + + .dataTableMuted { + color: var(--vscode-descriptionForeground); + opacity: 0.7; + } + + code { + background: var(--vscode-textCodeBlock-background); + padding: 1px 4px; + border-radius: 3px; + font-family: var(--vscode-editor-font-family), monospace; + font-size: 0.95em; + } + + strong { + color: var(--vscode-foreground); + font-weight: 600; + } + } + .bulletList { margin: 0; padding: 0 0 0 18px; @@ -454,6 +560,61 @@ } } + .editableLaunchName { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + + .editablePencil { + font-size: 0.85em; + opacity: 0.85; + } + + .fui-Input { + min-width: 220px; + flex: 1 1 220px; + } + } + + .editableGenerateToggle { + margin: 0; + padding: 0; + font-size: 0.85em; + } + + .editableValueList, + .editableGenerateList { + list-style: none; + padding: 0; + margin: 0; + + li { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + + .editableValueLabel, + .editableGenerateLabel { + color: var(--vscode-descriptionForeground); + font-size: 0.8em; + flex: 0 0 auto; + } + + .editableValueLabel strong, + .editableGenerateLabel strong { + color: var(--vscode-foreground); + font-weight: 600; + } + + .editableValueInput { + flex: 1 1 160px; + min-width: 120px; + } + } + } + .link { color: var(--vscode-textLink-foreground); cursor: default; diff --git a/src/webviews/copilotOnRails/views/styles/stageProgress.scss b/src/webviews/copilotOnRails/views/styles/stageProgress.scss new file mode 100644 index 00000000..bc8b0620 --- /dev/null +++ b/src/webviews/copilotOnRails/views/styles/stageProgress.scss @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.stageProgressTop { + margin-bottom: 4px; +} + +.stageProgress { + display: flex; + flex-direction: column; + gap: 6px; + padding: 6px 10px; + border: 1px solid var(--vscode-widget-border); + border-radius: 8px; + background: var(--vscode-editor-background); +} + +.stageProgressTrack { + position: relative; + height: 3px; + border-radius: 999px; + overflow: hidden; + background: var(--vscode-widget-border); +} + +.stageProgressFill { + position: absolute; + top: 0; + height: 100%; + background: var(--vscode-focusBorder); + transition: left 180ms ease-out, width 180ms ease-out; +} + +.stageProgressSteps { + margin: 0; + display: flex; + justify-content: space-between; + gap: 6px; +} + +.stageProgressStep { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 4px; + flex: 1; + min-width: 0; +} + +.stageProgressMarker { + width: 16px; + height: 16px; + border-radius: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 9px; + font-weight: 700; + color: var(--vscode-descriptionForeground); + background: var(--vscode-editor-background); + border: 1px solid var(--vscode-widget-border); +} + +.stageProgressLabel { + font-size: 0.66em; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--vscode-descriptionForeground); + line-height: 1.1; +} + +.stageProgressStep.completed .stageProgressMarker { + color: var(--vscode-descriptionForeground); + background: var(--vscode-editor-background); + border-color: var(--vscode-focusBorder); + opacity: 0.75; +} + +.stageProgressStep.current .stageProgressMarker { + color: var(--vscode-button-foreground); + background: var(--vscode-focusBorder); + border-color: var(--vscode-focusBorder); + box-shadow: 0 0 0 2px var(--vscode-editorWidget-border); + transform: scale(1.08); +} + +.stageProgressStep.completed .stageProgressLabel { + color: var(--vscode-descriptionForeground); + opacity: 1; +} + +.stageProgressStep.current .stageProgressLabel { + color: var(--vscode-foreground); + font-weight: 700; +} diff --git a/src/webviews/copilotOnRails/views/utils/parseLocalPlanMarkdown.ts b/src/webviews/copilotOnRails/views/utils/parseLocalPlanMarkdown.ts index f3531212..5e510c3a 100644 --- a/src/webviews/copilotOnRails/views/utils/parseLocalPlanMarkdown.ts +++ b/src/webviews/copilotOnRails/views/utils/parseLocalPlanMarkdown.ts @@ -21,13 +21,20 @@ export interface LocalPlanSection { content: LocalPlanContent[]; } +export interface SourceRange { + /** 0-indexed inclusive line in the source markdown that this block starts at. */ + lineStart: number; + /** 0-indexed inclusive line in the source markdown that this block ends at. */ + lineEnd: number; +} + export type LocalPlanContent = - | { type: 'table'; headers: string[]; rows: string[][] } - | { type: 'blockquote'; text: string } - | { type: 'paragraph'; text: string } - | { type: 'codeBlock'; language: string; code: string } - | { type: 'bulletList'; items: string[] } - | { type: 'subsection'; title: string; content: LocalPlanContent[] }; + | ({ type: 'table'; headers: string[]; rows: string[][]; rowLines: number[] } & SourceRange) + | ({ type: 'blockquote'; text: string } & SourceRange) + | ({ type: 'paragraph'; text: string } & SourceRange) + | ({ type: 'codeBlock'; language: string; code: string } & SourceRange) + | ({ type: 'bulletList'; items: string[] } & SourceRange) + | ({ type: 'subsection'; title: string; content: LocalPlanContent[] } & SourceRange); export function parseLocalPlanMarkdown(markdown: string): LocalPlanData { const lines = markdown.replace(/\r\n/g, '\n').split('\n'); @@ -125,6 +132,8 @@ function parseContent(lines: string[], start: number, end: number): LocalPlanCon continue; } + const blockStart = i; + // Sub-section heading (###) const subMatch = trimmed.match(/^###\s+(.+)$/); if (subMatch) { @@ -135,7 +144,7 @@ function parseContent(lines: string[], start: number, end: number): LocalPlanCon subEnd++; } const subContent = parseContent(lines, i, subEnd); - content.push({ type: 'subsection', title: subTitle, content: subContent }); + content.push({ type: 'subsection', title: subTitle, content: subContent, lineStart: blockStart, lineEnd: subEnd - 1 }); i = subEnd; continue; } @@ -150,13 +159,14 @@ function parseContent(lines: string[], start: number, end: number): LocalPlanCon i++; } if (i < end) { i++; } - content.push({ type: 'codeBlock', language: lang, code: codeLines.join('\n') }); + content.push({ type: 'codeBlock', language: lang, code: codeLines.join('\n'), lineStart: blockStart, lineEnd: i - 1 }); continue; } // Table if (trimmed.startsWith('|')) { const headers = parseTableRow(trimmed); + const rowLines: number[] = []; i++; if (i < end && lines[i].trim().match(/^\|[-\s|:]+$/)) { i++; @@ -164,9 +174,10 @@ function parseContent(lines: string[], start: number, end: number): LocalPlanCon const rows: string[][] = []; while (i < end && lines[i].trim().startsWith('|')) { rows.push(parseTableRow(lines[i].trim())); + rowLines.push(i); i++; } - content.push({ type: 'table', headers, rows }); + content.push({ type: 'table', headers, rows, rowLines, lineStart: blockStart, lineEnd: i - 1 }); continue; } @@ -177,7 +188,7 @@ function parseContent(lines: string[], start: number, end: number): LocalPlanCon items.push(lines[i].trim().substring(2).trim()); i++; } - content.push({ type: 'bulletList', items }); + content.push({ type: 'bulletList', items, lineStart: blockStart, lineEnd: i - 1 }); continue; } @@ -188,12 +199,12 @@ function parseContent(lines: string[], start: number, end: number): LocalPlanCon quoteLines.push(lines[i].trim().replace(/^>\s?/, '')); i++; } - content.push({ type: 'blockquote', text: quoteLines.join(' ').trim() }); + content.push({ type: 'blockquote', text: quoteLines.join(' ').trim(), lineStart: blockStart, lineEnd: i - 1 }); continue; } // Paragraph - content.push({ type: 'paragraph', text: trimmed }); + content.push({ type: 'paragraph', text: trimmed, lineStart: blockStart, lineEnd: blockStart }); i++; } @@ -201,8 +212,12 @@ function parseContent(lines: string[], start: number, end: number): LocalPlanCon } function parseTableRow(line: string): string[] { - return line - .split('|') - .slice(1, -1) - .map((cell) => cell.trim()); + let parts = line.split('|'); + if (parts.length > 0 && parts[0].trim() === '') { + parts = parts.slice(1); + } + if (parts.length > 0 && parts[parts.length - 1].trim() === '') { + parts = parts.slice(0, -1); + } + return parts.map((cell) => cell.trim()); } From adcd4cb1d71ec5c56f6b2ce1386018145423294a Mon Sep 17 00:00:00 2001 From: Megan Mott Date: Fri, 29 May 2026 09:33:32 -0700 Subject: [PATCH 4/4] change box sizes --- .../copilotOnRails/views/styles/localPlanView.scss | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/webviews/copilotOnRails/views/styles/localPlanView.scss b/src/webviews/copilotOnRails/views/styles/localPlanView.scss index 70c4ebfb..60b625c1 100644 --- a/src/webviews/copilotOnRails/views/styles/localPlanView.scss +++ b/src/webviews/copilotOnRails/views/styles/localPlanView.scss @@ -460,18 +460,22 @@ .dataTableInputCell { min-width: 180px; + max-width: 260px; .fui-Input { width: 100%; + max-width: 240px; } } .dataTableDropdownCell { min-width: 120px; + max-width: 180px; .fui-Dropdown { min-width: 100px; width: 100%; + max-width: 160px; } } @@ -572,8 +576,9 @@ } .fui-Input { - min-width: 220px; - flex: 1 1 220px; + min-width: 180px; + flex: 0 0 240px; + max-width: 240px; } } @@ -609,8 +614,9 @@ } .editableValueInput { - flex: 1 1 160px; - min-width: 120px; + flex: 0 0 220px; + width: 220px; + max-width: 220px; } } }