feat(connect): list server filesystem roots/drives in directory browser (closes #57) (#100)

The directory browser could only descend from a manually-typed path
since the SDK had no way to enumerate the server's filesystem roots
(multiple drives on Windows, mount points, home dir). Add
file.roots() to sdk.ts (GET /file/roots, added server-side in
dzianisv/opencode#238) and show the results as pinned top-level chips
in DirectoryBrowserSheet that jump straight into that root.

Degrades gracefully: older servers 404 on the new endpoint, which the
SDK turns into null, normalizeRoots() turns into an empty list, and
the browser just shows no chips — manual "Jump to path" entry keeps
working exactly as before.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Den
2026-07-17 10:24:37 -07:00
committed by GitHub
parent 922cffffbd
commit 9a24cea61f
4 changed files with 193 additions and 1 deletions

View File

@@ -0,0 +1,58 @@
import { test } from "node:test"
import assert from "node:assert/strict"
import { normalizeRoots } from "./file-roots.ts"
test("normalizeRoots: passes through well-formed entries", () => {
const result = normalizeRoots([
{ path: "/", label: "/" },
{ path: "/home/user", label: "Home" },
])
assert.deepEqual(result, [
{ path: "/", label: "/" },
{ path: "/home/user", label: "Home" },
])
})
test("normalizeRoots: null input (unsupported server) normalizes to empty", () => {
assert.deepEqual(normalizeRoots(null), [])
})
test("normalizeRoots: undefined input normalizes to empty", () => {
assert.deepEqual(normalizeRoots(undefined), [])
})
test("normalizeRoots: non-array input normalizes to empty", () => {
assert.deepEqual(normalizeRoots("not an array"), [])
assert.deepEqual(normalizeRoots({ path: "/" }), [])
})
test("normalizeRoots: drops entries missing a path", () => {
const result = normalizeRoots([{ label: "No path" }, { path: "", label: "Empty path" }, { path: "/valid" }])
assert.deepEqual(result, [{ path: "/valid", label: "/valid" }])
})
test("normalizeRoots: drops entries where path is not a string", () => {
const result = normalizeRoots([{ path: 42, label: "Bad type" }, { path: "/ok", label: "ok" }])
assert.deepEqual(result, [{ path: "/ok", label: "ok" }])
})
test("normalizeRoots: falls back to path when label is missing or blank", () => {
const result = normalizeRoots([{ path: "/mnt/data" }, { path: "/mnt/other", label: " " }])
assert.deepEqual(result, [
{ path: "/mnt/data", label: "/mnt/data" },
{ path: "/mnt/other", label: "/mnt/other" },
])
})
test("normalizeRoots: dedupes by path, keeping the first occurrence", () => {
const result = normalizeRoots([
{ path: "/mnt/data", label: "First" },
{ path: "/mnt/data", label: "Second" },
])
assert.deepEqual(result, [{ path: "/mnt/data", label: "First" }])
})
test("normalizeRoots: ignores non-object items in the array", () => {
const result = normalizeRoots([null, "string", 42, { path: "/ok" }])
assert.deepEqual(result, [{ path: "/ok", label: "/ok" }])
})

40
src/lib/file-roots.ts Normal file
View File

@@ -0,0 +1,40 @@
// Pure helpers for normalizing GET /file/roots responses from opencode
// servers. No React Native imports — unit-testable with node --test.
//
// Older servers don't expose the endpoint (404), which the SDK client turns
// into `null`; newer servers may still send malformed, empty-path, or
// duplicate entries, so this module defends against all of that before the
// directory browser renders anything.
export interface FileRoot {
path: string
label: string
}
function isPlausibleRoot(value: unknown): value is { path: string; label: unknown } {
if (typeof value !== "object" || value === null) return false
const candidate = value as Record<string, unknown>
return typeof candidate.path === "string" && candidate.path.length > 0
}
/**
* Validate and dedupe a raw /file/roots response into pinned entries for
* the directory browser. Drops entries missing a non-empty `path`, falls
* back to the path itself when `label` is missing/blank, and drops later
* duplicates by path (keeping first-seen order). Non-array input (including
* `null`, which the SDK returns for servers that don't support the
* endpoint) normalizes to an empty list rather than throwing.
*/
export function normalizeRoots(raw: unknown): FileRoot[] {
if (!Array.isArray(raw)) return []
const seen = new Set<string>()
const roots: FileRoot[] = []
for (const item of raw) {
if (!isPlausibleRoot(item)) continue
if (seen.has(item.path)) continue
seen.add(item.path)
const label = typeof item.label === "string" && item.label.trim() ? item.label : item.path
roots.push({ path: item.path, label })
}
return roots
}

View File

@@ -6,6 +6,7 @@ import { fetch as expoFetch } from "expo/fetch"
import { buildRequestHeaders } from "./headers"
import { SSEParser } from "./sse"
import { apiErrorFor } from "./api-error"
import type { FileRoot } from "./file-roots"
export { ApiAuthError, isAuthError } from "./api-error"
@@ -298,6 +299,19 @@ export function createClient(config: ClientConfig) {
const query = new URLSearchParams({ path: params.path ?? "." })
return request<FileEntry[]>(config, `/file?${query.toString()}`)
},
// Enumerate the server's filesystem roots (mounted drives, home dir)
// to seed the directory browser's pinned top-level entries. Resolves
// to null on servers that don't yet expose GET /file/roots (older
// opencode builds) so callers fall back to manual path entry instead
// of crashing; other errors propagate like any other request.
roots: async (): Promise<FileRoot[] | null> => {
try {
return await request<FileRoot[]>(config, "/file/roots")
} catch (err) {
if (err instanceof ApiError && err.status === 404) return null
throw err
}
},
},
path: {