From f0a7c1d868a8603c401b08ab8d9b38a4d394ef97 Mon Sep 17 00:00:00 2001 From: engineer Date: Thu, 16 Jul 2026 15:48:38 -0700 Subject: [PATCH 1/2] feat: add browsable directory picker for new sessions and project switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/(tabs)/index.tsx | 59 +++- src/components/chat/DirectoryBrowserSheet.tsx | 326 ++++++++++++++++++ src/components/chat/DirectorySwitcher.tsx | 40 ++- src/components/chat/index.ts | 1 + src/lib/sdk.ts | 19 + 5 files changed, 436 insertions(+), 9 deletions(-) create mode 100644 src/components/chat/DirectoryBrowserSheet.tsx diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 3b6caec..81c40b8 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -22,7 +22,7 @@ import { useConnections } from "../../src/stores/connections" import { useCatalog } from "../../src/stores/catalog" import type BottomSheet from "@gorhom/bottom-sheet" import type { Session, Project } from "../../src/lib/sdk" -import { DirectorySwitcher } from "../../src/components/chat" +import { DirectorySwitcher, DirectoryBrowserSheet } from "../../src/components/chat" function formatTime(timestamp: number): string { const date = new Date(timestamp) @@ -129,6 +129,11 @@ export default function SessionsScreen() { } = useConnections() const loadCatalog = useCatalog((s) => s.load) const dirSheetRef = useRef(null) + const browserSheetRef = useRef(null) + const [browseStartDir, setBrowseStartDir] = useState(null) + // Shared folder browser is opened either to pick a directory for a new + // session, or to switch the active connection's directory. + const [browseMode, setBrowseMode] = useState<"create" | "switch">("create") const [refreshing, setRefreshing] = useState(false) // Fetch server-known projects when the new session modal opens @@ -270,6 +275,27 @@ export default function SessionsScreen() { } } + const openBrowser = useCallback( + (startDir: string | null, mode: "create" | "switch") => { + setBrowseStartDir(startDir || serverHome || null) + setBrowseMode(mode) + browserSheetRef.current?.expand() + }, + [serverHome], + ) + + const onBrowserSelect = useCallback( + (directory: string) => { + if (browseMode === "switch") { + handleSwitchDirectory(directory) + dirSheetRef.current?.close() + } else { + onCreateInDirectory(directory) + } + }, + [browseMode, handleSwitchDirectory, onCreateInDirectory], + ) + const onFabPress = () => { // Quick create in current project onCreateSession() @@ -478,6 +504,24 @@ export default function SessionsScreen() { )} + {/* Browse the server's filesystem instead of typing a path */} + + openBrowser(currentProject?.path?.absolute || activeConnection?.directory || null, "create") + } + disabled={isCreating} + > + + + Browse Folders… + + Explore the server's filesystem + + + + + {/* Manual path input fallback */} Enter Path Manually @@ -609,6 +653,19 @@ export default function SessionsScreen() { serverHome={serverHome} isDark={isDark} onSwitch={handleSwitchDirectory} + onBrowse={() => + openBrowser(activeConnection?.directory || currentProject?.path?.absolute || null, "switch") + } + /> + + {/* Browsable folder picker — used for both "new session in..." and + "switch project directory" flows (see browseMode). */} + ) diff --git a/src/components/chat/DirectoryBrowserSheet.tsx b/src/components/chat/DirectoryBrowserSheet.tsx new file mode 100644 index 0000000..4b486d1 --- /dev/null +++ b/src/components/chat/DirectoryBrowserSheet.tsx @@ -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 + // 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(null) + const [entries, setEntries] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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 ( + ( + + )} + onChange={handleSheetChange} + > + + Browse Folders + + + + + + {browseDir || "…"} + + + + + + + {jumpPath.trim() && ( + + + + )} + + + item.absolute} + renderItem={({ item }: { item: FileEntry }) => ( + enter(item.absolute)}> + + + {item.name} + + + + )} + contentContainerStyle={s.list} + ListHeaderComponent={ + loading ? ( + + + + ) : error ? ( + + {error} + + ) : null + } + ListEmptyComponent={ + !loading && !error ? No subfolders here : null + } + /> + + + + + + Use {browseDir ? nameOf(browseDir) : "this folder"} + + + + + ) +} + +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" }, +}) diff --git a/src/components/chat/DirectorySwitcher.tsx b/src/components/chat/DirectorySwitcher.tsx index 82de552..a92fe3a 100644 --- a/src/components/chat/DirectorySwitcher.tsx +++ b/src/components/chat/DirectorySwitcher.tsx @@ -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 {/* Quick path chips */} - {serverHome && ( + {(serverHome || onBrowse) && ( - setCustom(serverHome)}> - ~ - - setCustom(serverHome + "/")}> - ~/ - + {serverHome && ( + <> + setCustom(serverHome)}> + ~ + + setCustom(serverHome + "/")}> + ~/ + + + )} + {onBrowse && ( + { + sheetRef.current?.close() + onBrowse() + }} + > + + Browse… + + )} )} @@ -188,6 +207,11 @@ const s = StyleSheet.create({ chipDark: { backgroundColor: "#2a2040", }, + chipBrowse: { + flexDirection: "row", + alignItems: "center", + gap: 4, + }, chipText: { fontSize: 13, fontWeight: "600", diff --git a/src/components/chat/index.ts b/src/components/chat/index.ts index c6ab19d..2710ff0 100644 --- a/src/components/chat/index.ts +++ b/src/components/chat/index.ts @@ -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" diff --git a/src/lib/sdk.ts b/src/lib/sdk.ts index cc5b8f9..9266a91 100644 --- a/src/lib/sdk.ts +++ b/src/lib/sdk.ts @@ -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 @@ -250,6 +258,17 @@ export function createClient(config: ClientConfig) { current: () => request(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(config, `/file?${query.toString()}`) + }, + }, + path: { get: () => request<{ home: string; state: string; config: string; worktree: string; directory: string }>(config, "/path"), From 7e9b3981c3e948d0670421a79e6474c04054f14d Mon Sep 17 00:00:00 2001 From: engineer Date: Thu, 16 Jul 2026 15:57:31 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(directory-picker):=20address=20review?= =?UTF-8?q?=20=E2=80=94=20modal=20layering,=20root=20nav,=20stale=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HIGH: the "Browse Folders..." entry in the New Session RN expanded a sibling BottomSheet, which a native Modal always covers (a BottomSheetModal through the root portal would be covered too), so the primary entry point was invisible/untouchable. The modal is now closed before the sheet expands and restored on cancel via a new onDismiss callback (restoreNewSessionOnDismiss ref); picking a folder proceeds to session creation without reopening the modal. - MEDIUM: parentOf("/") returned "/" so Up at the POSIX root looped forever; it now returns null at "/", "\" and Windows drive roots alike, disabling the Up button there. - LOW: opening the sheet with no known start directory (server home not loaded yet) showed the previous open's stale entries; it now clears state, invalidates in-flight loads, and shows an "Enter a path above to start browsing" empty state. Sheet init also no longer re-runs on snap-point drags (wasOpen guard). - Extracted the pure path helpers (stripTrailingSlash/parentOf/nameOf) into src/lib/path-utils.ts (no RN imports) with node --test coverage for POSIX root, Windows drive roots, trailing slashes, and backslash paths. typecheck clean; 97/97 tests pass (16 new). --- app/(tabs)/index.tsx | 21 ++++- src/components/chat/DirectoryBrowserSheet.tsx | 67 ++++++++------ src/lib/path-utils.test.ts | 88 +++++++++++++++++++ src/lib/path-utils.ts | 38 ++++++++ 4 files changed, 184 insertions(+), 30 deletions(-) create mode 100644 src/lib/path-utils.test.ts create mode 100644 src/lib/path-utils.ts diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 81c40b8..6ca78ae 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -275,17 +275,28 @@ export default function SessionsScreen() { } } + // The browser sheet is a sibling of the New Session . A native RN + // Modal layers above everything in the React root (including bottom-sheet + // portals), so the modal must be closed before the sheet is shown; this ref + // remembers to bring it back if the user cancels without picking a folder. + const restoreNewSessionOnDismiss = useRef(false) + const openBrowser = useCallback( (startDir: string | null, mode: "create" | "switch") => { setBrowseStartDir(startDir || serverHome || null) setBrowseMode(mode) + if (mode === "create" && showNewSession) { + restoreNewSessionOnDismiss.current = true + setShowNewSession(false) + } browserSheetRef.current?.expand() }, - [serverHome], + [serverHome, showNewSession], ) const onBrowserSelect = useCallback( (directory: string) => { + restoreNewSessionOnDismiss.current = false if (browseMode === "switch") { handleSwitchDirectory(directory) dirSheetRef.current?.close() @@ -296,6 +307,13 @@ export default function SessionsScreen() { [browseMode, handleSwitchDirectory, onCreateInDirectory], ) + const onBrowserDismiss = useCallback(() => { + if (restoreNewSessionOnDismiss.current) { + restoreNewSessionOnDismiss.current = false + setShowNewSession(true) + } + }, []) + const onFabPress = () => { // Quick create in current project onCreateSession() @@ -666,6 +684,7 @@ export default function SessionsScreen() { clientForDirectory={clientForDirectory} isDark={isDark} onSelect={onBrowserSelect} + onDismiss={onBrowserDismiss} /> ) diff --git a/src/components/chat/DirectoryBrowserSheet.tsx b/src/components/chat/DirectoryBrowserSheet.tsx index 4b486d1..5f9df9a 100644 --- a/src/components/chat/DirectoryBrowserSheet.tsx +++ b/src/components/chat/DirectoryBrowserSheet.tsx @@ -3,6 +3,7 @@ import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from "rea import { Ionicons } from "@expo/vector-icons" import BottomSheet, { BottomSheetBackdrop, BottomSheetFlatList, BottomSheetTextInput } from "@gorhom/bottom-sheet" import type { Client, FileEntry } from "../../lib/sdk" +import { parentOf, nameOf } from "../../lib/path-utils" interface Props { sheetRef: React.RefObject @@ -13,32 +14,18 @@ interface Props { isDark: boolean // Called with the chosen absolute directory when the user taps "Use this folder". onSelect: (directory: string) => void + // Called whenever the sheet fully closes (selection or cancel). + onDismiss?: () => 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) { +export function DirectoryBrowserSheet({ + sheetRef, + startDirectory, + clientForDirectory, + isDark, + onSelect, + onDismiss, +}: Props) { const [browseDir, setBrowseDir] = useState(null) const [entries, setEntries] = useState([]) const [loading, setLoading] = useState(false) @@ -84,14 +71,32 @@ export function DirectoryBrowserSheet({ sheetRef, startDirectory, clientForDirec [load], ) - // Reset to the starting directory every time the sheet opens. + // Reset to the starting directory when the sheet transitions from closed + // to open (not on drags between snap points), and notify on full close. + const wasOpen = useRef(false) const handleSheetChange = useCallback( (index: number) => { - if (index < 0) return + if (index < 0) { + wasOpen.current = false + onDismiss?.() + return + } + if (wasOpen.current) return // snap-point change while already open + wasOpen.current = true setJumpPath("") - if (startDirectory) enter(startDirectory) + if (startDirectory) { + enter(startDirectory) + } else { + // No starting directory known (e.g. server home not loaded yet): + // show an explicit empty state instead of a previous open's entries. + loadToken.current++ + setBrowseDir(null) + setEntries([]) + setError(null) + setLoading(false) + } }, - [startDirectory, enter], + [startDirectory, enter, onDismiss], ) const goUp = useCallback(() => { @@ -196,7 +201,11 @@ export function DirectoryBrowserSheet({ sheetRef, startDirectory, clientForDirec ) : null } ListEmptyComponent={ - !loading && !error ? No subfolders here : null + !loading && !error ? ( + + {browseDir ? "No subfolders here" : "Enter a path above to start browsing"} + + ) : null } /> diff --git a/src/lib/path-utils.test.ts b/src/lib/path-utils.test.ts new file mode 100644 index 0000000..7b50892 --- /dev/null +++ b/src/lib/path-utils.test.ts @@ -0,0 +1,88 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { stripTrailingSlash, parentOf, nameOf } from "./path-utils.ts" + +// stripTrailingSlash ------------------------------------------------------- + +test("stripTrailingSlash: removes single and repeated trailing separators", () => { + assert.equal(stripTrailingSlash("/a/b/"), "/a/b") + assert.equal(stripTrailingSlash("/a/b///"), "/a/b") + assert.equal(stripTrailingSlash("C:\\proj\\"), "C:\\proj") +}) + +test("stripTrailingSlash: keeps input when stripping would empty it", () => { + assert.equal(stripTrailingSlash("/"), "/") + assert.equal(stripTrailingSlash("\\"), "\\") + assert.equal(stripTrailingSlash("///"), "///") +}) + +test("stripTrailingSlash: leaves paths without trailing separator unchanged", () => { + assert.equal(stripTrailingSlash("/a/b"), "/a/b") + assert.equal(stripTrailingSlash("D:/work"), "D:/work") +}) + +// parentOf ----------------------------------------------------------------- + +test("parentOf: POSIX root has no parent", () => { + assert.equal(parentOf("/"), null) + assert.equal(parentOf("//"), null) +}) + +test("parentOf: bare backslash has no parent", () => { + assert.equal(parentOf("\\"), null) +}) + +test("parentOf: Windows drive roots have no parent", () => { + assert.equal(parentOf("C:\\"), null) + assert.equal(parentOf("D:/"), null) + assert.equal(parentOf("c:"), null) +}) + +test("parentOf: first-level POSIX dir returns the root", () => { + assert.equal(parentOf("/home"), "/") + assert.equal(parentOf("/home/"), "/") +}) + +test("parentOf: nested POSIX paths walk up one level", () => { + assert.equal(parentOf("/home/user/project"), "/home/user") + assert.equal(parentOf("/home/user/project/"), "/home/user") +}) + +test("parentOf: first-level Windows dir returns the drive root", () => { + assert.equal(parentOf("C:\\projects"), "C:\\") + assert.equal(parentOf("D:/work"), "D:\\") +}) + +test("parentOf: nested Windows backslash paths walk up one level", () => { + assert.equal(parentOf("C:\\projects\\app"), "C:\\projects") + assert.equal(parentOf("C:\\projects\\app\\"), "C:\\projects") +}) + +test("parentOf: mixed-separator Windows paths walk up one level", () => { + assert.equal(parentOf("D:/work/repo"), "D:/work") +}) + +test("parentOf: relative segment without separators has no parent", () => { + assert.equal(parentOf("project"), null) +}) + +// nameOf --------------------------------------------------------------------- + +test("nameOf: returns the last POSIX segment", () => { + assert.equal(nameOf("/home/user/project"), "project") + assert.equal(nameOf("/home/user/project/"), "project") +}) + +test("nameOf: returns the last Windows segment", () => { + assert.equal(nameOf("C:\\projects\\app"), "app") + assert.equal(nameOf("D:/work/repo"), "repo") +}) + +test("nameOf: root paths fall back to the trimmed input", () => { + assert.equal(nameOf("/"), "/") + assert.equal(nameOf("C:\\"), "C:") +}) + +test("nameOf: bare segment is returned as-is", () => { + assert.equal(nameOf("project"), "project") +}) diff --git a/src/lib/path-utils.ts b/src/lib/path-utils.ts new file mode 100644 index 0000000..176fe71 --- /dev/null +++ b/src/lib/path-utils.ts @@ -0,0 +1,38 @@ +// Pure absolute-path helpers for the server-filesystem browser. +// No React Native imports — unit-testable with node --test. +// +// 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. + +/** Remove trailing slashes/backslashes, keeping the input if that would empty it. */ +export function stripTrailingSlash(dir: string): string { + return dir.replace(/[\\/]+$/, "") || dir +} + +function isRoot(trimmed: string): boolean { + // POSIX root ("/", "//"), a bare backslash, or a Windows drive root ("C:"). + return /^[\\/]+$/.test(trimmed) || /^[a-zA-Z]:$/.test(trimmed) +} + +/** + * Parent directory of an absolute path, or null when already at a + * filesystem root (POSIX "/" or a Windows drive root like "C:\"). + */ +export function parentOf(dir: string): string | null { + const trimmed = stripTrailingSlash(dir) + if (isRoot(trimmed)) return null + 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 +} + +/** Last path segment, e.g. "/a/b/" -> "b", "C:\\proj" -> "proj". */ +export 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 +}