diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index 6d452a94459..31681f4d246 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -40,6 +40,12 @@ Aspire resource action + + Aspire restore completed + + + Aspire restore completed for {0}. + Aspire terminal @@ -58,6 +64,9 @@ Automatically open the dashboard in the browser. + + Automatically run 'aspire restore' when the workspace opens and whenever aspire.config.json changes (e.g. after switching git branches). Keeps integration packages in sync and prevents editor errors. + Build failed for project {0} with error: {1}. @@ -376,6 +385,12 @@ Running AppHosts + + Running aspire restore ({0}/{1} projects)... + + + Running aspire restore on {0}... + Scaffold a new Aspire project from a starter template. The template includes an apphost orchestrator, a sample API, and a web frontend. [Create new project](command:aspire-vscode.new) @@ -490,5 +505,8 @@ You're all set! Add integrations for databases, messaging, and cloud services, or deploy your app to production. [Add an integration](command:aspire-vscode.add) [Open Aspire docs](https://aspire.dev/docs/) + + aspire restore failed for {0}: {1} + \ No newline at end of file diff --git a/extension/package.json b/extension/package.json index 8de74192425..e8f9a330520 100644 --- a/extension/package.json +++ b/extension/package.json @@ -670,6 +670,12 @@ "default": true, "description": "%configuration.aspire.enableGutterDecorations%", "scope": "window" + }, + "aspire.enableAutoRestore": { + "type": "boolean", + "default": true, + "description": "%configuration.aspire.enableAutoRestore%", + "scope": "window" } } }, diff --git a/extension/package.nls.json b/extension/package.nls.json index 241240c5769..f3c9e231142 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -39,6 +39,7 @@ "configuration.aspire.globalAppHostsPollingInterval": "Polling interval in milliseconds for fetching all running apphosts (used in global view). Minimum: 1000.", "configuration.aspire.enableCodeLens": "Show CodeLens actions (state, restart, stop, logs) inline above resource declarations in apphost files.", "configuration.aspire.enableGutterDecorations": "Show colored status dots in the editor gutter next to resource declarations in apphost files.", + "configuration.aspire.enableAutoRestore": "Automatically run 'aspire restore' when the workspace opens and whenever aspire.config.json changes (e.g. after switching git branches). Keeps integration packages in sync and prevents editor errors.", "command.runAppHost": "Run Aspire apphost", "command.debugAppHost": "Debug Aspire apphost", "aspire-vscode.strings.noCsprojFound": "No apphost found in the current workspace.", @@ -117,6 +118,11 @@ "aspire-vscode.strings.selectDirectoryTitle": "Select directory", "aspire-vscode.strings.selectFileTitle": "Select file", "aspire-vscode.strings.enterPipelineStep": "Enter the pipeline step to execute", + "aspire-vscode.strings.runningAspireRestore": "Running aspire restore on {0} ...", + "aspire-vscode.strings.runningAspireRestoreProgress": "Running aspire restore ({0}/{1} projects)...", + "aspire-vscode.strings.aspireRestoreCompleted": "Aspire restore completed for {0}.", + "aspire-vscode.strings.aspireRestoreAllCompleted": "Aspire restore completed", + "aspire-vscode.strings.aspireRestoreFailed": "aspire restore failed for {0}: {1}", "viewsContainers.aspirePanel.title": "Aspire", "views.runningAppHosts.name": "Running AppHosts", "views.runningAppHosts.loading": "Searching for running apphosts...", diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 36e3543519f..14eb82deddd 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -25,6 +25,7 @@ import { settingsCommand } from './commands/settings'; import { openLocalSettingsCommand, openGlobalSettingsCommand } from './commands/openSettings'; import { checkCliAvailableOrRedirect, checkForExistingAppHostPathInWorkspace } from './utils/workspace'; import { AspireEditorCommandProvider } from './editor/AspireEditorCommandProvider'; +import { AspirePackageRestoreProvider } from './utils/AspirePackageRestoreProvider'; import { AspireAppHostTreeProvider } from './views/AspireAppHostTreeProvider'; import { AppHostDataRepository } from './views/AppHostDataRepository'; import { installCliStableCommand, installCliDailyCommand, verifyCliInstalledCommand } from './commands/walkthroughCommands'; @@ -191,6 +192,14 @@ export async function activate(context: vscode.ExtensionContext) { // any user-visible errors should be handled within checkForExistingAppHostPathInWorkspace. }); } + + // Auto-restore: run `aspire restore` on workspace open and when aspire.config.json changes + const packageRestoreProvider = new AspirePackageRestoreProvider(terminalProvider); + context.subscriptions.push(packageRestoreProvider); + void packageRestoreProvider.activate().catch(err => { + extensionLogOutputChannel.warn(`Auto-restore activation failed: ${String(err)}`); + }); + // Return exported API for tests or other extensions return { rpcServerInfo: rpcServer.connectionInfo, diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index b64e1db0c79..7ebc72c346e 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -96,6 +96,11 @@ export const openCliInstallInstructions = vscode.l10n.t('See CLI installation in export const cliNotAvailable = vscode.l10n.t('Aspire CLI is not available on PATH. Please install it and restart VS Code.'); export const cliFoundAtDefaultPath = (path: string) => vscode.l10n.t('Aspire CLI found at {0}. The extension will use this path.', path); export const selectDirectoryTitle = vscode.l10n.t('Select directory'); +export const runningAspireRestore = (configPath: string) => vscode.l10n.t('Running aspire restore on {0}...', configPath); +export const runningAspireRestoreProgress = (completed: number, total: number) => vscode.l10n.t('Running aspire restore ({0}/{1} projects)...', completed, total); +export const aspireRestoreCompleted = (configPath: string) => vscode.l10n.t('Aspire restore completed for {0}.', configPath); +export const aspireRestoreAllCompleted = vscode.l10n.t('Aspire restore completed'); +export const aspireRestoreFailed = (configPath: string, error: string) => vscode.l10n.t('aspire restore failed for {0}: {1}', configPath, error); export const selectFileTitle = vscode.l10n.t('Select file'); export const enterPipelineStep = vscode.l10n.t('Enter the pipeline step to execute'); diff --git a/extension/src/utils/AspirePackageRestoreProvider.ts b/extension/src/utils/AspirePackageRestoreProvider.ts new file mode 100644 index 00000000000..31db746a3a9 --- /dev/null +++ b/extension/src/utils/AspirePackageRestoreProvider.ts @@ -0,0 +1,204 @@ +import * as vscode from 'vscode'; +import path from 'path'; +import { aspireConfigFileName } from './cliTypes'; +import { findAspireSettingsFiles } from './workspace'; +import { ChildProcessWithoutNullStreams } from 'child_process'; +import { spawnCliProcess } from '../debugger/languages/cli'; +import { AspireTerminalProvider } from './AspireTerminalProvider'; +import { extensionLogOutputChannel } from './logging'; +import { getEnableAutoRestore } from './settings'; +import { runningAspireRestore, runningAspireRestoreProgress, aspireRestoreCompleted, aspireRestoreAllCompleted, aspireRestoreFailed } from '../loc/strings'; + +/** + * Runs `aspire restore` on workspace open and whenever aspire.config.json content changes + * (e.g. after a git branch switch). + */ +export class AspirePackageRestoreProvider implements vscode.Disposable { + private static readonly _maxConcurrency = 4; + + private readonly _disposables: vscode.Disposable[] = []; + private readonly _terminalProvider: AspireTerminalProvider; + private readonly _statusBarItem: vscode.StatusBarItem; + private readonly _lastContent = new Map(); // fsPath → content + private readonly _active = new Map(); // configDir → relativePath + private readonly _childProcesses = new Set(); + private readonly _timeouts = new Set>(); + private readonly _pendingRestore = new Set(); // configDirs needing re-restore + private _total = 0; + private _completed = 0; + + constructor(terminalProvider: AspireTerminalProvider) { + this._terminalProvider = terminalProvider; + this._statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 0); + this._disposables.push(this._statusBarItem); + } + + async activate(): Promise { + this._disposables.push( + vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('aspire.enableAutoRestore') && getEnableAutoRestore()) { + this._restoreAll(); + } + }) + ); + + if (!getEnableAutoRestore()) { + extensionLogOutputChannel.info('Auto-restore is disabled'); + return; + } + + await this._restoreAll(); + this._watchConfigFiles(); + } + + private async _restoreAll(): Promise { + const allConfigs = await findAspireSettingsFiles(); + const configs = allConfigs.filter(uri => uri.fsPath.endsWith(aspireConfigFileName)); + if (configs.length === 0) { + return; + } + + this._total = configs.length; + this._completed = 0; + + const pending = new Set>(); + for (const uri of configs) { + const p = this._restoreIfChanged(uri, true).finally(() => pending.delete(p)); + pending.add(p); + if (pending.size >= AspirePackageRestoreProvider._maxConcurrency) { + await Promise.race(pending); + } + } + await Promise.all(pending); + } + + private _watchConfigFiles(): void { + for (const folder of vscode.workspace.workspaceFolders ?? []) { + const watcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(folder, `**/${aspireConfigFileName}`) + ); + watcher.onDidChange(uri => this._onChanged(uri)); + watcher.onDidCreate(uri => this._onChanged(uri)); + this._disposables.push(watcher); + } + } + + private async _onChanged(uri: vscode.Uri): Promise { + if (!getEnableAutoRestore()) { + return; + } + if (this._active.size === 0) { + this._total = 1; + this._completed = 0; + } + await this._restoreIfChanged(uri, false); + } + + private async _restoreIfChanged(uri: vscode.Uri, isInitial: boolean): Promise { + let content: string; + try { + content = (await vscode.workspace.fs.readFile(uri)).toString(); + } catch (error) { + extensionLogOutputChannel.warn(`Failed to read ${uri.fsPath}: ${error}`); + return; + } + + const prev = this._lastContent.get(uri.fsPath); + if (!isInitial && prev === content) { + return; + } + + const configDir = path.dirname(uri.fsPath); + const relativePath = vscode.workspace.asRelativePath(uri); + extensionLogOutputChannel.info(`${isInitial ? 'Initial' : 'Changed'} restore for ${relativePath}`); + + // Don't update baseline until restore succeeds; queue re-restore if one is already active + if (this._active.has(configDir)) { + this._pendingRestore.add(configDir); + return; + } + + this._lastContent.set(uri.fsPath, content); + try { + await this._runRestore(configDir, relativePath); + } catch (error) { + extensionLogOutputChannel.warn(`Restore failed for ${relativePath}: ${error}`); + } + + // If a change arrived while we were restoring, re-read and restore again + if (this._pendingRestore.delete(configDir)) { + await this._restoreIfChanged(uri, false); + } + } + + private async _runRestore(configDir: string, relativePath: string): Promise { + this._active.set(configDir, relativePath); + this._showProgress(); + + const cliPath = await this._terminalProvider.getAspireCliExecutablePath(); + await new Promise((resolve, reject) => { + const proc = spawnCliProcess(this._terminalProvider, cliPath, ['restore'], { + workingDirectory: configDir, + noExtensionVariables: true, + exitCallback: code => { + if (code === 0) { + extensionLogOutputChannel.info(aspireRestoreCompleted(relativePath)); + resolve(); + } else { + extensionLogOutputChannel.warn(aspireRestoreFailed(relativePath, `exit code ${code}`)); + reject(new Error(`exit code ${code}`)); + } + }, + errorCallback: error => { + extensionLogOutputChannel.warn(aspireRestoreFailed(relativePath, error.message)); + reject(error); + }, + }); + this._childProcesses.add(proc); + const timeout = setTimeout(() => { proc.kill(); reject(new Error('restore timed out')); }, 120_000); + this._timeouts.add(timeout); + proc.on('close', () => { + clearTimeout(timeout); + this._timeouts.delete(timeout); + this._childProcesses.delete(proc); + }); + }).finally(() => { + this._active.delete(configDir); + this._completed++; + this._showProgress(); + if (this._active.size === 0) { + const hideTimeout = setTimeout(() => { + this._timeouts.delete(hideTimeout); + if (this._active.size === 0) { this._statusBarItem.hide(); } + }, 5000); + this._timeouts.add(hideTimeout); + } + }); + } + + private _showProgress(): void { + if (this._active.size === 0) { + this._statusBarItem.text = `$(check) ${aspireRestoreAllCompleted}`; + } else if (this._total <= 1) { + this._statusBarItem.text = `$(sync~spin) ${runningAspireRestore([...this._active.values()][0])}`; + } else { + this._statusBarItem.text = `$(sync~spin) ${runningAspireRestoreProgress(this._completed, this._total)}`; + } + this._statusBarItem.show(); + } + + dispose(): void { + for (const proc of this._childProcesses) { + proc.kill(); + } + this._childProcesses.clear(); + for (const timeout of this._timeouts) { + clearTimeout(timeout); + } + this._timeouts.clear(); + for (const d of this._disposables) { + d.dispose(); + } + this._disposables.length = 0; + } +} diff --git a/extension/src/utils/settings.ts b/extension/src/utils/settings.ts index 6c1c8263617..b5f3fee2af7 100644 --- a/extension/src/utils/settings.ts +++ b/extension/src/utils/settings.ts @@ -15,3 +15,7 @@ function getAspireConfig(): vscode.WorkspaceConfiguration { export function getRegisterMcpServerInWorkspace(): boolean { return getAspireConfig().get(registerMcpServerInWorkspaceSettingName, false); } + +export function getEnableAutoRestore(): boolean { + return getAspireConfig().get('enableAutoRestore', true); +} diff --git a/playground/TypeScriptApps/AzureFunctionsSample/aspire.config.json b/playground/TypeScriptApps/AzureFunctionsSample/aspire.config.json index 20eb971cfad..776ee3c965b 100644 --- a/playground/TypeScriptApps/AzureFunctionsSample/aspire.config.json +++ b/playground/TypeScriptApps/AzureFunctionsSample/aspire.config.json @@ -1,12 +1,13 @@ { - "sdk": { - "version": "13.3.0-preview.1.26163.4" + "appHost": { + "path": "AppHost/apphost.ts", + "language": "typescript/nodejs" }, - "channel": "daily", "packages": { - "Aspire.Hosting.Azure.Storage": "13.3.0-preview.1.26167.8", - "Aspire.Hosting.JavaScript": "13.3.0-preview.1.26167.8", - "Aspire.Hosting.Azure": "13.3.0-preview.1.26167.8", - "Aspire.Hosting.Azure.EventHubs": "13.3.0-preview.1.26167.8" + "Aspire.Hosting.Azure.Storage": "13.2.0", + "Aspire.Hosting.JavaScript": "13.2.0", + "Aspire.Hosting.Azure": "13.2.0", + "Aspire.Hosting.Azure.EventHubs": "13.2.0" } + } diff --git a/playground/TypeScriptApps/RpsArena/aspire.config.json b/playground/TypeScriptApps/RpsArena/aspire.config.json index 1bd1eebff0e..f5f751ac336 100644 --- a/playground/TypeScriptApps/RpsArena/aspire.config.json +++ b/playground/TypeScriptApps/RpsArena/aspire.config.json @@ -2,13 +2,9 @@ "appHost": { "path": "apphost.ts" }, - "sdk": { - "version": "13.3.0-preview.1.26163.4" - }, - "channel": "daily", "packages": { - "Aspire.Hosting.Python": "13.3.0-preview.1.26167.8", - "Aspire.Hosting.PostgreSQL": "13.3.0-preview.1.26167.8", - "Aspire.Hosting.JavaScript": "13.3.0-preview.1.26167.8" + "Aspire.Hosting.Python": "13.2.0", + "Aspire.Hosting.PostgreSQL": "13.2.0", + "Aspire.Hosting.JavaScript": "13.2.0" } }