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:
@@ -4,6 +4,7 @@ import { Ionicons } from "@expo/vector-icons"
|
|||||||
import BottomSheet, { BottomSheetBackdrop, BottomSheetFlatList, BottomSheetTextInput } from "@gorhom/bottom-sheet"
|
import BottomSheet, { BottomSheetBackdrop, BottomSheetFlatList, BottomSheetTextInput } from "@gorhom/bottom-sheet"
|
||||||
import type { Client, FileEntry } from "../../lib/sdk"
|
import type { Client, FileEntry } from "../../lib/sdk"
|
||||||
import { parentOf, nameOf } from "../../lib/path-utils"
|
import { parentOf, nameOf } from "../../lib/path-utils"
|
||||||
|
import { normalizeRoots, type FileRoot } from "../../lib/file-roots"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sheetRef: React.RefObject<BottomSheet | null>
|
sheetRef: React.RefObject<BottomSheet | null>
|
||||||
@@ -31,6 +32,11 @@ export function DirectoryBrowserSheet({
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [jumpPath, setJumpPath] = useState("")
|
const [jumpPath, setJumpPath] = useState("")
|
||||||
|
// Pinned top-level entries (drives, home dir) fetched from GET /file/roots.
|
||||||
|
// Stays empty on older servers that don't expose the endpoint, or while a
|
||||||
|
// fetch is in flight — the manual "Jump to path" input keeps working
|
||||||
|
// either way.
|
||||||
|
const [roots, setRoots] = useState<FileRoot[]>([])
|
||||||
const loadToken = useRef(0)
|
const loadToken = useRef(0)
|
||||||
|
|
||||||
const load = useCallback(
|
const load = useCallback(
|
||||||
@@ -71,6 +77,24 @@ export function DirectoryBrowserSheet({
|
|||||||
[load],
|
[load],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Fetch pinned filesystem roots for the current server. Silently falls
|
||||||
|
// back to no pinned roots (manual path entry still works) on older
|
||||||
|
// servers or any request failure.
|
||||||
|
const loadRoots = useCallback(
|
||||||
|
(dir: string) => {
|
||||||
|
const client = clientForDirectory(dir)
|
||||||
|
if (!client) {
|
||||||
|
setRoots([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client.file
|
||||||
|
.roots()
|
||||||
|
.then((result) => setRoots(normalizeRoots(result)))
|
||||||
|
.catch(() => setRoots([]))
|
||||||
|
},
|
||||||
|
[clientForDirectory],
|
||||||
|
)
|
||||||
|
|
||||||
// Reset to the starting directory when the sheet transitions from closed
|
// Reset to the starting directory when the sheet transitions from closed
|
||||||
// to open (not on drags between snap points), and notify on full close.
|
// to open (not on drags between snap points), and notify on full close.
|
||||||
const wasOpen = useRef(false)
|
const wasOpen = useRef(false)
|
||||||
@@ -86,6 +110,7 @@ export function DirectoryBrowserSheet({
|
|||||||
setJumpPath("")
|
setJumpPath("")
|
||||||
if (startDirectory) {
|
if (startDirectory) {
|
||||||
enter(startDirectory)
|
enter(startDirectory)
|
||||||
|
loadRoots(startDirectory)
|
||||||
} else {
|
} else {
|
||||||
// No starting directory known (e.g. server home not loaded yet):
|
// No starting directory known (e.g. server home not loaded yet):
|
||||||
// show an explicit empty state instead of a previous open's entries.
|
// show an explicit empty state instead of a previous open's entries.
|
||||||
@@ -94,9 +119,10 @@ export function DirectoryBrowserSheet({
|
|||||||
setEntries([])
|
setEntries([])
|
||||||
setError(null)
|
setError(null)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
setRoots([])
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[startDirectory, enter, onDismiss],
|
[startDirectory, enter, loadRoots, onDismiss],
|
||||||
)
|
)
|
||||||
|
|
||||||
const goUp = useCallback(() => {
|
const goUp = useCallback(() => {
|
||||||
@@ -153,6 +179,35 @@ export function DirectoryBrowserSheet({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{roots.length > 0 && (
|
||||||
|
<View style={s.rootsRow}>
|
||||||
|
{roots.map((root) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={root.path}
|
||||||
|
style={[s.rootChip, isDark && s.rootChipDark, browseDir === root.path && s.rootChipActive]}
|
||||||
|
onPress={() => enter(root.path)}
|
||||||
|
testID={`directory-root-${root.label}`}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={root.label === "Home" ? "home-outline" : "layers-outline"}
|
||||||
|
size={14}
|
||||||
|
color={browseDir === root.path ? "#ffffff" : isDark ? "#c4b5fd" : "#6d28d9"}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
s.rootChipText,
|
||||||
|
isDark && s.rootChipTextDark,
|
||||||
|
browseDir === root.path && s.rootChipTextActive,
|
||||||
|
]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{root.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<View style={s.inputWrap}>
|
<View style={s.inputWrap}>
|
||||||
<BottomSheetTextInput
|
<BottomSheetTextInput
|
||||||
style={[s.input, isDark && s.inputDark]}
|
style={[s.input, isDark && s.inputDark]}
|
||||||
@@ -248,6 +303,31 @@ const s = StyleSheet.create({
|
|||||||
color: "#666666",
|
color: "#666666",
|
||||||
},
|
},
|
||||||
dimDark: { color: "#888888" },
|
dimDark: { color: "#888888" },
|
||||||
|
rootsRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 10,
|
||||||
|
},
|
||||||
|
rootChip: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 5,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: "#e8e5f0",
|
||||||
|
},
|
||||||
|
rootChipDark: { backgroundColor: "#2a2040" },
|
||||||
|
rootChipActive: { backgroundColor: "#8b5cf6" },
|
||||||
|
rootChipText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#6d28d9",
|
||||||
|
},
|
||||||
|
rootChipTextDark: { color: "#c4b5fd" },
|
||||||
|
rootChipTextActive: { color: "#ffffff" },
|
||||||
inputWrap: {
|
inputWrap: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
58
src/lib/file-roots.test.ts
Normal file
58
src/lib/file-roots.test.ts
Normal 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
40
src/lib/file-roots.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { fetch as expoFetch } from "expo/fetch"
|
|||||||
import { buildRequestHeaders } from "./headers"
|
import { buildRequestHeaders } from "./headers"
|
||||||
import { SSEParser } from "./sse"
|
import { SSEParser } from "./sse"
|
||||||
import { apiErrorFor } from "./api-error"
|
import { apiErrorFor } from "./api-error"
|
||||||
|
import type { FileRoot } from "./file-roots"
|
||||||
|
|
||||||
export { ApiAuthError, isAuthError } from "./api-error"
|
export { ApiAuthError, isAuthError } from "./api-error"
|
||||||
|
|
||||||
@@ -298,6 +299,19 @@ export function createClient(config: ClientConfig) {
|
|||||||
const query = new URLSearchParams({ path: params.path ?? "." })
|
const query = new URLSearchParams({ path: params.path ?? "." })
|
||||||
return request<FileEntry[]>(config, `/file?${query.toString()}`)
|
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: {
|
path: {
|
||||||
|
|||||||
Reference in New Issue
Block a user