From 0006d04f9ecdb37f6426c06f05106600ebfd1d20 Mon Sep 17 00:00:00 2001 From: junes <673638712@qq.com> Date: Thu, 7 May 2026 08:30:52 +0800 Subject: [PATCH] feat: support multiple workspace roots (#584) --- README.md | 2 +- cli/README.md | 6 +- cli/src/agent/sessionFactory.ts | 4 +- cli/src/api/api.ts | 4 +- cli/src/api/apiMachine.test.ts | 33 +++++- cli/src/api/apiMachine.ts | 105 ++++++++++------- cli/src/api/types.ts | 16 ++- cli/src/commands/runner.ts | 40 ++++--- cli/src/runner/run.ts | 14 +-- cli/src/utils/workspaceRoot.ts | 19 ++-- hub/src/sync/machineCache.ts | 24 +++- web/src/components/NewSession/index.tsx | 6 +- web/src/components/WorkspaceBrowser.tsx | 145 ++++++++++++++++++------ web/src/lib/locales/en.ts | 4 +- web/src/lib/locales/zh-CN.ts | 4 +- web/src/types/api.ts | 2 +- 16 files changed, 302 insertions(+), 126 deletions(-) diff --git a/README.md b/README.md index 92032d50..450bb172 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Run official Claude Code / Codex / Gemini / OpenCode sessions locally and contro - **Your AI, Your Choice** - Claude Code, Codex, Cursor Agent, Gemini, OpenCode—different models, one unified workflow. - **Terminal Anywhere** - Run commands from your phone or browser, directly connected to the working machine. - **Voice Control** - Talk to your AI agent hands-free using the built-in voice assistant. -- **Workspace Browser** - Opt-in via `hapi runner start --workspace-root `: browse a scoped file tree from the web and start sessions in any subdirectory. +- **Workspace Browser** - Opt-in via one or more `hapi runner start --workspace-root ` flags: browse scoped file trees from the web and start sessions in allowed subdirectories. ## Demo diff --git a/cli/README.md b/cli/README.md index a2c0623c..127fa859 100644 --- a/cli/README.md +++ b/cli/README.md @@ -52,10 +52,10 @@ See `src/commands/auth.ts`. - `hapi runner stop-session ` - Terminate specific session. - `hapi runner logs` - Print path to latest runner log file. -Both `start` and `start-sync` accept `--workspace-root ` (or `--workspace-root=`). When set: +Both `start` and `start-sync` accept repeatable `--workspace-root ` (or `--workspace-root=`). When set: -- The web `/browse` page surfaces a scoped file tree rooted at that path. -- The runner refuses `list-directory` and `spawn-session` requests for paths outside the root. +- The web `/browse` page surfaces scoped file trees rooted at those paths. +- The runner refuses `list-directory` and `spawn-session` requests for paths outside the configured roots. - `~` and `~/foo` are expanded. Omitting the flag keeps the legacy behavior: no scoping, no `/browse` feature. diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts index dc27f89c..b1ee7399 100644 --- a/cli/src/agent/sessionFactory.ts +++ b/cli/src/agent/sessionFactory.ts @@ -38,7 +38,7 @@ export type SessionBootstrapResult = { workingDirectory: string } -export function buildMachineMetadata(options?: { workspaceRoot?: string }): MachineMetadata { +export function buildMachineMetadata(options?: { workspaceRoots?: string[] }): MachineMetadata { return { host: process.env.HAPI_HOSTNAME || os.hostname(), platform: os.platform(), @@ -46,7 +46,7 @@ export function buildMachineMetadata(options?: { workspaceRoot?: string }): Mach homeDir: os.homedir(), happyHomeDir: configuration.happyHomeDir, happyLibDir: runtimePath(), - workspaceRoot: options?.workspaceRoot + workspaceRoots: options?.workspaceRoots } } diff --git a/cli/src/api/api.ts b/cli/src/api/api.ts index d327c5e5..73ebb9bc 100644 --- a/cli/src/api/api.ts +++ b/cli/src/api/api.ts @@ -142,7 +142,7 @@ export class ApiClient { return new ApiSessionClient(this.token, session) } - machineSyncClient(machine: Machine, options?: { workspaceRoot?: string }): ApiMachineClient { - return new ApiMachineClient(this.token, machine, options?.workspaceRoot) + machineSyncClient(machine: Machine, options?: { workspaceRoots?: string[] }): ApiMachineClient { + return new ApiMachineClient(this.token, machine, options?.workspaceRoots) } } diff --git a/cli/src/api/apiMachine.test.ts b/cli/src/api/apiMachine.test.ts index 9edfa62b..784ea7d4 100644 --- a/cli/src/api/apiMachine.test.ts +++ b/cli/src/api/apiMachine.test.ts @@ -62,12 +62,12 @@ describe('ApiMachineClient listOpencodeModelsForCwd handler', () => { it('rejects cwd outside the workspace root with the standard error shape', async () => { const machine = makeMachine('machine-1') - const client = new ApiMachineClient('cli-token', machine, workspaceRoot) + const client = new ApiMachineClient('cli-token', machine, [workspaceRoot]) const outsideCwd = mkdtempSync(join(tmpdir(), 'hapi-outside-')) try { const result = await callListOpencodeModels(client, machine.id, outsideCwd) - expect(result).toEqual({ success: false, error: 'Path is outside workspace root' }) + expect(result).toEqual({ success: false, error: 'Path is outside workspace roots' }) expect(listOpencodeModelsForCwdMock).not.toHaveBeenCalled() } finally { rmSync(outsideCwd, { recursive: true, force: true }) @@ -77,7 +77,7 @@ describe('ApiMachineClient listOpencodeModelsForCwd handler', () => { it('rejects empty cwd with cwd-required error', async () => { const machine = makeMachine('machine-2') - const client = new ApiMachineClient('cli-token', machine, workspaceRoot) + const client = new ApiMachineClient('cli-token', machine, [workspaceRoot]) try { const result = await callListOpencodeModels(client, machine.id, '') @@ -90,7 +90,7 @@ describe('ApiMachineClient listOpencodeModelsForCwd handler', () => { it('forwards a workspace-internal cwd to listOpencodeModelsForCwd', async () => { const machine = makeMachine('machine-3') - const client = new ApiMachineClient('cli-token', machine, workspaceRoot) + const client = new ApiMachineClient('cli-token', machine, [workspaceRoot]) const innerDir = join(workspaceRoot, 'inner-project') mkdirSync(innerDir) @@ -115,4 +115,29 @@ describe('ApiMachineClient listOpencodeModelsForCwd handler', () => { client.shutdown() } }) + + it('accepts cwd inside any configured workspace root', async () => { + const machine = makeMachine('machine-4') + const secondWorkspaceRoot = mkdtempSync(join(tmpdir(), 'hapi-machine-ws-2-')) + const client = new ApiMachineClient('cli-token', machine, [workspaceRoot, secondWorkspaceRoot]) + + listOpencodeModelsForCwdMock.mockResolvedValueOnce({ + success: true, + availableModels: [{ modelId: 'x/y' }], + currentModelId: 'x/y' + }) + + try { + const result = await callListOpencodeModels(client, machine.id, secondWorkspaceRoot) + expect(result).toEqual({ + success: true, + availableModels: [{ modelId: 'x/y' }], + currentModelId: 'x/y' + }) + expect(listOpencodeModelsForCwdMock).toHaveBeenCalledWith(secondWorkspaceRoot) + } finally { + rmSync(secondWorkspaceRoot, { recursive: true, force: true }) + client.shutdown() + } + }) }) diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 99a7d334..dc2a5054 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -90,31 +90,52 @@ interface ListMachineDirectoryResponse { error?: string } +function normalizeWorkspaceRoots(paths?: string[]): string[] | undefined { + if (!paths?.length) { + return undefined + } + + const normalized = Array.from(new Set(paths.map((path) => { + try { + return realpathSync(path) + } catch { + return resolvePath(path) + } + }))) + + return normalized.length > 0 ? normalized : undefined +} + +function workspaceRootsEqual(left?: string[], right?: string[]): boolean { + const normalizedLeft = left ?? [] + const normalizedRight = right ?? [] + if (normalizedLeft.length !== normalizedRight.length) { + return false + } + + return normalizedLeft.every((value, index) => value === normalizedRight[index]) +} + +function formatWorkspaceRoots(paths?: string[]): string { + return paths?.length ? paths.join(', ') : '(none)' +} + export class ApiMachineClient { private socket!: Socket private keepAliveInterval: NodeJS.Timeout | null = null private rpcHandlerManager: RpcHandlerManager - private readonly normalizedWorkspaceRoot: string | undefined + private readonly normalizedWorkspaceRoots: string[] | undefined constructor( private readonly token: string, private readonly machine: Machine, - private readonly workspaceRoot?: string + private readonly workspaceRoots?: string[] ) { - // realpath the root once so all subsequent comparisons are against - // the canonical, symlink-resolved path. Falls back to a lexical - // resolve if realpath fails (e.g. unusual permission setup) so we - // still get *some* protection rather than skipping the check. - if (workspaceRoot) { - try { - this.normalizedWorkspaceRoot = realpathSync(workspaceRoot) - } catch { - this.normalizedWorkspaceRoot = resolvePath(workspaceRoot) - } - } else { - this.normalizedWorkspaceRoot = undefined - } + // Realpath roots once so all subsequent comparisons are against + // canonical, symlink-resolved locations. Falls back to lexical + // resolution if realpath fails so we still get protection. + this.normalizedWorkspaceRoots = normalizeWorkspaceRoots(workspaceRoots) this.rpcHandlerManager = new RpcHandlerManager({ scopePrefix: this.machine.id, @@ -143,7 +164,7 @@ export class ApiMachineClient { }) this.rpcHandlerManager.registerHandler('list-directory', async (params) => { - if (!this.normalizedWorkspaceRoot) { + if (!this.normalizedWorkspaceRoots?.length) { return { success: false, error: 'Workspace browsing is not enabled for this machine' } } @@ -153,8 +174,8 @@ export class ApiMachineClient { } const targetPath = await this.resolveForWorkspaceCheck(rawPath) - if (!this.isWithinWorkspaceRoot(targetPath)) { - return { success: false, error: 'Path is outside workspace root' } + if (!this.isWithinWorkspaceRoots(targetPath)) { + return { success: false, error: 'Path is outside workspace roots' } } try { @@ -228,8 +249,8 @@ export class ApiMachineClient { } const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd) - if (!this.isWithinWorkspaceRoot(resolvedCwd)) { - return { success: false, error: 'Path is outside workspace root' } + if (!this.isWithinWorkspaceRoots(resolvedCwd)) { + return { success: false, error: 'Path is outside workspace roots' } } return await listOpencodeModelsForCwd(resolvedCwd) @@ -237,10 +258,12 @@ export class ApiMachineClient { ) } - private isWithinWorkspaceRoot(absolutePath: string): boolean { - if (!this.normalizedWorkspaceRoot) return true - const rel = relative(this.normalizedWorkspaceRoot, absolutePath) - return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) + private isWithinWorkspaceRoots(absolutePath: string): boolean { + if (!this.normalizedWorkspaceRoots?.length) return true + return this.normalizedWorkspaceRoots.some((workspaceRoot) => { + const rel = relative(workspaceRoot, absolutePath) + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) + }) } /** @@ -283,8 +306,8 @@ export class ApiMachineClient { } const resolvedDirectory = await this.resolveForWorkspaceCheck(directory) - if (!this.isWithinWorkspaceRoot(resolvedDirectory)) { - return { type: 'error', errorMessage: 'Directory is outside this machine\'s workspace root' } + if (!this.isWithinWorkspaceRoots(resolvedDirectory)) { + return { type: 'error', errorMessage: 'Directory is outside this machine\'s workspace roots' } } const result = await spawnSession({ @@ -428,31 +451,33 @@ export class ApiMachineClient { logger.debug('[API MACHINE] Failed to update runner state on connect', error) }) - const hubWorkspaceRoot = this.machine.metadata?.workspaceRoot - const desiredWorkspaceRoot = this.workspaceRoot - if (desiredWorkspaceRoot !== hubWorkspaceRoot) { - if (desiredWorkspaceRoot) { - console.log(`[HAPI] Syncing workspace root to hub: ${desiredWorkspaceRoot} (current hub value: ${hubWorkspaceRoot ?? 'none'})`) + const hubWorkspaceRoots = this.machine.metadata?.workspaceRoots + const desiredWorkspaceRoots = this.workspaceRoots + if (!workspaceRootsEqual(desiredWorkspaceRoots, hubWorkspaceRoots)) { + if (desiredWorkspaceRoots?.length) { + console.log(`[HAPI] Syncing workspace roots to hub: ${formatWorkspaceRoots(desiredWorkspaceRoots)} (current hub value: ${formatWorkspaceRoots(hubWorkspaceRoots)})`) } else { - console.log(`[HAPI] Clearing workspace root on hub (was: ${hubWorkspaceRoot})`) + console.log(`[HAPI] Clearing workspace roots on hub (was: ${formatWorkspaceRoots(hubWorkspaceRoots)})`) } this.updateMachineMetadata((current) => { const base = current ?? this.machine.metadata if (!base) { - return { workspaceRoot: desiredWorkspaceRoot } as MachineMetadata + return { workspaceRoots: desiredWorkspaceRoots } as MachineMetadata } - if (desiredWorkspaceRoot) { - return { ...base, workspaceRoot: desiredWorkspaceRoot } + if (desiredWorkspaceRoots?.length) { + return { ...base, workspaceRoots: desiredWorkspaceRoots } + } + const { workspaceRoot: _legacyWorkspaceRoot, workspaceRoots: _workspaceRoots, ...rest } = base as MachineMetadata & { + workspaceRoot?: string } - const { workspaceRoot: _omit, ...rest } = base return rest as MachineMetadata }).then(() => { - console.log(`[HAPI] Workspace root synced: ${this.machine.metadata?.workspaceRoot ?? '(none)'}`) + console.log(`[HAPI] Workspace roots synced: ${formatWorkspaceRoots(this.machine.metadata?.workspaceRoots)}`) }).catch((error) => { - console.error('[HAPI] Failed to sync workspace root:', error instanceof Error ? error.message : error) + console.error('[HAPI] Failed to sync workspace roots:', error instanceof Error ? error.message : error) }) - } else if (desiredWorkspaceRoot) { - console.log(`[HAPI] Workspace root already up to date on hub: ${desiredWorkspaceRoot}`) + } else if (desiredWorkspaceRoots?.length) { + console.log(`[HAPI] Workspace roots already up to date on hub: ${formatWorkspaceRoots(desiredWorkspaceRoots)}`) } this.startKeepAlive() diff --git a/cli/src/api/types.ts b/cli/src/api/types.ts index ddb8c3e4..fd59b583 100644 --- a/cli/src/api/types.ts +++ b/cli/src/api/types.ts @@ -37,7 +37,21 @@ export const MachineMetadataSchema = z.object({ homeDir: z.string(), happyHomeDir: z.string(), happyLibDir: z.string(), - workspaceRoot: z.string().optional() + workspaceRoot: z.string().optional(), + workspaceRoots: z.array(z.string()).optional() +}).transform(({ workspaceRoot, workspaceRoots, ...rest }) => { + const normalizedWorkspaceRoots = Array.from(new Set( + Array.isArray(workspaceRoots) + ? workspaceRoots.filter((path): path is string => typeof path === 'string' && path.trim().length > 0) + : workspaceRoot + ? [workspaceRoot] + : [] + )) + + return { + ...rest, + workspaceRoots: normalizedWorkspaceRoots.length > 0 ? normalizedWorkspaceRoots : undefined + } }) export type MachineMetadata = z.infer diff --git a/cli/src/commands/runner.ts b/cli/src/commands/runner.ts index 2b251c6d..e08c9cb1 100644 --- a/cli/src/commands/runner.ts +++ b/cli/src/commands/runner.ts @@ -16,15 +16,18 @@ import { initializeToken } from '@/ui/tokenInit' import type { CommandDefinition } from './types' /** - * Parses `--workspace-root ` / `--workspace-root=` from the - * runner's positional args. Returns the resolved absolute path or exits + * Parses repeated `--workspace-root ` / `--workspace-root=` from + * the runner's positional args. Returns resolved absolute paths or exits * the process with a clear error. Mutates `args` to remove the consumed * entries so subcommand dispatch still works. */ -function extractWorkspaceRootArg(args: string[]): string | undefined { - for (let i = 0; i < args.length; i++) { +function extractWorkspaceRootArgs(args: string[]): string[] | undefined { + const workspaceRoots: string[] = [] + + for (let i = 0; i < args.length;) { const arg = args[i] let value: string | undefined + let consumed = 0 if (arg === '--workspace-root') { const next = args[i + 1] if (next === undefined || next.startsWith('--')) { @@ -32,12 +35,15 @@ function extractWorkspaceRootArg(args: string[]): string | undefined { process.exit(1) } value = next - args.splice(i, 2) + consumed = 2 } else if (arg?.startsWith('--workspace-root=')) { value = arg.slice('--workspace-root='.length) - args.splice(i, 1) + consumed = 1 + } + if (value === undefined) { + i += 1 + continue } - if (value === undefined) continue const trimmed = value.trim() if (!trimmed) { @@ -56,9 +62,12 @@ function extractWorkspaceRootArg(args: string[]): string | undefined { console.error(`--workspace-root path does not exist or is not a directory: ${absolute}`) process.exit(1) } - return absolute + workspaceRoots.push(absolute) + args.splice(i, consumed) } - return undefined + + const uniqueWorkspaceRoots = Array.from(new Set(workspaceRoots)) + return uniqueWorkspaceRoots.length > 0 ? uniqueWorkspaceRoots : undefined } export const runnerCommand: CommandDefinition = { @@ -66,7 +75,7 @@ export const runnerCommand: CommandDefinition = { requiresRuntimeAssets: true, run: async ({ commandArgs }) => { const mutableArgs = [...commandArgs] - const workspaceRoot = extractWorkspaceRootArg(mutableArgs) + const workspaceRoots = extractWorkspaceRootArgs(mutableArgs) const runnerSubcommand = mutableArgs[0] if (runnerSubcommand === 'list') { @@ -103,8 +112,10 @@ export const runnerCommand: CommandDefinition = { if (runnerSubcommand === 'start') { const childArgs = ['runner', 'start-sync'] - if (workspaceRoot) { - childArgs.push('--workspace-root', workspaceRoot) + if (workspaceRoots?.length) { + for (const workspaceRoot of workspaceRoots) { + childArgs.push('--workspace-root', workspaceRoot) + } } const child = spawnHappyCLI(childArgs, { detached: true, @@ -133,7 +144,7 @@ export const runnerCommand: CommandDefinition = { if (runnerSubcommand === 'start-sync') { await initializeToken() - await startRunner({ workspaceRoot }) + await startRunner({ workspaceRoots }) process.exit(0) } @@ -168,7 +179,8 @@ ${chalk.bold('Usage:')} ${chalk.bold('Options:')} --workspace-root Restrict the runner to this directory. - Browse & spawn will reject paths outside it. + Repeat to allow multiple directories/drives. + Browse & spawn reject paths outside them. Supports \`~\` / \`~/foo\` expansion. Omit to leave browsing off (legacy mode). diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index c0f02a4d..3373036c 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -22,10 +22,10 @@ import { startRunnerControlServer } from './controlServer'; import { createWorktree, removeWorktree, type WorktreeInfo } from './worktree'; import { join } from 'path'; import { buildMachineMetadata } from '@/agent/sessionFactory'; -import { resolveWorkspaceRoot } from '@/utils/workspaceRoot'; +import { resolveWorkspaceRoots } from '@/utils/workspaceRoot'; import { hashRunnerCliApiToken } from './runnerIdentity'; -export async function startRunner(options: { workspaceRoot?: string } = {}): Promise { +export async function startRunner(options: { workspaceRoots?: string[] } = {}): Promise { // We don't have cleanup function at the time of server construction // Control flow is: // 1. Create promise that will resolve when shutdown is requested @@ -685,14 +685,14 @@ export async function startRunner(options: { workspaceRoot?: string } = {}): Pro // Create API client const api = await ApiClient.create(); - const workspaceRoot = resolveWorkspaceRoot(options.workspaceRoot); - logger.debug(`[RUNNER RUN] Workspace root: ${workspaceRoot ?? '(not set)'}`); + const workspaceRoots = resolveWorkspaceRoots(options.workspaceRoots); + logger.debug(`[RUNNER RUN] Workspace roots: ${workspaceRoots?.join(', ') ?? '(not set)'}`); // Get or create machine (with retry for transient connection errors) const machine = await withRetry( () => api.getOrCreateMachine({ machineId, - metadata: buildMachineMetadata({ workspaceRoot }), + metadata: buildMachineMetadata({ workspaceRoots }), runnerState: initialRunnerState }), { @@ -709,7 +709,7 @@ export async function startRunner(options: { workspaceRoot?: string } = {}): Pro logger.debug(`[RUNNER RUN] Machine registered: ${machine.id}`); // Create realtime machine session - const apiMachine = api.machineSyncClient(machine, { workspaceRoot }); + const apiMachine = api.machineSyncClient(machine, { workspaceRoots }); // Set RPC handlers apiMachine.setRPCHandlers({ @@ -725,7 +725,7 @@ export async function startRunner(options: { workspaceRoot?: string } = {}): Pro // regardless of the verbose/quiet logger setting. console.log(''); console.log('Hapi runner started.'); - console.log(` Workspace root: ${workspaceRoot ?? '(not set — browse disabled; pass --workspace-root to enable)'}`); + console.log(` Workspace roots: ${workspaceRoots?.join(', ') ?? '(not set — browse disabled; pass --workspace-root to enable)'}`); console.log(` Hub URL: ${configuration.apiUrl}`); console.log(` Machine ID: ${machine.id}`); console.log(` Control port: ${controlPort}`); diff --git a/cli/src/utils/workspaceRoot.ts b/cli/src/utils/workspaceRoot.ts index 29fabdf3..bff66a81 100644 --- a/cli/src/utils/workspaceRoot.ts +++ b/cli/src/utils/workspaceRoot.ts @@ -1,17 +1,22 @@ import { isAbsolute } from 'node:path' /** - * Resolves the runner's workspace root — the directory tree the runner is + * Resolves the runner's workspace roots — the directory trees the runner is * allowed to browse and spawn sessions in. Returns `undefined` when the * user hasn't explicitly opted in; in that case the runner behaves like - * the legacy hapi (no scoping, no /browse feature surfaced in the web UI). + * legacy hapi (no scoping, no /browse feature surfaced in the web UI). * * The only signal is the `explicit` argument — typically the resolved - * `--workspace-root` flag. Non-absolute values are ignored. + * `--workspace-root` flag values. Non-absolute values are ignored. */ -export function resolveWorkspaceRoot(explicit?: string): string | undefined { - if (explicit && isAbsolute(explicit)) { - return explicit +export function resolveWorkspaceRoots(explicit?: string[]): string[] | undefined { + if (!explicit?.length) { + return undefined } - return undefined + + const uniqueRoots = Array.from( + new Set(explicit.filter((path): path is string => typeof path === 'string' && isAbsolute(path))) + ) + + return uniqueRoots.length > 0 ? uniqueRoots : undefined } diff --git a/hub/src/sync/machineCache.ts b/hub/src/sync/machineCache.ts index 977dfa80..e80c3eb7 100644 --- a/hub/src/sync/machineCache.ts +++ b/hub/src/sync/machineCache.ts @@ -11,7 +11,8 @@ const machineMetadataSchema = z.object({ homeDir: z.string().optional(), happyHomeDir: z.string().optional(), happyLibDir: z.string().optional(), - workspaceRoot: z.string().optional() + workspaceRoot: z.string().optional(), + workspaceRoots: z.array(z.string()).optional() }) export interface Machine { @@ -30,7 +31,7 @@ export interface Machine { homeDir?: string happyHomeDir?: string happyLibDir?: string - workspaceRoot?: string + workspaceRoots?: string[] } | null metadataVersion: number runnerState: unknown | null @@ -103,8 +104,23 @@ export class MachineCache { const homeDir = typeof data.homeDir === 'string' ? data.homeDir : undefined const happyHomeDir = typeof data.happyHomeDir === 'string' ? data.happyHomeDir : undefined const happyLibDir = typeof data.happyLibDir === 'string' ? data.happyLibDir : undefined - const workspaceRoot = typeof data.workspaceRoot === 'string' ? data.workspaceRoot : undefined - return { host, platform, happyCliVersion, displayName, homeDir, happyHomeDir, happyLibDir, workspaceRoot } + const workspaceRoots = Array.from(new Set( + Array.isArray(data.workspaceRoots) + ? data.workspaceRoots.filter((path): path is string => typeof path === 'string' && path.trim().length > 0) + : typeof data.workspaceRoot === 'string' + ? [data.workspaceRoot] + : [] + )) + return { + host, + platform, + happyCliVersion, + displayName, + homeDir, + happyHomeDir, + happyLibDir, + workspaceRoots: workspaceRoots.length > 0 ? workspaceRoots : undefined + } })() const storedActiveAt = stored.activeAt ?? stored.createdAt diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index 41e32c10..bebc190a 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -290,11 +290,11 @@ export function NewSession(props: { }, [suggestions, selectedIndex, moveUp, moveDown, clearSuggestions, handleSuggestionSelect]) const chooseFolderCallback = props.onChooseFolder - const workspaceRootAvailable = Boolean(selectedMachine?.metadata?.workspaceRoot) + const workspaceRootsAvailable = Boolean(selectedMachine?.metadata?.workspaceRoots?.length) const handleChooseFolder = useMemo(() => { - if (!chooseFolderCallback || !workspaceRootAvailable) return undefined + if (!chooseFolderCallback || !workspaceRootsAvailable) return undefined return () => chooseFolderCallback({ machineId, directory: trimmedDirectory }) - }, [chooseFolderCallback, workspaceRootAvailable, machineId, trimmedDirectory]) + }, [chooseFolderCallback, workspaceRootsAvailable, machineId, trimmedDirectory]) async function handleCreate() { if (!machineId || !trimmedDirectory) return diff --git a/web/src/components/WorkspaceBrowser.tsx b/web/src/components/WorkspaceBrowser.tsx index 1b1e8f87..87d9ee2d 100644 --- a/web/src/components/WorkspaceBrowser.tsx +++ b/web/src/components/WorkspaceBrowser.tsx @@ -58,33 +58,85 @@ function getMachineTitle(machine: Machine): string { return machine.id.slice(0, 8) } +function getMachineRootsSummary(machine: Machine): string { + const roots = machine.metadata?.workspaceRoots ?? [] + if (roots.length === 0) return '' + if (roots.length === 1) return roots[0] + return `${roots[0]} (+${roots.length - 1})` +} + +function isWindowsStylePath(path: string): boolean { + return /^[A-Za-z]:[\\/]/.test(path) || path.includes('\\') +} + +function getPathSeparator(path: string): '/' | '\\' { + return isWindowsStylePath(path) ? '\\' : '/' +} + +function normalizePathForComparison(path: string): string { + const normalized = path.replace(/[\\/]+/g, '/') + if (/^[A-Za-z]:\/$/.test(normalized) || normalized === '/') { + return normalized + } + if (/^[A-Za-z]:$/.test(normalized)) { + return `${normalized}/` + } + return normalized.replace(/\/+$/, '') +} + +function denormalizePath(path: string, sample: string): string { + return getPathSeparator(sample) === '\\' + ? path.replace(/\//g, '\\') + : path +} + function joinPath(base: string, name: string): string { - return base.endsWith('/') ? base + name : base + '/' + name + const normalizedBase = normalizePathForComparison(base) + const joined = normalizedBase === '/' || /^[A-Za-z]:\/$/.test(normalizedBase) + ? `${normalizedBase}${name}` + : `${normalizedBase}/${name}` + return denormalizePath(joined, base) } function parentPath(path: string): string { - const stripped = path.replace(/\/+$/, '') - const idx = stripped.lastIndexOf('/') - if (idx <= 0) return '/' - return stripped.slice(0, idx) + const normalizedPath = normalizePathForComparison(path) + if (normalizedPath === '/' || /^[A-Za-z]:\/$/.test(normalizedPath)) { + return denormalizePath(normalizedPath, path) + } + + const idx = normalizedPath.lastIndexOf('/') + const parent = idx <= 0 ? '/' : normalizedPath.slice(0, idx) + const resolvedParent = /^[A-Za-z]:$/.test(parent) ? `${parent}/` : parent + return denormalizePath(resolvedParent, path) } function isPathWithin(candidate: string, root: string): boolean { - const c = candidate.replace(/\/+$/, '') || '/' - const r = root.replace(/\/+$/, '') || '/' - return c === r || c.startsWith(r + '/') + const c = normalizePathForComparison(candidate) + const r = normalizePathForComparison(root) + return c === r || c.startsWith(r.endsWith('/') ? r : `${r}/`) } function buildBreadcrumbs(currentPath: string, root: string): { label: string; path: string }[] { - const rootTrimmed = root.replace(/\/+$/, '') - const relative = currentPath.slice(rootTrimmed.length).replace(/^\/+/, '') - const crumbs: { label: string; path: string }[] = [{ label: rootTrimmed.split('/').pop() || '/', path: rootTrimmed || '/' }] + const normalizedRoot = normalizePathForComparison(root) + const normalizedCurrent = normalizePathForComparison(currentPath) + const rootLabel = (() => { + if (normalizedRoot === '/') return '/' + if (/^[A-Za-z]:\/$/.test(normalizedRoot)) return normalizedRoot.slice(0, 2) + return normalizedRoot.split('/').pop() || normalizedRoot + })() + const relative = normalizedCurrent.slice(normalizedRoot.length).replace(/^\/+/, '') + const crumbs: { label: string; path: string }[] = [{ + label: rootLabel, + path: denormalizePath(normalizedRoot, root) + }] if (!relative) return crumbs const parts = relative.split('/').filter(Boolean) - let acc = rootTrimmed + let acc = normalizedRoot for (const part of parts) { - acc = acc + '/' + part - crumbs.push({ label: part, path: acc }) + acc = acc === '/' || /^[A-Za-z]:\/$/.test(acc) + ? `${acc}${part}` + : `${acc}/${part}` + crumbs.push({ label: part, path: denormalizePath(acc, root) }) } return crumbs } @@ -101,6 +153,7 @@ export function WorkspaceBrowser(props: { const queryClient = useQueryClient() const [machineId, setMachineId] = useState(initialMachineId ?? null) + const [selectedRoot, setSelectedRoot] = useState(null) const [currentPath, setCurrentPath] = useState(null) const [entries, setEntries] = useState([]) const [isLoading, setIsLoading] = useState(false) @@ -131,7 +184,7 @@ export function WorkspaceBrowser(props: { () => machineId ? machines.find(m => m.id === machineId) ?? null : null, [machineId, machines] ) - const workspaceRoot = selectedMachine?.metadata?.workspaceRoot ?? null + const workspaceRoots = selectedMachine?.metadata?.workspaceRoots ?? [] const loadDirectory = useCallback(async (path: string) => { if (!machineId) return @@ -144,7 +197,7 @@ export function WorkspaceBrowser(props: { setCurrentPath(path) } else { setError(result.error ?? 'Failed to list directory') - // CLI may have just pushed new metadata (e.g. a workspaceRoot) + // CLI may have just pushed new metadata (e.g. workspaceRoots) // that we haven't picked up yet — refetch so the UI can // transition out of the no-root state if applicable. void queryClient.invalidateQueries({ queryKey: queryKeys.machines }) @@ -157,15 +210,25 @@ export function WorkspaceBrowser(props: { } }, [api, machineId, queryClient]) - // Auto-load workspace root when a machine with a root is selected useEffect(() => { - if (!machineId || !workspaceRoot) return - if (currentPath && isPathWithin(currentPath, workspaceRoot)) return - void loadDirectory(workspaceRoot) - }, [machineId, workspaceRoot, currentPath, loadDirectory]) + if (workspaceRoots.length === 0) { + if (selectedRoot !== null) setSelectedRoot(null) + return + } + if (selectedRoot && workspaceRoots.includes(selectedRoot)) return + setSelectedRoot(workspaceRoots[0] ?? null) + }, [workspaceRoots, selectedRoot]) + + // Auto-load selected root when a machine/root is selected + useEffect(() => { + if (!machineId || !selectedRoot) return + if (currentPath && isPathWithin(currentPath, selectedRoot)) return + void loadDirectory(selectedRoot) + }, [machineId, selectedRoot, currentPath, loadDirectory]) // If switching machines, reset view useEffect(() => { + setSelectedRoot(null) setCurrentPath(null) setEntries([]) setError(null) @@ -177,12 +240,12 @@ export function WorkspaceBrowser(props: { }, [currentPath, loadDirectory]) const handleGoUp = useCallback(() => { - if (!currentPath || !workspaceRoot) return - if (currentPath.replace(/\/+$/, '') === workspaceRoot.replace(/\/+$/, '')) return + if (!currentPath || !selectedRoot) return + if (normalizePathForComparison(currentPath) === normalizePathForComparison(selectedRoot)) return const parent = parentPath(currentPath) - if (!isPathWithin(parent, workspaceRoot)) return + if (!isPathWithin(parent, selectedRoot)) return void loadDirectory(parent) - }, [currentPath, workspaceRoot, loadDirectory]) + }, [currentPath, selectedRoot, loadDirectory]) const handleRefresh = useCallback(() => { if (currentPath) void loadDirectory(currentPath) @@ -194,12 +257,12 @@ export function WorkspaceBrowser(props: { }, [machineId, currentPath, props]) const breadcrumbs = useMemo(() => { - if (!currentPath || !workspaceRoot) return [] - return buildBreadcrumbs(currentPath, workspaceRoot) - }, [currentPath, workspaceRoot]) + if (!currentPath || !selectedRoot) return [] + return buildBreadcrumbs(currentPath, selectedRoot) + }, [currentPath, selectedRoot]) const directories = useMemo(() => entries.filter(e => e.type === 'directory'), [entries]) - const atRoot = !!(currentPath && workspaceRoot && currentPath.replace(/\/+$/, '') === workspaceRoot.replace(/\/+$/, '')) + const atRoot = !!(currentPath && selectedRoot && normalizePathForComparison(currentPath) === normalizePathForComparison(selectedRoot)) const machineSelector = (
@@ -213,7 +276,7 @@ export function WorkspaceBrowser(props: { {machines.map(m => ( ))} {machines.length === 0 && ( @@ -235,9 +298,9 @@ export function WorkspaceBrowser(props: { ) } - // Selected machine hasn't reported a workspaceRoot — show an info state. + // Selected machine hasn't reported workspace roots — show an info state. // Browsing is opt-in, triggered by `--workspace-root`. - if (selectedMachine && !workspaceRoot) { + if (selectedMachine && workspaceRoots.length === 0) { return (
{machineSelector}
@@ -245,7 +308,7 @@ export function WorkspaceBrowser(props: {
{t('browse.noRootTitle')}
{t('browse.noRootHint')}
- hapi runner start --workspace-root /path/to/folder + hapi runner start --workspace-root /path/a --workspace-root /path/b
{t('browse.noRootFooter')} @@ -260,6 +323,22 @@ export function WorkspaceBrowser(props: {
{machineSelector} + {workspaceRoots.length > 1 && ( +
+ +
+ )} + {currentPath && (