1import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
2import { environmentSession } from "../../state/session";
3import type { EnvironmentId } from "@t3tools/contracts";
4import { SymbolView } from "../../components/AppSymbol";
5import { MaterialScreenContent } from "../../components/MaterialScreenContent";
6import { useCallback, useMemo, useState, type ComponentProps } from "react";
7import { Platform, Pressable, View, type NativeSyntheticEvent } from "react-native";
8import {
9 Screen,
10 ScreenStack,
11 ScreenStackHeaderConfig,
12 ScreenStackHeaderSearchBarView,
13 SearchBar,
14} from "react-native-screens";
15
16import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
17import { MaterialFilesHeader } from "./MaterialFilesHeader";
18import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
19import { useUniwindTheme } from "../../lib/useUniwindTheme";
20import { useEnvironmentQuery } from "../../state/query";
21import { useEnvironmentPresentation } from "../../state/presentation";
22import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
23import { FileTreeBrowser } from "./FileTreeBrowser";
24import { useFileTreeEntries } from "./useFileTreeEntries";
25import { preloadWorkspaceFileContents } from "./preload-workspace-file";
26import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
27
28export function ThreadFileNavigatorPane(props: {
29 readonly cwd: string;
30 readonly environmentId: EnvironmentId;
31 readonly headerInset: number;
32 readonly projectName: string;
33 readonly selectedPath: string | null;
34 readonly onSelectFile: (path: string) => void;
35}) {
36 const [searchQuery, setSearchQuery] = useState("");
37 const { toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
38 const { themeAppearance: highlightTheme } = useAppearancePreferences();
39 const theme = useUniwindTheme();
40 const foregroundColor = theme["--color-foreground"];
41 const sheetColor = theme["--color-sheet"];
42 const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
43 const fileAccessSession = useEnvironmentQuery(
44 environmentSession.sessionStateAtom(props.environmentId),
45 );
46 const fileEnvironment = useEnvironmentPresentation(props.environmentId);
47 const fileAccess = resolveFilesystemReadAccess({
48 isCatalogReady: fileEnvironment.isReady,
49 connection: fileEnvironment.presentation?.connection ?? null,
50 session: fileAccessSession.data,
51 sessionError: fileAccessSession.error,
52 });
53 const { canReadFiles } = fileAccess;
54 const entriesQuery = useFileTreeEntries({
55 environmentId: props.environmentId,
56 cwd: canReadFiles ? props.cwd : null,
57 searchQuery,
58 });
59 const handlePreviewFile = useCallback(
60 (relativePath: string) => {
61 preloadWorkspaceFileContents({
62 cwd: props.cwd,
63 environmentId: props.environmentId,
64 relativePath,
65 theme: highlightTheme,
66 });
67 },
68 [highlightTheme, props.cwd, props.environmentId],
69 );
70 const nativeHeaderRightBarButtonItems = useMemo(
71 () =>
72 [
73 {
74 accessibilityLabel: "Close files",
75 icon: { name: "xmark", type: "sfSymbol" as const },
76 identifier: "thread-file-navigator-close",
77 onPress: toggleAuxiliaryPane,
78 sharesBackground: false,
79 tintColor: foregroundColor,
80 type: "button" as const,
81 },
82 ] as ComponentProps<typeof ScreenStackHeaderConfig>["headerRightBarButtonItems"],
83 [foregroundColor, toggleAuxiliaryPane],
84 );
85
86 const fileTree = (
87 <FileTreeBrowser
88 key={JSON.stringify([props.environmentId, props.cwd])}
89 entries={entriesQuery.entries}
90 loadingDirectories={entriesQuery.loadingDirectories}
91 onLoadDirectory={entriesQuery.loadDirectory}
92 error={
93 canReadFiles
94 ? entriesQuery.error
95 : fileAccess.isPending
96 ? null
97 : (fileAccess.error ?? "This connection cannot read host files.")
98 }
99 isPending={fileAccess.isPending || entriesQuery.isPending}
100 isRefreshing={entriesQuery.isRefreshing}
101 searchQuery={searchQuery}
102 searchTruncated={entriesQuery.searchTruncated}
103 selectedPath={props.selectedPath}
104 onPreviewFile={handlePreviewFile}
105 onRefresh={entriesQuery.refresh}
106 onSelectFile={props.onSelectFile}
107 />
108 );
109
110 if (Platform.OS === "ios") {
111 return (
112 <View className="flex-1 border-l border-border bg-sheet">
113 <ScreenStack style={{ flex: 1 }}>
114 <Screen
115 activityState={2}
116 enabled
117 isNativeStack
118 screenId="thread-file-navigator-native"
119 scrollEdgeEffects={headerScrollEdgeEffects}
120 style={{ backgroundColor: sheetColor, flex: 1 }}
121 >
122 {fileTree}
123 <ScreenStackHeaderConfig
124 backgroundColor="rgba(0,0,0,0)"
125 color={foregroundColor}
126 headerRightBarButtonItems={nativeHeaderRightBarButtonItems}
127 hideBackButton
128 hideShadow={false}
129 navigationItemStyle="editor"
130 subtitle={props.projectName}
131 title="Files"
132 titleColor={foregroundColor}
133 titleFontSize={17}
134 titleFontWeight="700"
135 translucent
136 >
137 <ScreenStackHeaderSearchBarView>
138 <SearchBar
139 allowToolbarIntegration
140 autoCapitalize="none"
141 barTintColor={sheetColor}
142 hideNavigationBar={false}
143 hideWhenScrolling={false}
144 obscureBackground={false}
145 onCancelButtonPress={() => {
146 setSearchQuery("");
147 }}
148 onChangeText={(event: NativeSyntheticEvent<{ readonly text?: string }>) => {
149 setSearchQuery(event.nativeEvent.text ?? "");
150 }}
151 placement="integratedButton"
152 placeholder="Search files"
153 textColor={foregroundColor}
154 tintColor={foregroundColor}
155 />
156 </ScreenStackHeaderSearchBarView>
157 </ScreenStackHeaderConfig>
158 </Screen>
159 </ScreenStack>
160 </View>
161 );
162 }
163
164 return (
165 <View
166 className={
167 Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 border-l border-border bg-sheet"
168 }
169 >
170 <View
171 className="ios:border-b ios:border-border android:bg-header"
172 style={{ paddingTop: Platform.OS === "android" ? 0 : props.headerInset }}
173 >
174 {Platform.OS === "android" ? (
175 <MaterialFilesHeader
176 projectName={props.projectName}
177 searchQuery={searchQuery}
178 onSearchQueryChange={setSearchQuery}
179 onRefresh={entriesQuery.refresh}
180 />
181 ) : (
182 <View className="h-12 flex-row items-center gap-2 px-3">
183 <View className="min-w-0 flex-1">
184 <Text className="text-sm font-t3-bold text-foreground">Files</Text>
185 <Text className="text-xs text-foreground-muted" numberOfLines={1}>
186 {props.projectName}
187 </Text>
188 </View>
189 <Pressable
190 accessibilityRole="button"
191 accessibilityLabel="Refresh files"
192 hitSlop={8}
193 className="h-8 w-8 items-center justify-center rounded-full active:bg-subtle"
194 onPress={entriesQuery.refresh}
195 >
196 <SymbolView
197 name="arrow.clockwise"
198 size={14}
199 tintColorClassName="accent-icon-muted"
200 type="monochrome"
201 />
202 </Pressable>
203 </View>
204 )}
205 {Platform.OS !== "android" ? (
206 <View className="flex-row items-center gap-2 border-t border-border px-3 py-2">
207 <SymbolView
208 name="magnifyingglass"
209 size={15}
210 tintColorClassName="accent-icon-muted"
211 type="monochrome"
212 />
213 <TextInput
214 accessibilityLabel="Search files"
215 autoCapitalize="none"
216 autoCorrect={false}
217 clearButtonMode="while-editing"
218 className="min-h-10 flex-1 rounded-xl py-2 text-sm"
219 placeholder="Search files"
220 value={searchQuery}
221 onChangeText={setSearchQuery}
222 />
223 </View>
224 ) : null}
225 </View>
226 <MaterialScreenContent insetHorizontal>{fileTree}</MaterialScreenContent>
227 </View>
228 );
229}