From f418f3ab02936ed88feddcb58a11b6ee1923db2a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 2 Mar 2026 20:02:54 +0700 Subject: [PATCH] feat: add file tabs and improve terminal rendering with WebGL --- package-lock.json | 18 +- packages/app/package.json | 2 + .../workspace/[workspaceId]/file/[fileId].tsx | 23 + .../app/src/components/agent-stream-view.tsx | 39 +- .../app/src/components/draggable-list.web.tsx | 8 +- .../app/src/components/explorer-sidebar.tsx | 7 + .../app/src/components/file-explorer-pane.tsx | 595 ++---------------- packages/app/src/components/file-pane.tsx | 278 ++++++++ .../components/sortable-inline-list.web.tsx | 5 +- .../app/src/components/terminal-emulator.tsx | 28 +- packages/app/src/components/terminal-pane.tsx | 15 +- .../screens/workspace/workspace-screen.tsx | 289 +++++++-- .../src/stores/workspace-file-tabs-store.ts | 98 +++ .../app/src/stores/workspace-tabs-store.ts | 7 +- packages/app/src/styles/theme.ts | 54 ++ .../runtime/terminal-emulator-runtime.ts | 133 +++- .../terminal-output-delivery-queue.test.ts | 84 +-- .../runtime/terminal-output-delivery-queue.ts | 8 +- .../runtime/terminal-output-pump.test.ts | 34 +- .../terminal/runtime/terminal-output-pump.ts | 28 +- .../runtime/terminal-stream-controller.ts | 4 +- packages/app/src/utils/host-routes.test.ts | 29 + packages/app/src/utils/host-routes.ts | 86 +++ packages/app/src/utils/to-xterm-theme.ts | 35 ++ packages/desktop/package.json | 3 +- 25 files changed, 1214 insertions(+), 696 deletions(-) create mode 100644 packages/app/src/app/h/[serverId]/workspace/[workspaceId]/file/[fileId].tsx create mode 100644 packages/app/src/components/file-pane.tsx create mode 100644 packages/app/src/stores/workspace-file-tabs-store.ts create mode 100644 packages/app/src/utils/to-xterm-theme.ts diff --git a/package-lock.json b/package-lock.json index 4de9630ce..4703178c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/packages/app/package.json b/packages/app/package.json index 204173c67..88d532537 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/file/[fileId].tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/file/[fileId].tsx new file mode 100644 index 000000000..0516169d7 --- /dev/null +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/file/[fileId].tsx @@ -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 ( + + ); +} + diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index a14e571b4..df7908374 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -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(null); const bottomAnchorRef = useRef(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({ // 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(" "); diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index cf4c5ff0f..b4a3e578f 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -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} /> @@ -324,6 +327,7 @@ export function ExplorerSidebar({ workspaceRoot={workspaceRoot} isGit={isGit} isMobile={false} + onOpenFile={onOpenFile} /> ); @@ -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} /> )} diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index f5d23f755..ee05a72ff 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -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>(() => new Set(["."])); - const [containerWidth, setContainerWidth] = useState(0); - const wasInlinePreviewVisibleRef = useRef(false); const treeListRef = useRef>(null); const treeScrollbarMetrics = useWebDesktopScrollbarMetrics(); - // Bottom sheet for file preview (mobile) - const previewSheetRef = useRef(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) => { 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) => ( - - ), - [] - ); - const handleBackFromError = useCallback(() => { if (!hasWorkspaceScope) { return; @@ -667,7 +528,6 @@ export function FileExplorerPane({ return ( setContainerWidth(event.nativeEvent.layout.width)} > {error ? ( @@ -701,385 +561,66 @@ export function FileExplorerPane({ No files ) : ( - - {shouldShowInlinePreview ? ( - - - - {selectedEntryPath?.split("/").pop() ?? "Preview"} - - - {isPreviewLoading ? ( - - ) : null} - [ - styles.iconButton, - (hovered || pressed) && styles.iconButtonHovered, - ]} - accessibilityRole="button" - accessibilityLabel="Close preview" - > - - - - - - + + + + + [ + styles.iconButton, + (hovered || pressed) && styles.iconButtonHovered, + pressed && styles.iconButtonPressed, + ]} + accessibilityRole="button" + accessibilityLabel="Refresh files" + > + + + + + + {currentSortLabel} + - ) : null} - - {shouldShowInlinePreview ? ( - - - - - - - - [ - styles.iconButton, - (hovered || pressed) && styles.iconButtonHovered, - pressed && styles.iconButtonPressed, - ]} - accessibilityRole="button" - accessibilityLabel="Refresh files" - > - - - - - - {currentSortLabel} - - - - 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} - /> - { - treeListRef.current?.scrollToOffset({ - offset: nextOffset, - animated: false, - }); - }} - /> - - ) : ( - - - - - [ - styles.iconButton, - (hovered || pressed) && styles.iconButtonHovered, - pressed && styles.iconButtonPressed, - ]} - accessibilityRole="button" - accessibilityLabel="Refresh files" - > - - - - - - {currentSortLabel} - - - - 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} - /> - { - treeListRef.current?.scrollToOffset({ - offset: nextOffset, - animated: false, - }); - }} - /> - - )} + + 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} + /> + { + treeListRef.current?.scrollToOffset({ + offset: nextOffset, + animated: false, + }); + }} + /> )} - - {isMobile ? ( - - - - {selectedEntryPath?.split("/").pop() ?? "Preview"} - - - - - - - - ) : null} ); } -function FilePreviewBody({ - preview, - isLoading, - variant, - showDesktopWebScrollbar, -}: { - preview: ExplorerFile | null; - isLoading: boolean; - variant: "inline" | "sheet"; - showDesktopWebScrollbar: boolean; -}) { - const enablePreviewDesktopScrollbar = - variant === "inline" && showDesktopWebScrollbar; - const previewScrollRef = useRef(null); - const previewScrollbarMetrics = useWebDesktopScrollbarMetrics(); - - const handlePreviewScroll = useCallback( - (event: NativeSyntheticEvent) => { - if (enablePreviewDesktopScrollbar) { - previewScrollbarMetrics.onScroll(event); - } - }, - [enablePreviewDesktopScrollbar, previewScrollbarMetrics] - ); - - const handlePreviewLayout = useCallback( - (event: LayoutChangeEvent) => { - if (enablePreviewDesktopScrollbar) { - previewScrollbarMetrics.onLayout(event); - } - }, - [enablePreviewDesktopScrollbar, previewScrollbarMetrics] - ); - - if (isLoading && !preview) { - return ( - - - Loading file… - - ); - } - - if (!preview) { - return ( - - No preview available - - ); - } - - if (preview.kind === "text") { - if (variant === "sheet") { - return ( - - - {preview.content} - - - ); - } - return ( - - - - {preview.content} - - - { - previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false }); - }} - /> - - ); - } - - if (preview.kind === "image" && preview.content) { - if (variant === "sheet") { - return ( - - - - ); - } - return ( - - - - - { - previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false }); - }} - /> - - ); - } - - return ( - - Binary preview unavailable - {formatFileSize({ size: preview.size })} - - ); -} - -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([ diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx new file mode 100644 index 000000000..ff699d036 --- /dev/null +++ b/packages/app/src/components/file-pane.tsx @@ -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(null); + const previewScrollbarMetrics = useWebDesktopScrollbarMetrics(); + + const handlePreviewScroll = useCallback( + (event: NativeSyntheticEvent) => { + if (enablePreviewDesktopScrollbar) { + previewScrollbarMetrics.onScroll(event); + } + }, + [enablePreviewDesktopScrollbar, previewScrollbarMetrics] + ); + + const handlePreviewLayout = useCallback( + (event: LayoutChangeEvent) => { + if (enablePreviewDesktopScrollbar) { + previewScrollbarMetrics.onLayout(event); + } + }, + [enablePreviewDesktopScrollbar, previewScrollbarMetrics] + ); + + if (isLoading && !preview) { + return ( + + + Loading file… + + ); + } + + if (!preview) { + return ( + + No preview available + + ); + } + + if (preview.kind === "text") { + return ( + + + + {preview.content} + + + { + previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false }); + }} + /> + + ); + } + + if (preview.kind === "image" && preview.content) { + return ( + + + + + { + previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false }); + }} + /> + + ); + } + + return ( + + Binary preview unavailable + {formatFileSize({ size: preview.size })} + + ); +} + +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 ( + + {query.data?.error ? ( + + {query.data.error} + + ) : null} + + + + ); +} + +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, + }, +})); diff --git a/packages/app/src/components/sortable-inline-list.web.tsx b/packages/app/src/components/sortable-inline-list.web.tsx index 8405a61be..68ba549af 100644 --- a/packages/app/src/components/sortable-inline-list.web.tsx +++ b/packages/app/src/components/sortable-inline-list.web.tsx @@ -57,7 +57,10 @@ function SortableItem({ // 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(" "); diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index 9ddf65442..05b23b396 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -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", diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index a7a2bff13..b10ed4c84 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -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({ 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) { diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 0b16faf97..e767025e0 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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(() => { - 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(); 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(() => { + 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(null); const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState( 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 ( + + ); + } + return ( ; } + if (activeDescriptor.kind === "file") { + return ; + } + const tabAgent = agentsById.get(activeDescriptor.agentId) ?? null; const tabAgentStatusBucket = tabAgent ? deriveSidebarStateBucket({ @@ -1416,6 +1591,8 @@ function WorkspaceScreenContent({ 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({ ) : ( - - + + @@ -1527,6 +1704,8 @@ function WorkspaceScreenContent({ /> ) : null} + ) : tab.kind === "file" ? ( + ) : ( ); @@ -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({ 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({ /> ) : ( - - + @@ -1759,6 +1954,7 @@ function WorkspaceScreenContent({ workspaceId={normalizedWorkspaceId} workspaceRoot={normalizedWorkspaceId} isGit={isGitCheckout} + onOpenFile={handleOpenFileFromExplorer} /> @@ -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, diff --git a/packages/app/src/stores/workspace-file-tabs-store.ts b/packages/app/src/stores/workspace-file-tabs-store.ts new file mode 100644 index 000000000..e8955d777 --- /dev/null +++ b/packages/app/src/stores/workspace-file-tabs-store.ts @@ -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; + 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()( + (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); + }, + }) +); + diff --git a/packages/app/src/stores/workspace-tabs-store.ts b/packages/app/src/stores/workspace-tabs-store.ts index 6c443d4db..8b0c7e841 100644 --- a/packages/app/src/stores/workspace-tabs-store.ts +++ b/packages/app/src/stores/workspace-tabs-store.ts @@ -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(); @@ -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; } diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index b5fa9a93d..0f0663198 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -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 = { diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index dfe09ed24..9f596aa97 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -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 | 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; diff --git a/packages/app/src/terminal/runtime/terminal-output-delivery-queue.test.ts b/packages/app/src/terminal/runtime/terminal-output-delivery-queue.test.ts index d901e7e4c..f709349bc 100644 --- a/packages/app/src/terminal/runtime/terminal-output-delivery-queue.test.ts +++ b/packages/app/src/terminal/runtime/terminal-output-delivery-queue.test.ts @@ -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 }, ]); }); }); diff --git a/packages/app/src/terminal/runtime/terminal-output-delivery-queue.ts b/packages/app/src/terminal/runtime/terminal-output-delivery-queue.ts index c6f89ab0e..a3d477973 100644 --- a/packages/app/src/terminal/runtime/terminal-output-delivery-queue.ts +++ b/packages/app/src/terminal/runtime/terminal-output-delivery-queue.ts @@ -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, }); } diff --git a/packages/app/src/terminal/runtime/terminal-output-pump.test.ts b/packages/app/src/terminal/runtime/terminal-output-pump.test.ts index 76c14cdfd..510fc6272 100644 --- a/packages/app/src/terminal/runtime/terminal-output-pump.test.ts +++ b/packages/app/src/terminal/runtime/terminal-output-pump.test.ts @@ -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(""); diff --git a/packages/app/src/terminal/runtime/terminal-output-pump.ts b/packages/app/src/terminal/runtime/terminal-output-pump.ts index 40ccb54ac..f1834005c 100644 --- a/packages/app/src/terminal/runtime/terminal-output-pump.ts +++ b/packages/app/src/terminal/runtime/terminal-output-pump.ts @@ -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 | 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, }); } diff --git a/packages/app/src/terminal/runtime/terminal-stream-controller.ts b/packages/app/src/terminal/runtime/terminal-stream-controller.ts index 9ef9e3a5f..d768a2c6d 100644 --- a/packages/app/src/terminal/runtime/terminal-stream-controller.ts +++ b/packages/app/src/terminal/runtime/terminal-stream-controller.ts @@ -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 { diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index 205e8bdfc..b0dd0dfb2 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -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")}` + ); + }); }); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 48224363f..3cab853ed 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.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, diff --git a/packages/app/src/utils/to-xterm-theme.ts b/packages/app/src/utils/to-xterm-theme.ts new file mode 100644 index 000000000..3c9907250 --- /dev/null +++ b/packages/app/src/utils/to-xterm-theme.ts @@ -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, + }; +} + diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 5f2ce5ee1..ef2b1552c 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -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" } }