authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-18 12:55:15-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-18 12:55:15-07:00
log54bbed660fc5dc99c6bfe335981e6d431275c91e
tree5c8d503ef374e42a46fd12df3c3a7473da88872c
parent989bde889dd119f5919ce27c9d56db77efedec4c
signature Signed by PGP key B5690EEEBB952194

refactor(mobile): share file header actions and search configuration (#12390)


1 files changed, 121 insertions(+), 206 deletions(-)

apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx+121-206
...@@ -1,6 +1,5 @@...@@ -1,6 +1,5 @@
1import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";1import { NativeStackScreenOptions } from "../../native/StackHeader";
2import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";2import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import type { MenuAction } from "@react-native-menu/menu";
4import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";3import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
5import { Platform, View } from "react-native";4import { Platform, View } from "react-native";
6import { useSafeAreaInsets } from "react-native-safe-area-context";5import { useSafeAreaInsets } from "react-native-safe-area-context";
...@@ -14,10 +13,8 @@ import {...@@ -14,10 +13,8 @@ import {
14} from "@t3tools/shared/filePreview";13} from "@t3tools/shared/filePreview";
15import { mediaFileReference } from "@t3tools/client-runtime/media-reference";14import { mediaFileReference } from "@t3tools/client-runtime/media-reference";
1615
17import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";
18import { MaterialScreenContent } from "../../components/MaterialScreenContent";16import { MaterialScreenContent } from "../../components/MaterialScreenContent";
19import { AudioFilePreview } from "../../components/AudioFilePreview";17import { AudioFilePreview } from "../../components/AudioFilePreview";
20import { ControlPillMenu } from "../../components/ControlPill";
21import { EmptyState } from "../../components/EmptyState";18import { EmptyState } from "../../components/EmptyState";
22import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";19import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
23import { LoadingScreen } from "../../components/LoadingScreen";20import { LoadingScreen } from "../../components/LoadingScreen";
...@@ -38,14 +35,6 @@ import {...@@ -38,14 +35,6 @@ import {
38 useAdaptiveWorkspacePaneRole,35 useAdaptiveWorkspacePaneRole,
39 useRegisterWorkspaceInspector,36 useRegisterWorkspaceInspector,
40} from "../layout/AdaptiveWorkspaceLayout";37} from "../layout/AdaptiveWorkspaceLayout";
41import {
42 createNativeMailSearchToolbarItem,
43 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
44} from "../layout/native-mail-search-toolbar";
45import {
46 AndroidWorkspaceSidebarButton,
47 WorkspaceSidebarToolbar,
48} from "../layout/workspace-sidebar-toolbar";
49import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";38import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
50import { ThreadRouteScreen } from "../threads/ThreadRouteScreen";39import { ThreadRouteScreen } from "../threads/ThreadRouteScreen";
51import { FilePreviewLoading, FilePreviewNotice } from "./FilePreviewFeedback";40import { FilePreviewLoading, FilePreviewNotice } from "./FilePreviewFeedback";
...@@ -55,7 +44,7 @@ import { useFileTreeEntries } from "./useFileTreeEntries";...@@ -55,7 +44,7 @@ import { useFileTreeEntries } from "./useFileTreeEntries";
55import { preloadWorkspaceFileContents } from "./preload-workspace-file";44import { preloadWorkspaceFileContents } from "./preload-workspace-file";
56import { SourceFileSurface } from "./SourceFileSurface";45import { SourceFileSurface } from "./SourceFileSurface";
57import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane";46import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane";
58import { MaterialFilesHeader } from "./MaterialFilesHeader";47import { ScreenHeader } from "../../components/ScreenHeader";
59import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";48import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";
60import { WorkspaceFilePreviewError } from "./WorkspaceFilePreviewError";49import { WorkspaceFilePreviewError } from "./WorkspaceFilePreviewError";
61import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview";50import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview";
...@@ -70,6 +59,107 @@ import {...@@ -70,6 +59,107 @@ import {
70} from "./filePath";59} from "./filePath";
71import { useWorkspaceFileAssetUrlState } from "./workspaceFileAssetUrl";60import { useWorkspaceFileAssetUrlState } from "./workspaceFileAssetUrl";
7261
62function FilesBrowserHeader(props: {
63 readonly projectName: string;
64 readonly searchQuery: string;
65 readonly onSearchQueryChange: (query: string) => void;
66 readonly onRefresh: () => void;
67 readonly onBack: () => void;
68}) {
69 return (
70 <ScreenHeader
71 title="Files"
72 subtitle={props.projectName}
73 onBack={props.onBack}
74 hideBottomBorder
75 matchSearchSurface
76 search={{
77 value: props.searchQuery,
78 onChangeText: props.onSearchQueryChange,
79 placeholder: "Search files",
80 onRefresh: props.onRefresh,
81 refreshAccessibilityLabel: "Refresh files",
82 closeAccessibilityLabel: "Close file search",
83 clearAccessibilityLabel: "Clear file search",
84 }}
85 />
86 );
87}
88
89function FileHeader(props: {
90 readonly title: string;
91 readonly subtitle: string;
92 readonly iconColor: string;
93 readonly activeMode: string;
94 readonly fileInspectorSupported: boolean;
95 readonly onBack: () => void;
96 readonly onReturnToThread: () => void;
97 readonly actions: ReadonlyArray<{
98 readonly id: string;
99 readonly title: string;
100 readonly icon: string;
101 readonly inline: boolean;
102 readonly onPress: () => unknown;
103 }>;
104}) {
105 const { panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
106 const modes = props.actions.filter(({ inline }) => inline);
107 return (
108 <ScreenHeader
109 title={props.title}
110 subtitle={props.subtitle}
111 onBack={props.onBack}
112 hideBottomBorder
113 options={{ headerTintColor: props.iconColor, headerTitle: props.title }}
114 backInSplitView={
115 props.fileInspectorSupported
116 ? {
117 accessibilityLabel: "Return to chat",
118 icon: "chevron.left",
119 onPress: props.onReturnToThread,
120 }
121 : undefined
122 }
123 actions={
124 props.fileInspectorSupported
125 ? [
126 {
127 accessibilityLabel: panes.auxiliaryPaneVisible
128 ? "Hide file navigator"
129 : "Show file navigator",
130 icon: "sidebar.right",
131 selected: panes.auxiliaryPaneVisible,
132 onPress: toggleAuxiliaryPane,
133 },
134 ]
135 : undefined
136 }
137 menus={[
138 {
139 title: "File actions",
140 icon: "ellipsis",
141 separateBackground: false,
142 items: [
143 ...(modes.length > 0
144 ? [
145 {
146 id: "modes",
147 inline: true,
148 items: modes.map((action) => ({
149 ...action,
150 selected: action.id === props.activeMode,
151 })),
152 },
153 ]
154 : []),
155 ...props.actions.filter(({ inline }) => !inline),
156 ],
157 },
158 ]}
159 />
160 );
161}
162
73type FileViewMode = "preview" | "source";163type FileViewMode = "preview" | "source";
74164
75function firstRouteParam(value: string | string[] | undefined): string | null {165function firstRouteParam(value: string | string[] | undefined): string | null {
...@@ -315,14 +405,10 @@ function FilesToolbarBottomFade() {...@@ -315,14 +405,10 @@ function FilesToolbarBottomFade() {
315export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {405export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
316 useAdaptiveWorkspacePaneRole("inspector");406 useAdaptiveWorkspacePaneRole("inspector");
317 const navigation = useNavigation();407 const navigation = useNavigation();
318 const { fileInspector, layout, panes, showAuxiliaryPane, togglePrimarySidebar } =408 const { fileInspector, layout, showAuxiliaryPane } = useAdaptiveWorkspaceLayout();
319 useAdaptiveWorkspaceLayout();
320 const [searchQuery, setSearchQuery] = useState("");409 const [searchQuery, setSearchQuery] = useState("");
321 const isAndroid = Platform.OS === "android";
322 const { themeAppearance: highlightTheme } = useAppearancePreferences();410 const { themeAppearance: highlightTheme } = useAppearancePreferences();
323 const theme = useUniwindTheme();411 const headerColor = useUniwindTheme()["--color-header"];
324 const headerColor = theme["--color-header"];
325 const sheetSurfaceColor = theme["--color-sheet-solid"];
326 const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(412 const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(
327 props.route.params,413 props.route.params,
328 );414 );
...@@ -430,84 +516,15 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {...@@ -430,84 +516,15 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
430 );516 );
431 }517 }
432518
433 const usesCompactMailToolbar =
434 Platform.OS === "ios" && !layout.usesSplitView && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
435
436 const content = (519 const content = (
437 <>520 <>
438 {/* Static header config (glass preset and title) lives in Stack.tsx. The521 <FilesBrowserHeader
439 live sheet color stays dynamic here so the FlatList can remain the522 projectName={projectName}
440 direct scene child for native scroll-edge sampling. */}523 searchQuery={searchQuery}
441 <NativeStackScreenOptions524 onSearchQueryChange={setSearchQuery}
442 options={{525 onRefresh={entriesQuery.refresh}
443 contentStyle: {526 onBack={handleReturnToThread}
444 backgroundColor: Platform.OS === "android" ? headerColor : sheetSurfaceColor,
445 },
446 headerShown: !isAndroid,
447 unstable_headerSubtitle:
448 Platform.OS === "ios" && projectName.length > 0 ? projectName : undefined,
449 // No refresh button: the list already supports pull-to-refresh.
450 unstable_headerToolbarItems: usesCompactMailToolbar
451 ? () => [
452 createNativeMailSearchToolbarItem({
453 onSearchTextChange: setSearchQuery,
454 placeholder: "Search files",
455 searchTextChangeId: "files-search-text",
456 }),
457 ]
458 : undefined,
459 headerSearchBarOptions: usesCompactMailToolbar
460 ? undefined
461 : {
462 allowToolbarIntegration: true,
463 autoCapitalize: "none",
464 hideNavigationBar: false,
465 placeholder: "Search files",
466 onChangeText: (event) => {
467 setSearchQuery(event.nativeEvent.text);
468 },
469 onCancelButtonPress: () => {
470 setSearchQuery("");
471 },
472 },
473 }}
474 />527 />
475 {isAndroid ? (
476 <>
477 {
478 <MaterialFilesHeader
479 projectName={projectName}
480 leading={<AndroidWorkspaceSidebarButton />}
481 searchQuery={searchQuery}
482 onSearchQueryChange={setSearchQuery}
483 onRefresh={entriesQuery.refresh}
484 onBack={handleReturnToThread}
485 />
486 }
487 </>
488 ) : (
489 <>
490 {layout.usesSplitView ? (
491 <NativeHeaderToolbar placement="left">
492 <NativeHeaderToolbar.Button
493 accessibilityLabel={panes.primarySidebarVisible ? "Maximize files" : "Show threads"}
494 icon={
495 panes.primarySidebarVisible
496 ? "arrow.up.left.and.arrow.down.right"
497 : "sidebar.left"
498 }
499 onPress={togglePrimarySidebar}
500 separateBackground
501 />
502 </NativeHeaderToolbar>
503 ) : null}
504 {usesCompactMailToolbar ? null : (
505 <NativeHeaderToolbar placement="bottom">
506 <NativeHeaderToolbar.SearchBarSlot />
507 </NativeHeaderToolbar>
508 )}
509 </>
510 )}
511 <MaterialScreenContent insetHorizontal={layout.usesSplitView}>528 <MaterialScreenContent insetHorizontal={layout.usesSplitView}>
512 <FileTreeBrowser529 <FileTreeBrowser
513 key={JSON.stringify([environmentId, cwd])}530 key={JSON.stringify([environmentId, cwd])}
...@@ -540,10 +557,9 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {...@@ -540,10 +557,9 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
540export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {557export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
541 useAdaptiveWorkspacePaneRole("inspector");558 useAdaptiveWorkspacePaneRole("inspector");
542 const navigation = useNavigation();559 const navigation = useNavigation();
543 const { fileInspector, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();560 const { fileInspector } = useAdaptiveWorkspaceLayout();
544 const { appearance, setCodeWordBreak } = useAppearancePreferences();561 const { appearance, setCodeWordBreak } = useAppearancePreferences();
545 const iconColor = useUniwindTheme()["--color-icon"];562 const iconColor = useUniwindTheme()["--color-icon"];
546 const isAndroid = Platform.OS === "android";
547 const params = props.route.params;563 const params = props.route.params;
548 const relativePath = normalizeRoutePath(params.path);564 const relativePath = normalizeRoutePath(params.path);
549 const targetLine = normalizeRouteLine(firstRouteParam(params.line));565 const targetLine = normalizeRouteLine(firstRouteParam(params.line));
...@@ -819,23 +835,6 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {...@@ -819,23 +835,6 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
819 fileData?.truncated,835 fileData?.truncated,
820 ]);836 ]);
821837
822 const androidFileMenuActions = useMemo<MenuAction[]>(
823 () =>
824 fileMenuActions.map((action) => ({
825 id: action.id,
826 title: action.title,
827 image: action.icon,
828 state: action.id === resolvedActiveMode ? "on" : undefined,
829 })),
830 [fileMenuActions, resolvedActiveMode],
831 );
832 const handleAndroidFileMenuAction = useCallback(
833 (event: { nativeEvent: { event: string } }) => {
834 const action = fileMenuActions.find(({ id }) => id === event.nativeEvent.event);
835 void action?.onPress();
836 },
837 [fileMenuActions],
838 );
839 const handleReturnToThread = useCallback(() => {838 const handleReturnToThread = useCallback(() => {
840 if (environmentId !== null && threadId !== null) {839 if (environmentId !== null && threadId !== null) {
841 navigation.dispatch(840 navigation.dispatch(
...@@ -878,100 +877,16 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {...@@ -878,100 +877,16 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
878877
879 return (878 return (
880 <View className="flex-1 bg-sheet">879 <View className="flex-1 bg-sheet">
881 <NativeStackScreenOptions880 <FileHeader
882 options={{881 title={basename(relativePath)}
883 // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS: solid882 subtitle={headerSubtitle}
884 // sheet-colored header — this route's content scrolls internally, so883 iconColor={iconColor}
885 // there is nothing for glass to sample). Only dynamic values here.884 activeMode={resolvedActiveMode}
886 headerShown: !isAndroid,885 fileInspectorSupported={fileInspector.supported}
887 headerTintColor: iconColor,886 onBack={handleBack}
888 headerTitle: basename(relativePath),887 onReturnToThread={handleReturnToThread}
889 title: basename(relativePath),888 actions={fileMenuActions}
890 unstable_headerSubtitle:
891 Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined,
892 }}
893 />889 />
894 {isAndroid ? (
895 <AndroidScreenHeader
896 title={basename(relativePath)}
897 subtitle={headerSubtitle}
898 leading={<AndroidWorkspaceSidebarButton />}
899 hideBottomBorder
900 onBack={handleBack}
901 trailing={
902 <>
903 {fileInspector.supported ? (
904 <AndroidHeaderIconButton
905 accessibilityLabel={
906 panes.auxiliaryPaneVisible ? "Hide file navigator" : "Show file navigator"
907 }
908 icon="sidebar.right"
909 selected={panes.auxiliaryPaneVisible}
910 onPress={toggleAuxiliaryPane}
911 />
912 ) : null}
913 <ControlPillMenu
914 actions={androidFileMenuActions}
915 isAnchoredToRight
916 title="File actions"
917 onPressAction={handleAndroidFileMenuAction}
918 >
919 <AndroidHeaderIconButton accessibilityLabel="File actions" icon="ellipsis" />
920 </ControlPillMenu>
921 </>
922 }
923 />
924 ) : null}
925 <WorkspaceSidebarToolbar>
926 {fileInspector.supported ? (
927 <NativeHeaderToolbar.Button
928 accessibilityLabel="Return to chat"
929 icon="chevron.left"
930 onPress={handleReturnToThread}
931 />
932 ) : null}
933 </WorkspaceSidebarToolbar>
934 <NativeHeaderToolbar placement="right">
935 {fileInspector.supported ? (
936 <NativeHeaderToolbar.Button
937 accessibilityLabel={
938 panes.auxiliaryPaneVisible ? "Hide file navigator" : "Show file navigator"
939 }
940 icon="sidebar.right"
941 onPress={toggleAuxiliaryPane}
942 separateBackground
943 />
944 ) : null}
945 <NativeHeaderToolbar.Menu accessibilityLabel="File actions" icon="ellipsis">
946 {fileMenuActions.some(({ inline }) => inline) ? (
947 <NativeHeaderToolbar.Menu inline>
948 {fileMenuActions
949 .filter(({ inline }) => inline)
950 .map((action) => (
951 <NativeHeaderToolbar.MenuAction
952 key={action.id}
953 icon={action.icon}
954 isOn={action.id === resolvedActiveMode}
955 onPress={action.onPress}
956 >
957 {action.title}
958 </NativeHeaderToolbar.MenuAction>
959 ))}
960 </NativeHeaderToolbar.Menu>
961 ) : null}
962 {fileMenuActions
963 .filter(({ inline }) => !inline)
964 .map((action) => (
965 <NativeHeaderToolbar.MenuAction
966 key={action.id}
967 icon={action.icon}
968 onPress={action.onPress}
969 >
970 {action.title}
971 </NativeHeaderToolbar.MenuAction>
972 ))}
973 </NativeHeaderToolbar.Menu>
974 </NativeHeaderToolbar>
975 <MaterialScreenContent>890 <MaterialScreenContent>
976 <FileContent891 <FileContent
977 key={previewKey}892 key={previewKey}