From 9f3d01a76c1ef4ccbf5dc1d2f2f985de77d64662 Mon Sep 17 00:00:00 2001 From: levivilet <72156503+levivilet@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:30:11 +0000 Subject: [PATCH 1/3] feature: route devcontainer progress through extension api --- packages/extension/src/devcontainerMain.ts | 6 +- .../extension/src/parts/Progress/Progress.ts | 96 +++++++++++++++++++ .../test/DevContainerProgress.test.ts | 19 ++++ 3 files changed, 120 insertions(+), 1 deletion(-) diff --git a/packages/extension/src/devcontainerMain.ts b/packages/extension/src/devcontainerMain.ts index 6068317..9a52704 100644 --- a/packages/extension/src/devcontainerMain.ts +++ b/packages/extension/src/devcontainerMain.ts @@ -4,6 +4,7 @@ import { registerFileSystemProvider, } from '@lvce-editor/api' import * as BuildError from './parts/BuildError/BuildError.ts' +import * as Progress from './parts/Progress/Progress.ts' import { fileSystem } from './parts/FileSystem/FileSystem.ts' import * as RegisterCommands from './parts/RegisterCommands/RegisterCommands.ts' @@ -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..f12ac13 100644 --- a/packages/extension/src/parts/Progress/Progress.ts +++ b/packages/extension/src/parts/Progress/Progress.ts @@ -4,11 +4,62 @@ import { openOutputView, showNotification, } from '@lvce-editor/api' +import * as ExtensionApi from '@lvce-editor/api' import * as Rpc from '../Rpc/Rpc.ts' let output: ReturnType | undefined let busy = false let monitorGeneration = 0 +let workspaceProgressOperationId: number | undefined +type WorkspaceProgressData = { + message: string + status: 'idle' | 'in-progress' | 'finished' | 'error' +} +let workspaceProgressData: WorkspaceProgressData = { + message: '', + status: 'idle', +} +let workspaceProgressRegistration: + | { + dispose: () => Promise + refresh: (operationId?: number) => Promise + } + | undefined +const registerWorkspaceProgressProvider = ( + ExtensionApi as typeof ExtensionApi & { + registerWorkspaceProgressProvider(provider: { + getProgressData: () => WorkspaceProgressData + id: string + }): { + dispose(): Promise + refresh(operationId?: number): Promise + } + } +).registerWorkspaceProgressProvider + +export const registerWorkspaceProgress = (): void => { + workspaceProgressRegistration = registerWorkspaceProgressProvider({ + getProgressData: () => workspaceProgressData, + id: 'devcontainer.setup', + }) +} + +export const deactivateWorkspaceProgress = async (): Promise => { + workspaceProgressData = { message: '', status: 'idle' } + await workspaceProgressRegistration?.dispose() + workspaceProgressRegistration = undefined +} + +const setWorkspaceProgressData = async ( + data: WorkspaceProgressData, +): Promise => { + workspaceProgressData = data + try { + await workspaceProgressRegistration?.refresh(workspaceProgressOperationId) + } catch { + // Workspace progress is optional and cannot interrupt container setup. + } +} const refreshOutput = async (): Promise => { try { @@ -40,6 +91,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. + } + workspaceProgressOperationId = + 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 +115,9 @@ export const run = async ( if (typeof text === 'string' && text !== previous) { previous = text await channel.replace(header + text) + const message = text.trim().split('\n').filter(Boolean).at(-1) + 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 +150,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 +180,15 @@ export const run = async ( finished = true completed.resolve() await polling + await setWorkspaceProgressData({ message: '', status: 'idle' }) + workspaceProgressOperationId = 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..f25d83b 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: '', @@ -25,6 +27,12 @@ mock.module('@lvce-editor/api', { openOutputView: async () => { events.push('output opened') }, + registerWorkspaceProgressProvider: (provider: { + getProgressData: () => { message: string; status: string } + }) => { + workspaceProgressProvider = provider + return { dispose: async () => {}, refresh: async () => {} } + }, openUri: 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() } }) From 10f07f61f5b5e1beee3ddf8a1dc7cce88486eeed Mon Sep 17 00:00:00 2001 From: levivilet <72156503+levivilet@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:39:54 +0000 Subject: [PATCH 2/3] Fix Dev Container workspace progress lint --- packages/extension/src/devcontainerMain.ts | 2 +- .../extension/src/parts/Progress/Progress.ts | 50 +++++++++---------- .../test/DevContainerProgress.test.ts | 2 +- 3 files changed, 26 insertions(+), 28 deletions(-) diff --git a/packages/extension/src/devcontainerMain.ts b/packages/extension/src/devcontainerMain.ts index 9a52704..2fbe9c6 100644 --- a/packages/extension/src/devcontainerMain.ts +++ b/packages/extension/src/devcontainerMain.ts @@ -4,8 +4,8 @@ import { registerFileSystemProvider, } from '@lvce-editor/api' import * as BuildError from './parts/BuildError/BuildError.ts' -import * as Progress from './parts/Progress/Progress.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 = { diff --git a/packages/extension/src/parts/Progress/Progress.ts b/packages/extension/src/parts/Progress/Progress.ts index f12ac13..02f8f2f 100644 --- a/packages/extension/src/parts/Progress/Progress.ts +++ b/packages/extension/src/parts/Progress/Progress.ts @@ -10,52 +10,50 @@ import * as Rpc from '../Rpc/Rpc.ts' let output: ReturnType | undefined let busy = false let monitorGeneration = 0 -let workspaceProgressOperationId: number | undefined type WorkspaceProgressData = { message: string status: 'idle' | 'in-progress' | 'finished' | 'error' } -let workspaceProgressData: WorkspaceProgressData = { - message: '', - status: 'idle', +type WorkspaceProgressHandle = { + dispose: () => Promise + refresh: (operationId?: number) => Promise } -let workspaceProgressRegistration: - | { - dispose: () => Promise - refresh: (operationId?: number) => Promise - } - | undefined -const registerWorkspaceProgressProvider = ( +const workspaceProgressApi = ExtensionApi as typeof ExtensionApi & { registerWorkspaceProgressProvider(provider: { getProgressData: () => WorkspaceProgressData id: string - }): { - dispose(): Promise - refresh(operationId?: number): Promise - } + }): WorkspaceProgressHandle } -).registerWorkspaceProgressProvider +const workspaceProgressState: { + data: WorkspaceProgressData + operationId: number | undefined + registration: WorkspaceProgressHandle | undefined +} = { + data: { message: '', status: 'idle' }, + operationId: undefined, + registration: undefined, +} export const registerWorkspaceProgress = (): void => { - workspaceProgressRegistration = registerWorkspaceProgressProvider({ - getProgressData: () => workspaceProgressData, + workspaceProgressState.registration = workspaceProgressApi.registerWorkspaceProgressProvider({ + getProgressData: () => workspaceProgressState.data, id: 'devcontainer.setup', }) } export const deactivateWorkspaceProgress = async (): Promise => { - workspaceProgressData = { message: '', status: 'idle' } - await workspaceProgressRegistration?.dispose() - workspaceProgressRegistration = undefined + workspaceProgressState.data = { message: '', status: 'idle' } + await workspaceProgressState.registration?.dispose() + workspaceProgressState.registration = undefined } const setWorkspaceProgressData = async ( data: WorkspaceProgressData, ): Promise => { - workspaceProgressData = data + workspaceProgressState.data = data try { - await workspaceProgressRegistration?.refresh(workspaceProgressOperationId) + await workspaceProgressState.registration?.refresh(workspaceProgressState.operationId) } catch { // Workspace progress is optional and cannot interrupt container setup. } @@ -100,7 +98,7 @@ export const run = async ( } catch { // Workspace progress is optional and cannot interrupt container setup. } - workspaceProgressOperationId = + workspaceProgressState.operationId = typeof workspaceProgressId === 'number' ? workspaceProgressId : undefined await setWorkspaceProgressData({ message: header.trim(), @@ -115,7 +113,7 @@ export const run = async ( if (typeof text === 'string' && text !== previous) { previous = text await channel.replace(header + text) - const message = text.trim().split('\n').filter(Boolean).at(-1) + 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 @@ -181,7 +179,7 @@ export const run = async ( completed.resolve() await polling await setWorkspaceProgressData({ message: '', status: 'idle' }) - workspaceProgressOperationId = undefined + workspaceProgressState.operationId = undefined if (typeof workspaceProgressId === 'number') { try { await executeCommand('Workspace.endProgress', workspaceProgressId) diff --git a/packages/extension/test/DevContainerProgress.test.ts b/packages/extension/test/DevContainerProgress.test.ts index f25d83b..ac27e16 100644 --- a/packages/extension/test/DevContainerProgress.test.ts +++ b/packages/extension/test/DevContainerProgress.test.ts @@ -27,13 +27,13 @@ mock.module('@lvce-editor/api', { openOutputView: async () => { events.push('output opened') }, + openUri: async () => {}, registerWorkspaceProgressProvider: (provider: { getProgressData: () => { message: string; status: string } }) => { workspaceProgressProvider = provider return { dispose: async () => {}, refresh: async () => {} } }, - openUri: async () => {}, showNotification: async (_type: string, message: string) => { events.push(message) }, From 6c450fb34dba5e240c6b5a08b2a6d21971459478 Mon Sep 17 00:00:00 2001 From: levivilet <72156503+levivilet@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:49:50 +0000 Subject: [PATCH 3/3] Use published workspace progress API --- package-lock.json | 8 +++--- packages/extension/package.json | 2 +- .../extension/src/parts/Progress/Progress.ts | 25 ++++++------------- 3 files changed, 12 insertions(+), 23 deletions(-) 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/parts/Progress/Progress.ts b/packages/extension/src/parts/Progress/Progress.ts index 02f8f2f..81f70f9 100644 --- a/packages/extension/src/parts/Progress/Progress.ts +++ b/packages/extension/src/parts/Progress/Progress.ts @@ -1,34 +1,23 @@ +import type { + WorkspaceProgressData, + WorkspaceProgressProviderHandle, +} from '@lvce-editor/api' import { createOutputChannel, executeCommand, openOutputView, + registerWorkspaceProgressProvider, showNotification, } from '@lvce-editor/api' -import * as ExtensionApi from '@lvce-editor/api' import * as Rpc from '../Rpc/Rpc.ts' let output: ReturnType | undefined let busy = false let monitorGeneration = 0 -type WorkspaceProgressData = { - message: string - status: 'idle' | 'in-progress' | 'finished' | 'error' -} -type WorkspaceProgressHandle = { - dispose: () => Promise - refresh: (operationId?: number) => Promise -} -const workspaceProgressApi = - ExtensionApi as typeof ExtensionApi & { - registerWorkspaceProgressProvider(provider: { - getProgressData: () => WorkspaceProgressData - id: string - }): WorkspaceProgressHandle - } const workspaceProgressState: { data: WorkspaceProgressData operationId: number | undefined - registration: WorkspaceProgressHandle | undefined + registration: WorkspaceProgressProviderHandle | undefined } = { data: { message: '', status: 'idle' }, operationId: undefined, @@ -36,7 +25,7 @@ const workspaceProgressState: { } export const registerWorkspaceProgress = (): void => { - workspaceProgressState.registration = workspaceProgressApi.registerWorkspaceProgressProvider({ + workspaceProgressState.registration = registerWorkspaceProgressProvider({ getProgressData: () => workspaceProgressState.data, id: 'devcontainer.setup', })