feat: add file tabs and improve terminal rendering with WebGL
This commit is contained in:
Generated
+16
-2
@@ -6434,6 +6434,7 @@
|
|||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
@@ -7210,6 +7211,18 @@
|
|||||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@xterm/headless": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -21586,6 +21599,8 @@
|
|||||||
"@tanstack/react-query": "^5.90.11",
|
"@tanstack/react-query": "^5.90.11",
|
||||||
"@tauri-apps/api": "^2.9.1",
|
"@tauri-apps/api": "^2.9.1",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-unicode11": "^0.9.0",
|
||||||
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
@@ -21726,8 +21741,7 @@
|
|||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.16",
|
"version": "0.1.16",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.6",
|
"@tauri-apps/cli": "^2.9.6"
|
||||||
"@tauri-apps/cli-linux-x64-gnu": "^2.9.6"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
|
|||||||
@@ -52,6 +52,8 @@
|
|||||||
"@tanstack/react-query": "^5.90.11",
|
"@tanstack/react-query": "^5.90.11",
|
||||||
"@tauri-apps/api": "^2.9.1",
|
"@tauri-apps/api": "^2.9.1",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-unicode11": "^0.9.0",
|
||||||
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"buffer": "^6.0.3",
|
"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 { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
FadeIn,
|
FadeIn,
|
||||||
FadeOut,
|
FadeOut,
|
||||||
@@ -81,6 +82,7 @@ import { createMarkdownStyles } from "@/styles/markdown-styles";
|
|||||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
||||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||||
|
import { buildHostWorkspaceFileRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
||||||
const isToolSequenceItem = (item?: StreamItem) =>
|
const isToolSequenceItem = (item?: StreamItem) =>
|
||||||
@@ -129,6 +131,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
const scrollViewRef = useRef<ScrollView>(null);
|
const scrollViewRef = useRef<ScrollView>(null);
|
||||||
const bottomAnchorRef = useRef<View>(null);
|
const bottomAnchorRef = useRef<View>(null);
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
const router = useRouter();
|
||||||
const isMobile =
|
const isMobile =
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const streamRenderStrategy = useMemo(
|
const streamRenderStrategy = useMemo(
|
||||||
@@ -174,13 +177,12 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
);
|
);
|
||||||
|
|
||||||
const workspaceRoot = agent.cwd?.trim() || "";
|
const workspaceRoot = agent.cwd?.trim() || "";
|
||||||
const workspaceId = agent.projectPlacement?.checkout?.cwd ?? workspaceRoot;
|
const workspaceId = agent.projectPlacement?.checkout?.cwd?.trim() || workspaceRoot;
|
||||||
const { requestDirectoryListing, requestFilePreview, selectExplorerEntry } =
|
const { requestDirectoryListing } = useFileExplorerActions({
|
||||||
useFileExplorerActions({
|
serverId: resolvedServerId,
|
||||||
serverId: resolvedServerId,
|
workspaceId,
|
||||||
workspaceId,
|
workspaceRoot,
|
||||||
workspaceRoot,
|
});
|
||||||
});
|
|
||||||
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
|
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
|
||||||
// tracked in react-native-reanimated#8422.
|
// tracked in react-native-reanimated#8422.
|
||||||
const shouldDisableEntryExitAnimations = Platform.OS === "android";
|
const shouldDisableEntryExitAnimations = Platform.OS === "android";
|
||||||
@@ -210,14 +212,20 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalized.file) {
|
||||||
|
const route = buildHostWorkspaceFileRoute(
|
||||||
|
resolvedServerId,
|
||||||
|
workspaceId,
|
||||||
|
normalized.file
|
||||||
|
);
|
||||||
|
router.replace(route as any);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void requestDirectoryListing(normalized.directory, {
|
void requestDirectoryListing(normalized.directory, {
|
||||||
recordHistory: false,
|
recordHistory: false,
|
||||||
setCurrentPath: false,
|
setCurrentPath: false,
|
||||||
});
|
});
|
||||||
if (normalized.file) {
|
|
||||||
selectExplorerEntry(normalized.file);
|
|
||||||
void requestFilePreview(normalized.file);
|
|
||||||
}
|
|
||||||
|
|
||||||
setExplorerTabForCheckout({
|
setExplorerTabForCheckout({
|
||||||
serverId: resolvedServerId,
|
serverId: resolvedServerId,
|
||||||
@@ -229,11 +237,12 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
agent.cwd,
|
agent.cwd,
|
||||||
requestDirectoryListing,
|
|
||||||
requestFilePreview,
|
|
||||||
selectExplorerEntry,
|
|
||||||
setExplorerTabForCheckout,
|
|
||||||
openFileExplorer,
|
openFileExplorer,
|
||||||
|
requestDirectoryListing,
|
||||||
|
resolvedServerId,
|
||||||
|
router,
|
||||||
|
setExplorerTabForCheckout,
|
||||||
|
workspaceId,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,13 @@ function SortableItem<T>({
|
|||||||
// Trigger drag - handled by dnd-kit's listeners
|
// 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 scaleTransform = isDragging ? "scale(1.02)" : "";
|
||||||
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
|
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ interface ExplorerSidebarProps {
|
|||||||
workspaceId?: string | null;
|
workspaceId?: string | null;
|
||||||
workspaceRoot: string;
|
workspaceRoot: string;
|
||||||
isGit: boolean;
|
isGit: boolean;
|
||||||
|
onOpenFile?: (filePath: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExplorerSidebar({
|
export function ExplorerSidebar({
|
||||||
@@ -44,6 +45,7 @@ export function ExplorerSidebar({
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
isGit,
|
isGit,
|
||||||
|
onOpenFile,
|
||||||
}: ExplorerSidebarProps) {
|
}: ExplorerSidebarProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isScreenFocused = useIsFocused();
|
const isScreenFocused = useIsFocused();
|
||||||
@@ -289,6 +291,7 @@ export function ExplorerSidebar({
|
|||||||
workspaceRoot={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
isGit={isGit}
|
isGit={isGit}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
onOpenFile={onOpenFile}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
@@ -324,6 +327,7 @@ export function ExplorerSidebar({
|
|||||||
workspaceRoot={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
isGit={isGit}
|
isGit={isGit}
|
||||||
isMobile={false}
|
isMobile={false}
|
||||||
|
onOpenFile={onOpenFile}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
@@ -338,6 +342,7 @@ interface SidebarContentProps {
|
|||||||
workspaceRoot: string;
|
workspaceRoot: string;
|
||||||
isGit: boolean;
|
isGit: boolean;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
|
onOpenFile?: (filePath: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarContent({
|
function SidebarContent({
|
||||||
@@ -349,6 +354,7 @@ function SidebarContent({
|
|||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
isGit,
|
isGit,
|
||||||
isMobile,
|
isMobile,
|
||||||
|
onOpenFile,
|
||||||
}: SidebarContentProps) {
|
}: SidebarContentProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const resolvedTab: ExplorerTab =
|
const resolvedTab: ExplorerTab =
|
||||||
@@ -413,6 +419,7 @@ function SidebarContent({
|
|||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
workspaceRoot={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
|
onOpenFile={onOpenFile}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -3,18 +3,16 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
FlatList,
|
FlatList,
|
||||||
Image as RNImage,
|
|
||||||
ListRenderItemInfo,
|
ListRenderItemInfo,
|
||||||
type LayoutChangeEvent,
|
type LayoutChangeEvent,
|
||||||
type NativeScrollEvent,
|
type NativeScrollEvent,
|
||||||
type NativeSyntheticEvent,
|
type NativeSyntheticEvent,
|
||||||
Pressable,
|
Pressable,
|
||||||
ScrollView as RNScrollView,
|
|
||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
Platform,
|
Platform,
|
||||||
} from "react-native";
|
} 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 { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
cancelAnimation,
|
cancelAnimation,
|
||||||
@@ -27,11 +25,6 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import {
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetScrollView,
|
|
||||||
BottomSheetBackdrop,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import {
|
import {
|
||||||
Copy,
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
@@ -47,7 +40,6 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
AgentFileExplorerState,
|
AgentFileExplorerState,
|
||||||
ExplorerEntry,
|
ExplorerEntry,
|
||||||
ExplorerFile,
|
|
||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
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 { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
||||||
import {
|
import {
|
||||||
usePanelStore,
|
usePanelStore,
|
||||||
DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
|
|
||||||
type SortOption,
|
type SortOption,
|
||||||
} from "@/stores/panel-store";
|
} from "@/stores/panel-store";
|
||||||
import { formatTimeAgo } from "@/utils/time";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
@@ -80,10 +71,21 @@ const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
|||||||
|
|
||||||
const INDENT_PER_LEVEL = 12;
|
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 {
|
interface FileExplorerPaneProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId?: string | null;
|
workspaceId?: string | null;
|
||||||
workspaceRoot: string;
|
workspaceRoot: string;
|
||||||
|
onOpenFile?: (filePath: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TreeRow {
|
interface TreeRow {
|
||||||
@@ -95,6 +97,7 @@ export function FileExplorerPane({
|
|||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
|
onOpenFile,
|
||||||
}: FileExplorerPaneProps) {
|
}: FileExplorerPaneProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile =
|
const isMobile =
|
||||||
@@ -132,7 +135,6 @@ export function FileExplorerPane({
|
|||||||
const {
|
const {
|
||||||
workspaceStateKey: actionsWorkspaceStateKey,
|
workspaceStateKey: actionsWorkspaceStateKey,
|
||||||
requestDirectoryListing,
|
requestDirectoryListing,
|
||||||
requestFilePreview,
|
|
||||||
requestFileDownloadToken,
|
requestFileDownloadToken,
|
||||||
selectExplorerEntry,
|
selectExplorerEntry,
|
||||||
} = useFileExplorerActions({
|
} = useFileExplorerActions({
|
||||||
@@ -142,23 +144,13 @@ export function FileExplorerPane({
|
|||||||
});
|
});
|
||||||
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
||||||
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
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 directories = explorerState?.directories ?? new Map();
|
||||||
const files = explorerState?.files ?? new Map();
|
|
||||||
const pendingRequest = explorerState?.pendingRequest ?? null;
|
const pendingRequest = explorerState?.pendingRequest ?? null;
|
||||||
const isExplorerLoading = explorerState?.isLoading ?? false;
|
const isExplorerLoading = explorerState?.isLoading ?? false;
|
||||||
const error = explorerState?.lastError ?? null;
|
const error = explorerState?.lastError ?? null;
|
||||||
const selectedEntryPath = explorerState?.selectedEntryPath ?? 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(
|
const isDirectoryLoading = useCallback(
|
||||||
(path: string) =>
|
(path: string) =>
|
||||||
Boolean(
|
Boolean(
|
||||||
@@ -168,15 +160,9 @@ export function FileExplorerPane({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
|
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
|
||||||
const [containerWidth, setContainerWidth] = useState(0);
|
|
||||||
const wasInlinePreviewVisibleRef = useRef(false);
|
|
||||||
const treeListRef = useRef<FlatList<TreeRow>>(null);
|
const treeListRef = useRef<FlatList<TreeRow>>(null);
|
||||||
const treeScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
const treeScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||||
|
|
||||||
// Bottom sheet for file preview (mobile)
|
|
||||||
const previewSheetRef = useRef<BottomSheetModal>(null);
|
|
||||||
const previewSnapPoints = useMemo(() => ["70%", "95%"], []);
|
|
||||||
|
|
||||||
const hasInitializedRef = useRef(false);
|
const hasInitializedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -222,22 +208,6 @@ export function FileExplorerPane({
|
|||||||
});
|
});
|
||||||
}, [directories, hasWorkspaceScope, requestDirectoryListing, selectedEntryPath]);
|
}, [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(
|
const handleToggleDirectory = useCallback(
|
||||||
(entry: ExplorerEntry) => {
|
(entry: ExplorerEntry) => {
|
||||||
if (!hasWorkspaceScope) {
|
if (!hasWorkspaceScope) {
|
||||||
@@ -272,9 +242,9 @@ export function FileExplorerPane({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
selectExplorerEntry(entry.path);
|
selectExplorerEntry(entry.path);
|
||||||
void requestFilePreview(entry.path);
|
onOpenFile?.(entry.path);
|
||||||
},
|
},
|
||||||
[hasWorkspaceScope, requestFilePreview, selectExplorerEntry]
|
[hasWorkspaceScope, onOpenFile, selectExplorerEntry]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleEntryPress = useCallback(
|
const handleEntryPress = useCallback(
|
||||||
@@ -342,7 +312,6 @@ export function FileExplorerPane({
|
|||||||
setCurrentPath: false,
|
setCurrentPath: false,
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
...(selectedEntryPath ? [requestFilePreview(selectedEntryPath)] : []),
|
|
||||||
]);
|
]);
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
@@ -420,93 +389,6 @@ export function FileExplorerPane({
|
|||||||
[explorerState]
|
[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(
|
const renderTreeRow = useCallback(
|
||||||
({ item }: ListRenderItemInfo<TreeRow>) => {
|
({ item }: ListRenderItemInfo<TreeRow>) => {
|
||||||
const entry = item.entry;
|
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(() => {
|
const handleBackFromError = useCallback(() => {
|
||||||
if (!hasWorkspaceScope) {
|
if (!hasWorkspaceScope) {
|
||||||
return;
|
return;
|
||||||
@@ -667,7 +528,6 @@ export function FileExplorerPane({
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={styles.container}
|
style={styles.container}
|
||||||
onLayout={(event) => setContainerWidth(event.nativeEvent.layout.width)}
|
|
||||||
>
|
>
|
||||||
{error ? (
|
{error ? (
|
||||||
<View style={styles.centerState}>
|
<View style={styles.centerState}>
|
||||||
@@ -701,385 +561,66 @@ export function FileExplorerPane({
|
|||||||
<Text style={styles.emptyText}>No files</Text>
|
<Text style={styles.emptyText}>No files</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.desktopSplit}>
|
<View style={[styles.treePane, styles.treePaneFill]}>
|
||||||
{shouldShowInlinePreview ? (
|
<View style={styles.paneHeader} testID="files-pane-header">
|
||||||
<View style={styles.previewPane}>
|
<View style={styles.paneHeaderLeft} />
|
||||||
<View style={styles.paneHeader} testID="preview-pane-header">
|
<View style={styles.paneHeaderRight}>
|
||||||
<Text style={styles.previewHeaderText} numberOfLines={1}>
|
<Pressable
|
||||||
{selectedEntryPath?.split("/").pop() ?? "Preview"}
|
onPress={handleRefresh}
|
||||||
</Text>
|
disabled={isRefreshFetching}
|
||||||
<View style={styles.previewHeaderRight}>
|
hitSlop={8}
|
||||||
{isPreviewLoading ? (
|
style={({ hovered, pressed }) => [
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
styles.iconButton,
|
||||||
) : null}
|
(hovered || pressed) && styles.iconButtonHovered,
|
||||||
<Pressable
|
pressed && styles.iconButtonPressed,
|
||||||
onPress={handleClosePreview}
|
]}
|
||||||
hitSlop={8}
|
accessibilityRole="button"
|
||||||
style={({ hovered, pressed }) => [
|
accessibilityLabel="Refresh files"
|
||||||
styles.iconButton,
|
>
|
||||||
(hovered || pressed) && styles.iconButtonHovered,
|
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
|
||||||
]}
|
<RotateCw size={16} color={theme.colors.foregroundMuted} />
|
||||||
accessibilityRole="button"
|
</Animated.View>
|
||||||
accessibilityLabel="Close preview"
|
</Pressable>
|
||||||
>
|
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
|
||||||
<X size={16} color={theme.colors.foregroundMuted} />
|
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<FilePreviewBody
|
|
||||||
preview={preview}
|
|
||||||
isLoading={isPreviewLoading}
|
|
||||||
variant="inline"
|
|
||||||
showDesktopWebScrollbar={showDesktopWebScrollbar}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
</View>
|
||||||
|
<FlatList
|
||||||
{shouldShowInlinePreview ? (
|
ref={treeListRef}
|
||||||
<Animated.View
|
style={styles.treeList}
|
||||||
style={[
|
data={treeRows}
|
||||||
styles.treePane,
|
renderItem={renderTreeRow}
|
||||||
styles.treePaneWithPreview,
|
keyExtractor={(row) => row.entry.path}
|
||||||
{ minWidth: minTreeWidth },
|
testID="file-explorer-tree-scroll"
|
||||||
treePaneAnimatedStyle,
|
contentContainerStyle={styles.entriesContent}
|
||||||
]}
|
onLayout={showDesktopWebScrollbar ? handleTreeListLayout : undefined}
|
||||||
>
|
onScroll={showDesktopWebScrollbar ? handleTreeListScroll : undefined}
|
||||||
<GestureDetector gesture={splitResizeGesture}>
|
onContentSizeChange={
|
||||||
<View
|
showDesktopWebScrollbar ? treeScrollbarMetrics.onContentSizeChange : undefined
|
||||||
style={[
|
}
|
||||||
styles.splitResizeHandle,
|
scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined}
|
||||||
Platform.OS === "web" && ({ cursor: "col-resize" } as any),
|
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||||
Platform.OS === "web" && ({ touchAction: "none", userSelect: "none" } as any),
|
initialNumToRender={24}
|
||||||
]}
|
maxToRenderPerBatch={40}
|
||||||
/>
|
windowSize={12}
|
||||||
</GestureDetector>
|
/>
|
||||||
<View style={styles.paneHeader} testID="files-pane-header">
|
<WebDesktopScrollbarOverlay
|
||||||
<View style={styles.paneHeaderLeft} />
|
enabled={showDesktopWebScrollbar}
|
||||||
<View style={styles.paneHeaderRight}>
|
metrics={treeScrollbarMetrics}
|
||||||
<Pressable
|
onScrollToOffset={(nextOffset) => {
|
||||||
onPress={handleRefresh}
|
treeListRef.current?.scrollToOffset({
|
||||||
disabled={isRefreshFetching}
|
offset: nextOffset,
|
||||||
hitSlop={8}
|
animated: false,
|
||||||
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>
|
</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>
|
</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";
|
type EntryDisplayKind = "directory" | "image" | "text" | "other";
|
||||||
|
|
||||||
const IMAGE_EXTENSIONS = new Set([
|
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
|
// 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 scaleTransform = isDragging ? "scale(1.01)" : "";
|
||||||
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
|
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import type { DOMProps } from "expo/dom";
|
import type { DOMProps } from "expo/dom";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
import type { ITheme } from "@xterm/xterm";
|
||||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||||
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
||||||
|
|
||||||
@@ -12,10 +13,9 @@ interface TerminalEmulatorProps {
|
|||||||
initialOutputText: string;
|
initialOutputText: string;
|
||||||
outputChunkText: string;
|
outputChunkText: string;
|
||||||
outputChunkSequence: number;
|
outputChunkSequence: number;
|
||||||
|
outputChunkReplay?: boolean;
|
||||||
testId?: string;
|
testId?: string;
|
||||||
backgroundColor?: string;
|
xtermTheme?: ITheme;
|
||||||
foregroundColor?: string;
|
|
||||||
cursorColor?: string;
|
|
||||||
swipeGesturesEnabled?: boolean;
|
swipeGesturesEnabled?: boolean;
|
||||||
onSwipeLeft?: () => void;
|
onSwipeLeft?: () => void;
|
||||||
onSwipeRight?: () => void;
|
onSwipeRight?: () => void;
|
||||||
@@ -44,10 +44,13 @@ export default function TerminalEmulator({
|
|||||||
initialOutputText,
|
initialOutputText,
|
||||||
outputChunkText,
|
outputChunkText,
|
||||||
outputChunkSequence,
|
outputChunkSequence,
|
||||||
|
outputChunkReplay = false,
|
||||||
testId = "terminal-surface",
|
testId = "terminal-surface",
|
||||||
backgroundColor = "#0b0b0b",
|
xtermTheme = {
|
||||||
foregroundColor = "#e6e6e6",
|
background: "#0b0b0b",
|
||||||
cursorColor = "#e6e6e6",
|
foreground: "#e6e6e6",
|
||||||
|
cursor: "#e6e6e6",
|
||||||
|
},
|
||||||
swipeGesturesEnabled = false,
|
swipeGesturesEnabled = false,
|
||||||
onSwipeLeft,
|
onSwipeLeft,
|
||||||
onSwipeRight,
|
onSwipeRight,
|
||||||
@@ -203,11 +206,7 @@ export default function TerminalEmulator({
|
|||||||
root,
|
root,
|
||||||
host,
|
host,
|
||||||
initialOutputText,
|
initialOutputText,
|
||||||
theme: {
|
theme: xtermTheme,
|
||||||
backgroundColor,
|
|
||||||
foregroundColor,
|
|
||||||
cursorColor,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
appliedInitialOutputRef.current = initialOutputText;
|
appliedInitialOutputRef.current = initialOutputText;
|
||||||
|
|
||||||
@@ -218,7 +217,7 @@ export default function TerminalEmulator({
|
|||||||
}
|
}
|
||||||
appliedInitialOutputRef.current = null;
|
appliedInitialOutputRef.current = null;
|
||||||
};
|
};
|
||||||
}, [backgroundColor, cursorColor, foregroundColor, streamKey]);
|
}, [streamKey, xtermTheme]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const runtime = runtimeRef.current;
|
const runtime = runtimeRef.current;
|
||||||
@@ -273,11 +272,12 @@ export default function TerminalEmulator({
|
|||||||
}
|
}
|
||||||
runtime.write({
|
runtime.write({
|
||||||
text: outputChunkText,
|
text: outputChunkText,
|
||||||
|
suppressInput: outputChunkReplay,
|
||||||
onCommitted: () => {
|
onCommitted: () => {
|
||||||
onOutputChunkConsumed?.(outputChunkSequence);
|
onOutputChunkConsumed?.(outputChunkSequence);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [onOutputChunkConsumed, outputChunkSequence, outputChunkText]);
|
}, [onOutputChunkConsumed, outputChunkReplay, outputChunkSequence, outputChunkText]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focusRequestToken <= 0) {
|
if (focusRequestToken <= 0) {
|
||||||
@@ -304,7 +304,7 @@ export default function TerminalEmulator({
|
|||||||
height: "100%",
|
height: "100%",
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
backgroundColor,
|
backgroundColor: xtermTheme.background ?? "#0b0b0b",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
overscrollBehavior: "none",
|
overscrollBehavior: "none",
|
||||||
touchAction: "pan-y",
|
touchAction: "pan-y",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
terminalDebugLog,
|
terminalDebugLog,
|
||||||
} from "@/terminal/runtime/terminal-debug";
|
} from "@/terminal/runtime/terminal-debug";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
|
import { toXtermTheme } from "@/utils/to-xterm-theme";
|
||||||
import TerminalEmulator from "./terminal-emulator";
|
import TerminalEmulator from "./terminal-emulator";
|
||||||
|
|
||||||
interface TerminalPaneProps {
|
interface TerminalPaneProps {
|
||||||
@@ -84,6 +85,7 @@ type ModifierState = {
|
|||||||
type TerminalOutputChunkState = {
|
type TerminalOutputChunkState = {
|
||||||
sequence: number;
|
sequence: number;
|
||||||
text: string;
|
text: string;
|
||||||
|
replay: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PendingTerminalInput =
|
type PendingTerminalInput =
|
||||||
@@ -146,6 +148,7 @@ export function TerminalPane({
|
|||||||
manageTerminalDirectorySubscription = true,
|
manageTerminalDirectorySubscription = true,
|
||||||
}: TerminalPaneProps) {
|
}: TerminalPaneProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]);
|
||||||
const isMobile =
|
const isMobile =
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
@@ -178,6 +181,7 @@ export function TerminalPane({
|
|||||||
const [selectedOutputChunk, setSelectedOutputChunk] = useState<TerminalOutputChunkState>({
|
const [selectedOutputChunk, setSelectedOutputChunk] = useState<TerminalOutputChunkState>({
|
||||||
sequence: 0,
|
sequence: 0,
|
||||||
text: "",
|
text: "",
|
||||||
|
replay: false,
|
||||||
});
|
});
|
||||||
const [selectedOutputSnapshot, setSelectedOutputSnapshot] = useState("");
|
const [selectedOutputSnapshot, setSelectedOutputSnapshot] = useState("");
|
||||||
const [activeStream, setActiveStream] = useState<{
|
const [activeStream, setActiveStream] = useState<{
|
||||||
@@ -600,8 +604,8 @@ export function TerminalPane({
|
|||||||
const controller = new TerminalStreamController({
|
const controller = new TerminalStreamController({
|
||||||
client,
|
client,
|
||||||
getPreferredSize: () => lastReportedSizeRef.current,
|
getPreferredSize: () => lastReportedSizeRef.current,
|
||||||
onChunk: ({ terminalId, text }) => {
|
onChunk: ({ terminalId, text, replay }) => {
|
||||||
outputPump.append({ terminalId, text });
|
outputPump.append({ terminalId, text, replay });
|
||||||
},
|
},
|
||||||
onReset: ({ terminalId }) => {
|
onReset: ({ terminalId }) => {
|
||||||
outputPump.clearTerminal({ terminalId });
|
outputPump.clearTerminal({ terminalId });
|
||||||
@@ -626,7 +630,7 @@ export function TerminalPane({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
outputDeliveryQueueRef.current?.reset();
|
outputDeliveryQueueRef.current?.reset();
|
||||||
pendingTerminalInputRef.current = [];
|
pendingTerminalInputRef.current = [];
|
||||||
setSelectedOutputChunk({ sequence: 0, text: "" });
|
setSelectedOutputChunk({ sequence: 0, text: "", replay: false });
|
||||||
outputPumpRef.current?.setSelectedTerminal({
|
outputPumpRef.current?.setSelectedTerminal({
|
||||||
terminalId: selectedTerminalId,
|
terminalId: selectedTerminalId,
|
||||||
});
|
});
|
||||||
@@ -1122,10 +1126,9 @@ export function TerminalPane({
|
|||||||
initialOutputText={selectedOutputSnapshot}
|
initialOutputText={selectedOutputSnapshot}
|
||||||
outputChunkText={selectedOutputChunk.text}
|
outputChunkText={selectedOutputChunk.text}
|
||||||
outputChunkSequence={selectedOutputChunk.sequence}
|
outputChunkSequence={selectedOutputChunk.sequence}
|
||||||
|
outputChunkReplay={selectedOutputChunk.replay}
|
||||||
testId="terminal-surface"
|
testId="terminal-surface"
|
||||||
backgroundColor={theme.colors.background}
|
xtermTheme={xtermTheme}
|
||||||
foregroundColor={theme.colors.foreground}
|
|
||||||
cursorColor={theme.colors.foreground}
|
|
||||||
swipeGesturesEnabled={swipeGesturesEnabled}
|
swipeGesturesEnabled={swipeGesturesEnabled}
|
||||||
onSwipeRight={() => {
|
onSwipeRight={() => {
|
||||||
if (!swipeGesturesEnabled) {
|
if (!swipeGesturesEnabled) {
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ import * as Clipboard from "expo-clipboard";
|
|||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
FileText,
|
||||||
Folder,
|
Folder,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
PanelRight,
|
PanelRight,
|
||||||
Plus,
|
Plus,
|
||||||
|
SquareTerminal,
|
||||||
Terminal,
|
Terminal,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
@@ -45,6 +47,7 @@ import {
|
|||||||
} from "@/components/ui/context-menu";
|
} from "@/components/ui/context-menu";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
||||||
|
import { FilePane } from "@/components/file-pane";
|
||||||
import { TerminalPane } from "@/components/terminal-pane";
|
import { TerminalPane } from "@/components/terminal-pane";
|
||||||
import { SortableInlineList } from "@/components/sortable-inline-list";
|
import { SortableInlineList } from "@/components/sortable-inline-list";
|
||||||
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
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 { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||||
import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store";
|
import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store";
|
||||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||||
|
import { useWorkspaceFileTabsStore } from "@/stores/workspace-file-tabs-store";
|
||||||
import {
|
import {
|
||||||
buildWorkspaceTabPersistenceKey,
|
buildWorkspaceTabPersistenceKey,
|
||||||
useWorkspaceTabsStore,
|
useWorkspaceTabsStore,
|
||||||
@@ -61,7 +65,9 @@ import {
|
|||||||
buildHostAgentDetailRoute,
|
buildHostAgentDetailRoute,
|
||||||
buildHostWorkspaceRoute,
|
buildHostWorkspaceRoute,
|
||||||
buildHostWorkspaceAgentRoute,
|
buildHostWorkspaceAgentRoute,
|
||||||
|
buildHostWorkspaceFileRoute,
|
||||||
buildHostWorkspaceTerminalRoute,
|
buildHostWorkspaceTerminalRoute,
|
||||||
|
encodeFilePathForPathSegment,
|
||||||
decodeWorkspaceIdFromPathSegment,
|
decodeWorkspaceIdFromPathSegment,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { buildNewAgentRoute } from "@/utils/new-agent-routing";
|
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_AGENT_OPTION_ID = "__new_tab_agent__";
|
||||||
const NEW_TAB_TERMINAL_OPTION_ID = "__new_tab_terminal__";
|
const NEW_TAB_TERMINAL_OPTION_ID = "__new_tab_terminal__";
|
||||||
const EMPTY_TAB_ORDER: string[] = [];
|
const EMPTY_TAB_ORDER: string[] = [];
|
||||||
|
const EMPTY_OPEN_FILE_PATHS: string[] = [];
|
||||||
|
|
||||||
type TabAvailability = "available" | "invalid" | "unknown";
|
type TabAvailability = "available" | "invalid" | "unknown";
|
||||||
|
|
||||||
@@ -110,6 +117,13 @@ type WorkspaceTabDescriptor =
|
|||||||
terminalId: string;
|
terminalId: string;
|
||||||
label: string;
|
label: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
key: string;
|
||||||
|
kind: "file";
|
||||||
|
filePath: string;
|
||||||
|
label: string;
|
||||||
|
subtitle: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function applyWorkspaceTabOrder(input: {
|
function applyWorkspaceTabOrder(input: {
|
||||||
@@ -219,6 +233,13 @@ function normalizeWorkspaceTab(
|
|||||||
}
|
}
|
||||||
return { kind: "terminal", terminalId };
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +256,9 @@ function tabEquals(left: WorkspaceTabTarget | null, right: WorkspaceTabTarget |
|
|||||||
if (left.kind === "terminal" && right.kind === "terminal") {
|
if (left.kind === "terminal" && right.kind === "terminal") {
|
||||||
return left.terminalId === right.terminalId;
|
return left.terminalId === right.terminalId;
|
||||||
}
|
}
|
||||||
|
if (left.kind === "file" && right.kind === "file") {
|
||||||
|
return left.path === right.path;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,6 +274,13 @@ function buildTabRoute(input: {
|
|||||||
input.tab.agentId
|
input.tab.agentId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (input.tab.kind === "file") {
|
||||||
|
return buildHostWorkspaceFileRoute(
|
||||||
|
input.serverId,
|
||||||
|
input.workspaceId,
|
||||||
|
input.tab.path
|
||||||
|
);
|
||||||
|
}
|
||||||
return buildHostWorkspaceTerminalRoute(
|
return buildHostWorkspaceTerminalRoute(
|
||||||
input.serverId,
|
input.serverId,
|
||||||
input.workspaceId,
|
input.workspaceId,
|
||||||
@@ -270,6 +301,9 @@ function resolveTabAvailability(input: {
|
|||||||
}
|
}
|
||||||
return input.agentsById.has(input.tab.agentId) ? "available" : "invalid";
|
return input.agentsById.has(input.tab.agentId) ? "available" : "invalid";
|
||||||
}
|
}
|
||||||
|
if (input.tab.kind === "file") {
|
||||||
|
return "available";
|
||||||
|
}
|
||||||
if (!input.terminalsHydrated) {
|
if (!input.terminalsHydrated) {
|
||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
@@ -293,6 +327,9 @@ function toWorkspaceTabTarget(
|
|||||||
if (tab.kind === "agent") {
|
if (tab.kind === "agent") {
|
||||||
return { kind: "agent", agentId: tab.agentId };
|
return { kind: "agent", agentId: tab.agentId };
|
||||||
}
|
}
|
||||||
|
if (tab.kind === "file") {
|
||||||
|
return { kind: "file", path: tab.filePath };
|
||||||
|
}
|
||||||
return { kind: "terminal", terminalId: tab.terminalId };
|
return { kind: "terminal", terminalId: tab.terminalId };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,33 +603,6 @@ function WorkspaceScreenContent({
|
|||||||
return map;
|
return map;
|
||||||
}, [workspaceAgents]);
|
}, [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 terminalIds = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
for (const terminal of terminals) {
|
for (const terminal of terminals) {
|
||||||
@@ -628,6 +638,64 @@ function WorkspaceScreenContent({
|
|||||||
);
|
);
|
||||||
const setTabOrder = useWorkspaceTabsStore((state) => state.setTabOrder);
|
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(
|
const tabs = useMemo(
|
||||||
() => applyWorkspaceTabOrder({ tabs: baseTabs, keys: tabOrder }),
|
() => applyWorkspaceTabOrder({ tabs: baseTabs, keys: tabOrder }),
|
||||||
[baseTabs, tabOrder]
|
[baseTabs, tabOrder]
|
||||||
@@ -648,6 +716,9 @@ function WorkspaceScreenContent({
|
|||||||
if (first.kind === "agent") {
|
if (first.kind === "agent") {
|
||||||
return { kind: "agent", agentId: first.agentId };
|
return { kind: "agent", agentId: first.agentId };
|
||||||
}
|
}
|
||||||
|
if (first.kind === "file") {
|
||||||
|
return { kind: "file", path: first.filePath };
|
||||||
|
}
|
||||||
return { kind: "terminal", terminalId: first.terminalId };
|
return { kind: "terminal", terminalId: first.terminalId };
|
||||||
}, [tabs]);
|
}, [tabs]);
|
||||||
|
|
||||||
@@ -737,6 +808,13 @@ function WorkspaceScreenContent({
|
|||||||
if (tabEquals(tab, resolvedTab)) {
|
if (tabEquals(tab, resolvedTab)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (tab.kind === "file") {
|
||||||
|
openFileTab({
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
filePath: tab.path,
|
||||||
|
});
|
||||||
|
}
|
||||||
const targetRoute = buildTabRoute({
|
const targetRoute = buildTabRoute({
|
||||||
serverId: normalizedServerId,
|
serverId: normalizedServerId,
|
||||||
workspaceId: normalizedWorkspaceId,
|
workspaceId: normalizedWorkspaceId,
|
||||||
@@ -752,12 +830,23 @@ function WorkspaceScreenContent({
|
|||||||
[
|
[
|
||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
|
openFileTab,
|
||||||
resolvedTab,
|
resolvedTab,
|
||||||
router,
|
router,
|
||||||
setLastFocusedTab,
|
setLastFocusedTab,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleOpenFileFromExplorer = useCallback(
|
||||||
|
(filePath: string) => {
|
||||||
|
if (isMobile) {
|
||||||
|
closeToAgent();
|
||||||
|
}
|
||||||
|
navigateToTab({ kind: "file", path: filePath });
|
||||||
|
},
|
||||||
|
[closeToAgent, isMobile, navigateToTab]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!resolvedTab) {
|
if (!resolvedTab) {
|
||||||
return;
|
return;
|
||||||
@@ -804,6 +893,7 @@ function WorkspaceScreenContent({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const [isTabSwitcherOpen, setIsTabSwitcherOpen] = useState(false);
|
const [isTabSwitcherOpen, setIsTabSwitcherOpen] = useState(false);
|
||||||
|
const [isNewTerminalHovered, setIsNewTerminalHovered] = useState(false);
|
||||||
const [hoveredTabKey, setHoveredTabKey] = useState<string | null>(null);
|
const [hoveredTabKey, setHoveredTabKey] = useState<string | null>(null);
|
||||||
const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState<string | null>(
|
const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState<string | null>(
|
||||||
null
|
null
|
||||||
@@ -817,6 +907,10 @@ function WorkspaceScreenContent({
|
|||||||
map.set(tab.key, { kind: "agent", agentId: tab.agentId });
|
map.set(tab.key, { kind: "agent", agentId: tab.agentId });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (tab.kind === "file") {
|
||||||
|
map.set(tab.key, { kind: "file", path: tab.filePath });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
map.set(tab.key, { kind: "terminal", terminalId: tab.terminalId });
|
map.set(tab.key, { kind: "terminal", terminalId: tab.terminalId });
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
@@ -829,6 +923,9 @@ function WorkspaceScreenContent({
|
|||||||
if (resolvedTab.kind === "agent") {
|
if (resolvedTab.kind === "agent") {
|
||||||
return `agent:${resolvedTab.agentId}`;
|
return `agent:${resolvedTab.agentId}`;
|
||||||
}
|
}
|
||||||
|
if (resolvedTab.kind === "file") {
|
||||||
|
return `file:${resolvedTab.path}`;
|
||||||
|
}
|
||||||
return `terminal:${resolvedTab.terminalId}`;
|
return `terminal:${resolvedTab.terminalId}`;
|
||||||
}, [resolvedTab]);
|
}, [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(
|
const handleCopyAgentId = useCallback(
|
||||||
async (agentId: string) => {
|
async (agentId: string) => {
|
||||||
if (!agentId) return;
|
if (!agentId) return;
|
||||||
@@ -1093,22 +1231,33 @@ function WorkspaceScreenContent({
|
|||||||
|
|
||||||
const agentIds: string[] = [];
|
const agentIds: string[] = [];
|
||||||
const terminalIdsToClose: string[] = [];
|
const terminalIdsToClose: string[] = [];
|
||||||
|
const filePathsToClose: string[] = [];
|
||||||
for (const tab of toClose) {
|
for (const tab of toClose) {
|
||||||
if (tab.kind === "agent") {
|
if (tab.kind === "agent") {
|
||||||
agentIds.push(tab.agentId);
|
agentIds.push(tab.agentId);
|
||||||
} else {
|
} else if (tab.kind === "terminal") {
|
||||||
terminalIdsToClose.push(tab.terminalId);
|
terminalIdsToClose.push(tab.terminalId);
|
||||||
|
} else {
|
||||||
|
filePathsToClose.push(tab.filePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmed = await confirmDialog({
|
const confirmed = await confirmDialog({
|
||||||
title: "Close tabs to the right?",
|
title: "Close tabs to the right?",
|
||||||
message:
|
message:
|
||||||
agentIds.length > 0 && terminalIdsToClose.length > 0
|
agentIds.length > 0 && terminalIdsToClose.length > 0 && filePathsToClose.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.`
|
? `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.`
|
||||||
: terminalIdsToClose.length > 0
|
: agentIds.length > 0 && 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) and close ${terminalIdsToClose.length} terminal(s). Any running process in a closed terminal will be stopped immediately.`
|
||||||
: `This will archive ${agentIds.length} agent(s).`,
|
: 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",
|
confirmLabel: "Close",
|
||||||
cancelLabel: "Cancel",
|
cancelLabel: "Cancel",
|
||||||
destructive: true,
|
destructive: true,
|
||||||
@@ -1145,11 +1294,21 @@ function WorkspaceScreenContent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const filePath of filePathsToClose) {
|
||||||
|
closeFileTab({
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
filePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const resolvedTabKey = resolvedTab?.kind === "agent"
|
const resolvedTabKey = resolvedTab?.kind === "agent"
|
||||||
? `agent:${resolvedTab.agentId}`
|
? `agent:${resolvedTab.agentId}`
|
||||||
: resolvedTab?.kind === "terminal"
|
: resolvedTab?.kind === "terminal"
|
||||||
? `terminal:${resolvedTab.terminalId}`
|
? `terminal:${resolvedTab.terminalId}`
|
||||||
: null;
|
: resolvedTab?.kind === "file"
|
||||||
|
? `file:${resolvedTab.path}`
|
||||||
|
: null;
|
||||||
const closedKeys = new Set(toClose.map((tab) => tab.key));
|
const closedKeys = new Set(toClose.map((tab) => tab.key));
|
||||||
if (resolvedTabKey && closedKeys.has(resolvedTabKey)) {
|
if (resolvedTabKey && closedKeys.has(resolvedTabKey)) {
|
||||||
const target = tabByKey.get(tabKey);
|
const target = tabByKey.get(tabKey);
|
||||||
@@ -1163,9 +1322,11 @@ function WorkspaceScreenContent({
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
archiveAgent,
|
archiveAgent,
|
||||||
|
closeFileTab,
|
||||||
killTerminalMutation,
|
killTerminalMutation,
|
||||||
navigateToTab,
|
navigateToTab,
|
||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
|
normalizedWorkspaceId,
|
||||||
queryClient,
|
queryClient,
|
||||||
resolvedTab,
|
resolvedTab,
|
||||||
tabByKey,
|
tabByKey,
|
||||||
@@ -1206,6 +1367,16 @@ function WorkspaceScreenContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resolvedTab.kind === "file") {
|
||||||
|
return (
|
||||||
|
<FilePane
|
||||||
|
serverId={normalizedServerId}
|
||||||
|
workspaceRoot={normalizedWorkspaceId}
|
||||||
|
filePath={resolvedTab.path}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TerminalPane
|
<TerminalPane
|
||||||
serverId={normalizedServerId}
|
serverId={normalizedServerId}
|
||||||
@@ -1342,6 +1513,10 @@ function WorkspaceScreenContent({
|
|||||||
return <Terminal size={14} color={theme.colors.foreground} />;
|
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 tabAgent = agentsById.get(activeDescriptor.agentId) ?? null;
|
||||||
const tabAgentStatusBucket = tabAgent
|
const tabAgentStatusBucket = tabAgent
|
||||||
? deriveSidebarStateBucket({
|
? deriveSidebarStateBucket({
|
||||||
@@ -1416,6 +1591,8 @@ function WorkspaceScreenContent({
|
|||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
testID="workspace-new-terminal-tab"
|
testID="workspace-new-terminal-tab"
|
||||||
onPress={() => handleSelectNewTabOption(NEW_TAB_TERMINAL_OPTION_ID)}
|
onPress={() => handleSelectNewTabOption(NEW_TAB_TERMINAL_OPTION_ID)}
|
||||||
|
onHoverIn={() => setIsNewTerminalHovered(true)}
|
||||||
|
onHoverOut={() => setIsNewTerminalHovered(false)}
|
||||||
disabled={createTerminalMutation.isPending}
|
disabled={createTerminalMutation.isPending}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="New terminal tab"
|
accessibilityLabel="New terminal tab"
|
||||||
@@ -1429,8 +1606,8 @@ function WorkspaceScreenContent({
|
|||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.terminalPlusIcon}>
|
<View style={styles.terminalPlusIcon}>
|
||||||
<Terminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<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} />
|
<Plus size={10} color={theme.colors.foregroundMuted} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -1527,6 +1704,8 @@ function WorkspaceScreenContent({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
) : tab.kind === "file" ? (
|
||||||
|
<FileText size={14} color={iconColor} />
|
||||||
) : (
|
) : (
|
||||||
<Terminal size={14} color={iconColor} />
|
<Terminal size={14} color={iconColor} />
|
||||||
);
|
);
|
||||||
@@ -1556,6 +1735,10 @@ function WorkspaceScreenContent({
|
|||||||
navigateToTab({ kind: "agent", agentId: tab.agentId });
|
navigateToTab({ kind: "agent", agentId: tab.agentId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (tab.kind === "file") {
|
||||||
|
navigateToTab({ kind: "file", path: tab.filePath });
|
||||||
|
return;
|
||||||
|
}
|
||||||
navigateToTab({
|
navigateToTab({
|
||||||
kind: "terminal",
|
kind: "terminal",
|
||||||
terminalId: tab.terminalId,
|
terminalId: tab.terminalId,
|
||||||
@@ -1587,7 +1770,9 @@ function WorkspaceScreenContent({
|
|||||||
testID={
|
testID={
|
||||||
tab.kind === "agent"
|
tab.kind === "agent"
|
||||||
? `workspace-agent-close-${tab.agentId}`
|
? `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"}
|
pointerEvents={shouldShowCloseButton ? "auto" : "none"}
|
||||||
disabled={!shouldShowCloseButton || isClosingTab}
|
disabled={!shouldShowCloseButton || isClosingTab}
|
||||||
@@ -1609,6 +1794,10 @@ function WorkspaceScreenContent({
|
|||||||
void handleCloseAgentTab(tab.agentId);
|
void handleCloseAgentTab(tab.agentId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (tab.kind === "file") {
|
||||||
|
handleCloseFileTab(tab.filePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
void handleCloseTerminalTab(tab.terminalId);
|
void handleCloseTerminalTab(tab.terminalId);
|
||||||
}}
|
}}
|
||||||
style={({ hovered, pressed }) => [
|
style={({ hovered, pressed }) => [
|
||||||
@@ -1676,6 +1865,10 @@ function WorkspaceScreenContent({
|
|||||||
void handleCloseAgentTab(tab.agentId);
|
void handleCloseAgentTab(tab.agentId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (tab.kind === "file") {
|
||||||
|
handleCloseFileTab(tab.filePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
void handleCloseTerminalTab(tab.terminalId);
|
void handleCloseTerminalTab(tab.terminalId);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1709,6 +1902,8 @@ function WorkspaceScreenContent({
|
|||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
testID="workspace-new-terminal-tab"
|
testID="workspace-new-terminal-tab"
|
||||||
onPress={() => handleSelectNewTabOption(NEW_TAB_TERMINAL_OPTION_ID)}
|
onPress={() => handleSelectNewTabOption(NEW_TAB_TERMINAL_OPTION_ID)}
|
||||||
|
onHoverIn={() => setIsNewTerminalHovered(true)}
|
||||||
|
onHoverOut={() => setIsNewTerminalHovered(false)}
|
||||||
disabled={createTerminalMutation.isPending}
|
disabled={createTerminalMutation.isPending}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="New terminal tab"
|
accessibilityLabel="New terminal tab"
|
||||||
@@ -1725,11 +1920,11 @@ function WorkspaceScreenContent({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.terminalPlusIcon}>
|
<View style={styles.terminalPlusIcon}>
|
||||||
<Terminal
|
<SquareTerminal
|
||||||
size={theme.iconSize.sm}
|
size={theme.iconSize.sm}
|
||||||
color={theme.colors.foregroundMuted}
|
color={theme.colors.foregroundMuted}
|
||||||
/>
|
/>
|
||||||
<View style={styles.terminalPlusBadge}>
|
<View style={[styles.terminalPlusBadge, isNewTerminalHovered && styles.terminalPlusBadgeHovered]}>
|
||||||
<Plus size={10} color={theme.colors.foregroundMuted} />
|
<Plus size={10} color={theme.colors.foregroundMuted} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -1759,6 +1954,7 @@ function WorkspaceScreenContent({
|
|||||||
workspaceId={normalizedWorkspaceId}
|
workspaceId={normalizedWorkspaceId}
|
||||||
workspaceRoot={normalizedWorkspaceId}
|
workspaceRoot={normalizedWorkspaceId}
|
||||||
isGit={isGitCheckout}
|
isGit={isGitCheckout}
|
||||||
|
onOpenFile={handleOpenFileFromExplorer}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -1832,17 +2028,18 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
terminalPlusBadge: {
|
terminalPlusBadge: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
right: -6,
|
right: -5,
|
||||||
bottom: -6,
|
bottom: -5,
|
||||||
width: 14,
|
width: 12,
|
||||||
height: 14,
|
height: 12,
|
||||||
borderRadius: 7,
|
borderRadius: 6,
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
|
terminalPlusBadgeHovered: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
mobileTabsRow: {
|
mobileTabsRow: {
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: theme.colors.border,
|
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 =
|
export type WorkspaceTabTarget =
|
||||||
| { kind: "agent"; agentId: string }
|
| { kind: "agent"; agentId: string }
|
||||||
| { kind: "terminal"; terminalId: string };
|
| { kind: "terminal"; terminalId: string }
|
||||||
|
| { kind: "file"; path: string };
|
||||||
|
|
||||||
function normalizeKeys(keys: string[]): string[] {
|
function normalizeKeys(keys: string[]): string[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -50,6 +51,10 @@ function normalizeWorkspaceTab(
|
|||||||
}
|
}
|
||||||
return { kind: "terminal", terminalId };
|
return { kind: "terminal", terminalId };
|
||||||
}
|
}
|
||||||
|
if (value.kind === "file") {
|
||||||
|
// File tabs are session-only; do not persist in workspace tab memory.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,33 @@ const lightSemanticColors = {
|
|||||||
accentBorder: "#ececf1",
|
accentBorder: "#ececf1",
|
||||||
input: "#f4f4f5",
|
input: "#f4f4f5",
|
||||||
ring: "#18181b",
|
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;
|
} as const;
|
||||||
|
|
||||||
const darkSemanticColors = {
|
const darkSemanticColors = {
|
||||||
@@ -186,6 +213,33 @@ const darkSemanticColors = {
|
|||||||
accentBorder: "#34343a",
|
accentBorder: "#34343a",
|
||||||
input: "#27272a",
|
input: "#27272a",
|
||||||
ring: "#d4d4d8",
|
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;
|
} as const;
|
||||||
|
|
||||||
const commonTheme = {
|
const commonTheme = {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { FitAddon } from "@xterm/addon-fit";
|
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 {
|
import {
|
||||||
type PendingTerminalModifiers,
|
type PendingTerminalModifiers,
|
||||||
isTerminalModifierDomKey,
|
isTerminalModifierDomKey,
|
||||||
@@ -10,17 +12,11 @@ import {
|
|||||||
} from "@/utils/terminal-keys";
|
} from "@/utils/terminal-keys";
|
||||||
import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
|
import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
|
||||||
|
|
||||||
export type TerminalEmulatorRuntimeTheme = {
|
|
||||||
backgroundColor: string;
|
|
||||||
foregroundColor: string;
|
|
||||||
cursorColor: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TerminalEmulatorRuntimeMountInput = {
|
export type TerminalEmulatorRuntimeMountInput = {
|
||||||
root: HTMLDivElement;
|
root: HTMLDivElement;
|
||||||
host: HTMLDivElement;
|
host: HTMLDivElement;
|
||||||
initialOutputText: string;
|
initialOutputText: string;
|
||||||
theme: TerminalEmulatorRuntimeTheme;
|
theme: ITheme;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TerminalEmulatorRuntimeCallbacks = {
|
export type TerminalEmulatorRuntimeCallbacks = {
|
||||||
@@ -48,12 +44,14 @@ type TerminalEmulatorRuntimeDisposables = {
|
|||||||
restoreDocumentStyles: () => void;
|
restoreDocumentStyles: () => void;
|
||||||
restoreViewportStyles: () => void;
|
restoreViewportStyles: () => void;
|
||||||
disposeFitAddon: () => void;
|
disposeFitAddon: () => void;
|
||||||
|
disposeWebglAddon: () => void;
|
||||||
disposeTerminal: () => void;
|
disposeTerminal: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TerminalOutputOperation = {
|
type TerminalOutputOperation = {
|
||||||
type: "write" | "clear";
|
type: "write" | "clear";
|
||||||
text: string;
|
text: string;
|
||||||
|
suppressInput?: boolean;
|
||||||
onCommitted?: () => void;
|
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 FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
|
||||||
const OUTPUT_OPERATION_TIMEOUT_MS = 5_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 {
|
export class TerminalEmulatorRuntime {
|
||||||
private callbacks: TerminalEmulatorRuntimeCallbacks = {};
|
private callbacks: TerminalEmulatorRuntimeCallbacks = {};
|
||||||
private pendingModifiers: PendingTerminalModifiers = {
|
private pendingModifiers: PendingTerminalModifiers = {
|
||||||
@@ -82,6 +100,7 @@ export class TerminalEmulatorRuntime {
|
|||||||
private outputOperations: TerminalOutputOperation[] = [];
|
private outputOperations: TerminalOutputOperation[] = [];
|
||||||
private inFlightOutputOperation: TerminalOutputOperation | null = null;
|
private inFlightOutputOperation: TerminalOutputOperation | null = null;
|
||||||
private inFlightOutputOperationTimeout: ReturnType<typeof setTimeout> | null = null;
|
private inFlightOutputOperationTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private suppressInput = false;
|
||||||
|
|
||||||
setCallbacks(input: { callbacks: TerminalEmulatorRuntimeCallbacks }): void {
|
setCallbacks(input: { callbacks: TerminalEmulatorRuntimeCallbacks }): void {
|
||||||
this.callbacks = input.callbacks;
|
this.callbacks = input.callbacks;
|
||||||
@@ -109,19 +128,49 @@ export class TerminalEmulatorRuntime {
|
|||||||
convertEol: false,
|
convertEol: false,
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
cursorStyle: "bar",
|
cursorStyle: "bar",
|
||||||
fontFamily: "'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', monospace",
|
fontFamily: DEFAULT_TERMINAL_FONT_FAMILY,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
lineHeight: 1.25,
|
lineHeight: 1.0,
|
||||||
scrollback: 10_000,
|
scrollback: 10_000,
|
||||||
theme: {
|
theme: input.theme,
|
||||||
background: input.theme.backgroundColor,
|
|
||||||
foreground: input.theme.foregroundColor,
|
|
||||||
cursor: input.theme.cursorColor,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const fitAddon = new FitAddon();
|
const fitAddon = new FitAddon();
|
||||||
|
const unicode11Addon = new Unicode11Addon();
|
||||||
|
let webglAddon: WebglAddon | null = null;
|
||||||
terminal.loadAddon(fitAddon);
|
terminal.loadAddon(fitAddon);
|
||||||
|
terminal.loadAddon(unicode11Addon);
|
||||||
terminal.open(input.host);
|
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({
|
const restoreDocumentStyles = this.applyDocumentBoundsStyles({
|
||||||
root: input.root,
|
root: input.root,
|
||||||
@@ -174,6 +223,17 @@ export class TerminalEmulatorRuntime {
|
|||||||
fitAndEmitResize(true);
|
fitAndEmitResize(true);
|
||||||
|
|
||||||
const inputDisposable = terminal.onData((data) => {
|
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({
|
terminalDebugLog({
|
||||||
scope: "emulator-runtime",
|
scope: "emulator-runtime",
|
||||||
event: "input:onData",
|
event: "input:onData",
|
||||||
@@ -283,7 +343,7 @@ export class TerminalEmulatorRuntime {
|
|||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
if (input.initialOutputText.length > 0) {
|
if (input.initialOutputText.length > 0) {
|
||||||
terminal.write(input.initialOutputText);
|
this.write({ text: input.initialOutputText, suppressInput: true });
|
||||||
terminalDebugLog({
|
terminalDebugLog({
|
||||||
scope: "emulator-runtime",
|
scope: "emulator-runtime",
|
||||||
event: "output:initial-write",
|
event: "output:initial-write",
|
||||||
@@ -329,6 +389,18 @@ export class TerminalEmulatorRuntime {
|
|||||||
disposeFitAddon: () => {
|
disposeFitAddon: () => {
|
||||||
fitAddon.dispose();
|
fitAddon.dispose();
|
||||||
},
|
},
|
||||||
|
disposeWebglAddon: () => {
|
||||||
|
if (webglAddonRaf !== null) {
|
||||||
|
cancelAnimationFrame(webglAddonRaf);
|
||||||
|
webglAddonRaf = null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
webglAddon?.dispose();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
webglAddon = null;
|
||||||
|
},
|
||||||
disposeTerminal: () => {
|
disposeTerminal: () => {
|
||||||
terminal.dispose();
|
terminal.dispose();
|
||||||
},
|
},
|
||||||
@@ -344,13 +416,14 @@ export class TerminalEmulatorRuntime {
|
|||||||
disposables.removeFontListeners();
|
disposables.removeFontListeners();
|
||||||
disposables.removeTouchListeners();
|
disposables.removeTouchListeners();
|
||||||
disposables.disposeFitAddon();
|
disposables.disposeFitAddon();
|
||||||
|
disposables.disposeWebglAddon();
|
||||||
disposables.disposeTerminal();
|
disposables.disposeTerminal();
|
||||||
disposables.restoreDocumentStyles();
|
disposables.restoreDocumentStyles();
|
||||||
disposables.restoreViewportStyles();
|
disposables.restoreViewportStyles();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
write(input: { text: string; onCommitted?: () => void }): void {
|
write(input: { text: string; suppressInput?: boolean; onCommitted?: () => void }): void {
|
||||||
if (input.text.length === 0) {
|
if (input.text.length === 0) {
|
||||||
input.onCommitted?.();
|
input.onCommitted?.();
|
||||||
return;
|
return;
|
||||||
@@ -358,6 +431,7 @@ export class TerminalEmulatorRuntime {
|
|||||||
this.outputOperations.push({
|
this.outputOperations.push({
|
||||||
type: "write",
|
type: "write",
|
||||||
text: input.text,
|
text: input.text,
|
||||||
|
suppressInput: input.suppressInput ?? false,
|
||||||
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
|
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
|
||||||
});
|
});
|
||||||
terminalDebugLog({
|
terminalDebugLog({
|
||||||
@@ -376,6 +450,7 @@ export class TerminalEmulatorRuntime {
|
|||||||
this.outputOperations.push({
|
this.outputOperations.push({
|
||||||
type: "clear",
|
type: "clear",
|
||||||
text: "",
|
text: "",
|
||||||
|
suppressInput: false,
|
||||||
...(input?.onCommitted ? { onCommitted: input.onCommitted } : {}),
|
...(input?.onCommitted ? { onCommitted: input.onCommitted } : {}),
|
||||||
});
|
});
|
||||||
terminalDebugLog({
|
terminalDebugLog({
|
||||||
@@ -421,6 +496,7 @@ export class TerminalEmulatorRuntime {
|
|||||||
this.fitAddon = null;
|
this.fitAddon = null;
|
||||||
this.fitAndEmitResize = null;
|
this.fitAndEmitResize = null;
|
||||||
this.lastSize = null;
|
this.lastSize = null;
|
||||||
|
this.suppressInput = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private processOutputQueue(): void {
|
private processOutputQueue(): void {
|
||||||
@@ -439,12 +515,17 @@ export class TerminalEmulatorRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.inFlightOutputOperation = operation;
|
this.inFlightOutputOperation = operation;
|
||||||
|
const previousSuppressInput = this.suppressInput;
|
||||||
|
if (operation.type === "write") {
|
||||||
|
this.suppressInput = Boolean(operation.suppressInput);
|
||||||
|
}
|
||||||
const finalizeOperation = (expectedOperation: TerminalOutputOperation) => {
|
const finalizeOperation = (expectedOperation: TerminalOutputOperation) => {
|
||||||
if (this.inFlightOutputOperation !== expectedOperation) {
|
if (this.inFlightOutputOperation !== expectedOperation) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.inFlightOutputOperation = null;
|
this.inFlightOutputOperation = null;
|
||||||
this.clearInFlightOutputTimeout();
|
this.clearInFlightOutputTimeout();
|
||||||
|
this.suppressInput = previousSuppressInput;
|
||||||
expectedOperation.onCommitted?.();
|
expectedOperation.onCommitted?.();
|
||||||
this.processOutputQueue();
|
this.processOutputQueue();
|
||||||
};
|
};
|
||||||
@@ -493,12 +574,19 @@ export class TerminalEmulatorRuntime {
|
|||||||
const previousDocumentElementOverflow = documentElement.style.overflow;
|
const previousDocumentElementOverflow = documentElement.style.overflow;
|
||||||
const previousDocumentElementWidth = documentElement.style.width;
|
const previousDocumentElementWidth = documentElement.style.width;
|
||||||
const previousDocumentElementHeight = documentElement.style.height;
|
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 previousBodyOverflow = body.style.overflow;
|
||||||
const previousBodyWidth = body.style.width;
|
const previousBodyWidth = body.style.width;
|
||||||
const previousBodyHeight = body.style.height;
|
const previousBodyHeight = body.style.height;
|
||||||
const previousBodyMargin = body.style.margin;
|
const previousBodyMargin = body.style.margin;
|
||||||
const previousBodyPadding = body.style.padding;
|
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 previousRootOverflow = rootContainer?.style.overflow ?? "";
|
||||||
const previousRootWidth = rootContainer?.style.width ?? "";
|
const previousRootWidth = rootContainer?.style.width ?? "";
|
||||||
@@ -507,12 +595,16 @@ export class TerminalEmulatorRuntime {
|
|||||||
documentElement.style.overflow = "hidden";
|
documentElement.style.overflow = "hidden";
|
||||||
documentElement.style.width = "100%";
|
documentElement.style.width = "100%";
|
||||||
documentElement.style.height = "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.overflow = "hidden";
|
||||||
body.style.width = "100%";
|
body.style.width = "100%";
|
||||||
body.style.height = "100%";
|
body.style.height = "100%";
|
||||||
body.style.margin = "0";
|
body.style.margin = "0";
|
||||||
body.style.padding = "0";
|
body.style.padding = "0";
|
||||||
|
body.style.setProperty("text-size-adjust", "100%");
|
||||||
|
body.style.setProperty("-webkit-text-size-adjust", "100%");
|
||||||
|
|
||||||
if (rootContainer) {
|
if (rootContainer) {
|
||||||
rootContainer.style.overflow = "hidden";
|
rootContainer.style.overflow = "hidden";
|
||||||
@@ -524,12 +616,19 @@ export class TerminalEmulatorRuntime {
|
|||||||
documentElement.style.overflow = previousDocumentElementOverflow;
|
documentElement.style.overflow = previousDocumentElementOverflow;
|
||||||
documentElement.style.width = previousDocumentElementWidth;
|
documentElement.style.width = previousDocumentElementWidth;
|
||||||
documentElement.style.height = previousDocumentElementHeight;
|
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.overflow = previousBodyOverflow;
|
||||||
body.style.width = previousBodyWidth;
|
body.style.width = previousBodyWidth;
|
||||||
body.style.height = previousBodyHeight;
|
body.style.height = previousBodyHeight;
|
||||||
body.style.margin = previousBodyMargin;
|
body.style.margin = previousBodyMargin;
|
||||||
body.style.padding = previousBodyPadding;
|
body.style.padding = previousBodyPadding;
|
||||||
|
body.style.setProperty("text-size-adjust", previousBodyTextSizeAdjust);
|
||||||
|
body.style.setProperty("-webkit-text-size-adjust", previousBodyWebkitTextSizeAdjust);
|
||||||
|
|
||||||
if (rootContainer) {
|
if (rootContainer) {
|
||||||
rootContainer.style.overflow = previousRootOverflow;
|
rootContainer.style.overflow = previousRootOverflow;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { TerminalOutputDeliveryQueue } from "./terminal-output-delivery-queue";
|
|||||||
describe("terminal-output-delivery-queue", () => {
|
describe("terminal-output-delivery-queue", () => {
|
||||||
it("retries in-flight delivery when consume is missing", () => {
|
it("retries in-flight delivery when consume is missing", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const delivered: Array<{ sequence: number; text: string }> = [];
|
const delivered: Array<{ sequence: number; text: string; replay: boolean }> = [];
|
||||||
const queue = new TerminalOutputDeliveryQueue({
|
const queue = new TerminalOutputDeliveryQueue({
|
||||||
onDeliver: (chunk) => {
|
onDeliver: (chunk) => {
|
||||||
delivered.push(chunk);
|
delivered.push(chunk);
|
||||||
@@ -13,45 +13,45 @@ describe("terminal-output-delivery-queue", () => {
|
|||||||
deliveryTimeoutMs: 100,
|
deliveryTimeoutMs: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
queue.enqueue({ sequence: 1, text: "a" });
|
queue.enqueue({ sequence: 1, text: "a", replay: false });
|
||||||
queue.enqueue({ sequence: 2, text: "b" });
|
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);
|
vi.advanceTimersByTime(100);
|
||||||
expect(delivered).toEqual([
|
expect(delivered).toEqual([
|
||||||
{ sequence: 1, text: "a" },
|
{ sequence: 1, text: "a", replay: false },
|
||||||
{ sequence: 1, text: "a" },
|
{ sequence: 1, text: "a", replay: false },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
queue.consume({ sequence: 1 });
|
queue.consume({ sequence: 1 });
|
||||||
expect(delivered).toEqual([
|
expect(delivered).toEqual([
|
||||||
{ sequence: 1, text: "a" },
|
{ sequence: 1, text: "a", replay: false },
|
||||||
{ sequence: 1, text: "a" },
|
{ sequence: 1, text: "a", replay: false },
|
||||||
{ sequence: 2, text: "b" },
|
{ sequence: 2, text: "b", replay: false },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("delivers first chunk immediately and blocks later chunks until consumed", () => {
|
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({
|
const queue = new TerminalOutputDeliveryQueue({
|
||||||
onDeliver: (chunk) => {
|
onDeliver: (chunk) => {
|
||||||
delivered.push(chunk);
|
delivered.push(chunk);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
queue.enqueue({ sequence: 1, text: "a" });
|
queue.enqueue({ sequence: 1, text: "a", replay: false });
|
||||||
queue.enqueue({ sequence: 2, text: "b" });
|
queue.enqueue({ sequence: 2, text: "b", replay: false });
|
||||||
queue.enqueue({ sequence: 3, text: "c" });
|
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 });
|
queue.consume({ sequence: 1 });
|
||||||
expect(delivered).toEqual([
|
expect(delivered).toEqual([
|
||||||
{ sequence: 1, text: "a" },
|
{ sequence: 1, text: "a", replay: false },
|
||||||
{ sequence: 3, text: "bc" },
|
{ sequence: 3, text: "bc", replay: false },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,83 +59,83 @@ describe("terminal-output-delivery-queue", () => {
|
|||||||
const delivered = vi.fn();
|
const delivered = vi.fn();
|
||||||
const queue = new TerminalOutputDeliveryQueue({ onDeliver: delivered });
|
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.consume({ sequence: 99 });
|
||||||
queue.enqueue({ sequence: 2, text: "y" });
|
queue.enqueue({ sequence: 2, text: "y", replay: false });
|
||||||
|
|
||||||
expect(delivered).toHaveBeenCalledTimes(1);
|
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 });
|
queue.consume({ sequence: 1 });
|
||||||
|
|
||||||
expect(delivered).toHaveBeenCalledTimes(2);
|
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", () => {
|
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({
|
const queue = new TerminalOutputDeliveryQueue({
|
||||||
onDeliver: (chunk) => {
|
onDeliver: (chunk) => {
|
||||||
delivered.push(chunk);
|
delivered.push(chunk);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
queue.enqueue({ sequence: 1, text: "hello" });
|
queue.enqueue({ sequence: 1, text: "hello", replay: false });
|
||||||
queue.enqueue({ sequence: 2, text: " world" });
|
queue.enqueue({ sequence: 2, text: " world", replay: false });
|
||||||
|
|
||||||
queue.reset();
|
queue.reset();
|
||||||
queue.enqueue({ sequence: 3, text: "next" });
|
queue.enqueue({ sequence: 3, text: "next", replay: false });
|
||||||
|
|
||||||
expect(delivered).toEqual([
|
expect(delivered).toEqual([
|
||||||
{ sequence: 1, text: "hello" },
|
{ sequence: 1, text: "hello", replay: false },
|
||||||
{ sequence: 3, text: "next" },
|
{ sequence: 3, text: "next", replay: false },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves empty chunk payloads for authoritative clears", () => {
|
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({
|
const queue = new TerminalOutputDeliveryQueue({
|
||||||
onDeliver: (chunk) => {
|
onDeliver: (chunk) => {
|
||||||
delivered.push(chunk);
|
delivered.push(chunk);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
queue.enqueue({ sequence: 1, text: "abc" });
|
queue.enqueue({ sequence: 1, text: "abc", replay: false });
|
||||||
queue.consume({ sequence: 1 });
|
queue.consume({ sequence: 1 });
|
||||||
queue.enqueue({ sequence: 2, text: "" });
|
queue.enqueue({ sequence: 2, text: "", replay: false });
|
||||||
|
|
||||||
expect(delivered).toEqual([
|
expect(delivered).toEqual([
|
||||||
{ sequence: 1, text: "abc" },
|
{ sequence: 1, text: "abc", replay: false },
|
||||||
{ sequence: 2, text: "" },
|
{ sequence: 2, text: "", replay: false },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats clear chunks as a delivery barrier and drops pending stale text", () => {
|
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({
|
const queue = new TerminalOutputDeliveryQueue({
|
||||||
onDeliver: (chunk) => {
|
onDeliver: (chunk) => {
|
||||||
delivered.push(chunk);
|
delivered.push(chunk);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
queue.enqueue({ sequence: 1, text: "a" });
|
queue.enqueue({ sequence: 1, text: "a", replay: false });
|
||||||
queue.enqueue({ sequence: 2, text: "b" });
|
queue.enqueue({ sequence: 2, text: "b", replay: false });
|
||||||
queue.enqueue({ sequence: 3, text: "" });
|
queue.enqueue({ sequence: 3, text: "", replay: false });
|
||||||
queue.enqueue({ sequence: 4, text: "c" });
|
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 });
|
queue.consume({ sequence: 1 });
|
||||||
expect(delivered).toEqual([
|
expect(delivered).toEqual([
|
||||||
{ sequence: 1, text: "a" },
|
{ sequence: 1, text: "a", replay: false },
|
||||||
{ sequence: 3, text: "" },
|
{ sequence: 3, text: "", replay: false },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
queue.consume({ sequence: 3 });
|
queue.consume({ sequence: 3 });
|
||||||
expect(delivered).toEqual([
|
expect(delivered).toEqual([
|
||||||
{ sequence: 1, text: "a" },
|
{ sequence: 1, text: "a", replay: false },
|
||||||
{ sequence: 3, text: "" },
|
{ sequence: 3, text: "", replay: false },
|
||||||
{ sequence: 4, text: "c" },
|
{ sequence: 4, text: "c", replay: false },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
|
|||||||
export type TerminalOutputDeliveryChunk = {
|
export type TerminalOutputDeliveryChunk = {
|
||||||
sequence: number;
|
sequence: number;
|
||||||
text: string;
|
text: string;
|
||||||
|
replay: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TerminalOutputDeliveryQueueOptions = {
|
export type TerminalOutputDeliveryQueueOptions = {
|
||||||
@@ -48,7 +49,11 @@ export class TerminalOutputDeliveryQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lastPendingChunk = this.pendingChunks[this.pendingChunks.length - 1];
|
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.sequence = chunk.sequence;
|
||||||
lastPendingChunk.text += chunk.text;
|
lastPendingChunk.text += chunk.text;
|
||||||
} else {
|
} else {
|
||||||
@@ -138,6 +143,7 @@ export class TerminalOutputDeliveryQueue {
|
|||||||
this.options.onDeliver({
|
this.options.onDeliver({
|
||||||
sequence: chunk.sequence,
|
sequence: chunk.sequence,
|
||||||
text: chunk.text,
|
text: chunk.text,
|
||||||
|
replay: chunk.replay,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { TerminalOutputPump } from "./terminal-output-pump";
|
|||||||
describe("terminal-output-pump", () => {
|
describe("terminal-output-pump", () => {
|
||||||
it("batches selected-terminal chunk bursts into ordered flushes", () => {
|
it("batches selected-terminal chunk bursts into ordered flushes", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const chunks: Array<{ sequence: number; text: string }> = [];
|
const chunks: Array<{ sequence: number; text: string; replay: boolean }> = [];
|
||||||
const pump = new TerminalOutputPump({
|
const pump = new TerminalOutputPump({
|
||||||
maxOutputChars: 100,
|
maxOutputChars: 100,
|
||||||
onSelectedOutputChunk: (chunk) => {
|
onSelectedOutputChunk: (chunk) => {
|
||||||
@@ -14,16 +14,16 @@ describe("terminal-output-pump", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
pump.setSelectedTerminal({ terminalId: "term-1" });
|
pump.setSelectedTerminal({ terminalId: "term-1" });
|
||||||
pump.append({ terminalId: "term-1", text: "a" });
|
pump.append({ terminalId: "term-1", text: "a", replay: false });
|
||||||
pump.append({ terminalId: "term-1", text: "b" });
|
pump.append({ terminalId: "term-1", text: "b", replay: false });
|
||||||
pump.append({ terminalId: "term-1", text: "c" });
|
pump.append({ terminalId: "term-1", text: "c", replay: false });
|
||||||
|
|
||||||
expect(chunks).toEqual([]);
|
expect(chunks).toEqual([]);
|
||||||
|
|
||||||
vi.runOnlyPendingTimers();
|
vi.runOnlyPendingTimers();
|
||||||
|
|
||||||
expect(chunks).toEqual([
|
expect(chunks).toEqual([
|
||||||
{ sequence: 1, text: "abc" },
|
{ sequence: 1, text: "abc", replay: false },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
@@ -31,7 +31,7 @@ describe("terminal-output-pump", () => {
|
|||||||
|
|
||||||
it("keeps per-terminal snapshots and switches selected stream deterministically", () => {
|
it("keeps per-terminal snapshots and switches selected stream deterministically", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const chunks: Array<{ sequence: number; text: string }> = [];
|
const chunks: Array<{ sequence: number; text: string; replay: boolean }> = [];
|
||||||
const pump = new TerminalOutputPump({
|
const pump = new TerminalOutputPump({
|
||||||
maxOutputChars: 10,
|
maxOutputChars: 10,
|
||||||
onSelectedOutputChunk: (chunk) => {
|
onSelectedOutputChunk: (chunk) => {
|
||||||
@@ -40,21 +40,21 @@ describe("terminal-output-pump", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
pump.setSelectedTerminal({ terminalId: "term-1" });
|
pump.setSelectedTerminal({ terminalId: "term-1" });
|
||||||
pump.append({ terminalId: "term-1", text: "hello" });
|
pump.append({ terminalId: "term-1", text: "hello", replay: false });
|
||||||
vi.runOnlyPendingTimers();
|
vi.runOnlyPendingTimers();
|
||||||
expect(pump.readSnapshot({ terminalId: "term-1" })).toBe("hello");
|
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();
|
vi.runOnlyPendingTimers();
|
||||||
expect(pump.readSnapshot({ terminalId: "term-2" })).toBe("world");
|
expect(pump.readSnapshot({ terminalId: "term-2" })).toBe("world");
|
||||||
|
|
||||||
pump.setSelectedTerminal({ terminalId: "term-2" });
|
pump.setSelectedTerminal({ terminalId: "term-2" });
|
||||||
pump.append({ terminalId: "term-2", text: "!" });
|
pump.append({ terminalId: "term-2", text: "!", replay: false });
|
||||||
vi.runOnlyPendingTimers();
|
vi.runOnlyPendingTimers();
|
||||||
|
|
||||||
expect(chunks).toEqual([
|
expect(chunks).toEqual([
|
||||||
{ sequence: 1, text: "hello" },
|
{ sequence: 1, text: "hello", replay: false },
|
||||||
{ sequence: 2, text: "!" },
|
{ sequence: 2, text: "!", replay: false },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
@@ -62,7 +62,7 @@ describe("terminal-output-pump", () => {
|
|||||||
|
|
||||||
it("resets selected output when clearing selected terminal", () => {
|
it("resets selected output when clearing selected terminal", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const chunks: Array<{ sequence: number; text: string }> = [];
|
const chunks: Array<{ sequence: number; text: string; replay: boolean }> = [];
|
||||||
const pump = new TerminalOutputPump({
|
const pump = new TerminalOutputPump({
|
||||||
maxOutputChars: 10,
|
maxOutputChars: 10,
|
||||||
onSelectedOutputChunk: (chunk) => {
|
onSelectedOutputChunk: (chunk) => {
|
||||||
@@ -71,15 +71,15 @@ describe("terminal-output-pump", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
pump.setSelectedTerminal({ terminalId: "term-1" });
|
pump.setSelectedTerminal({ terminalId: "term-1" });
|
||||||
pump.append({ terminalId: "term-1", text: "abc" });
|
pump.append({ terminalId: "term-1", text: "abc", replay: false });
|
||||||
vi.runOnlyPendingTimers();
|
vi.runOnlyPendingTimers();
|
||||||
|
|
||||||
pump.clearTerminal({ terminalId: "term-1" });
|
pump.clearTerminal({ terminalId: "term-1" });
|
||||||
|
|
||||||
expect(pump.readSnapshot({ terminalId: "term-1" })).toBe("");
|
expect(pump.readSnapshot({ terminalId: "term-1" })).toBe("");
|
||||||
expect(chunks).toEqual([
|
expect(chunks).toEqual([
|
||||||
{ sequence: 1, text: "abc" },
|
{ sequence: 1, text: "abc", replay: false },
|
||||||
{ sequence: 2, text: "" },
|
{ sequence: 2, text: "", replay: false },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
@@ -91,8 +91,8 @@ describe("terminal-output-pump", () => {
|
|||||||
onSelectedOutputChunk: () => {},
|
onSelectedOutputChunk: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
pump.append({ terminalId: "a", text: "one" });
|
pump.append({ terminalId: "a", text: "one", replay: false });
|
||||||
pump.append({ terminalId: "b", text: "two" });
|
pump.append({ terminalId: "b", text: "two", replay: false });
|
||||||
pump.prune({ terminalIds: ["b"] });
|
pump.prune({ terminalIds: ["b"] });
|
||||||
|
|
||||||
expect(pump.readSnapshot({ terminalId: "a" })).toBe("");
|
expect(pump.readSnapshot({ terminalId: "a" })).toBe("");
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
|
|||||||
export type TerminalOutputChunk = {
|
export type TerminalOutputChunk = {
|
||||||
sequence: number;
|
sequence: number;
|
||||||
text: string;
|
text: string;
|
||||||
|
replay: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TerminalOutputPumpOptions = {
|
export type TerminalOutputPumpOptions = {
|
||||||
@@ -23,6 +24,7 @@ export type TerminalOutputPumpSetSelectedInput = {
|
|||||||
export type TerminalOutputPumpAppendInput = {
|
export type TerminalOutputPumpAppendInput = {
|
||||||
terminalId: string;
|
terminalId: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
replay: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TerminalOutputPumpReadInput = {
|
export type TerminalOutputPumpReadInput = {
|
||||||
@@ -42,6 +44,7 @@ export class TerminalOutputPump {
|
|||||||
private selectedTerminalId: string | null = null;
|
private selectedTerminalId: string | null = null;
|
||||||
private selectedChunkSequence = 0;
|
private selectedChunkSequence = 0;
|
||||||
private selectedChunkAccumulator = "";
|
private selectedChunkAccumulator = "";
|
||||||
|
private selectedChunkAccumulatorReplay: boolean | null = null;
|
||||||
private selectedChunkFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
private selectedChunkFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
constructor(private readonly options: TerminalOutputPumpOptions) {}
|
constructor(private readonly options: TerminalOutputPumpOptions) {}
|
||||||
@@ -61,6 +64,7 @@ export class TerminalOutputPump {
|
|||||||
});
|
});
|
||||||
this.clearSelectedChunkFlushTimer();
|
this.clearSelectedChunkFlushTimer();
|
||||||
this.selectedChunkAccumulator = "";
|
this.selectedChunkAccumulator = "";
|
||||||
|
this.selectedChunkAccumulatorReplay = null;
|
||||||
this.selectedTerminalId = input.terminalId;
|
this.selectedTerminalId = input.terminalId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +89,18 @@ export class TerminalOutputPump {
|
|||||||
return;
|
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;
|
this.selectedChunkAccumulator += input.text;
|
||||||
terminalDebugLog({
|
terminalDebugLog({
|
||||||
scope: "output-pump",
|
scope: "output-pump",
|
||||||
@@ -93,6 +109,7 @@ export class TerminalOutputPump {
|
|||||||
terminalId: input.terminalId,
|
terminalId: input.terminalId,
|
||||||
appendedLength: input.text.length,
|
appendedLength: input.text.length,
|
||||||
accumulatorLength: this.selectedChunkAccumulator.length,
|
accumulatorLength: this.selectedChunkAccumulator.length,
|
||||||
|
replay: input.replay,
|
||||||
preview: summarizeTerminalText({ text: input.text, maxChars: 80 }),
|
preview: summarizeTerminalText({ text: input.text, maxChars: 80 }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -104,7 +121,8 @@ export class TerminalOutputPump {
|
|||||||
if (this.selectedTerminalId === input.terminalId) {
|
if (this.selectedTerminalId === input.terminalId) {
|
||||||
this.clearSelectedChunkFlushTimer();
|
this.clearSelectedChunkFlushTimer();
|
||||||
this.selectedChunkAccumulator = "";
|
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 text = this.selectedChunkAccumulator;
|
||||||
|
const replay = this.selectedChunkAccumulatorReplay ?? false;
|
||||||
this.selectedChunkAccumulator = "";
|
this.selectedChunkAccumulator = "";
|
||||||
|
this.selectedChunkAccumulatorReplay = null;
|
||||||
terminalDebugLog({
|
terminalDebugLog({
|
||||||
scope: "output-pump",
|
scope: "output-pump",
|
||||||
event: "selected-terminal:flush",
|
event: "selected-terminal:flush",
|
||||||
details: {
|
details: {
|
||||||
terminalId: this.selectedTerminalId,
|
terminalId: this.selectedTerminalId,
|
||||||
textLength: text.length,
|
textLength: text.length,
|
||||||
|
replay,
|
||||||
preview: summarizeTerminalText({ text, maxChars: 96 }),
|
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.selectedChunkSequence += 1;
|
||||||
this.options.onSelectedOutputChunk({
|
this.options.onSelectedOutputChunk({
|
||||||
sequence: this.selectedChunkSequence,
|
sequence: this.selectedChunkSequence,
|
||||||
text: input.text,
|
text: input.text,
|
||||||
|
replay: input.replay,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export type TerminalStreamControllerStatus = {
|
|||||||
export type TerminalStreamControllerOptions = {
|
export type TerminalStreamControllerOptions = {
|
||||||
client: TerminalStreamControllerClient;
|
client: TerminalStreamControllerClient;
|
||||||
getPreferredSize: () => TerminalStreamControllerSize | null;
|
getPreferredSize: () => TerminalStreamControllerSize | null;
|
||||||
onChunk: (input: { terminalId: string; text: string }) => void;
|
onChunk: (input: { terminalId: string; text: string; replay: boolean }) => void;
|
||||||
onReset?: (input: { terminalId: string }) => void;
|
onReset?: (input: { terminalId: string }) => void;
|
||||||
onStatusChange?: (status: TerminalStreamControllerStatus) => void;
|
onStatusChange?: (status: TerminalStreamControllerStatus) => void;
|
||||||
maxAttachAttempts?: number;
|
maxAttachAttempts?: number;
|
||||||
@@ -500,6 +500,7 @@ export class TerminalStreamController {
|
|||||||
this.options.onChunk({
|
this.options.onChunk({
|
||||||
terminalId: input.terminalId,
|
terminalId: input.terminalId,
|
||||||
text,
|
text,
|
||||||
|
replay: Boolean(input.chunk.replay),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,6 +566,7 @@ export class TerminalStreamController {
|
|||||||
this.options.onChunk({
|
this.options.onChunk({
|
||||||
terminalId: activeStream.terminalId,
|
terminalId: activeStream.terminalId,
|
||||||
text: tail,
|
text: tail,
|
||||||
|
replay: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
buildHostWorkspaceRoute,
|
buildHostWorkspaceRoute,
|
||||||
|
buildHostWorkspaceFileRoute,
|
||||||
|
decodeFilePathFromPathSegment,
|
||||||
decodeWorkspaceIdFromPathSegment,
|
decodeWorkspaceIdFromPathSegment,
|
||||||
|
encodeFilePathForPathSegment,
|
||||||
encodeWorkspaceIdForPathSegment,
|
encodeWorkspaceIdForPathSegment,
|
||||||
parseHostAgentDraftRouteFromPathname,
|
parseHostAgentDraftRouteFromPathname,
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
parseHostDraftRouteFromPathname,
|
parseHostDraftRouteFromPathname,
|
||||||
parseHostWorkspaceAgentRouteFromPathname,
|
parseHostWorkspaceAgentRouteFromPathname,
|
||||||
|
parseHostWorkspaceFileRouteFromPathname,
|
||||||
parseHostWorkspaceTerminalRouteFromPathname,
|
parseHostWorkspaceTerminalRouteFromPathname,
|
||||||
parseHostWorkspaceRouteFromPathname,
|
parseHostWorkspaceRouteFromPathname,
|
||||||
} from "./host-routes";
|
} from "./host-routes";
|
||||||
@@ -54,6 +58,12 @@ describe("workspace route parsing", () => {
|
|||||||
expect(decodeWorkspaceIdFromPathSegment("L3RtcC9yZXBv")).toBe("/tmp/repo");
|
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", () => {
|
it("parses workspace route", () => {
|
||||||
expect(
|
expect(
|
||||||
parseHostWorkspaceRouteFromPathname("/h/local/workspace/L3RtcC9yZXBv")
|
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", () => {
|
it("parses workspace agent route", () => {
|
||||||
expect(
|
expect(
|
||||||
parseHostWorkspaceAgentRouteFromPathname(
|
parseHostWorkspaceAgentRouteFromPathname(
|
||||||
@@ -104,4 +127,10 @@ describe("workspace route parsing", () => {
|
|||||||
"/h/local/workspace/L3RtcC9yZXBv"
|
"/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);
|
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 {
|
export function parseServerIdFromPathname(pathname: string): string | null {
|
||||||
const match = pathname.match(/^\/h\/([^/]+)(?:\/|$)/);
|
const match = pathname.match(/^\/h\/([^/]+)(?:\/|$)/);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
@@ -211,6 +231,10 @@ export function parseHostWorkspaceRouteFromPathname(
|
|||||||
if (terminalIdx >= 0 && terminalIdx > workspaceIdStart) {
|
if (terminalIdx >= 0 && terminalIdx > workspaceIdStart) {
|
||||||
workspaceIdEnd = Math.min(workspaceIdEnd, terminalIdx);
|
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 rawWorkspaceId = pathname.slice(workspaceIdStart, workspaceIdEnd).replace(/\/+$/, "");
|
||||||
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
|
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
|
||||||
@@ -320,6 +344,55 @@ export function parseHostWorkspaceTerminalRouteFromPathname(
|
|||||||
return { serverId, workspaceId, terminalId };
|
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(
|
export function buildHostWorkspaceRoute(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
workspaceId: string
|
workspaceId: string
|
||||||
@@ -362,6 +435,19 @@ export function buildHostWorkspaceTerminalRoute(
|
|||||||
return `${base}/terminal/${encodeSegment(normalizedTerminalId)}`;
|
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(
|
export function buildHostAgentDetailRoute(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
agentId: 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"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.6",
|
"@tauri-apps/cli": "^2.9.6"
|
||||||
"@tauri-apps/cli-linux-x64-gnu": "^2.9.6"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user