From 48643c940399dd168b9e0808d8d8e1e5b1a97162 Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Wed, 25 Mar 2026 19:17:57 +1300 Subject: [PATCH] feat: add filesystem browse API and path utilities Add the filesystem browsing infrastructure for navigating directories: - contracts: FilesystemBrowseInput/Result schemas, WS method, NativeApi - server: filesystem.browse endpoint with directory listing - web: wsNativeApi filesystem.browse transport wiring - shared: path detection helpers (Windows/Unix/relative) - web/lib: projectPaths utilities for browse navigation and project paths Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/server/src/wsServer.test.ts | 207 +++++++++++++++++++++++++ apps/server/src/wsServer.ts | 78 ++++++++++ apps/web/src/lib/projectPaths.test.ts | 107 +++++++++++++ apps/web/src/lib/projectPaths.ts | 213 ++++++++++++++++++++++++++ apps/web/src/wsNativeApi.test.ts | 14 ++ apps/web/src/wsNativeApi.ts | 3 + packages/contracts/src/filesystem.ts | 22 +++ packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 4 + packages/contracts/src/ws.test.ts | 19 +++ packages/contracts/src/ws.ts | 7 + packages/shared/package.json | 4 + packages/shared/src/path.test.ts | 34 ++++ packages/shared/src/path.ts | 22 +++ 14 files changed, 735 insertions(+) create mode 100644 apps/web/src/lib/projectPaths.test.ts create mode 100644 apps/web/src/lib/projectPaths.ts create mode 100644 packages/contracts/src/filesystem.ts create mode 100644 packages/shared/src/path.test.ts create mode 100644 packages/shared/src/path.ts diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index ff95b54112..d8180b488a 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1617,6 +1617,213 @@ describe("WebSocket Server", () => { }); }); + it("supports filesystem.browse with directory-only results", async () => { + const workspace = makeTempDir("t3code-ws-filesystem-browse-"); + fs.mkdirSync(path.join(workspace, "components"), { recursive: true }); + fs.mkdirSync(path.join(workspace, "composables"), { recursive: true }); + fs.writeFileSync(path.join(workspace, "composer.ts"), "export {};\n", "utf8"); + + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.filesystemBrowse, { + partialPath: path.join(workspace, "comp"), + }); + + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + parentPath: workspace, + entries: [ + { + name: "components", + fullPath: path.join(workspace, "components"), + }, + { + name: "composables", + fullPath: path.join(workspace, "composables"), + }, + ], + }); + }); + + it("includes hidden directories when browsing a full directory", async () => { + const workspace = makeTempDir("t3code-ws-filesystem-browse-hidden-"); + fs.mkdirSync(path.join(workspace, ".config"), { recursive: true }); + fs.mkdirSync(path.join(workspace, "docs"), { recursive: true }); + + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.filesystemBrowse, { + partialPath: `${workspace}/`, + }); + + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + parentPath: workspace, + entries: [ + { + name: ".config", + fullPath: path.join(workspace, ".config"), + }, + { + name: "docs", + fullPath: path.join(workspace, "docs"), + }, + ], + }); + }); + + it("skips unreadable or broken browse entries instead of failing the request", async () => { + if (process.platform === "win32") { + return; + } + + const workspace = makeTempDir("t3code-ws-filesystem-browse-broken-entry-"); + fs.mkdirSync(path.join(workspace, "docs"), { recursive: true }); + fs.symlinkSync(path.join(workspace, "missing-target"), path.join(workspace, "broken-link")); + + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.filesystemBrowse, { + partialPath: `${workspace}/`, + }); + + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + parentPath: workspace, + entries: [ + { + name: "docs", + fullPath: path.join(workspace, "docs"), + }, + ], + }); + }); + + it("resolves relative filesystem.browse paths against the provided cwd", async () => { + const workspace = makeTempDir("t3code-ws-filesystem-browse-relative-"); + fs.mkdirSync(path.join(workspace, "apps"), { recursive: true }); + fs.mkdirSync(path.join(workspace, "docs"), { recursive: true }); + + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.filesystemBrowse, { + partialPath: "../d", + cwd: path.join(workspace, "apps"), + }); + + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + parentPath: workspace, + entries: [ + { + name: "docs", + fullPath: path.join(workspace, "docs"), + }, + ], + }); + }); + + it("resolves bare dot and dot-dot filesystem.browse paths against the provided cwd", async () => { + const root = makeTempDir("t3code-ws-filesystem-browse-dot-relative-"); + const workspace = path.join(root, "workspace"); + fs.mkdirSync(path.join(workspace, "apps"), { recursive: true }); + + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const currentDirResponse = await sendRequest(ws, WS_METHODS.filesystemBrowse, { + partialPath: ".", + cwd: workspace, + }); + expect(currentDirResponse.error).toBeUndefined(); + expect(currentDirResponse.result).toEqual({ + parentPath: root, + entries: [ + { + name: "workspace", + fullPath: workspace, + }, + ], + }); + + const parentDirResponse = await sendRequest(ws, WS_METHODS.filesystemBrowse, { + partialPath: "..", + cwd: path.join(workspace, "apps"), + }); + expect(parentDirResponse.error).toBeUndefined(); + expect(parentDirResponse.result).toEqual({ + parentPath: root, + entries: [ + { + name: "workspace", + fullPath: workspace, + }, + ], + }); + }); + + it("rejects relative filesystem.browse paths without a cwd", async () => { + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.filesystemBrowse, { + partialPath: "./docs", + }); + + expect(response.result).toBeUndefined(); + expect(response.error?.message).toContain( + "Relative filesystem browse paths require a current project.", + ); + }); + + it("rejects windows-style filesystem.browse paths on non-windows hosts", async () => { + if (process.platform === "win32") { + return; + } + + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.filesystemBrowse, { + partialPath: "C:\\Work\\Repo", + }); + + expect(response.result).toBeUndefined(); + expect(response.error?.message).toContain("Windows-style paths are only supported on Windows."); + }); + it("supports projects.writeFile within the workspace root", async () => { const workspace = makeTempDir("t3code-ws-write-file-"); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index bcb3850e7a..880fee575f 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -77,6 +77,7 @@ import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; import { expandHomePath } from "./os-jank.ts"; import { makeServerPushBus } from "./wsServer/pushBus.ts"; import { makeServerReadiness } from "./wsServer/readiness.ts"; +import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; /** @@ -110,6 +111,23 @@ const isServerNotRunningError = (error: Error): boolean => { ); }; +function resolveFilesystemBrowseInputPath(input: { + cwd: string | undefined; + path: Path.Path; + partialPath: string; +}): Effect.Effect { + return Effect.gen(function* () { + if (!isExplicitRelativePath(input.partialPath)) { + return input.path.resolve(yield* expandHomePath(input.partialPath)); + } + if (!input.cwd) { + return null; + } + const expandedCwd = yield* expandHomePath(input.cwd); + return input.path.resolve(expandedCwd, input.partialPath); + }); +} + function rejectUpgrade(socket: Duplex, statusCode: number, message: string): void { socket.end( `HTTP/1.1 ${statusCode} ${statusCode === 401 ? "Unauthorized" : "Bad Request"}\r\n` + @@ -878,6 +896,66 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return yield* terminalManager.close(body); } + case WS_METHODS.filesystemBrowse: { + const body = stripRequestTag(request.body); + if (process.platform !== "win32" && isWindowsAbsolutePath(body.partialPath)) { + return yield* new RouteRequestError({ + message: "Windows-style paths are only supported on Windows.", + }); + } + const resolvedInputPath = yield* resolveFilesystemBrowseInputPath({ + cwd: body.cwd, + path, + partialPath: body.partialPath, + }); + if (resolvedInputPath === null) { + return yield* new RouteRequestError({ + message: "Relative filesystem browse paths require a current project.", + }); + } + + const expanded = resolvedInputPath; + const endsWithSep = /[\\/]$/.test(body.partialPath) || body.partialPath === "~"; + const parentDir = endsWithSep ? expanded : path.dirname(expanded); + const prefix = endsWithSep ? "" : path.basename(expanded); + + const names = yield* fileSystem.readDirectory(parentDir).pipe( + Effect.mapError( + (cause) => + new RouteRequestError({ + message: `Unable to browse '${parentDir}': ${Cause.pretty(Cause.fail(cause)).trim()}`, + }), + ), + ); + + const showHidden = endsWithSep || prefix.startsWith("."); + const lowerPrefix = prefix.toLowerCase(); + const filtered = names + .filter( + (name) => + name.toLowerCase().startsWith(lowerPrefix) && (showHidden || !name.startsWith(".")), + ) + .toSorted((left, right) => left.localeCompare(right)); + + const entries = yield* Effect.forEach( + filtered, + (name) => + fileSystem.stat(path.join(parentDir, name)).pipe( + Effect.match({ + onFailure: () => null, + onSuccess: (s) => + s.type === "Directory" ? { name, fullPath: path.join(parentDir, name) } : null, + }), + ), + { concurrency: 16 }, + ); + + return { + parentPath: parentDir, + entries: entries.filter(Boolean), + }; + } + case WS_METHODS.serverGetConfig: const keybindingsConfig = yield* keybindingsManager.loadConfigState; return { diff --git a/apps/web/src/lib/projectPaths.test.ts b/apps/web/src/lib/projectPaths.test.ts new file mode 100644 index 0000000000..81fb47fa11 --- /dev/null +++ b/apps/web/src/lib/projectPaths.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; + +import { + appendBrowsePathSegment, + canNavigateUp, + getBrowseDirectoryPath, + findProjectByPath, + getBrowseLeafPathSegment, + getBrowseParentPath, + hasTrailingPathSeparator, + inferProjectTitleFromPath, + isExplicitRelativeProjectPath, + isFilesystemBrowseQuery, + normalizeProjectPathForComparison, + normalizeProjectPathForDispatch, + isUnsupportedWindowsProjectPath, + resolveProjectPathForDispatch, +} from "./projectPaths"; + +describe("projectPaths", () => { + it("normalizes trailing separators for dispatch and comparison", () => { + expect(normalizeProjectPathForDispatch(" /repo/app/ ")).toBe("/repo/app"); + expect(normalizeProjectPathForComparison("/repo/app/")).toBe("/repo/app"); + }); + + it("normalizes windows-style paths for comparison", () => { + expect(normalizeProjectPathForComparison("C:/Work/Repo/")).toBe("c:\\work\\repo"); + expect(normalizeProjectPathForComparison("C:\\Work\\Repo\\")).toBe("c:\\work\\repo"); + }); + + it("finds existing projects even when the input formatting differs", () => { + const existing = findProjectByPath( + [ + { id: "project-1", cwd: "/repo/app" }, + { id: "project-2", cwd: "C:\\Work\\Repo" }, + ], + "C:/Work/Repo/", + ); + + expect(existing?.id).toBe("project-2"); + }); + + it("infers project titles from normalized paths", () => { + expect(inferProjectTitleFromPath("/repo/app/")).toBe("app"); + expect(inferProjectTitleFromPath("C:\\Work\\Repo\\")).toBe("Repo"); + }); + + it("detects browse queries across supported path styles", () => { + expect(isFilesystemBrowseQuery(".")).toBe(false); + expect(isFilesystemBrowseQuery("..")).toBe(false); + expect(isFilesystemBrowseQuery("./")).toBe(true); + expect(isFilesystemBrowseQuery("../")).toBe(true); + expect(isFilesystemBrowseQuery("~/projects")).toBe(true); + expect(isFilesystemBrowseQuery("..\\docs")).toBe(true); + expect(isFilesystemBrowseQuery("notes")).toBe(false); + }); + + it("only treats windows-style paths as browse queries on windows", () => { + expect(isFilesystemBrowseQuery("C:\\Work\\Repo\\", "MacIntel")).toBe(false); + expect(isFilesystemBrowseQuery("C:\\Work\\Repo\\", "Win32")).toBe(true); + expect(isUnsupportedWindowsProjectPath("C:\\Work\\Repo\\", "MacIntel")).toBe(true); + expect(isUnsupportedWindowsProjectPath("C:\\Work\\Repo\\", "Win32")).toBe(false); + }); + + it("detects explicit relative project paths", () => { + expect(isExplicitRelativeProjectPath(".")).toBe(true); + expect(isExplicitRelativeProjectPath("..")).toBe(true); + expect(isExplicitRelativeProjectPath("./docs")).toBe(true); + expect(isExplicitRelativeProjectPath("..\\docs")).toBe(true); + expect(isExplicitRelativeProjectPath("/repo/docs")).toBe(false); + }); + + it("resolves explicit relative paths against the current project", () => { + expect(resolveProjectPathForDispatch(".", "/repo/app")).toBe("/repo/app"); + expect(resolveProjectPathForDispatch("..", "/repo/app")).toBe("/repo"); + expect(resolveProjectPathForDispatch("./docs", "/repo/app")).toBe("/repo/app/docs"); + expect(resolveProjectPathForDispatch("../docs", "/repo/app")).toBe("/repo/docs"); + expect(resolveProjectPathForDispatch("./Repo", "C:\\Work")).toBe("C:\\Work\\Repo"); + }); + + it("navigates browse paths with matching separators", () => { + expect(appendBrowsePathSegment("/repo/", "src")).toBe("/repo/src/"); + expect(appendBrowsePathSegment("C:\\Work\\", "Repo")).toBe("C:\\Work\\Repo\\"); + expect(getBrowseParentPath("/repo/src/")).toBe("/repo/"); + expect(getBrowseParentPath("C:\\Work\\Repo\\")).toBe("C:\\Work\\"); + expect(getBrowseParentPath("\\\\server\\share\\")).toBeNull(); + expect(getBrowseParentPath("\\\\server\\share\\repo\\")).toBe("\\\\server\\share\\"); + expect(getBrowseParentPath("C:\\")).toBeNull(); + }); + + it("detects browse path boundaries", () => { + expect(hasTrailingPathSeparator("/repo/src/")).toBe(true); + expect(hasTrailingPathSeparator("/repo/src")).toBe(false); + expect(getBrowseDirectoryPath("/repo/src")).toBe("/repo/"); + expect(getBrowseDirectoryPath("/repo/src/")).toBe("/repo/src/"); + expect(getBrowseLeafPathSegment("/repo/src")).toBe("src"); + expect(getBrowseLeafPathSegment("C:\\Work\\Repo\\Docs")).toBe("Docs"); + }); + + it("only allows browse-up after entering a directory", () => { + expect(canNavigateUp("~/repo")).toBe(false); + expect(canNavigateUp("~/a")).toBe(false); + expect(canNavigateUp("~/repo/")).toBe(true); + expect(canNavigateUp("\\\\server\\share\\")).toBe(false); + expect(canNavigateUp("\\\\server\\share\\repo\\")).toBe(true); + }); +}); diff --git a/apps/web/src/lib/projectPaths.ts b/apps/web/src/lib/projectPaths.ts new file mode 100644 index 0000000000..281f67baa1 --- /dev/null +++ b/apps/web/src/lib/projectPaths.ts @@ -0,0 +1,213 @@ +import { + isExplicitRelativePath, + isUncPath, + isWindowsAbsolutePath, + isWindowsDrivePath, +} from "@t3tools/shared/path"; +import { isWindowsPlatform } from "./utils"; + +function isRootPath(value: string): boolean { + return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value); +} + +function trimTrailingPathSeparators(value: string): string { + if (value.length === 0 || isRootPath(value)) { + return value; + } + + const trimmed = value.replace(/[\\/]+$/g, ""); + if (trimmed.length === 0) { + return value; + } + + return /^[a-zA-Z]:$/.test(trimmed) ? `${trimmed}\\` : trimmed; +} + +function preferredPathSeparator(value: string): "/" | "\\" { + return value.includes("\\") ? "\\" : "/"; +} + +export function hasTrailingPathSeparator(value: string): boolean { + return /[\\/]$/.test(value); +} + +export { isExplicitRelativePath as isExplicitRelativeProjectPath }; + +function splitAbsolutePath(value: string): { + root: string; + separator: "/" | "\\"; + segments: string[]; +} | null { + const separator = preferredPathSeparator(value); + if (isWindowsDrivePath(value)) { + const root = `${value.slice(0, 2)}\\`; + const segments = value + .slice(root.length) + .split(/[\\/]+/) + .filter(Boolean); + return { root, separator: "\\", segments }; + } + if (isUncPath(value)) { + const segments = value.split(/[\\/]+/).filter(Boolean); + const [server, share, ...rest] = segments; + if (!server || !share) { + return null; + } + return { + root: `\\\\${server}\\${share}\\`, + separator: "\\", + segments: rest, + }; + } + if (value.startsWith("/")) { + return { + root: "/", + separator, + segments: value + .slice(1) + .split(/[\\/]+/) + .filter(Boolean), + }; + } + return null; +} + +export function isFilesystemBrowseQuery( + value: string, + platform = typeof navigator === "undefined" ? "" : navigator.platform, +): boolean { + const allowWindowsPaths = isWindowsPlatform(platform); + return ( + value.startsWith("./") || + value.startsWith("../") || + value.startsWith(".\\") || + value.startsWith("..\\") || + value.startsWith("/") || + value.startsWith("~/") || + (allowWindowsPaths && isWindowsAbsolutePath(value)) + ); +} + +export function isUnsupportedWindowsProjectPath(value: string, platform: string): boolean { + return isWindowsAbsolutePath(value) && !isWindowsPlatform(platform); +} + +export function normalizeProjectPathForDispatch(value: string): string { + return trimTrailingPathSeparators(value.trim()); +} + +export function resolveProjectPathForDispatch(value: string, cwd?: string | null): string { + const trimmedValue = value.trim(); + if (!isExplicitRelativePath(trimmedValue) || !cwd) { + return normalizeProjectPathForDispatch(trimmedValue); + } + + const absoluteBase = splitAbsolutePath(normalizeProjectPathForDispatch(cwd)); + if (!absoluteBase) { + return normalizeProjectPathForDispatch(trimmedValue); + } + + const nextSegments = [...absoluteBase.segments]; + for (const segment of trimmedValue.split(/[\\/]+/)) { + if (segment.length === 0 || segment === ".") { + continue; + } + if (segment === "..") { + nextSegments.pop(); + continue; + } + nextSegments.push(segment); + } + + const joinedPath = nextSegments.join(absoluteBase.separator); + if (joinedPath.length === 0) { + return normalizeProjectPathForDispatch(absoluteBase.root); + } + + return normalizeProjectPathForDispatch(`${absoluteBase.root}${joinedPath}`); +} + +export function normalizeProjectPathForComparison(value: string): string { + const normalized = normalizeProjectPathForDispatch(value); + if (isWindowsDrivePath(normalized) || normalized.startsWith("\\\\")) { + return normalized.replaceAll("/", "\\").toLowerCase(); + } + return normalized; +} + +export function findProjectByPath( + projects: ReadonlyArray, + candidatePath: string, +): T | undefined { + const normalizedCandidate = normalizeProjectPathForComparison(candidatePath); + if (normalizedCandidate.length === 0) { + return undefined; + } + + return projects.find( + (project) => normalizeProjectPathForComparison(project.cwd) === normalizedCandidate, + ); +} + +export function inferProjectTitleFromPath(value: string): string { + const normalized = normalizeProjectPathForDispatch(value); + const segments = normalized.split(/[/\\]/); + return segments.findLast(Boolean) ?? normalized; +} + +export function appendBrowsePathSegment(currentPath: string, segment: string): string { + const separator = preferredPathSeparator(currentPath); + return `${getBrowseDirectoryPath(currentPath)}${segment}${separator}`; +} + +export function getBrowseLeafPathSegment(currentPath: string): string { + const lastSeparatorIndex = Math.max(currentPath.lastIndexOf("/"), currentPath.lastIndexOf("\\")); + return currentPath.slice(lastSeparatorIndex + 1); +} + +export function getBrowseDirectoryPath(currentPath: string): string { + if (hasTrailingPathSeparator(currentPath)) { + return currentPath; + } + + const lastSeparatorIndex = Math.max(currentPath.lastIndexOf("/"), currentPath.lastIndexOf("\\")); + if (lastSeparatorIndex < 0) { + return currentPath; + } + + return currentPath.slice(0, lastSeparatorIndex + 1); +} + +export function getBrowseParentPath(currentPath: string): string | null { + const trimmed = trimTrailingPathSeparators(currentPath); + const absolutePath = splitAbsolutePath(trimmed); + if (absolutePath) { + if (absolutePath.segments.length === 0) { + return null; + } + + if (absolutePath.segments.length === 1) { + return absolutePath.root; + } + + const parentSegments = absolutePath.segments.slice(0, -1).join(absolutePath.separator); + return `${absolutePath.root}${parentSegments}${absolutePath.separator}`; + } + + const separator = preferredPathSeparator(currentPath); + const lastSeparatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + + if (lastSeparatorIndex < 0) { + return null; + } + + if (lastSeparatorIndex === 2 && /^[a-zA-Z]:/.test(trimmed)) { + return `${trimmed.slice(0, 2)}${separator}`; + } + + return trimmed.slice(0, lastSeparatorIndex + 1); +} + +export function canNavigateUp(currentPath: string): boolean { + return hasTrailingPathSeparator(currentPath) && getBrowseParentPath(currentPath) !== null; +} diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index e500b57791..ec0ad1d472 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -342,6 +342,20 @@ describe("wsNativeApi", () => { }); }); + it("forwards filesystem browse requests to the websocket filesystem method", async () => { + requestMock.mockResolvedValue({ parentPath: "/tmp", entries: [] }); + const { createWsNativeApi } = await import("./wsNativeApi"); + + const api = createWsNativeApi(); + await api.filesystem.browse({ + partialPath: "/tmp/project", + }); + + expect(requestMock).toHaveBeenCalledWith(WS_METHODS.filesystemBrowse, { + partialPath: "/tmp/project", + }); + }); + it("uses no client timeout for git.runStackedAction", async () => { requestMock.mockResolvedValue({ action: "commit", diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 042875f6f7..fa7a2a8be4 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -127,6 +127,9 @@ export function createWsNativeApi(): NativeApi { searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), writeFile: (input) => transport.request(WS_METHODS.projectsWriteFile, input), }, + filesystem: { + browse: (input) => transport.request(WS_METHODS.filesystemBrowse, input), + }, shell: { openInEditor: (cwd, editor) => transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }), diff --git a/packages/contracts/src/filesystem.ts b/packages/contracts/src/filesystem.ts new file mode 100644 index 0000000000..0675066daf --- /dev/null +++ b/packages/contracts/src/filesystem.ts @@ -0,0 +1,22 @@ +import { Schema } from "effect"; +import { TrimmedNonEmptyString } from "./baseSchemas"; + +const FILESYSTEM_PATH_MAX_LENGTH = 512; + +export const FilesystemBrowseInput = Schema.Struct({ + partialPath: TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH)), + cwd: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH))), +}); +export type FilesystemBrowseInput = typeof FilesystemBrowseInput.Type; + +export const FilesystemBrowseEntry = Schema.Struct({ + name: TrimmedNonEmptyString, + fullPath: TrimmedNonEmptyString, +}); +export type FilesystemBrowseEntry = typeof FilesystemBrowseEntry.Type; + +export const FilesystemBrowseResult = Schema.Struct({ + parentPath: TrimmedNonEmptyString, + entries: Schema.Array(FilesystemBrowseEntry), +}); +export type FilesystemBrowseResult = typeof FilesystemBrowseResult.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 0f37a93515..0f88834086 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -11,3 +11,4 @@ export * from "./git"; export * from "./orchestration"; export * from "./editor"; export * from "./project"; +export * from "./filesystem"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index ea73024de3..d6e38d3e5e 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -19,6 +19,7 @@ import type { GitStatusInput, GitStatusResult, } from "./git"; +import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem"; import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult, @@ -130,6 +131,9 @@ export interface NativeApi { searchEntries: (input: ProjectSearchEntriesInput) => Promise; writeFile: (input: ProjectWriteFileInput) => Promise; }; + filesystem: { + browse: (input: FilesystemBrowseInput) => Promise; + }; shell: { openInEditor: (cwd: string, editor: EditorId) => Promise; openExternal: (url: string) => Promise; diff --git a/packages/contracts/src/ws.test.ts b/packages/contracts/src/ws.test.ts index 2030dad4e5..8b563fbdb6 100644 --- a/packages/contracts/src/ws.test.ts +++ b/packages/contracts/src/ws.test.ts @@ -73,6 +73,25 @@ it.effect("accepts git.preparePullRequestThread requests", () => }), ); +it.effect("accepts filesystem browse requests and trims the partial path", () => + Effect.gen(function* () { + const parsed = yield* decodeWebSocketRequest({ + id: "req-filesystem-1", + body: { + _tag: WS_METHODS.filesystemBrowse, + partialPath: " ~/projects ", + cwd: " /repo/app ", + }, + }); + + assert.strictEqual(parsed.body._tag, WS_METHODS.filesystemBrowse); + if (parsed.body._tag === WS_METHODS.filesystemBrowse) { + assert.strictEqual(parsed.body.partialPath, "~/projects"); + assert.strictEqual(parsed.body.cwd, "/repo/app"); + } + }), +); + it.effect("accepts typed websocket push envelopes with sequence", () => Effect.gen(function* () { const parsed = yield* decodeWsResponse({ diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 45ef0512da..e5fce6178e 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -34,6 +34,7 @@ import { TerminalRestartInput, TerminalWriteInput, } from "./terminal"; +import { FilesystemBrowseInput } from "./filesystem"; import { KeybindingRule } from "./keybindings"; import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; import { OpenInEditorInput } from "./editor"; @@ -73,6 +74,9 @@ export const WS_METHODS = { terminalRestart: "terminal.restart", terminalClose: "terminal.close", + // Filesystem + filesystemBrowse: "filesystem.browse", + // Server meta serverGetConfig: "server.getConfig", serverUpsertKeybinding: "server.upsertKeybinding", @@ -138,6 +142,9 @@ const WebSocketRequestBody = Schema.Union([ tagRequestBody(WS_METHODS.terminalRestart, TerminalRestartInput), tagRequestBody(WS_METHODS.terminalClose, TerminalCloseInput), + // Filesystem + tagRequestBody(WS_METHODS.filesystemBrowse, FilesystemBrowseInput), + // Server meta tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})), tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule), diff --git a/packages/shared/package.json b/packages/shared/package.json index 02ae794d64..eb7d7fa400 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -31,6 +31,10 @@ "./schemaJson": { "types": "./src/schemaJson.ts", "import": "./src/schemaJson.ts" + }, + "./path": { + "types": "./src/path.ts", + "import": "./src/path.ts" } }, "scripts": { diff --git a/packages/shared/src/path.test.ts b/packages/shared/src/path.test.ts new file mode 100644 index 0000000000..912e1e13d7 --- /dev/null +++ b/packages/shared/src/path.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + isExplicitRelativePath, + isUncPath, + isWindowsAbsolutePath, + isWindowsDrivePath, +} from "./path"; + +describe("path helpers", () => { + it("detects windows drive paths", () => { + expect(isWindowsDrivePath("C:\\repo")).toBe(true); + expect(isWindowsDrivePath("D:/repo")).toBe(true); + expect(isWindowsDrivePath("/repo")).toBe(false); + }); + + it("detects UNC paths", () => { + expect(isUncPath("\\\\server\\share\\repo")).toBe(true); + expect(isUncPath("C:\\repo")).toBe(false); + }); + + it("detects windows absolute paths", () => { + expect(isWindowsAbsolutePath("C:\\repo")).toBe(true); + expect(isWindowsAbsolutePath("\\\\server\\share\\repo")).toBe(true); + expect(isWindowsAbsolutePath("./repo")).toBe(false); + }); + + it("detects explicit relative paths", () => { + expect(isExplicitRelativePath(".")).toBe(true); + expect(isExplicitRelativePath("..")).toBe(true); + expect(isExplicitRelativePath("./repo")).toBe(true); + expect(isExplicitRelativePath("..\\repo")).toBe(true); + expect(isExplicitRelativePath("~/repo")).toBe(false); + }); +}); diff --git a/packages/shared/src/path.ts b/packages/shared/src/path.ts new file mode 100644 index 0000000000..2bb2ca0238 --- /dev/null +++ b/packages/shared/src/path.ts @@ -0,0 +1,22 @@ +export function isWindowsDrivePath(value: string): boolean { + return /^[a-zA-Z]:([/\\]|$)/.test(value); +} + +export function isUncPath(value: string): boolean { + return value.startsWith("\\\\"); +} + +export function isWindowsAbsolutePath(value: string): boolean { + return isUncPath(value) || isWindowsDrivePath(value); +} + +export function isExplicitRelativePath(value: string): boolean { + return ( + value === "." || + value === ".." || + value.startsWith("./") || + value.startsWith("../") || + value.startsWith(".\\") || + value.startsWith("..\\") + ); +}