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 @@
1import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
1import { NativeStackScreenOptions } from "../../native/StackHeader";
22import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import type { MenuAction } from "@react-native-menu/menu";
43import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
54import { Platform, View } from "react-native";
65import { useSafeAreaInsets } from "react-native-safe-area-context";
......@@ -14,10 +13,8 @@ import {
1413} from "@t3tools/shared/filePreview";
1514import { mediaFileReference } from "@t3tools/client-runtime/media-reference";
1615
17import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";
1816import { MaterialScreenContent } from "../../components/MaterialScreenContent";
1917import { AudioFilePreview } from "../../components/AudioFilePreview";
20import { ControlPillMenu } from "../../components/ControlPill";
2118import { EmptyState } from "../../components/EmptyState";
2219import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
2320import { LoadingScreen } from "../../components/LoadingScreen";
......@@ -38,14 +35,6 @@ import {
3835 useAdaptiveWorkspacePaneRole,
3936 useRegisterWorkspaceInspector,
4037} 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";
4938import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
5039import { ThreadRouteScreen } from "../threads/ThreadRouteScreen";
5140import { FilePreviewLoading, FilePreviewNotice } from "./FilePreviewFeedback";
......@@ -55,7 +44,7 @@ import { useFileTreeEntries } from "./useFileTreeEntries";
5544import { preloadWorkspaceFileContents } from "./preload-workspace-file";
5645import { SourceFileSurface } from "./SourceFileSurface";
5746import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane";
58import { MaterialFilesHeader } from "./MaterialFilesHeader";
47import { ScreenHeader } from "../../components/ScreenHeader";
5948import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";
6049import { WorkspaceFilePreviewError } from "./WorkspaceFilePreviewError";
6150import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview";
......@@ -70,6 +59,107 @@ import {
7059} from "./filePath";
7160import { 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
73163type FileViewMode = "preview" | "source";
74164
75165function firstRouteParam(value: string | string[] | undefined): string | null {
......@@ -315,14 +405,10 @@ function FilesToolbarBottomFade() {
315405export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
316406 useAdaptiveWorkspacePaneRole("inspector");
317407 const navigation = useNavigation();
318 const { fileInspector, layout, panes, showAuxiliaryPane, togglePrimarySidebar } =
319 useAdaptiveWorkspaceLayout();
408 const { fileInspector, layout, showAuxiliaryPane } = useAdaptiveWorkspaceLayout();
320409 const [searchQuery, setSearchQuery] = useState("");
321 const isAndroid = Platform.OS === "android";
322410 const { themeAppearance: highlightTheme } = useAppearancePreferences();
323 const theme = useUniwindTheme();
324 const headerColor = theme["--color-header"];
325 const sheetSurfaceColor = theme["--color-sheet-solid"];
411 const headerColor = useUniwindTheme()["--color-header"];
326412 const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(
327413 props.route.params,
328414 );
......@@ -430,84 +516,15 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
430516 );
431517 }
432518
433 const usesCompactMailToolbar =
434 Platform.OS === "ios" && !layout.usesSplitView && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
435
436519 const content = (
437520 <>
438 {/* Static header config (glass preset and title) lives in Stack.tsx. The
439 live sheet color stays dynamic here so the FlatList can remain the
440 direct scene child for native scroll-edge sampling. */}
441 <NativeStackScreenOptions
442 options={{
443 contentStyle: {
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 }}
521 <FilesBrowserHeader
522 projectName={projectName}
523 searchQuery={searchQuery}
524 onSearchQueryChange={setSearchQuery}
525 onRefresh={entriesQuery.refresh}
526 onBack={handleReturnToThread}
474527 />
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 )}
511528 <MaterialScreenContent insetHorizontal={layout.usesSplitView}>
512529 <FileTreeBrowser
513530 key={JSON.stringify([environmentId, cwd])}
......@@ -540,10 +557,9 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
540557export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
541558 useAdaptiveWorkspacePaneRole("inspector");
542559 const navigation = useNavigation();
543 const { fileInspector, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
560 const { fileInspector } = useAdaptiveWorkspaceLayout();
544561 const { appearance, setCodeWordBreak } = useAppearancePreferences();
545562 const iconColor = useUniwindTheme()["--color-icon"];
546 const isAndroid = Platform.OS === "android";
547563 const params = props.route.params;
548564 const relativePath = normalizeRoutePath(params.path);
549565 const targetLine = normalizeRouteLine(firstRouteParam(params.line));
......@@ -819,23 +835,6 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
819835 fileData?.truncated,
820836 ]);
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 );
839838 const handleReturnToThread = useCallback(() => {
840839 if (environmentId !== null && threadId !== null) {
841840 navigation.dispatch(
......@@ -878,100 +877,16 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
878877
879878 return (
880879 <View className="flex-1 bg-sheet">
881 <NativeStackScreenOptions
882 options={{
883 // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS: solid
884 // sheet-colored header — this route's content scrolls internally, so
885 // there is nothing for glass to sample). Only dynamic values here.
886 headerShown: !isAndroid,
887 headerTintColor: iconColor,
888 headerTitle: basename(relativePath),
889 title: basename(relativePath),
890 unstable_headerSubtitle:
891 Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined,
892 }}
880 <FileHeader
881 title={basename(relativePath)}
882 subtitle={headerSubtitle}
883 iconColor={iconColor}
884 activeMode={resolvedActiveMode}
885 fileInspectorSupported={fileInspector.supported}
886 onBack={handleBack}
887 onReturnToThread={handleReturnToThread}
888 actions={fileMenuActions}
893889 />
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>
975890 <MaterialScreenContent>
976891 <FileContent
977892 key={previewKey}