feat: add file tabs and improve terminal rendering with WebGL
This commit is contained in:
@@ -29,6 +29,7 @@ import Markdown from "react-native-markdown-display";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
@@ -81,6 +82,7 @@ import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
import { buildHostWorkspaceFileRoute } from "@/utils/host-routes";
|
||||
|
||||
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
||||
const isToolSequenceItem = (item?: StreamItem) =>
|
||||
@@ -129,6 +131,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const bottomAnchorRef = useRef<View>(null);
|
||||
const { theme } = useUnistyles();
|
||||
const router = useRouter();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const streamRenderStrategy = useMemo(
|
||||
@@ -174,13 +177,12 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
);
|
||||
|
||||
const workspaceRoot = agent.cwd?.trim() || "";
|
||||
const workspaceId = agent.projectPlacement?.checkout?.cwd ?? workspaceRoot;
|
||||
const { requestDirectoryListing, requestFilePreview, selectExplorerEntry } =
|
||||
useFileExplorerActions({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
});
|
||||
const workspaceId = agent.projectPlacement?.checkout?.cwd?.trim() || workspaceRoot;
|
||||
const { requestDirectoryListing } = useFileExplorerActions({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
});
|
||||
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
|
||||
// tracked in react-native-reanimated#8422.
|
||||
const shouldDisableEntryExitAnimations = Platform.OS === "android";
|
||||
@@ -210,14 +212,20 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalized.file) {
|
||||
const route = buildHostWorkspaceFileRoute(
|
||||
resolvedServerId,
|
||||
workspaceId,
|
||||
normalized.file
|
||||
);
|
||||
router.replace(route as any);
|
||||
return;
|
||||
}
|
||||
|
||||
void requestDirectoryListing(normalized.directory, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
if (normalized.file) {
|
||||
selectExplorerEntry(normalized.file);
|
||||
void requestFilePreview(normalized.file);
|
||||
}
|
||||
|
||||
setExplorerTabForCheckout({
|
||||
serverId: resolvedServerId,
|
||||
@@ -229,11 +237,12 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
},
|
||||
[
|
||||
agent.cwd,
|
||||
requestDirectoryListing,
|
||||
requestFilePreview,
|
||||
selectExplorerEntry,
|
||||
setExplorerTabForCheckout,
|
||||
openFileExplorer,
|
||||
requestDirectoryListing,
|
||||
resolvedServerId,
|
||||
router,
|
||||
setExplorerTabForCheckout,
|
||||
workspaceId,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -74,7 +74,13 @@ function SortableItem<T>({
|
||||
// Trigger drag - handled by dnd-kit's listeners
|
||||
};
|
||||
|
||||
const baseTransform = CSS.Transform.toString(transform);
|
||||
// dnd-kit can set `scaleX/scaleY` on the active item when dragging over a
|
||||
// differently-sized droppable. For variable-height rows this can look like
|
||||
// the "ghost" stretches. Keep the dragged item's size stable by zeroing
|
||||
// out the dnd-kit scaling component.
|
||||
const baseTransform = CSS.Transform.toString(
|
||||
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform
|
||||
);
|
||||
const scaleTransform = isDragging ? "scale(1.02)" : "";
|
||||
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ interface ExplorerSidebarProps {
|
||||
workspaceId?: string | null;
|
||||
workspaceRoot: string;
|
||||
isGit: boolean;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
}
|
||||
|
||||
export function ExplorerSidebar({
|
||||
@@ -44,6 +45,7 @@ export function ExplorerSidebar({
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
onOpenFile,
|
||||
}: ExplorerSidebarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isScreenFocused = useIsFocused();
|
||||
@@ -289,6 +291,7 @@ export function ExplorerSidebar({
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isMobile={isMobile}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
@@ -324,6 +327,7 @@ export function ExplorerSidebar({
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isMobile={false}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
@@ -338,6 +342,7 @@ interface SidebarContentProps {
|
||||
workspaceRoot: string;
|
||||
isGit: boolean;
|
||||
isMobile: boolean;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
}
|
||||
|
||||
function SidebarContent({
|
||||
@@ -349,6 +354,7 @@ function SidebarContent({
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
isMobile,
|
||||
onOpenFile,
|
||||
}: SidebarContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const resolvedTab: ExplorerTab =
|
||||
@@ -413,6 +419,7 @@ function SidebarContent({
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -3,18 +3,16 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Image as RNImage,
|
||||
ListRenderItemInfo,
|
||||
type LayoutChangeEvent,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
Pressable,
|
||||
ScrollView as RNScrollView,
|
||||
Text,
|
||||
View,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { ScrollView, Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
@@ -27,11 +25,6 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import {
|
||||
BottomSheetModal,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetBackdrop,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import {
|
||||
Copy,
|
||||
Download,
|
||||
@@ -47,7 +40,6 @@ import {
|
||||
import type {
|
||||
AgentFileExplorerState,
|
||||
ExplorerEntry,
|
||||
ExplorerFile,
|
||||
} from "@/stores/session-store";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -63,7 +55,6 @@ import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
||||
import {
|
||||
usePanelStore,
|
||||
DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
|
||||
type SortOption,
|
||||
} from "@/stores/panel-store";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
@@ -80,10 +71,21 @@ const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
|
||||
const INDENT_PER_LEVEL = 12;
|
||||
|
||||
function formatFileSize({ size }: { size: number }): string {
|
||||
if (size < 1024) {
|
||||
return `${size} B`;
|
||||
}
|
||||
if (size < 1024 * 1024) {
|
||||
return `${(size / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
interface FileExplorerPaneProps {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
workspaceRoot: string;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
}
|
||||
|
||||
interface TreeRow {
|
||||
@@ -95,6 +97,7 @@ export function FileExplorerPane({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
onOpenFile,
|
||||
}: FileExplorerPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile =
|
||||
@@ -132,7 +135,6 @@ export function FileExplorerPane({
|
||||
const {
|
||||
workspaceStateKey: actionsWorkspaceStateKey,
|
||||
requestDirectoryListing,
|
||||
requestFilePreview,
|
||||
requestFileDownloadToken,
|
||||
selectExplorerEntry,
|
||||
} = useFileExplorerActions({
|
||||
@@ -142,23 +144,13 @@ export function FileExplorerPane({
|
||||
});
|
||||
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
||||
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
||||
const splitRatio = usePanelStore((state) => state.explorerFilesSplitRatio);
|
||||
const setSplitRatio = usePanelStore((state) => state.setExplorerFilesSplitRatio);
|
||||
|
||||
const directories = explorerState?.directories ?? new Map();
|
||||
const files = explorerState?.files ?? new Map();
|
||||
const pendingRequest = explorerState?.pendingRequest ?? null;
|
||||
const isExplorerLoading = explorerState?.isLoading ?? false;
|
||||
const error = explorerState?.lastError ?? null;
|
||||
const selectedEntryPath = explorerState?.selectedEntryPath ?? null;
|
||||
|
||||
const preview = selectedEntryPath ? files.get(selectedEntryPath) : null;
|
||||
const isPreviewLoading = Boolean(
|
||||
isExplorerLoading &&
|
||||
pendingRequest?.mode === "file" &&
|
||||
pendingRequest?.path === selectedEntryPath
|
||||
);
|
||||
|
||||
const isDirectoryLoading = useCallback(
|
||||
(path: string) =>
|
||||
Boolean(
|
||||
@@ -168,15 +160,9 @@ export function FileExplorerPane({
|
||||
);
|
||||
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const wasInlinePreviewVisibleRef = useRef(false);
|
||||
const treeListRef = useRef<FlatList<TreeRow>>(null);
|
||||
const treeScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||
|
||||
// Bottom sheet for file preview (mobile)
|
||||
const previewSheetRef = useRef<BottomSheetModal>(null);
|
||||
const previewSnapPoints = useMemo(() => ["70%", "95%"], []);
|
||||
|
||||
const hasInitializedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -222,22 +208,6 @@ export function FileExplorerPane({
|
||||
});
|
||||
}, [directories, hasWorkspaceScope, requestDirectoryListing, selectedEntryPath]);
|
||||
|
||||
// Open/close preview sheet based on selection
|
||||
useEffect(() => {
|
||||
if (!isMobile) {
|
||||
return;
|
||||
}
|
||||
if (selectedEntryPath) {
|
||||
previewSheetRef.current?.present();
|
||||
} else {
|
||||
previewSheetRef.current?.dismiss();
|
||||
}
|
||||
}, [isMobile, selectedEntryPath]);
|
||||
|
||||
const handleClosePreview = useCallback(() => {
|
||||
selectExplorerEntry(null);
|
||||
}, [selectExplorerEntry]);
|
||||
|
||||
const handleToggleDirectory = useCallback(
|
||||
(entry: ExplorerEntry) => {
|
||||
if (!hasWorkspaceScope) {
|
||||
@@ -272,9 +242,9 @@ export function FileExplorerPane({
|
||||
return;
|
||||
}
|
||||
selectExplorerEntry(entry.path);
|
||||
void requestFilePreview(entry.path);
|
||||
onOpenFile?.(entry.path);
|
||||
},
|
||||
[hasWorkspaceScope, requestFilePreview, selectExplorerEntry]
|
||||
[hasWorkspaceScope, onOpenFile, selectExplorerEntry]
|
||||
);
|
||||
|
||||
const handleEntryPress = useCallback(
|
||||
@@ -342,7 +312,6 @@ export function FileExplorerPane({
|
||||
setCurrentPath: false,
|
||||
})
|
||||
),
|
||||
...(selectedEntryPath ? [requestFilePreview(selectedEntryPath)] : []),
|
||||
]);
|
||||
return null;
|
||||
},
|
||||
@@ -420,93 +389,6 @@ export function FileExplorerPane({
|
||||
[explorerState]
|
||||
);
|
||||
|
||||
const shouldShowInlinePreview = !isMobile && Boolean(selectedEntryPath);
|
||||
const minTreeWidth = 220;
|
||||
const minPreviewWidth = 320;
|
||||
|
||||
const safeSplitRatio = Number.isFinite(splitRatio)
|
||||
? splitRatio
|
||||
: DEFAULT_EXPLORER_FILES_SPLIT_RATIO;
|
||||
|
||||
const splitAvailableWidth = useSharedValue(0);
|
||||
const splitMaxTreeWidth = useSharedValue(minTreeWidth);
|
||||
const splitTreeWidth = useSharedValue(minTreeWidth);
|
||||
const splitStartTreeWidth = useSharedValue(minTreeWidth);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShowInlinePreview) {
|
||||
wasInlinePreviewVisibleRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (containerWidth <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const available = Math.max(0, containerWidth);
|
||||
const maxTree = Math.max(minTreeWidth, available - minPreviewWidth);
|
||||
const isOpeningInlinePreview = !wasInlinePreviewVisibleRef.current;
|
||||
const desired = isOpeningInlinePreview
|
||||
? Math.round(available * safeSplitRatio)
|
||||
: splitTreeWidth.value;
|
||||
const clamped = Math.max(minTreeWidth, Math.min(maxTree, desired));
|
||||
|
||||
splitAvailableWidth.value = available;
|
||||
splitMaxTreeWidth.value = maxTree;
|
||||
splitTreeWidth.value = clamped;
|
||||
wasInlinePreviewVisibleRef.current = true;
|
||||
}, [
|
||||
containerWidth,
|
||||
minPreviewWidth,
|
||||
minTreeWidth,
|
||||
safeSplitRatio,
|
||||
shouldShowInlinePreview,
|
||||
splitAvailableWidth,
|
||||
splitMaxTreeWidth,
|
||||
splitTreeWidth,
|
||||
]);
|
||||
|
||||
const treePaneAnimatedStyle = useAnimatedStyle(() => ({
|
||||
width: splitTreeWidth.value,
|
||||
flexBasis: splitTreeWidth.value,
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
}));
|
||||
|
||||
const splitResizeGesture = useMemo(() => {
|
||||
if (isMobile || !shouldShowInlinePreview) {
|
||||
return Gesture.Pan().enabled(false);
|
||||
}
|
||||
|
||||
return Gesture.Pan()
|
||||
.hitSlop({ left: 12, right: 12, top: 0, bottom: 0 })
|
||||
.onStart(() => {
|
||||
splitStartTreeWidth.value = splitTreeWidth.value;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
const nextWidth = splitStartTreeWidth.value - event.translationX;
|
||||
const clamped = Math.max(
|
||||
minTreeWidth,
|
||||
Math.min(splitMaxTreeWidth.value, nextWidth)
|
||||
);
|
||||
splitTreeWidth.value = clamped;
|
||||
})
|
||||
.onEnd(() => {
|
||||
const available = splitAvailableWidth.value;
|
||||
const ratio = available > 0 ? splitTreeWidth.value / available : safeSplitRatio;
|
||||
runOnJS(setSplitRatio)(ratio);
|
||||
});
|
||||
}, [
|
||||
isMobile,
|
||||
minTreeWidth,
|
||||
safeSplitRatio,
|
||||
setSplitRatio,
|
||||
shouldShowInlinePreview,
|
||||
splitAvailableWidth,
|
||||
splitMaxTreeWidth,
|
||||
splitStartTreeWidth,
|
||||
splitTreeWidth,
|
||||
]);
|
||||
|
||||
const renderTreeRow = useCallback(
|
||||
({ item }: ListRenderItemInfo<TreeRow>) => {
|
||||
const entry = item.entry;
|
||||
@@ -606,27 +488,6 @@ export function FileExplorerPane({
|
||||
]
|
||||
);
|
||||
|
||||
const handlePreviewSheetChange = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
handleClosePreview();
|
||||
}
|
||||
},
|
||||
[handleClosePreview]
|
||||
);
|
||||
|
||||
const renderPreviewBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
opacity={0.5}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleBackFromError = useCallback(() => {
|
||||
if (!hasWorkspaceScope) {
|
||||
return;
|
||||
@@ -667,7 +528,6 @@ export function FileExplorerPane({
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
onLayout={(event) => setContainerWidth(event.nativeEvent.layout.width)}
|
||||
>
|
||||
{error ? (
|
||||
<View style={styles.centerState}>
|
||||
@@ -701,385 +561,66 @@ export function FileExplorerPane({
|
||||
<Text style={styles.emptyText}>No files</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.desktopSplit}>
|
||||
{shouldShowInlinePreview ? (
|
||||
<View style={styles.previewPane}>
|
||||
<View style={styles.paneHeader} testID="preview-pane-header">
|
||||
<Text style={styles.previewHeaderText} numberOfLines={1}>
|
||||
{selectedEntryPath?.split("/").pop() ?? "Preview"}
|
||||
</Text>
|
||||
<View style={styles.previewHeaderRight}>
|
||||
{isPreviewLoading ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
) : null}
|
||||
<Pressable
|
||||
onPress={handleClosePreview}
|
||||
hitSlop={8}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.iconButton,
|
||||
(hovered || pressed) && styles.iconButtonHovered,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Close preview"
|
||||
>
|
||||
<X size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<FilePreviewBody
|
||||
preview={preview}
|
||||
isLoading={isPreviewLoading}
|
||||
variant="inline"
|
||||
showDesktopWebScrollbar={showDesktopWebScrollbar}
|
||||
/>
|
||||
<View style={[styles.treePane, styles.treePaneFill]}>
|
||||
<View style={styles.paneHeader} testID="files-pane-header">
|
||||
<View style={styles.paneHeaderLeft} />
|
||||
<View style={styles.paneHeaderRight}>
|
||||
<Pressable
|
||||
onPress={handleRefresh}
|
||||
disabled={isRefreshFetching}
|
||||
hitSlop={8}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.iconButton,
|
||||
(hovered || pressed) && styles.iconButtonHovered,
|
||||
pressed && styles.iconButtonPressed,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Refresh files"
|
||||
>
|
||||
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
|
||||
<RotateCw size={16} color={theme.colors.foregroundMuted} />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
|
||||
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{shouldShowInlinePreview ? (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.treePane,
|
||||
styles.treePaneWithPreview,
|
||||
{ minWidth: minTreeWidth },
|
||||
treePaneAnimatedStyle,
|
||||
]}
|
||||
>
|
||||
<GestureDetector gesture={splitResizeGesture}>
|
||||
<View
|
||||
style={[
|
||||
styles.splitResizeHandle,
|
||||
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
|
||||
Platform.OS === "web" && ({ touchAction: "none", userSelect: "none" } as any),
|
||||
]}
|
||||
/>
|
||||
</GestureDetector>
|
||||
<View style={styles.paneHeader} testID="files-pane-header">
|
||||
<View style={styles.paneHeaderLeft} />
|
||||
<View style={styles.paneHeaderRight}>
|
||||
<Pressable
|
||||
onPress={handleRefresh}
|
||||
disabled={isRefreshFetching}
|
||||
hitSlop={8}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.iconButton,
|
||||
(hovered || pressed) && styles.iconButtonHovered,
|
||||
pressed && styles.iconButtonPressed,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Refresh files"
|
||||
>
|
||||
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
|
||||
<RotateCw size={16} color={theme.colors.foregroundMuted} />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
|
||||
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<FlatList
|
||||
ref={treeListRef}
|
||||
style={styles.treeList}
|
||||
data={treeRows}
|
||||
renderItem={renderTreeRow}
|
||||
keyExtractor={(row) => row.entry.path}
|
||||
testID="file-explorer-tree-scroll"
|
||||
contentContainerStyle={styles.entriesContent}
|
||||
onLayout={
|
||||
showDesktopWebScrollbar ? handleTreeListLayout : undefined
|
||||
}
|
||||
onScroll={
|
||||
showDesktopWebScrollbar ? handleTreeListScroll : undefined
|
||||
}
|
||||
onContentSizeChange={
|
||||
showDesktopWebScrollbar
|
||||
? treeScrollbarMetrics.onContentSizeChange
|
||||
: undefined
|
||||
}
|
||||
scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
initialNumToRender={24}
|
||||
maxToRenderPerBatch={40}
|
||||
windowSize={12}
|
||||
/>
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={showDesktopWebScrollbar}
|
||||
metrics={treeScrollbarMetrics}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
treeListRef.current?.scrollToOffset({
|
||||
offset: nextOffset,
|
||||
animated: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Animated.View>
|
||||
) : (
|
||||
<View style={[styles.treePane, styles.treePaneFill]}>
|
||||
<View style={styles.paneHeader} testID="files-pane-header">
|
||||
<View style={styles.paneHeaderLeft} />
|
||||
<View style={styles.paneHeaderRight}>
|
||||
<Pressable
|
||||
onPress={handleRefresh}
|
||||
disabled={isRefreshFetching}
|
||||
hitSlop={8}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.iconButton,
|
||||
(hovered || pressed) && styles.iconButtonHovered,
|
||||
pressed && styles.iconButtonPressed,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Refresh files"
|
||||
>
|
||||
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
|
||||
<RotateCw size={16} color={theme.colors.foregroundMuted} />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
|
||||
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<FlatList
|
||||
ref={treeListRef}
|
||||
style={styles.treeList}
|
||||
data={treeRows}
|
||||
renderItem={renderTreeRow}
|
||||
keyExtractor={(row) => row.entry.path}
|
||||
testID="file-explorer-tree-scroll"
|
||||
contentContainerStyle={styles.entriesContent}
|
||||
onLayout={
|
||||
showDesktopWebScrollbar ? handleTreeListLayout : undefined
|
||||
}
|
||||
onScroll={
|
||||
showDesktopWebScrollbar ? handleTreeListScroll : undefined
|
||||
}
|
||||
onContentSizeChange={
|
||||
showDesktopWebScrollbar
|
||||
? treeScrollbarMetrics.onContentSizeChange
|
||||
: undefined
|
||||
}
|
||||
scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
initialNumToRender={24}
|
||||
maxToRenderPerBatch={40}
|
||||
windowSize={12}
|
||||
/>
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={showDesktopWebScrollbar}
|
||||
metrics={treeScrollbarMetrics}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
treeListRef.current?.scrollToOffset({
|
||||
offset: nextOffset,
|
||||
animated: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<FlatList
|
||||
ref={treeListRef}
|
||||
style={styles.treeList}
|
||||
data={treeRows}
|
||||
renderItem={renderTreeRow}
|
||||
keyExtractor={(row) => row.entry.path}
|
||||
testID="file-explorer-tree-scroll"
|
||||
contentContainerStyle={styles.entriesContent}
|
||||
onLayout={showDesktopWebScrollbar ? handleTreeListLayout : undefined}
|
||||
onScroll={showDesktopWebScrollbar ? handleTreeListScroll : undefined}
|
||||
onContentSizeChange={
|
||||
showDesktopWebScrollbar ? treeScrollbarMetrics.onContentSizeChange : undefined
|
||||
}
|
||||
scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
initialNumToRender={24}
|
||||
maxToRenderPerBatch={40}
|
||||
windowSize={12}
|
||||
/>
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={showDesktopWebScrollbar}
|
||||
metrics={treeScrollbarMetrics}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
treeListRef.current?.scrollToOffset({
|
||||
offset: nextOffset,
|
||||
animated: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isMobile ? (
|
||||
<BottomSheetModal
|
||||
ref={previewSheetRef}
|
||||
snapPoints={previewSnapPoints}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
onChange={handlePreviewSheetChange}
|
||||
backdropComponent={renderPreviewBackdrop}
|
||||
enablePanDownToClose
|
||||
backgroundStyle={styles.sheetBackground}
|
||||
handleIndicatorStyle={styles.handleIndicator}
|
||||
>
|
||||
<View style={styles.sheetHeader}>
|
||||
<Text style={styles.sheetTitle} numberOfLines={1}>
|
||||
{selectedEntryPath?.split("/").pop() ?? "Preview"}
|
||||
</Text>
|
||||
<Pressable onPress={handleClosePreview} style={styles.sheetCloseButton}>
|
||||
<X size={20} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<FilePreviewBody
|
||||
preview={preview}
|
||||
isLoading={isPreviewLoading}
|
||||
variant="sheet"
|
||||
showDesktopWebScrollbar={false}
|
||||
/>
|
||||
</BottomSheetModal>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function FilePreviewBody({
|
||||
preview,
|
||||
isLoading,
|
||||
variant,
|
||||
showDesktopWebScrollbar,
|
||||
}: {
|
||||
preview: ExplorerFile | null;
|
||||
isLoading: boolean;
|
||||
variant: "inline" | "sheet";
|
||||
showDesktopWebScrollbar: boolean;
|
||||
}) {
|
||||
const enablePreviewDesktopScrollbar =
|
||||
variant === "inline" && showDesktopWebScrollbar;
|
||||
const previewScrollRef = useRef<RNScrollView>(null);
|
||||
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||
|
||||
const handlePreviewScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (enablePreviewDesktopScrollbar) {
|
||||
previewScrollbarMetrics.onScroll(event);
|
||||
}
|
||||
},
|
||||
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
|
||||
);
|
||||
|
||||
const handlePreviewLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
if (enablePreviewDesktopScrollbar) {
|
||||
previewScrollbarMetrics.onLayout(event);
|
||||
}
|
||||
},
|
||||
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
|
||||
);
|
||||
|
||||
if (isLoading && !preview) {
|
||||
return (
|
||||
<View style={styles.sheetCenterState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.loadingText}>Loading file…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!preview) {
|
||||
return (
|
||||
<View style={styles.sheetCenterState}>
|
||||
<Text style={styles.emptyText}>No preview available</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.kind === "text") {
|
||||
if (variant === "sheet") {
|
||||
return (
|
||||
<BottomSheetScrollView style={styles.previewContent}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
contentContainerStyle={styles.previewCodeScrollContent}
|
||||
>
|
||||
<Text style={styles.codeText}>{preview.content}</Text>
|
||||
</ScrollView>
|
||||
</BottomSheetScrollView>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.previewScrollContainer}>
|
||||
<RNScrollView
|
||||
ref={previewScrollRef}
|
||||
style={styles.previewContent}
|
||||
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
|
||||
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
|
||||
onContentSizeChange={
|
||||
enablePreviewDesktopScrollbar
|
||||
? previewScrollbarMetrics.onContentSizeChange
|
||||
: undefined
|
||||
}
|
||||
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
|
||||
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
|
||||
>
|
||||
<RNScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
contentContainerStyle={styles.previewCodeScrollContent}
|
||||
>
|
||||
<Text style={styles.codeText}>{preview.content}</Text>
|
||||
</RNScrollView>
|
||||
</RNScrollView>
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={enablePreviewDesktopScrollbar}
|
||||
metrics={previewScrollbarMetrics}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.kind === "image" && preview.content) {
|
||||
if (variant === "sheet") {
|
||||
return (
|
||||
<BottomSheetScrollView contentContainerStyle={styles.previewImageScrollContent}>
|
||||
<RNImage
|
||||
source={{
|
||||
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
|
||||
}}
|
||||
style={styles.previewImage}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</BottomSheetScrollView>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.previewScrollContainer}>
|
||||
<RNScrollView
|
||||
ref={previewScrollRef}
|
||||
style={styles.previewContent}
|
||||
contentContainerStyle={styles.previewImageScrollContent}
|
||||
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
|
||||
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
|
||||
onContentSizeChange={
|
||||
enablePreviewDesktopScrollbar
|
||||
? previewScrollbarMetrics.onContentSizeChange
|
||||
: undefined
|
||||
}
|
||||
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
|
||||
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
|
||||
>
|
||||
<RNImage
|
||||
source={{
|
||||
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
|
||||
}}
|
||||
style={styles.previewImage}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</RNScrollView>
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={enablePreviewDesktopScrollbar}
|
||||
metrics={previewScrollbarMetrics}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.sheetCenterState}>
|
||||
<Text style={styles.emptyText}>Binary preview unavailable</Text>
|
||||
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function formatFileSize({ size }: { size: number }): string {
|
||||
if (size < 1024) {
|
||||
return `${size} B`;
|
||||
}
|
||||
if (size < 1024 * 1024) {
|
||||
return `${(size / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
type EntryDisplayKind = "directory" | "image" | "text" | "other";
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image as RNImage,
|
||||
ScrollView as RNScrollView,
|
||||
Text,
|
||||
View,
|
||||
Platform,
|
||||
type LayoutChangeEvent,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
} from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||
import {
|
||||
WebDesktopScrollbarOverlay,
|
||||
useWebDesktopScrollbarMetrics,
|
||||
} from "@/components/web-desktop-scrollbar";
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function formatFileSize({ size }: { size: number }): string {
|
||||
if (size < 1024) {
|
||||
return `${size} B`;
|
||||
}
|
||||
if (size < 1024 * 1024) {
|
||||
return `${(size / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function FilePreviewBody({
|
||||
preview,
|
||||
isLoading,
|
||||
showDesktopWebScrollbar,
|
||||
}: {
|
||||
preview: ExplorerFile | null;
|
||||
isLoading: boolean;
|
||||
showDesktopWebScrollbar: boolean;
|
||||
}) {
|
||||
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar;
|
||||
const previewScrollRef = useRef<RNScrollView>(null);
|
||||
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||
|
||||
const handlePreviewScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (enablePreviewDesktopScrollbar) {
|
||||
previewScrollbarMetrics.onScroll(event);
|
||||
}
|
||||
},
|
||||
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
|
||||
);
|
||||
|
||||
const handlePreviewLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
if (enablePreviewDesktopScrollbar) {
|
||||
previewScrollbarMetrics.onLayout(event);
|
||||
}
|
||||
},
|
||||
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
|
||||
);
|
||||
|
||||
if (isLoading && !preview) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.loadingText}>Loading file…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!preview) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.emptyText}>No preview available</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.kind === "text") {
|
||||
return (
|
||||
<View style={styles.previewScrollContainer}>
|
||||
<RNScrollView
|
||||
ref={previewScrollRef}
|
||||
style={styles.previewContent}
|
||||
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
|
||||
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
|
||||
onContentSizeChange={
|
||||
enablePreviewDesktopScrollbar
|
||||
? previewScrollbarMetrics.onContentSizeChange
|
||||
: undefined
|
||||
}
|
||||
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
|
||||
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
|
||||
>
|
||||
<RNScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
contentContainerStyle={styles.previewCodeScrollContent}
|
||||
>
|
||||
<Text style={styles.codeText}>{preview.content}</Text>
|
||||
</RNScrollView>
|
||||
</RNScrollView>
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={enablePreviewDesktopScrollbar}
|
||||
metrics={previewScrollbarMetrics}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.kind === "image" && preview.content) {
|
||||
return (
|
||||
<View style={styles.previewScrollContainer}>
|
||||
<RNScrollView
|
||||
ref={previewScrollRef}
|
||||
style={styles.previewContent}
|
||||
contentContainerStyle={styles.previewImageScrollContent}
|
||||
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
|
||||
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
|
||||
onContentSizeChange={
|
||||
enablePreviewDesktopScrollbar
|
||||
? previewScrollbarMetrics.onContentSizeChange
|
||||
: undefined
|
||||
}
|
||||
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
|
||||
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
|
||||
>
|
||||
<RNImage
|
||||
source={{
|
||||
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
|
||||
}}
|
||||
style={styles.previewImage}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</RNScrollView>
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={enablePreviewDesktopScrollbar}
|
||||
metrics={previewScrollbarMetrics}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.emptyText}>Binary preview unavailable</Text>
|
||||
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilePane({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
filePath,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceRoot: string;
|
||||
filePath: string;
|
||||
}) {
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||
const normalizedFilePath = useMemo(() => trimNonEmpty(filePath), [filePath]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["workspaceFile", serverId, normalizedWorkspaceRoot, normalizedFilePath],
|
||||
enabled: Boolean(client && normalizedWorkspaceRoot && normalizedFilePath),
|
||||
queryFn: async () => {
|
||||
if (!client || !normalizedWorkspaceRoot || !normalizedFilePath) {
|
||||
return { file: null as ExplorerFile | null, error: "Host is not connected" };
|
||||
}
|
||||
const payload = await client.exploreFileSystem(
|
||||
normalizedWorkspaceRoot,
|
||||
normalizedFilePath,
|
||||
"file"
|
||||
);
|
||||
return { file: payload.file ?? null, error: payload.error ?? null };
|
||||
},
|
||||
staleTime: 5_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="workspace-file-pane">
|
||||
{query.data?.error ? (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.errorText}>{query.data.error}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<FilePreviewBody
|
||||
preview={query.data?.file ?? null}
|
||||
isLoading={query.isFetching}
|
||||
showDesktopWebScrollbar={showDesktopWebScrollbar}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
centerState: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: theme.spacing[2],
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
errorText: {
|
||||
color: theme.colors.destructive,
|
||||
fontSize: theme.fontSize.sm,
|
||||
textAlign: "center",
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
textAlign: "center",
|
||||
},
|
||||
binaryMetaText: {
|
||||
marginTop: theme.spacing[2],
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
previewScrollContainer: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
previewContent: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
previewCodeScrollContent: {
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
codeText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.45,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
previewImageScrollContent: {
|
||||
flexGrow: 1,
|
||||
padding: theme.spacing[4],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
previewImage: {
|
||||
width: "100%",
|
||||
height: 420,
|
||||
},
|
||||
}));
|
||||
@@ -57,7 +57,10 @@ function SortableItem<T>({
|
||||
// This is a no-op but matches the mobile API
|
||||
}, []);
|
||||
|
||||
const baseTransform = CSS.Transform.toString(transform);
|
||||
// See `draggable-list.web.tsx` for details on why we zero out dnd-kit scale.
|
||||
const baseTransform = CSS.Transform.toString(
|
||||
transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform
|
||||
);
|
||||
const scaleTransform = isDragging ? "scale(1.01)" : "";
|
||||
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { DOMProps } from "expo/dom";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import type { ITheme } from "@xterm/xterm";
|
||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
||||
|
||||
@@ -12,10 +13,9 @@ interface TerminalEmulatorProps {
|
||||
initialOutputText: string;
|
||||
outputChunkText: string;
|
||||
outputChunkSequence: number;
|
||||
outputChunkReplay?: boolean;
|
||||
testId?: string;
|
||||
backgroundColor?: string;
|
||||
foregroundColor?: string;
|
||||
cursorColor?: string;
|
||||
xtermTheme?: ITheme;
|
||||
swipeGesturesEnabled?: boolean;
|
||||
onSwipeLeft?: () => void;
|
||||
onSwipeRight?: () => void;
|
||||
@@ -44,10 +44,13 @@ export default function TerminalEmulator({
|
||||
initialOutputText,
|
||||
outputChunkText,
|
||||
outputChunkSequence,
|
||||
outputChunkReplay = false,
|
||||
testId = "terminal-surface",
|
||||
backgroundColor = "#0b0b0b",
|
||||
foregroundColor = "#e6e6e6",
|
||||
cursorColor = "#e6e6e6",
|
||||
xtermTheme = {
|
||||
background: "#0b0b0b",
|
||||
foreground: "#e6e6e6",
|
||||
cursor: "#e6e6e6",
|
||||
},
|
||||
swipeGesturesEnabled = false,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
@@ -203,11 +206,7 @@ export default function TerminalEmulator({
|
||||
root,
|
||||
host,
|
||||
initialOutputText,
|
||||
theme: {
|
||||
backgroundColor,
|
||||
foregroundColor,
|
||||
cursorColor,
|
||||
},
|
||||
theme: xtermTheme,
|
||||
});
|
||||
appliedInitialOutputRef.current = initialOutputText;
|
||||
|
||||
@@ -218,7 +217,7 @@ export default function TerminalEmulator({
|
||||
}
|
||||
appliedInitialOutputRef.current = null;
|
||||
};
|
||||
}, [backgroundColor, cursorColor, foregroundColor, streamKey]);
|
||||
}, [streamKey, xtermTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
const runtime = runtimeRef.current;
|
||||
@@ -273,11 +272,12 @@ export default function TerminalEmulator({
|
||||
}
|
||||
runtime.write({
|
||||
text: outputChunkText,
|
||||
suppressInput: outputChunkReplay,
|
||||
onCommitted: () => {
|
||||
onOutputChunkConsumed?.(outputChunkSequence);
|
||||
},
|
||||
});
|
||||
}, [onOutputChunkConsumed, outputChunkSequence, outputChunkText]);
|
||||
}, [onOutputChunkConsumed, outputChunkReplay, outputChunkSequence, outputChunkText]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusRequestToken <= 0) {
|
||||
@@ -304,7 +304,7 @@ export default function TerminalEmulator({
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
backgroundColor,
|
||||
backgroundColor: xtermTheme.background ?? "#0b0b0b",
|
||||
overflow: "hidden",
|
||||
overscrollBehavior: "none",
|
||||
touchAction: "pan-y",
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
terminalDebugLog,
|
||||
} from "@/terminal/runtime/terminal-debug";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { toXtermTheme } from "@/utils/to-xterm-theme";
|
||||
import TerminalEmulator from "./terminal-emulator";
|
||||
|
||||
interface TerminalPaneProps {
|
||||
@@ -84,6 +85,7 @@ type ModifierState = {
|
||||
type TerminalOutputChunkState = {
|
||||
sequence: number;
|
||||
text: string;
|
||||
replay: boolean;
|
||||
};
|
||||
|
||||
type PendingTerminalInput =
|
||||
@@ -146,6 +148,7 @@ export function TerminalPane({
|
||||
manageTerminalDirectorySubscription = true,
|
||||
}: TerminalPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]);
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
@@ -178,6 +181,7 @@ export function TerminalPane({
|
||||
const [selectedOutputChunk, setSelectedOutputChunk] = useState<TerminalOutputChunkState>({
|
||||
sequence: 0,
|
||||
text: "",
|
||||
replay: false,
|
||||
});
|
||||
const [selectedOutputSnapshot, setSelectedOutputSnapshot] = useState("");
|
||||
const [activeStream, setActiveStream] = useState<{
|
||||
@@ -600,8 +604,8 @@ export function TerminalPane({
|
||||
const controller = new TerminalStreamController({
|
||||
client,
|
||||
getPreferredSize: () => lastReportedSizeRef.current,
|
||||
onChunk: ({ terminalId, text }) => {
|
||||
outputPump.append({ terminalId, text });
|
||||
onChunk: ({ terminalId, text, replay }) => {
|
||||
outputPump.append({ terminalId, text, replay });
|
||||
},
|
||||
onReset: ({ terminalId }) => {
|
||||
outputPump.clearTerminal({ terminalId });
|
||||
@@ -626,7 +630,7 @@ export function TerminalPane({
|
||||
useEffect(() => {
|
||||
outputDeliveryQueueRef.current?.reset();
|
||||
pendingTerminalInputRef.current = [];
|
||||
setSelectedOutputChunk({ sequence: 0, text: "" });
|
||||
setSelectedOutputChunk({ sequence: 0, text: "", replay: false });
|
||||
outputPumpRef.current?.setSelectedTerminal({
|
||||
terminalId: selectedTerminalId,
|
||||
});
|
||||
@@ -1122,10 +1126,9 @@ export function TerminalPane({
|
||||
initialOutputText={selectedOutputSnapshot}
|
||||
outputChunkText={selectedOutputChunk.text}
|
||||
outputChunkSequence={selectedOutputChunk.sequence}
|
||||
outputChunkReplay={selectedOutputChunk.replay}
|
||||
testId="terminal-surface"
|
||||
backgroundColor={theme.colors.background}
|
||||
foregroundColor={theme.colors.foreground}
|
||||
cursorColor={theme.colors.foreground}
|
||||
xtermTheme={xtermTheme}
|
||||
swipeGesturesEnabled={swipeGesturesEnabled}
|
||||
onSwipeRight={() => {
|
||||
if (!swipeGesturesEnabled) {
|
||||
|
||||
Reference in New Issue
Block a user