diff --git a/package-lock.json b/package-lock.json index 7c3f5e3..c4e85d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2748,9 +2748,9 @@ "license": "MIT" }, "node_modules/@lvce-editor/api": { - "version": "9.36.0", - "resolved": "https://registry.npmjs.org/@lvce-editor/api/-/api-9.36.0.tgz", - "integrity": "sha512-uu/Nya4K0GFPCgn/htuS5A5KV2Tm1RGY0mNFctjbmUqss/mLYvmrzSBYEOdpJwgkVYO03FHxybEk6k1bv3N97w==", + "version": "9.43.1", + "resolved": "https://registry.npmjs.org/@lvce-editor/api/-/api-9.43.1.tgz", + "integrity": "sha512-8ddaby7RRwvw98mD33sagF9ZKLbV6xT1r+LQx5AI3dCDX4SF6VLPBoh1ee8XL6Oea1RIfUgdwvewep7vkIUi7w==", "dev": true, "license": "MIT", "dependencies": { @@ -14971,7 +14971,7 @@ "version": "0.0.0-dev", "license": "MIT", "devDependencies": { - "@lvce-editor/api": "^9.36.0" + "@lvce-editor/api": "^9.43.1" } }, "packages/memory": { diff --git a/packages/extension/package.json b/packages/extension/package.json index b558783..18d6085 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -4,7 +4,7 @@ "license": "MIT", "type": "module", "devDependencies": { - "@lvce-editor/api": "^9.36.0" + "@lvce-editor/api": "^9.43.1" }, "scripts": { "test": "node --experimental-test-module-mocks --test test/*.test.ts" diff --git a/packages/extension/src/devcontainerMain.ts b/packages/extension/src/devcontainerMain.ts index 6068317..2fbe9c6 100644 --- a/packages/extension/src/devcontainerMain.ts +++ b/packages/extension/src/devcontainerMain.ts @@ -5,6 +5,7 @@ import { } from '@lvce-editor/api' import * as BuildError from './parts/BuildError/BuildError.ts' import { fileSystem } from './parts/FileSystem/FileSystem.ts' +import * as Progress from './parts/Progress/Progress.ts' import * as RegisterCommands from './parts/RegisterCommands/RegisterCommands.ts' const state = { @@ -17,11 +18,14 @@ export const activate = async (): Promise => { } state.isActivated = true await activateExtensionApi() + Progress.registerWorkspaceProgress() registerFileSystemProvider(fileSystem) registerFileSystemProvider(BuildError.fileSystem) RegisterCommands.registerCommands() } -export const deactivate = (): void => {} +export const deactivate = (): void => { + void Progress.deactivateWorkspaceProgress() +} await activate() diff --git a/packages/extension/src/parts/Progress/Progress.ts b/packages/extension/src/parts/Progress/Progress.ts index c10d3d6..81f70f9 100644 --- a/packages/extension/src/parts/Progress/Progress.ts +++ b/packages/extension/src/parts/Progress/Progress.ts @@ -1,7 +1,12 @@ +import type { + WorkspaceProgressData, + WorkspaceProgressProviderHandle, +} from '@lvce-editor/api' import { createOutputChannel, executeCommand, openOutputView, + registerWorkspaceProgressProvider, showNotification, } from '@lvce-editor/api' import * as Rpc from '../Rpc/Rpc.ts' @@ -9,6 +14,39 @@ import * as Rpc from '../Rpc/Rpc.ts' let output: ReturnType | undefined let busy = false let monitorGeneration = 0 +const workspaceProgressState: { + data: WorkspaceProgressData + operationId: number | undefined + registration: WorkspaceProgressProviderHandle | undefined +} = { + data: { message: '', status: 'idle' }, + operationId: undefined, + registration: undefined, +} + +export const registerWorkspaceProgress = (): void => { + workspaceProgressState.registration = registerWorkspaceProgressProvider({ + getProgressData: () => workspaceProgressState.data, + id: 'devcontainer.setup', + }) +} + +export const deactivateWorkspaceProgress = async (): Promise => { + workspaceProgressState.data = { message: '', status: 'idle' } + await workspaceProgressState.registration?.dispose() + workspaceProgressState.registration = undefined +} + +const setWorkspaceProgressData = async ( + data: WorkspaceProgressData, +): Promise => { + workspaceProgressState.data = data + try { + await workspaceProgressState.registration?.refresh(workspaceProgressState.operationId) + } catch { + // Workspace progress is optional and cannot interrupt container setup. + } +} const refreshOutput = async (): Promise => { try { @@ -40,6 +78,21 @@ export const run = async ( const header = `Starting Dev Containers for ${workspaceFolder}…\n` await channel.replace(header) await openOutputView({ channel: 'dev-containers' }) + let workspaceProgressId: unknown + try { + workspaceProgressId = await executeCommand( + 'Workspace.startProgress', + header.trim(), + ) + } catch { + // Workspace progress is optional and cannot interrupt container setup. + } + workspaceProgressState.operationId = + typeof workspaceProgressId === 'number' ? workspaceProgressId : undefined + await setWorkspaceProgressData({ + message: header.trim(), + status: 'in-progress', + }) const progressId = crypto.randomUUID() const completed = Promise.withResolvers() let finished = false @@ -49,6 +102,9 @@ export const run = async ( if (typeof text === 'string' && text !== previous) { previous = text await channel.replace(header + text) + const message = text.trim().split('\n').findLast(Boolean) + if (message) + await setWorkspaceProgressData({ message, status: 'in-progress' }) // Extension output storage currently has no change notifications. Refresh // the visible channel without reopening the panel or changing selection. await refreshOutput() @@ -81,6 +137,24 @@ export const run = async ( progressId, workspaceFolder, }) + if (result && typeof result === 'object' && 'ok' in result && result.ok) { + await setWorkspaceProgressData({ + message: 'Dev Container is ready', + status: 'finished', + }) + } else { + const errorMessage = + result && + typeof result === 'object' && + 'errorMessage' in result && + typeof result.errorMessage === 'string' + ? result.errorMessage + : 'Dev Container setup failed' + await setWorkspaceProgressData({ + message: errorMessage, + status: 'error', + }) + } if (result && typeof result === 'object' && 'ok' in result && result.ok) { // Lifecycle commands have their own lifetime; they do not hold the // startup guard while the editor is connected to the container. @@ -93,6 +167,15 @@ export const run = async ( finished = true completed.resolve() await polling + await setWorkspaceProgressData({ message: '', status: 'idle' }) + workspaceProgressState.operationId = undefined + if (typeof workspaceProgressId === 'number') { + try { + await executeCommand('Workspace.endProgress', workspaceProgressId) + } catch { + // The workspace operation may have been cancelled or superseded. + } + } try { await refresh() } catch { diff --git a/packages/extension/test/DevContainerProgress.test.ts b/packages/extension/test/DevContainerProgress.test.ts index fe64d14..ac27e16 100644 --- a/packages/extension/test/DevContainerProgress.test.ts +++ b/packages/extension/test/DevContainerProgress.test.ts @@ -4,6 +4,8 @@ import { mock, test } from 'node:test' const events: string[] = [] const pending = Promise.withResolvers() let startupResult: unknown = pending.promise +let workspaceProgressProvider: + { getProgressData: () => { message: string; status: string } } | undefined let lifecycle = { errorMessage: undefined as string | undefined, output: '', @@ -26,6 +28,12 @@ mock.module('@lvce-editor/api', { events.push('output opened') }, openUri: async () => {}, + registerWorkspaceProgressProvider: (provider: { + getProgressData: () => { message: string; status: string } + }) => { + workspaceProgressProvider = provider + return { dispose: async () => {}, refresh: async () => {} } + }, showNotification: async (_type: string, message: string) => { events.push(message) }, @@ -46,8 +54,10 @@ mock.module('../src/parts/Rpc/Rpc.ts', { }) const { openWorkspace } = await import('../src/parts/DevContainerCommands/DevContainerCommands.ts') +const Progress = await import('../src/parts/Progress/Progress.ts') await test('reopen opens output and displays logs before the build finishes', async () => { + Progress.registerWorkspaceProgress() const operation = openWorkspace() try { await new Promise((resolve) => setTimeout(resolve, 600)) @@ -60,6 +70,10 @@ await test('reopen opens output and displays logs before the build finishes', as events.some((text) => text.includes('Building layer 1')), 'Build output must appear before completion', ) + assert.deepEqual(workspaceProgressProvider?.getProgressData(), { + message: 'Building layer 1', + status: 'in-progress', + }) } finally { pending.resolve({ errorMessage: 'build failed', ok: false }) await operation @@ -68,6 +82,11 @@ await test('reopen opens output and displays logs before the build finishes', as text.includes('Failed to open devcontainer workspace: build failed'), ), ) + assert.deepEqual(workspaceProgressProvider?.getProgressData(), { + message: '', + status: 'idle', + }) + await Progress.deactivateWorkspaceProgress() } })