diff --git a/README.md b/README.md index 905eba3..964c939 100644 --- a/README.md +++ b/README.md @@ -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:///`. 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:///!`. The local path keeps recent entries +recognizable; container-relative file paths follow the `!` separator. Older +`devcontainers:///` 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 diff --git a/packages/devcontainer-worker/src/parts/ContainerUri/ContainerUri.ts b/packages/devcontainer-worker/src/parts/ContainerUri/ContainerUri.ts index 78406e2..cd6e0ad 100644 --- a/packages/devcontainer-worker/src/parts/ContainerUri/ContainerUri.ts +++ b/packages/devcontainer-worker/src/parts/ContainerUri/ContainerUri.ts @@ -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( @@ -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('/') } } diff --git a/packages/devcontainer-worker/src/parts/CreateDevContainer/CreateDevContainer.ts b/packages/devcontainer-worker/src/parts/CreateDevContainer/CreateDevContainer.ts index 6a16fff..468d374 100644 --- a/packages/devcontainer-worker/src/parts/CreateDevContainer/CreateDevContainer.ts +++ b/packages/devcontainer-worker/src/parts/CreateDevContainer/CreateDevContainer.ts @@ -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 @@ -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 ({ diff --git a/packages/devcontainer-worker/src/parts/DevContainerState/DevContainerState.ts b/packages/devcontainer-worker/src/parts/DevContainerState/DevContainerState.ts index 8a3def8..a389845 100644 --- a/packages/devcontainer-worker/src/parts/DevContainerState/DevContainerState.ts +++ b/packages/devcontainer-worker/src/parts/DevContainerState/DevContainerState.ts @@ -35,14 +35,23 @@ export const reset = () => { } export const getWorkspaceFolder = async (uri: string): Promise => { - 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( diff --git a/packages/devcontainer-worker/test/ContainerUri.test.ts b/packages/devcontainer-worker/test/ContainerUri.test.ts new file mode 100644 index 0000000..f11c041 --- /dev/null +++ b/packages/devcontainer-worker/test/ContainerUri.test.ts @@ -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', + }) +}) diff --git a/packages/devcontainer-worker/test/DevContainer.test.ts b/packages/devcontainer-worker/test/DevContainer.test.ts index 40f4e59..dcd7b7f 100644 --- a/packages/devcontainer-worker/test/DevContainer.test.ts +++ b/packages/devcontainer-worker/test/DevContainer.test.ts @@ -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' @@ -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( diff --git a/packages/devcontainer-worker/test/DevContainerState.test.ts b/packages/devcontainer-worker/test/DevContainerState.test.ts index 4c96723..cd802a5 100644 --- a/packages/devcontainer-worker/test/DevContainerState.test.ts +++ b/packages/devcontainer-worker/test/DevContainerState.test.ts @@ -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' @@ -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]) diff --git a/packages/e2e/helpers/waitForContainerWorkspace.ts b/packages/e2e/helpers/waitForContainerWorkspace.ts index 296f46f..5d3422e 100644 --- a/packages/e2e/helpers/waitForContainerWorkspace.ts +++ b/packages/e2e/helpers/waitForContainerWorkspace.ts @@ -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',