feat: add file tabs and improve terminal rendering with WebGL
This commit is contained in:
Generated
+16
-2
@@ -6434,6 +6434,7 @@
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
@@ -7210,6 +7211,18 @@
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-unicode11": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz",
|
||||
"integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-webgl": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
|
||||
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/headless": {
|
||||
"version": "6.0.0",
|
||||
"license": "MIT",
|
||||
@@ -21586,6 +21599,8 @@
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tauri-apps/api": "^2.9.1",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"base64-js": "^1.5.1",
|
||||
"buffer": "^6.0.3",
|
||||
@@ -21726,8 +21741,7 @@
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.16",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.6",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "^2.9.6"
|
||||
"@tauri-apps/cli": "^2.9.6"
|
||||
}
|
||||
},
|
||||
"packages/relay": {
|
||||
|
||||
@@ -52,6 +52,8 @@
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tauri-apps/api": "^2.9.1",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"base64-js": "^1.5.1",
|
||||
"buffer": "^6.0.3",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||
import { decodeFilePathFromPathSegment } from "@/utils/host-routes";
|
||||
|
||||
export default function HostWorkspaceFileRoute() {
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string;
|
||||
workspaceId?: string;
|
||||
fileId?: string;
|
||||
}>();
|
||||
|
||||
const fileId = typeof params.fileId === "string" ? params.fileId : "";
|
||||
const filePath = fileId ? decodeFilePathFromPathSegment(fileId) ?? "" : "";
|
||||
|
||||
return (
|
||||
<WorkspaceScreen
|
||||
serverId={typeof params.serverId === "string" ? params.serverId : ""}
|
||||
workspaceId={typeof params.workspaceId === "string" ? params.workspaceId : ""}
|
||||
routeTab={filePath ? { kind: "file", path: filePath } : null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -14,11 +14,13 @@ import * as Clipboard from "expo-clipboard";
|
||||
import {
|
||||
Bot,
|
||||
ChevronDown,
|
||||
FileText,
|
||||
Folder,
|
||||
GitBranch,
|
||||
MoreVertical,
|
||||
PanelRight,
|
||||
Plus,
|
||||
SquareTerminal,
|
||||
Terminal,
|
||||
X,
|
||||
} from "lucide-react-native";
|
||||
@@ -45,6 +47,7 @@ import {
|
||||
} from "@/components/ui/context-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
||||
import { FilePane } from "@/components/file-pane";
|
||||
import { TerminalPane } from "@/components/terminal-pane";
|
||||
import { SortableInlineList } from "@/components/sortable-inline-list";
|
||||
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||
@@ -52,6 +55,7 @@ import { useToast } from "@/contexts/toast-context";
|
||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||
import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { useWorkspaceFileTabsStore } from "@/stores/workspace-file-tabs-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
useWorkspaceTabsStore,
|
||||
@@ -61,7 +65,9 @@ import {
|
||||
buildHostAgentDetailRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
buildHostWorkspaceAgentRoute,
|
||||
buildHostWorkspaceFileRoute,
|
||||
buildHostWorkspaceTerminalRoute,
|
||||
encodeFilePathForPathSegment,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
} from "@/utils/host-routes";
|
||||
import { buildNewAgentRoute } from "@/utils/new-agent-routing";
|
||||
@@ -84,6 +90,7 @@ const DROPDOWN_WIDTH = 220;
|
||||
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
|
||||
const NEW_TAB_TERMINAL_OPTION_ID = "__new_tab_terminal__";
|
||||
const EMPTY_TAB_ORDER: string[] = [];
|
||||
const EMPTY_OPEN_FILE_PATHS: string[] = [];
|
||||
|
||||
type TabAvailability = "available" | "invalid" | "unknown";
|
||||
|
||||
@@ -110,6 +117,13 @@ type WorkspaceTabDescriptor =
|
||||
terminalId: string;
|
||||
label: string;
|
||||
subtitle: string;
|
||||
}
|
||||
| {
|
||||
key: string;
|
||||
kind: "file";
|
||||
filePath: string;
|
||||
label: string;
|
||||
subtitle: string;
|
||||
};
|
||||
|
||||
function applyWorkspaceTabOrder(input: {
|
||||
@@ -219,6 +233,13 @@ function normalizeWorkspaceTab(
|
||||
}
|
||||
return { kind: "terminal", terminalId };
|
||||
}
|
||||
if (value.kind === "file") {
|
||||
const path = trimNonEmpty(value.path);
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
return { kind: "file", path: path.replace(/\\/g, "/") };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -235,6 +256,9 @@ function tabEquals(left: WorkspaceTabTarget | null, right: WorkspaceTabTarget |
|
||||
if (left.kind === "terminal" && right.kind === "terminal") {
|
||||
return left.terminalId === right.terminalId;
|
||||
}
|
||||
if (left.kind === "file" && right.kind === "file") {
|
||||
return left.path === right.path;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -250,6 +274,13 @@ function buildTabRoute(input: {
|
||||
input.tab.agentId
|
||||
);
|
||||
}
|
||||
if (input.tab.kind === "file") {
|
||||
return buildHostWorkspaceFileRoute(
|
||||
input.serverId,
|
||||
input.workspaceId,
|
||||
input.tab.path
|
||||
);
|
||||
}
|
||||
return buildHostWorkspaceTerminalRoute(
|
||||
input.serverId,
|
||||
input.workspaceId,
|
||||
@@ -270,6 +301,9 @@ function resolveTabAvailability(input: {
|
||||
}
|
||||
return input.agentsById.has(input.tab.agentId) ? "available" : "invalid";
|
||||
}
|
||||
if (input.tab.kind === "file") {
|
||||
return "available";
|
||||
}
|
||||
if (!input.terminalsHydrated) {
|
||||
return "unknown";
|
||||
}
|
||||
@@ -293,6 +327,9 @@ function toWorkspaceTabTarget(
|
||||
if (tab.kind === "agent") {
|
||||
return { kind: "agent", agentId: tab.agentId };
|
||||
}
|
||||
if (tab.kind === "file") {
|
||||
return { kind: "file", path: tab.filePath };
|
||||
}
|
||||
return { kind: "terminal", terminalId: tab.terminalId };
|
||||
}
|
||||
|
||||
@@ -566,33 +603,6 @@ function WorkspaceScreenContent({
|
||||
return map;
|
||||
}, [workspaceAgents]);
|
||||
|
||||
const baseTabs = useMemo<WorkspaceTabDescriptor[]>(() => {
|
||||
const next: WorkspaceTabDescriptor[] = [];
|
||||
|
||||
for (const agent of workspaceAgents) {
|
||||
next.push({
|
||||
key: `agent:${agent.id}`,
|
||||
kind: "agent",
|
||||
agentId: agent.id,
|
||||
provider: agent.provider,
|
||||
label: agent.title?.trim() || "New agent",
|
||||
subtitle: `${formatProviderLabel(agent.provider)} agent`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const terminal of terminals) {
|
||||
next.push({
|
||||
key: `terminal:${terminal.id}`,
|
||||
kind: "terminal",
|
||||
terminalId: terminal.id,
|
||||
label: terminal.name,
|
||||
subtitle: "Terminal",
|
||||
});
|
||||
}
|
||||
|
||||
return next;
|
||||
}, [terminals, workspaceAgents]);
|
||||
|
||||
const terminalIds = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const terminal of terminals) {
|
||||
@@ -628,6 +638,64 @@ function WorkspaceScreenContent({
|
||||
);
|
||||
const setTabOrder = useWorkspaceTabsStore((state) => state.setTabOrder);
|
||||
|
||||
const openFileTab = useWorkspaceFileTabsStore((state) => state.openFileTab);
|
||||
const closeFileTab = useWorkspaceFileTabsStore((state) => state.closeFileTab);
|
||||
|
||||
useEffect(() => {
|
||||
if (requestedTab?.kind !== "file") {
|
||||
return;
|
||||
}
|
||||
openFileTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
filePath: requestedTab.path,
|
||||
});
|
||||
}, [normalizedServerId, normalizedWorkspaceId, openFileTab, requestedTab]);
|
||||
|
||||
const openFilePaths = useWorkspaceFileTabsStore((state) =>
|
||||
persistenceKey
|
||||
? state.openFilePathsByWorkspace[persistenceKey] ?? EMPTY_OPEN_FILE_PATHS
|
||||
: EMPTY_OPEN_FILE_PATHS
|
||||
);
|
||||
|
||||
const baseTabs = useMemo<WorkspaceTabDescriptor[]>(() => {
|
||||
const next: WorkspaceTabDescriptor[] = [];
|
||||
|
||||
for (const agent of workspaceAgents) {
|
||||
next.push({
|
||||
key: `agent:${agent.id}`,
|
||||
kind: "agent",
|
||||
agentId: agent.id,
|
||||
provider: agent.provider,
|
||||
label: agent.title?.trim() || "New agent",
|
||||
subtitle: `${formatProviderLabel(agent.provider)} agent`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const terminal of terminals) {
|
||||
next.push({
|
||||
key: `terminal:${terminal.id}`,
|
||||
kind: "terminal",
|
||||
terminalId: terminal.id,
|
||||
label: terminal.name,
|
||||
subtitle: "Terminal",
|
||||
});
|
||||
}
|
||||
|
||||
for (const filePath of openFilePaths) {
|
||||
const fileName = filePath.split("/").filter(Boolean).pop() ?? filePath;
|
||||
next.push({
|
||||
key: `file:${filePath}`,
|
||||
kind: "file",
|
||||
filePath,
|
||||
label: fileName,
|
||||
subtitle: filePath,
|
||||
});
|
||||
}
|
||||
|
||||
return next;
|
||||
}, [openFilePaths, terminals, workspaceAgents]);
|
||||
|
||||
const tabs = useMemo(
|
||||
() => applyWorkspaceTabOrder({ tabs: baseTabs, keys: tabOrder }),
|
||||
[baseTabs, tabOrder]
|
||||
@@ -648,6 +716,9 @@ function WorkspaceScreenContent({
|
||||
if (first.kind === "agent") {
|
||||
return { kind: "agent", agentId: first.agentId };
|
||||
}
|
||||
if (first.kind === "file") {
|
||||
return { kind: "file", path: first.filePath };
|
||||
}
|
||||
return { kind: "terminal", terminalId: first.terminalId };
|
||||
}, [tabs]);
|
||||
|
||||
@@ -737,6 +808,13 @@ function WorkspaceScreenContent({
|
||||
if (tabEquals(tab, resolvedTab)) {
|
||||
return;
|
||||
}
|
||||
if (tab.kind === "file") {
|
||||
openFileTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
filePath: tab.path,
|
||||
});
|
||||
}
|
||||
const targetRoute = buildTabRoute({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
@@ -752,12 +830,23 @@ function WorkspaceScreenContent({
|
||||
[
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
openFileTab,
|
||||
resolvedTab,
|
||||
router,
|
||||
setLastFocusedTab,
|
||||
]
|
||||
);
|
||||
|
||||
const handleOpenFileFromExplorer = useCallback(
|
||||
(filePath: string) => {
|
||||
if (isMobile) {
|
||||
closeToAgent();
|
||||
}
|
||||
navigateToTab({ kind: "file", path: filePath });
|
||||
},
|
||||
[closeToAgent, isMobile, navigateToTab]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedTab) {
|
||||
return;
|
||||
@@ -804,6 +893,7 @@ function WorkspaceScreenContent({
|
||||
]);
|
||||
|
||||
const [isTabSwitcherOpen, setIsTabSwitcherOpen] = useState(false);
|
||||
const [isNewTerminalHovered, setIsNewTerminalHovered] = useState(false);
|
||||
const [hoveredTabKey, setHoveredTabKey] = useState<string | null>(null);
|
||||
const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState<string | null>(
|
||||
null
|
||||
@@ -817,6 +907,10 @@ function WorkspaceScreenContent({
|
||||
map.set(tab.key, { kind: "agent", agentId: tab.agentId });
|
||||
continue;
|
||||
}
|
||||
if (tab.kind === "file") {
|
||||
map.set(tab.key, { kind: "file", path: tab.filePath });
|
||||
continue;
|
||||
}
|
||||
map.set(tab.key, { kind: "terminal", terminalId: tab.terminalId });
|
||||
}
|
||||
return map;
|
||||
@@ -829,6 +923,9 @@ function WorkspaceScreenContent({
|
||||
if (resolvedTab.kind === "agent") {
|
||||
return `agent:${resolvedTab.agentId}`;
|
||||
}
|
||||
if (resolvedTab.kind === "file") {
|
||||
return `file:${resolvedTab.path}`;
|
||||
}
|
||||
return `terminal:${resolvedTab.terminalId}`;
|
||||
}, [resolvedTab]);
|
||||
|
||||
@@ -1036,6 +1133,47 @@ function WorkspaceScreenContent({
|
||||
]
|
||||
);
|
||||
|
||||
const handleCloseFileTab = useCallback(
|
||||
(filePath: string) => {
|
||||
const tabKey = `file:${filePath}`;
|
||||
const nextTab = resolvedTab?.kind === "file" && resolvedTab.path === filePath
|
||||
? getTabAfterClosing(tabKey)
|
||||
: null;
|
||||
|
||||
closeFileTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
filePath,
|
||||
});
|
||||
|
||||
setHoveredTabKey((current) => (current === tabKey ? null : current));
|
||||
setHoveredCloseTabKey((current) => (current === tabKey ? null : current));
|
||||
|
||||
if (nextTab) {
|
||||
navigateToTab(nextTab);
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolvedTab?.kind === "file" && resolvedTab.path === filePath) {
|
||||
router.replace(
|
||||
buildHostWorkspaceRoute(
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId
|
||||
) as any
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
closeFileTab,
|
||||
getTabAfterClosing,
|
||||
navigateToTab,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
resolvedTab,
|
||||
router,
|
||||
]
|
||||
);
|
||||
|
||||
const handleCopyAgentId = useCallback(
|
||||
async (agentId: string) => {
|
||||
if (!agentId) return;
|
||||
@@ -1093,22 +1231,33 @@ function WorkspaceScreenContent({
|
||||
|
||||
const agentIds: string[] = [];
|
||||
const terminalIdsToClose: string[] = [];
|
||||
const filePathsToClose: string[] = [];
|
||||
for (const tab of toClose) {
|
||||
if (tab.kind === "agent") {
|
||||
agentIds.push(tab.agentId);
|
||||
} else {
|
||||
} else if (tab.kind === "terminal") {
|
||||
terminalIdsToClose.push(tab.terminalId);
|
||||
} else {
|
||||
filePathsToClose.push(tab.filePath);
|
||||
}
|
||||
}
|
||||
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Close tabs to the right?",
|
||||
message:
|
||||
agentIds.length > 0 && terminalIdsToClose.length > 0
|
||||
? `This will archive ${agentIds.length} agent(s) and close ${terminalIdsToClose.length} terminal(s). Any running process in a closed terminal will be stopped immediately.`
|
||||
: terminalIdsToClose.length > 0
|
||||
? `This will close ${terminalIdsToClose.length} terminal(s). Any running process in a closed terminal will be stopped immediately.`
|
||||
: `This will archive ${agentIds.length} agent(s).`,
|
||||
agentIds.length > 0 && terminalIdsToClose.length > 0 && filePathsToClose.length > 0
|
||||
? `This will archive ${agentIds.length} agent(s), close ${terminalIdsToClose.length} terminal(s), and close ${filePathsToClose.length} file(s). Any running process in a closed terminal will be stopped immediately.`
|
||||
: agentIds.length > 0 && terminalIdsToClose.length > 0
|
||||
? `This will archive ${agentIds.length} agent(s) and close ${terminalIdsToClose.length} terminal(s). Any running process in a closed terminal will be stopped immediately.`
|
||||
: terminalIdsToClose.length > 0 && filePathsToClose.length > 0
|
||||
? `This will close ${terminalIdsToClose.length} terminal(s) and close ${filePathsToClose.length} file(s). Any running process in a closed terminal will be stopped immediately.`
|
||||
: agentIds.length > 0 && filePathsToClose.length > 0
|
||||
? `This will archive ${agentIds.length} agent(s) and close ${filePathsToClose.length} file(s).`
|
||||
: terminalIdsToClose.length > 0
|
||||
? `This will close ${terminalIdsToClose.length} terminal(s). Any running process in a closed terminal will be stopped immediately.`
|
||||
: filePathsToClose.length > 0
|
||||
? `This will close ${filePathsToClose.length} file(s).`
|
||||
: `This will archive ${agentIds.length} agent(s).`,
|
||||
confirmLabel: "Close",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
@@ -1145,11 +1294,21 @@ function WorkspaceScreenContent({
|
||||
}
|
||||
}
|
||||
|
||||
for (const filePath of filePathsToClose) {
|
||||
closeFileTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
filePath,
|
||||
});
|
||||
}
|
||||
|
||||
const resolvedTabKey = resolvedTab?.kind === "agent"
|
||||
? `agent:${resolvedTab.agentId}`
|
||||
: resolvedTab?.kind === "terminal"
|
||||
? `terminal:${resolvedTab.terminalId}`
|
||||
: null;
|
||||
: resolvedTab?.kind === "file"
|
||||
? `file:${resolvedTab.path}`
|
||||
: null;
|
||||
const closedKeys = new Set(toClose.map((tab) => tab.key));
|
||||
if (resolvedTabKey && closedKeys.has(resolvedTabKey)) {
|
||||
const target = tabByKey.get(tabKey);
|
||||
@@ -1163,9 +1322,11 @@ function WorkspaceScreenContent({
|
||||
},
|
||||
[
|
||||
archiveAgent,
|
||||
closeFileTab,
|
||||
killTerminalMutation,
|
||||
navigateToTab,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
queryClient,
|
||||
resolvedTab,
|
||||
tabByKey,
|
||||
@@ -1206,6 +1367,16 @@ function WorkspaceScreenContent({
|
||||
);
|
||||
}
|
||||
|
||||
if (resolvedTab.kind === "file") {
|
||||
return (
|
||||
<FilePane
|
||||
serverId={normalizedServerId}
|
||||
workspaceRoot={normalizedWorkspaceId}
|
||||
filePath={resolvedTab.path}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TerminalPane
|
||||
serverId={normalizedServerId}
|
||||
@@ -1342,6 +1513,10 @@ function WorkspaceScreenContent({
|
||||
return <Terminal size={14} color={theme.colors.foreground} />;
|
||||
}
|
||||
|
||||
if (activeDescriptor.kind === "file") {
|
||||
return <FileText size={14} color={theme.colors.foreground} />;
|
||||
}
|
||||
|
||||
const tabAgent = agentsById.get(activeDescriptor.agentId) ?? null;
|
||||
const tabAgentStatusBucket = tabAgent
|
||||
? deriveSidebarStateBucket({
|
||||
@@ -1416,6 +1591,8 @@ function WorkspaceScreenContent({
|
||||
<TooltipTrigger
|
||||
testID="workspace-new-terminal-tab"
|
||||
onPress={() => handleSelectNewTabOption(NEW_TAB_TERMINAL_OPTION_ID)}
|
||||
onHoverIn={() => setIsNewTerminalHovered(true)}
|
||||
onHoverOut={() => setIsNewTerminalHovered(false)}
|
||||
disabled={createTerminalMutation.isPending}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="New terminal tab"
|
||||
@@ -1429,8 +1606,8 @@ function WorkspaceScreenContent({
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<View style={styles.terminalPlusIcon}>
|
||||
<Terminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<View style={styles.terminalPlusBadge}>
|
||||
<SquareTerminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<View style={[styles.terminalPlusBadge, isNewTerminalHovered && styles.terminalPlusBadgeHovered]}>
|
||||
<Plus size={10} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
</View>
|
||||
@@ -1527,6 +1704,8 @@ function WorkspaceScreenContent({
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
) : tab.kind === "file" ? (
|
||||
<FileText size={14} color={iconColor} />
|
||||
) : (
|
||||
<Terminal size={14} color={iconColor} />
|
||||
);
|
||||
@@ -1556,6 +1735,10 @@ function WorkspaceScreenContent({
|
||||
navigateToTab({ kind: "agent", agentId: tab.agentId });
|
||||
return;
|
||||
}
|
||||
if (tab.kind === "file") {
|
||||
navigateToTab({ kind: "file", path: tab.filePath });
|
||||
return;
|
||||
}
|
||||
navigateToTab({
|
||||
kind: "terminal",
|
||||
terminalId: tab.terminalId,
|
||||
@@ -1587,7 +1770,9 @@ function WorkspaceScreenContent({
|
||||
testID={
|
||||
tab.kind === "agent"
|
||||
? `workspace-agent-close-${tab.agentId}`
|
||||
: `workspace-terminal-close-${tab.terminalId}`
|
||||
: tab.kind === "terminal"
|
||||
? `workspace-terminal-close-${tab.terminalId}`
|
||||
: `workspace-file-close-${encodeFilePathForPathSegment(tab.filePath)}`
|
||||
}
|
||||
pointerEvents={shouldShowCloseButton ? "auto" : "none"}
|
||||
disabled={!shouldShowCloseButton || isClosingTab}
|
||||
@@ -1609,6 +1794,10 @@ function WorkspaceScreenContent({
|
||||
void handleCloseAgentTab(tab.agentId);
|
||||
return;
|
||||
}
|
||||
if (tab.kind === "file") {
|
||||
handleCloseFileTab(tab.filePath);
|
||||
return;
|
||||
}
|
||||
void handleCloseTerminalTab(tab.terminalId);
|
||||
}}
|
||||
style={({ hovered, pressed }) => [
|
||||
@@ -1676,6 +1865,10 @@ function WorkspaceScreenContent({
|
||||
void handleCloseAgentTab(tab.agentId);
|
||||
return;
|
||||
}
|
||||
if (tab.kind === "file") {
|
||||
handleCloseFileTab(tab.filePath);
|
||||
return;
|
||||
}
|
||||
void handleCloseTerminalTab(tab.terminalId);
|
||||
}}
|
||||
>
|
||||
@@ -1709,6 +1902,8 @@ function WorkspaceScreenContent({
|
||||
<TooltipTrigger
|
||||
testID="workspace-new-terminal-tab"
|
||||
onPress={() => handleSelectNewTabOption(NEW_TAB_TERMINAL_OPTION_ID)}
|
||||
onHoverIn={() => setIsNewTerminalHovered(true)}
|
||||
onHoverOut={() => setIsNewTerminalHovered(false)}
|
||||
disabled={createTerminalMutation.isPending}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="New terminal tab"
|
||||
@@ -1725,11 +1920,11 @@ function WorkspaceScreenContent({
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.terminalPlusIcon}>
|
||||
<Terminal
|
||||
<SquareTerminal
|
||||
size={theme.iconSize.sm}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
<View style={styles.terminalPlusBadge}>
|
||||
<View style={[styles.terminalPlusBadge, isNewTerminalHovered && styles.terminalPlusBadgeHovered]}>
|
||||
<Plus size={10} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
</View>
|
||||
@@ -1759,6 +1954,7 @@ function WorkspaceScreenContent({
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
workspaceRoot={normalizedWorkspaceId}
|
||||
isGit={isGitCheckout}
|
||||
onOpenFile={handleOpenFileFromExplorer}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -1832,17 +2028,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
terminalPlusBadge: {
|
||||
position: "absolute",
|
||||
right: -6,
|
||||
bottom: -6,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 7,
|
||||
right: -5,
|
||||
bottom: -5,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
terminalPlusBadgeHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
mobileTabsRow: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { create } from "zustand";
|
||||
import { buildWorkspaceTabPersistenceKey } from "./workspace-tabs-store";
|
||||
|
||||
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 normalizeFilePath(value: string | null | undefined): string | null {
|
||||
const trimmed = trimNonEmpty(value);
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
return trimmed.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
type WorkspaceFileTabsState = {
|
||||
openFilePathsByWorkspace: Record<string, string[]>;
|
||||
openFileTab: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
filePath: string;
|
||||
}) => void;
|
||||
closeFileTab: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
filePath: string;
|
||||
}) => void;
|
||||
isFileTabOpen: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
filePath: string;
|
||||
}) => boolean;
|
||||
};
|
||||
|
||||
export const useWorkspaceFileTabsStore = create<WorkspaceFileTabsState>()(
|
||||
(set, get) => ({
|
||||
openFilePathsByWorkspace: {},
|
||||
openFileTab: ({ serverId, workspaceId, filePath }) => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
const normalizedFilePath = normalizeFilePath(filePath);
|
||||
if (!key || !normalizedFilePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const current = state.openFilePathsByWorkspace[key] ?? [];
|
||||
if (current.includes(normalizedFilePath)) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
openFilePathsByWorkspace: {
|
||||
...state.openFilePathsByWorkspace,
|
||||
[key]: [...current, normalizedFilePath],
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
closeFileTab: ({ serverId, workspaceId, filePath }) => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
const normalizedFilePath = normalizeFilePath(filePath);
|
||||
if (!key || !normalizedFilePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const current = state.openFilePathsByWorkspace[key] ?? [];
|
||||
if (current.length === 0 || !current.includes(normalizedFilePath)) {
|
||||
return state;
|
||||
}
|
||||
const next = current.filter((path) => path !== normalizedFilePath);
|
||||
if (next.length === 0) {
|
||||
const { [key]: _removed, ...rest } = state.openFilePathsByWorkspace;
|
||||
return { openFilePathsByWorkspace: rest };
|
||||
}
|
||||
return {
|
||||
openFilePathsByWorkspace: {
|
||||
...state.openFilePathsByWorkspace,
|
||||
[key]: next,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
isFileTabOpen: ({ serverId, workspaceId, filePath }) => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
const normalizedFilePath = normalizeFilePath(filePath);
|
||||
if (!key || !normalizedFilePath) {
|
||||
return false;
|
||||
}
|
||||
const current = get().openFilePathsByWorkspace[key] ?? [];
|
||||
return current.includes(normalizedFilePath);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -4,7 +4,8 @@ import { createJSONStorage, persist } from "zustand/middleware";
|
||||
|
||||
export type WorkspaceTabTarget =
|
||||
| { kind: "agent"; agentId: string }
|
||||
| { kind: "terminal"; terminalId: string };
|
||||
| { kind: "terminal"; terminalId: string }
|
||||
| { kind: "file"; path: string };
|
||||
|
||||
function normalizeKeys(keys: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
@@ -50,6 +51,10 @@ function normalizeWorkspaceTab(
|
||||
}
|
||||
return { kind: "terminal", terminalId };
|
||||
}
|
||||
if (value.kind === "file") {
|
||||
// File tabs are session-only; do not persist in workspace tab memory.
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -144,6 +144,33 @@ const lightSemanticColors = {
|
||||
accentBorder: "#ececf1",
|
||||
input: "#f4f4f5",
|
||||
ring: "#18181b",
|
||||
|
||||
terminal: {
|
||||
background: "#ffffff",
|
||||
foreground: "#09090b",
|
||||
cursor: "#09090b",
|
||||
cursorAccent: "#ffffff",
|
||||
selectionBackground: "rgba(0, 0, 0, 0.15)",
|
||||
selectionForeground: "#09090b",
|
||||
|
||||
black: "#09090b",
|
||||
red: "#dc2626",
|
||||
green: "#16a34a",
|
||||
yellow: "#ca8a04",
|
||||
blue: "#2563eb",
|
||||
magenta: "#9333ea",
|
||||
cyan: "#0891b2",
|
||||
white: "#ffffff",
|
||||
|
||||
brightBlack: "#3f3f46",
|
||||
brightRed: "#ef4444",
|
||||
brightGreen: "#22c55e",
|
||||
brightYellow: "#f59e0b",
|
||||
brightBlue: "#3b82f6",
|
||||
brightMagenta: "#a855f7",
|
||||
brightCyan: "#06b6d4",
|
||||
brightWhite: "#fafafa",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const darkSemanticColors = {
|
||||
@@ -186,6 +213,33 @@ const darkSemanticColors = {
|
||||
accentBorder: "#34343a",
|
||||
input: "#27272a",
|
||||
ring: "#d4d4d8",
|
||||
|
||||
terminal: {
|
||||
background: "#18181c",
|
||||
foreground: "#fafafa",
|
||||
cursor: "#fafafa",
|
||||
cursorAccent: "#18181c",
|
||||
selectionBackground: "rgba(255, 255, 255, 0.2)",
|
||||
selectionForeground: "#fafafa",
|
||||
|
||||
black: "#121214",
|
||||
red: "#ef4444",
|
||||
green: "#22c55e",
|
||||
yellow: "#f59e0b",
|
||||
blue: "#3b82f6",
|
||||
magenta: "#a855f7",
|
||||
cyan: "#06b6d4",
|
||||
white: "#e4e4e7",
|
||||
|
||||
brightBlack: "#3f3f46",
|
||||
brightRed: "#f87171",
|
||||
brightGreen: "#4ade80",
|
||||
brightYellow: "#fbbf24",
|
||||
brightBlue: "#60a5fa",
|
||||
brightMagenta: "#c084fc",
|
||||
brightCyan: "#22d3ee",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const commonTheme = {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { Terminal, type ITheme } from "@xterm/xterm";
|
||||
import {
|
||||
type PendingTerminalModifiers,
|
||||
isTerminalModifierDomKey,
|
||||
@@ -10,17 +12,11 @@ import {
|
||||
} from "@/utils/terminal-keys";
|
||||
import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
|
||||
|
||||
export type TerminalEmulatorRuntimeTheme = {
|
||||
backgroundColor: string;
|
||||
foregroundColor: string;
|
||||
cursorColor: string;
|
||||
};
|
||||
|
||||
export type TerminalEmulatorRuntimeMountInput = {
|
||||
root: HTMLDivElement;
|
||||
host: HTMLDivElement;
|
||||
initialOutputText: string;
|
||||
theme: TerminalEmulatorRuntimeTheme;
|
||||
theme: ITheme;
|
||||
};
|
||||
|
||||
export type TerminalEmulatorRuntimeCallbacks = {
|
||||
@@ -48,12 +44,14 @@ type TerminalEmulatorRuntimeDisposables = {
|
||||
restoreDocumentStyles: () => void;
|
||||
restoreViewportStyles: () => void;
|
||||
disposeFitAddon: () => void;
|
||||
disposeWebglAddon: () => void;
|
||||
disposeTerminal: () => void;
|
||||
};
|
||||
|
||||
type TerminalOutputOperation = {
|
||||
type: "write" | "clear";
|
||||
text: string;
|
||||
suppressInput?: boolean;
|
||||
onCommitted?: () => void;
|
||||
};
|
||||
|
||||
@@ -67,6 +65,26 @@ const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
|
||||
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
|
||||
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
|
||||
|
||||
const DEFAULT_TERMINAL_FONT_FAMILY = [
|
||||
// Prefer common developer fonts, with Nerd Font variants for prompt/TUI glyphs.
|
||||
"JetBrains Mono",
|
||||
"JetBrainsMono Nerd Font",
|
||||
"JetBrainsMono NF",
|
||||
"MesloLGM Nerd Font",
|
||||
"MesloLGM NF",
|
||||
"Hack Nerd Font",
|
||||
"FiraCode Nerd Font",
|
||||
// PUA-only fallback (many Nerd glyphs live here on some systems).
|
||||
"Symbols Nerd Font",
|
||||
// System fallbacks.
|
||||
"SF Mono",
|
||||
"Menlo",
|
||||
"Monaco",
|
||||
"Consolas",
|
||||
"'Liberation Mono'",
|
||||
"monospace",
|
||||
].join(", ");
|
||||
|
||||
export class TerminalEmulatorRuntime {
|
||||
private callbacks: TerminalEmulatorRuntimeCallbacks = {};
|
||||
private pendingModifiers: PendingTerminalModifiers = {
|
||||
@@ -82,6 +100,7 @@ export class TerminalEmulatorRuntime {
|
||||
private outputOperations: TerminalOutputOperation[] = [];
|
||||
private inFlightOutputOperation: TerminalOutputOperation | null = null;
|
||||
private inFlightOutputOperationTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private suppressInput = false;
|
||||
|
||||
setCallbacks(input: { callbacks: TerminalEmulatorRuntimeCallbacks }): void {
|
||||
this.callbacks = input.callbacks;
|
||||
@@ -109,19 +128,49 @@ export class TerminalEmulatorRuntime {
|
||||
convertEol: false,
|
||||
cursorBlink: true,
|
||||
cursorStyle: "bar",
|
||||
fontFamily: "'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', monospace",
|
||||
fontFamily: DEFAULT_TERMINAL_FONT_FAMILY,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.25,
|
||||
lineHeight: 1.0,
|
||||
scrollback: 10_000,
|
||||
theme: {
|
||||
background: input.theme.backgroundColor,
|
||||
foreground: input.theme.foregroundColor,
|
||||
cursor: input.theme.cursorColor,
|
||||
},
|
||||
theme: input.theme,
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
const unicode11Addon = new Unicode11Addon();
|
||||
let webglAddon: WebglAddon | null = null;
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(unicode11Addon);
|
||||
terminal.open(input.host);
|
||||
try {
|
||||
terminal.unicode.activeVersion = "11";
|
||||
} catch {
|
||||
// Ignore if unicode API isn't available in this build/runtime.
|
||||
}
|
||||
|
||||
// Prefer GPU rendering when available. This tends to reduce visible seams in block characters
|
||||
// and improves scroll performance, but must gracefully fall back on platforms without WebGL.
|
||||
let webglAddonRaf: number | null = requestAnimationFrame(() => {
|
||||
webglAddonRaf = null;
|
||||
try {
|
||||
webglAddon = new WebglAddon();
|
||||
webglAddon.onContextLoss(() => {
|
||||
try {
|
||||
webglAddon?.dispose();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
webglAddon = null;
|
||||
// Force a refresh after context loss to avoid visual corruption.
|
||||
try {
|
||||
terminal.refresh(0, terminal.rows - 1);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
terminal.loadAddon(webglAddon);
|
||||
} catch {
|
||||
webglAddon = null;
|
||||
}
|
||||
});
|
||||
|
||||
const restoreDocumentStyles = this.applyDocumentBoundsStyles({
|
||||
root: input.root,
|
||||
@@ -174,6 +223,17 @@ export class TerminalEmulatorRuntime {
|
||||
fitAndEmitResize(true);
|
||||
|
||||
const inputDisposable = terminal.onData((data) => {
|
||||
if (this.suppressInput) {
|
||||
terminalDebugLog({
|
||||
scope: "emulator-runtime",
|
||||
event: "input:suppressed",
|
||||
details: {
|
||||
length: data.length,
|
||||
preview: summarizeTerminalText({ text: data, maxChars: 64 }),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
terminalDebugLog({
|
||||
scope: "emulator-runtime",
|
||||
event: "input:onData",
|
||||
@@ -283,7 +343,7 @@ export class TerminalEmulatorRuntime {
|
||||
}, 0);
|
||||
|
||||
if (input.initialOutputText.length > 0) {
|
||||
terminal.write(input.initialOutputText);
|
||||
this.write({ text: input.initialOutputText, suppressInput: true });
|
||||
terminalDebugLog({
|
||||
scope: "emulator-runtime",
|
||||
event: "output:initial-write",
|
||||
@@ -329,6 +389,18 @@ export class TerminalEmulatorRuntime {
|
||||
disposeFitAddon: () => {
|
||||
fitAddon.dispose();
|
||||
},
|
||||
disposeWebglAddon: () => {
|
||||
if (webglAddonRaf !== null) {
|
||||
cancelAnimationFrame(webglAddonRaf);
|
||||
webglAddonRaf = null;
|
||||
}
|
||||
try {
|
||||
webglAddon?.dispose();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
webglAddon = null;
|
||||
},
|
||||
disposeTerminal: () => {
|
||||
terminal.dispose();
|
||||
},
|
||||
@@ -344,13 +416,14 @@ export class TerminalEmulatorRuntime {
|
||||
disposables.removeFontListeners();
|
||||
disposables.removeTouchListeners();
|
||||
disposables.disposeFitAddon();
|
||||
disposables.disposeWebglAddon();
|
||||
disposables.disposeTerminal();
|
||||
disposables.restoreDocumentStyles();
|
||||
disposables.restoreViewportStyles();
|
||||
};
|
||||
}
|
||||
|
||||
write(input: { text: string; onCommitted?: () => void }): void {
|
||||
write(input: { text: string; suppressInput?: boolean; onCommitted?: () => void }): void {
|
||||
if (input.text.length === 0) {
|
||||
input.onCommitted?.();
|
||||
return;
|
||||
@@ -358,6 +431,7 @@ export class TerminalEmulatorRuntime {
|
||||
this.outputOperations.push({
|
||||
type: "write",
|
||||
text: input.text,
|
||||
suppressInput: input.suppressInput ?? false,
|
||||
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
|
||||
});
|
||||
terminalDebugLog({
|
||||
@@ -376,6 +450,7 @@ export class TerminalEmulatorRuntime {
|
||||
this.outputOperations.push({
|
||||
type: "clear",
|
||||
text: "",
|
||||
suppressInput: false,
|
||||
...(input?.onCommitted ? { onCommitted: input.onCommitted } : {}),
|
||||
});
|
||||
terminalDebugLog({
|
||||
@@ -421,6 +496,7 @@ export class TerminalEmulatorRuntime {
|
||||
this.fitAddon = null;
|
||||
this.fitAndEmitResize = null;
|
||||
this.lastSize = null;
|
||||
this.suppressInput = false;
|
||||
}
|
||||
|
||||
private processOutputQueue(): void {
|
||||
@@ -439,12 +515,17 @@ export class TerminalEmulatorRuntime {
|
||||
}
|
||||
|
||||
this.inFlightOutputOperation = operation;
|
||||
const previousSuppressInput = this.suppressInput;
|
||||
if (operation.type === "write") {
|
||||
this.suppressInput = Boolean(operation.suppressInput);
|
||||
}
|
||||
const finalizeOperation = (expectedOperation: TerminalOutputOperation) => {
|
||||
if (this.inFlightOutputOperation !== expectedOperation) {
|
||||
return;
|
||||
}
|
||||
this.inFlightOutputOperation = null;
|
||||
this.clearInFlightOutputTimeout();
|
||||
this.suppressInput = previousSuppressInput;
|
||||
expectedOperation.onCommitted?.();
|
||||
this.processOutputQueue();
|
||||
};
|
||||
@@ -493,12 +574,19 @@ export class TerminalEmulatorRuntime {
|
||||
const previousDocumentElementOverflow = documentElement.style.overflow;
|
||||
const previousDocumentElementWidth = documentElement.style.width;
|
||||
const previousDocumentElementHeight = documentElement.style.height;
|
||||
const previousDocumentElementTextSizeAdjust =
|
||||
documentElement.style.getPropertyValue("text-size-adjust");
|
||||
const previousDocumentElementWebkitTextSizeAdjust =
|
||||
documentElement.style.getPropertyValue("-webkit-text-size-adjust");
|
||||
|
||||
const previousBodyOverflow = body.style.overflow;
|
||||
const previousBodyWidth = body.style.width;
|
||||
const previousBodyHeight = body.style.height;
|
||||
const previousBodyMargin = body.style.margin;
|
||||
const previousBodyPadding = body.style.padding;
|
||||
const previousBodyTextSizeAdjust = body.style.getPropertyValue("text-size-adjust");
|
||||
const previousBodyWebkitTextSizeAdjust =
|
||||
body.style.getPropertyValue("-webkit-text-size-adjust");
|
||||
|
||||
const previousRootOverflow = rootContainer?.style.overflow ?? "";
|
||||
const previousRootWidth = rootContainer?.style.width ?? "";
|
||||
@@ -507,12 +595,16 @@ export class TerminalEmulatorRuntime {
|
||||
documentElement.style.overflow = "hidden";
|
||||
documentElement.style.width = "100%";
|
||||
documentElement.style.height = "100%";
|
||||
documentElement.style.setProperty("text-size-adjust", "100%");
|
||||
documentElement.style.setProperty("-webkit-text-size-adjust", "100%");
|
||||
|
||||
body.style.overflow = "hidden";
|
||||
body.style.width = "100%";
|
||||
body.style.height = "100%";
|
||||
body.style.margin = "0";
|
||||
body.style.padding = "0";
|
||||
body.style.setProperty("text-size-adjust", "100%");
|
||||
body.style.setProperty("-webkit-text-size-adjust", "100%");
|
||||
|
||||
if (rootContainer) {
|
||||
rootContainer.style.overflow = "hidden";
|
||||
@@ -524,12 +616,19 @@ export class TerminalEmulatorRuntime {
|
||||
documentElement.style.overflow = previousDocumentElementOverflow;
|
||||
documentElement.style.width = previousDocumentElementWidth;
|
||||
documentElement.style.height = previousDocumentElementHeight;
|
||||
documentElement.style.setProperty("text-size-adjust", previousDocumentElementTextSizeAdjust);
|
||||
documentElement.style.setProperty(
|
||||
"-webkit-text-size-adjust",
|
||||
previousDocumentElementWebkitTextSizeAdjust
|
||||
);
|
||||
|
||||
body.style.overflow = previousBodyOverflow;
|
||||
body.style.width = previousBodyWidth;
|
||||
body.style.height = previousBodyHeight;
|
||||
body.style.margin = previousBodyMargin;
|
||||
body.style.padding = previousBodyPadding;
|
||||
body.style.setProperty("text-size-adjust", previousBodyTextSizeAdjust);
|
||||
body.style.setProperty("-webkit-text-size-adjust", previousBodyWebkitTextSizeAdjust);
|
||||
|
||||
if (rootContainer) {
|
||||
rootContainer.style.overflow = previousRootOverflow;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TerminalOutputDeliveryQueue } from "./terminal-output-delivery-queue";
|
||||
describe("terminal-output-delivery-queue", () => {
|
||||
it("retries in-flight delivery when consume is missing", () => {
|
||||
vi.useFakeTimers();
|
||||
const delivered: Array<{ sequence: number; text: string }> = [];
|
||||
const delivered: Array<{ sequence: number; text: string; replay: boolean }> = [];
|
||||
const queue = new TerminalOutputDeliveryQueue({
|
||||
onDeliver: (chunk) => {
|
||||
delivered.push(chunk);
|
||||
@@ -13,45 +13,45 @@ describe("terminal-output-delivery-queue", () => {
|
||||
deliveryTimeoutMs: 100,
|
||||
});
|
||||
|
||||
queue.enqueue({ sequence: 1, text: "a" });
|
||||
queue.enqueue({ sequence: 2, text: "b" });
|
||||
queue.enqueue({ sequence: 1, text: "a", replay: false });
|
||||
queue.enqueue({ sequence: 2, text: "b", replay: false });
|
||||
|
||||
expect(delivered).toEqual([{ sequence: 1, text: "a" }]);
|
||||
expect(delivered).toEqual([{ sequence: 1, text: "a", replay: false }]);
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(delivered).toEqual([
|
||||
{ sequence: 1, text: "a" },
|
||||
{ sequence: 1, text: "a" },
|
||||
{ sequence: 1, text: "a", replay: false },
|
||||
{ sequence: 1, text: "a", replay: false },
|
||||
]);
|
||||
|
||||
queue.consume({ sequence: 1 });
|
||||
expect(delivered).toEqual([
|
||||
{ sequence: 1, text: "a" },
|
||||
{ sequence: 1, text: "a" },
|
||||
{ sequence: 2, text: "b" },
|
||||
{ sequence: 1, text: "a", replay: false },
|
||||
{ sequence: 1, text: "a", replay: false },
|
||||
{ sequence: 2, text: "b", replay: false },
|
||||
]);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("delivers first chunk immediately and blocks later chunks until consumed", () => {
|
||||
const delivered: Array<{ sequence: number; text: string }> = [];
|
||||
const delivered: Array<{ sequence: number; text: string; replay: boolean }> = [];
|
||||
const queue = new TerminalOutputDeliveryQueue({
|
||||
onDeliver: (chunk) => {
|
||||
delivered.push(chunk);
|
||||
},
|
||||
});
|
||||
|
||||
queue.enqueue({ sequence: 1, text: "a" });
|
||||
queue.enqueue({ sequence: 2, text: "b" });
|
||||
queue.enqueue({ sequence: 3, text: "c" });
|
||||
queue.enqueue({ sequence: 1, text: "a", replay: false });
|
||||
queue.enqueue({ sequence: 2, text: "b", replay: false });
|
||||
queue.enqueue({ sequence: 3, text: "c", replay: false });
|
||||
|
||||
expect(delivered).toEqual([{ sequence: 1, text: "a" }]);
|
||||
expect(delivered).toEqual([{ sequence: 1, text: "a", replay: false }]);
|
||||
|
||||
queue.consume({ sequence: 1 });
|
||||
expect(delivered).toEqual([
|
||||
{ sequence: 1, text: "a" },
|
||||
{ sequence: 3, text: "bc" },
|
||||
{ sequence: 1, text: "a", replay: false },
|
||||
{ sequence: 3, text: "bc", replay: false },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -59,83 +59,83 @@ describe("terminal-output-delivery-queue", () => {
|
||||
const delivered = vi.fn();
|
||||
const queue = new TerminalOutputDeliveryQueue({ onDeliver: delivered });
|
||||
|
||||
queue.enqueue({ sequence: 1, text: "x" });
|
||||
queue.enqueue({ sequence: 1, text: "x", replay: false });
|
||||
queue.consume({ sequence: 99 });
|
||||
queue.enqueue({ sequence: 2, text: "y" });
|
||||
queue.enqueue({ sequence: 2, text: "y", replay: false });
|
||||
|
||||
expect(delivered).toHaveBeenCalledTimes(1);
|
||||
expect(delivered).toHaveBeenNthCalledWith(1, { sequence: 1, text: "x" });
|
||||
expect(delivered).toHaveBeenNthCalledWith(1, { sequence: 1, text: "x", replay: false });
|
||||
|
||||
queue.consume({ sequence: 1 });
|
||||
|
||||
expect(delivered).toHaveBeenCalledTimes(2);
|
||||
expect(delivered).toHaveBeenNthCalledWith(2, { sequence: 2, text: "y" });
|
||||
expect(delivered).toHaveBeenNthCalledWith(2, { sequence: 2, text: "y", replay: false });
|
||||
});
|
||||
|
||||
it("resets in-flight and pending chunks", () => {
|
||||
const delivered: Array<{ sequence: number; text: string }> = [];
|
||||
const delivered: Array<{ sequence: number; text: string; replay: boolean }> = [];
|
||||
const queue = new TerminalOutputDeliveryQueue({
|
||||
onDeliver: (chunk) => {
|
||||
delivered.push(chunk);
|
||||
},
|
||||
});
|
||||
|
||||
queue.enqueue({ sequence: 1, text: "hello" });
|
||||
queue.enqueue({ sequence: 2, text: " world" });
|
||||
queue.enqueue({ sequence: 1, text: "hello", replay: false });
|
||||
queue.enqueue({ sequence: 2, text: " world", replay: false });
|
||||
|
||||
queue.reset();
|
||||
queue.enqueue({ sequence: 3, text: "next" });
|
||||
queue.enqueue({ sequence: 3, text: "next", replay: false });
|
||||
|
||||
expect(delivered).toEqual([
|
||||
{ sequence: 1, text: "hello" },
|
||||
{ sequence: 3, text: "next" },
|
||||
{ sequence: 1, text: "hello", replay: false },
|
||||
{ sequence: 3, text: "next", replay: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves empty chunk payloads for authoritative clears", () => {
|
||||
const delivered: Array<{ sequence: number; text: string }> = [];
|
||||
const delivered: Array<{ sequence: number; text: string; replay: boolean }> = [];
|
||||
const queue = new TerminalOutputDeliveryQueue({
|
||||
onDeliver: (chunk) => {
|
||||
delivered.push(chunk);
|
||||
},
|
||||
});
|
||||
|
||||
queue.enqueue({ sequence: 1, text: "abc" });
|
||||
queue.enqueue({ sequence: 1, text: "abc", replay: false });
|
||||
queue.consume({ sequence: 1 });
|
||||
queue.enqueue({ sequence: 2, text: "" });
|
||||
queue.enqueue({ sequence: 2, text: "", replay: false });
|
||||
|
||||
expect(delivered).toEqual([
|
||||
{ sequence: 1, text: "abc" },
|
||||
{ sequence: 2, text: "" },
|
||||
{ sequence: 1, text: "abc", replay: false },
|
||||
{ sequence: 2, text: "", replay: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats clear chunks as a delivery barrier and drops pending stale text", () => {
|
||||
const delivered: Array<{ sequence: number; text: string }> = [];
|
||||
const delivered: Array<{ sequence: number; text: string; replay: boolean }> = [];
|
||||
const queue = new TerminalOutputDeliveryQueue({
|
||||
onDeliver: (chunk) => {
|
||||
delivered.push(chunk);
|
||||
},
|
||||
});
|
||||
|
||||
queue.enqueue({ sequence: 1, text: "a" });
|
||||
queue.enqueue({ sequence: 2, text: "b" });
|
||||
queue.enqueue({ sequence: 3, text: "" });
|
||||
queue.enqueue({ sequence: 4, text: "c" });
|
||||
queue.enqueue({ sequence: 1, text: "a", replay: false });
|
||||
queue.enqueue({ sequence: 2, text: "b", replay: false });
|
||||
queue.enqueue({ sequence: 3, text: "", replay: false });
|
||||
queue.enqueue({ sequence: 4, text: "c", replay: false });
|
||||
|
||||
expect(delivered).toEqual([{ sequence: 1, text: "a" }]);
|
||||
expect(delivered).toEqual([{ sequence: 1, text: "a", replay: false }]);
|
||||
|
||||
queue.consume({ sequence: 1 });
|
||||
expect(delivered).toEqual([
|
||||
{ sequence: 1, text: "a" },
|
||||
{ sequence: 3, text: "" },
|
||||
{ sequence: 1, text: "a", replay: false },
|
||||
{ sequence: 3, text: "", replay: false },
|
||||
]);
|
||||
|
||||
queue.consume({ sequence: 3 });
|
||||
expect(delivered).toEqual([
|
||||
{ sequence: 1, text: "a" },
|
||||
{ sequence: 3, text: "" },
|
||||
{ sequence: 4, text: "c" },
|
||||
{ sequence: 1, text: "a", replay: false },
|
||||
{ sequence: 3, text: "", replay: false },
|
||||
{ sequence: 4, text: "c", replay: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
|
||||
export type TerminalOutputDeliveryChunk = {
|
||||
sequence: number;
|
||||
text: string;
|
||||
replay: boolean;
|
||||
};
|
||||
|
||||
export type TerminalOutputDeliveryQueueOptions = {
|
||||
@@ -48,7 +49,11 @@ export class TerminalOutputDeliveryQueue {
|
||||
}
|
||||
|
||||
const lastPendingChunk = this.pendingChunks[this.pendingChunks.length - 1];
|
||||
if (lastPendingChunk && lastPendingChunk.text.length > 0) {
|
||||
if (
|
||||
lastPendingChunk &&
|
||||
lastPendingChunk.text.length > 0 &&
|
||||
lastPendingChunk.replay === chunk.replay
|
||||
) {
|
||||
lastPendingChunk.sequence = chunk.sequence;
|
||||
lastPendingChunk.text += chunk.text;
|
||||
} else {
|
||||
@@ -138,6 +143,7 @@ export class TerminalOutputDeliveryQueue {
|
||||
this.options.onDeliver({
|
||||
sequence: chunk.sequence,
|
||||
text: chunk.text,
|
||||
replay: chunk.replay,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TerminalOutputPump } from "./terminal-output-pump";
|
||||
describe("terminal-output-pump", () => {
|
||||
it("batches selected-terminal chunk bursts into ordered flushes", () => {
|
||||
vi.useFakeTimers();
|
||||
const chunks: Array<{ sequence: number; text: string }> = [];
|
||||
const chunks: Array<{ sequence: number; text: string; replay: boolean }> = [];
|
||||
const pump = new TerminalOutputPump({
|
||||
maxOutputChars: 100,
|
||||
onSelectedOutputChunk: (chunk) => {
|
||||
@@ -14,16 +14,16 @@ describe("terminal-output-pump", () => {
|
||||
});
|
||||
|
||||
pump.setSelectedTerminal({ terminalId: "term-1" });
|
||||
pump.append({ terminalId: "term-1", text: "a" });
|
||||
pump.append({ terminalId: "term-1", text: "b" });
|
||||
pump.append({ terminalId: "term-1", text: "c" });
|
||||
pump.append({ terminalId: "term-1", text: "a", replay: false });
|
||||
pump.append({ terminalId: "term-1", text: "b", replay: false });
|
||||
pump.append({ terminalId: "term-1", text: "c", replay: false });
|
||||
|
||||
expect(chunks).toEqual([]);
|
||||
|
||||
vi.runOnlyPendingTimers();
|
||||
|
||||
expect(chunks).toEqual([
|
||||
{ sequence: 1, text: "abc" },
|
||||
{ sequence: 1, text: "abc", replay: false },
|
||||
]);
|
||||
|
||||
vi.useRealTimers();
|
||||
@@ -31,7 +31,7 @@ describe("terminal-output-pump", () => {
|
||||
|
||||
it("keeps per-terminal snapshots and switches selected stream deterministically", () => {
|
||||
vi.useFakeTimers();
|
||||
const chunks: Array<{ sequence: number; text: string }> = [];
|
||||
const chunks: Array<{ sequence: number; text: string; replay: boolean }> = [];
|
||||
const pump = new TerminalOutputPump({
|
||||
maxOutputChars: 10,
|
||||
onSelectedOutputChunk: (chunk) => {
|
||||
@@ -40,21 +40,21 @@ describe("terminal-output-pump", () => {
|
||||
});
|
||||
|
||||
pump.setSelectedTerminal({ terminalId: "term-1" });
|
||||
pump.append({ terminalId: "term-1", text: "hello" });
|
||||
pump.append({ terminalId: "term-1", text: "hello", replay: false });
|
||||
vi.runOnlyPendingTimers();
|
||||
expect(pump.readSnapshot({ terminalId: "term-1" })).toBe("hello");
|
||||
|
||||
pump.append({ terminalId: "term-2", text: "world" });
|
||||
pump.append({ terminalId: "term-2", text: "world", replay: false });
|
||||
vi.runOnlyPendingTimers();
|
||||
expect(pump.readSnapshot({ terminalId: "term-2" })).toBe("world");
|
||||
|
||||
pump.setSelectedTerminal({ terminalId: "term-2" });
|
||||
pump.append({ terminalId: "term-2", text: "!" });
|
||||
pump.append({ terminalId: "term-2", text: "!", replay: false });
|
||||
vi.runOnlyPendingTimers();
|
||||
|
||||
expect(chunks).toEqual([
|
||||
{ sequence: 1, text: "hello" },
|
||||
{ sequence: 2, text: "!" },
|
||||
{ sequence: 1, text: "hello", replay: false },
|
||||
{ sequence: 2, text: "!", replay: false },
|
||||
]);
|
||||
|
||||
vi.useRealTimers();
|
||||
@@ -62,7 +62,7 @@ describe("terminal-output-pump", () => {
|
||||
|
||||
it("resets selected output when clearing selected terminal", () => {
|
||||
vi.useFakeTimers();
|
||||
const chunks: Array<{ sequence: number; text: string }> = [];
|
||||
const chunks: Array<{ sequence: number; text: string; replay: boolean }> = [];
|
||||
const pump = new TerminalOutputPump({
|
||||
maxOutputChars: 10,
|
||||
onSelectedOutputChunk: (chunk) => {
|
||||
@@ -71,15 +71,15 @@ describe("terminal-output-pump", () => {
|
||||
});
|
||||
|
||||
pump.setSelectedTerminal({ terminalId: "term-1" });
|
||||
pump.append({ terminalId: "term-1", text: "abc" });
|
||||
pump.append({ terminalId: "term-1", text: "abc", replay: false });
|
||||
vi.runOnlyPendingTimers();
|
||||
|
||||
pump.clearTerminal({ terminalId: "term-1" });
|
||||
|
||||
expect(pump.readSnapshot({ terminalId: "term-1" })).toBe("");
|
||||
expect(chunks).toEqual([
|
||||
{ sequence: 1, text: "abc" },
|
||||
{ sequence: 2, text: "" },
|
||||
{ sequence: 1, text: "abc", replay: false },
|
||||
{ sequence: 2, text: "", replay: false },
|
||||
]);
|
||||
|
||||
vi.useRealTimers();
|
||||
@@ -91,8 +91,8 @@ describe("terminal-output-pump", () => {
|
||||
onSelectedOutputChunk: () => {},
|
||||
});
|
||||
|
||||
pump.append({ terminalId: "a", text: "one" });
|
||||
pump.append({ terminalId: "b", text: "two" });
|
||||
pump.append({ terminalId: "a", text: "one", replay: false });
|
||||
pump.append({ terminalId: "b", text: "two", replay: false });
|
||||
pump.prune({ terminalIds: ["b"] });
|
||||
|
||||
expect(pump.readSnapshot({ terminalId: "a" })).toBe("");
|
||||
|
||||
@@ -9,6 +9,7 @@ import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
|
||||
export type TerminalOutputChunk = {
|
||||
sequence: number;
|
||||
text: string;
|
||||
replay: boolean;
|
||||
};
|
||||
|
||||
export type TerminalOutputPumpOptions = {
|
||||
@@ -23,6 +24,7 @@ export type TerminalOutputPumpSetSelectedInput = {
|
||||
export type TerminalOutputPumpAppendInput = {
|
||||
terminalId: string;
|
||||
text: string;
|
||||
replay: boolean;
|
||||
};
|
||||
|
||||
export type TerminalOutputPumpReadInput = {
|
||||
@@ -42,6 +44,7 @@ export class TerminalOutputPump {
|
||||
private selectedTerminalId: string | null = null;
|
||||
private selectedChunkSequence = 0;
|
||||
private selectedChunkAccumulator = "";
|
||||
private selectedChunkAccumulatorReplay: boolean | null = null;
|
||||
private selectedChunkFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(private readonly options: TerminalOutputPumpOptions) {}
|
||||
@@ -61,6 +64,7 @@ export class TerminalOutputPump {
|
||||
});
|
||||
this.clearSelectedChunkFlushTimer();
|
||||
this.selectedChunkAccumulator = "";
|
||||
this.selectedChunkAccumulatorReplay = null;
|
||||
this.selectedTerminalId = input.terminalId;
|
||||
}
|
||||
|
||||
@@ -85,6 +89,18 @@ export class TerminalOutputPump {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.selectedChunkAccumulator.length > 0 &&
|
||||
typeof this.selectedChunkAccumulatorReplay === "boolean" &&
|
||||
this.selectedChunkAccumulatorReplay !== input.replay
|
||||
) {
|
||||
this.flushSelectedChunkAccumulator();
|
||||
}
|
||||
|
||||
if (this.selectedChunkAccumulator.length === 0) {
|
||||
this.selectedChunkAccumulatorReplay = input.replay;
|
||||
}
|
||||
|
||||
this.selectedChunkAccumulator += input.text;
|
||||
terminalDebugLog({
|
||||
scope: "output-pump",
|
||||
@@ -93,6 +109,7 @@ export class TerminalOutputPump {
|
||||
terminalId: input.terminalId,
|
||||
appendedLength: input.text.length,
|
||||
accumulatorLength: this.selectedChunkAccumulator.length,
|
||||
replay: input.replay,
|
||||
preview: summarizeTerminalText({ text: input.text, maxChars: 80 }),
|
||||
},
|
||||
});
|
||||
@@ -104,7 +121,8 @@ export class TerminalOutputPump {
|
||||
if (this.selectedTerminalId === input.terminalId) {
|
||||
this.clearSelectedChunkFlushTimer();
|
||||
this.selectedChunkAccumulator = "";
|
||||
this.emitSelectedChunk({ text: "" });
|
||||
this.selectedChunkAccumulatorReplay = null;
|
||||
this.emitSelectedChunk({ text: "", replay: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,24 +170,28 @@ export class TerminalOutputPump {
|
||||
}
|
||||
|
||||
const text = this.selectedChunkAccumulator;
|
||||
const replay = this.selectedChunkAccumulatorReplay ?? false;
|
||||
this.selectedChunkAccumulator = "";
|
||||
this.selectedChunkAccumulatorReplay = null;
|
||||
terminalDebugLog({
|
||||
scope: "output-pump",
|
||||
event: "selected-terminal:flush",
|
||||
details: {
|
||||
terminalId: this.selectedTerminalId,
|
||||
textLength: text.length,
|
||||
replay,
|
||||
preview: summarizeTerminalText({ text, maxChars: 96 }),
|
||||
},
|
||||
});
|
||||
this.emitSelectedChunk({ text });
|
||||
this.emitSelectedChunk({ text, replay });
|
||||
}
|
||||
|
||||
private emitSelectedChunk(input: { text: string }): void {
|
||||
private emitSelectedChunk(input: { text: string; replay: boolean }): void {
|
||||
this.selectedChunkSequence += 1;
|
||||
this.options.onSelectedOutputChunk({
|
||||
sequence: this.selectedChunkSequence,
|
||||
text: input.text,
|
||||
replay: input.replay,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ export type TerminalStreamControllerStatus = {
|
||||
export type TerminalStreamControllerOptions = {
|
||||
client: TerminalStreamControllerClient;
|
||||
getPreferredSize: () => TerminalStreamControllerSize | null;
|
||||
onChunk: (input: { terminalId: string; text: string }) => void;
|
||||
onChunk: (input: { terminalId: string; text: string; replay: boolean }) => void;
|
||||
onReset?: (input: { terminalId: string }) => void;
|
||||
onStatusChange?: (status: TerminalStreamControllerStatus) => void;
|
||||
maxAttachAttempts?: number;
|
||||
@@ -500,6 +500,7 @@ export class TerminalStreamController {
|
||||
this.options.onChunk({
|
||||
terminalId: input.terminalId,
|
||||
text,
|
||||
replay: Boolean(input.chunk.replay),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -565,6 +566,7 @@ export class TerminalStreamController {
|
||||
this.options.onChunk({
|
||||
terminalId: activeStream.terminalId,
|
||||
text: tail,
|
||||
replay: false,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildHostWorkspaceRoute,
|
||||
buildHostWorkspaceFileRoute,
|
||||
decodeFilePathFromPathSegment,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
encodeFilePathForPathSegment,
|
||||
encodeWorkspaceIdForPathSegment,
|
||||
parseHostAgentDraftRouteFromPathname,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseHostDraftRouteFromPathname,
|
||||
parseHostWorkspaceAgentRouteFromPathname,
|
||||
parseHostWorkspaceFileRouteFromPathname,
|
||||
parseHostWorkspaceTerminalRouteFromPathname,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
} from "./host-routes";
|
||||
@@ -54,6 +58,12 @@ describe("workspace route parsing", () => {
|
||||
expect(decodeWorkspaceIdFromPathSegment("L3RtcC9yZXBv")).toBe("/tmp/repo");
|
||||
});
|
||||
|
||||
it("encodes file paths as base64url (no padding)", () => {
|
||||
const encoded = encodeFilePathForPathSegment("src/index.ts");
|
||||
expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
expect(decodeFilePathFromPathSegment(encoded)).toBe("src/index.ts");
|
||||
});
|
||||
|
||||
it("parses workspace route", () => {
|
||||
expect(
|
||||
parseHostWorkspaceRouteFromPathname("/h/local/workspace/L3RtcC9yZXBv")
|
||||
@@ -63,6 +73,19 @@ describe("workspace route parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("parses workspace file route", () => {
|
||||
const encodedPath = encodeFilePathForPathSegment("src/index.ts");
|
||||
expect(
|
||||
parseHostWorkspaceFileRouteFromPathname(
|
||||
`/h/local/workspace/L3RtcC9yZXBv/file/${encodedPath}`
|
||||
)
|
||||
).toEqual({
|
||||
serverId: "local",
|
||||
workspaceId: "/tmp/repo",
|
||||
filePath: "src/index.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses workspace agent route", () => {
|
||||
expect(
|
||||
parseHostWorkspaceAgentRouteFromPathname(
|
||||
@@ -104,4 +127,10 @@ describe("workspace route parsing", () => {
|
||||
"/h/local/workspace/L3RtcC9yZXBv"
|
||||
);
|
||||
});
|
||||
|
||||
it("builds base64url workspace file routes", () => {
|
||||
expect(buildHostWorkspaceFileRoute("local", "/tmp/repo", "src/index.ts")).toBe(
|
||||
`/h/local/workspace/L3RtcC9yZXBv/file/${encodeFilePathForPathSegment("src/index.ts")}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,6 +94,26 @@ export function decodeWorkspaceIdFromPathSegment(workspaceIdSegment: string): st
|
||||
return normalizeWorkspaceId(decoded);
|
||||
}
|
||||
|
||||
export function encodeFilePathForPathSegment(filePath: string): string {
|
||||
const normalized = trimNonEmpty(filePath);
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
return toBase64UrlNoPad(normalized);
|
||||
}
|
||||
|
||||
export function decodeFilePathFromPathSegment(filePathSegment: string): string | null {
|
||||
const normalizedSegment = trimNonEmpty(filePathSegment);
|
||||
if (!normalizedSegment) {
|
||||
return null;
|
||||
}
|
||||
const decoded = trimNonEmpty(decodeSegment(normalizedSegment));
|
||||
if (!decoded) {
|
||||
return null;
|
||||
}
|
||||
return tryDecodeBase64UrlNoPadUtf8(decoded);
|
||||
}
|
||||
|
||||
export function parseServerIdFromPathname(pathname: string): string | null {
|
||||
const match = pathname.match(/^\/h\/([^/]+)(?:\/|$)/);
|
||||
if (!match) {
|
||||
@@ -211,6 +231,10 @@ export function parseHostWorkspaceRouteFromPathname(
|
||||
if (terminalIdx >= 0 && terminalIdx > workspaceIdStart) {
|
||||
workspaceIdEnd = Math.min(workspaceIdEnd, terminalIdx);
|
||||
}
|
||||
const fileIdx = pathname.lastIndexOf("/file/");
|
||||
if (fileIdx >= 0 && fileIdx > workspaceIdStart) {
|
||||
workspaceIdEnd = Math.min(workspaceIdEnd, fileIdx);
|
||||
}
|
||||
|
||||
const rawWorkspaceId = pathname.slice(workspaceIdStart, workspaceIdEnd).replace(/\/+$/, "");
|
||||
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
|
||||
@@ -320,6 +344,55 @@ export function parseHostWorkspaceTerminalRouteFromPathname(
|
||||
return { serverId, workspaceId, terminalId };
|
||||
}
|
||||
|
||||
export function parseHostWorkspaceFileRouteFromPathname(
|
||||
pathname: string
|
||||
): { serverId: string; workspaceId: string; filePath: string } | null {
|
||||
const prefix = "/h/";
|
||||
if (!pathname.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const serverIdStart = prefix.length;
|
||||
const serverIdEnd = pathname.indexOf("/", serverIdStart);
|
||||
if (serverIdEnd < 0) {
|
||||
return null;
|
||||
}
|
||||
const rawServerId = pathname.slice(serverIdStart, serverIdEnd);
|
||||
const serverId = trimNonEmpty(decodeSegment(rawServerId));
|
||||
if (!serverId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspacePrefix = "/workspace/";
|
||||
if (!pathname.startsWith(workspacePrefix, serverIdEnd)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspaceIdStart = serverIdEnd + workspacePrefix.length;
|
||||
const fileMarker = "/file/";
|
||||
const fileIdx = pathname.lastIndexOf(fileMarker);
|
||||
if (fileIdx < 0 || fileIdx <= workspaceIdStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawWorkspaceId = pathname.slice(workspaceIdStart, fileIdx).replace(/\/+$/, "");
|
||||
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
|
||||
if (!workspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filePathStart = fileIdx + fileMarker.length;
|
||||
const filePathEnd = pathname.indexOf("/", filePathStart);
|
||||
const rawFilePath =
|
||||
filePathEnd < 0 ? pathname.slice(filePathStart) : pathname.slice(filePathStart, filePathEnd);
|
||||
const filePath = decodeFilePathFromPathSegment(rawFilePath);
|
||||
if (!filePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { serverId, workspaceId, filePath };
|
||||
}
|
||||
|
||||
export function buildHostWorkspaceRoute(
|
||||
serverId: string,
|
||||
workspaceId: string
|
||||
@@ -362,6 +435,19 @@ export function buildHostWorkspaceTerminalRoute(
|
||||
return `${base}/terminal/${encodeSegment(normalizedTerminalId)}`;
|
||||
}
|
||||
|
||||
export function buildHostWorkspaceFileRoute(
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
filePath: string
|
||||
): string {
|
||||
const base = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||
const encodedFilePath = encodeFilePathForPathSegment(filePath);
|
||||
if (base === "/" || !encodedFilePath) {
|
||||
return "/";
|
||||
}
|
||||
return `${base}/file/${encodeSegment(encodedFilePath)}`;
|
||||
}
|
||||
|
||||
export function buildHostAgentDetailRoute(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ITheme } from "@xterm/xterm";
|
||||
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
type TerminalPalette = Theme["colors"]["terminal"];
|
||||
|
||||
export function toXtermTheme(terminal: TerminalPalette): ITheme {
|
||||
return {
|
||||
background: terminal.background,
|
||||
foreground: terminal.foreground,
|
||||
cursor: terminal.cursor,
|
||||
cursorAccent: terminal.cursorAccent,
|
||||
selectionBackground: terminal.selectionBackground,
|
||||
selectionForeground: terminal.selectionForeground,
|
||||
|
||||
black: terminal.black,
|
||||
red: terminal.red,
|
||||
green: terminal.green,
|
||||
yellow: terminal.yellow,
|
||||
blue: terminal.blue,
|
||||
magenta: terminal.magenta,
|
||||
cyan: terminal.cyan,
|
||||
white: terminal.white,
|
||||
|
||||
brightBlack: terminal.brightBlack,
|
||||
brightRed: terminal.brightRed,
|
||||
brightGreen: terminal.brightGreen,
|
||||
brightYellow: terminal.brightYellow,
|
||||
brightBlue: terminal.brightBlue,
|
||||
brightMagenta: terminal.brightMagenta,
|
||||
brightCyan: terminal.brightCyan,
|
||||
brightWhite: terminal.brightWhite,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.6",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "^2.9.6"
|
||||
"@tauri-apps/cli": "^2.9.6"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user