Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,21 @@ import { getCopilotOnRailsBundleLocation } from "../copilotOnRailsBundleLocation
export type { DeploymentPlanViewConfiguration, DeploymentPlanViewStrings };

export class DeploymentPlanViewController extends WebviewController<DeploymentPlanViewConfiguration> {
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();
Expand All @@ -49,13 +53,18 @@ export class DeploymentPlanViewController extends WebviewController<DeploymentPl
}

updateDeploymentPlanData(planData: DeploymentPlanData, sourceFileUri?: vscode.Uri): void {
this.latestPlanData = planData;
if (sourceFileUri) {
this.sourceFileUri = sourceFileUri;
}
void this.panel.webview.postMessage({ command: 'setDeploymentPlanData', data: planData });
void this.postDeploymentPlanData();
void this.panel.webview.postMessage({ command: 'revisionComplete' });
}

private async postDeploymentPlanData(): Promise<void> {
await this.panel.webview.postMessage({ command: 'setDeploymentPlanData', data: this.latestPlanData });
}

private openSourceFile(): void {
if (!this.sourceFileUri) {
void vscode.window.showWarningMessage(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export class LocalPlanViewController extends WebviewController<Record<string, ne

this.sourceFileUri = sourceFileUri;

this.panel.webview.onDidReceiveMessage((message: { command: string; data?: LocalPlanData; prompt?: string }) => {
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 });
Expand Down Expand Up @@ -46,6 +46,9 @@ export class LocalPlanViewController extends WebviewController<Record<string, ne
case 'openSourceFile':
this.openSourceFile();
break;
case 'updateCodeBlock':
void this.updateCodeBlock(message.originalCode, message.newCode);
break;
}
});
}
Expand All @@ -67,4 +70,44 @@ export class LocalPlanViewController extends WebviewController<Record<string, ne
}
void vscode.commands.executeCommand('vscode.open', this.sourceFileUri);
}

private async updateCodeBlock(originalCode: string | undefined, newCode: string | undefined): Promise<void> {
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;
Comment on lines +92 to +103
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),
});
}
}
}
35 changes: 31 additions & 4 deletions src/webviews/copilotOnRails/extension/openDeploymentPlanView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -25,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'),
Expand All @@ -42,8 +45,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'),
};
Expand All @@ -58,7 +61,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<void> {
const planData = tryParseDeploymentPlan(content, sourceFileUri);
const liveSubscriptions = await getAvailableAzureSubscriptions();
if (liveSubscriptions) {
planData.availableSubscriptions = liveSubscriptions;
if (planData.subscription && !liveSubscriptions.includes(planData.subscription)) {
planData.subscription = '';
}
}
Comment on lines 63 to +75

if (currentDeploymentPlanViewController) {
currentDeploymentPlanViewController.updateDeploymentPlanData(planData, sourceFileUri);
Expand All @@ -75,6 +89,19 @@ export function openDeploymentPlanViewWithContent(content: string, sourceFileUri
});
}

async function getAvailableAzureSubscriptions(): Promise<string[] | undefined> {
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;
Expand Down Expand Up @@ -103,7 +130,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,
},
};
Expand All @@ -112,7 +139,7 @@ function tryParseDeploymentPlan(content: string, sourceFileUri: vscode.Uri | und
}

export async function openDeploymentPlanViewFromWorkspace(): Promise<void> {
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;
Expand Down
74 changes: 33 additions & 41 deletions src/webviews/copilotOnRails/views/DeploymentPlanView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -287,25 +287,27 @@ export const DeploymentPlanView = (): JSX.Element => {
</div>
</div>
<div className='headerActions'>
<Button
appearance='subtle'
aria-label={strings.feedbackButtonAriaLabel}
icon={
<span className='feedbackIconWrapper'>
<CommentEditRegular />
{hasEdits && (
<CounterBadge
className='feedbackBadge'
count={feedbackItems.length + (freeformDraft.trim() ? 1 : 0)}
size='small'
color='danger'
/>
)}
</span>
}
disabled={isAwaitingRevision}
onClick={() => setDrawerOpen(v => !v)}
/>
<Tooltip content={strings.feedbackButtonTooltip} relationship='label'>
<Button
appearance='subtle'
aria-label={strings.feedbackButtonAriaLabel}
icon={
<span className='feedbackIconWrapper'>
<CommentEditRegular />
{hasEdits && (
<CounterBadge
className='feedbackBadge'
count={feedbackItems.length + (freeformDraft.trim() ? 1 : 0)}
size='small'
color='danger'
/>
)}
</span>
}
disabled={isAwaitingRevision}
onClick={() => setDrawerOpen(v => !v)}
/>
</Tooltip>
<Button
appearance='primary'
icon={<CheckmarkRegular />}
Expand Down Expand Up @@ -374,21 +376,6 @@ export const DeploymentPlanView = (): JSX.Element => {
</div>
</div>

<details className='sectionCard'>
<summary><h2>{strings.architectureDiagramHeading}</h2></summary>
<MermaidDiagram definition={plan.mermaidDiagram} noDiagramAvailableLabel={strings.noDiagramAvailable} />
</details>

<details className='sectionCard'>
<summary><h2>{strings.workspaceScanHeading}</h2></summary>
<PlanTable table={plan.workspaceScan} />
</details>

<details className='sectionCard'>
<summary><h2>{strings.decisionsHeading}</h2></summary>
<PlanTable table={plan.decisions} />
</details>

<div className='sectionCard'>
<h2>{strings.azureResourcesHeading}</h2>
<ResourcesTable
Expand All @@ -398,6 +385,16 @@ export const DeploymentPlanView = (): JSX.Element => {
onSkuChange={handleResourceSkuChange}
/>
</div>

<details className='sectionCard' open>
<summary><h2>{strings.architectureDiagramHeading}</h2></summary>
<MermaidDiagram definition={plan.mermaidDiagram} noDiagramAvailableLabel={strings.noDiagramAvailable} />
</details>

<details className='sectionCard'>
<summary><h2>{strings.workspaceScanHeading}</h2></summary>
<PlanTable table={plan.workspaceScan} />
</details>
</div>

{drawerOpen && !isAwaitingRevision && (
Expand Down Expand Up @@ -450,14 +447,9 @@ const FeedbackDrawer = ({ strings, items, freeformDraft, onFreeformChange, onAdd
onClick={onClose}
/>
</div>
<p className='drawerInfo'>{strings.feedbackDrawerInfoTooltip}</p>

<div className='drawerBody'>
{items.length === 0 && (
<p className='drawerHint'>
{strings.drawerHint}
</p>
)}

{items.length > 0 && (
<ul className='feedbackList'>
{items.map(item => (
Expand Down
Loading
Loading