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
13 changes: 8 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -431,11 +431,6 @@
"name": "Workspace",
"visibility": "visible"
},
{
"id": "azureProject",
"name": "%azureProject.viewName%",
"visibility": "visible"
},
{
"id": "azureTenantsView",
"name": "Accounts & Tenants",
Expand All @@ -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": [
Expand Down
4 changes: 2 additions & 2 deletions resources/agents/azure-local-debug.agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
77 changes: 73 additions & 4 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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<void | TreeDataItem | TreeDataItem[] | null | undefined>();
context.subscriptions.push(refreshAzureTreeEmitter);
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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<void> {
const update = async (): Promise<void> => {
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<boolean> {
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;
}
238 changes: 238 additions & 0 deletions src/tree/project/AzureProjectProgressTreeDataProvider.ts
Original file line number Diff line number Diff line change
@@ -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<ProgressNode>, vscode.Disposable {
private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter<ProgressNode | undefined | void>();
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<ProgressNode[]> {
if (!element) {
return this.getStageNodes();
}

if (element.kind === 'stage') {
return this.getActionNodes(element);
}

return [];
}

private async getStageNodes(): Promise<StageNode[]> {
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';
}
Loading
Loading