Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 67 additions & 14 deletions apps/mobile/src/components/ui/AppBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Toolbar } from "./Toolbar";
import { AppActivityIndicator } from "./AppActivityIndicator";
import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens";
import { NavigationContext } from "@react-navigation/native";
import { use, useId, useLayoutEffect } from "react";
import { use, useEffect, useId, useLayoutEffect, useState } from "react";
import { View } from "react-native";
import { createNativeHeaderMenu } from "./nativeHeaderMenu.ios";
import { ScreenHeaderButton } from "./ScreenHeaderButton";
Expand Down Expand Up @@ -78,23 +78,71 @@ export function AppBar(props: AppBarProps) {
const usesNativeMailSearchToolbar = useNativeMailSearchToolbar();
const { search, menus } = props;
const menu = menus?.[0];
const compactSearch =
// Reserve mail-toolbar chrome from the first frame even before search attaches, so menus[0]
// never flashes as a trailing bar button during the push.
const wantsCompactSearch =
search !== undefined &&
(search.compactToolbar ?? !layout.usesSplitView) &&
usesNativeMailSearchToolbar;
const visibleMenus = compactSearch ? (menus?.slice(1) ?? []) : (menus ?? []);
// Token identity: search stays detached until transitionEnd marks this key ready.
const searchAttachKey = wantsCompactSearch ? `${headerId}:compact` : null;
const [attachedSearchKey, setAttachedSearchKey] = useState<string | null>(null);
const searchAttached = searchAttachKey === null || attachedSearchKey === searchAttachKey;
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- attaches native search after the push so UIKit does not flash its trailing bar-button form
useEffect(() => {
if (searchAttachKey === null) return;
let done = false;
let opening = false;
const finish = () => {
if (done) return;
done = true;
setAttachedSearchKey(searchAttachKey);
};
const stackNavigation = navigation as unknown as {
readonly addListener: (
type: "transitionStart" | "transitionEnd",
listener: (event: { readonly data: { readonly closing: boolean } }) => void,
) => () => void;
} | null;
const removeStart = stackNavigation?.addListener("transitionStart", ({ data }) => {
if (!data.closing) opening = true;
});
const removeEnd = stackNavigation?.addListener("transitionEnd", ({ data }) => {
if (!data.closing) finish();
});
let frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(() => {
if (!opening) finish();
});
});
const fallback = setTimeout(finish, 1_000);
return () => {
done = true;
removeStart?.();
removeEnd?.();
cancelAnimationFrame(frame);
clearTimeout(fallback);
};
}, [navigation, searchAttachKey]);
const compactSearch = wantsCompactSearch && searchAttached;
const activeSearch = wantsCompactSearch && !searchAttached ? undefined : search;
const visibleMenus = wantsCompactSearch ? (menus?.slice(1) ?? []) : (menus ?? []);
// Where the mail toolbar isn't used, the Duo keeps search at the bottom of the pane like its
// workspace lists; elsewhere inline search is a button in the bar.
const duoBottomSearch =
search !== undefined &&
activeSearch !== undefined &&
!compactSearch &&
NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED &&
isDuoDevice(deviceType);
if (props.embedded || props.sheet) return null;
return (
<>
<NativeStackScreenOptions
optionsVersion={[props.optionsVersion, compactSearch ? menu : undefined]}
optionsVersion={[
props.optionsVersion,
wantsCompactSearch ? menu : undefined,
searchAttached,
]}
options={{
headerShown: true,
headerBackVisible:
Expand All @@ -108,14 +156,14 @@ export function AppBar(props: AppBarProps) {
...(props.matchSearchSurface
? { contentStyle: { backgroundColor: themeVariables["--color-sheet-solid"] } }
: undefined),
...(search
...(activeSearch
? {
unstable_headerToolbarItems: compactSearch
? () => [
createNativeMailSearchToolbarItem({
showsSearchDismissButton: true,
placeholder: search.compactPlaceholder ?? search.placeholder,
onSearchTextChange: search.onChangeText,
placeholder: activeSearch.compactPlaceholder ?? activeSearch.placeholder,
onSearchTextChange: activeSearch.onChangeText,
searchTextChangeId: `${headerId}-search-text`,
...(menu
? {
Expand All @@ -133,20 +181,25 @@ export function AppBar(props: AppBarProps) {
allowToolbarIntegration: true,
...(duoBottomSearch
? { placement: "integrated" as const }
: NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED && search.mode === "inline"
: NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED && activeSearch.mode === "inline"
? { placement: "integratedButton" as const }
: undefined),
autoCapitalize: "none",
hideNavigationBar: false,
...(search.mode === "inline" || duoBottomSearch
...(activeSearch.mode === "inline" || duoBottomSearch
? { obscureBackground: false }
: undefined),
placeholder: search.placeholder,
onChangeText: (event) => search.onChangeText(event.nativeEvent.text),
onCancelButtonPress: () => search.onChangeText(""),
placeholder: activeSearch.placeholder,
onChangeText: (event) => activeSearch.onChangeText(event.nativeEvent.text),
onCancelButtonPress: () => activeSearch.onChangeText(""),
},
}
: undefined),
: wantsCompactSearch
? {
// Keep the mail-toolbar filter slot reserved before search attaches.
unstable_headerToolbarItems: () => [],
}
: undefined),
}}
/>
{props.onClose ? (
Expand Down
8 changes: 6 additions & 2 deletions apps/mobile/src/components/ui/RowPressable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,16 @@ export function RowPressable({
return (
<GestureDetector gesture={hoverGesture}>
<Pressable {...props} className={cn("relative overflow-hidden", className)}>
{() => (
{({ pressed }) => (
<>
<View
pointerEvents="none"
className={cn("absolute inset-0", interactionClassName)}
style={{ opacity: props.disabled || !hovered ? 0 : interactionOpacity }}
style={{
// Phones have no hover; show the same wash on press so taps feel acknowledged
// before navigation starts.
opacity: props.disabled || !(hovered || pressed) ? 0 : interactionOpacity,
}}
/>
{children}
</>
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -423,8 +423,10 @@ export function ArchivedThreadsScreen(props: {
}}
contentInsetAdjustmentBehavior="automatic"
data={listItems}
drawDistance={500}
estimatedItemSize={62}
getItemType={(item) => item.kind}
itemsAreEqual={(previous, next) => previous === next}
keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="handled"
keyExtractor={(item) => item.key}
Expand All @@ -433,6 +435,7 @@ export function ArchivedThreadsScreen(props: {
props.error ? <ArchiveError message={props.error} onRetry={props.onRefresh} /> : null
}
onScrollBeginDrag={() => openSwipeableRef.current?.close()}
recycleItems
refreshControl={
<RefreshControl
onRefresh={props.onRefresh}
Expand Down
14 changes: 12 additions & 2 deletions apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ export function ConnectionEnvironmentRow(props: {
);
};

return (
<Animated.View layout={LinearTransition.duration(250)} className="bg-grouped-card">
const body = (
<>
<View className="flex-row items-center gap-3 px-4 py-3.5">
<Pressable
className="min-w-0 flex-1 gap-0.5 active:opacity-70"
Expand Down Expand Up @@ -315,6 +315,16 @@ export function ConnectionEnvironmentRow(props: {
)}
</Animated.View>
) : null}
</>
);

// Details push doesn't expand inline — skip layout animation work on that path.
if (props.opensDetails) {
return <View className="bg-grouped-card">{body}</View>;
}
return (
<Animated.View layout={LinearTransition.duration(250)} className="bg-grouped-card">
{body}
</Animated.View>
);
}
9 changes: 7 additions & 2 deletions apps/mobile/src/features/files/FileTreeBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { LegendList } from "@legendapp/list/react-native";
import type { ProjectEntry } from "@t3tools/contracts";
import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
import { SymbolView } from "../../components/ui/AppSymbol";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Pressable, RefreshControl, View } from "react-native";

import { AppText as Text } from "../../components/ui/AppText";
Expand Down Expand Up @@ -209,7 +209,11 @@ export function FileTreeBrowser(props: {
/>
);

const extraData = { expandedPaths, loadingDirectories, selectedPath };
// Keep LegendList from refreshing every visible row on parent ticks that rebuild this object.
const extraData = useMemo(
() => ({ expandedPaths, loadingDirectories, selectedPath }),
[expandedPaths, loadingDirectories, selectedPath],
);

// Keep the list as direct screen content so UIKit can apply header insets.
return (
Expand All @@ -226,6 +230,7 @@ export function FileTreeBrowser(props: {
keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="handled"
estimatedItemSize={42}
drawDistance={500}
recycleItems
maintainVisibleContentPosition
extraData={extraData}
Expand Down
6 changes: 3 additions & 3 deletions apps/mobile/src/features/files/SourceFileSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -392,9 +392,9 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) {
refreshControl={refreshControl}
data={lines}
keyExtractor={(_line, index) => String(index)}
initialNumToRender={80}
maxToRenderPerBatch={80}
windowSize={12}
initialNumToRender={24}
maxToRenderPerBatch={24}
windowSize={7}
{...(codeWordBreak
? {}
: {
Expand Down
68 changes: 45 additions & 23 deletions apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@ import {
usePreventRemove,
type StaticScreenProps,
} from "@react-navigation/native";
import { type ComponentProps, type ReactNode, useId, useState } from "react";
import {
type ComponentProps,
type ReactNode,
startTransition,
useEffect,
useId,
useState,
} from "react";
import { Platform, View } from "react-native";
import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
import { EnvironmentId, type ProjectReadFileResult, ThreadId } from "@t3tools/contracts";
Expand All @@ -20,6 +27,7 @@ import {
} from "@t3tools/shared/filePreview";
import { mediaFileReference } from "@t3tools/client-runtime/media-reference";

import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
import { ScreenContent } from "../../components/ui/ScreenContent";
import { filePreviewDelimiter, parseDelimitedPreview } from "@t3tools/shared/delimitedPreview";
import { DelimitedFilePreview } from "./DelimitedFilePreview";
Expand Down Expand Up @@ -460,6 +468,14 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
const navigation = useNavigation();
const { fileInspector } = useWorkspacePaneState();
const [searchQuery, setSearchQuery] = useState("");
// Push presents with the Files bar first; LegendList mounts after the open frame.
const [treeReady, setTreeReady] = useState(false);
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- defers the file tree until after the Files route open frame
useEffect(() => {
startTransition(() => {
setTreeReady(true);
});
}, []);
Comment thread
wSedlacek marked this conversation as resolved.
Resolved
const { themeAppearance: highlightTheme } = useAppearancePreferences();
const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(
props.route.params,
Expand Down Expand Up @@ -584,28 +600,34 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
onBack={handleReturnToThread}
/>
<ScreenContent>
<FileTreeBrowser
key={JSON.stringify([environmentId, cwd])}
revealSelection={entriesQuery.revealSelection}
expandedPaths={entriesQuery.expandedPaths}
setExpandedPaths={entriesQuery.setExpandedPaths}
entries={entriesQuery.entries}
loadingDirectories={entriesQuery.loadingDirectories}
onLoadDirectory={entriesQuery.loadDirectory}
error={entriesQuery.error}
isPending={entriesQuery.isPending}
isRefreshing={entriesQuery.isRefreshing}
searchQuery={searchQuery}
searchResultPaths={entriesQuery.searchResultPaths}
searchTruncated={entriesQuery.searchTruncated}
selectedPath={entriesQuery.selectedPath}
onPreviewFile={handlePreviewFile}
onRefresh={entriesQuery.refresh}
onSelectFile={(path) => {
entriesQuery.selectPath(path);
handleSelectFile(path);
}}
/>
{treeReady ? (
<FileTreeBrowser
key={JSON.stringify([environmentId, cwd])}
revealSelection={entriesQuery.revealSelection}
expandedPaths={entriesQuery.expandedPaths}
setExpandedPaths={entriesQuery.setExpandedPaths}
entries={entriesQuery.entries}
loadingDirectories={entriesQuery.loadingDirectories}
onLoadDirectory={entriesQuery.loadDirectory}
error={entriesQuery.error}
isPending={entriesQuery.isPending}
isRefreshing={entriesQuery.isRefreshing}
searchQuery={searchQuery}
searchResultPaths={entriesQuery.searchResultPaths}
searchTruncated={entriesQuery.searchTruncated}
selectedPath={entriesQuery.selectedPath}
onPreviewFile={handlePreviewFile}
onRefresh={entriesQuery.refresh}
onSelectFile={(path) => {
entriesQuery.selectPath(path);
handleSelectFile(path);
}}
/>
) : (
<View className="px-4 py-5">
<AppActivityIndicator size="small" accessibilityLabel="Loading files" />
</View>
)}
<FilesToolbarBottomFade />
</ScreenContent>
</View>
Expand Down
17 changes: 15 additions & 2 deletions apps/mobile/src/features/files/thread-file-navigator-pane.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
import { environmentSession } from "../../state/session";
import type { EnvironmentId } from "@t3tools/contracts";
import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
import { AppBar } from "../../components/ui/AppBar";
import { ScreenContent } from "../../components/ui/ScreenContent";
import { useState } from "react";
import { startTransition, useEffect, useState } from "react";
import { View } from "react-native";

import { useEnvironmentQuery } from "../../state/query";
Expand All @@ -20,6 +21,14 @@ export function ThreadFileNavigatorPane(props: {
readonly selectedPath: string | null;
readonly onSelectFile: (path: string) => void;
}) {
// Sheet/column present with the Files bar first; LegendList mounts after the open frame.
const [treeReady, setTreeReady] = useState(false);
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- defers the file tree until after the inspector open frame
useEffect(() => {
startTransition(() => {
setTreeReady(true);
});
}, []);
const [searchQuery, setSearchQuery] = useState("");
const { themeAppearance: highlightTheme } = useAppearancePreferences();
const fileAccessSession = useEnvironmentQuery(
Expand All @@ -46,7 +55,7 @@ export function ThreadFileNavigatorPane(props: {
theme: highlightTheme,
});
};
const fileTree = (
const fileTree = treeReady ? (
<FileTreeBrowser
key={JSON.stringify([props.environmentId, props.cwd])}
revealSelection={entriesQuery.revealSelection}
Expand Down Expand Up @@ -75,6 +84,10 @@ export function ThreadFileNavigatorPane(props: {
props.onSelectFile(path);
}}
/>
) : (
<View className="px-4 py-5">
<AppActivityIndicator size="small" accessibilityLabel="Loading files" />
</View>
);

return (
Expand Down
Loading
Loading