merge: feat/directory-picker — browsable server directory picker (#49/#57) (reviewed: APPROVE after fixes)
This commit is contained in:
@@ -22,7 +22,7 @@ import { useConnections } from "../../src/stores/connections"
|
|||||||
import { useCatalog } from "../../src/stores/catalog"
|
import { useCatalog } from "../../src/stores/catalog"
|
||||||
import type BottomSheet from "@gorhom/bottom-sheet"
|
import type BottomSheet from "@gorhom/bottom-sheet"
|
||||||
import type { Session, Project } from "../../src/lib/sdk"
|
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 {
|
function formatTime(timestamp: number): string {
|
||||||
const date = new Date(timestamp)
|
const date = new Date(timestamp)
|
||||||
@@ -129,6 +129,11 @@ export default function SessionsScreen() {
|
|||||||
} = useConnections()
|
} = useConnections()
|
||||||
const loadCatalog = useCatalog((s) => s.load)
|
const loadCatalog = useCatalog((s) => s.load)
|
||||||
const dirSheetRef = useRef<BottomSheet>(null)
|
const dirSheetRef = useRef<BottomSheet>(null)
|
||||||
|
const browserSheetRef = useRef<BottomSheet>(null)
|
||||||
|
const [browseStartDir, setBrowseStartDir] = useState<string | null>(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)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
|
||||||
// Fetch server-known projects when the new session modal opens
|
// Fetch server-known projects when the new session modal opens
|
||||||
@@ -270,6 +275,45 @@ export default function SessionsScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The browser sheet is a sibling of the New Session <Modal>. 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, showNewSession],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onBrowserSelect = useCallback(
|
||||||
|
(directory: string) => {
|
||||||
|
restoreNewSessionOnDismiss.current = false
|
||||||
|
if (browseMode === "switch") {
|
||||||
|
handleSwitchDirectory(directory)
|
||||||
|
dirSheetRef.current?.close()
|
||||||
|
} else {
|
||||||
|
onCreateInDirectory(directory)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[browseMode, handleSwitchDirectory, onCreateInDirectory],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onBrowserDismiss = useCallback(() => {
|
||||||
|
if (restoreNewSessionOnDismiss.current) {
|
||||||
|
restoreNewSessionOnDismiss.current = false
|
||||||
|
setShowNewSession(true)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const onFabPress = () => {
|
const onFabPress = () => {
|
||||||
// Quick create in current project
|
// Quick create in current project
|
||||||
onCreateSession()
|
onCreateSession()
|
||||||
@@ -478,6 +522,24 @@ export default function SessionsScreen() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Browse the server's filesystem instead of typing a path */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.projectRow, isDark && styles.projectRowDark, { marginTop: 16 }]}
|
||||||
|
onPress={() =>
|
||||||
|
openBrowser(currentProject?.path?.absolute || activeConnection?.directory || null, "create")
|
||||||
|
}
|
||||||
|
disabled={isCreating}
|
||||||
|
>
|
||||||
|
<Ionicons name="folder-open-outline" size={18} color={isDark ? "#8b5cf6" : "#6d28d9"} />
|
||||||
|
<View style={styles.projectRowContent}>
|
||||||
|
<Text style={[styles.projectRowName, isDark && styles.textDark]}>Browse Folders…</Text>
|
||||||
|
<Text style={[styles.projectRowPath, isDark && styles.metaDark]}>
|
||||||
|
Explore the server's filesystem
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={16} color={isDark ? "#666666" : "#999999"} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Manual path input fallback */}
|
{/* Manual path input fallback */}
|
||||||
<Text style={[styles.modalLabel, isDark && styles.metaDark, { marginTop: 16 }]}>
|
<Text style={[styles.modalLabel, isDark && styles.metaDark, { marginTop: 16 }]}>
|
||||||
Enter Path Manually
|
Enter Path Manually
|
||||||
@@ -609,6 +671,20 @@ export default function SessionsScreen() {
|
|||||||
serverHome={serverHome}
|
serverHome={serverHome}
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
onSwitch={handleSwitchDirectory}
|
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). */}
|
||||||
|
<DirectoryBrowserSheet
|
||||||
|
sheetRef={browserSheetRef}
|
||||||
|
startDirectory={browseStartDir}
|
||||||
|
clientForDirectory={clientForDirectory}
|
||||||
|
isDark={isDark}
|
||||||
|
onSelect={onBrowserSelect}
|
||||||
|
onDismiss={onBrowserDismiss}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|||||||
335
src/components/chat/DirectoryBrowserSheet.tsx
Normal file
335
src/components/chat/DirectoryBrowserSheet.tsx
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
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"
|
||||||
|
import { parentOf, nameOf } from "../../lib/path-utils"
|
||||||
|
|
||||||
|
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
|
||||||
|
// Called whenever the sheet fully closes (selection or cancel).
|
||||||
|
onDismiss?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DirectoryBrowserSheet({
|
||||||
|
sheetRef,
|
||||||
|
startDirectory,
|
||||||
|
clientForDirectory,
|
||||||
|
isDark,
|
||||||
|
onSelect,
|
||||||
|
onDismiss,
|
||||||
|
}: 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 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) {
|
||||||
|
wasOpen.current = false
|
||||||
|
onDismiss?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (wasOpen.current) return // snap-point change while already open
|
||||||
|
wasOpen.current = true
|
||||||
|
setJumpPath("")
|
||||||
|
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, onDismiss],
|
||||||
|
)
|
||||||
|
|
||||||
|
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]}>
|
||||||
|
{browseDir ? "No subfolders here" : "Enter a path above to start browsing"}
|
||||||
|
</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" },
|
||||||
|
})
|
||||||
@@ -10,9 +10,12 @@ interface Props {
|
|||||||
serverHome: string | null
|
serverHome: string | null
|
||||||
isDark: boolean
|
isDark: boolean
|
||||||
onSwitch: (directory?: string) => void
|
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 [custom, setCustom] = useState("")
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
@@ -100,14 +103,30 @@ export function DirectorySwitcher({ sheetRef, current, recents, serverHome, isDa
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Quick path chips */}
|
{/* Quick path chips */}
|
||||||
{serverHome && (
|
{(serverHome || onBrowse) && (
|
||||||
<View style={s.chips}>
|
<View style={s.chips}>
|
||||||
|
{serverHome && (
|
||||||
|
<>
|
||||||
<TouchableOpacity style={[s.chip, isDark && s.chipDark]} onPress={() => setCustom(serverHome)}>
|
<TouchableOpacity style={[s.chip, isDark && s.chipDark]} onPress={() => setCustom(serverHome)}>
|
||||||
<Text style={[s.chipText, isDark && s.chipTextDark]}>~</Text>
|
<Text style={[s.chipText, isDark && s.chipTextDark]}>~</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={[s.chip, isDark && s.chipDark]} onPress={() => setCustom(serverHome + "/")}>
|
<TouchableOpacity style={[s.chip, isDark && s.chipDark]} onPress={() => setCustom(serverHome + "/")}>
|
||||||
<Text style={[s.chipText, isDark && s.chipTextDark]}>~/</Text>
|
<Text style={[s.chipText, isDark && s.chipTextDark]}>~/</Text>
|
||||||
</TouchableOpacity>
|
</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>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -188,6 +207,11 @@ const s = StyleSheet.create({
|
|||||||
chipDark: {
|
chipDark: {
|
||||||
backgroundColor: "#2a2040",
|
backgroundColor: "#2a2040",
|
||||||
},
|
},
|
||||||
|
chipBrowse: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
chipText: {
|
chipText: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
|
|||||||
@@ -9,4 +9,5 @@ export { SlashPopover, type SlashCommand } from "./SlashPopover"
|
|||||||
export { ModelPicker } from "./ModelPicker"
|
export { ModelPicker } from "./ModelPicker"
|
||||||
export { ImageAttachments, type Attachment } from "./ImageAttachments"
|
export { ImageAttachments, type Attachment } from "./ImageAttachments"
|
||||||
export { DirectorySwitcher } from "./DirectorySwitcher"
|
export { DirectorySwitcher } from "./DirectorySwitcher"
|
||||||
|
export { DirectoryBrowserSheet } from "./DirectoryBrowserSheet"
|
||||||
export { SessionInfo } from "./SessionInfo"
|
export { SessionInfo } from "./SessionInfo"
|
||||||
|
|||||||
88
src/lib/path-utils.test.ts
Normal file
88
src/lib/path-utils.test.ts
Normal file
@@ -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")
|
||||||
|
})
|
||||||
38
src/lib/path-utils.ts
Normal file
38
src/lib/path-utils.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -143,6 +143,14 @@ export interface Project {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FileEntry {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
absolute: string
|
||||||
|
type: "file" | "directory"
|
||||||
|
ignored: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface Event {
|
export interface Event {
|
||||||
type: string
|
type: string
|
||||||
properties: Record<string, unknown>
|
properties: Record<string, unknown>
|
||||||
@@ -250,6 +258,17 @@ export function createClient(config: ClientConfig) {
|
|||||||
current: () => request<Project>(config, "/project/current"),
|
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: {
|
path: {
|
||||||
get: () =>
|
get: () =>
|
||||||
request<{ home: string; state: string; config: string; worktree: string; directory: string }>(config, "/path"),
|
request<{ home: string; state: string; config: string; worktree: string; directory: string }>(config, "/path"),
|
||||||
|
|||||||
Reference in New Issue
Block a user