diff --git a/apps/server/src/fileSystemBrowser.test.ts b/apps/server/src/fileSystemBrowser.test.ts new file mode 100644 index 00000000..eda3efe9 --- /dev/null +++ b/apps/server/src/fileSystemBrowser.test.ts @@ -0,0 +1,128 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, assert, describe, expect, it } from "vitest"; + +import { browseFileSystemDirectory } from "./fileSystemBrowser"; + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + // On macOS /tmp resolves through a symlink to /private/tmp. The handler + // resolves symlinks in the returned path, so normalize expectations. + const resolved = fs.realpathSync(dir); + tempDirs.push(resolved); + return resolved; +} + +describe("browseFileSystemDirectory", () => { + afterEach(() => { + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("lists directories first, then files, each group alphabetically", async () => { + const root = makeTempDir("okcode-fsbrowse-sort-"); + fs.mkdirSync(path.join(root, "zeta-dir")); + fs.mkdirSync(path.join(root, "alpha-dir")); + fs.writeFileSync(path.join(root, "b.txt"), ""); + fs.writeFileSync(path.join(root, "a.txt"), ""); + + const result = await browseFileSystemDirectory({ path: root }); + + assert.deepEqual( + result.entries.map((e) => ({ name: e.name, kind: e.kind })), + [ + { name: "alpha-dir", kind: "directory" }, + { name: "zeta-dir", kind: "directory" }, + { name: "a.txt", kind: "file" }, + { name: "b.txt", kind: "file" }, + ], + ); + assert.equal(result.path, root); + assert.equal(result.parentPath, path.dirname(root)); + assert.equal(result.partial, false); + }); + + it("filters dot-prefixed entries by default", async () => { + const root = makeTempDir("okcode-fsbrowse-hidden-"); + fs.writeFileSync(path.join(root, "visible.txt"), ""); + fs.writeFileSync(path.join(root, ".secret"), ""); + fs.mkdirSync(path.join(root, ".hidden-dir")); + + const result = await browseFileSystemDirectory({ path: root }); + const names = result.entries.map((e) => e.name); + + assert.deepEqual(names, ["visible.txt"]); + }); + + it("includes dot-prefixed entries when includeHidden is true", async () => { + const root = makeTempDir("okcode-fsbrowse-hidden-on-"); + fs.writeFileSync(path.join(root, "visible.txt"), ""); + fs.writeFileSync(path.join(root, ".secret"), ""); + fs.mkdirSync(path.join(root, ".hidden-dir")); + + const result = await browseFileSystemDirectory({ path: root, includeHidden: true }); + const names = result.entries.map((e) => e.name).sort(); + + assert.deepEqual(names, [".hidden-dir", ".secret", "visible.txt"]); + }); + + it("rejects non-absolute paths", async () => { + await expect(browseFileSystemDirectory({ path: "relative/path" })).rejects.toThrow( + /must be absolute/, + ); + }); + + it("rejects paths that are not directories", async () => { + const root = makeTempDir("okcode-fsbrowse-notdir-"); + const file = path.join(root, "plain.txt"); + fs.writeFileSync(file, ""); + + await expect(browseFileSystemDirectory({ path: file })).rejects.toThrow(/not a directory/); + }); + + it("flags partial and reports as file when a symlink target is unreadable", async () => { + const root = makeTempDir("okcode-fsbrowse-brokenlink-"); + const missingTarget = path.join(root, "does-not-exist"); + const link = path.join(root, "broken-link"); + fs.symlinkSync(missingTarget, link); + + const result = await browseFileSystemDirectory({ path: root }); + + assert.equal(result.partial, true); + assert.equal(result.entries.length, 1); + assert.equal(result.entries[0]?.name, "broken-link"); + assert.equal(result.entries[0]?.kind, "file"); + assert.equal(result.entries[0]?.isSymlink, true); + }); + + it("resolves symlinks to directories as directory-kind entries", async () => { + const root = makeTempDir("okcode-fsbrowse-dirlink-"); + const target = path.join(root, "real-dir"); + fs.mkdirSync(target); + fs.symlinkSync(target, path.join(root, "alias-dir")); + + const result = await browseFileSystemDirectory({ path: root }); + + const alias = result.entries.find((e) => e.name === "alias-dir"); + assert.ok(alias, "expected alias-dir entry"); + assert.equal(alias.kind, "directory"); + assert.equal(alias.isSymlink, true); + assert.equal(result.partial, false); + }); + + it("omits parentPath at the filesystem root", async () => { + const result = await browseFileSystemDirectory({ path: "/" }); + assert.equal(result.path, "/"); + assert.equal(result.parentPath, undefined); + }); + + it("defaults to the service user's home directory when path is omitted", async () => { + const result = await browseFileSystemDirectory({}); + assert.equal(result.path, os.homedir()); + }); +}); diff --git a/apps/server/src/fileSystemBrowser.ts b/apps/server/src/fileSystemBrowser.ts new file mode 100644 index 00000000..b937e450 --- /dev/null +++ b/apps/server/src/fileSystemBrowser.ts @@ -0,0 +1,91 @@ +import { promises as fs } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; + +import type { + FileSystemEntry, + ProjectBrowseDirectoryInput, + ProjectBrowseDirectoryResult, +} from "@okcode/contracts"; + +/** + * Lists the immediate children of a directory on the machine running the OK + * Code server. Unlike `listWorkspaceDirectory`, this does not build a full + * project index — it is intended for interactive folder-picker UIs where the + * user navigates one level at a time. + * + * SECURITY POSTURE: This endpoint is reachable only through the authenticated + * WebSocket transport (the same auth-token gate as every other WS method), but + * within that gate it performs no path allowlisting. Any caller holding a + * valid token can enumerate any directory the server process can stat — + * effectively the entire filesystem of the user the server runs as. That is + * intentional: this is a filesystem picker, and constraining it would defeat + * the feature. Operators running the server with a shared or widely-distributed + * token should treat filesystem enumeration as within scope of that token. + */ +export async function browseFileSystemDirectory( + input: ProjectBrowseDirectoryInput, +): Promise { + const requested = input.path ?? homedir(); + if (!path.isAbsolute(requested)) { + throw new Error(`path must be absolute, got: ${requested}`); + } + + const resolved = path.resolve(requested); + const stat = await fs.stat(resolved); + if (!stat.isDirectory()) { + throw new Error(`not a directory: ${resolved}`); + } + + const dirents = await fs.readdir(resolved, { withFileTypes: true }); + const includeHidden = input.includeHidden ?? false; + const entries: FileSystemEntry[] = []; + let partial = false; + + for (const dirent of dirents) { + if (!includeHidden && dirent.name.startsWith(".")) { + continue; + } + + let isSymlink = dirent.isSymbolicLink(); + let isDirectory = dirent.isDirectory(); + let isFile = dirent.isFile(); + + // Resolve symlink targets so the caller can navigate through them. If the + // target cannot be stat'd (broken link, permission denied) fall back to + // reporting the link itself as a file-kind entry and flag partial. + if (isSymlink) { + try { + const targetStat = await fs.stat(path.join(resolved, dirent.name)); + isDirectory = targetStat.isDirectory(); + isFile = targetStat.isFile(); + } catch { + partial = true; + isDirectory = false; + isFile = true; + } + } + + if (!isDirectory && !isFile) { + // Skip sockets, block devices, fifos — not meaningful for project picking. + continue; + } + + entries.push({ + name: dirent.name, + kind: isDirectory ? "directory" : "file", + isSymlink, + }); + } + + entries.sort((a, b) => { + if (a.kind !== b.kind) return a.kind === "directory" ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + const parent = path.dirname(resolved); + // Omit parentPath at the filesystem root, where dirname returns the input. + return parent === resolved + ? { path: resolved, entries, partial } + : { path: resolved, parentPath: parent, entries, partial }; +} diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 31168ab1..2603b3a9 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -57,6 +57,7 @@ import { pickFolderNative } from "./nativeFolderPicker.ts"; import { GitManager } from "./git/Services/GitManager.ts"; import { TerminalManager } from "./terminal/Services/Manager.ts"; import { Keybindings } from "./keybindings"; +import { browseFileSystemDirectory } from "./fileSystemBrowser.ts"; import { clearWorkspaceIndexCache, listWorkspaceDirectory, @@ -1118,6 +1119,17 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }); } + case WS_METHODS.projectsBrowseDirectory: { + const body = stripRequestTag(request.body); + return yield* Effect.tryPromise({ + try: () => browseFileSystemDirectory(body), + catch: (cause) => + new RouteRequestError({ + message: `Failed to browse directory: ${String(cause)}`, + }), + }); + } + case WS_METHODS.projectsPathExists: { const body = stripRequestTag(request.body); return yield* Effect.gen(function* () { diff --git a/apps/web/src/components/ServerFolderPickerDialog.tsx b/apps/web/src/components/ServerFolderPickerDialog.tsx new file mode 100644 index 00000000..eba4237a --- /dev/null +++ b/apps/web/src/components/ServerFolderPickerDialog.tsx @@ -0,0 +1,292 @@ +import { useQuery } from "@tanstack/react-query"; +import { + ArrowDownAZIcon, + ArrowUpAZIcon, + ArrowUpIcon, + CheckIcon, + FileIcon, + FolderIcon, + FolderOpenIcon, + HomeIcon, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { readNativeApi } from "~/nativeApi"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { Input } from "./ui/input"; +import { ScrollArea } from "./ui/scroll-area"; +import { Spinner } from "./ui/spinner"; + +interface ServerFolderPickerDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Initial path to show. When undefined, defaults to the service user's home. */ + initialPath?: string; + onSelect: (path: string) => void; +} + +/** + * In-app folder picker that browses the OK Code server's filesystem over + * WebSocket. Used in web/mobile mode (where no native OS folder picker is + * available on the server host) to let users add projects by navigating the + * remote filesystem. + */ +export function ServerFolderPickerDialog({ + open, + onOpenChange, + initialPath, + onSelect, +}: ServerFolderPickerDialogProps) { + const [currentPath, setCurrentPath] = useState(initialPath); + const [pathInput, setPathInput] = useState(initialPath ?? ""); + const [selectedChild, setSelectedChild] = useState(null); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); + + useEffect(() => { + if (open) { + setCurrentPath(initialPath); + setPathInput(initialPath ?? ""); + setSelectedChild(null); + } + }, [open, initialPath]); + + const browseQuery = useQuery({ + queryKey: ["server-folder-picker", currentPath ?? "__home__"], + enabled: open, + queryFn: async () => { + const api = readNativeApi(); + if (!api) { + throw new Error("okcode API unavailable"); + } + return api.projects.browseDirectory(currentPath === undefined ? {} : { path: currentPath }); + }, + staleTime: 2000, + retry: false, + }); + + useEffect(() => { + if (browseQuery.data?.path && currentPath !== browseQuery.data.path) { + setCurrentPath(browseQuery.data.path); + setPathInput(browseQuery.data.path); + } + }, [browseQuery.data, currentPath]); + + const navigateTo = useCallback((target: string) => { + setCurrentPath(target); + setPathInput(target); + setSelectedChild(null); + }, []); + + const navigateUp = useCallback(() => { + if (browseQuery.data?.parentPath) { + navigateTo(browseQuery.data.parentPath); + } + }, [browseQuery.data?.parentPath, navigateTo]); + + const navigateHome = useCallback(() => { + setCurrentPath(undefined); + setSelectedChild(null); + }, []); + + const handlePathInputSubmit = useCallback(() => { + const trimmed = pathInput.trim(); + if (trimmed.length === 0) return; + navigateTo(trimmed); + }, [navigateTo, pathInput]); + + // Default sort is case-insensitive alphabetical by filename, flat (no + // directory/file grouping). Clicking the column header toggles direction. + const sortedEntries = useMemo(() => { + const entries = [...(browseQuery.data?.entries ?? [])]; + entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); + if (sortDirection === "desc") entries.reverse(); + return entries; + }, [browseQuery.data, sortDirection]); + + const toggleSort = useCallback(() => { + setSortDirection((prev) => (prev === "asc" ? "desc" : "asc")); + }, []); + + const pickPath = useMemo(() => { + if (selectedChild && browseQuery.data) { + return joinPath(browseQuery.data.path, selectedChild); + } + return browseQuery.data?.path; + }, [browseQuery.data, selectedChild]); + + const handleSelect = useCallback(() => { + if (!pickPath) return; + onSelect(pickPath); + onOpenChange(false); + }, [onOpenChange, onSelect, pickPath]); + + return ( + + + + Select project folder + + Browse the server’s filesystem and pick a directory to open as a project. + + + +
+ + + setPathInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + handlePathInputSubmit(); + } + }} + placeholder="/absolute/path" + className="min-w-0 flex-1 font-mono text-xs" + aria-label="Current path" + /> +
+ +
+
+ + {browseQuery.data?.partial ? ( + Some entries skipped + ) : null} +
+ + {browseQuery.isLoading ? ( +
+ + Loading... +
+ ) : browseQuery.error ? ( +
+ {browseQuery.error instanceof Error + ? browseQuery.error.message + : "Failed to list directory"} +
+ ) : sortedEntries.length === 0 ? ( +
+ This directory is empty. +
+ ) : ( + +
    + {sortedEntries.map((entry) => { + const isDirectory = entry.kind === "directory"; + const isSelected = selectedChild === entry.name; + if (!isDirectory) { + return ( +
  • + + {entry.name} + {entry.isSymlink ? ( + link + ) : null} +
  • + ); + } + return ( +
  • + +
  • + ); + })} +
+
+ )} +
+
+ + + + +
+
+ ); +} + +/** Join a directory and a child name using the server's path conventions. */ +function joinPath(dir: string, child: string): string { + if (dir.endsWith("/") || dir.endsWith("\\")) { + return `${dir}${child}`; + } + // Preserve Windows separators if the parent used them. + const sep = dir.includes("\\") && !dir.includes("/") ? "\\" : "/"; + return `${dir}${sep}${child}`; +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ecafda80..4d412fb4 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -59,6 +59,7 @@ import { } from "react"; import { CloneRepositoryDialog } from "~/components/CloneRepositoryDialog"; import { EditableThreadTitle } from "~/components/EditableThreadTitle"; +import { ServerFolderPickerDialog } from "~/components/ServerFolderPickerDialog"; import { ProjectIconEditorDialog } from "~/components/ProjectIconEditorDialog"; import { ProjectIcon } from "~/components/ProjectIcon"; import { useClientMode } from "~/hooks/useClientMode"; @@ -598,6 +599,7 @@ export default function Sidebar() { const [addProjectError, setAddProjectError] = useState(null); const [manualProjectPathEntry, setManualProjectPathEntry] = useState(false); const [cloneDialogOpen, setCloneDialogOpen] = useState(false); + const [serverFolderPickerOpen, setServerFolderPickerOpen] = useState(false); const [projectIconDialogOpen, setProjectIconDialogOpen] = useState(false); const [projectIconDialogProjectId, setProjectIconDialogProjectId] = useState( null, @@ -950,21 +952,42 @@ export default function Sidebar() { if (!api || isPickingFolder) return; setIsPickingFolder(true); let pickedPath: string | null = null; + let pickerFailed = false; try { pickedPath = await api.dialogs.pickFolder(); } catch (error) { + pickerFailed = true; toastManager.add({ type: "error", title: "Could not open folder picker", description: error instanceof Error ? error.message : "An unexpected error occurred.", }); } + setIsPickingFolder(false); + if (pickedPath) { await addProjectFromPath(pickedPath); - } else if (!shouldBrowseForProjectImmediately) { + return; + } + + // In browser/mobile mode the server-side native picker (osascript / zenity + // / kdialog) will return null on headless hosts — that's the normal remote + // case. Fall back to the in-app folder browser so users can still navigate + // the server's filesystem. Electron desktop users keep the native OS + // dialog flow and don't see this fallback. + if (!isElectron && !pickerFailed) { + setServerFolderPickerOpen(true); + return; + } + + if (!shouldBrowseForProjectImmediately) { addProjectInputRef.current?.focus(); } - setIsPickingFolder(false); + }; + + const handleServerFolderSelected = async (selectedPath: string) => { + setServerFolderPickerOpen(false); + await addProjectFromPath(selectedPath); }; const handleStartAddProject = () => { @@ -2530,6 +2553,12 @@ export default function Sidebar() { onOpenChange={setCloneDialogOpen} onCloned={handleCloneComplete} /> + + ); } diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index b9d02f54..4f57d49e 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -266,6 +266,7 @@ export function createWsNativeApi(): NativeApi { projects: { searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), listDirectory: (input) => transport.request(WS_METHODS.projectsListDirectory, input), + browseDirectory: (input) => transport.request(WS_METHODS.projectsBrowseDirectory, input), writeFile: (input) => transport.request(WS_METHODS.projectsWriteFile, input), readFile: (input) => transport.request(WS_METHODS.projectsReadFile, input), deleteEntry: (input) => transport.request(WS_METHODS.projectsDeleteEntry, input), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 8b9c011e..d3a28723 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -30,6 +30,8 @@ import type { import type { ProjectDeleteEntryInput, ProjectFileTreeChangedPayload, + ProjectBrowseDirectoryInput, + ProjectBrowseDirectoryResult, ProjectListDirectoryInput, ProjectListDirectoryResult, ProjectReadFileInput, @@ -380,6 +382,7 @@ export interface NativeApi { projects: { searchEntries: (input: ProjectSearchEntriesInput) => Promise; listDirectory: (input: ProjectListDirectoryInput) => Promise; + browseDirectory: (input: ProjectBrowseDirectoryInput) => Promise; writeFile: (input: ProjectWriteFileInput) => Promise; readFile: (input: ProjectReadFileInput) => Promise; deleteEntry: (input: ProjectDeleteEntryInput) => Promise; diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index abd0a957..c305ee85 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -58,6 +58,39 @@ export const ProjectListDirectoryResult = Schema.Struct({ }); export type ProjectListDirectoryResult = typeof ProjectListDirectoryResult.Type; +export const FileSystemEntry = Schema.Struct({ + name: TrimmedNonEmptyString, + kind: ProjectEntryKind, + /** True if the entry is a symlink (regardless of its target kind). */ + isSymlink: Schema.Boolean, +}); +export type FileSystemEntry = typeof FileSystemEntry.Type; + +export const ProjectBrowseDirectoryInput = Schema.Struct({ + /** + * Absolute path to browse. When omitted, the service user's home directory + * is used. Unlike listDirectory, this endpoint walks any accessible path + * without project-scoped indexing. + */ + path: Schema.optional( + TrimmedNonEmptyString.check(Schema.isMaxLength(PROJECT_DIRECTORY_PATH_MAX_LENGTH)), + ), + /** Include entries whose name starts with ".". Default false. */ + includeHidden: Schema.optional(Schema.Boolean), +}); +export type ProjectBrowseDirectoryInput = typeof ProjectBrowseDirectoryInput.Type; + +export const ProjectBrowseDirectoryResult = Schema.Struct({ + /** Resolved absolute path that was listed. */ + path: TrimmedNonEmptyString, + /** Absolute path of the parent directory, or absent if at filesystem root. */ + parentPath: Schema.optional(TrimmedNonEmptyString), + entries: Schema.Array(FileSystemEntry), + /** True if some entries were skipped because they were unreadable. */ + partial: Schema.Boolean, +}); +export type ProjectBrowseDirectoryResult = typeof ProjectBrowseDirectoryResult.Type; + export const ProjectWriteFileInput = Schema.Struct({ cwd: TrimmedNonEmptyString, relativePath: TrimmedNonEmptyString.check(Schema.isMaxLength(PROJECT_WRITE_FILE_PATH_MAX_LENGTH)), diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index ace540d7..621e4485 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -72,6 +72,7 @@ import { SaveProjectEnvironmentVariablesInput, } from "./environment"; import { + ProjectBrowseDirectoryInput, ProjectDeleteEntryInput, ProjectListDirectoryInput, ProjectReadFileInput, @@ -131,6 +132,7 @@ export const WS_METHODS = { projectsRemove: "projects.remove", projectsSearchEntries: "projects.searchEntries", projectsListDirectory: "projects.listDirectory", + projectsBrowseDirectory: "projects.browseDirectory", projectsWriteFile: "projects.writeFile", projectsReadFile: "projects.readFile", projectsDeleteEntry: "projects.deleteEntry", @@ -292,6 +294,7 @@ const WebSocketRequestBody = Schema.Union([ // Project Search tagRequestBody(WS_METHODS.projectsSearchEntries, ProjectSearchEntriesInput), tagRequestBody(WS_METHODS.projectsListDirectory, ProjectListDirectoryInput), + tagRequestBody(WS_METHODS.projectsBrowseDirectory, ProjectBrowseDirectoryInput), tagRequestBody(WS_METHODS.projectsWriteFile, ProjectWriteFileInput), tagRequestBody(WS_METHODS.projectsReadFile, ProjectReadFileInput), tagRequestBody(WS_METHODS.projectsDeleteEntry, ProjectDeleteEntryInput),