feat: add browsable directory picker for new sessions and project switch

Users had to type an absolute server path on a phone keyboard to pick a
working directory (#49 "Choose project UIX"), and #57 reports that
only the default-drive project is ever discoverable. #52 already added
recents + client.project.list() as flat pickers, but there was still no
way to browse into subdirectories or discover paths the server hadn't
already indexed as a "project" — the only fallback was manual typing.

The opencode server already exposes a scoped filesystem-listing endpoint
(GET /file, handled in file.ts/handlers/file.ts) that resolves relative
to whatever directory the request is scoped to (header or query param) —
no new server endpoint is needed. Add file.list() to the mobile SDK
client and a new DirectoryBrowserSheet that lists subdirectories one
level at a time (via clientForDirectory(dir) + file.list({path: "."})),
supports "up" navigation, and a manual jump-to-path field. Wire it into
both the "new session" modal and the existing DirectorySwitcher, so
recents/manual entry remain available as a fallback alongside browsing.

Residual gap: there's still no "list available drives" API, so Windows
users with projects on D:, E:, etc. still need to type the drive root
once (it's then remembered via recents) — a full fix for #57 would need
a small server-side addition to enumerate mounted volumes.
This commit is contained in:
engineer
2026-07-16 15:48:38 -07:00
parent a5723bf087
commit f0a7c1d868
5 changed files with 436 additions and 9 deletions

View File

@@ -0,0 +1,326 @@
import { useCallback, useRef, useState } from "react"
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from "react-native"
import { Ionicons } from "@expo/vector-icons"
import BottomSheet, { BottomSheetBackdrop, BottomSheetFlatList, BottomSheetTextInput } from "@gorhom/bottom-sheet"
import type { Client, FileEntry } from "../../lib/sdk"
interface Props {
sheetRef: React.RefObject<BottomSheet | null>
// Directory to start browsing from whenever the sheet opens (project root, server home, etc).
startDirectory: string | null
// Builds a client rooted at an arbitrary absolute directory (see connections store).
clientForDirectory: (directory: string) => Client | null
isDark: boolean
// Called with the chosen absolute directory when the user taps "Use this folder".
onSelect: (directory: string) => void
}
// Absolute-path helpers. Server working directories can be POSIX (/a/b) or
// Windows (C:\a\b, D:/a/b) since the mobile app can point at either kind of
// opencode server, so both separators are handled.
function stripTrailingSlash(dir: string): string {
return dir.replace(/[\\/]+$/, "") || dir
}
function parentOf(dir: string): string | null {
const trimmed = stripTrailingSlash(dir)
const lastSlash = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"))
if (lastSlash < 0) return null
const head = trimmed.slice(0, lastSlash)
if (!head) return trimmed[0] === "\\" ? "\\" : "/" // reached posix root
if (/^[a-zA-Z]:$/.test(head)) return `${head}\\` // reached a windows drive root
return head
}
function nameOf(dir: string): string {
const trimmed = stripTrailingSlash(dir)
const lastSlash = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"))
return lastSlash >= 0 ? trimmed.slice(lastSlash + 1) || trimmed : trimmed
}
export function DirectoryBrowserSheet({ sheetRef, startDirectory, clientForDirectory, isDark, onSelect }: Props) {
const [browseDir, setBrowseDir] = useState<string | null>(null)
const [entries, setEntries] = useState<FileEntry[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [jumpPath, setJumpPath] = useState("")
const loadToken = useRef(0)
const load = useCallback(
(dir: string) => {
const client = clientForDirectory(dir)
const token = ++loadToken.current
setLoading(true)
setError(null)
if (!client) {
setEntries([])
setLoading(false)
setError("No active connection")
return
}
client.file
.list({ path: "." })
.then((items) => {
if (loadToken.current !== token) return
setEntries(items.filter((item) => item.type === "directory"))
})
.catch((err) => {
if (loadToken.current !== token) return
setEntries([])
setError(err instanceof Error ? err.message : "Failed to list directory")
})
.finally(() => {
if (loadToken.current === token) setLoading(false)
})
},
[clientForDirectory],
)
const enter = useCallback(
(dir: string) => {
setBrowseDir(dir)
load(dir)
},
[load],
)
// Reset to the starting directory every time the sheet opens.
const handleSheetChange = useCallback(
(index: number) => {
if (index < 0) return
setJumpPath("")
if (startDirectory) enter(startDirectory)
},
[startDirectory, enter],
)
const goUp = useCallback(() => {
if (!browseDir) return
const parent = parentOf(browseDir)
if (!parent) return
enter(parent)
}, [browseDir, enter])
const goJump = useCallback(() => {
const dir = jumpPath.trim()
if (!dir) return
setJumpPath("")
enter(dir)
}, [jumpPath, enter])
const handleUseFolder = useCallback(() => {
if (!browseDir) return
onSelect(browseDir)
sheetRef.current?.close()
}, [browseDir, onSelect, sheetRef])
const canGoUp = !!browseDir && !!parentOf(browseDir)
return (
<BottomSheet
ref={sheetRef}
index={-1}
snapPoints={["65%", "92%"]}
enablePanDownToClose
keyboardBehavior="interactive"
keyboardBlurBehavior="restore"
android_keyboardInputMode="adjustResize"
backgroundStyle={isDark ? s.sheetDark : s.sheet}
handleIndicatorStyle={{ backgroundColor: isDark ? "#666666" : "#cccccc" }}
backdropComponent={(props) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.5} />
)}
onChange={handleSheetChange}
>
<View style={s.header}>
<Text style={[s.title, isDark && s.white]}>Browse Folders</Text>
<View style={s.pathRow}>
<TouchableOpacity onPress={goUp} disabled={!canGoUp} hitSlop={8}>
<Ionicons
name="arrow-up-circle-outline"
size={22}
color={canGoUp ? (isDark ? "#8b5cf6" : "#6d28d9") : isDark ? "#3a3a3a" : "#dddddd"}
/>
</TouchableOpacity>
<Text style={[s.path, isDark && s.dimDark]} numberOfLines={1} ellipsizeMode="head">
{browseDir || "…"}
</Text>
</View>
</View>
<View style={s.inputWrap}>
<BottomSheetTextInput
style={[s.input, isDark && s.inputDark]}
placeholder="Jump to path..."
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={jumpPath}
onChangeText={setJumpPath}
onSubmitEditing={goJump}
returnKeyType="go"
autoCapitalize="none"
autoCorrect={false}
/>
{jumpPath.trim() && (
<TouchableOpacity style={[s.goBtn, isDark && s.goBtnDark]} onPress={goJump}>
<Ionicons name="arrow-forward" size={18} color={isDark ? "#0a0a0a" : "#ffffff"} />
</TouchableOpacity>
)}
</View>
<BottomSheetFlatList
data={entries}
keyExtractor={(item: FileEntry) => item.absolute}
renderItem={({ item }: { item: FileEntry }) => (
<TouchableOpacity style={[s.row, isDark && s.rowDark]} onPress={() => enter(item.absolute)}>
<Ionicons
name="folder-outline"
size={20}
color={item.ignored ? (isDark ? "#555555" : "#bbbbbb") : isDark ? "#888888" : "#666666"}
/>
<Text style={[s.rowLabel, isDark && s.white, item.ignored && s.rowLabelDim]} numberOfLines={1}>
{item.name}
</Text>
<Ionicons name="chevron-forward" size={16} color={isDark ? "#555555" : "#cccccc"} />
</TouchableOpacity>
)}
contentContainerStyle={s.list}
ListHeaderComponent={
loading ? (
<View style={s.centerBox}>
<ActivityIndicator color={isDark ? "#ffffff" : "#0a0a0a"} />
</View>
) : error ? (
<View style={s.centerBox}>
<Text style={s.errorText}>{error}</Text>
</View>
) : null
}
ListEmptyComponent={
!loading && !error ? <Text style={[s.emptyText, isDark && s.dimDark]}>No subfolders here</Text> : null
}
/>
<View style={s.footer}>
<TouchableOpacity
style={[s.selectBtn, isDark && s.selectBtnDark, !browseDir && s.selectBtnDisabled]}
onPress={handleUseFolder}
disabled={!browseDir}
>
<Ionicons name="checkmark-circle" size={18} color={isDark ? "#0a0a0a" : "#ffffff"} />
<Text style={[s.selectBtnText, isDark && s.selectBtnTextDark]} numberOfLines={1}>
Use {browseDir ? nameOf(browseDir) : "this folder"}
</Text>
</TouchableOpacity>
</View>
</BottomSheet>
)
}
const s = StyleSheet.create({
sheet: { backgroundColor: "#ffffff" },
sheetDark: { backgroundColor: "#1a1a1a" },
header: { paddingHorizontal: 16, paddingBottom: 8, gap: 8 },
title: { fontSize: 18, fontWeight: "700", color: "#0a0a0a" },
white: { color: "#ffffff" },
pathRow: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
path: {
flex: 1,
fontSize: 12,
color: "#666666",
},
dimDark: { color: "#888888" },
inputWrap: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
paddingBottom: 8,
gap: 8,
},
input: {
flex: 1,
height: 40,
borderRadius: 10,
paddingHorizontal: 12,
backgroundColor: "#f5f5f5",
color: "#0a0a0a",
fontSize: 14,
},
inputDark: {
backgroundColor: "#2a2a2a",
color: "#ffffff",
},
goBtn: {
width: 40,
height: 40,
borderRadius: 10,
backgroundColor: "#0a0a0a",
alignItems: "center",
justifyContent: "center",
},
goBtnDark: { backgroundColor: "#ffffff" },
list: {
paddingHorizontal: 16,
paddingBottom: 8,
},
row: {
flexDirection: "row",
alignItems: "center",
gap: 12,
paddingHorizontal: 14,
paddingVertical: 11,
borderRadius: 10,
backgroundColor: "#f5f5f5",
marginBottom: 6,
},
rowDark: { backgroundColor: "#2a2a2a" },
rowLabel: {
flex: 1,
fontSize: 14,
fontWeight: "600",
color: "#0a0a0a",
},
rowLabelDim: { color: "#999999" },
centerBox: {
paddingVertical: 24,
alignItems: "center",
},
errorText: {
fontSize: 13,
color: "#ef4444",
textAlign: "center",
paddingHorizontal: 16,
},
emptyText: {
fontSize: 13,
color: "#999999",
textAlign: "center",
paddingVertical: 24,
},
footer: {
paddingHorizontal: 16,
paddingVertical: 12,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: "#e5e5e5",
},
selectBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
height: 46,
borderRadius: 12,
backgroundColor: "#0a0a0a",
},
selectBtnDark: { backgroundColor: "#ffffff" },
selectBtnDisabled: { opacity: 0.5 },
selectBtnText: {
fontSize: 15,
fontWeight: "600",
color: "#ffffff",
},
selectBtnTextDark: { color: "#0a0a0a" },
})

View File

@@ -10,9 +10,12 @@ interface Props {
serverHome: string | null
isDark: boolean
onSwitch: (directory?: string) => void
// Opens a browsable folder picker rooted at the server's filesystem, as an
// alternative to typing a path. Optional so existing callers keep working.
onBrowse?: () => void
}
export function DirectorySwitcher({ sheetRef, current, recents, serverHome, isDark, onSwitch }: Props) {
export function DirectorySwitcher({ sheetRef, current, recents, serverHome, isDark, onSwitch, onBrowse }: Props) {
const [custom, setCustom] = useState("")
const handleSelect = useCallback(
@@ -100,14 +103,30 @@ export function DirectorySwitcher({ sheetRef, current, recents, serverHome, isDa
</View>
{/* Quick path chips */}
{serverHome && (
{(serverHome || onBrowse) && (
<View style={s.chips}>
<TouchableOpacity style={[s.chip, isDark && s.chipDark]} onPress={() => setCustom(serverHome)}>
<Text style={[s.chipText, isDark && s.chipTextDark]}>~</Text>
</TouchableOpacity>
<TouchableOpacity style={[s.chip, isDark && s.chipDark]} onPress={() => setCustom(serverHome + "/")}>
<Text style={[s.chipText, isDark && s.chipTextDark]}>~/</Text>
</TouchableOpacity>
{serverHome && (
<>
<TouchableOpacity style={[s.chip, isDark && s.chipDark]} onPress={() => setCustom(serverHome)}>
<Text style={[s.chipText, isDark && s.chipTextDark]}>~</Text>
</TouchableOpacity>
<TouchableOpacity style={[s.chip, isDark && s.chipDark]} onPress={() => setCustom(serverHome + "/")}>
<Text style={[s.chipText, isDark && s.chipTextDark]}>~/</Text>
</TouchableOpacity>
</>
)}
{onBrowse && (
<TouchableOpacity
style={[s.chip, s.chipBrowse, isDark && s.chipDark]}
onPress={() => {
sheetRef.current?.close()
onBrowse()
}}
>
<Ionicons name="folder-open-outline" size={14} color={isDark ? "#8b5cf6" : "#6d28d9"} />
<Text style={[s.chipText, isDark && s.chipTextDark]}>Browse…</Text>
</TouchableOpacity>
)}
</View>
)}
@@ -188,6 +207,11 @@ const s = StyleSheet.create({
chipDark: {
backgroundColor: "#2a2040",
},
chipBrowse: {
flexDirection: "row",
alignItems: "center",
gap: 4,
},
chipText: {
fontSize: 13,
fontWeight: "600",

View File

@@ -9,4 +9,5 @@ export { SlashPopover, type SlashCommand } from "./SlashPopover"
export { ModelPicker } from "./ModelPicker"
export { ImageAttachments, type Attachment } from "./ImageAttachments"
export { DirectorySwitcher } from "./DirectorySwitcher"
export { DirectoryBrowserSheet } from "./DirectoryBrowserSheet"
export { SessionInfo } from "./SessionInfo"

View File

@@ -143,6 +143,14 @@ export interface Project {
}
}
export interface FileEntry {
name: string
path: string
absolute: string
type: "file" | "directory"
ignored: boolean
}
export interface Event {
type: string
properties: Record<string, unknown>
@@ -250,6 +258,17 @@ export function createClient(config: ClientConfig) {
current: () => request<Project>(config, "/project/current"),
},
// Server-side filesystem browsing, scoped to this client's directory
// (see ClientConfig.directory / x-opencode-directory header). Use
// clientForDirectory(dir) to get a client rooted at a specific folder,
// then list("." ) to enumerate its immediate children.
file: {
list: (params: { path?: string } = {}) => {
const query = new URLSearchParams({ path: params.path ?? "." })
return request<FileEntry[]>(config, `/file?${query.toString()}`)
},
},
path: {
get: () =>
request<{ home: string; state: string; config: string; worktree: string; directory: string }>(config, "/path"),