Skip to content
Merged
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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 5 additions & 1 deletion packages/extension/src/devcontainerMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -17,11 +18,14 @@ export const activate = async (): Promise<void> => {
}
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()
83 changes: 83 additions & 0 deletions packages/extension/src/parts/Progress/Progress.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,52 @@
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'

let output: ReturnType<typeof createOutputChannel> | 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<void> => {
workspaceProgressState.data = { message: '', status: 'idle' }
await workspaceProgressState.registration?.dispose()
workspaceProgressState.registration = undefined
}

const setWorkspaceProgressData = async (
data: WorkspaceProgressData,
): Promise<void> => {
workspaceProgressState.data = data
try {
await workspaceProgressState.registration?.refresh(workspaceProgressState.operationId)
} catch {
// Workspace progress is optional and cannot interrupt container setup.
}
}

const refreshOutput = async (): Promise<void> => {
try {
Expand Down Expand Up @@ -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<void>()
let finished = false
Expand All @@ -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()
Expand Down Expand Up @@ -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.
Expand All @@ -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 {
Expand Down
19 changes: 19 additions & 0 deletions packages/extension/test/DevContainerProgress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { mock, test } from 'node:test'
const events: string[] = []
const pending = Promise.withResolvers<unknown>()
let startupResult: unknown = pending.promise
let workspaceProgressProvider:
{ getProgressData: () => { message: string; status: string } } | undefined
let lifecycle = {
errorMessage: undefined as string | undefined,
output: '',
Expand All @@ -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)
},
Expand All @@ -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))
Expand All @@ -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
Expand All @@ -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()
}
})

Expand Down
Loading