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
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,13 @@ The runner allows three minutes per test and removes containers belonging to its
Use **Dev Containers: Reopen in Container** or **Dev Containers: Start Current
Workspace** to build/start the current workspace's
configuration and open its configured `workspaceFolder` at
`devcontainers:///<containerId>`. Explorer and file editing use Docker inside the
container as `remoteUser`; the local bind mount is not used as a substitute for
the container filesystem. A failed build or connection leaves the local workspace
open. The existing execution, stop, and remove commands also work from the
container workspace.
`devcontainers:///<local-workspace-path>!`. The local path keeps recent entries
recognizable; container-relative file paths follow the `!` separator. Older
`devcontainers:///<containerId>` workspace URLs remain supported. Explorer and
file editing use Docker inside the container as `remoteUser`; the local bind
mount is not used as a substitute for the container filesystem. A failed build
or connection leaves the local workspace open. The existing execution, stop,
and remove commands also work from the container workspace.

New terminals run an interactive shell through `devcontainer exec`, using the
configured remote user and environment. Opening a terminal from an Explorer
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,4 @@
export const parse = (uri: string): { id: string; path: string } => {
const url = new URL(uri)
if (url.protocol !== 'devcontainers:' || url.host || url.search || url.hash) {
throw new Error('Invalid devcontainer URI')
}
const [, id, ...segments] = url.pathname.split('/')
if (!id || !/^[a-zA-Z0-9-]+$/.test(id)) {
throw new Error('Invalid devcontainer id')
}
const decodeSegments = (segments: string[]): string[] => {
const decoded = segments.map(decodeURIComponent)
if (
decoded.some(
Expand All @@ -19,5 +11,54 @@ export const parse = (uri: string): { id: string; path: string } => {
) {
throw new Error('Invalid devcontainer path')
}
return decoded
}

const encodeSegment = (segment: string): string =>
encodeURIComponent(segment).replaceAll('!', '%21')

export const create = (workspaceFolder: string): string => {
const windowsPath = /^([a-zA-Z]:)[\\/](.*)$/.exec(workspaceFolder)
if (!workspaceFolder.startsWith('/') && !windowsPath) {
throw new Error('Invalid devcontainer workspace folder')
}
const sourceSegments = windowsPath
? [windowsPath[1], ...windowsPath[2].split(/[\\/]/).filter(Boolean)]
: workspaceFolder.split('/').filter(Boolean)
const sourcePath = `/${sourceSegments
.map((segment, index) =>
windowsPath && index === 0 ? segment : encodeSegment(segment),
)
.join('/')}`
return `devcontainers://${sourcePath}!`
}

export const parse = (
uri: string,
): { id: string; path: string; workspaceFolder?: string } => {
const url = new URL(uri)
if (url.protocol !== 'devcontainers:' || url.host || url.search || url.hash) {
throw new Error('Invalid devcontainer URI')
}
const separator = url.pathname.indexOf('!')
if (separator !== -1) {
const sourcePath = url.pathname.slice(0, separator)
const source = decodeSegments(sourcePath.split('/').filter(Boolean))
if (source.length === 0) {
throw new Error('Invalid devcontainer workspace folder')
}
const drive = /^([a-zA-Z]):$/.exec(source[0])
const workspaceFolder = drive
? `${drive[1]}:\\${source.slice(1).join('\\')}`
: `/${source.join('/')}`
const rawPath = url.pathname.slice(separator + 1).replace(/^\//, '')
const path = decodeSegments(rawPath ? rawPath.split('/') : []).join('/')
return { id: `workspace:${workspaceFolder}`, path, workspaceFolder }
}
const [, id, ...segments] = url.pathname.split('/')
if (!id || !/^[a-zA-Z0-9-]+$/.test(id)) {
throw new Error('Invalid devcontainer id')
}
const decoded = decodeSegments(segments)
return { id, path: decoded.join('/') }
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type * as Config from '../DevContainerConfig/DevContainerConfig.ts'
import type * as Client from '../DevContainerNodeClient/DevContainerNodeClient.ts'
import type * as State from '../DevContainerState/DevContainerState.ts'
import type * as Workspace from '../WorkspaceFolder/WorkspaceFolder.ts'
import * as ContainerUri from '../ContainerUri/ContainerUri.ts'

export interface Host {
cancelStart?: (workspaceFolder: string) => Promise<unknown>
Expand Down Expand Up @@ -286,7 +287,7 @@ export const createDevContainer = ({
remoteWorkspaceFolder: state.remoteWorkspaceFolder,
})
await DevContainerState.persist(workspaceFolder)
return { ok: true, workspaceUri: `devcontainers:///${state.containerId}` }
return { ok: true, workspaceUri: ContainerUri.create(workspaceFolder) }
}

const openWorkspace = async ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,23 @@ export const reset = () => {
}

export const getWorkspaceFolder = async (uri: string): Promise<string> => {
const { id } = ContainerUri.parse(uri)
const { id, workspaceFolder: sourceWorkspaceFolder } = ContainerUri.parse(uri)
for (const [workspaceFolder, value] of state) {
if (value.containerId === id) {
if (
(sourceWorkspaceFolder &&
workspaceFolder === sourceWorkspaceFolder &&
id === `workspace:${workspaceFolder}`) ||
(!sourceWorkspaceFolder && value.containerId === id)
) {
return workspaceFolder
}
}
const restored = await restore(id)
if (restored && state.get(restored)?.containerId === id) {
const key = sourceWorkspaceFolder || id
const restored = await restore(key)
if (
restored &&
(state.get(restored)?.containerId === id || id === `workspace:${restored}`)
) {
return restored
}
throw new Error(
Expand Down
41 changes: 41 additions & 0 deletions packages/devcontainer-worker/test/ContainerUri.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { expect, test } from '@jest/globals'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import * as ContainerUri from '../src/parts/ContainerUri/ContainerUri.ts'

test('creates readable workspace URIs and preserves paths inside the container', () => {
const workspaceFolder = join(tmpdir(), 'my project!')
const uri = ContainerUri.create(workspaceFolder)

expect(uri).toContain('my%20project%21!')
expect(uri).toMatch(/^devcontainers:\/\//)
expect(ContainerUri.parse(uri)).toEqual({
id: `workspace:${workspaceFolder}`,
path: '',
workspaceFolder,
})
expect(ContainerUri.parse(`${uri}/src/a%20b.ts`)).toEqual({
id: `workspace:${workspaceFolder}`,
path: 'src/a b.ts',
workspaceFolder,
})
})

test('round-trips Windows workspace paths', () => {
const workspaceFolder = 'C:\\Users\\simon\\my project!'
const uri = ContainerUri.create(workspaceFolder)

expect(uri).toBe('devcontainers:///C:/Users/simon/my%20project%21!')
expect(ContainerUri.parse(uri)).toEqual({
id: `workspace:${workspaceFolder}`,
path: '',
workspaceFolder,
})
})

test('continues to parse saved container-id URIs', () => {
expect(ContainerUri.parse('devcontainers:///abc123/src/a%20b.ts')).toEqual({
id: 'abc123',
path: 'src/a b.ts',
})
})
6 changes: 5 additions & 1 deletion packages/devcontainer-worker/test/DevContainer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import * as ContainerUri from '../src/parts/ContainerUri/ContainerUri.ts'
import * as DevContainer from '../src/parts/DevContainer/DevContainer.js'
import * as DevContainerNodeClient from '../src/parts/DevContainerNodeClient/DevContainerNodeClient.js'
import * as DevContainerState from '../src/parts/DevContainerState/DevContainerState.js'
Expand Down Expand Up @@ -248,7 +249,10 @@ test('openWorkspace builds, checks the remote folder, and resolves lifecycle com
DevContainer.openWorkspace({ workspaceFolder }),
DevContainer.openWorkspace({ workspaceFolder }),
])
expect(first).toEqual({ ok: true, workspaceUri: 'devcontainers:///abc123' })
expect(first).toEqual({
ok: true,
workspaceUri: ContainerUri.create(workspaceFolder),
})
expect(second).toEqual(first)
expect(operations).toEqual(['build', 'connect'])
expect(
Expand Down
5 changes: 4 additions & 1 deletion packages/devcontainer-worker/test/DevContainerState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, beforeEach, expect, test } from '@jest/globals'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import * as ContainerUri from '../src/parts/ContainerUri/ContainerUri.ts'
import * as DevContainerNodeClient from '../src/parts/DevContainerNodeClient/DevContainerNodeClient.ts'
import * as DevContainerState from '../src/parts/DevContainerState/DevContainerState.ts'
import * as WorkspaceFolder from '../src/parts/WorkspaceFolder/WorkspaceFolder.ts'
Expand Down Expand Up @@ -54,7 +55,9 @@ test.each([undefined, 'podman'])(
DevContainerState.reset()

expect(
await WorkspaceFolder.toPath('devcontainers:///abc123/file.txt'),
await WorkspaceFolder.toPath(
`${ContainerUri.create(workspaceFolder)}/file.txt`,
),
).toBe(workspaceFolder)
expect(DevContainerState.get(workspaceFolder)).toEqual(state)
expect(inspectedEngines).toEqual([containerCli])
Expand Down
3 changes: 2 additions & 1 deletion packages/e2e/helpers/waitForContainerWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export const waitForContainerWorkspace = async ({
workspaceUri = await Command.execute('Workspace.getUri')
if (
typeof workspaceUri === 'string' &&
/^devcontainers:\/\/\/[a-f0-9]+$/.test(workspaceUri)
(workspaceUri.endsWith('!') ||
/^devcontainers:\/\/\/[a-f0-9]+$/.test(workspaceUri))
) {
const state = await Command.executeExtensionCommand(
'devcontainer.getState',
Expand Down
Loading