mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 11:12:53 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcb3157e75 | ||
|
|
de162b98d4 | ||
|
|
0a960f9ae0 | ||
|
|
7c4f8580e9 | ||
|
|
33674e0b10 | ||
|
|
0ecc51bd99 | ||
|
|
526e6070f8 | ||
|
|
40d6a9127a |
@@ -0,0 +1,193 @@
|
|||||||
|
import { expect, test, type Page } from "./fixtures";
|
||||||
|
import { waitForTabBar } from "./helpers/launcher";
|
||||||
|
import { setupDeterministicPrompt, waitForTerminalContent } from "./helpers/terminal-perf";
|
||||||
|
import { connectSeedClient } from "./helpers/seed-client";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import { openFileExplorer, openFileFromExplorer } from "./helpers/file-explorer";
|
||||||
|
import {
|
||||||
|
connectWorkspaceSetupClient,
|
||||||
|
openHomeWithProject,
|
||||||
|
seedProjectForWorkspaceSetup,
|
||||||
|
} from "./helpers/workspace-setup";
|
||||||
|
import { buildHostWorkspaceRoute } from "../src/utils/host-routes";
|
||||||
|
|
||||||
|
function getServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set.");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findShortcut(): string {
|
||||||
|
return process.platform === "darwin" ? "Meta+f" : "Control+f";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateToWorkspaceViaSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||||
|
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||||
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
await row.click();
|
||||||
|
await waitForTabBar(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openFind(page: Page): Promise<void> {
|
||||||
|
await page.keyboard.press(findShortcut());
|
||||||
|
await expect(page.getByTestId("pane-find-bar")).toBeVisible({ timeout: 10_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function typeFindQuery(page: Page, query: string): Promise<void> {
|
||||||
|
const input = page.getByTestId("pane-find-input");
|
||||||
|
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||||
|
await input.fill(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("in-pane find", () => {
|
||||||
|
test("walks chat, file, terminal, split-pane, and browser-web find flows in the running app", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const agentClient = await connectSeedClient();
|
||||||
|
const repo = await createTempGitRepo("find-pane-qa-", {
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
path: "src/find-target.txt",
|
||||||
|
content: [
|
||||||
|
"alpha needle first",
|
||||||
|
"beta without match",
|
||||||
|
"gamma NEEDLE second",
|
||||||
|
"delta needle third",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
const workspaceResult = await client.openProject(repo.path);
|
||||||
|
if (!workspaceResult.workspace) {
|
||||||
|
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await openHomeWithProject(page, repo.path);
|
||||||
|
await navigateToWorkspaceViaSidebar(page, workspaceResult.workspace.id);
|
||||||
|
|
||||||
|
await openFileExplorer(page);
|
||||||
|
await openFileFromExplorer(page, "src");
|
||||||
|
await openFileFromExplorer(page, "find-target.txt");
|
||||||
|
await expect(page.getByTestId("workspace-file-pane")).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByTestId("workspace-file-pane").click();
|
||||||
|
|
||||||
|
await openFind(page);
|
||||||
|
await typeFindQuery(page, "needle");
|
||||||
|
await expect(page.getByText("1 / 3")).toBeVisible({ timeout: 10_000 });
|
||||||
|
await page.getByTestId("pane-find-next").click();
|
||||||
|
await expect(page.getByText("2 / 3")).toBeVisible({ timeout: 10_000 });
|
||||||
|
await page.getByTestId("pane-find-prev").click();
|
||||||
|
await expect(page.getByText("1 / 3")).toBeVisible({ timeout: 10_000 });
|
||||||
|
await page.getByTestId("pane-find-input").focus();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await expect(page.getByText("2 / 3")).toBeVisible({ timeout: 10_000 });
|
||||||
|
await page.keyboard.press("Shift+Enter");
|
||||||
|
await expect(page.getByText("1 / 3")).toBeVisible({ timeout: 10_000 });
|
||||||
|
await typeFindQuery(page, "missing-value");
|
||||||
|
await expect(page.getByText("No matches")).toBeVisible({ timeout: 10_000 });
|
||||||
|
await typeFindQuery(page, "");
|
||||||
|
await expect(page.getByText("0 / 0")).toBeVisible({ timeout: 10_000 });
|
||||||
|
await typeFindQuery(page, "needle");
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.getByTestId("pane-find-bar")).toHaveCount(0);
|
||||||
|
await testInfo.attach("file-find-walkthrough", {
|
||||||
|
body: await page.screenshot(),
|
||||||
|
contentType: "image/png",
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByLabel("Split pane right").filter({ visible: true }).last().click();
|
||||||
|
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
|
||||||
|
await page.getByTestId("workspace-new-terminal").filter({ visible: true }).last().click();
|
||||||
|
const splitTerminal = page.getByTestId("terminal-surface").last();
|
||||||
|
await expect(splitTerminal).toBeVisible({ timeout: 20_000 });
|
||||||
|
await splitTerminal.click();
|
||||||
|
await setupDeterministicPrompt(page, `SPLIT_FIND_READY_${Date.now()}`);
|
||||||
|
await splitTerminal.pressSequentially("printf 'split needle one\\nsplit needle two\\n'\n", {
|
||||||
|
delay: 0,
|
||||||
|
});
|
||||||
|
await waitForTerminalContent(page, (text) => text.includes("split needle two"), 10_000);
|
||||||
|
await openFind(page);
|
||||||
|
await typeFindQuery(page, "needle");
|
||||||
|
await expect(page.getByText(/[1-9] \/ [1-9]/)).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
await page.getByTestId("workspace-file-pane").click();
|
||||||
|
await expect(page.getByTestId("pane-find-bar")).toHaveCount(0);
|
||||||
|
await openFind(page);
|
||||||
|
await typeFindQuery(page, "needle");
|
||||||
|
await expect(page.getByText("1 / 3")).toBeVisible({ timeout: 10_000 });
|
||||||
|
await page.getByTestId("pane-find-close").click();
|
||||||
|
await expect(page.getByTestId("pane-find-bar")).toHaveCount(0);
|
||||||
|
await testInfo.attach("terminal-find-walkthrough", {
|
||||||
|
body: await page.screenshot(),
|
||||||
|
contentType: "image/png",
|
||||||
|
});
|
||||||
|
await testInfo.attach("split-pane-focus-switching", {
|
||||||
|
body: await page.screenshot(),
|
||||||
|
contentType: "image/png",
|
||||||
|
});
|
||||||
|
|
||||||
|
const agent = await agentClient.createAgent({
|
||||||
|
provider: "mock",
|
||||||
|
cwd: repo.path,
|
||||||
|
title: "Find pane QA",
|
||||||
|
modeId: "load-test",
|
||||||
|
model: "ten-second-stream",
|
||||||
|
initialPrompt: "chat needle alpha",
|
||||||
|
});
|
||||||
|
await page.goto(
|
||||||
|
`${buildHostWorkspaceRoute(getServerId(), repo.path)}?open=${encodeURIComponent(
|
||||||
|
`agent:${agent.id}`,
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
await expect(page.getByText("chat needle alpha").first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await page.getByText("chat needle alpha").first().click();
|
||||||
|
await openFind(page);
|
||||||
|
await typeFindQuery(page, "needle");
|
||||||
|
await expect(page.getByText(/[1-9] \/ [1-9]/)).toBeVisible({ timeout: 10_000 });
|
||||||
|
await page.getByTestId("pane-find-next").click();
|
||||||
|
await page.getByTestId("pane-find-prev").click();
|
||||||
|
await page.getByTestId("pane-find-close").click();
|
||||||
|
await expect(page.getByTestId("pane-find-bar")).toHaveCount(0);
|
||||||
|
await testInfo.attach("chat-find-walkthrough", {
|
||||||
|
body: await page.screenshot(),
|
||||||
|
contentType: "image/png",
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByTestId("workspace-header-menu-trigger").click();
|
||||||
|
const browserMenuItem = page.getByTestId("workspace-header-new-browser");
|
||||||
|
if (await browserMenuItem.isVisible().catch(() => false)) {
|
||||||
|
await browserMenuItem.click();
|
||||||
|
await expect(
|
||||||
|
page.getByText("Open this workspace in Electron to use the built-in browser."),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
await page.keyboard.press(findShortcut());
|
||||||
|
await expect(page.getByTestId("pane-find-bar")).toHaveCount(0);
|
||||||
|
await testInfo.attach("browser-web-fallback", {
|
||||||
|
body: await page.screenshot(),
|
||||||
|
contentType: "image/png",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await testInfo.attach("browser-web-fallback", {
|
||||||
|
body: "Browser tab creation is not exposed in this browser-web runtime.",
|
||||||
|
contentType: "text/plain",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await agentClient.close();
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,11 +32,30 @@ const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
|
|||||||
autoscrollToTopThreshold: 0,
|
autoscrollToTopThreshold: 0,
|
||||||
});
|
});
|
||||||
const HISTORY_START_THRESHOLD_PX = 96;
|
const HISTORY_START_THRESHOLD_PX = 96;
|
||||||
|
const SCROLL_TO_INDEX_RETRY_DELAY_MS = 80;
|
||||||
|
|
||||||
|
interface NativeScrollToIndexFailedInfo {
|
||||||
|
index: number;
|
||||||
|
highestMeasuredFrameIndex: number;
|
||||||
|
averageItemLength: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NativeScrollIndexFallbackInput {
|
||||||
|
index: number;
|
||||||
|
averageItemLength: number;
|
||||||
|
}
|
||||||
|
|
||||||
function keyExtractor(item: { id: string }): string {
|
function keyExtractor(item: { id: string }): string {
|
||||||
return item.id;
|
return item.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getNativeScrollToIndexFallbackOffset(input: NativeScrollIndexFallbackInput) {
|
||||||
|
if (!Number.isFinite(input.averageItemLength) || input.averageItemLength <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.max(0, input.averageItemLength * input.index);
|
||||||
|
}
|
||||||
|
|
||||||
function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrategy }) {
|
function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrategy }) {
|
||||||
const {
|
const {
|
||||||
agentId,
|
agentId,
|
||||||
@@ -69,6 +88,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
});
|
});
|
||||||
const scrollOffsetYRef = useRef(0);
|
const scrollOffsetYRef = useRef(0);
|
||||||
const programmaticScrollEventBudgetRef = useRef(0);
|
const programmaticScrollEventBudgetRef = useRef(0);
|
||||||
|
const scrollToIndexRetryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
|
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
|
||||||
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
|
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
|
||||||
const historyStartReadyRef = useRef(false);
|
const historyStartReadyRef = useRef(false);
|
||||||
@@ -87,6 +107,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const clearScrollToIndexRetry = useCallback(() => {
|
||||||
|
if (scrollToIndexRetryTimeoutRef.current !== null) {
|
||||||
|
clearTimeout(scrollToIndexRetryTimeoutRef.current);
|
||||||
|
scrollToIndexRetryTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const markNativeViewportSettling = useCallback(() => {
|
const markNativeViewportSettling = useCallback(() => {
|
||||||
clearNativeViewportSettling();
|
clearNativeViewportSettling();
|
||||||
setIsNativeViewportSettling(true);
|
setIsNativeViewportSettling(true);
|
||||||
@@ -149,6 +176,37 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const scrollToHistoryIndex = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
programmaticScrollEventBudgetRef.current = 3;
|
||||||
|
clearScrollToIndexRetry();
|
||||||
|
flatListRef.current?.scrollToIndex({
|
||||||
|
index,
|
||||||
|
animated: true,
|
||||||
|
viewPosition: 0.5,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[clearScrollToIndexRetry],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleScrollToIndexFailed = useStableEvent((info: NativeScrollToIndexFailedInfo) => {
|
||||||
|
programmaticScrollEventBudgetRef.current = 3;
|
||||||
|
flatListRef.current?.scrollToOffset({
|
||||||
|
offset: getNativeScrollToIndexFallbackOffset(info),
|
||||||
|
animated: true,
|
||||||
|
});
|
||||||
|
clearScrollToIndexRetry();
|
||||||
|
scrollToIndexRetryTimeoutRef.current = setTimeout(() => {
|
||||||
|
scrollToIndexRetryTimeoutRef.current = null;
|
||||||
|
programmaticScrollEventBudgetRef.current = 3;
|
||||||
|
flatListRef.current?.scrollToIndex({
|
||||||
|
index: info.index,
|
||||||
|
animated: true,
|
||||||
|
viewPosition: 0.5,
|
||||||
|
});
|
||||||
|
}, SCROLL_TO_INDEX_RETRY_DELAY_MS);
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
streamViewportMetricsRef.current = {
|
streamViewportMetricsRef.current = {
|
||||||
containerKey: "native-virtualized",
|
containerKey: "native-virtualized",
|
||||||
@@ -161,6 +219,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
};
|
};
|
||||||
scrollOffsetYRef.current = 0;
|
scrollOffsetYRef.current = 0;
|
||||||
clearNativeViewportSettling();
|
clearNativeViewportSettling();
|
||||||
|
clearScrollToIndexRetry();
|
||||||
setIsNativeViewportSettling(false);
|
setIsNativeViewportSettling(false);
|
||||||
historyStartReadyRef.current = false;
|
historyStartReadyRef.current = false;
|
||||||
const frame = requestAnimationFrame(() => {
|
const frame = requestAnimationFrame(() => {
|
||||||
@@ -169,7 +228,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
return () => {
|
return () => {
|
||||||
cancelAnimationFrame(frame);
|
cancelAnimationFrame(frame);
|
||||||
};
|
};
|
||||||
}, [agentId, clearNativeViewportSettling]);
|
}, [agentId, clearNativeViewportSettling, clearScrollToIndexRetry]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const keyboardEvents = [
|
const keyboardEvents = [
|
||||||
@@ -190,8 +249,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
subscription.remove();
|
subscription.remove();
|
||||||
}
|
}
|
||||||
clearNativeViewportSettling();
|
clearNativeViewportSettling();
|
||||||
|
clearScrollToIndexRetry();
|
||||||
};
|
};
|
||||||
}, [clearNativeViewportSettling, markNativeViewportSettling]);
|
}, [clearNativeViewportSettling, clearScrollToIndexRetry, markNativeViewportSettling]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomAnchorController.prepareForStickyContentChange();
|
bottomAnchorController.prepareForStickyContentChange();
|
||||||
@@ -209,6 +269,17 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
bottomAnchorController.prepareForStickyViewportChange();
|
bottomAnchorController.prepareForStickyViewportChange();
|
||||||
markNativeViewportSettling();
|
markNativeViewportSettling();
|
||||||
},
|
},
|
||||||
|
scrollToStreamItem: (target) => {
|
||||||
|
programmaticScrollEventBudgetRef.current = 3;
|
||||||
|
if (target.source === "liveHead") {
|
||||||
|
flatListRef.current?.scrollToOffset({
|
||||||
|
offset: 0,
|
||||||
|
animated: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scrollToHistoryIndex(target.index);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
viewportRef.current = handle;
|
viewportRef.current = handle;
|
||||||
return () => {
|
return () => {
|
||||||
@@ -216,7 +287,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
viewportRef.current = null;
|
viewportRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [agentId, bottomAnchorController, markNativeViewportSettling, viewportRef]);
|
}, [
|
||||||
|
agentId,
|
||||||
|
bottomAnchorController,
|
||||||
|
markNativeViewportSettling,
|
||||||
|
scrollToHistoryIndex,
|
||||||
|
viewportRef,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleScroll = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
const handleScroll = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
|
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
|
||||||
@@ -360,6 +437,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
onContentSizeChange={handleContentSizeChange}
|
onContentSizeChange={handleContentSizeChange}
|
||||||
|
onScrollToIndexFailed={handleScrollToIndexFailed}
|
||||||
maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION}
|
maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION}
|
||||||
initialNumToRender={40}
|
initialNumToRender={40}
|
||||||
maxToRenderPerBatch={40}
|
maxToRenderPerBatch={40}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, {
|
import React, {
|
||||||
Fragment,
|
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -67,6 +66,10 @@ function scrollElementToBottom(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStreamItemElementId(itemId: string): string {
|
||||||
|
return `agent-stream-row-${itemId}`;
|
||||||
|
}
|
||||||
|
|
||||||
function syncNearBottom(
|
function syncNearBottom(
|
||||||
scrollContainer: HTMLElement | null,
|
scrollContainer: HTMLElement | null,
|
||||||
onNearBottomChange: (value: boolean) => void,
|
onNearBottomChange: (value: boolean) => void,
|
||||||
@@ -468,6 +471,21 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
}
|
}
|
||||||
scheduleStickToBottom();
|
scheduleStickToBottom();
|
||||||
},
|
},
|
||||||
|
scrollToStreamItem: (target) => {
|
||||||
|
setFollowOutput(false);
|
||||||
|
cancelPendingStickToBottom();
|
||||||
|
if (target.source === "historyVirtualized") {
|
||||||
|
rowVirtualizer.scrollToIndex(target.index, { align: "center" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = document.getElementById(getStreamItemElementId(target.itemId));
|
||||||
|
row?.scrollIntoView({ block: "center", behavior: "auto" });
|
||||||
|
const scrollContainer = scrollContainerRef.current;
|
||||||
|
if (scrollContainer) {
|
||||||
|
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
|
||||||
|
syncNearBottom(scrollContainer, onNearBottomChange);
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
viewportRef.current = handle;
|
viewportRef.current = handle;
|
||||||
return () => {
|
return () => {
|
||||||
@@ -476,7 +494,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
}
|
}
|
||||||
cancelPendingStickToBottom();
|
cancelPendingStickToBottom();
|
||||||
};
|
};
|
||||||
}, [cancelPendingStickToBottom, forceStickToBottom, scheduleStickToBottom, viewportRef]);
|
}, [
|
||||||
|
cancelPendingStickToBottom,
|
||||||
|
forceStickToBottom,
|
||||||
|
onNearBottomChange,
|
||||||
|
rowVirtualizer,
|
||||||
|
scheduleStickToBottom,
|
||||||
|
viewportRef,
|
||||||
|
]);
|
||||||
|
|
||||||
const contentContainerStyle = useMemo((): CSSProperties => {
|
const contentContainerStyle = useMemo((): CSSProperties => {
|
||||||
return {
|
return {
|
||||||
@@ -520,14 +545,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
);
|
);
|
||||||
const mountedHistoryRows = useMemo(() => {
|
const mountedHistoryRows = useMemo(() => {
|
||||||
return segments.historyMounted.map((item, index) => (
|
return segments.historyMounted.map((item, index) => (
|
||||||
<Fragment key={item.id}>
|
<div id={getStreamItemElementId(item.id)} key={item.id}>
|
||||||
{renderHistoryMountedRow(item, index, segments.historyMounted)}
|
{renderHistoryMountedRow(item, index, segments.historyMounted)}
|
||||||
</Fragment>
|
</div>
|
||||||
));
|
));
|
||||||
}, [renderHistoryMountedRow, segments.historyMounted]);
|
}, [renderHistoryMountedRow, segments.historyMounted]);
|
||||||
const liveHeadRows = useMemo(() => {
|
const liveHeadRows = useMemo(() => {
|
||||||
return segments.liveHead.map((item, index) => (
|
return segments.liveHead.map((item, index) => (
|
||||||
<Fragment key={item.id}>{renderLiveHeadRow(item, index, segments.liveHead)}</Fragment>
|
<div id={getStreamItemElementId(item.id)} key={item.id}>
|
||||||
|
{renderLiveHeadRow(item, index, segments.liveHead)}
|
||||||
|
</div>
|
||||||
));
|
));
|
||||||
}, [renderLiveHeadRow, segments.liveHead]);
|
}, [renderLiveHeadRow, segments.liveHead]);
|
||||||
const liveAuxiliary = useMemo(() => {
|
const liveAuxiliary = useMemo(() => {
|
||||||
@@ -570,6 +597,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
<div
|
<div
|
||||||
key={virtualRow.key}
|
key={virtualRow.key}
|
||||||
data-index={virtualRow.index}
|
data-index={virtualRow.index}
|
||||||
|
id={getStreamItemElementId(item.id)}
|
||||||
ref={measureVirtualizedRowElement}
|
ref={measureVirtualizedRowElement}
|
||||||
style={renderVirtualRowStyle(virtualRow.start)}
|
style={renderVirtualRowStyle(virtualRow.start)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ export interface StreamEdgeSlotProps {
|
|||||||
export interface StreamViewportHandle {
|
export interface StreamViewportHandle {
|
||||||
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
|
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
|
||||||
prepareForViewportChange: () => void;
|
prepareForViewportChange: () => void;
|
||||||
|
scrollToStreamItem: (target: StreamScrollTarget) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamScrollTarget {
|
||||||
|
source: "historyVirtualized" | "historyMounted" | "liveHead";
|
||||||
|
index: number;
|
||||||
|
itemId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamSegmentRenderers {
|
export interface StreamSegmentRenderers {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
CompactionMarker,
|
CompactionMarker,
|
||||||
MessageOuterSpacingProvider,
|
MessageOuterSpacingProvider,
|
||||||
type InlinePathTarget,
|
type InlinePathTarget,
|
||||||
|
type MessageFindHighlight,
|
||||||
} from "@/components/message";
|
} from "@/components/message";
|
||||||
import { PlanCard } from "@/components/plan-card";
|
import { PlanCard } from "@/components/plan-card";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
@@ -58,6 +59,11 @@ import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
|||||||
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
|
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
|
||||||
import { CompletedTurnFooterRow, TurnFooter, type TurnContentStrategy } from "./turn-footer";
|
import { CompletedTurnFooterRow, TurnFooter, type TurnContentStrategy } from "./turn-footer";
|
||||||
import { layoutStream, type StreamLayoutItem } from "./layout";
|
import { layoutStream, type StreamLayoutItem } from "./layout";
|
||||||
|
import {
|
||||||
|
buildAgentStreamSearchModel,
|
||||||
|
findAgentStreamSearchMatches,
|
||||||
|
type AgentStreamSearchMatch,
|
||||||
|
} from "@/components/agent-stream-search-model";
|
||||||
import {
|
import {
|
||||||
type BottomAnchorLocalRequest,
|
type BottomAnchorLocalRequest,
|
||||||
type BottomAnchorRouteRequest,
|
type BottomAnchorRouteRequest,
|
||||||
@@ -75,6 +81,12 @@ import {
|
|||||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
|
import {
|
||||||
|
FindBar,
|
||||||
|
type PaneFindMatchState,
|
||||||
|
type UsePaneFindResult,
|
||||||
|
usePaneFind,
|
||||||
|
} from "@/panels/pane-find";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
|
|
||||||
@@ -197,6 +209,69 @@ function renderLiveHeadStreamItem(input: {
|
|||||||
return input.renderStreamItem(layoutItem);
|
return input.renderStreamItem(layoutItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createAgentStreamFindMatchState(input: {
|
||||||
|
query: string;
|
||||||
|
matches: AgentStreamSearchMatch[];
|
||||||
|
currentMatchId: string | null;
|
||||||
|
}): PaneFindMatchState {
|
||||||
|
if (input.query.length === 0) {
|
||||||
|
return { status: "empty" };
|
||||||
|
}
|
||||||
|
if (input.matches.length === 0) {
|
||||||
|
return { status: "no-match" };
|
||||||
|
}
|
||||||
|
const currentIndex = Math.max(
|
||||||
|
0,
|
||||||
|
input.matches.findIndex((match) => match.id === input.currentMatchId),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: "matched",
|
||||||
|
current: currentIndex + 1,
|
||||||
|
total: input.matches.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAgentStreamFindHighlightsByItemId(input: {
|
||||||
|
matches: AgentStreamSearchMatch[];
|
||||||
|
currentMatchId: string | null;
|
||||||
|
}): Map<string, MessageFindHighlight[]> {
|
||||||
|
const highlightsByItemId = new Map<string, MessageFindHighlight[]>();
|
||||||
|
|
||||||
|
for (const match of input.matches) {
|
||||||
|
if (match.segmentKey !== "text") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemKind = match.entry.item.kind;
|
||||||
|
if (itemKind !== "user_message" && itemKind !== "assistant_message") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlights = highlightsByItemId.get(match.entry.item.id) ?? [];
|
||||||
|
highlights.push({
|
||||||
|
id: match.id,
|
||||||
|
start: match.start,
|
||||||
|
end: match.end,
|
||||||
|
isCurrent: match.id === input.currentMatchId,
|
||||||
|
});
|
||||||
|
highlightsByItemId.set(match.entry.item.id, highlights);
|
||||||
|
}
|
||||||
|
|
||||||
|
return highlightsByItemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentStreamFindState {
|
||||||
|
query: string;
|
||||||
|
matches: AgentStreamSearchMatch[];
|
||||||
|
currentMatchId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_AGENT_STREAM_FIND_STATE: AgentStreamFindState = {
|
||||||
|
query: "",
|
||||||
|
matches: [],
|
||||||
|
currentMatchId: null,
|
||||||
|
};
|
||||||
|
|
||||||
export interface AgentStreamViewHandle {
|
export interface AgentStreamViewHandle {
|
||||||
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
|
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
|
||||||
prepareForViewportChange(): void;
|
prepareForViewportChange(): void;
|
||||||
@@ -373,6 +448,154 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
streamRenderStrategy,
|
streamRenderStrategy,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
const searchModel = useMemo(
|
||||||
|
() =>
|
||||||
|
buildAgentStreamSearchModel({
|
||||||
|
streamItems,
|
||||||
|
streamHead: streamHead ?? [],
|
||||||
|
platform: isWeb ? "web" : "native",
|
||||||
|
isMobileBreakpoint: isMobile,
|
||||||
|
cwd: agent.cwd,
|
||||||
|
}),
|
||||||
|
[agent.cwd, isMobile, streamHead, streamItems],
|
||||||
|
);
|
||||||
|
const [findState, setFindState] = useState<AgentStreamFindState>(EMPTY_AGENT_STREAM_FIND_STATE);
|
||||||
|
const findQuery = findState.query;
|
||||||
|
const findMatches = findState.matches;
|
||||||
|
const currentFindMatchId = findState.currentMatchId;
|
||||||
|
const currentFindMatchIndex = useMemo(() => {
|
||||||
|
if (findMatches.length === 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
const existingIndex = findMatches.findIndex((match) => match.id === currentFindMatchId);
|
||||||
|
return existingIndex >= 0 ? existingIndex : 0;
|
||||||
|
}, [currentFindMatchId, findMatches]);
|
||||||
|
const findMatchState = useMemo(
|
||||||
|
() =>
|
||||||
|
createAgentStreamFindMatchState({
|
||||||
|
query: findQuery,
|
||||||
|
matches: findMatches,
|
||||||
|
currentMatchId: currentFindMatchId,
|
||||||
|
}),
|
||||||
|
[currentFindMatchId, findMatches, findQuery],
|
||||||
|
);
|
||||||
|
const findHighlightsByItemId = useMemo(
|
||||||
|
() =>
|
||||||
|
createAgentStreamFindHighlightsByItemId({
|
||||||
|
matches: findMatches,
|
||||||
|
currentMatchId: currentFindMatchId,
|
||||||
|
}),
|
||||||
|
[currentFindMatchId, findMatches],
|
||||||
|
);
|
||||||
|
const scrollFindMatchIntoView = useCallback((match: AgentStreamSearchMatch | undefined) => {
|
||||||
|
if (!match) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
viewportRef.current?.scrollToStreamItem({
|
||||||
|
source: match.entry.source,
|
||||||
|
index: match.entry.index,
|
||||||
|
itemId: match.entry.item.id,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
const paneFind = usePaneFind({
|
||||||
|
matchState: findMatchState,
|
||||||
|
onQuery: (query) => {
|
||||||
|
const nextMatches = findAgentStreamSearchMatches({
|
||||||
|
model: searchModel,
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
const nextMatchId = nextMatches[0]?.id ?? null;
|
||||||
|
setFindState({ query, matches: nextMatches, currentMatchId: nextMatchId });
|
||||||
|
scrollFindMatchIntoView(nextMatches[0]);
|
||||||
|
return createAgentStreamFindMatchState({
|
||||||
|
query,
|
||||||
|
matches: nextMatches,
|
||||||
|
currentMatchId: nextMatchId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onNext: () => {
|
||||||
|
if (findMatches.length === 0) {
|
||||||
|
return findMatchState;
|
||||||
|
}
|
||||||
|
const nextIndex = (currentFindMatchIndex + 1) % findMatches.length;
|
||||||
|
const nextMatch = findMatches[nextIndex];
|
||||||
|
setFindState((current) => ({
|
||||||
|
...current,
|
||||||
|
currentMatchId: nextMatch?.id ?? null,
|
||||||
|
}));
|
||||||
|
scrollFindMatchIntoView(nextMatch);
|
||||||
|
return createAgentStreamFindMatchState({
|
||||||
|
query: findQuery,
|
||||||
|
matches: findMatches,
|
||||||
|
currentMatchId: nextMatch?.id ?? null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onPrev: () => {
|
||||||
|
if (findMatches.length === 0) {
|
||||||
|
return findMatchState;
|
||||||
|
}
|
||||||
|
const nextIndex = (currentFindMatchIndex - 1 + findMatches.length) % findMatches.length;
|
||||||
|
const nextMatch = findMatches[nextIndex];
|
||||||
|
setFindState((current) => ({
|
||||||
|
...current,
|
||||||
|
currentMatchId: nextMatch?.id ?? null,
|
||||||
|
}));
|
||||||
|
scrollFindMatchIntoView(nextMatch);
|
||||||
|
return createAgentStreamFindMatchState({
|
||||||
|
query: findQuery,
|
||||||
|
matches: findMatches,
|
||||||
|
currentMatchId: nextMatch?.id ?? null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onClose: () => {
|
||||||
|
setFindState(EMPTY_AGENT_STREAM_FIND_STATE);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
setFindState((current) => {
|
||||||
|
if (current.query.length === 0) {
|
||||||
|
return current.currentMatchId === null && current.matches.length === 0
|
||||||
|
? current
|
||||||
|
: EMPTY_AGENT_STREAM_FIND_STATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMatches = findAgentStreamSearchMatches({
|
||||||
|
model: searchModel,
|
||||||
|
query: current.query,
|
||||||
|
});
|
||||||
|
let keepsCurrentMatch = false;
|
||||||
|
if (current.currentMatchId) {
|
||||||
|
for (const match of nextMatches) {
|
||||||
|
if (match.id === current.currentMatchId) {
|
||||||
|
keepsCurrentMatch = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nextMatchId = keepsCurrentMatch
|
||||||
|
? current.currentMatchId
|
||||||
|
: (nextMatches[0]?.id ?? null);
|
||||||
|
|
||||||
|
let matchesUnchanged = current.matches.length === nextMatches.length;
|
||||||
|
if (matchesUnchanged) {
|
||||||
|
for (let index = 0; index < current.matches.length; index += 1) {
|
||||||
|
if (current.matches[index]?.id !== nextMatches[index]?.id) {
|
||||||
|
matchesUnchanged = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matchesUnchanged && current.currentMatchId === nextMatchId) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
query: current.query,
|
||||||
|
matches: nextMatches,
|
||||||
|
currentMatchId: nextMatchId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [searchModel]);
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
@@ -423,10 +646,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
client={client}
|
client={client}
|
||||||
isFirstInGroup={layoutItem.isFirstInUserGroup}
|
isFirstInGroup={layoutItem.isFirstInUserGroup}
|
||||||
isLastInGroup={layoutItem.isLastInUserGroup}
|
isLastInGroup={layoutItem.isLastInUserGroup}
|
||||||
|
findHighlights={findHighlightsByItemId.get(item.id)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[agent.capabilities, agentId, client, resolvedServerId],
|
[agent.capabilities, agentId, client, findHighlightsByItemId, resolvedServerId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderAssistantMessageItem = useCallback(
|
const renderAssistantMessageItem = useCallback(
|
||||||
@@ -446,11 +670,19 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
serverId={resolvedServerId}
|
serverId={resolvedServerId}
|
||||||
client={client}
|
client={client}
|
||||||
spacing={layoutItem.assistantSpacing}
|
spacing={layoutItem.assistantSpacing}
|
||||||
|
findHighlights={findHighlightsByItemId.get(item.id)}
|
||||||
/>
|
/>
|
||||||
</AssistantFileLinkResolverProvider>
|
</AssistantFileLinkResolverProvider>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot],
|
[
|
||||||
|
client,
|
||||||
|
findHighlightsByItemId,
|
||||||
|
handleInlinePathPress,
|
||||||
|
resolvedServerId,
|
||||||
|
toast,
|
||||||
|
workspaceRoot,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderThoughtItem = useCallback(
|
const renderThoughtItem = useCallback(
|
||||||
@@ -700,6 +932,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
return (
|
return (
|
||||||
<ToolCallSheetProvider>
|
<ToolCallSheetProvider>
|
||||||
<View style={stylesheet.container}>
|
<View style={stylesheet.container}>
|
||||||
|
<AgentStreamFindBarSlot paneFind={paneFind} />
|
||||||
<MessageOuterSpacingProvider disableOuterSpacing>
|
<MessageOuterSpacingProvider disableOuterSpacing>
|
||||||
{streamRenderStrategy.render({
|
{streamRenderStrategy.render({
|
||||||
agentId,
|
agentId,
|
||||||
@@ -748,6 +981,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
export const AgentStreamView = memo(AgentStreamViewComponent);
|
export const AgentStreamView = memo(AgentStreamViewComponent);
|
||||||
AgentStreamView.displayName = "AgentStreamView";
|
AgentStreamView.displayName = "AgentStreamView";
|
||||||
|
|
||||||
|
function AgentStreamFindBarSlot({ paneFind }: { paneFind: UsePaneFindResult }) {
|
||||||
|
return paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null;
|
||||||
|
}
|
||||||
|
|
||||||
interface ToolCallSlotProps extends Omit<
|
interface ToolCallSlotProps extends Omit<
|
||||||
ComponentProps<typeof ToolCall>,
|
ComponentProps<typeof ToolCall>,
|
||||||
"onInlineDetailsExpandedChange"
|
"onInlineDetailsExpandedChange"
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { StreamItem } from "@/types/stream";
|
||||||
|
import {
|
||||||
|
buildAgentStreamSearchModel,
|
||||||
|
findAgentStreamSearchMatches,
|
||||||
|
} from "./agent-stream-search-model";
|
||||||
|
|
||||||
|
function timestamp(seed: number): Date {
|
||||||
|
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function userMessage(id: string, text: string, seed = 1): StreamItem {
|
||||||
|
return {
|
||||||
|
kind: "user_message",
|
||||||
|
id,
|
||||||
|
text,
|
||||||
|
timestamp: timestamp(seed),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantMessage(id: string, text: string, seed = 1): StreamItem {
|
||||||
|
return {
|
||||||
|
kind: "assistant_message",
|
||||||
|
id,
|
||||||
|
text,
|
||||||
|
timestamp: timestamp(seed),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function thought(id: string, text: string, seed = 1): StreamItem {
|
||||||
|
return {
|
||||||
|
kind: "thought",
|
||||||
|
id,
|
||||||
|
text,
|
||||||
|
status: "ready",
|
||||||
|
timestamp: timestamp(seed),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function activityLog(id: string, message: string, seed = 1): StreamItem {
|
||||||
|
return {
|
||||||
|
kind: "activity_log",
|
||||||
|
id,
|
||||||
|
activityType: "info",
|
||||||
|
message,
|
||||||
|
metadata: { hidden: "metadata is not searched" },
|
||||||
|
timestamp: timestamp(seed),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function todoList(id: string, seed = 1): StreamItem {
|
||||||
|
return {
|
||||||
|
kind: "todo_list",
|
||||||
|
id,
|
||||||
|
provider: "codex",
|
||||||
|
items: [
|
||||||
|
{ text: "Write the red test", completed: true },
|
||||||
|
{ text: "Make search green", completed: false },
|
||||||
|
],
|
||||||
|
timestamp: timestamp(seed),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSearchableText(item: StreamItem): string {
|
||||||
|
const model = buildAgentStreamSearchModel({
|
||||||
|
streamItems: [item],
|
||||||
|
streamHead: [],
|
||||||
|
platform: "web",
|
||||||
|
isMobileBreakpoint: true,
|
||||||
|
});
|
||||||
|
return model.entries[0]?.text ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildAgentStreamSearchModel", () => {
|
||||||
|
it("indexes user and assistant message text", () => {
|
||||||
|
expect(getSearchableText(userMessage("u1", "user text"))).toBe("user text");
|
||||||
|
expect(getSearchableText(assistantMessage("a1", "assistant text"))).toBe("assistant text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes non-message rows that do not render find highlights", () => {
|
||||||
|
expect(getSearchableText(thought("t1", "thought text"))).toBe("");
|
||||||
|
expect(getSearchableText(activityLog("l1", "activity text"))).toBe("");
|
||||||
|
expect(getSearchableText(todoList("todo"))).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes tool call rows from the search index", () => {
|
||||||
|
const toolCall: StreamItem = {
|
||||||
|
kind: "tool_call",
|
||||||
|
id: "shell",
|
||||||
|
timestamp: timestamp(1),
|
||||||
|
payload: {
|
||||||
|
source: "agent",
|
||||||
|
data: {
|
||||||
|
provider: "codex",
|
||||||
|
callId: "call-shell",
|
||||||
|
name: "exec_command",
|
||||||
|
status: "completed",
|
||||||
|
error: null,
|
||||||
|
detail: {
|
||||||
|
type: "shell",
|
||||||
|
command: "npm run typecheck",
|
||||||
|
output: "internal output should stay out",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getSearchableText(toolCall)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders virtualized history, mounted history, live head, and optimistic items deterministically", () => {
|
||||||
|
const committed: StreamItem[] = [];
|
||||||
|
for (let index = 0; index < 64; index += 1) {
|
||||||
|
committed.push(userMessage(`u${index}`, `history ${index}`, index));
|
||||||
|
}
|
||||||
|
const optimistic = userMessage("optimistic", "draft message", 65);
|
||||||
|
const liveHead = [assistantMessage("live", "live answer", 66)];
|
||||||
|
|
||||||
|
const model = buildAgentStreamSearchModel({
|
||||||
|
streamItems: committed,
|
||||||
|
optimisticItems: [optimistic],
|
||||||
|
streamHead: liveHead,
|
||||||
|
platform: "web",
|
||||||
|
isMobileBreakpoint: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.entries.at(0)?.item.id).toBe("optimistic");
|
||||||
|
expect(model.entries.map((entry) => entry.source)).toEqual([
|
||||||
|
...Array.from(
|
||||||
|
{ length: model.segments.historyVirtualized.length },
|
||||||
|
() => "historyVirtualized",
|
||||||
|
),
|
||||||
|
...Array.from({ length: model.segments.historyMounted.length }, () => "historyMounted"),
|
||||||
|
"liveHead",
|
||||||
|
]);
|
||||||
|
expect(model.entries.at(-1)?.item.id).toBe("live");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not duplicate an optimistic item once committed history has the same id", () => {
|
||||||
|
const committed = [userMessage("u1", "committed draft")];
|
||||||
|
const model = buildAgentStreamSearchModel({
|
||||||
|
streamItems: committed,
|
||||||
|
optimisticItems: [userMessage("u1", "optimistic draft")],
|
||||||
|
streamHead: [],
|
||||||
|
platform: "web",
|
||||||
|
isMobileBreakpoint: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.entries.map((entry) => entry.text)).toEqual(["committed draft"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findAgentStreamSearchMatches", () => {
|
||||||
|
it("returns stable match ids from item identity and local occurrence data", () => {
|
||||||
|
const model = buildAgentStreamSearchModel({
|
||||||
|
streamItems: [assistantMessage("a1", "Alpha alpha beta")],
|
||||||
|
streamHead: [assistantMessage("h1", "alpha live")],
|
||||||
|
platform: "web",
|
||||||
|
isMobileBreakpoint: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const matches = findAgentStreamSearchMatches({
|
||||||
|
model,
|
||||||
|
query: "alpha",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(matches.map((match) => match.id)).toEqual([
|
||||||
|
"a1:text:0:0:5",
|
||||||
|
"a1:text:1:6:11",
|
||||||
|
"h1:text:0:0:5",
|
||||||
|
]);
|
||||||
|
expect(matches.map((match) => match.entry.item.id)).toEqual(["a1", "a1", "h1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips fenced code blocks while preserving message offsets for highlights", () => {
|
||||||
|
const model = buildAgentStreamSearchModel({
|
||||||
|
streamItems: [assistantMessage("a1", "before alpha\n```\nalpha\n```\nafter alpha")],
|
||||||
|
streamHead: [],
|
||||||
|
platform: "web",
|
||||||
|
isMobileBreakpoint: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const matches = findAgentStreamSearchMatches({
|
||||||
|
model,
|
||||||
|
query: "alpha",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(matches.map((match) => match.id)).toEqual(["a1:text:0:7:12", "a1:text:0:33:38"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import type { StreamItem } from "@/types/stream";
|
||||||
|
import {
|
||||||
|
findMountedWindowStart,
|
||||||
|
getWebMountedRecentStreamItems,
|
||||||
|
getWebPartialVirtualizationThreshold,
|
||||||
|
} from "@/agent-stream/web-virtualization";
|
||||||
|
|
||||||
|
type AgentStreamSearchSource = "historyVirtualized" | "historyMounted" | "liveHead";
|
||||||
|
|
||||||
|
interface AgentStreamSearchTextSegment {
|
||||||
|
key: string;
|
||||||
|
text: string;
|
||||||
|
startOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentStreamSearchEntry {
|
||||||
|
item: StreamItem;
|
||||||
|
source: AgentStreamSearchSource;
|
||||||
|
index: number;
|
||||||
|
text: string;
|
||||||
|
segments: AgentStreamSearchTextSegment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentStreamSearchMatch {
|
||||||
|
id: string;
|
||||||
|
entry: AgentStreamSearchEntry;
|
||||||
|
segmentKey: string;
|
||||||
|
occurrenceIndex: number;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentStreamSearchModel {
|
||||||
|
entries: AgentStreamSearchEntry[];
|
||||||
|
segments: {
|
||||||
|
historyVirtualized: AgentStreamSearchEntry[];
|
||||||
|
historyMounted: AgentStreamSearchEntry[];
|
||||||
|
liveHead: AgentStreamSearchEntry[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildAgentStreamSearchModelInput {
|
||||||
|
platform: "web" | "native";
|
||||||
|
isMobileBreakpoint: boolean;
|
||||||
|
streamItems: StreamItem[];
|
||||||
|
streamHead: StreamItem[];
|
||||||
|
optimisticItems?: StreamItem[];
|
||||||
|
cwd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FindAgentStreamSearchMatchesInput {
|
||||||
|
model: AgentStreamSearchModel;
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFenceDelimiter(line: string): string | null {
|
||||||
|
const match = /^( {0,3})(`{3,}|~{3,})/.exec(line);
|
||||||
|
return match?.[2] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageSearchableSegments(text: string): AgentStreamSearchTextSegment[] {
|
||||||
|
const segments: AgentStreamSearchTextSegment[] = [];
|
||||||
|
let activeFenceCharacter: "`" | "~" | null = null;
|
||||||
|
let activeFenceLength = 0;
|
||||||
|
let currentText = "";
|
||||||
|
let currentStartOffset = 0;
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (currentText.length > 0) {
|
||||||
|
segments.push({ key: "text", text: currentText, startOffset: currentStartOffset });
|
||||||
|
currentText = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
const lineWithBreak = offset + line.length < text.length ? `${line}\n` : line;
|
||||||
|
const fenceDelimiter = getFenceDelimiter(line);
|
||||||
|
const isClosingFence =
|
||||||
|
activeFenceCharacter &&
|
||||||
|
fenceDelimiter?.[0] === activeFenceCharacter &&
|
||||||
|
fenceDelimiter.length >= activeFenceLength;
|
||||||
|
const isOpeningFence = !activeFenceCharacter && fenceDelimiter;
|
||||||
|
const isIndentedCode = !activeFenceCharacter && (/^( {4,}|\t)/.test(line) || line === " ");
|
||||||
|
|
||||||
|
if (isOpeningFence || activeFenceCharacter || isIndentedCode) {
|
||||||
|
flush();
|
||||||
|
} else {
|
||||||
|
if (currentText.length === 0) {
|
||||||
|
currentStartOffset = offset;
|
||||||
|
}
|
||||||
|
currentText += lineWithBreak;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOpeningFence) {
|
||||||
|
activeFenceCharacter = fenceDelimiter[0] as "`" | "~";
|
||||||
|
activeFenceLength = fenceDelimiter.length;
|
||||||
|
} else if (isClosingFence) {
|
||||||
|
activeFenceCharacter = null;
|
||||||
|
activeFenceLength = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += lineWithBreak.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
flush();
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAgentStreamItemSearchableSegments(item: StreamItem): AgentStreamSearchTextSegment[] {
|
||||||
|
switch (item.kind) {
|
||||||
|
case "user_message":
|
||||||
|
case "assistant_message":
|
||||||
|
return item.text ? getMessageSearchableSegments(item.text) : [];
|
||||||
|
case "thought":
|
||||||
|
case "activity_log":
|
||||||
|
case "todo_list":
|
||||||
|
case "tool_call":
|
||||||
|
case "compaction":
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeOptimisticItems(input: {
|
||||||
|
streamItems: StreamItem[];
|
||||||
|
optimisticItems: StreamItem[] | undefined;
|
||||||
|
}): StreamItem[] {
|
||||||
|
if (!input.optimisticItems || input.optimisticItems.length === 0) {
|
||||||
|
return input.streamItems;
|
||||||
|
}
|
||||||
|
const committedIds = new Set(input.streamItems.map((item) => item.id));
|
||||||
|
const pendingOptimisticItems = input.optimisticItems.filter((item) => !committedIds.has(item.id));
|
||||||
|
if (pendingOptimisticItems.length === 0) {
|
||||||
|
return input.streamItems;
|
||||||
|
}
|
||||||
|
return [...pendingOptimisticItems, ...input.streamItems];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEntries(input: {
|
||||||
|
items: StreamItem[];
|
||||||
|
source: AgentStreamSearchSource;
|
||||||
|
startIndex: number;
|
||||||
|
cwd: string | undefined;
|
||||||
|
}): AgentStreamSearchEntry[] {
|
||||||
|
return input.items.map((item, offset) => {
|
||||||
|
const segments = getAgentStreamItemSearchableSegments(item);
|
||||||
|
return {
|
||||||
|
item,
|
||||||
|
source: input.source,
|
||||||
|
index: input.startIndex + offset,
|
||||||
|
text: segments.map((segment) => segment.text).join("\n"),
|
||||||
|
segments,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderStreamItems(input: {
|
||||||
|
items: StreamItem[];
|
||||||
|
platform: "web" | "native";
|
||||||
|
}): StreamItem[] {
|
||||||
|
return input.platform === "native" ? [...input.items].toReversed() : input.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitOrderedHistory(input: {
|
||||||
|
orderedTail: StreamItem[];
|
||||||
|
platform: "web" | "native";
|
||||||
|
isMobileBreakpoint: boolean;
|
||||||
|
}): {
|
||||||
|
historyVirtualizedItems: StreamItem[];
|
||||||
|
historyMountedItems: StreamItem[];
|
||||||
|
} {
|
||||||
|
const shouldSplitHistory =
|
||||||
|
input.platform === "web" &&
|
||||||
|
!input.isMobileBreakpoint &&
|
||||||
|
input.orderedTail.length > getWebPartialVirtualizationThreshold();
|
||||||
|
if (!shouldSplitHistory) {
|
||||||
|
return {
|
||||||
|
historyVirtualizedItems: [],
|
||||||
|
historyMountedItems: input.orderedTail,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const mountedWindowStart = findMountedWindowStart({
|
||||||
|
items: input.orderedTail,
|
||||||
|
minMountedCount: getWebMountedRecentStreamItems(),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
historyVirtualizedItems: input.orderedTail.slice(0, mountedWindowStart),
|
||||||
|
historyMountedItems: input.orderedTail.slice(mountedWindowStart),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAgentStreamSearchModel(
|
||||||
|
input: BuildAgentStreamSearchModelInput,
|
||||||
|
): AgentStreamSearchModel {
|
||||||
|
const tail = mergeOptimisticItems({
|
||||||
|
streamItems: input.streamItems,
|
||||||
|
optimisticItems: input.optimisticItems,
|
||||||
|
});
|
||||||
|
const orderedTail = orderStreamItems({
|
||||||
|
items: tail,
|
||||||
|
platform: input.platform,
|
||||||
|
});
|
||||||
|
const orderedHead = orderStreamItems({
|
||||||
|
items: input.streamHead,
|
||||||
|
platform: input.platform,
|
||||||
|
});
|
||||||
|
const splitHistory = splitOrderedHistory({
|
||||||
|
orderedTail,
|
||||||
|
platform: input.platform,
|
||||||
|
isMobileBreakpoint: input.isMobileBreakpoint,
|
||||||
|
});
|
||||||
|
const historyVirtualized = buildEntries({
|
||||||
|
items: splitHistory.historyVirtualizedItems,
|
||||||
|
source: "historyVirtualized",
|
||||||
|
startIndex: 0,
|
||||||
|
cwd: input.cwd,
|
||||||
|
});
|
||||||
|
const historyMounted = buildEntries({
|
||||||
|
items: splitHistory.historyMountedItems,
|
||||||
|
source: "historyMounted",
|
||||||
|
startIndex: historyVirtualized.length,
|
||||||
|
cwd: input.cwd,
|
||||||
|
});
|
||||||
|
const liveHead = buildEntries({
|
||||||
|
items: orderedHead,
|
||||||
|
source: "liveHead",
|
||||||
|
startIndex: historyVirtualized.length + historyMounted.length,
|
||||||
|
cwd: input.cwd,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
entries: [...historyVirtualized, ...historyMounted, ...liveHead],
|
||||||
|
segments: {
|
||||||
|
historyVirtualized,
|
||||||
|
historyMounted,
|
||||||
|
liveHead,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findAgentStreamSearchMatches(
|
||||||
|
input: FindAgentStreamSearchMatchesInput,
|
||||||
|
): AgentStreamSearchMatch[] {
|
||||||
|
const normalizedQuery = input.query.toLocaleLowerCase();
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches: AgentStreamSearchMatch[] = [];
|
||||||
|
for (const entry of input.model.entries) {
|
||||||
|
for (const segment of entry.segments) {
|
||||||
|
const normalizedText = segment.text.toLocaleLowerCase();
|
||||||
|
let occurrenceIndex = 0;
|
||||||
|
let fromIndex = 0;
|
||||||
|
while (fromIndex <= normalizedText.length) {
|
||||||
|
const start = normalizedText.indexOf(normalizedQuery, fromIndex);
|
||||||
|
if (start < 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const end = start + input.query.length;
|
||||||
|
const absoluteStart = segment.startOffset + start;
|
||||||
|
const absoluteEnd = segment.startOffset + end;
|
||||||
|
matches.push({
|
||||||
|
id: `${entry.item.id}:${segment.key}:${occurrenceIndex}:${absoluteStart}:${absoluteEnd}`,
|
||||||
|
entry,
|
||||||
|
segmentKey: segment.key,
|
||||||
|
occurrenceIndex,
|
||||||
|
start: absoluteStart,
|
||||||
|
end: absoluteEnd,
|
||||||
|
});
|
||||||
|
occurrenceIndex += 1;
|
||||||
|
fromIndex = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import React from "react";
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { BrowserPane } from "@/components/browser-pane.electron";
|
||||||
|
import {
|
||||||
|
PaneFocusProvider,
|
||||||
|
PaneProvider,
|
||||||
|
createPaneFocusContextValue,
|
||||||
|
type PaneContextValue,
|
||||||
|
} from "@/panels/pane-context";
|
||||||
|
import {
|
||||||
|
createPaneFindPaneId,
|
||||||
|
handlePaneFindKeyboardAction,
|
||||||
|
setActivePaneFindPaneId,
|
||||||
|
} from "@/panels/pane-find-registry";
|
||||||
|
|
||||||
|
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||||
|
true;
|
||||||
|
|
||||||
|
const updateBrowser = vi.fn();
|
||||||
|
let browserState = {
|
||||||
|
browsersById: {
|
||||||
|
"browser-a": {
|
||||||
|
id: "browser-a",
|
||||||
|
url: "https://example.com",
|
||||||
|
title: "",
|
||||||
|
faviconUrl: null,
|
||||||
|
canGoBack: false,
|
||||||
|
canGoForward: false,
|
||||||
|
isLoading: false,
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
updateBrowser,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { theme } = vi.hoisted(() => ({
|
||||||
|
theme: {
|
||||||
|
borderRadius: { md: 6 },
|
||||||
|
colors: {
|
||||||
|
accent: "#3b82f6",
|
||||||
|
border: "#333",
|
||||||
|
foreground: "#fff",
|
||||||
|
foregroundMuted: "#aaa",
|
||||||
|
palette: { red: { 500: "#ef4444" } },
|
||||||
|
surface0: "#111",
|
||||||
|
surface1: "#222",
|
||||||
|
surface2: "#333",
|
||||||
|
},
|
||||||
|
fontSize: { sm: 13, xs: 11 },
|
||||||
|
spacing: { 1: 4, 2: 8 },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const desktopBridge = vi.hoisted(() => {
|
||||||
|
const foundInPageListeners = new Set<(result: unknown) => void>();
|
||||||
|
return {
|
||||||
|
foundInPageListeners,
|
||||||
|
findInPage: vi.fn<(browserId: string, text: string, options?: unknown) => number>(),
|
||||||
|
setActivePane: vi.fn<(browserId: string | null) => Promise<void>>(),
|
||||||
|
stopFindInPage: vi.fn<(browserId: string, action: string) => void>(),
|
||||||
|
onFoundInPage: vi.fn((_browserId: string, listener: (result: unknown) => void) => {
|
||||||
|
const scopedListener = (result: unknown) => listener(result);
|
||||||
|
foundInPageListeners.add(scopedListener);
|
||||||
|
return () => {
|
||||||
|
foundInPageListeners.delete(scopedListener);
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
eventsOn: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("lucide-react-native", () => {
|
||||||
|
const createIcon = (name: string) =>
|
||||||
|
function Icon() {
|
||||||
|
return React.createElement("span", { "data-icon": name });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
ArrowLeft: createIcon("ArrowLeft"),
|
||||||
|
ArrowRight: createIcon("ArrowRight"),
|
||||||
|
ChevronDown: createIcon("ChevronDown"),
|
||||||
|
ChevronUp: createIcon("ChevronUp"),
|
||||||
|
MousePointer2: createIcon("MousePointer2"),
|
||||||
|
RotateCw: createIcon("RotateCw"),
|
||||||
|
X: createIcon("X"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("react-native", () => {
|
||||||
|
const MockView = ({
|
||||||
|
children,
|
||||||
|
testID,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
testID?: string;
|
||||||
|
style?: unknown;
|
||||||
|
}) => React.createElement("div", { "data-testid": testID, style }, children);
|
||||||
|
const MockText = ({ children }: { children?: React.ReactNode }) =>
|
||||||
|
React.createElement("span", null, children);
|
||||||
|
const MockPressable = ({
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
onPress,
|
||||||
|
testID,
|
||||||
|
}: {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
onPress?: () => void;
|
||||||
|
testID?: string;
|
||||||
|
}) =>
|
||||||
|
React.createElement(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
"data-testid": testID,
|
||||||
|
disabled,
|
||||||
|
onClick: () => {
|
||||||
|
if (!disabled) onPress?.();
|
||||||
|
},
|
||||||
|
type: "button",
|
||||||
|
},
|
||||||
|
children,
|
||||||
|
);
|
||||||
|
const MockTextInput = React.forwardRef<
|
||||||
|
HTMLInputElement,
|
||||||
|
{
|
||||||
|
value?: string;
|
||||||
|
onChangeText?: (text: string) => void;
|
||||||
|
onFocus?: () => void;
|
||||||
|
onKeyPress?: (event: {
|
||||||
|
nativeEvent: { key: string; shiftKey?: boolean };
|
||||||
|
preventDefault: () => void;
|
||||||
|
}) => void;
|
||||||
|
onSubmitEditing?: () => void;
|
||||||
|
testID?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
>(function TextInput(
|
||||||
|
{ value, onChangeText, onFocus, onKeyPress, onSubmitEditing, testID, placeholder },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return React.createElement("input", {
|
||||||
|
"data-testid": testID,
|
||||||
|
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
onChangeText?.(event.currentTarget.value),
|
||||||
|
onFocus,
|
||||||
|
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
onKeyPress?.({
|
||||||
|
nativeEvent: { key: event.key, shiftKey: event.shiftKey },
|
||||||
|
preventDefault: () => event.preventDefault(),
|
||||||
|
});
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
onSubmitEditing?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
placeholder,
|
||||||
|
ref,
|
||||||
|
value: value ?? "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
Platform: {
|
||||||
|
OS: "web",
|
||||||
|
select: (options: Record<string, unknown>) => options.web ?? options.default,
|
||||||
|
},
|
||||||
|
Pressable: MockPressable,
|
||||||
|
Text: MockText,
|
||||||
|
TextInput: MockTextInput,
|
||||||
|
View: MockView,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("react-native-unistyles", () => ({
|
||||||
|
StyleSheet: {
|
||||||
|
hairlineWidth: 1,
|
||||||
|
create: (factory: unknown) =>
|
||||||
|
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
|
||||||
|
},
|
||||||
|
useUnistyles: () => ({ theme }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/attachments/workspace-attachments-store", () => ({
|
||||||
|
buildWorkspaceAttachmentScopeKey: () => "scope-a",
|
||||||
|
useWorkspaceAttachments: () => [],
|
||||||
|
useWorkspaceAttachmentsStore: (
|
||||||
|
selector: (state: { setWorkspaceAttachments: () => void }) => unknown,
|
||||||
|
) => selector({ setWorkspaceAttachments: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/desktop/host", () => ({
|
||||||
|
getDesktopHost: () => ({
|
||||||
|
browser: {
|
||||||
|
findInPage: desktopBridge.findInPage,
|
||||||
|
onFoundInPage: desktopBridge.onFoundInPage,
|
||||||
|
setActivePane: desktopBridge.setActivePane,
|
||||||
|
stopFindInPage: desktopBridge.stopFindInPage,
|
||||||
|
},
|
||||||
|
events: { on: desktopBridge.eventsOn },
|
||||||
|
}),
|
||||||
|
isElectronRuntime: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/constants/layout", () => ({
|
||||||
|
WORKSPACE_SECONDARY_HEADER_HEIGHT: 36,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/browser-store", () => ({
|
||||||
|
normalizeWorkspaceBrowserUrl: (url: string) => url,
|
||||||
|
useBrowserStore: (selector: (state: typeof browserState) => unknown) => selector(browserState),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type FakeWebview = HTMLDivElement & {
|
||||||
|
getURL: ReturnType<typeof vi.fn<() => string>>;
|
||||||
|
canGoBack: ReturnType<typeof vi.fn<() => boolean>>;
|
||||||
|
canGoForward: ReturnType<typeof vi.fn<() => boolean>>;
|
||||||
|
reload: ReturnType<typeof vi.fn<() => void>>;
|
||||||
|
stop: ReturnType<typeof vi.fn<() => void>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let container: HTMLDivElement | null = null;
|
||||||
|
let root: Root | null = null;
|
||||||
|
let webview: FakeWebview | null = null;
|
||||||
|
let nextRequestId = 1;
|
||||||
|
let restoreCreateElement: (() => void) | null = null;
|
||||||
|
|
||||||
|
const paneInstanceId = createPaneFindPaneId({
|
||||||
|
serverId: "server-a",
|
||||||
|
workspaceId: "workspace-a",
|
||||||
|
paneId: "pane-a",
|
||||||
|
});
|
||||||
|
|
||||||
|
const paneContextValue: PaneContextValue = {
|
||||||
|
serverId: "server-a",
|
||||||
|
workspaceId: "workspace-a",
|
||||||
|
paneInstanceId,
|
||||||
|
tabId: "browser_browser-a",
|
||||||
|
target: { kind: "browser", browserId: "browser-a" },
|
||||||
|
openTab: vi.fn(),
|
||||||
|
closeCurrentTab: vi.fn(),
|
||||||
|
retargetCurrentTab: vi.fn(),
|
||||||
|
openFileInWorkspace: vi.fn(),
|
||||||
|
openImportSheet: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function installWebviewElementFactory(): void {
|
||||||
|
const originalCreateElement = document.createElement.bind(document);
|
||||||
|
const createElementSpy = vi.spyOn(document, "createElement");
|
||||||
|
const createElement = ((tagName: string, options?: ElementCreationOptions) => {
|
||||||
|
if (tagName.toLowerCase() !== "webview") {
|
||||||
|
return originalCreateElement(tagName, options);
|
||||||
|
}
|
||||||
|
const element = originalCreateElement("div") as FakeWebview;
|
||||||
|
element.getURL = vi.fn(() => "https://example.com");
|
||||||
|
element.canGoBack = vi.fn(() => false);
|
||||||
|
element.canGoForward = vi.fn(() => false);
|
||||||
|
element.reload = vi.fn();
|
||||||
|
element.stop = vi.fn();
|
||||||
|
webview = element;
|
||||||
|
return element;
|
||||||
|
}) as typeof document.createElement;
|
||||||
|
createElementSpy.mockImplementation(createElement);
|
||||||
|
restoreCreateElement = () => createElementSpy.mockRestore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBrowserPane(input?: { isInteractive?: boolean }): void {
|
||||||
|
act(() => {
|
||||||
|
root?.render(
|
||||||
|
<PaneProvider value={paneContextValue}>
|
||||||
|
<PaneFocusProvider
|
||||||
|
value={createPaneFocusContextValue({
|
||||||
|
isPaneFocused: input?.isInteractive ?? true,
|
||||||
|
isWorkspaceFocused: true,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<BrowserPane
|
||||||
|
browserId="browser-a"
|
||||||
|
serverId="server-a"
|
||||||
|
workspaceId="workspace-a"
|
||||||
|
cwd="/repo"
|
||||||
|
isInteractive={input?.isInteractive ?? true}
|
||||||
|
/>
|
||||||
|
</PaneFocusProvider>
|
||||||
|
</PaneProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFind(): void {
|
||||||
|
act(() => {
|
||||||
|
setActivePaneFindPaneId(paneInstanceId);
|
||||||
|
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function markWebviewDomReady(): void {
|
||||||
|
act(() => {
|
||||||
|
webview?.dispatchEvent(new Event("dom-ready"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputElement(): HTMLInputElement {
|
||||||
|
const input = container?.querySelector('[data-testid="pane-find-input"]');
|
||||||
|
expect(input).toBeInstanceOf(HTMLInputElement);
|
||||||
|
return input as HTMLInputElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeInput(value: string): void {
|
||||||
|
const input = inputElement();
|
||||||
|
act(() => {
|
||||||
|
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||||
|
valueSetter?.call(input, value);
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pressKey(key: string, shiftKey = false): void {
|
||||||
|
const input = inputElement();
|
||||||
|
act(() => {
|
||||||
|
input.dispatchEvent(new KeyboardEvent("keydown", { key, shiftKey, bubbles: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function click(testId: string): void {
|
||||||
|
const element = container?.querySelector(`[data-testid="${testId}"]`);
|
||||||
|
expect(element).toBeInstanceOf(HTMLElement);
|
||||||
|
act(() => {
|
||||||
|
element?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchFoundInPage(result: {
|
||||||
|
requestId?: number;
|
||||||
|
activeMatchOrdinal: number;
|
||||||
|
matches: number;
|
||||||
|
}): void {
|
||||||
|
act(() => {
|
||||||
|
for (const listener of desktopBridge.foundInPageListeners) {
|
||||||
|
listener(result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
updateBrowser.mockClear();
|
||||||
|
desktopBridge.findInPage.mockImplementation(() => nextRequestId++);
|
||||||
|
desktopBridge.findInPage.mockClear();
|
||||||
|
desktopBridge.onFoundInPage.mockClear();
|
||||||
|
desktopBridge.setActivePane.mockClear();
|
||||||
|
desktopBridge.stopFindInPage.mockClear();
|
||||||
|
desktopBridge.eventsOn.mockClear();
|
||||||
|
desktopBridge.foundInPageListeners.clear();
|
||||||
|
browserState = {
|
||||||
|
...browserState,
|
||||||
|
browsersById: {
|
||||||
|
"browser-a": {
|
||||||
|
id: "browser-a",
|
||||||
|
url: "https://example.com",
|
||||||
|
title: "",
|
||||||
|
faviconUrl: null,
|
||||||
|
canGoBack: false,
|
||||||
|
canGoForward: false,
|
||||||
|
isLoading: false,
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
nextRequestId = 1;
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
installWebviewElementFactory();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
root = null;
|
||||||
|
container?.remove();
|
||||||
|
container = null;
|
||||||
|
webview = null;
|
||||||
|
restoreCreateElement?.();
|
||||||
|
restoreCreateElement = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BrowserPane Electron find", () => {
|
||||||
|
it("registers browser find through the shared FindBar and desktop bridge find APIs", () => {
|
||||||
|
renderBrowserPane();
|
||||||
|
markWebviewDomReady();
|
||||||
|
openFind();
|
||||||
|
|
||||||
|
changeInput("needle");
|
||||||
|
|
||||||
|
expect(desktopBridge.onFoundInPage).toHaveBeenCalledWith("browser-a", expect.any(Function));
|
||||||
|
expect(desktopBridge.findInPage).toHaveBeenLastCalledWith("browser-a", "needle", {
|
||||||
|
forward: true,
|
||||||
|
findNext: false,
|
||||||
|
matchCase: false,
|
||||||
|
});
|
||||||
|
expect(container?.textContent).toContain("Searching...");
|
||||||
|
dispatchFoundInPage({ requestId: 1, activeMatchOrdinal: 2, matches: 5 });
|
||||||
|
expect(container?.textContent).toContain("2 / 5");
|
||||||
|
|
||||||
|
pressKey("Enter");
|
||||||
|
expect(desktopBridge.findInPage).toHaveBeenLastCalledWith("browser-a", "needle", {
|
||||||
|
forward: true,
|
||||||
|
findNext: true,
|
||||||
|
matchCase: false,
|
||||||
|
});
|
||||||
|
dispatchFoundInPage({ requestId: 2, activeMatchOrdinal: 3, matches: 5 });
|
||||||
|
|
||||||
|
pressKey("Enter", true);
|
||||||
|
expect(desktopBridge.findInPage).toHaveBeenLastCalledWith("browser-a", "needle", {
|
||||||
|
forward: false,
|
||||||
|
findNext: true,
|
||||||
|
matchCase: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
click("pane-find-close");
|
||||||
|
|
||||||
|
expect(desktopBridge.stopFindInPage).toHaveBeenLastCalledWith("browser-a", "clearSelection");
|
||||||
|
expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cleans browser find selection on empty query, navigation, blur, and unmount", () => {
|
||||||
|
renderBrowserPane();
|
||||||
|
markWebviewDomReady();
|
||||||
|
openFind();
|
||||||
|
changeInput("needle");
|
||||||
|
dispatchFoundInPage({ requestId: 1, activeMatchOrdinal: 1, matches: 3 });
|
||||||
|
|
||||||
|
changeInput("");
|
||||||
|
expect(desktopBridge.stopFindInPage).toHaveBeenLastCalledWith("browser-a", "clearSelection");
|
||||||
|
expect(container?.textContent).toContain("0 / 0");
|
||||||
|
dispatchFoundInPage({ activeMatchOrdinal: 2, matches: 9 });
|
||||||
|
expect(container?.textContent).toContain("0 / 0");
|
||||||
|
|
||||||
|
changeInput("needle");
|
||||||
|
expect(container?.textContent).toContain("Searching...");
|
||||||
|
act(() => {
|
||||||
|
webview?.dispatchEvent(new Event("did-start-loading"));
|
||||||
|
});
|
||||||
|
expect(desktopBridge.stopFindInPage).toHaveBeenLastCalledWith("browser-a", "clearSelection");
|
||||||
|
expect(container?.textContent).toContain("0 / 0");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
webview?.dispatchEvent(new Event("dom-ready"));
|
||||||
|
});
|
||||||
|
expect(desktopBridge.findInPage).toHaveBeenLastCalledWith("browser-a", "needle", {
|
||||||
|
forward: true,
|
||||||
|
findNext: false,
|
||||||
|
matchCase: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderBrowserPane({ isInteractive: false });
|
||||||
|
expect(desktopBridge.stopFindInPage).toHaveBeenLastCalledWith("browser-a", "clearSelection");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
expect(desktopBridge.stopFindInPage).toHaveBeenLastCalledWith("browser-a", "clearSelection");
|
||||||
|
expect(desktopBridge.foundInPageListeners.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call the browser find bridge before dom-ready", () => {
|
||||||
|
renderBrowserPane();
|
||||||
|
openFind();
|
||||||
|
|
||||||
|
changeInput("needle");
|
||||||
|
expect(container?.textContent).toContain("Searching...");
|
||||||
|
expect(desktopBridge.findInPage).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
click("pane-find-close");
|
||||||
|
expect(desktopBridge.stopFindInPage).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
openFind();
|
||||||
|
changeInput("needle");
|
||||||
|
markWebviewDomReady();
|
||||||
|
expect(desktopBridge.findInPage).toHaveBeenLastCalledWith("browser-a", "needle", {
|
||||||
|
forward: true,
|
||||||
|
findNext: false,
|
||||||
|
matchCase: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -20,9 +20,12 @@ import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
|||||||
import {
|
import {
|
||||||
getDesktopHost,
|
getDesktopHost,
|
||||||
isElectronRuntime,
|
isElectronRuntime,
|
||||||
|
type DesktopBrowserFoundInPageResult,
|
||||||
|
type DesktopBrowserFindAction,
|
||||||
type DesktopBrowserShortcutEvent,
|
type DesktopBrowserShortcutEvent,
|
||||||
} from "@/desktop/host";
|
} from "@/desktop/host";
|
||||||
import { isDev } from "@/constants/platform";
|
import { isDev } from "@/constants/platform";
|
||||||
|
import { FindBar, usePaneFind, type PaneFindMatchState } from "@/panels/pane-find";
|
||||||
import { useBrowserStore, normalizeWorkspaceBrowserUrl } from "@/stores/browser-store";
|
import { useBrowserStore, normalizeWorkspaceBrowserUrl } from "@/stores/browser-store";
|
||||||
|
|
||||||
type ElectronWebview = HTMLElement & {
|
type ElectronWebview = HTMLElement & {
|
||||||
@@ -50,6 +53,15 @@ type BrowserElementSelection = Omit<BrowserElementAttachment, "formatted"> & {
|
|||||||
|
|
||||||
const ERR_ABORTED = -3;
|
const ERR_ABORTED = -3;
|
||||||
const ALLOWED_BROWSER_PROTOCOLS = new Set(["http:", "https:"]);
|
const ALLOWED_BROWSER_PROTOCOLS = new Set(["http:", "https:"]);
|
||||||
|
const EMPTY_FIND_MATCH_STATE: PaneFindMatchState = { status: "empty" };
|
||||||
|
const NO_FIND_MATCH_STATE: PaneFindMatchState = { status: "no-match" };
|
||||||
|
const PENDING_FIND_MATCH_STATE: PaneFindMatchState = { status: "pending" };
|
||||||
|
|
||||||
|
interface ActiveBrowserFind {
|
||||||
|
generation: number;
|
||||||
|
query: string;
|
||||||
|
requestId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
function truncateText(value: string, maxLength: number): string {
|
function truncateText(value: string, maxLength: number): string {
|
||||||
return value.length > maxLength ? `${value.slice(0, maxLength).trim()}...` : value;
|
return value.length > maxLength ? `${value.slice(0, maxLength).trim()}...` : value;
|
||||||
@@ -234,6 +246,18 @@ function isDesktopBrowserShortcutEvent(payload: unknown): payload is DesktopBrow
|
|||||||
return event.action === "focus-url";
|
return event.action === "focus-url";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stopBrowserFindInPage(input: {
|
||||||
|
browserId: string;
|
||||||
|
action: DesktopBrowserFindAction;
|
||||||
|
}): void {
|
||||||
|
const bridge = getDesktopHost()?.browser;
|
||||||
|
if (!bridge?.stopFindInPage) {
|
||||||
|
console.warn("Electron browser find bridge is unavailable; cannot stop find-in-page.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void bridge.stopFindInPage(input.browserId, input.action);
|
||||||
|
}
|
||||||
|
|
||||||
function startSelectorResultPolling(input: {
|
function startSelectorResultPolling(input: {
|
||||||
webview: ElectronWebview;
|
webview: ElectronWebview;
|
||||||
onSelection: (selection: BrowserElementSelection) => void;
|
onSelection: (selection: BrowserElementSelection) => void;
|
||||||
@@ -295,7 +319,13 @@ export function BrowserPane({
|
|||||||
browserRef.current = browser;
|
browserRef.current = browser;
|
||||||
const pendingNavigationUrlRef = useRef<string | null>(null);
|
const pendingNavigationUrlRef = useRef<string | null>(null);
|
||||||
const domReadyRef = useRef(false);
|
const domReadyRef = useRef(false);
|
||||||
|
const browserFindQueryRef = useRef("");
|
||||||
|
const browserFindGenerationRef = useRef(0);
|
||||||
|
const activeBrowserFindRef = useRef<ActiveBrowserFind | null>(null);
|
||||||
|
const browserFindOpenRef = useRef(false);
|
||||||
const [selectorActive, setSelectorActive] = useState(false);
|
const [selectorActive, setSelectorActive] = useState(false);
|
||||||
|
const [browserFindMatchState, setBrowserFindMatchState] =
|
||||||
|
useState<PaneFindMatchState>(EMPTY_FIND_MATCH_STATE);
|
||||||
const [draftUrl, setDraftUrl] = useState(browser?.url ?? "https://example.com");
|
const [draftUrl, setDraftUrl] = useState(browser?.url ?? "https://example.com");
|
||||||
const workspaceAttachmentScopeKey = useMemo(
|
const workspaceAttachmentScopeKey = useMemo(
|
||||||
() => buildBrowserAttachmentScopeKey({ cwd, serverId, workspaceId }),
|
() => buildBrowserAttachmentScopeKey({ cwd, serverId, workspaceId }),
|
||||||
@@ -372,6 +402,115 @@ export function BrowserPane({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const clearBrowserFindSelection = useCallback((input?: { resetQuery?: boolean }) => {
|
||||||
|
if (input?.resetQuery) {
|
||||||
|
browserFindQueryRef.current = "";
|
||||||
|
}
|
||||||
|
browserFindGenerationRef.current += 1;
|
||||||
|
activeBrowserFindRef.current = null;
|
||||||
|
setBrowserFindMatchState(EMPTY_FIND_MATCH_STATE);
|
||||||
|
if (domReadyRef.current) {
|
||||||
|
stopBrowserFindInPage({
|
||||||
|
browserId: browserIdRef.current,
|
||||||
|
action: "clearSelection",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runBrowserFind = useCallback(
|
||||||
|
(query: string, direction: "next" | "previous", input?: { reset?: boolean }) => {
|
||||||
|
browserFindQueryRef.current = query;
|
||||||
|
if (!query) {
|
||||||
|
clearBrowserFindSelection({ resetQuery: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!domReadyRef.current) {
|
||||||
|
setBrowserFindMatchState(PENDING_FIND_MATCH_STATE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bridgeFindInPage = getDesktopHost()?.browser?.findInPage;
|
||||||
|
if (!bridgeFindInPage) {
|
||||||
|
console.warn("Electron browser find bridge is unavailable; cannot start find-in-page.");
|
||||||
|
setBrowserFindMatchState(NO_FIND_MATCH_STATE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generation = (browserFindGenerationRef.current += 1);
|
||||||
|
activeBrowserFindRef.current = {
|
||||||
|
generation,
|
||||||
|
query,
|
||||||
|
requestId: null,
|
||||||
|
};
|
||||||
|
setBrowserFindMatchState(PENDING_FIND_MATCH_STATE);
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
forward: direction === "next",
|
||||||
|
findNext: !input?.reset,
|
||||||
|
matchCase: false,
|
||||||
|
};
|
||||||
|
const requestIdResult = bridgeFindInPage(browserIdRef.current, query, options);
|
||||||
|
if (typeof requestIdResult === "number") {
|
||||||
|
activeBrowserFindRef.current = {
|
||||||
|
generation,
|
||||||
|
query,
|
||||||
|
requestId: requestIdResult,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void Promise.resolve(requestIdResult ?? null).then((requestId) => {
|
||||||
|
const activeFind = activeBrowserFindRef.current;
|
||||||
|
if (
|
||||||
|
!activeFind ||
|
||||||
|
activeFind.generation !== generation ||
|
||||||
|
activeFind.query !== browserFindQueryRef.current
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
activeBrowserFindRef.current = {
|
||||||
|
...activeFind,
|
||||||
|
requestId: typeof requestId === "number" ? requestId : null,
|
||||||
|
};
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[clearBrowserFindSelection],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFoundInPageResult = useCallback((result: DesktopBrowserFoundInPageResult) => {
|
||||||
|
const activeFind = activeBrowserFindRef.current;
|
||||||
|
if (
|
||||||
|
!activeFind ||
|
||||||
|
activeFind.generation !== browserFindGenerationRef.current ||
|
||||||
|
activeFind.query !== browserFindQueryRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requestId = result.requestId;
|
||||||
|
if (typeof requestId !== "number") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof activeFind.requestId !== "number" || requestId !== activeFind.requestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = typeof result.matches === "number" ? Math.max(0, result.matches) : 0;
|
||||||
|
if (total === 0) {
|
||||||
|
setBrowserFindMatchState(NO_FIND_MATCH_STATE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current =
|
||||||
|
typeof result.activeMatchOrdinal === "number" && result.activeMatchOrdinal > 0
|
||||||
|
? result.activeMatchOrdinal
|
||||||
|
: 1;
|
||||||
|
setBrowserFindMatchState({
|
||||||
|
status: "matched",
|
||||||
|
current: Math.min(current, total),
|
||||||
|
total,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isElectronRuntime()) {
|
if (!isElectronRuntime()) {
|
||||||
return;
|
return;
|
||||||
@@ -403,6 +542,7 @@ export function BrowserPane({
|
|||||||
webview.style.background = "transparent";
|
webview.style.background = "transparent";
|
||||||
|
|
||||||
const handleStartLoading = () => {
|
const handleStartLoading = () => {
|
||||||
|
clearBrowserFindSelection();
|
||||||
updateBrowser(browserId, { isLoading: true, lastError: null });
|
updateBrowser(browserId, { isLoading: true, lastError: null });
|
||||||
syncNavigationState({ syncUrl: false });
|
syncNavigationState({ syncUrl: false });
|
||||||
};
|
};
|
||||||
@@ -429,6 +569,7 @@ export function BrowserPane({
|
|||||||
syncNavigationState();
|
syncNavigationState();
|
||||||
};
|
};
|
||||||
const handleWillNavigate = (event: Event) => {
|
const handleWillNavigate = (event: Event) => {
|
||||||
|
clearBrowserFindSelection();
|
||||||
const nextUrl =
|
const nextUrl =
|
||||||
typeof (event as Event & { url?: unknown }).url === "string"
|
typeof (event as Event & { url?: unknown }).url === "string"
|
||||||
? ((event as Event & { url?: string }).url ?? "")
|
? ((event as Event & { url?: string }).url ?? "")
|
||||||
@@ -471,6 +612,9 @@ export function BrowserPane({
|
|||||||
const handleDomReady = () => {
|
const handleDomReady = () => {
|
||||||
domReadyRef.current = true;
|
domReadyRef.current = true;
|
||||||
syncNavigationState();
|
syncNavigationState();
|
||||||
|
if (browserFindOpenRef.current && browserFindQueryRef.current) {
|
||||||
|
runBrowserFind(browserFindQueryRef.current, "next", { reset: true });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const handleWebviewFocus = () => {
|
const handleWebviewFocus = () => {
|
||||||
onFocusPane?.();
|
onFocusPane?.();
|
||||||
@@ -488,6 +632,29 @@ export function BrowserPane({
|
|||||||
webview.addEventListener("focus", handleWebviewFocus);
|
webview.addEventListener("focus", handleWebviewFocus);
|
||||||
webview.addEventListener("mousedown", handleWebviewFocus);
|
webview.addEventListener("mousedown", handleWebviewFocus);
|
||||||
|
|
||||||
|
const foundInPageBridge = getDesktopHost()?.browser?.onFoundInPage;
|
||||||
|
let unsubscribeFoundInPage: (() => void) | null = null;
|
||||||
|
let didCleanupFoundInPage = false;
|
||||||
|
if (foundInPageBridge) {
|
||||||
|
const unsubscribeResult = foundInPageBridge(browserId, (result) => {
|
||||||
|
handleFoundInPageResult(result);
|
||||||
|
});
|
||||||
|
if (typeof unsubscribeResult === "function") {
|
||||||
|
unsubscribeFoundInPage = unsubscribeResult;
|
||||||
|
} else {
|
||||||
|
void Promise.resolve(unsubscribeResult).then((unsubscribe) => {
|
||||||
|
if (didCleanupFoundInPage) {
|
||||||
|
unsubscribe();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
unsubscribeFoundInPage = unsubscribe;
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("Electron browser find bridge is unavailable; found-in-page events disabled.");
|
||||||
|
}
|
||||||
|
|
||||||
host.appendChild(webview);
|
host.appendChild(webview);
|
||||||
if (initialUnsafeNavigationMessage) {
|
if (initialUnsafeNavigationMessage) {
|
||||||
updateBrowserRef.current(browserIdRef.current, {
|
updateBrowserRef.current(browserIdRef.current, {
|
||||||
@@ -506,8 +673,16 @@ export function BrowserPane({
|
|||||||
webview.removeEventListener("page-favicon-updated", handleFaviconUpdated);
|
webview.removeEventListener("page-favicon-updated", handleFaviconUpdated);
|
||||||
webview.removeEventListener("did-fail-load", handleLoadFailed);
|
webview.removeEventListener("did-fail-load", handleLoadFailed);
|
||||||
webview.removeEventListener("dom-ready", handleDomReady);
|
webview.removeEventListener("dom-ready", handleDomReady);
|
||||||
|
didCleanupFoundInPage = true;
|
||||||
|
unsubscribeFoundInPage?.();
|
||||||
webview.removeEventListener("focus", handleWebviewFocus);
|
webview.removeEventListener("focus", handleWebviewFocus);
|
||||||
webview.removeEventListener("mousedown", handleWebviewFocus);
|
webview.removeEventListener("mousedown", handleWebviewFocus);
|
||||||
|
if (domReadyRef.current) {
|
||||||
|
stopBrowserFindInPage({
|
||||||
|
browserId: browserIdRef.current,
|
||||||
|
action: "clearSelection",
|
||||||
|
});
|
||||||
|
}
|
||||||
if (host.contains(webview)) {
|
if (host.contains(webview)) {
|
||||||
host.removeChild(webview);
|
host.removeChild(webview);
|
||||||
}
|
}
|
||||||
@@ -519,6 +694,35 @@ export function BrowserPane({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [browserId, onFocusPane]);
|
}, [browserId, onFocusPane]);
|
||||||
|
|
||||||
|
const paneFind = usePaneFind({
|
||||||
|
matchState: browserFindMatchState,
|
||||||
|
onQuery: (query) => runBrowserFind(query, "next", { reset: true }),
|
||||||
|
onNext: () => runBrowserFind(browserFindQueryRef.current, "next"),
|
||||||
|
onPrev: () => runBrowserFind(browserFindQueryRef.current, "previous"),
|
||||||
|
onClose: () => clearBrowserFindSelection({ resetQuery: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
browserFindOpenRef.current = paneFind.isOpen;
|
||||||
|
if (!paneFind.isOpen) {
|
||||||
|
clearBrowserFindSelection({ resetQuery: true });
|
||||||
|
}
|
||||||
|
}, [clearBrowserFindSelection, paneFind.isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInteractive) {
|
||||||
|
if (paneFind.isOpen && browserFindQueryRef.current) {
|
||||||
|
runBrowserFind(browserFindQueryRef.current, "next", { reset: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearBrowserFindSelection();
|
||||||
|
}, [clearBrowserFindSelection, isInteractive, paneFind.isOpen, runBrowserFind]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => clearBrowserFindSelection({ resetQuery: true });
|
||||||
|
}, [clearBrowserFindSelection]);
|
||||||
|
|
||||||
const navigate = useCallback((nextUrl: string) => {
|
const navigate = useCallback((nextUrl: string) => {
|
||||||
const normalizedUrl = normalizeWorkspaceBrowserUrl(nextUrl);
|
const normalizedUrl = normalizeWorkspaceBrowserUrl(nextUrl);
|
||||||
const webview = webviewRef.current;
|
const webview = webviewRef.current;
|
||||||
@@ -939,6 +1143,7 @@ export function BrowserPane({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
|
{paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null}
|
||||||
<View style={styles.chromeRow}>
|
<View style={styles.chromeRow}>
|
||||||
<View style={styles.chromeLeft}>
|
<View style={styles.chromeLeft}>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
createFilePaneFindTokenSegments,
|
||||||
|
createFilePaneLineFindHighlightMap,
|
||||||
|
createFilePaneTextRenderData,
|
||||||
|
findFilePaneTextMatches,
|
||||||
|
} from "@/components/file-pane-text-render-data";
|
||||||
|
|
||||||
|
type FilePaneTextLineRenderData = ReturnType<typeof createFilePaneTextRenderData>["lines"][number];
|
||||||
|
|
||||||
|
function tokenText(line: FilePaneTextLineRenderData): string {
|
||||||
|
return line.tokens.map(({ text }) => text).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createFilePaneTextRenderData", () => {
|
||||||
|
it("keeps code render lines in source order and reconstructs searchable text from tokens", () => {
|
||||||
|
const renderData = createFilePaneTextRenderData(
|
||||||
|
"const answer = 42;\nconsole.log(answer);",
|
||||||
|
"src/answer.ts",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(renderData.lines.map((line) => line.lineNumber)).toEqual([1, 2]);
|
||||||
|
expect(renderData.lines.map((line) => line.text)).toEqual([
|
||||||
|
"const answer = 42;",
|
||||||
|
"console.log(answer);",
|
||||||
|
]);
|
||||||
|
expect(renderData.searchableText).toBe("const answer = 42;\nconsole.log(answer);");
|
||||||
|
const tokenTexts = renderData.lines.map(tokenText);
|
||||||
|
expect(tokenTexts).toEqual(renderData.lines.map((line) => line.text));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves blank text lines for navigation and scrolling targets", () => {
|
||||||
|
const renderData = createFilePaneTextRenderData("alpha\n\nbeta", "notes.txt");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
renderData.lines.map((line) => ({ lineNumber: line.lineNumber, text: line.text })),
|
||||||
|
).toEqual([
|
||||||
|
{ lineNumber: 1, text: "alpha" },
|
||||||
|
{ lineNumber: 2, text: "" },
|
||||||
|
{ lineNumber: 3, text: "beta" },
|
||||||
|
]);
|
||||||
|
expect(renderData.searchableText).toBe("alpha\n\nbeta");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findFilePaneTextMatches", () => {
|
||||||
|
it("indexes loaded text case-insensitively in source order", () => {
|
||||||
|
const renderData = createFilePaneTextRenderData(
|
||||||
|
"Alpha beta\nBETA gamma\nalphabet",
|
||||||
|
"notes.txt",
|
||||||
|
);
|
||||||
|
|
||||||
|
const matches = findFilePaneTextMatches(renderData, "beta");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
matches.map((match) => ({
|
||||||
|
index: match.index,
|
||||||
|
lineSpans: match.lineSpans,
|
||||||
|
})),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
lineSpans: [{ lineNumber: 1, startColumn: 6, endColumn: 10 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
lineSpans: [{ lineNumber: 2, startColumn: 0, endColumn: 4 }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps pasted multi-line queries back to per-line spans", () => {
|
||||||
|
const renderData = createFilePaneTextRenderData("alpha\nbeta", "notes.txt");
|
||||||
|
|
||||||
|
const matches = findFilePaneTextMatches(renderData, "ha\nbe");
|
||||||
|
|
||||||
|
expect(matches).toHaveLength(1);
|
||||||
|
expect(matches[0]?.lineSpans).toEqual([
|
||||||
|
{ lineNumber: 1, startColumn: 3, endColumn: 5 },
|
||||||
|
{ lineNumber: 2, startColumn: 0, endColumn: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createFilePaneFindTokenSegments", () => {
|
||||||
|
it("splits immutable token render data around match spans and marks the current match", () => {
|
||||||
|
const line: FilePaneTextLineRenderData = {
|
||||||
|
lineNumber: 1,
|
||||||
|
text: "const answer",
|
||||||
|
tokens: [
|
||||||
|
{ text: "const", style: "keyword" },
|
||||||
|
{ text: " answer", style: null },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const highlights = createFilePaneLineFindHighlightMap(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
startOffset: 2,
|
||||||
|
endOffset: 8,
|
||||||
|
lineSpans: [{ lineNumber: 1, startColumn: 2, endColumn: 8 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const segments = createFilePaneFindTokenSegments(line, highlights.get(1) ?? []);
|
||||||
|
|
||||||
|
expect(segments).toEqual([
|
||||||
|
{ text: "co", style: "keyword", isFindMatch: false, isCurrentFindMatch: false },
|
||||||
|
{ text: "nst", style: "keyword", isFindMatch: true, isCurrentFindMatch: true },
|
||||||
|
{ text: " an", style: null, isFindMatch: true, isCurrentFindMatch: true },
|
||||||
|
{ text: "swer", style: null, isFindMatch: false, isCurrentFindMatch: false },
|
||||||
|
]);
|
||||||
|
expect(line.tokens).toEqual([
|
||||||
|
{ text: "const", style: "keyword" },
|
||||||
|
{ text: " answer", style: null },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||||
|
|
||||||
|
interface FilePaneTextLineRenderData {
|
||||||
|
lineNumber: number;
|
||||||
|
text: string;
|
||||||
|
tokens: HighlightToken[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilePaneTextRenderData {
|
||||||
|
lines: FilePaneTextLineRenderData[];
|
||||||
|
searchableText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilePaneFindLineSpan {
|
||||||
|
lineNumber: number;
|
||||||
|
startColumn: number;
|
||||||
|
endColumn: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilePaneFindMatch {
|
||||||
|
index: number;
|
||||||
|
startOffset: number;
|
||||||
|
endOffset: number;
|
||||||
|
lineSpans: FilePaneFindLineSpan[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilePaneFindLineHighlight extends FilePaneFindLineSpan {
|
||||||
|
matchIndex: number;
|
||||||
|
isCurrent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilePaneFindTokenSegment {
|
||||||
|
text: string;
|
||||||
|
style: HighlightToken["style"];
|
||||||
|
isFindMatch: boolean;
|
||||||
|
isCurrentFindMatch: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFilePaneTextRenderData(
|
||||||
|
content: string,
|
||||||
|
filePath: string,
|
||||||
|
): FilePaneTextRenderData {
|
||||||
|
const lines = highlightCode(content, filePath).map((tokens, index) => ({
|
||||||
|
lineNumber: index + 1,
|
||||||
|
text: tokens.map((token) => token.text).join(""),
|
||||||
|
tokens,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
lines,
|
||||||
|
searchableText: lines.map((line) => line.text).join("\n"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findFilePaneTextMatches(
|
||||||
|
renderData: FilePaneTextRenderData,
|
||||||
|
query: string,
|
||||||
|
): FilePaneFindMatch[] {
|
||||||
|
if (query.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchableText = renderData.searchableText.toLocaleLowerCase();
|
||||||
|
const normalizedQuery = query.toLocaleLowerCase();
|
||||||
|
const matches: FilePaneFindMatch[] = [];
|
||||||
|
let nextOffset = searchableText.indexOf(normalizedQuery);
|
||||||
|
|
||||||
|
while (nextOffset >= 0) {
|
||||||
|
const endOffset = nextOffset + normalizedQuery.length;
|
||||||
|
matches.push({
|
||||||
|
index: matches.length,
|
||||||
|
startOffset: nextOffset,
|
||||||
|
endOffset,
|
||||||
|
lineSpans: mapFilePaneTextRangeToLineSpans(renderData, nextOffset, endOffset),
|
||||||
|
});
|
||||||
|
nextOffset = searchableText.indexOf(normalizedQuery, endOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFilePaneLineFindHighlightMap(
|
||||||
|
matches: FilePaneFindMatch[],
|
||||||
|
currentMatchIndex: number,
|
||||||
|
): Map<number, FilePaneFindLineHighlight[]> {
|
||||||
|
const highlightsByLine = new Map<number, FilePaneFindLineHighlight[]>();
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
for (const span of match.lineSpans) {
|
||||||
|
const highlights = highlightsByLine.get(span.lineNumber) ?? [];
|
||||||
|
highlights.push({
|
||||||
|
...span,
|
||||||
|
matchIndex: match.index,
|
||||||
|
isCurrent: match.index === currentMatchIndex,
|
||||||
|
});
|
||||||
|
highlightsByLine.set(span.lineNumber, highlights);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const highlights of highlightsByLine.values()) {
|
||||||
|
highlights.sort((left, right) => left.startColumn - right.startColumn);
|
||||||
|
}
|
||||||
|
|
||||||
|
return highlightsByLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFilePaneFindTokenSegments(
|
||||||
|
line: FilePaneTextLineRenderData,
|
||||||
|
highlights: FilePaneFindLineHighlight[],
|
||||||
|
): FilePaneFindTokenSegment[] {
|
||||||
|
if (highlights.length === 0) {
|
||||||
|
return line.tokens.map((token) => ({
|
||||||
|
...token,
|
||||||
|
isFindMatch: false,
|
||||||
|
isCurrentFindMatch: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments: FilePaneFindTokenSegment[] = [];
|
||||||
|
let tokenStartColumn = 0;
|
||||||
|
|
||||||
|
for (const token of line.tokens) {
|
||||||
|
let cursor = tokenStartColumn;
|
||||||
|
const tokenEndColumn = tokenStartColumn + token.text.length;
|
||||||
|
const tokenHighlights = highlights.filter(
|
||||||
|
(highlight) =>
|
||||||
|
highlight.startColumn < tokenEndColumn && highlight.endColumn > tokenStartColumn,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const highlight of tokenHighlights) {
|
||||||
|
const highlightStart = Math.max(highlight.startColumn, tokenStartColumn);
|
||||||
|
const highlightEnd = Math.min(highlight.endColumn, tokenEndColumn);
|
||||||
|
|
||||||
|
if (cursor < highlightStart) {
|
||||||
|
segments.push({
|
||||||
|
text: token.text.slice(cursor - tokenStartColumn, highlightStart - tokenStartColumn),
|
||||||
|
style: token.style,
|
||||||
|
isFindMatch: false,
|
||||||
|
isCurrentFindMatch: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (highlightStart < highlightEnd) {
|
||||||
|
segments.push({
|
||||||
|
text: token.text.slice(
|
||||||
|
highlightStart - tokenStartColumn,
|
||||||
|
highlightEnd - tokenStartColumn,
|
||||||
|
),
|
||||||
|
style: token.style,
|
||||||
|
isFindMatch: true,
|
||||||
|
isCurrentFindMatch: highlight.isCurrent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = highlightEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor < tokenEndColumn) {
|
||||||
|
segments.push({
|
||||||
|
text: token.text.slice(cursor - tokenStartColumn),
|
||||||
|
style: token.style,
|
||||||
|
isFindMatch: false,
|
||||||
|
isCurrentFindMatch: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenStartColumn = tokenEndColumn;
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapFilePaneTextRangeToLineSpans(
|
||||||
|
renderData: FilePaneTextRenderData,
|
||||||
|
startOffset: number,
|
||||||
|
endOffset: number,
|
||||||
|
): FilePaneFindLineSpan[] {
|
||||||
|
const spans: FilePaneFindLineSpan[] = [];
|
||||||
|
let lineStartOffset = 0;
|
||||||
|
|
||||||
|
for (const line of renderData.lines) {
|
||||||
|
const lineEndOffset = lineStartOffset + line.text.length;
|
||||||
|
const spanStartOffset = Math.max(startOffset, lineStartOffset);
|
||||||
|
const spanEndOffset = Math.min(endOffset, lineEndOffset);
|
||||||
|
|
||||||
|
if (spanStartOffset < spanEndOffset) {
|
||||||
|
spans.push({
|
||||||
|
lineNumber: line.lineNumber,
|
||||||
|
startColumn: spanStartOffset - lineStartOffset,
|
||||||
|
endColumn: spanEndOffset - lineStartOffset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
lineStartOffset = lineEndOffset + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import React from "react";
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ExplorerFile } from "@/stores/session-store";
|
||||||
|
import { FilePane } from "@/components/file-pane";
|
||||||
|
import {
|
||||||
|
PaneFocusProvider,
|
||||||
|
PaneProvider,
|
||||||
|
createPaneFocusContextValue,
|
||||||
|
type PaneContextValue,
|
||||||
|
} from "@/panels/pane-context";
|
||||||
|
import {
|
||||||
|
clearActivePaneFindPaneId,
|
||||||
|
handlePaneFindKeyboardAction,
|
||||||
|
setActivePaneFindPaneId,
|
||||||
|
} from "@/panels/pane-find-registry";
|
||||||
|
|
||||||
|
const { queryState, theme } = vi.hoisted(() => ({
|
||||||
|
queryState: {
|
||||||
|
current: {
|
||||||
|
data: null as null | {
|
||||||
|
error: string | null;
|
||||||
|
file: ExplorerFile | null;
|
||||||
|
imageAttachment?: unknown;
|
||||||
|
},
|
||||||
|
isFetching: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
|
||||||
|
fontSize: { sm: 13, code: 13 },
|
||||||
|
colors: {
|
||||||
|
destructive: "#f43f5e",
|
||||||
|
foreground: "#fff",
|
||||||
|
foregroundMuted: "#aaa",
|
||||||
|
primary: "#0a84ff",
|
||||||
|
surface0: "#111",
|
||||||
|
surface1: "#222",
|
||||||
|
surface2: "#333",
|
||||||
|
},
|
||||||
|
colorScheme: "dark",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tanstack/react-query", () => ({
|
||||||
|
useQuery: () => queryState.current,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native", () => {
|
||||||
|
const MockView = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
{ children?: React.ReactNode; testID?: string }
|
||||||
|
>(function View({ children, testID }, ref) {
|
||||||
|
return React.createElement("div", { "data-testid": testID, ref }, children);
|
||||||
|
});
|
||||||
|
const MockScrollView = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
{ children?: React.ReactNode; horizontal?: boolean }
|
||||||
|
>(function ScrollView({ children, horizontal }, ref) {
|
||||||
|
return React.createElement(
|
||||||
|
"div",
|
||||||
|
{ "data-horizontal": horizontal ? "true" : undefined, ref },
|
||||||
|
children,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const flattenMockStyle = (value: unknown): React.CSSProperties => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.reduce<React.CSSProperties>(
|
||||||
|
(acc, item) => Object.assign(acc, flattenMockStyle(item)),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (value as React.CSSProperties | null | undefined) ?? {};
|
||||||
|
};
|
||||||
|
const MockText = ({ children, style }: { children?: React.ReactNode; style?: unknown }) =>
|
||||||
|
React.createElement("span", { style: flattenMockStyle(style) }, children);
|
||||||
|
const MockPressable = ({
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
onPress,
|
||||||
|
testID,
|
||||||
|
}: {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
onPress?: () => void;
|
||||||
|
testID?: string;
|
||||||
|
}) =>
|
||||||
|
React.createElement(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
"data-testid": testID,
|
||||||
|
disabled,
|
||||||
|
onClick: () => {
|
||||||
|
if (!disabled) {
|
||||||
|
onPress?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: "button",
|
||||||
|
},
|
||||||
|
children,
|
||||||
|
);
|
||||||
|
const MockTextInput = React.forwardRef<
|
||||||
|
HTMLInputElement,
|
||||||
|
{
|
||||||
|
value?: string;
|
||||||
|
onChangeText?: (text: string) => void;
|
||||||
|
onKeyPress?: (event: {
|
||||||
|
nativeEvent: { key: string; shiftKey?: boolean };
|
||||||
|
preventDefault: () => void;
|
||||||
|
}) => void;
|
||||||
|
testID?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
>(function TextInput({ value, onChangeText, onKeyPress, testID, placeholder }, ref) {
|
||||||
|
return React.createElement("input", {
|
||||||
|
"data-testid": testID,
|
||||||
|
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
onChangeText?.(event.currentTarget.value),
|
||||||
|
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) =>
|
||||||
|
onKeyPress?.({
|
||||||
|
nativeEvent: { key: event.key, shiftKey: event.shiftKey },
|
||||||
|
preventDefault: () => event.preventDefault(),
|
||||||
|
}),
|
||||||
|
placeholder,
|
||||||
|
ref,
|
||||||
|
value: value ?? "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const MockImage = ({ source }: { source?: { uri?: string } }) =>
|
||||||
|
React.createElement("img", { alt: "", src: source?.uri ?? "" });
|
||||||
|
|
||||||
|
return {
|
||||||
|
ActivityIndicator: () => React.createElement("span", { "data-testid": "activity" }),
|
||||||
|
Image: MockImage,
|
||||||
|
Platform: {
|
||||||
|
OS: "web",
|
||||||
|
select: (options: Record<string, unknown>) => options.web ?? options.default,
|
||||||
|
},
|
||||||
|
Pressable: MockPressable,
|
||||||
|
ScrollView: MockScrollView,
|
||||||
|
Text: MockText,
|
||||||
|
TextInput: MockTextInput,
|
||||||
|
View: MockView,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("lucide-react-native", () => {
|
||||||
|
const createIcon = (name: string) =>
|
||||||
|
function Icon() {
|
||||||
|
return React.createElement("span", { "data-icon": name });
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
ChevronDown: createIcon("ChevronDown"),
|
||||||
|
ChevronUp: createIcon("ChevronUp"),
|
||||||
|
X: createIcon("X"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("react-native-markdown-display", () => ({
|
||||||
|
default: ({ children }: { children?: React.ReactNode }) =>
|
||||||
|
React.createElement("article", { "data-testid": "markdown-preview" }, children),
|
||||||
|
MarkdownIt: () => ({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native-unistyles", () => ({
|
||||||
|
StyleSheet: {
|
||||||
|
create: (factory: unknown) =>
|
||||||
|
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
|
||||||
|
},
|
||||||
|
useUnistyles: () => ({ theme }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/styles/syntax-token-styles", () => ({
|
||||||
|
syntaxTokenStyleFor: () => ({ color: "#fff" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/attachments/use-attachment-preview-url", () => ({
|
||||||
|
useAttachmentPreviewUrl: (metadata: unknown) => (metadata ? "blob:preview" : null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/use-web-scrollbar", () => ({
|
||||||
|
useWebScrollViewScrollbar: () => ({
|
||||||
|
onContentSizeChange: vi.fn(),
|
||||||
|
onLayout: vi.fn(),
|
||||||
|
onScroll: vi.fn(),
|
||||||
|
overlay: null,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-web-scrollbar-style", () => ({
|
||||||
|
useWebScrollbarStyle: () => ({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/constants/layout", () => ({
|
||||||
|
useIsCompactFormFactor: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/constants/platform", () => ({
|
||||||
|
isWeb: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/styles/markdown-styles", () => ({
|
||||||
|
createMarkdownStyles: () => ({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/session-store", () => ({
|
||||||
|
useSessionStore: (selector: (state: unknown) => unknown) => selector({ sessions: {} }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLElement | null = null;
|
||||||
|
const paneInstanceId = "server:workspace:file";
|
||||||
|
const paneContext: PaneContextValue = {
|
||||||
|
serverId: "server",
|
||||||
|
workspaceId: "workspace",
|
||||||
|
paneInstanceId,
|
||||||
|
tabId: "file",
|
||||||
|
target: { kind: "file", path: "src/example.ts" },
|
||||||
|
openTab: () => {},
|
||||||
|
closeCurrentTab: () => {},
|
||||||
|
retargetCurrentTab: () => {},
|
||||||
|
openFileInWorkspace: () => {},
|
||||||
|
openImportSheet: () => {},
|
||||||
|
};
|
||||||
|
const paneFocus = createPaneFocusContextValue({
|
||||||
|
isPaneFocused: true,
|
||||||
|
isWorkspaceFocused: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||||
|
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||||
|
callback(0);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
clearActivePaneFindPaneId(paneInstanceId);
|
||||||
|
root = null;
|
||||||
|
container?.remove();
|
||||||
|
container = null;
|
||||||
|
queryState.current = { data: null, isFetching: false };
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderFilePane(location: { path: string } = { path: "src/example.ts" }) {
|
||||||
|
act(() => {
|
||||||
|
root?.render(
|
||||||
|
<PaneProvider value={paneContext}>
|
||||||
|
<PaneFocusProvider value={paneFocus}>
|
||||||
|
<FilePane serverId="server" workspaceRoot="/repo" location={location} />
|
||||||
|
</PaneFocusProvider>
|
||||||
|
</PaneProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
setActivePaneFindPaneId(paneInstanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputElement(): HTMLInputElement {
|
||||||
|
const input = container?.querySelector('[data-testid="pane-find-input"]');
|
||||||
|
expect(input).toBeInstanceOf(HTMLInputElement);
|
||||||
|
return input as HTMLInputElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeInput(value: string): void {
|
||||||
|
const input = inputElement();
|
||||||
|
act(() => {
|
||||||
|
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||||
|
valueSetter?.call(input, value);
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function click(testId: string): void {
|
||||||
|
const element = container?.querySelector(`[data-testid="${testId}"]`);
|
||||||
|
expect(element).toBeInstanceOf(HTMLElement);
|
||||||
|
act(() => {
|
||||||
|
element?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFile(file: Partial<ExplorerFile>): ExplorerFile {
|
||||||
|
return {
|
||||||
|
content: "",
|
||||||
|
encoding: "utf-8",
|
||||||
|
kind: "text",
|
||||||
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||||
|
path: file.path ?? "src/example.ts",
|
||||||
|
size: file.size ?? 0,
|
||||||
|
...file,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("FilePane preview rendering", () => {
|
||||||
|
it("renders code/text previews with line numbers and highlighted token text", () => {
|
||||||
|
queryState.current = {
|
||||||
|
data: {
|
||||||
|
error: null,
|
||||||
|
file: makeFile({ content: "const answer = 42;\nconsole.log(answer);" }),
|
||||||
|
},
|
||||||
|
isFetching: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
renderFilePane();
|
||||||
|
|
||||||
|
const text = container?.textContent ?? "";
|
||||||
|
expect(text).toContain("1");
|
||||||
|
expect(text).toContain("2");
|
||||||
|
expect(text).toContain("const answer = 42;");
|
||||||
|
expect(text).toContain("console.log(answer);");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps markdown files on the markdown preview path", () => {
|
||||||
|
queryState.current = {
|
||||||
|
data: {
|
||||||
|
error: null,
|
||||||
|
file: makeFile({ content: "# Guide", path: "README.md" }),
|
||||||
|
},
|
||||||
|
isFetching: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
renderFilePane({ path: "README.md" });
|
||||||
|
|
||||||
|
expect(container?.querySelector('[data-testid="markdown-preview"]')?.textContent).toBe(
|
||||||
|
"# Guide",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders image previews from the attachment preview URL", () => {
|
||||||
|
queryState.current = {
|
||||||
|
data: {
|
||||||
|
error: null,
|
||||||
|
file: makeFile({ content: undefined, encoding: "none", kind: "image", path: "logo.png" }),
|
||||||
|
imageAttachment: { id: "preview" },
|
||||||
|
},
|
||||||
|
isFetching: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
renderFilePane({ path: "logo.png" });
|
||||||
|
|
||||||
|
expect(container?.querySelector("img")?.getAttribute("src")).toBe("blob:preview");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps binary previews on the unavailable state with file size", () => {
|
||||||
|
queryState.current = {
|
||||||
|
data: {
|
||||||
|
error: null,
|
||||||
|
file: makeFile({
|
||||||
|
content: undefined,
|
||||||
|
encoding: "none",
|
||||||
|
kind: "binary",
|
||||||
|
path: "tool",
|
||||||
|
size: 2048,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
isFetching: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
renderFilePane({ path: "tool" });
|
||||||
|
|
||||||
|
expect(container?.textContent).toContain("Binary preview unavailable");
|
||||||
|
expect(container?.textContent).toContain("2.0 KB");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("searches code/text previews case-insensitively and navigates matches", () => {
|
||||||
|
queryState.current = {
|
||||||
|
data: {
|
||||||
|
error: null,
|
||||||
|
file: makeFile({ content: "const answer = 42;\nconsole.log(ANSWER);" }),
|
||||||
|
},
|
||||||
|
isFetching: false,
|
||||||
|
};
|
||||||
|
renderFilePane();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
|
||||||
|
});
|
||||||
|
changeInput("answer");
|
||||||
|
|
||||||
|
expect(container?.textContent).toContain("1 / 2");
|
||||||
|
expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalledTimes(1);
|
||||||
|
expect(container?.querySelectorAll('span[style*="background-color"]').length).toBeGreaterThan(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
click("pane-find-next");
|
||||||
|
expect(container?.textContent).toContain("2 / 2");
|
||||||
|
expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
click("pane-find-prev");
|
||||||
|
expect(container?.textContent).toContain("1 / 2");
|
||||||
|
expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears file highlights on empty query and close", () => {
|
||||||
|
queryState.current = {
|
||||||
|
data: {
|
||||||
|
error: null,
|
||||||
|
file: makeFile({ content: "needle\nneedle" }),
|
||||||
|
},
|
||||||
|
isFetching: false,
|
||||||
|
};
|
||||||
|
renderFilePane();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
|
||||||
|
});
|
||||||
|
changeInput("needle");
|
||||||
|
expect(container?.textContent).toContain("1 / 2");
|
||||||
|
|
||||||
|
changeInput("");
|
||||||
|
expect(container?.textContent).toContain("0 / 0");
|
||||||
|
expect(container?.querySelectorAll('span[style*="background-color"]').length).toBe(0);
|
||||||
|
|
||||||
|
changeInput("needle");
|
||||||
|
click("pane-find-close");
|
||||||
|
expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull();
|
||||||
|
expect(container?.querySelectorAll('span[style*="background-color"]').length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useMemo, useRef } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||||
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
||||||
@@ -15,7 +15,7 @@ import { Fonts } from "@/constants/theme";
|
|||||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
import type { HighlightToken } from "@getpaseo/highlight";
|
||||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||||
@@ -26,15 +26,33 @@ import type { AttachmentMetadata } from "@/attachments/types";
|
|||||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||||
import { persistAttachmentFromBytes } from "@/attachments/service";
|
import { persistAttachmentFromBytes } from "@/attachments/service";
|
||||||
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
|
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
|
||||||
|
import {
|
||||||
|
createFilePaneFindTokenSegments,
|
||||||
|
createFilePaneLineFindHighlightMap,
|
||||||
|
createFilePaneTextRenderData,
|
||||||
|
findFilePaneTextMatches,
|
||||||
|
type FilePaneFindLineHighlight,
|
||||||
|
type FilePaneFindMatch,
|
||||||
|
type FilePaneFindTokenSegment,
|
||||||
|
} from "@/components/file-pane-text-render-data";
|
||||||
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
||||||
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
|
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
|
||||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||||
|
import {
|
||||||
|
FindBar,
|
||||||
|
type PaneFindMatchState,
|
||||||
|
type UsePaneFindResult,
|
||||||
|
usePaneFind,
|
||||||
|
} from "@/panels/pane-find";
|
||||||
|
|
||||||
interface CodeLineProps {
|
interface CodeLineProps {
|
||||||
tokens: HighlightToken[];
|
segments: FilePaneFindTokenSegment[];
|
||||||
lineNumber: number;
|
lineNumber: number;
|
||||||
gutterWidth: number;
|
gutterWidth: number;
|
||||||
highlighted: boolean;
|
highlighted: boolean;
|
||||||
|
matchBackgroundColor: string;
|
||||||
|
currentMatchBackgroundColor: string;
|
||||||
|
onLineRef?: (lineNumber: number, node: View | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FilePreviewBodyProps {
|
interface FilePreviewBodyProps {
|
||||||
@@ -46,6 +64,50 @@ interface FilePreviewBodyProps {
|
|||||||
imagePreviewUri: string | null;
|
imagePreviewUri: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FilePaneTextScrollRefs {
|
||||||
|
lineRefs: React.MutableRefObject<Map<number, View>>;
|
||||||
|
previewScrollRef: React.RefObject<RNScrollView | null>;
|
||||||
|
registerLineRef: (lineNumber: number, node: View | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilePaneCenterStateProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilePaneTextPreviewProps {
|
||||||
|
currentMatchBackgroundColor: string;
|
||||||
|
findHighlightsByLine: Map<number, FilePaneFindLineHighlight[]>;
|
||||||
|
gutterWidth: number;
|
||||||
|
isMarkdownFile: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
|
lineSelection: FileLineSelection | null;
|
||||||
|
markdownParser: ReturnType<typeof MarkdownIt>;
|
||||||
|
markdownStyles: ReturnType<typeof createMarkdownStyles>;
|
||||||
|
matchBackgroundColor: string;
|
||||||
|
preview: ExplorerFile;
|
||||||
|
previewScrollRef: React.RefObject<RNScrollView | null>;
|
||||||
|
scrollbar: ReturnType<typeof useWebScrollViewScrollbar>;
|
||||||
|
showDesktopWebScrollbar: boolean;
|
||||||
|
textRenderData: ReturnType<typeof createFilePaneTextRenderData> | null;
|
||||||
|
textScrollRefs: FilePaneTextScrollRefs;
|
||||||
|
webScrollbarStyle: object;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilePaneSearchableTextPreviewProps extends Omit<
|
||||||
|
FilePaneTextPreviewProps,
|
||||||
|
"findHighlightsByLine" | "textRenderData"
|
||||||
|
> {
|
||||||
|
textRenderData: ReturnType<typeof createFilePaneTextRenderData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilePaneImagePreviewProps {
|
||||||
|
imagePreviewUri: string | null;
|
||||||
|
imageSource: { uri: string } | null;
|
||||||
|
previewScrollRef: React.RefObject<RNScrollView | null>;
|
||||||
|
scrollbar: ReturnType<typeof useWebScrollViewScrollbar>;
|
||||||
|
showDesktopWebScrollbar: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
return null;
|
return null;
|
||||||
@@ -117,11 +179,20 @@ function clampLineSelection(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CodeLine = React.memo(function CodeLine({
|
const CodeLine = React.memo(function CodeLine({
|
||||||
tokens,
|
segments,
|
||||||
lineNumber,
|
lineNumber,
|
||||||
gutterWidth,
|
gutterWidth,
|
||||||
highlighted,
|
highlighted,
|
||||||
|
matchBackgroundColor,
|
||||||
|
currentMatchBackgroundColor,
|
||||||
|
onLineRef,
|
||||||
}: CodeLineProps) {
|
}: CodeLineProps) {
|
||||||
|
const setLineRef = useCallback(
|
||||||
|
(node: View | null) => {
|
||||||
|
onLineRef?.(lineNumber, node);
|
||||||
|
},
|
||||||
|
[lineNumber, onLineRef],
|
||||||
|
);
|
||||||
const gutterStyle = useMemo(
|
const gutterStyle = useMemo(
|
||||||
() => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })],
|
() => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })],
|
||||||
[gutterWidth],
|
[gutterWidth],
|
||||||
@@ -131,19 +202,28 @@ const CodeLine = React.memo(function CodeLine({
|
|||||||
[highlighted],
|
[highlighted],
|
||||||
);
|
);
|
||||||
const keyedTokens = useMemo(
|
const keyedTokens = useMemo(
|
||||||
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
|
() => segments.map((segment, index) => ({ key: `${index}-${segment.text}`, segment })),
|
||||||
[tokens],
|
[segments],
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<View style={lineStyle}>
|
<View ref={setLineRef} style={lineStyle}>
|
||||||
<View style={gutterStyle}>
|
<View style={gutterStyle}>
|
||||||
<Text numberOfLines={1} style={codeLineStyles.gutterText}>
|
<Text numberOfLines={1} style={codeLineStyles.gutterText}>
|
||||||
{String(lineNumber)}
|
{String(lineNumber)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text selectable style={codeLineStyles.lineText}>
|
<Text selectable style={codeLineStyles.lineText}>
|
||||||
{keyedTokens.map(({ key, token }) => (
|
{keyedTokens.map(({ key, segment }) => (
|
||||||
<CodeLineToken key={key} token={token} />
|
<CodeLineToken
|
||||||
|
key={key}
|
||||||
|
backgroundColor={getFindSegmentBackgroundColor({
|
||||||
|
currentMatchBackgroundColor,
|
||||||
|
matchBackgroundColor,
|
||||||
|
segment,
|
||||||
|
})}
|
||||||
|
style={segment.style}
|
||||||
|
text={segment.text}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -151,11 +231,237 @@ const CodeLine = React.memo(function CodeLine({
|
|||||||
});
|
});
|
||||||
|
|
||||||
interface CodeLineTokenProps {
|
interface CodeLineTokenProps {
|
||||||
token: HighlightToken;
|
backgroundColor?: string;
|
||||||
|
style: HighlightToken["style"];
|
||||||
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CodeLineToken({ token }: CodeLineTokenProps) {
|
function CodeLineToken({ backgroundColor, style, text }: CodeLineTokenProps) {
|
||||||
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
|
const tokenStyle = useMemo(
|
||||||
|
() => [
|
||||||
|
style ? syntaxTokenStyleFor(style) : undefined,
|
||||||
|
backgroundColor ? { backgroundColor } : null,
|
||||||
|
],
|
||||||
|
[backgroundColor, style],
|
||||||
|
);
|
||||||
|
return <Text style={tokenStyle}>{text}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFindSegmentBackgroundColor(input: {
|
||||||
|
segment: FilePaneFindTokenSegment;
|
||||||
|
matchBackgroundColor: string;
|
||||||
|
currentMatchBackgroundColor: string;
|
||||||
|
}) {
|
||||||
|
if (input.segment.isCurrentFindMatch) {
|
||||||
|
return input.currentMatchBackgroundColor;
|
||||||
|
}
|
||||||
|
if (input.segment.isFindMatch) {
|
||||||
|
return input.matchBackgroundColor;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useFilePaneTextScrollRefs(lineNumbers: number[] | null): FilePaneTextScrollRefs {
|
||||||
|
const previewScrollRef = useRef<RNScrollView>(null);
|
||||||
|
const lineRefs = useRef(new Map<number, View>());
|
||||||
|
|
||||||
|
const registerLineRef = useCallback((lineNumber: number, node: View | null) => {
|
||||||
|
if (node) {
|
||||||
|
lineRefs.current.set(lineNumber, node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lineRefs.current.delete(lineNumber);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lineNumbers) {
|
||||||
|
lineRefs.current.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleLineNumbers = new Set(lineNumbers);
|
||||||
|
for (const lineNumber of lineRefs.current.keys()) {
|
||||||
|
if (!visibleLineNumbers.has(lineNumber)) {
|
||||||
|
lineRefs.current.delete(lineNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [lineNumbers]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
lineRefs,
|
||||||
|
previewScrollRef,
|
||||||
|
registerLineRef,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFilePaneMatchState(
|
||||||
|
query: string,
|
||||||
|
matches: FilePaneFindMatch[],
|
||||||
|
currentMatchIndex: number,
|
||||||
|
): PaneFindMatchState {
|
||||||
|
if (query.length === 0) {
|
||||||
|
return { status: "empty" };
|
||||||
|
}
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return { status: "no-match" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: "matched",
|
||||||
|
current: Math.max(0, currentMatchIndex) + 1,
|
||||||
|
total: matches.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollFilePaneLineIntoView(input: {
|
||||||
|
lineRefs: React.MutableRefObject<Map<number, View>>;
|
||||||
|
previewScrollRef: React.RefObject<RNScrollView | null>;
|
||||||
|
lineNumber: number;
|
||||||
|
}) {
|
||||||
|
const lineNode = input.lineRefs.current.get(input.lineNumber);
|
||||||
|
const scrollNode = input.previewScrollRef.current;
|
||||||
|
if (!lineNode || !scrollNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWeb && "scrollIntoView" in lineNode) {
|
||||||
|
(
|
||||||
|
lineNode as unknown as { scrollIntoView(options?: ScrollIntoViewOptions): void }
|
||||||
|
).scrollIntoView({
|
||||||
|
block: "center",
|
||||||
|
inline: "nearest",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const measurableLineNode = lineNode as View & {
|
||||||
|
measureLayout?: (
|
||||||
|
relativeToNativeNode: unknown,
|
||||||
|
onSuccess: (x: number, y: number) => void,
|
||||||
|
onFail?: () => void,
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
measurableLineNode.measureLayout?.(scrollNode, (_x, y) => {
|
||||||
|
scrollNode.scrollTo({ y: Math.max(0, y - 48), animated: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollFilePaneLineIntoViewSoon(input: {
|
||||||
|
lineRefs: React.MutableRefObject<Map<number, View>>;
|
||||||
|
previewScrollRef: React.RefObject<RNScrollView | null>;
|
||||||
|
lineNumber: number;
|
||||||
|
}) {
|
||||||
|
const schedule =
|
||||||
|
globalThis.requestAnimationFrame ??
|
||||||
|
((callback: FrameRequestCallback) => {
|
||||||
|
setTimeout(() => callback(Date.now()), 0);
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
schedule(() => {
|
||||||
|
scrollFilePaneLineIntoView(input);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilePaneFindState {
|
||||||
|
query: string;
|
||||||
|
matches: FilePaneFindMatch[];
|
||||||
|
currentMatchIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FILE_PANE_FIND_STATE: FilePaneFindState = {
|
||||||
|
query: "",
|
||||||
|
matches: [],
|
||||||
|
currentMatchIndex: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function useFilePaneFindAdapter(input: {
|
||||||
|
textRenderData: ReturnType<typeof createFilePaneTextRenderData> | null;
|
||||||
|
textScrollRefs: FilePaneTextScrollRefs;
|
||||||
|
}) {
|
||||||
|
const [findState, setFindState] = useState<FilePaneFindState>(EMPTY_FILE_PANE_FIND_STATE);
|
||||||
|
const findQuery = findState.query;
|
||||||
|
const findMatches = findState.matches;
|
||||||
|
const currentMatchIndex = findState.currentMatchIndex;
|
||||||
|
const findHighlightsByLine = useMemo(
|
||||||
|
() => createFilePaneLineFindHighlightMap(findMatches, currentMatchIndex),
|
||||||
|
[currentMatchIndex, findMatches],
|
||||||
|
);
|
||||||
|
const findMatchState = useMemo(
|
||||||
|
() => createFilePaneMatchState(findQuery, findMatches, currentMatchIndex),
|
||||||
|
[currentMatchIndex, findMatches, findQuery],
|
||||||
|
);
|
||||||
|
const scrollMatchIntoView = useCallback(
|
||||||
|
(matches: FilePaneFindMatch[], matchIndex: number) => {
|
||||||
|
const lineNumber = matches[matchIndex]?.lineSpans[0]?.lineNumber;
|
||||||
|
if (!lineNumber) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scrollFilePaneLineIntoViewSoon({
|
||||||
|
lineRefs: input.textScrollRefs.lineRefs,
|
||||||
|
lineNumber,
|
||||||
|
previewScrollRef: input.textScrollRefs.previewScrollRef,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[input.textScrollRefs.lineRefs, input.textScrollRefs.previewScrollRef],
|
||||||
|
);
|
||||||
|
const paneFind = usePaneFind({
|
||||||
|
matchState: findMatchState,
|
||||||
|
onQuery: (query) => {
|
||||||
|
const nextMatches = input.textRenderData
|
||||||
|
? findFilePaneTextMatches(input.textRenderData, query)
|
||||||
|
: [];
|
||||||
|
setFindState({ query, matches: nextMatches, currentMatchIndex: 0 });
|
||||||
|
scrollMatchIntoView(nextMatches, 0);
|
||||||
|
return createFilePaneMatchState(query, nextMatches, 0);
|
||||||
|
},
|
||||||
|
onNext: () => {
|
||||||
|
if (findMatches.length === 0) {
|
||||||
|
return createFilePaneMatchState(findQuery, findMatches, currentMatchIndex);
|
||||||
|
}
|
||||||
|
const nextIndex = (currentMatchIndex + 1) % findMatches.length;
|
||||||
|
setFindState((current) => ({ ...current, currentMatchIndex: nextIndex }));
|
||||||
|
scrollMatchIntoView(findMatches, nextIndex);
|
||||||
|
return createFilePaneMatchState(findQuery, findMatches, nextIndex);
|
||||||
|
},
|
||||||
|
onPrev: () => {
|
||||||
|
if (findMatches.length === 0) {
|
||||||
|
return createFilePaneMatchState(findQuery, findMatches, currentMatchIndex);
|
||||||
|
}
|
||||||
|
const nextIndex = (currentMatchIndex - 1 + findMatches.length) % findMatches.length;
|
||||||
|
setFindState((current) => ({ ...current, currentMatchIndex: nextIndex }));
|
||||||
|
scrollMatchIntoView(findMatches, nextIndex);
|
||||||
|
return createFilePaneMatchState(findQuery, findMatches, nextIndex);
|
||||||
|
},
|
||||||
|
onClose: () => {
|
||||||
|
setFindState(EMPTY_FILE_PANE_FIND_STATE);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFindState((current) => {
|
||||||
|
if (!input.textRenderData || current.query.length === 0) {
|
||||||
|
return current.query.length === 0 &&
|
||||||
|
current.matches.length === 0 &&
|
||||||
|
current.currentMatchIndex === 0
|
||||||
|
? current
|
||||||
|
: EMPTY_FILE_PANE_FIND_STATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMatches = findFilePaneTextMatches(input.textRenderData, current.query);
|
||||||
|
const nextMatchIndex =
|
||||||
|
nextMatches.length === 0 ? 0 : Math.min(current.currentMatchIndex, nextMatches.length - 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
query: current.query,
|
||||||
|
matches: nextMatches,
|
||||||
|
currentMatchIndex: nextMatchIndex,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [input.textRenderData]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
findHighlightsByLine,
|
||||||
|
paneFind,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const codeLineStyles = StyleSheet.create((theme) => ({
|
const codeLineStyles = StyleSheet.create((theme) => ({
|
||||||
@@ -186,6 +492,175 @@ const codeLineStyles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
function FilePaneFindBarSlot({ paneFind }: { paneFind: UsePaneFindResult }) {
|
||||||
|
return paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilePaneCenterState({ children }: FilePaneCenterStateProps) {
|
||||||
|
return <View style={styles.centerState}>{children}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLineSelected(lineSelection: FileLineSelection | null, lineNumber: number): boolean {
|
||||||
|
if (!lineSelection) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return lineNumber >= lineSelection.lineStart && lineNumber <= lineSelection.lineEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilePaneTextPreview({
|
||||||
|
currentMatchBackgroundColor,
|
||||||
|
findHighlightsByLine,
|
||||||
|
gutterWidth,
|
||||||
|
isMarkdownFile,
|
||||||
|
isMobile,
|
||||||
|
lineSelection,
|
||||||
|
markdownParser,
|
||||||
|
markdownStyles,
|
||||||
|
matchBackgroundColor,
|
||||||
|
preview,
|
||||||
|
previewScrollRef,
|
||||||
|
scrollbar,
|
||||||
|
showDesktopWebScrollbar,
|
||||||
|
textRenderData,
|
||||||
|
textScrollRefs,
|
||||||
|
webScrollbarStyle,
|
||||||
|
}: FilePaneTextPreviewProps) {
|
||||||
|
if (isMarkdownFile) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RNScrollView
|
||||||
|
ref={previewScrollRef}
|
||||||
|
style={styles.previewContent}
|
||||||
|
contentContainerStyle={styles.previewMarkdownScrollContent}
|
||||||
|
onLayout={scrollbar.onLayout}
|
||||||
|
onScroll={scrollbar.onScroll}
|
||||||
|
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||||
|
>
|
||||||
|
<Markdown style={markdownStyles} markdownit={markdownParser}>
|
||||||
|
{preview.content ?? ""}
|
||||||
|
</Markdown>
|
||||||
|
</RNScrollView>
|
||||||
|
{scrollbar.overlay}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = textRenderData?.lines ?? [
|
||||||
|
{
|
||||||
|
lineNumber: 1,
|
||||||
|
text: preview.content ?? "",
|
||||||
|
tokens: [{ text: preview.content ?? "", style: null }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const keyedLines = lines.map((line) => ({
|
||||||
|
key: `line-${line.lineNumber}`,
|
||||||
|
line,
|
||||||
|
}));
|
||||||
|
const codeLines = (
|
||||||
|
<View>
|
||||||
|
{keyedLines.map(({ key, line }) => (
|
||||||
|
<CodeLine
|
||||||
|
key={key}
|
||||||
|
segments={createFilePaneFindTokenSegments(
|
||||||
|
line,
|
||||||
|
findHighlightsByLine.get(line.lineNumber) ?? [],
|
||||||
|
)}
|
||||||
|
lineNumber={line.lineNumber}
|
||||||
|
gutterWidth={gutterWidth}
|
||||||
|
highlighted={isLineSelected(lineSelection, line.lineNumber)}
|
||||||
|
matchBackgroundColor={matchBackgroundColor}
|
||||||
|
currentMatchBackgroundColor={currentMatchBackgroundColor}
|
||||||
|
onLineRef={textScrollRefs.registerLineRef}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RNScrollView
|
||||||
|
ref={previewScrollRef}
|
||||||
|
style={styles.previewContent}
|
||||||
|
onLayout={scrollbar.onLayout}
|
||||||
|
onScroll={scrollbar.onScroll}
|
||||||
|
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||||
|
>
|
||||||
|
{isMobile ? (
|
||||||
|
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
|
||||||
|
) : (
|
||||||
|
<RNScrollView
|
||||||
|
horizontal
|
||||||
|
nestedScrollEnabled
|
||||||
|
showsHorizontalScrollIndicator
|
||||||
|
style={webScrollbarStyle}
|
||||||
|
contentContainerStyle={styles.previewCodeScrollContent}
|
||||||
|
>
|
||||||
|
{codeLines}
|
||||||
|
</RNScrollView>
|
||||||
|
)}
|
||||||
|
</RNScrollView>
|
||||||
|
{scrollbar.overlay}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilePaneSearchableTextPreview(props: FilePaneSearchableTextPreviewProps) {
|
||||||
|
const { findHighlightsByLine, paneFind } = useFilePaneFindAdapter({
|
||||||
|
textRenderData: props.textRenderData,
|
||||||
|
textScrollRefs: props.textScrollRefs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FilePaneFindBarSlot paneFind={paneFind} />
|
||||||
|
<FilePaneTextPreview {...props} findHighlightsByLine={findHighlightsByLine} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilePaneImagePreview({
|
||||||
|
imagePreviewUri,
|
||||||
|
imageSource,
|
||||||
|
previewScrollRef,
|
||||||
|
scrollbar,
|
||||||
|
showDesktopWebScrollbar,
|
||||||
|
}: FilePaneImagePreviewProps) {
|
||||||
|
if (!imagePreviewUri) {
|
||||||
|
return (
|
||||||
|
<FilePaneCenterState>
|
||||||
|
<ActivityIndicator size="small" />
|
||||||
|
<Text style={styles.loadingText}>Loading file…</Text>
|
||||||
|
</FilePaneCenterState>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RNScrollView
|
||||||
|
ref={previewScrollRef}
|
||||||
|
style={styles.previewContent}
|
||||||
|
contentContainerStyle={styles.previewImageScrollContent}
|
||||||
|
onLayout={scrollbar.onLayout}
|
||||||
|
onScroll={scrollbar.onScroll}
|
||||||
|
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||||
|
>
|
||||||
|
<RNImage
|
||||||
|
source={imageSource ?? undefined}
|
||||||
|
style={styles.previewImage}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
</RNScrollView>
|
||||||
|
{scrollbar.overlay}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FilePreviewBody({
|
function FilePreviewBody({
|
||||||
preview,
|
preview,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -196,40 +671,50 @@ function FilePreviewBody({
|
|||||||
}: FilePreviewBodyProps) {
|
}: FilePreviewBodyProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const filePath = location.path;
|
const filePath = location.path;
|
||||||
|
const isDark = theme.colorScheme === "dark";
|
||||||
|
const matchBackgroundColor = isDark ? "rgba(250, 204, 21, 0.32)" : "rgba(250, 204, 21, 0.38)";
|
||||||
|
const currentMatchBackgroundColor = isDark
|
||||||
|
? "rgba(251, 146, 60, 0.58)"
|
||||||
|
: "rgba(251, 146, 60, 0.48)";
|
||||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||||
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
|
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
|
||||||
const isMarkdownFile =
|
const isMarkdownFile =
|
||||||
preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart;
|
preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart;
|
||||||
|
|
||||||
const previewScrollRef = useRef<RNScrollView>(null);
|
const fallbackScrollRef = useRef<RNScrollView>(null);
|
||||||
const webScrollbarStyle = useWebScrollbarStyle();
|
const webScrollbarStyle = useWebScrollbarStyle();
|
||||||
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
|
|
||||||
enabled: showDesktopWebScrollbar,
|
|
||||||
});
|
|
||||||
|
|
||||||
const highlightedLines = useMemo(() => {
|
const textRenderData = useMemo(() => {
|
||||||
if (!preview || preview.kind !== "text" || isMarkdownFile) {
|
if (!preview || preview.kind !== "text" || isMarkdownFile) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return highlightCode(preview.content ?? "", filePath);
|
return createFilePaneTextRenderData(preview.content ?? "", filePath);
|
||||||
}, [isMarkdownFile, preview, filePath]);
|
}, [isMarkdownFile, preview, filePath]);
|
||||||
|
const textLineNumbers = useMemo(
|
||||||
|
() => textRenderData?.lines.map((line) => line.lineNumber) ?? null,
|
||||||
|
[textRenderData],
|
||||||
|
);
|
||||||
|
const textScrollRefs = useFilePaneTextScrollRefs(textLineNumbers);
|
||||||
|
const previewScrollRef = textRenderData ? textScrollRefs.previewScrollRef : fallbackScrollRef;
|
||||||
|
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
|
||||||
|
enabled: showDesktopWebScrollbar,
|
||||||
|
});
|
||||||
const gutterWidth = useMemo(() => {
|
const gutterWidth = useMemo(() => {
|
||||||
if (!highlightedLines) return 0;
|
if (!textRenderData) return 0;
|
||||||
return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.code);
|
return lineNumberGutterWidth(textRenderData.lines.length, theme.fontSize.code);
|
||||||
}, [highlightedLines, theme.fontSize.code]);
|
}, [textRenderData, theme.fontSize.code]);
|
||||||
const lineHeight = theme.fontSize.code * 1.45;
|
const lineHeight = theme.fontSize.code * 1.45;
|
||||||
const lineSelection = useMemo(() => {
|
const lineSelection = useMemo(() => {
|
||||||
if (!highlightedLines) {
|
if (!textRenderData) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return clampLineSelection({
|
return clampLineSelection({
|
||||||
lineStart: location.lineStart,
|
lineStart: location.lineStart,
|
||||||
lineEnd: location.lineEnd,
|
lineEnd: location.lineEnd,
|
||||||
lineCount: highlightedLines.length,
|
lineCount: textRenderData.lines.length,
|
||||||
});
|
});
|
||||||
}, [highlightedLines, location.lineEnd, location.lineStart]);
|
}, [textRenderData, location.lineEnd, location.lineStart]);
|
||||||
|
|
||||||
const imageSource = useMemo(
|
const imageSource = useMemo(
|
||||||
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
|
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
|
||||||
@@ -247,141 +732,83 @@ function FilePreviewBody({
|
|||||||
});
|
});
|
||||||
}, 0);
|
}, 0);
|
||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
}, [lineHeight, lineSelection]);
|
}, [lineHeight, lineSelection, previewScrollRef]);
|
||||||
|
|
||||||
|
let content: React.ReactNode;
|
||||||
if (isLoading && !preview) {
|
if (isLoading && !preview) {
|
||||||
return (
|
content = (
|
||||||
<View style={styles.centerState}>
|
<FilePaneCenterState>
|
||||||
<ActivityIndicator size="small" />
|
<ActivityIndicator size="small" />
|
||||||
<Text style={styles.loadingText}>Loading file…</Text>
|
<Text style={styles.loadingText}>Loading file…</Text>
|
||||||
</View>
|
</FilePaneCenterState>
|
||||||
);
|
);
|
||||||
}
|
} else if (!preview) {
|
||||||
|
content = (
|
||||||
if (!preview) {
|
<FilePaneCenterState>
|
||||||
return (
|
|
||||||
<View style={styles.centerState}>
|
|
||||||
<Text style={styles.emptyText}>No preview available</Text>
|
<Text style={styles.emptyText}>No preview available</Text>
|
||||||
</View>
|
</FilePaneCenterState>
|
||||||
|
);
|
||||||
|
} else if (preview.kind === "text" && textRenderData) {
|
||||||
|
content = (
|
||||||
|
<FilePaneSearchableTextPreview
|
||||||
|
currentMatchBackgroundColor={currentMatchBackgroundColor}
|
||||||
|
gutterWidth={gutterWidth}
|
||||||
|
isMarkdownFile={isMarkdownFile}
|
||||||
|
isMobile={isMobile}
|
||||||
|
lineSelection={lineSelection}
|
||||||
|
markdownParser={markdownParser}
|
||||||
|
markdownStyles={markdownStyles}
|
||||||
|
matchBackgroundColor={matchBackgroundColor}
|
||||||
|
preview={preview}
|
||||||
|
previewScrollRef={previewScrollRef}
|
||||||
|
scrollbar={scrollbar}
|
||||||
|
showDesktopWebScrollbar={showDesktopWebScrollbar}
|
||||||
|
textRenderData={textRenderData}
|
||||||
|
textScrollRefs={textScrollRefs}
|
||||||
|
webScrollbarStyle={webScrollbarStyle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (preview.kind === "text") {
|
||||||
|
content = (
|
||||||
|
<FilePaneTextPreview
|
||||||
|
currentMatchBackgroundColor={currentMatchBackgroundColor}
|
||||||
|
findHighlightsByLine={new Map()}
|
||||||
|
gutterWidth={gutterWidth}
|
||||||
|
isMarkdownFile={isMarkdownFile}
|
||||||
|
isMobile={isMobile}
|
||||||
|
lineSelection={lineSelection}
|
||||||
|
markdownParser={markdownParser}
|
||||||
|
markdownStyles={markdownStyles}
|
||||||
|
matchBackgroundColor={matchBackgroundColor}
|
||||||
|
preview={preview}
|
||||||
|
previewScrollRef={previewScrollRef}
|
||||||
|
scrollbar={scrollbar}
|
||||||
|
showDesktopWebScrollbar={showDesktopWebScrollbar}
|
||||||
|
textRenderData={textRenderData}
|
||||||
|
textScrollRefs={textScrollRefs}
|
||||||
|
webScrollbarStyle={webScrollbarStyle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (preview.kind === "image") {
|
||||||
|
content = (
|
||||||
|
<FilePaneImagePreview
|
||||||
|
imagePreviewUri={imagePreviewUri}
|
||||||
|
imageSource={imageSource}
|
||||||
|
previewScrollRef={previewScrollRef}
|
||||||
|
scrollbar={scrollbar}
|
||||||
|
showDesktopWebScrollbar={showDesktopWebScrollbar}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
content = (
|
||||||
|
<FilePaneCenterState>
|
||||||
|
<Text style={styles.emptyText}>Binary preview unavailable</Text>
|
||||||
|
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
|
||||||
|
</FilePaneCenterState>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preview.kind === "text") {
|
return <View style={styles.previewScrollContainer}>{content}</View>;
|
||||||
if (isMarkdownFile) {
|
|
||||||
return (
|
|
||||||
<View style={styles.previewScrollContainer}>
|
|
||||||
<RNScrollView
|
|
||||||
ref={previewScrollRef}
|
|
||||||
style={styles.previewContent}
|
|
||||||
contentContainerStyle={styles.previewMarkdownScrollContent}
|
|
||||||
onLayout={scrollbar.onLayout}
|
|
||||||
onScroll={scrollbar.onScroll}
|
|
||||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
|
||||||
scrollEventThrottle={16}
|
|
||||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
|
||||||
>
|
|
||||||
<Markdown style={markdownStyles} markdownit={markdownParser}>
|
|
||||||
{preview.content ?? ""}
|
|
||||||
</Markdown>
|
|
||||||
</RNScrollView>
|
|
||||||
{scrollbar.overlay}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
|
|
||||||
const keyedLines = lines.map((tokens, index) => ({
|
|
||||||
key: `line-${index}`,
|
|
||||||
tokens,
|
|
||||||
lineNumber: index + 1,
|
|
||||||
}));
|
|
||||||
const codeLines = (
|
|
||||||
<View>
|
|
||||||
{keyedLines.map(({ key, tokens, lineNumber }) => (
|
|
||||||
<CodeLine
|
|
||||||
key={key}
|
|
||||||
tokens={tokens}
|
|
||||||
lineNumber={lineNumber}
|
|
||||||
gutterWidth={gutterWidth}
|
|
||||||
highlighted={
|
|
||||||
Boolean(lineSelection) &&
|
|
||||||
lineNumber >= (lineSelection?.lineStart ?? 0) &&
|
|
||||||
lineNumber <= (lineSelection?.lineEnd ?? 0)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.previewScrollContainer}>
|
|
||||||
<RNScrollView
|
|
||||||
ref={previewScrollRef}
|
|
||||||
style={styles.previewContent}
|
|
||||||
onLayout={scrollbar.onLayout}
|
|
||||||
onScroll={scrollbar.onScroll}
|
|
||||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
|
||||||
scrollEventThrottle={16}
|
|
||||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
|
||||||
>
|
|
||||||
{isMobile ? (
|
|
||||||
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
|
|
||||||
) : (
|
|
||||||
<RNScrollView
|
|
||||||
horizontal
|
|
||||||
nestedScrollEnabled
|
|
||||||
showsHorizontalScrollIndicator
|
|
||||||
style={webScrollbarStyle}
|
|
||||||
contentContainerStyle={styles.previewCodeScrollContent}
|
|
||||||
>
|
|
||||||
{codeLines}
|
|
||||||
</RNScrollView>
|
|
||||||
)}
|
|
||||||
</RNScrollView>
|
|
||||||
{scrollbar.overlay}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preview.kind === "image") {
|
|
||||||
if (!imagePreviewUri) {
|
|
||||||
return (
|
|
||||||
<View style={styles.centerState}>
|
|
||||||
<ActivityIndicator size="small" />
|
|
||||||
<Text style={styles.loadingText}>Loading file…</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.previewScrollContainer}>
|
|
||||||
<RNScrollView
|
|
||||||
ref={previewScrollRef}
|
|
||||||
style={styles.previewContent}
|
|
||||||
contentContainerStyle={styles.previewImageScrollContent}
|
|
||||||
onLayout={scrollbar.onLayout}
|
|
||||||
onScroll={scrollbar.onScroll}
|
|
||||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
|
||||||
scrollEventThrottle={16}
|
|
||||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
|
||||||
>
|
|
||||||
<RNImage
|
|
||||||
source={imageSource ?? undefined}
|
|
||||||
style={styles.previewImage}
|
|
||||||
resizeMode="contain"
|
|
||||||
/>
|
|
||||||
</RNScrollView>
|
|
||||||
{scrollbar.overlay}
|
|
||||||
</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({
|
export function FilePane({
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ interface UserMessageProps {
|
|||||||
isFirstInGroup?: boolean;
|
isFirstInGroup?: boolean;
|
||||||
isLastInGroup?: boolean;
|
isLastInGroup?: boolean;
|
||||||
disableOuterSpacing?: boolean;
|
disableOuterSpacing?: boolean;
|
||||||
|
findHighlights?: MessageFindHighlight[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const MessageOuterSpacingContext = createContext(false);
|
const MessageOuterSpacingContext = createContext(false);
|
||||||
@@ -158,6 +159,19 @@ const MARKDOWN_ALLOWED_IMAGE_HANDLERS = [
|
|||||||
] as const;
|
] as const;
|
||||||
const MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM = <Text key="dotdotdot">...</Text>;
|
const MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM = <Text key="dotdotdot">...</Text>;
|
||||||
|
|
||||||
|
export interface MessageFindHighlight {
|
||||||
|
id: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
isCurrent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageFindTextSegment {
|
||||||
|
key: string;
|
||||||
|
text: string;
|
||||||
|
highlight?: MessageFindHighlight;
|
||||||
|
}
|
||||||
|
|
||||||
interface MarkdownWithStableRendererProps {
|
interface MarkdownWithStableRendererProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
style: ReturnType<typeof createMarkdownStyles>;
|
style: ReturnType<typeof createMarkdownStyles>;
|
||||||
@@ -209,6 +223,95 @@ const SCROLL_EDGE_EPSILON = 0.5;
|
|||||||
export const STREAM_METADATA_FONT_SIZE = 13;
|
export const STREAM_METADATA_FONT_SIZE = 13;
|
||||||
type ScrollAxis = "x" | "y";
|
type ScrollAxis = "x" | "y";
|
||||||
|
|
||||||
|
function normalizeFindHighlights(
|
||||||
|
textLength: number,
|
||||||
|
highlights: MessageFindHighlight[] | undefined,
|
||||||
|
): MessageFindHighlight[] {
|
||||||
|
if (!highlights || highlights.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return highlights
|
||||||
|
.map((highlight) => ({
|
||||||
|
...highlight,
|
||||||
|
start: Math.max(0, Math.min(textLength, highlight.start)),
|
||||||
|
end: Math.max(0, Math.min(textLength, highlight.end)),
|
||||||
|
}))
|
||||||
|
.filter((highlight) => highlight.start < highlight.end)
|
||||||
|
.sort((left, right) => left.start - right.start || left.end - right.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMessageFindTextSegments(
|
||||||
|
text: string,
|
||||||
|
highlights: MessageFindHighlight[] | undefined,
|
||||||
|
): MessageFindTextSegment[] {
|
||||||
|
const normalizedHighlights = normalizeFindHighlights(text.length, highlights);
|
||||||
|
if (normalizedHighlights.length === 0) {
|
||||||
|
return [{ key: "text", text }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments: MessageFindTextSegment[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
for (const highlight of normalizedHighlights) {
|
||||||
|
if (cursor < highlight.start) {
|
||||||
|
segments.push({
|
||||||
|
key: `plain:${cursor}:${highlight.start}`,
|
||||||
|
text: text.slice(cursor, highlight.start),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
segments.push({
|
||||||
|
key: highlight.id,
|
||||||
|
text: text.slice(highlight.start, highlight.end),
|
||||||
|
highlight,
|
||||||
|
});
|
||||||
|
cursor = highlight.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor < text.length) {
|
||||||
|
segments.push({
|
||||||
|
key: `plain:${cursor}:${text.length}`,
|
||||||
|
text: text.slice(cursor),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarkdownBlockWithOffset {
|
||||||
|
key: string;
|
||||||
|
block: string;
|
||||||
|
startOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMarkdownBlocksWithOffsets(message: string): MarkdownBlockWithOffset[] {
|
||||||
|
let cursor = 0;
|
||||||
|
return splitMarkdownBlocks(message).map((block, index) => {
|
||||||
|
const startOffset = Math.max(cursor, message.indexOf(block, cursor));
|
||||||
|
cursor = startOffset + block.length;
|
||||||
|
return {
|
||||||
|
key: `${index}:${block.slice(0, 32)}`,
|
||||||
|
block,
|
||||||
|
startOffset,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBlockFindHighlights(
|
||||||
|
blockStartOffset: number,
|
||||||
|
blockLength: number,
|
||||||
|
highlights: MessageFindHighlight[] | undefined,
|
||||||
|
): MessageFindHighlight[] {
|
||||||
|
const blockEndOffset = blockStartOffset + blockLength;
|
||||||
|
return normalizeFindHighlights(blockEndOffset, highlights)
|
||||||
|
.filter((highlight) => highlight.start < blockEndOffset && highlight.end > blockStartOffset)
|
||||||
|
.map((highlight) => ({
|
||||||
|
id: highlight.id,
|
||||||
|
start: Math.max(0, highlight.start - blockStartOffset),
|
||||||
|
end: Math.min(blockLength, highlight.end - blockStartOffset),
|
||||||
|
isCurrent: highlight.isCurrent,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function ensureWebToolCallShimmerKeyframes() {
|
function ensureWebToolCallShimmerKeyframes() {
|
||||||
if (isNative) {
|
if (isNative) {
|
||||||
return;
|
return;
|
||||||
@@ -369,6 +472,14 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
lineHeight: 22,
|
lineHeight: 22,
|
||||||
overflowWrap: "anywhere",
|
overflowWrap: "anywhere",
|
||||||
},
|
},
|
||||||
|
findMatchText: {
|
||||||
|
backgroundColor:
|
||||||
|
theme.colorScheme === "dark" ? "rgba(250, 204, 21, 0.32)" : "rgba(250, 204, 21, 0.38)",
|
||||||
|
},
|
||||||
|
findCurrentMatchText: {
|
||||||
|
backgroundColor:
|
||||||
|
theme.colorScheme === "dark" ? "rgba(251, 146, 60, 0.58)" : "rgba(251, 146, 60, 0.48)",
|
||||||
|
},
|
||||||
imagePreviewContainer: {
|
imagePreviewContainer: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
@@ -436,6 +547,28 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
function MessageFindHighlightedText({ segments }: { segments: MessageFindTextSegment[] }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{segments.map((segment) => (
|
||||||
|
<Text key={segment.key} style={getMessageFindHighlightStyle(segment.highlight)}>
|
||||||
|
{segment.text}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageFindHighlightStyle(highlight: MessageFindHighlight | undefined) {
|
||||||
|
if (highlight?.isCurrent) {
|
||||||
|
return userMessageStylesheet.findCurrentMatchText;
|
||||||
|
}
|
||||||
|
if (highlight) {
|
||||||
|
return userMessageStylesheet.findMatchText;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAttachment }) {
|
function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAttachment }) {
|
||||||
const uri = useAttachmentPreviewUrl(image);
|
const uri = useAttachmentPreviewUrl(image);
|
||||||
const imageSource = useMemo(() => ({ uri: uri ?? "" }), [uri]);
|
const imageSource = useMemo(() => ({ uri: uri ?? "" }), [uri]);
|
||||||
@@ -475,6 +608,7 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
isFirstInGroup = true,
|
isFirstInGroup = true,
|
||||||
isLastInGroup = true,
|
isLastInGroup = true,
|
||||||
disableOuterSpacing,
|
disableOuterSpacing,
|
||||||
|
findHighlights,
|
||||||
}: UserMessageProps) {
|
}: UserMessageProps) {
|
||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
@@ -498,6 +632,10 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
},
|
},
|
||||||
[rewindMutation],
|
[rewindMutation],
|
||||||
);
|
);
|
||||||
|
const highlightedMessageSegments = useMemo(
|
||||||
|
() => createMessageFindTextSegments(message, findHighlights),
|
||||||
|
[findHighlights, message],
|
||||||
|
);
|
||||||
|
|
||||||
const containerStyle = useMemo(
|
const containerStyle = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -567,7 +705,7 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
) : null}
|
) : null}
|
||||||
{hasText ? (
|
{hasText ? (
|
||||||
<Text selectable style={userMessageStylesheet.text}>
|
<Text selectable style={userMessageStylesheet.text}>
|
||||||
{message}
|
<MessageFindHighlightedText segments={highlightedMessageSegments} />
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
@@ -752,6 +890,7 @@ interface AssistantMessageProps {
|
|||||||
serverId?: string;
|
serverId?: string;
|
||||||
client?: DaemonClient | null;
|
client?: DaemonClient | null;
|
||||||
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
|
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
|
||||||
|
findHighlights?: MessageFindHighlight[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||||
@@ -1487,6 +1626,7 @@ interface MemoizedMarkdownBlockProps {
|
|||||||
rules: RenderRules;
|
rules: RenderRules;
|
||||||
parser: MarkdownIt;
|
parser: MarkdownIt;
|
||||||
onLinkPress: (url: string) => boolean;
|
onLinkPress: (url: string) => boolean;
|
||||||
|
findHighlights?: MessageFindHighlight[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
|
const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
|
||||||
@@ -1494,26 +1634,61 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
|
|||||||
rules,
|
rules,
|
||||||
parser,
|
parser,
|
||||||
onLinkPress,
|
onLinkPress,
|
||||||
|
findHighlights,
|
||||||
}: MemoizedMarkdownBlockProps) {
|
}: MemoizedMarkdownBlockProps) {
|
||||||
|
const textCursorRef = useRef(0);
|
||||||
|
textCursorRef.current = 0;
|
||||||
|
const renderFindHighlightedText = useCallback(
|
||||||
|
(content: string) => {
|
||||||
|
if (!findHighlights || findHighlights.length === 0 || content.length === 0) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = text.indexOf(content, textCursorRef.current);
|
||||||
|
if (start < 0) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
textCursorRef.current = start + content.length;
|
||||||
|
const end = start + content.length;
|
||||||
|
const contentHighlights = findHighlights
|
||||||
|
.filter((highlight) => highlight.start < end && highlight.end > start)
|
||||||
|
.map((highlight) => ({
|
||||||
|
id: highlight.id,
|
||||||
|
start: Math.max(0, highlight.start - start),
|
||||||
|
end: Math.min(content.length, highlight.end - start),
|
||||||
|
isCurrent: highlight.isCurrent,
|
||||||
|
}));
|
||||||
|
const segments = createMessageFindTextSegments(content, contentHighlights);
|
||||||
|
return <MessageFindHighlightedText segments={segments} />;
|
||||||
|
},
|
||||||
|
[findHighlights, text],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemedMarkdown
|
<MarkdownFindHighlightContext.Provider value={renderFindHighlightedText}>
|
||||||
uniProps={markdownStyleMapping}
|
<ThemedMarkdown
|
||||||
rules={rules}
|
uniProps={markdownStyleMapping}
|
||||||
markdownit={parser}
|
rules={rules}
|
||||||
onLinkPress={onLinkPress}
|
markdownit={parser}
|
||||||
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
|
onLinkPress={onLinkPress}
|
||||||
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
|
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
|
||||||
>
|
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
|
||||||
{text}
|
>
|
||||||
</ThemedMarkdown>
|
{text}
|
||||||
|
</ThemedMarkdown>
|
||||||
|
</MarkdownFindHighlightContext.Provider>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const MarkdownFindHighlightContext = createContext<((content: string) => ReactNode) | null>(null);
|
||||||
|
|
||||||
interface MarkdownInheritedTextProps {
|
interface MarkdownInheritedTextProps {
|
||||||
inheritedStyles: TextStyle;
|
inheritedStyles: TextStyle;
|
||||||
textStyle: TextStyle;
|
textStyle: TextStyle;
|
||||||
style?: StyleProp<TextStyle>;
|
style?: StyleProp<TextStyle>;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
disableFindHighlight?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MarkdownInheritedText({
|
function MarkdownInheritedText({
|
||||||
@@ -1521,12 +1696,18 @@ function MarkdownInheritedText({
|
|||||||
textStyle,
|
textStyle,
|
||||||
style: overrideStyle,
|
style: overrideStyle,
|
||||||
children,
|
children,
|
||||||
|
disableFindHighlight = false,
|
||||||
}: MarkdownInheritedTextProps) {
|
}: MarkdownInheritedTextProps) {
|
||||||
|
const renderFindHighlightedText = useContext(MarkdownFindHighlightContext);
|
||||||
const style = useMemo(
|
const style = useMemo(
|
||||||
() => [inheritedStyles, textStyle, overrideStyle],
|
() => [inheritedStyles, textStyle, overrideStyle],
|
||||||
[inheritedStyles, textStyle, overrideStyle],
|
[inheritedStyles, textStyle, overrideStyle],
|
||||||
);
|
);
|
||||||
return <MarkdownTextSpan style={style}>{children}</MarkdownTextSpan>;
|
const renderedChildren =
|
||||||
|
!disableFindHighlight && typeof children === "string" && renderFindHighlightedText
|
||||||
|
? renderFindHighlightedText(children)
|
||||||
|
: children;
|
||||||
|
return <MarkdownTextSpan style={style}>{renderedChildren}</MarkdownTextSpan>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MarkdownListItemContentProps {
|
interface MarkdownListItemContentProps {
|
||||||
@@ -1559,6 +1740,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
serverId,
|
serverId,
|
||||||
client,
|
client,
|
||||||
spacing = "default",
|
spacing = "default",
|
||||||
|
findHighlights,
|
||||||
}: AssistantMessageProps) {
|
}: AssistantMessageProps) {
|
||||||
const markdownParser = useMemo(() => {
|
const markdownParser = useMemo(() => {
|
||||||
const parser = MarkdownIt({ typographer: true, linkify: true });
|
const parser = MarkdownIt({ typographer: true, linkify: true });
|
||||||
@@ -1696,6 +1878,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
key={node.key}
|
key={node.key}
|
||||||
inheritedStyles={inheritedStyles}
|
inheritedStyles={inheritedStyles}
|
||||||
textStyle={styles.code_inline}
|
textStyle={styles.code_inline}
|
||||||
|
disableFindHighlight
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</MarkdownInheritedText>
|
</MarkdownInheritedText>
|
||||||
@@ -1803,11 +1986,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
};
|
};
|
||||||
}, [client, fileLinkActions, markdownParser, serverId, workspaceRoot]);
|
}, [client, fileLinkActions, markdownParser, serverId, workspaceRoot]);
|
||||||
|
|
||||||
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
|
const keyedBlocks = useMemo(() => createMarkdownBlocksWithOffsets(message), [message]);
|
||||||
const keyedBlocks = useMemo(
|
|
||||||
() => blocks.map((block, index) => ({ key: `${index}:${block.slice(0, 32)}`, block })),
|
|
||||||
[blocks],
|
|
||||||
);
|
|
||||||
|
|
||||||
const assistantContainerStyle = useMemo(
|
const assistantContainerStyle = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -1822,7 +2001,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View testID="assistant-message" style={assistantContainerStyle}>
|
<View testID="assistant-message" style={assistantContainerStyle}>
|
||||||
{keyedBlocks.map(({ key, block }, index) => (
|
{keyedBlocks.map(({ key, block, startOffset }, index) => (
|
||||||
<AssistantMessageBlockContainer
|
<AssistantMessageBlockContainer
|
||||||
key={key}
|
key={key}
|
||||||
block={block}
|
block={block}
|
||||||
@@ -1833,6 +2012,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
rules={markdownRules}
|
rules={markdownRules}
|
||||||
parser={markdownParser}
|
parser={markdownParser}
|
||||||
onLinkPress={handleMarkdownLinkPress}
|
onLinkPress={handleMarkdownLinkPress}
|
||||||
|
findHighlights={createBlockFindHighlights(startOffset, block.length, findHighlights)}
|
||||||
/>
|
/>
|
||||||
</AssistantMessageBlockContainer>
|
</AssistantMessageBlockContainer>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { getNativeScrollToIndexFallbackOffset } from "@/agent-stream/strategy-native";
|
||||||
|
|
||||||
|
describe("getNativeScrollToIndexFallbackOffset", () => {
|
||||||
|
it("approximates the offset for an unmeasured native FlatList row", () => {
|
||||||
|
expect(
|
||||||
|
getNativeScrollToIndexFallbackOffset({
|
||||||
|
index: 25,
|
||||||
|
averageItemLength: 72,
|
||||||
|
}),
|
||||||
|
).toBe(1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the start when the average item length is not useful", () => {
|
||||||
|
expect(
|
||||||
|
getNativeScrollToIndexFallbackOffset({
|
||||||
|
index: 25,
|
||||||
|
averageItemLength: 0,
|
||||||
|
}),
|
||||||
|
).toBe(0);
|
||||||
|
expect(
|
||||||
|
getNativeScrollToIndexFallbackOffset({
|
||||||
|
index: 25,
|
||||||
|
averageItemLength: Number.NaN,
|
||||||
|
}),
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,7 @@ import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-m
|
|||||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||||
import {
|
import {
|
||||||
TerminalEmulatorRuntime,
|
TerminalEmulatorRuntime,
|
||||||
|
type TerminalFindResultChangeEvent,
|
||||||
type TerminalOutputData,
|
type TerminalOutputData,
|
||||||
} from "../terminal/runtime/terminal-emulator-runtime";
|
} from "../terminal/runtime/terminal-emulator-runtime";
|
||||||
import type {
|
import type {
|
||||||
@@ -49,6 +50,10 @@ export interface TerminalEmulatorHandle {
|
|||||||
renderSnapshot: (state: TerminalState | null) => void;
|
renderSnapshot: (state: TerminalState | null) => void;
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
blur: () => void;
|
blur: () => void;
|
||||||
|
findNext: (input: { query: string }) => boolean;
|
||||||
|
findPrevious: (input: { query: string }) => boolean;
|
||||||
|
clearFindDecorations: () => void;
|
||||||
|
onFindResultsChanged: (listener: (event: TerminalFindResultChangeEvent) => void) => () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCROLLBAR_HANDLE_WIDTH_IDLE = 6;
|
const SCROLLBAR_HANDLE_WIDTH_IDLE = 6;
|
||||||
@@ -328,6 +333,18 @@ export default function TerminalEmulator({
|
|||||||
blur: () => {
|
blur: () => {
|
||||||
runtimeRef.current?.blur();
|
runtimeRef.current?.blur();
|
||||||
},
|
},
|
||||||
|
findNext: (input: { query: string }) => {
|
||||||
|
return runtimeRef.current?.findNext(input) ?? false;
|
||||||
|
},
|
||||||
|
findPrevious: (input: { query: string }) => {
|
||||||
|
return runtimeRef.current?.findPrevious(input) ?? false;
|
||||||
|
},
|
||||||
|
clearFindDecorations: () => {
|
||||||
|
runtimeRef.current?.clearFindDecorations();
|
||||||
|
},
|
||||||
|
onFindResultsChanged: (listener: (event: TerminalFindResultChangeEvent) => void) => {
|
||||||
|
return runtimeRef.current?.onFindResultsChanged(listener) ?? (() => {});
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,452 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import React, { act, useImperativeHandle } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { TerminalPane } from "@/components/terminal-pane";
|
||||||
|
import type { TerminalEmulatorHandle } from "@/components/terminal-emulator";
|
||||||
|
import {
|
||||||
|
PaneFocusProvider,
|
||||||
|
PaneProvider,
|
||||||
|
createPaneFocusContextValue,
|
||||||
|
type PaneContextValue,
|
||||||
|
} from "@/panels/pane-context";
|
||||||
|
import {
|
||||||
|
clearActivePaneFindPaneId,
|
||||||
|
handlePaneFindKeyboardAction,
|
||||||
|
setActivePaneFindPaneId,
|
||||||
|
} from "@/panels/pane-find-registry";
|
||||||
|
|
||||||
|
interface FindResultChange {
|
||||||
|
resultIndex: number;
|
||||||
|
resultCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TerminalKeyInput {
|
||||||
|
key: string;
|
||||||
|
ctrl: boolean;
|
||||||
|
shift: boolean;
|
||||||
|
alt: boolean;
|
||||||
|
meta: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MockTerminalEmulatorProps {
|
||||||
|
testId?: string;
|
||||||
|
onTerminalKey?: (input: TerminalKeyInput) => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { client, findListeners, terminalProps, theme, terminalHandle, resetTerminalHandle } =
|
||||||
|
vi.hoisted(() => {
|
||||||
|
const listeners: Array<(event: FindResultChange) => void> = [];
|
||||||
|
const handle = {
|
||||||
|
writeOutput: vi.fn(),
|
||||||
|
restoreOutput: vi.fn(),
|
||||||
|
renderSnapshot: vi.fn(),
|
||||||
|
clear: vi.fn(),
|
||||||
|
blur: vi.fn(),
|
||||||
|
findNext: vi.fn(() => true),
|
||||||
|
findPrevious: vi.fn(() => true),
|
||||||
|
clearFindDecorations: vi.fn(),
|
||||||
|
onFindResultsChanged: vi.fn((listener: (event: FindResultChange) => void) => {
|
||||||
|
listeners.push(listener);
|
||||||
|
return () => {
|
||||||
|
const index = listeners.indexOf(listener);
|
||||||
|
if (index >= 0) {
|
||||||
|
listeners.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
client: {
|
||||||
|
on: vi.fn(() => () => {}),
|
||||||
|
sendTerminalInput: vi.fn(),
|
||||||
|
},
|
||||||
|
findListeners: listeners,
|
||||||
|
terminalProps: {
|
||||||
|
current: null as null | MockTerminalEmulatorProps,
|
||||||
|
},
|
||||||
|
terminalHandle: handle,
|
||||||
|
resetTerminalHandle: () => {
|
||||||
|
handle.writeOutput.mockClear();
|
||||||
|
handle.renderSnapshot.mockClear();
|
||||||
|
handle.clear.mockClear();
|
||||||
|
handle.findNext.mockClear();
|
||||||
|
handle.findPrevious.mockClear();
|
||||||
|
handle.clearFindDecorations.mockClear();
|
||||||
|
handle.onFindResultsChanged.mockClear();
|
||||||
|
listeners.splice(0);
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
|
||||||
|
fontSize: { xs: 11, sm: 13 },
|
||||||
|
fontWeight: { medium: "500" as const },
|
||||||
|
borderRadius: { md: 6 },
|
||||||
|
colors: {
|
||||||
|
background: "#000",
|
||||||
|
border: "#333",
|
||||||
|
destructive: "#f43f5e",
|
||||||
|
foreground: "#fff",
|
||||||
|
foregroundMuted: "#aaa",
|
||||||
|
primary: "#0a84ff",
|
||||||
|
surface0: "#111",
|
||||||
|
surface1: "#222",
|
||||||
|
surface2: "#333",
|
||||||
|
terminal: {
|
||||||
|
background: "#000",
|
||||||
|
foreground: "#fff",
|
||||||
|
cursor: "#fff",
|
||||||
|
cursorAccent: "#000",
|
||||||
|
selectionBackground: "#444",
|
||||||
|
selectionForeground: "#fff",
|
||||||
|
black: "#000",
|
||||||
|
red: "#f00",
|
||||||
|
green: "#0f0",
|
||||||
|
yellow: "#ff0",
|
||||||
|
blue: "#00f",
|
||||||
|
magenta: "#f0f",
|
||||||
|
cyan: "#0ff",
|
||||||
|
white: "#fff",
|
||||||
|
brightBlack: "#555",
|
||||||
|
brightRed: "#f55",
|
||||||
|
brightGreen: "#5f5",
|
||||||
|
brightYellow: "#ff5",
|
||||||
|
brightBlue: "#55f",
|
||||||
|
brightMagenta: "#f5f",
|
||||||
|
brightCyan: "#5ff",
|
||||||
|
brightWhite: "#fff",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("react-native", () => {
|
||||||
|
const MockView = ({ children, testID }: { children?: React.ReactNode; testID?: string }) =>
|
||||||
|
React.createElement("div", { "data-testid": testID }, children);
|
||||||
|
const MockText = ({ children }: { children?: React.ReactNode }) =>
|
||||||
|
React.createElement("span", null, children);
|
||||||
|
const MockPressable = ({
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
onPress,
|
||||||
|
testID,
|
||||||
|
}: {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
onPress?: () => void;
|
||||||
|
testID?: string;
|
||||||
|
}) =>
|
||||||
|
React.createElement(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
"data-testid": testID,
|
||||||
|
disabled,
|
||||||
|
onClick: () => {
|
||||||
|
if (!disabled) {
|
||||||
|
onPress?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: "button",
|
||||||
|
},
|
||||||
|
children,
|
||||||
|
);
|
||||||
|
const MockTextInput = React.forwardRef<
|
||||||
|
HTMLInputElement,
|
||||||
|
{
|
||||||
|
value?: string;
|
||||||
|
onChangeText?: (text: string) => void;
|
||||||
|
onKeyPress?: (event: {
|
||||||
|
nativeEvent: { key: string; shiftKey?: boolean };
|
||||||
|
preventDefault: () => void;
|
||||||
|
}) => void;
|
||||||
|
testID?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
>(function TextInput({ value, onChangeText, onKeyPress, testID, placeholder }, ref) {
|
||||||
|
return React.createElement("input", {
|
||||||
|
"data-testid": testID,
|
||||||
|
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
onChangeText?.(event.currentTarget.value),
|
||||||
|
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) =>
|
||||||
|
onKeyPress?.({
|
||||||
|
nativeEvent: { key: event.key, shiftKey: event.shiftKey },
|
||||||
|
preventDefault: () => event.preventDefault(),
|
||||||
|
}),
|
||||||
|
placeholder,
|
||||||
|
ref,
|
||||||
|
value: value ?? "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ActivityIndicator: () => React.createElement("span", { "data-testid": "activity" }),
|
||||||
|
Platform: {
|
||||||
|
OS: "web",
|
||||||
|
select: (options: Record<string, unknown>) => options.web ?? options.default,
|
||||||
|
},
|
||||||
|
Pressable: MockPressable,
|
||||||
|
ScrollView: MockView,
|
||||||
|
Text: MockText,
|
||||||
|
TextInput: MockTextInput,
|
||||||
|
View: MockView,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("react-native-reanimated", () => ({
|
||||||
|
default: {
|
||||||
|
View: ({ children }: { children?: React.ReactNode }) =>
|
||||||
|
React.createElement("div", null, children),
|
||||||
|
},
|
||||||
|
runOnJS: (fn: () => void) => fn,
|
||||||
|
useAnimatedReaction: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native-unistyles", () => ({
|
||||||
|
StyleSheet: {
|
||||||
|
absoluteFillObject: {},
|
||||||
|
hairlineWidth: 1,
|
||||||
|
create: (factory: unknown) =>
|
||||||
|
Object.prototype.toString.call(factory) === "[object Function]"
|
||||||
|
? (factory as (value: unknown) => unknown)(theme)
|
||||||
|
: factory,
|
||||||
|
},
|
||||||
|
useUnistyles: () => ({ theme }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("lucide-react-native", () => {
|
||||||
|
const createIcon = (name: string) =>
|
||||||
|
function Icon() {
|
||||||
|
return React.createElement("span", { "data-icon": name });
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
ChevronDown: createIcon("ChevronDown"),
|
||||||
|
ChevronUp: createIcon("ChevronUp"),
|
||||||
|
X: createIcon("X"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/runtime/host-runtime", () => ({
|
||||||
|
useHostRuntimeClient: () => client,
|
||||||
|
useHostRuntimeIsConnected: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-app-visible", () => ({
|
||||||
|
useAppVisible: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-keyboard-shift-style", () => ({
|
||||||
|
useKeyboardShiftStyle: () => ({
|
||||||
|
shift: { value: 0 },
|
||||||
|
style: null,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-settings", () => ({
|
||||||
|
useAppSettings: () => ({ settings: { terminalScrollbackLines: 1000 } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/constants/layout", () => ({
|
||||||
|
useIsCompactFormFactor: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/panel-store", () => ({
|
||||||
|
usePanelStore: (
|
||||||
|
selector: (state: { mobileView: string; showMobileAgentList: () => void }) => unknown,
|
||||||
|
) => selector({ mobileView: "agent", showMobileAgentList: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/terminal/runtime/workspace-terminal-session", () => ({
|
||||||
|
getWorkspaceTerminalSession: () => ({
|
||||||
|
snapshots: {
|
||||||
|
clear: vi.fn(),
|
||||||
|
get: vi.fn(() => null),
|
||||||
|
set: vi.fn(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/terminal/runtime/terminal-stream-controller", () => ({
|
||||||
|
TerminalStreamController: class {
|
||||||
|
dispose() {}
|
||||||
|
setTerminal() {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/terminal-emulator", () => ({
|
||||||
|
default: React.forwardRef<TerminalEmulatorHandle, MockTerminalEmulatorProps>(
|
||||||
|
function TerminalEmulator(props, ref) {
|
||||||
|
terminalProps.current = props;
|
||||||
|
useImperativeHandle(ref, () => terminalHandle);
|
||||||
|
return React.createElement("div", { "data-testid": props.testId ?? "terminal-surface" });
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const paneInstanceId = "server-a:workspace-a:left";
|
||||||
|
const paneContext: PaneContextValue = {
|
||||||
|
serverId: "server-a",
|
||||||
|
workspaceId: "workspace-a",
|
||||||
|
paneInstanceId,
|
||||||
|
tabId: "terminal",
|
||||||
|
target: { kind: "terminal", terminalId: "terminal-a" },
|
||||||
|
openTab: () => {},
|
||||||
|
closeCurrentTab: () => {},
|
||||||
|
retargetCurrentTab: () => {},
|
||||||
|
openFileInWorkspace: () => {},
|
||||||
|
openImportSheet: () => {},
|
||||||
|
};
|
||||||
|
const paneFocus = createPaneFocusContextValue({
|
||||||
|
isPaneFocused: true,
|
||||||
|
isWorkspaceFocused: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLElement | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||||
|
resetTerminalHandle();
|
||||||
|
client.sendTerminalInput.mockClear();
|
||||||
|
client.on.mockClear();
|
||||||
|
terminalProps.current = null;
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
clearActivePaneFindPaneId(paneInstanceId);
|
||||||
|
root = null;
|
||||||
|
container?.remove();
|
||||||
|
container = null;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderTerminalPane(): void {
|
||||||
|
act(() => {
|
||||||
|
root?.render(
|
||||||
|
<PaneProvider value={paneContext}>
|
||||||
|
<PaneFocusProvider value={paneFocus}>
|
||||||
|
<TerminalPane
|
||||||
|
serverId="server-a"
|
||||||
|
cwd="/repo"
|
||||||
|
terminalId="terminal-a"
|
||||||
|
isWorkspaceFocused
|
||||||
|
isPaneFocused
|
||||||
|
onOpenFileExplorer={vi.fn()}
|
||||||
|
onOpenWorkspaceFile={vi.fn()}
|
||||||
|
/>
|
||||||
|
</PaneFocusProvider>
|
||||||
|
</PaneProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
setActivePaneFindPaneId(paneInstanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputElement(): HTMLInputElement {
|
||||||
|
const input = container?.querySelector('[data-testid="pane-find-input"]');
|
||||||
|
expect(input).toBeInstanceOf(HTMLInputElement);
|
||||||
|
return input as HTMLInputElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeInput(value: string): void {
|
||||||
|
const input = inputElement();
|
||||||
|
act(() => {
|
||||||
|
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||||
|
valueSetter?.call(input, value);
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pressFindKey(key: string, shiftKey = false): void {
|
||||||
|
act(() => {
|
||||||
|
inputElement().dispatchEvent(new KeyboardEvent("keydown", { key, shiftKey, bubbles: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function click(testId: string): void {
|
||||||
|
const element = container?.querySelector(`[data-testid="${testId}"]`);
|
||||||
|
expect(element).toBeInstanceOf(HTMLElement);
|
||||||
|
act(() => {
|
||||||
|
element?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitFindResults(event: FindResultChange): void {
|
||||||
|
act(() => {
|
||||||
|
for (const listener of findListeners) {
|
||||||
|
listener(event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TerminalPane find", () => {
|
||||||
|
it("searches through xterm, navigates matches, and clears decorations", () => {
|
||||||
|
renderTerminalPane();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
|
||||||
|
});
|
||||||
|
changeInput("needle");
|
||||||
|
expect(container?.textContent).toContain("Searching...");
|
||||||
|
emitFindResults({ resultIndex: 2, resultCount: 7 });
|
||||||
|
|
||||||
|
expect(terminalHandle.findNext).toHaveBeenCalledWith({ query: "needle" });
|
||||||
|
expect(container?.textContent).toContain("3 / 7");
|
||||||
|
|
||||||
|
click("pane-find-next");
|
||||||
|
expect(terminalHandle.findNext).toHaveBeenLastCalledWith({ query: "needle" });
|
||||||
|
expect(container?.textContent).toContain("Searching...");
|
||||||
|
emitFindResults({ resultIndex: 3, resultCount: 7 });
|
||||||
|
|
||||||
|
click("pane-find-prev");
|
||||||
|
expect(terminalHandle.findPrevious).toHaveBeenLastCalledWith({ query: "needle" });
|
||||||
|
emitFindResults({ resultIndex: 2, resultCount: 7 });
|
||||||
|
|
||||||
|
pressFindKey("Enter");
|
||||||
|
expect(terminalHandle.findNext).toHaveBeenLastCalledWith({ query: "needle" });
|
||||||
|
emitFindResults({ resultIndex: 3, resultCount: 7 });
|
||||||
|
|
||||||
|
pressFindKey("Enter", true);
|
||||||
|
expect(terminalHandle.findPrevious).toHaveBeenLastCalledWith({ query: "needle" });
|
||||||
|
|
||||||
|
changeInput("");
|
||||||
|
expect(terminalHandle.clearFindDecorations).toHaveBeenCalledTimes(1);
|
||||||
|
expect(container?.textContent).toContain("0 / 0");
|
||||||
|
|
||||||
|
changeInput("needle");
|
||||||
|
click("pane-find-close");
|
||||||
|
expect(terminalHandle.clearFindDecorations).toHaveBeenCalledTimes(2);
|
||||||
|
expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps terminal key input flowing while the find bar is open", async () => {
|
||||||
|
renderTerminalPane();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await terminalProps.current?.onTerminalKey?.({
|
||||||
|
key: "c",
|
||||||
|
ctrl: true,
|
||||||
|
shift: false,
|
||||||
|
alt: false,
|
||||||
|
meta: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.sendTerminalInput).toHaveBeenCalledWith("terminal-a", {
|
||||||
|
type: "input",
|
||||||
|
data: "\u0003",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Pressable,
|
Pressable,
|
||||||
@@ -47,6 +47,8 @@ import {
|
|||||||
type OpenFileDisposition,
|
type OpenFileDisposition,
|
||||||
type WorkspaceFileOpenRequest,
|
type WorkspaceFileOpenRequest,
|
||||||
} from "@/workspace/file-open";
|
} from "@/workspace/file-open";
|
||||||
|
import { FindBar, usePaneFind, type PaneFindMatchState } from "@/panels/pane-find";
|
||||||
|
import type { TerminalFindResultChangeEvent } from "@/terminal/runtime/terminal-emulator-runtime";
|
||||||
|
|
||||||
interface TerminalPaneProps {
|
interface TerminalPaneProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -106,10 +108,25 @@ const EMPTY_MODIFIERS: ModifierState = {
|
|||||||
alt: false,
|
alt: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EMPTY_FIND_MATCH_STATE: PaneFindMatchState = { status: "empty" };
|
||||||
|
const NO_FIND_MATCH_STATE: PaneFindMatchState = { status: "no-match" };
|
||||||
|
const PENDING_FIND_MATCH_STATE: PaneFindMatchState = { status: "pending" };
|
||||||
|
|
||||||
function terminalScopeKey(input: { serverId: string; cwd: string }): string {
|
function terminalScopeKey(input: { serverId: string; cwd: string }): string {
|
||||||
return `${input.serverId}:${input.cwd}`;
|
return `${input.serverId}:${input.cwd}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function terminalFindStateFromResult(event: TerminalFindResultChangeEvent): PaneFindMatchState {
|
||||||
|
if (event.resultCount <= 0 || event.resultIndex < 0) {
|
||||||
|
return NO_FIND_MATCH_STATE;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: "matched",
|
||||||
|
current: event.resultIndex + 1,
|
||||||
|
total: event.resultCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface ModifierButtonProps {
|
interface ModifierButtonProps {
|
||||||
modifier: keyof ModifierState;
|
modifier: keyof ModifierState;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@@ -212,9 +229,16 @@ export function TerminalPane({
|
|||||||
win32InputMode: false,
|
win32InputMode: false,
|
||||||
});
|
});
|
||||||
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
|
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
|
||||||
|
const terminalFindQueryRef = useRef("");
|
||||||
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
||||||
const lastAutoFocusKeyRef = useRef<string | null>(null);
|
const lastAutoFocusKeyRef = useRef<string | null>(null);
|
||||||
const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId });
|
const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId });
|
||||||
|
const [terminalFindMatchState, setTerminalFindMatchStateValue] =
|
||||||
|
useState<PaneFindMatchState>(EMPTY_FIND_MATCH_STATE);
|
||||||
|
|
||||||
|
const setTerminalFindMatchState = useCallback((nextState: PaneFindMatchState) => {
|
||||||
|
setTerminalFindMatchStateValue(nextState);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
terminalIdRef.current = terminalId;
|
terminalIdRef.current = terminalId;
|
||||||
@@ -224,6 +248,19 @@ export function TerminalPane({
|
|||||||
};
|
};
|
||||||
}, [terminalId]);
|
}, [terminalId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isWorkspaceFocused || !terminalId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return emulatorRef.current?.onFindResultsChanged((event) => {
|
||||||
|
if (terminalFindQueryRef.current.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTerminalFindMatchState(terminalFindStateFromResult(event));
|
||||||
|
});
|
||||||
|
}, [isWorkspaceFocused, setTerminalFindMatchState, terminalId]);
|
||||||
|
|
||||||
const requestTerminalFocus = useCallback(() => {
|
const requestTerminalFocus = useCallback(() => {
|
||||||
setFocusRequestToken((current) => current + 1);
|
setFocusRequestToken((current) => current + 1);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -251,6 +288,43 @@ export function TerminalPane({
|
|||||||
[terminalId, terminalStreamKey, workspaceTerminalSession.snapshots],
|
[terminalId, terminalStreamKey, workspaceTerminalSession.snapshots],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const clearTerminalFind = useCallback(() => {
|
||||||
|
terminalFindQueryRef.current = "";
|
||||||
|
emulatorRef.current?.clearFindDecorations();
|
||||||
|
setTerminalFindMatchState(EMPTY_FIND_MATCH_STATE);
|
||||||
|
}, [setTerminalFindMatchState]);
|
||||||
|
|
||||||
|
const runTerminalFind = useCallback(
|
||||||
|
(query: string, direction: "next" | "previous"): PaneFindMatchState => {
|
||||||
|
terminalFindQueryRef.current = query;
|
||||||
|
if (query.length === 0) {
|
||||||
|
clearTerminalFind();
|
||||||
|
return EMPTY_FIND_MATCH_STATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const found =
|
||||||
|
direction === "previous"
|
||||||
|
? (emulatorRef.current?.findPrevious({ query }) ?? false)
|
||||||
|
: (emulatorRef.current?.findNext({ query }) ?? false);
|
||||||
|
if (!found) {
|
||||||
|
setTerminalFindMatchState(NO_FIND_MATCH_STATE);
|
||||||
|
return NO_FIND_MATCH_STATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTerminalFindMatchState(PENDING_FIND_MATCH_STATE);
|
||||||
|
return PENDING_FIND_MATCH_STATE;
|
||||||
|
},
|
||||||
|
[clearTerminalFind, setTerminalFindMatchState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const paneFind = usePaneFind({
|
||||||
|
matchState: terminalFindMatchState,
|
||||||
|
onQuery: (query) => runTerminalFind(query, "next"),
|
||||||
|
onNext: () => runTerminalFind(terminalFindQueryRef.current, "next"),
|
||||||
|
onPrev: () => runTerminalFind(terminalFindQueryRef.current, "previous"),
|
||||||
|
onClose: clearTerminalFind,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) {
|
if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) {
|
||||||
lastAutoFocusKeyRef.current = null;
|
lastAutoFocusKeyRef.current = null;
|
||||||
@@ -748,6 +822,7 @@ export function TerminalPane({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View style={containerStyle}>
|
<Animated.View style={containerStyle}>
|
||||||
|
{paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null}
|
||||||
<View style={styles.outputContainer}>
|
<View style={styles.outputContainer}>
|
||||||
{isWorkspaceFocused ? (
|
{isWorkspaceFocused ? (
|
||||||
<View style={styles.terminalGestureContainer}>
|
<View style={styles.terminalGestureContainer}>
|
||||||
|
|||||||
@@ -92,10 +92,35 @@ export interface DesktopBrowserShortcutEvent {
|
|||||||
action: "focus-url";
|
action: "focus-url";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DesktopBrowserFindOptions {
|
||||||
|
forward?: boolean;
|
||||||
|
findNext?: boolean;
|
||||||
|
matchCase?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DesktopBrowserFindAction = "clearSelection" | "keepSelection" | "activateSelection";
|
||||||
|
|
||||||
|
export interface DesktopBrowserFoundInPageResult {
|
||||||
|
requestId?: number;
|
||||||
|
activeMatchOrdinal?: number;
|
||||||
|
matches?: number;
|
||||||
|
finalUpdate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DesktopBrowserBridge {
|
export interface DesktopBrowserBridge {
|
||||||
setWorkspaceActiveBrowser?: (browserId: string | null) => Promise<void>;
|
setWorkspaceActiveBrowser?: (browserId: string | null) => Promise<void>;
|
||||||
openDevTools?: (browserId: string) => Promise<unknown>;
|
openDevTools?: (browserId: string) => Promise<unknown>;
|
||||||
clearPartition?: (browserId: string) => Promise<void>;
|
clearPartition?: (browserId: string) => Promise<void>;
|
||||||
|
findInPage: (
|
||||||
|
browserId: string,
|
||||||
|
text: string,
|
||||||
|
options?: DesktopBrowserFindOptions,
|
||||||
|
) => Promise<number | null> | number | null;
|
||||||
|
stopFindInPage: (browserId: string, action: DesktopBrowserFindAction) => Promise<void> | void;
|
||||||
|
onFoundInPage: (
|
||||||
|
browserId: string,
|
||||||
|
listener: (result: DesktopBrowserFoundInPageResult) => void,
|
||||||
|
) => Promise<() => void> | (() => void);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DesktopInvokeBridge {
|
export interface DesktopInvokeBridge {
|
||||||
|
|||||||
@@ -27,6 +27,25 @@ import {
|
|||||||
navigateToLastWorkspace,
|
navigateToLastWorkspace,
|
||||||
useActiveWorkspaceSelection,
|
useActiveWorkspaceSelection,
|
||||||
} from "@/stores/navigation-active-workspace-store";
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
|
import { handlePaneFindKeyboardAction } from "@/panels/pane-find-registry";
|
||||||
|
|
||||||
|
type KeyboardShortcutResult = ReturnType<typeof resolveKeyboardShortcut>;
|
||||||
|
|
||||||
|
function applyPendingChordEventHandling(event: KeyboardEvent, result: KeyboardShortcutResult) {
|
||||||
|
if (result.preventDefault && !result.match) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHandledShortcutEventHandling(event: KeyboardEvent, result: KeyboardShortcutResult) {
|
||||||
|
if (result.preventDefault || result.match?.preventDefault) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
if (result.preventDefault || result.match?.stopPropagation) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useKeyboardShortcuts({
|
export function useKeyboardShortcuts({
|
||||||
enabled,
|
enabled,
|
||||||
@@ -94,6 +113,8 @@ export function useKeyboardShortcuts({
|
|||||||
return false;
|
return false;
|
||||||
case "dispatch":
|
case "dispatch":
|
||||||
return keyboardActionDispatcher.dispatch(action.action);
|
return keyboardActionDispatcher.dispatch(action.action);
|
||||||
|
case "pane-find-open":
|
||||||
|
return handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
|
||||||
case "navigate-workspace":
|
case "navigate-workspace":
|
||||||
navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname });
|
navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname });
|
||||||
return true;
|
return true;
|
||||||
@@ -182,10 +203,7 @@ export function useKeyboardShortcuts({
|
|||||||
|
|
||||||
chordStateRef.current = result.nextChordState;
|
chordStateRef.current = result.nextChordState;
|
||||||
|
|
||||||
if (result.preventDefault) {
|
applyPendingChordEventHandling(event, result);
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result.match) {
|
if (!result.match) {
|
||||||
return;
|
return;
|
||||||
@@ -208,12 +226,7 @@ export function useKeyboardShortcuts({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.match.preventDefault) {
|
applyHandledShortcutEventHandling(event, result);
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
if (result.match.stopPropagation) {
|
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyUp = (event: KeyboardEvent) => {
|
const handleKeyUp = (event: KeyboardEvent) => {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export type KeyboardActionId =
|
|||||||
| "agent.new"
|
| "agent.new"
|
||||||
| "workspace.tab.new"
|
| "workspace.tab.new"
|
||||||
| "workspace.tab.close.current"
|
| "workspace.tab.close.current"
|
||||||
|
| "workspace.find.open"
|
||||||
| "workspace.tab.navigate.index"
|
| "workspace.tab.navigate.index"
|
||||||
| "workspace.tab.navigate.relative"
|
| "workspace.tab.navigate.relative"
|
||||||
| "workspace.pane.split.right"
|
| "workspace.pane.split.right"
|
||||||
|
|||||||
@@ -206,6 +206,18 @@ describe("keyboard-shortcuts", () => {
|
|||||||
context: { isMac: true, isDesktop: true },
|
context: { isMac: true, isDesktop: true },
|
||||||
action: "workspace.tab.close.current",
|
action: "workspace.tab.close.current",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "matches Cmd+F to open find in the focused pane",
|
||||||
|
event: { key: "f", code: "KeyF", metaKey: true },
|
||||||
|
context: { isMac: true, focusScope: "terminal" },
|
||||||
|
action: "workspace.find.open",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "matches Ctrl+F to open find in the focused pane on non-mac",
|
||||||
|
event: { key: "f", code: "KeyF", ctrlKey: true },
|
||||||
|
context: { isMac: false, focusScope: "terminal" },
|
||||||
|
action: "workspace.find.open",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "matches Ctrl+W to close current tab on non-mac desktop",
|
name: "matches Ctrl+W to close current tab on non-mac desktop",
|
||||||
event: { key: "w", code: "KeyW", ctrlKey: true },
|
event: { key: "w", code: "KeyW", ctrlKey: true },
|
||||||
@@ -540,6 +552,7 @@ describe("keyboard-shortcut help sections", () => {
|
|||||||
"workspace-jump-index": ["alt", "1-9"],
|
"workspace-jump-index": ["alt", "1-9"],
|
||||||
"workspace-tab-jump-index": ["alt", "shift", "1-9"],
|
"workspace-tab-jump-index": ["alt", "shift", "1-9"],
|
||||||
"workspace-tab-close-current": ["alt", "shift", "W"],
|
"workspace-tab-close-current": ["alt", "shift", "W"],
|
||||||
|
"workspace-find-open": ["mod", "F"],
|
||||||
"workspace-pane-split-right": ["mod", "\\"],
|
"workspace-pane-split-right": ["mod", "\\"],
|
||||||
"workspace-pane-close": ["mod", "shift", "W"],
|
"workspace-pane-close": ["mod", "shift", "W"],
|
||||||
},
|
},
|
||||||
@@ -553,6 +566,7 @@ describe("keyboard-shortcut help sections", () => {
|
|||||||
"workspace-jump-index": ["mod", "1-9"],
|
"workspace-jump-index": ["mod", "1-9"],
|
||||||
"workspace-tab-jump-index": ["mod", "alt", "1-9"],
|
"workspace-tab-jump-index": ["mod", "alt", "1-9"],
|
||||||
"workspace-tab-close-current": ["meta", "W"],
|
"workspace-tab-close-current": ["meta", "W"],
|
||||||
|
"workspace-find-open": ["mod", "F"],
|
||||||
"workspace-pane-split-right": ["mod", "\\"],
|
"workspace-pane-split-right": ["mod", "\\"],
|
||||||
"workspace-pane-close": ["mod", "shift", "W"],
|
"workspace-pane-close": ["mod", "shift", "W"],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -250,6 +250,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- Find in pane ---
|
||||||
|
{
|
||||||
|
id: "workspace-find-open-cmd-f-mac",
|
||||||
|
action: "workspace.find.open",
|
||||||
|
combo: "Cmd+F",
|
||||||
|
when: { mac: true, commandCenter: false },
|
||||||
|
help: {
|
||||||
|
id: "workspace-find-open",
|
||||||
|
section: "tabs-panes",
|
||||||
|
label: "Find in pane",
|
||||||
|
keys: ["mod", "F"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "workspace-find-open-ctrl-f-non-mac",
|
||||||
|
action: "workspace.find.open",
|
||||||
|
combo: "Ctrl+F",
|
||||||
|
when: { mac: false, commandCenter: false },
|
||||||
|
help: {
|
||||||
|
id: "workspace-find-open",
|
||||||
|
section: "tabs-panes",
|
||||||
|
label: "Find in pane",
|
||||||
|
keys: ["mod", "F"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// --- Workspace index jump ---
|
// --- Workspace index jump ---
|
||||||
{
|
{
|
||||||
id: "workspace-navigate-index-cmd-digit-mac",
|
id: "workspace-navigate-index-cmd-digit-mac",
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ export type ShortcutAction =
|
|||||||
| { kind: "open-project-picker" }
|
| { kind: "open-project-picker" }
|
||||||
| { kind: "callback"; name: ShortcutCallbackName }
|
| { kind: "callback"; name: ShortcutCallbackName }
|
||||||
| { kind: "command-center-toggle"; nextOpen: boolean }
|
| { kind: "command-center-toggle"; nextOpen: boolean }
|
||||||
| { kind: "shortcuts-dialog-toggle"; nextOpen: boolean };
|
| { kind: "shortcuts-dialog-toggle"; nextOpen: boolean }
|
||||||
|
| { kind: "pane-find-open" };
|
||||||
|
|
||||||
const NONE: ShortcutAction = { kind: "none" };
|
const NONE: ShortcutAction = { kind: "none" };
|
||||||
|
|
||||||
@@ -201,6 +202,8 @@ export function routeKeyboardShortcut(
|
|||||||
return { kind: "command-center-toggle", nextOpen: !ctx.commandCenterOpen };
|
return { kind: "command-center-toggle", nextOpen: !ctx.commandCenterOpen };
|
||||||
case "shortcuts.dialog.toggle":
|
case "shortcuts.dialog.toggle":
|
||||||
return { kind: "shortcuts-dialog-toggle", nextOpen: !ctx.shortcutsDialogOpen };
|
return { kind: "shortcuts-dialog-toggle", nextOpen: !ctx.shortcutsDialogOpen };
|
||||||
|
case "workspace.find.open":
|
||||||
|
return { kind: "pane-find-open" };
|
||||||
default:
|
default:
|
||||||
return NONE;
|
return NONE;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
|||||||
export interface PaneContextValue {
|
export interface PaneContextValue {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
|
paneInstanceId: string | null;
|
||||||
tabId: string;
|
tabId: string;
|
||||||
target: WorkspaceTabTarget;
|
target: WorkspaceTabTarget;
|
||||||
openTab: (target: WorkspaceTabTarget) => void;
|
openTab: (target: WorkspaceTabTarget) => void;
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearActivePaneFindPaneId,
|
||||||
|
createPaneFindRegistry,
|
||||||
|
handlePaneFindKeyboardAction,
|
||||||
|
paneFindRegistry,
|
||||||
|
setActivePaneFindPaneId,
|
||||||
|
type PaneFindController,
|
||||||
|
} from "@/panels/pane-find-registry";
|
||||||
|
|
||||||
|
function createController(input?: { openResult?: boolean }): PaneFindController {
|
||||||
|
return {
|
||||||
|
openFind: vi.fn(() => input?.openResult ?? true),
|
||||||
|
closeFind: vi.fn(() => true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("pane find registry", () => {
|
||||||
|
it("routes open find to the active pane instance", () => {
|
||||||
|
const activePaneId = { current: "server:workspace:left" };
|
||||||
|
const registry = createPaneFindRegistry({
|
||||||
|
getActivePaneId: () => activePaneId.current,
|
||||||
|
});
|
||||||
|
const left = createController({ openResult: false });
|
||||||
|
const right = createController({ openResult: true });
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
paneId: "server:workspace:left",
|
||||||
|
controller: left,
|
||||||
|
});
|
||||||
|
registry.register({
|
||||||
|
paneId: "server:workspace:right",
|
||||||
|
controller: right,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(registry.openFindInActivePane()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops routing to a pane after it unregisters", () => {
|
||||||
|
const registry = createPaneFindRegistry({
|
||||||
|
getActivePaneId: () => "server:workspace:left",
|
||||||
|
});
|
||||||
|
const controller = createController();
|
||||||
|
|
||||||
|
const unregister = registry.register({
|
||||||
|
paneId: "server:workspace:left",
|
||||||
|
controller,
|
||||||
|
});
|
||||||
|
unregister();
|
||||||
|
|
||||||
|
expect(registry.openFindInActivePane()).toBe(false);
|
||||||
|
expect(controller.closeFind).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps split panes with the same target distinct by pane instance", () => {
|
||||||
|
const activePaneId = { current: "server:workspace:right" };
|
||||||
|
const registry = createPaneFindRegistry({
|
||||||
|
getActivePaneId: () => activePaneId.current,
|
||||||
|
});
|
||||||
|
const left = createController({ openResult: false });
|
||||||
|
const right = createController({ openResult: true });
|
||||||
|
|
||||||
|
registry.register({
|
||||||
|
paneId: "server:workspace:left",
|
||||||
|
controller: left,
|
||||||
|
});
|
||||||
|
registry.register({
|
||||||
|
paneId: "server:workspace:right",
|
||||||
|
controller: right,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(registry.openFindInActivePane()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles the keyboard find action through the active pane", () => {
|
||||||
|
const controller = createController();
|
||||||
|
const unregister = paneFindRegistry.register({
|
||||||
|
paneId: "server:workspace:left",
|
||||||
|
controller,
|
||||||
|
});
|
||||||
|
setActivePaneFindPaneId("server:workspace:left");
|
||||||
|
|
||||||
|
expect(handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" })).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(controller.openFind).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
clearActivePaneFindPaneId("server:workspace:left");
|
||||||
|
expect(controller.closeFind).toHaveBeenCalledTimes(1);
|
||||||
|
unregister();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the previous active pane when pane focus changes", () => {
|
||||||
|
const left = createController();
|
||||||
|
const right = createController();
|
||||||
|
const unregisterLeft = paneFindRegistry.register({
|
||||||
|
paneId: "server:workspace:left",
|
||||||
|
controller: left,
|
||||||
|
});
|
||||||
|
const unregisterRight = paneFindRegistry.register({
|
||||||
|
paneId: "server:workspace:right",
|
||||||
|
controller: right,
|
||||||
|
});
|
||||||
|
|
||||||
|
setActivePaneFindPaneId("server:workspace:left");
|
||||||
|
setActivePaneFindPaneId("server:workspace:right");
|
||||||
|
|
||||||
|
expect(left.closeFind).toHaveBeenCalledTimes(1);
|
||||||
|
expect(right.closeFind).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
clearActivePaneFindPaneId("server:workspace:right");
|
||||||
|
unregisterLeft();
|
||||||
|
unregisterRight();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
export interface PaneFindController {
|
||||||
|
openFind(): boolean;
|
||||||
|
closeFind(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaneFindKeyboardAction {
|
||||||
|
id: "workspace.find.open";
|
||||||
|
scope: "workspace";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegisterPaneFindInput {
|
||||||
|
paneId: string;
|
||||||
|
controller: PaneFindController;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaneFindRegistry {
|
||||||
|
register(input: RegisterPaneFindInput): () => void;
|
||||||
|
openFindInActivePane(): boolean;
|
||||||
|
closeFindInPane(paneId: string): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPaneFindRegistry(input: { getActivePaneId: () => string | null }) {
|
||||||
|
const controllers = new Map<string, PaneFindController>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
register({ paneId, controller }: RegisterPaneFindInput) {
|
||||||
|
controllers.set(paneId, controller);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (controllers.get(paneId) === controller) {
|
||||||
|
controller.closeFind();
|
||||||
|
controllers.delete(paneId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
openFindInActivePane(): boolean {
|
||||||
|
const activePaneId = input.getActivePaneId();
|
||||||
|
if (!activePaneId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return controllers.get(activePaneId)?.openFind() ?? false;
|
||||||
|
},
|
||||||
|
|
||||||
|
closeFindInPane(paneId: string): boolean {
|
||||||
|
return controllers.get(paneId)?.closeFind() ?? false;
|
||||||
|
},
|
||||||
|
} satisfies PaneFindRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
let activePaneId: string | null = null;
|
||||||
|
|
||||||
|
export const paneFindRegistry = createPaneFindRegistry({
|
||||||
|
getActivePaneId: () => activePaneId,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function createPaneFindPaneId(input: {
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
paneId: string;
|
||||||
|
}): string {
|
||||||
|
return `${input.serverId}:${input.workspaceId}:${input.paneId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setActivePaneFindPaneId(paneId: string | null) {
|
||||||
|
if (activePaneId && activePaneId !== paneId) {
|
||||||
|
paneFindRegistry.closeFindInPane(activePaneId);
|
||||||
|
}
|
||||||
|
activePaneId = paneId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearActivePaneFindPaneId(paneId: string) {
|
||||||
|
if (activePaneId === paneId) {
|
||||||
|
paneFindRegistry.closeFindInPane(paneId);
|
||||||
|
activePaneId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handlePaneFindKeyboardAction(action: PaneFindKeyboardAction): boolean {
|
||||||
|
void action;
|
||||||
|
return paneFindRegistry.openFindInActivePane();
|
||||||
|
}
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import React from "react";
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { Text, View } from "react-native";
|
||||||
|
import {
|
||||||
|
FindBar,
|
||||||
|
usePaneFind,
|
||||||
|
type PaneFindCommandResult,
|
||||||
|
type PaneFindMatchState,
|
||||||
|
} from "@/panels/pane-find";
|
||||||
|
import {
|
||||||
|
PaneFocusProvider,
|
||||||
|
PaneProvider,
|
||||||
|
createPaneFocusContextValue,
|
||||||
|
usePaneContext,
|
||||||
|
type PaneContextValue,
|
||||||
|
} from "@/panels/pane-context";
|
||||||
|
import {
|
||||||
|
clearActivePaneFindPaneId,
|
||||||
|
createPaneFindPaneId,
|
||||||
|
handlePaneFindKeyboardAction,
|
||||||
|
setActivePaneFindPaneId,
|
||||||
|
} from "@/panels/pane-find-registry";
|
||||||
|
import {
|
||||||
|
buildWorkspacePaneContentModel,
|
||||||
|
WorkspacePaneContent,
|
||||||
|
} from "@/screens/workspace/workspace-pane-content";
|
||||||
|
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||||
|
|
||||||
|
const { theme } = vi.hoisted(() => ({
|
||||||
|
theme: {
|
||||||
|
colors: {
|
||||||
|
border: "#333",
|
||||||
|
foreground: "#fff",
|
||||||
|
foregroundMuted: "#aaa",
|
||||||
|
surface0: "#111",
|
||||||
|
surface1: "#222",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("lucide-react-native", () => {
|
||||||
|
const createIcon = (name: string) =>
|
||||||
|
function Icon() {
|
||||||
|
return React.createElement("span", { "data-icon": name });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
ChevronDown: createIcon("ChevronDown"),
|
||||||
|
ChevronUp: createIcon("ChevronUp"),
|
||||||
|
X: createIcon("X"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("react-native", () => {
|
||||||
|
const MockView = ({ children, testID }: { children?: React.ReactNode; testID?: string }) =>
|
||||||
|
React.createElement("div", { "data-testid": testID }, children);
|
||||||
|
const MockText = ({ children }: { children?: React.ReactNode }) =>
|
||||||
|
React.createElement("span", null, children);
|
||||||
|
const MockPressable = ({
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
onPress,
|
||||||
|
testID,
|
||||||
|
}: {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
onPress?: () => void;
|
||||||
|
testID?: string;
|
||||||
|
}) =>
|
||||||
|
React.createElement(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
"data-testid": testID,
|
||||||
|
disabled,
|
||||||
|
onClick: () => {
|
||||||
|
if (!disabled) onPress?.();
|
||||||
|
},
|
||||||
|
type: "button",
|
||||||
|
},
|
||||||
|
children,
|
||||||
|
);
|
||||||
|
const MockTextInput = React.forwardRef<
|
||||||
|
HTMLInputElement,
|
||||||
|
{
|
||||||
|
value?: string;
|
||||||
|
onChangeText?: (text: string) => void;
|
||||||
|
onKeyPress?: (event: {
|
||||||
|
nativeEvent: { key: string; shiftKey?: boolean };
|
||||||
|
preventDefault: () => void;
|
||||||
|
}) => void;
|
||||||
|
testID?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
>(function TextInput({ value, onChangeText, onKeyPress, testID, placeholder }, ref) {
|
||||||
|
return React.createElement("input", {
|
||||||
|
"data-testid": testID,
|
||||||
|
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
onChangeText?.(event.currentTarget.value),
|
||||||
|
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) =>
|
||||||
|
onKeyPress?.({
|
||||||
|
nativeEvent: { key: event.key, shiftKey: event.shiftKey },
|
||||||
|
preventDefault: () => event.preventDefault(),
|
||||||
|
}),
|
||||||
|
placeholder,
|
||||||
|
ref,
|
||||||
|
value: value ?? "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { Pressable: MockPressable, Text: MockText, TextInput: MockTextInput, View: MockView };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("react-native-unistyles", () => ({
|
||||||
|
StyleSheet: {
|
||||||
|
hairlineWidth: 1,
|
||||||
|
create: (factory: unknown) =>
|
||||||
|
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
|
||||||
|
},
|
||||||
|
useUnistyles: () => ({ theme }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/panels/register-panels", () => ({
|
||||||
|
ensurePanelsRegistered: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/panels/panel-registry", () => ({
|
||||||
|
getPanelRegistration: () => ({
|
||||||
|
kind: "agent",
|
||||||
|
component: FakeFindPanel,
|
||||||
|
useDescriptor: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface FakeSearchController {
|
||||||
|
query: ReturnType<typeof vi.fn<(query: string) => PaneFindCommandResult>>;
|
||||||
|
next: ReturnType<typeof vi.fn<() => PaneFindCommandResult>>;
|
||||||
|
prev: ReturnType<typeof vi.fn<() => PaneFindCommandResult>>;
|
||||||
|
close: ReturnType<typeof vi.fn<() => void>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controllers = new Map<string, FakeSearchController>();
|
||||||
|
|
||||||
|
function createController(input?: { total?: number }): FakeSearchController {
|
||||||
|
const total = input?.total ?? 3;
|
||||||
|
return {
|
||||||
|
query: vi.fn((query: string) =>
|
||||||
|
query === "missing" ? { status: "no-match" } : { status: "matched", current: 1, total },
|
||||||
|
),
|
||||||
|
next: vi.fn(() => ({ status: "matched", current: 2, total })),
|
||||||
|
prev: vi.fn(() => ({ status: "matched", current: 3, total })),
|
||||||
|
close: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function FakeFindPanel() {
|
||||||
|
const paneContext = usePaneContext();
|
||||||
|
const controller = controllers.get(paneContext.paneInstanceId ?? "");
|
||||||
|
if (!controller) {
|
||||||
|
throw new Error(`Missing fake find controller for pane ${paneContext.paneInstanceId}`);
|
||||||
|
}
|
||||||
|
const paneFind = usePaneFind({
|
||||||
|
onQuery: controller.query,
|
||||||
|
onNext: controller.next,
|
||||||
|
onPrev: controller.prev,
|
||||||
|
onClose: controller.close,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
{paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null}
|
||||||
|
<Text>Pane body</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AsyncFindPanel({
|
||||||
|
matchState,
|
||||||
|
onQuery,
|
||||||
|
}: {
|
||||||
|
matchState: PaneFindMatchState;
|
||||||
|
onQuery: (query: string) => PaneFindCommandResult;
|
||||||
|
}) {
|
||||||
|
const paneFind = usePaneFind({
|
||||||
|
matchState,
|
||||||
|
onQuery,
|
||||||
|
onNext: () => undefined,
|
||||||
|
onPrev: () => undefined,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
{paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null}
|
||||||
|
<Text>Pane body</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tab: WorkspaceTabDescriptor = {
|
||||||
|
key: "agent_agent-a",
|
||||||
|
tabId: "agent_agent-a",
|
||||||
|
kind: "agent",
|
||||||
|
target: { kind: "agent", agentId: "agent-a" },
|
||||||
|
};
|
||||||
|
const leftPaneInstanceId = createPaneFindPaneId({
|
||||||
|
serverId: "server-a",
|
||||||
|
workspaceId: "workspace-a",
|
||||||
|
paneId: "left",
|
||||||
|
});
|
||||||
|
const harnessPaneContext: PaneContextValue = {
|
||||||
|
serverId: "server-a",
|
||||||
|
workspaceId: "workspace-a",
|
||||||
|
paneInstanceId: leftPaneInstanceId,
|
||||||
|
tabId: "agent_agent-a",
|
||||||
|
target: tab.target,
|
||||||
|
openTab: () => {},
|
||||||
|
closeCurrentTab: () => {},
|
||||||
|
retargetCurrentTab: () => {},
|
||||||
|
openFileInWorkspace: () => {},
|
||||||
|
openImportSheet: () => {},
|
||||||
|
};
|
||||||
|
const harnessPaneFocus = createPaneFocusContextValue({
|
||||||
|
isPaneFocused: true,
|
||||||
|
isWorkspaceFocused: true,
|
||||||
|
onFocusPane: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLElement | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
clearActivePaneFindPaneId("server-a:workspace-a:left");
|
||||||
|
clearActivePaneFindPaneId("server-a:workspace-a:right");
|
||||||
|
controllers.clear();
|
||||||
|
root = null;
|
||||||
|
container?.remove();
|
||||||
|
container = null;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderFindHarness(controller: FakeSearchController = createController()) {
|
||||||
|
controllers.set(leftPaneInstanceId, controller);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root?.render(
|
||||||
|
<PaneProvider value={harnessPaneContext}>
|
||||||
|
<PaneFocusProvider value={harnessPaneFocus}>
|
||||||
|
<FakeFindPanel />
|
||||||
|
</PaneFocusProvider>
|
||||||
|
</PaneProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
setActivePaneFindPaneId(leftPaneInstanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputElement(): HTMLInputElement {
|
||||||
|
const input = container?.querySelector('[data-testid="pane-find-input"]');
|
||||||
|
expect(input).toBeInstanceOf(HTMLInputElement);
|
||||||
|
return input as HTMLInputElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function button(testId: string): HTMLElement {
|
||||||
|
const element = container?.querySelector(`[data-testid="${testId}"]`);
|
||||||
|
expect(element).toBeInstanceOf(HTMLElement);
|
||||||
|
return element as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeInput(value: string): void {
|
||||||
|
const input = inputElement();
|
||||||
|
act(() => {
|
||||||
|
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||||
|
valueSetter?.call(input, value);
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pressKey(key: string, shiftKey = false): void {
|
||||||
|
const input = inputElement();
|
||||||
|
act(() => {
|
||||||
|
input.dispatchEvent(new KeyboardEvent("keydown", { key, shiftKey, bubbles: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("FindBar", () => {
|
||||||
|
it("opens through pane registration and focuses the query input", () => {
|
||||||
|
renderFindHarness();
|
||||||
|
|
||||||
|
expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(inputElement()).toBe(document.activeElement);
|
||||||
|
expect(container?.textContent).toContain("Find");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches query changes and renders match, empty, and no-match states", () => {
|
||||||
|
const controller = createController();
|
||||||
|
renderFindHarness(controller);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container?.textContent).toContain("0 / 0");
|
||||||
|
|
||||||
|
changeInput("abc");
|
||||||
|
expect(controller.query).toHaveBeenLastCalledWith("abc");
|
||||||
|
expect(container?.textContent).toContain("1 / 3");
|
||||||
|
|
||||||
|
changeInput("missing");
|
||||||
|
expect(container?.textContent).toContain("No matches");
|
||||||
|
|
||||||
|
changeInput("");
|
||||||
|
expect(container?.textContent).toContain("0 / 0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles next, previous, Escape, Enter, Shift+Enter, and close", () => {
|
||||||
|
const controller = createController();
|
||||||
|
renderFindHarness(controller);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
|
||||||
|
});
|
||||||
|
changeInput("abc");
|
||||||
|
|
||||||
|
pressKey("Enter");
|
||||||
|
expect(controller.next).toHaveBeenCalledTimes(1);
|
||||||
|
expect(container?.textContent).toContain("2 / 3");
|
||||||
|
|
||||||
|
pressKey("Enter", true);
|
||||||
|
expect(controller.prev).toHaveBeenCalledTimes(1);
|
||||||
|
expect(container?.textContent).toContain("3 / 3");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
button("pane-find-next").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
expect(container?.textContent).toContain("2 / 3");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
button("pane-find-prev").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
expect(container?.textContent).toContain("3 / 3");
|
||||||
|
|
||||||
|
pressKey("Escape");
|
||||||
|
expect(controller.close).toHaveBeenCalledTimes(1);
|
||||||
|
expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
button("pane-find-close").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cleans up the active find adapter on pane deactivation and unmount", () => {
|
||||||
|
const controller = createController();
|
||||||
|
renderFindHarness(controller);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
|
||||||
|
});
|
||||||
|
changeInput("abc");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
clearActivePaneFindPaneId(leftPaneInstanceId);
|
||||||
|
});
|
||||||
|
expect(controller.close).toHaveBeenCalledTimes(1);
|
||||||
|
expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
setActivePaneFindPaneId(leftPaneInstanceId);
|
||||||
|
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
|
||||||
|
});
|
||||||
|
changeInput("abc");
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
expect(controller.close).toHaveBeenCalledTimes(2);
|
||||||
|
root = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows pending match metadata until an adapter-owned async result arrives", () => {
|
||||||
|
const onQuery = vi.fn(() => undefined);
|
||||||
|
let setExternalMatchState: ((matchState: PaneFindMatchState) => void) | null = null;
|
||||||
|
|
||||||
|
function Harness() {
|
||||||
|
const [externalMatchState, setMatchState] = React.useState<PaneFindMatchState>({
|
||||||
|
status: "empty",
|
||||||
|
});
|
||||||
|
setExternalMatchState = setMatchState;
|
||||||
|
return (
|
||||||
|
<PaneProvider value={harnessPaneContext}>
|
||||||
|
<PaneFocusProvider value={harnessPaneFocus}>
|
||||||
|
<AsyncFindPanel matchState={externalMatchState} onQuery={onQuery} />
|
||||||
|
</PaneFocusProvider>
|
||||||
|
</PaneProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root?.render(<Harness />);
|
||||||
|
});
|
||||||
|
setActivePaneFindPaneId(leftPaneInstanceId);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
|
||||||
|
});
|
||||||
|
changeInput("needle");
|
||||||
|
|
||||||
|
expect(onQuery).toHaveBeenCalledWith("needle");
|
||||||
|
expect(container?.textContent).toContain("Searching...");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
setExternalMatchState?.({ status: "matched", current: 2, total: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container?.textContent).toContain("2 / 4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes open find through the focused workspace pane without replacing pane focus", () => {
|
||||||
|
const left = createController({ total: 7 });
|
||||||
|
const right = createController({ total: 5 });
|
||||||
|
const leftContent = buildWorkspacePaneContentModel({
|
||||||
|
tab,
|
||||||
|
paneId: "left",
|
||||||
|
normalizedServerId: "server-a",
|
||||||
|
normalizedWorkspaceId: "workspace-a",
|
||||||
|
onOpenTab: vi.fn(),
|
||||||
|
onCloseCurrentTab: vi.fn(),
|
||||||
|
onRetargetCurrentTab: vi.fn(),
|
||||||
|
onOpenWorkspaceFile: vi.fn(),
|
||||||
|
onOpenImportSheet: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rightContent = buildWorkspacePaneContentModel({
|
||||||
|
tab,
|
||||||
|
paneId: "right",
|
||||||
|
normalizedServerId: "server-a",
|
||||||
|
normalizedWorkspaceId: "workspace-a",
|
||||||
|
onOpenTab: vi.fn(),
|
||||||
|
onCloseCurrentTab: vi.fn(),
|
||||||
|
onRetargetCurrentTab: vi.fn(),
|
||||||
|
onOpenWorkspaceFile: vi.fn(),
|
||||||
|
onOpenImportSheet: vi.fn(),
|
||||||
|
});
|
||||||
|
const focusLeft = vi.fn();
|
||||||
|
const focusRight = vi.fn();
|
||||||
|
controllers.set("server-a:workspace-a:left", left);
|
||||||
|
controllers.set("server-a:workspace-a:right", right);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root?.render(
|
||||||
|
<View>
|
||||||
|
<WorkspacePaneContent
|
||||||
|
content={leftContent}
|
||||||
|
isPaneFocused={false}
|
||||||
|
isWorkspaceFocused
|
||||||
|
onFocusPane={focusLeft}
|
||||||
|
/>
|
||||||
|
<WorkspacePaneContent
|
||||||
|
content={rightContent}
|
||||||
|
isPaneFocused
|
||||||
|
isWorkspaceFocused
|
||||||
|
onFocusPane={focusRight}
|
||||||
|
/>
|
||||||
|
</View>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
|
||||||
|
});
|
||||||
|
|
||||||
|
changeInput("abc");
|
||||||
|
expect(container?.textContent).toContain("1 / 5");
|
||||||
|
expect(container?.textContent).not.toContain("1 / 7");
|
||||||
|
expect(focusLeft).not.toHaveBeenCalled();
|
||||||
|
expect(focusRight).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import { ChevronDown, ChevronUp, X } from "lucide-react-native";
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Pressable,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
type NativeSyntheticEvent,
|
||||||
|
type TextInputKeyPressEventData,
|
||||||
|
} from "react-native";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
|
import { usePaneContext } from "@/panels/pane-context";
|
||||||
|
import { paneFindRegistry } from "@/panels/pane-find-registry";
|
||||||
|
|
||||||
|
export type PaneFindMatchState =
|
||||||
|
| { status: "empty" }
|
||||||
|
| { status: "pending" }
|
||||||
|
| { status: "no-match" }
|
||||||
|
| { status: "matched"; current: number; total: number };
|
||||||
|
|
||||||
|
export type PaneFindCommandResult = PaneFindMatchState | void;
|
||||||
|
|
||||||
|
export interface UsePaneFindInput {
|
||||||
|
matchState?: PaneFindMatchState;
|
||||||
|
onQuery(query: string): PaneFindCommandResult;
|
||||||
|
onNext(): PaneFindCommandResult;
|
||||||
|
onPrev(): PaneFindCommandResult;
|
||||||
|
onClose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FindBarProps {
|
||||||
|
query: string;
|
||||||
|
matchState: PaneFindMatchState;
|
||||||
|
focusToken: number;
|
||||||
|
onQueryChange(query: string): void;
|
||||||
|
onNext(): void;
|
||||||
|
onPrev(): void;
|
||||||
|
onClose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePaneFindResult {
|
||||||
|
isOpen: boolean;
|
||||||
|
findBarProps: FindBarProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePaneFind(input: UsePaneFindInput): UsePaneFindResult {
|
||||||
|
const { paneInstanceId } = usePaneContext();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [matchState, setMatchState] = useState<PaneFindMatchState>({ status: "empty" });
|
||||||
|
const [focusToken, setFocusToken] = useState(0);
|
||||||
|
const onQuery = useStableEvent(input.onQuery);
|
||||||
|
const onNextInput = useStableEvent(input.onNext);
|
||||||
|
const onPrevInput = useStableEvent(input.onPrev);
|
||||||
|
const onCloseInput = useStableEvent(input.onClose);
|
||||||
|
const isOpenRef = useRef(false);
|
||||||
|
const queryRef = useRef("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isOpenRef.current = isOpen;
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
queryRef.current = query;
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !input.matchState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMatchState(input.matchState);
|
||||||
|
}, [input.matchState, isOpen]);
|
||||||
|
|
||||||
|
const openFind = useCallback(() => {
|
||||||
|
isOpenRef.current = true;
|
||||||
|
setIsOpen(true);
|
||||||
|
setFocusToken((current) => current + 1);
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeFind = useCallback(() => {
|
||||||
|
if (!isOpenRef.current && queryRef.current.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
isOpenRef.current = false;
|
||||||
|
queryRef.current = "";
|
||||||
|
setIsOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
setMatchState({ status: "empty" });
|
||||||
|
onCloseInput();
|
||||||
|
return true;
|
||||||
|
}, [onCloseInput]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!paneInstanceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return paneFindRegistry.register({
|
||||||
|
paneId: paneInstanceId,
|
||||||
|
controller: { openFind, closeFind },
|
||||||
|
});
|
||||||
|
}, [closeFind, openFind, paneInstanceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
closeFind();
|
||||||
|
};
|
||||||
|
}, [closeFind]);
|
||||||
|
|
||||||
|
const handleQueryChange = useCallback(
|
||||||
|
(nextQuery: string) => {
|
||||||
|
queryRef.current = nextQuery;
|
||||||
|
setQuery(nextQuery);
|
||||||
|
const nextState = onQuery(nextQuery);
|
||||||
|
setMatchState(
|
||||||
|
nextQuery.length === 0 ? { status: "empty" } : (nextState ?? { status: "pending" }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[onQuery],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleNext = useCallback(() => {
|
||||||
|
if (queryRef.current.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMatchState(onNextInput() ?? { status: "pending" });
|
||||||
|
}, [onNextInput]);
|
||||||
|
|
||||||
|
const handlePrev = useCallback(() => {
|
||||||
|
if (queryRef.current.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMatchState(onPrevInput() ?? { status: "pending" });
|
||||||
|
}, [onPrevInput]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
closeFind();
|
||||||
|
}, [closeFind]);
|
||||||
|
|
||||||
|
const findBarProps = useMemo(
|
||||||
|
() => ({
|
||||||
|
query,
|
||||||
|
matchState,
|
||||||
|
focusToken,
|
||||||
|
onQueryChange: handleQueryChange,
|
||||||
|
onNext: handleNext,
|
||||||
|
onPrev: handlePrev,
|
||||||
|
onClose: handleClose,
|
||||||
|
}),
|
||||||
|
[focusToken, handleClose, handleNext, handlePrev, handleQueryChange, matchState, query],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { isOpen, findBarProps };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FindBar({
|
||||||
|
query,
|
||||||
|
matchState,
|
||||||
|
focusToken,
|
||||||
|
onQueryChange,
|
||||||
|
onNext,
|
||||||
|
onPrev,
|
||||||
|
onClose,
|
||||||
|
}: FindBarProps) {
|
||||||
|
const inputRef = useRef<TextInput>(null);
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const iconColor = theme.colors?.foregroundMuted ?? "#71717a";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, [focusToken]);
|
||||||
|
|
||||||
|
const canNavigate = matchState.status === "matched";
|
||||||
|
const handleKeyPress = useCallback(
|
||||||
|
(event: NativeSyntheticEvent<TextInputKeyPressEventData & { shiftKey?: boolean }>) => {
|
||||||
|
if (event.nativeEvent.key === "Escape") {
|
||||||
|
event.preventDefault?.();
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.nativeEvent.key !== "Enter") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault?.();
|
||||||
|
if (!canNavigate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.nativeEvent.shiftKey) {
|
||||||
|
onPrev();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onNext();
|
||||||
|
},
|
||||||
|
[canNavigate, onClose, onNext, onPrev],
|
||||||
|
);
|
||||||
|
|
||||||
|
const counterText = formatMatchState(matchState);
|
||||||
|
const controlDisabled = !canNavigate;
|
||||||
|
const matchButtonStyle = useMemo(
|
||||||
|
() => [styles.iconButton, controlDisabled && styles.iconButtonDisabled],
|
||||||
|
[controlDisabled],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container} testID="pane-find-bar">
|
||||||
|
<Text style={styles.label}>Find</Text>
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
testID="pane-find-input"
|
||||||
|
value={query}
|
||||||
|
onChangeText={onQueryChange}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
placeholder="Search"
|
||||||
|
placeholderTextColor={theme.colors?.foregroundMuted ?? "#71717a"}
|
||||||
|
style={styles.input}
|
||||||
|
/>
|
||||||
|
<Text style={styles.counter}>{counterText}</Text>
|
||||||
|
<Pressable
|
||||||
|
accessibilityLabel="Previous match"
|
||||||
|
disabled={controlDisabled}
|
||||||
|
onPress={onPrev}
|
||||||
|
style={matchButtonStyle}
|
||||||
|
testID="pane-find-prev"
|
||||||
|
>
|
||||||
|
<ChevronUp size={14} color={iconColor} />
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
accessibilityLabel="Next match"
|
||||||
|
disabled={controlDisabled}
|
||||||
|
onPress={onNext}
|
||||||
|
style={matchButtonStyle}
|
||||||
|
testID="pane-find-next"
|
||||||
|
>
|
||||||
|
<ChevronDown size={14} color={iconColor} />
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
accessibilityLabel="Close find"
|
||||||
|
onPress={onClose}
|
||||||
|
style={styles.iconButton}
|
||||||
|
testID="pane-find-close"
|
||||||
|
>
|
||||||
|
<X size={14} color={iconColor} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMatchState(matchState: PaneFindMatchState): string {
|
||||||
|
if (matchState.status === "matched") {
|
||||||
|
return `${matchState.current} / ${matchState.total}`;
|
||||||
|
}
|
||||||
|
if (matchState.status === "no-match") {
|
||||||
|
return "No matches";
|
||||||
|
}
|
||||||
|
if (matchState.status === "pending") {
|
||||||
|
return "Searching...";
|
||||||
|
}
|
||||||
|
return "0 / 0";
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
minHeight: 36,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderBottomColor: theme.colors.border,
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 120,
|
||||||
|
height: 26,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
borderRadius: 6,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
backgroundColor: theme.colors.surface0,
|
||||||
|
fontSize: 13,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
},
|
||||||
|
counter: {
|
||||||
|
minWidth: 64,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: 12,
|
||||||
|
textAlign: "right",
|
||||||
|
},
|
||||||
|
iconButton: {
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
iconButtonDisabled: {
|
||||||
|
opacity: 0.4,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -113,6 +113,7 @@ describe("WorkspacePaneContent", () => {
|
|||||||
isInteractive: false,
|
isInteractive: false,
|
||||||
focusPane: expect.any(Function),
|
focusPane: expect.any(Function),
|
||||||
});
|
});
|
||||||
|
expect(snapshots[0]?.paneContextValue.paneInstanceId).toBeNull();
|
||||||
expect(snapshots[1]?.focus).toEqual({
|
expect(snapshots[1]?.focus).toEqual({
|
||||||
isWorkspaceFocused: true,
|
isWorkspaceFocused: true,
|
||||||
isPaneFocused: true,
|
isPaneFocused: true,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo, type ComponentType } from "react";
|
import React, { useEffect, useMemo, type ComponentType } from "react";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import {
|
import {
|
||||||
createPaneFocusContextValue,
|
createPaneFocusContextValue,
|
||||||
@@ -10,6 +10,11 @@ import { getPanelRegistration } from "@/panels/panel-registry";
|
|||||||
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||||
|
import {
|
||||||
|
clearActivePaneFindPaneId,
|
||||||
|
createPaneFindPaneId,
|
||||||
|
setActivePaneFindPaneId,
|
||||||
|
} from "@/panels/pane-find-registry";
|
||||||
|
|
||||||
export interface WorkspacePaneContentModel {
|
export interface WorkspacePaneContentModel {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -19,6 +24,7 @@ export interface WorkspacePaneContentModel {
|
|||||||
|
|
||||||
export interface BuildWorkspacePaneContentModelInput {
|
export interface BuildWorkspacePaneContentModelInput {
|
||||||
tab: WorkspaceTabDescriptor;
|
tab: WorkspaceTabDescriptor;
|
||||||
|
paneId?: string | null;
|
||||||
normalizedServerId: string;
|
normalizedServerId: string;
|
||||||
normalizedWorkspaceId: string;
|
normalizedWorkspaceId: string;
|
||||||
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
|
||||||
@@ -30,6 +36,7 @@ export interface BuildWorkspacePaneContentModelInput {
|
|||||||
|
|
||||||
export function buildWorkspacePaneContentModel({
|
export function buildWorkspacePaneContentModel({
|
||||||
tab,
|
tab,
|
||||||
|
paneId,
|
||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
onOpenTab,
|
onOpenTab,
|
||||||
@@ -41,12 +48,20 @@ export function buildWorkspacePaneContentModel({
|
|||||||
ensurePanelsRegistered();
|
ensurePanelsRegistered();
|
||||||
const registration = getPanelRegistration(tab.kind);
|
const registration = getPanelRegistration(tab.kind);
|
||||||
invariant(registration, `No panel registration for kind: ${tab.kind}`);
|
invariant(registration, `No panel registration for kind: ${tab.kind}`);
|
||||||
|
const paneInstanceId = paneId
|
||||||
|
? createPaneFindPaneId({
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
paneId,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
return {
|
return {
|
||||||
key: `${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}`,
|
key: `${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}`,
|
||||||
Component: registration.component,
|
Component: registration.component,
|
||||||
paneContextValue: {
|
paneContextValue: {
|
||||||
serverId: normalizedServerId,
|
serverId: normalizedServerId,
|
||||||
workspaceId: normalizedWorkspaceId,
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
paneInstanceId,
|
||||||
tabId: tab.tabId,
|
tabId: tab.tabId,
|
||||||
target: tab.target,
|
target: tab.target,
|
||||||
openTab: onOpenTab,
|
openTab: onOpenTab,
|
||||||
@@ -81,6 +96,16 @@ export function WorkspacePaneContent({
|
|||||||
}),
|
}),
|
||||||
[isPaneFocused, isWorkspaceFocused, onFocusPane],
|
[isPaneFocused, isWorkspaceFocused, onFocusPane],
|
||||||
);
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!paneContextValue.paneInstanceId || !isWorkspaceFocused || !isPaneFocused) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const paneInstanceId = paneContextValue.paneInstanceId;
|
||||||
|
setActivePaneFindPaneId(paneInstanceId);
|
||||||
|
return () => {
|
||||||
|
clearActivePaneFindPaneId(paneInstanceId);
|
||||||
|
};
|
||||||
|
}, [isPaneFocused, isWorkspaceFocused, paneContextValue.paneInstanceId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PaneProvider value={paneContextValue}>
|
<PaneProvider value={paneContextValue}>
|
||||||
|
|||||||
@@ -2827,6 +2827,7 @@ function WorkspaceScreenContent({
|
|||||||
}) =>
|
}) =>
|
||||||
buildWorkspacePaneContentModel({
|
buildWorkspacePaneContentModel({
|
||||||
tab: input.tab,
|
tab: input.tab,
|
||||||
|
paneId: input.paneId,
|
||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
onOpenTab: (target) => {
|
onOpenTab: (target) => {
|
||||||
|
|||||||
@@ -26,6 +26,16 @@ vi.mock("@xterm/addon-ligatures/lib/addon-ligatures.mjs", () => ({
|
|||||||
|
|
||||||
vi.mock("@xterm/addon-search", () => ({
|
vi.mock("@xterm/addon-search", () => ({
|
||||||
SearchAddon: class SearchAddon {
|
SearchAddon: class SearchAddon {
|
||||||
|
findNext(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
findPrevious(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
clearDecorations(): void {}
|
||||||
|
onDidChangeResults(): { dispose: () => void } {
|
||||||
|
return { dispose: () => {} };
|
||||||
|
}
|
||||||
dispose(): void {}
|
dispose(): void {}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -91,6 +101,15 @@ interface StubTerminal {
|
|||||||
cols?: number;
|
cols?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StubSearchAddon {
|
||||||
|
findNext: (term: string, options?: unknown) => boolean;
|
||||||
|
findPrevious: (term: string, options?: unknown) => boolean;
|
||||||
|
clearDecorations: () => void;
|
||||||
|
onDidChangeResults: (listener: (event: { resultIndex: number; resultCount: number }) => void) => {
|
||||||
|
dispose: () => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function createRuntimeWithTerminal(): {
|
function createRuntimeWithTerminal(): {
|
||||||
runtime: TerminalEmulatorRuntime;
|
runtime: TerminalEmulatorRuntime;
|
||||||
terminal: StubTerminal & {
|
terminal: StubTerminal & {
|
||||||
@@ -145,6 +164,55 @@ function decodeTerminalOutput(data: string | Uint8Array): string {
|
|||||||
return new TextDecoder().decode(data);
|
return new TextDecoder().decode(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createRuntimeWithSearchAddon(input?: {
|
||||||
|
findNextResult?: boolean;
|
||||||
|
findPreviousResult?: boolean;
|
||||||
|
}): {
|
||||||
|
runtime: TerminalEmulatorRuntime;
|
||||||
|
searchAddon: StubSearchAddon;
|
||||||
|
calls: {
|
||||||
|
findNext: Array<{ term: string; options: unknown }>;
|
||||||
|
findPrevious: Array<{ term: string; options: unknown }>;
|
||||||
|
clearDecorations: number;
|
||||||
|
subscribedListeners: Array<(event: { resultIndex: number; resultCount: number }) => void>;
|
||||||
|
disposeSubscription: number;
|
||||||
|
};
|
||||||
|
} {
|
||||||
|
const runtime = new TerminalEmulatorRuntime();
|
||||||
|
const calls = {
|
||||||
|
findNext: [] as Array<{ term: string; options: unknown }>,
|
||||||
|
findPrevious: [] as Array<{ term: string; options: unknown }>,
|
||||||
|
clearDecorations: 0,
|
||||||
|
subscribedListeners: [] as Array<(event: { resultIndex: number; resultCount: number }) => void>,
|
||||||
|
disposeSubscription: 0,
|
||||||
|
};
|
||||||
|
const searchAddon: StubSearchAddon = {
|
||||||
|
findNext: (term, options) => {
|
||||||
|
calls.findNext.push({ term, options });
|
||||||
|
return input?.findNextResult ?? true;
|
||||||
|
},
|
||||||
|
findPrevious: (term, options) => {
|
||||||
|
calls.findPrevious.push({ term, options });
|
||||||
|
return input?.findPreviousResult ?? true;
|
||||||
|
},
|
||||||
|
clearDecorations: () => {
|
||||||
|
calls.clearDecorations += 1;
|
||||||
|
},
|
||||||
|
onDidChangeResults: (listener) => {
|
||||||
|
calls.subscribedListeners.push(listener);
|
||||||
|
return {
|
||||||
|
dispose: () => {
|
||||||
|
calls.disposeSubscription += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
(runtime as unknown as { searchAddon: StubSearchAddon }).searchAddon = searchAddon;
|
||||||
|
|
||||||
|
return { runtime, searchAddon, calls };
|
||||||
|
}
|
||||||
|
|
||||||
describe("terminal-emulator-runtime", () => {
|
describe("terminal-emulator-runtime", () => {
|
||||||
const originalWindow = (globalThis as { window?: unknown }).window;
|
const originalWindow = (globalThis as { window?: unknown }).window;
|
||||||
|
|
||||||
@@ -418,4 +486,71 @@ describe("terminal-emulator-runtime", () => {
|
|||||||
|
|
||||||
expect(fitAndEmitResize).not.toHaveBeenCalled();
|
expect(fitAndEmitResize).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("delegates find navigation and clearing to the stored xterm search addon", () => {
|
||||||
|
const { runtime, calls } = createRuntimeWithSearchAddon({
|
||||||
|
findNextResult: true,
|
||||||
|
findPreviousResult: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runtime.findNext({ query: "needle" })).toBe(true);
|
||||||
|
expect(runtime.findPrevious({ query: "needle" })).toBe(false);
|
||||||
|
runtime.clearFindDecorations();
|
||||||
|
|
||||||
|
expect(calls.findNext).toEqual([
|
||||||
|
{
|
||||||
|
term: "needle",
|
||||||
|
options: {
|
||||||
|
caseSensitive: false,
|
||||||
|
decorations: {
|
||||||
|
matchBackground: "#facc15",
|
||||||
|
matchOverviewRuler: "#facc15",
|
||||||
|
activeMatchBackground: "#38bdf8",
|
||||||
|
activeMatchColorOverviewRuler: "#38bdf8",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(calls.findPrevious).toEqual([
|
||||||
|
{
|
||||||
|
term: "needle",
|
||||||
|
options: {
|
||||||
|
caseSensitive: false,
|
||||||
|
decorations: {
|
||||||
|
matchBackground: "#facc15",
|
||||||
|
matchOverviewRuler: "#facc15",
|
||||||
|
activeMatchBackground: "#38bdf8",
|
||||||
|
activeMatchColorOverviewRuler: "#38bdf8",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(calls.clearDecorations).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards xterm search result changes and disposes the subscription", () => {
|
||||||
|
const { runtime, calls } = createRuntimeWithSearchAddon();
|
||||||
|
const events: Array<{ resultIndex: number; resultCount: number }> = [];
|
||||||
|
|
||||||
|
const unsubscribe = runtime.onFindResultsChanged((event) => {
|
||||||
|
events.push(event);
|
||||||
|
});
|
||||||
|
calls.subscribedListeners[0]?.({ resultIndex: 2, resultCount: 5 });
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
expect(events).toEqual([{ resultIndex: 2, resultCount: 5 }]);
|
||||||
|
expect(calls.disposeSubscription).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps find methods inert before the search addon is mounted", () => {
|
||||||
|
const runtime = new TerminalEmulatorRuntime();
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
expect(runtime.findNext({ query: "needle" })).toBe(false);
|
||||||
|
expect(runtime.findPrevious({ query: "needle" })).toBe(false);
|
||||||
|
runtime.clearFindDecorations();
|
||||||
|
runtime.onFindResultsChanged(listener)();
|
||||||
|
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { ClipboardAddon } from "@xterm/addon-clipboard";
|
|||||||
import { FitAddon } from "@xterm/addon-fit";
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
import { ImageAddon } from "@xterm/addon-image";
|
import { ImageAddon } from "@xterm/addon-image";
|
||||||
import { SearchAddon } from "@xterm/addon-search";
|
import { SearchAddon } from "@xterm/addon-search";
|
||||||
|
import type { ISearchOptions, ISearchResultChangeEvent } from "@xterm/addon-search";
|
||||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||||
import { WebglAddon } from "@xterm/addon-webgl";
|
import { WebglAddon } from "@xterm/addon-webgl";
|
||||||
import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs";
|
import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs";
|
||||||
import { Terminal, type ITheme } from "@xterm/xterm";
|
import { Terminal, type IDisposable, type ITheme } from "@xterm/xterm";
|
||||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||||
import {
|
import {
|
||||||
type TerminalInputModeState,
|
type TerminalInputModeState,
|
||||||
@@ -61,6 +62,12 @@ export interface TerminalEmulatorRuntimeCallbacks {
|
|||||||
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
|
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TerminalFindResultChangeEvent = ISearchResultChangeEvent;
|
||||||
|
|
||||||
|
export interface TerminalFindQueryInput {
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface TerminalEmulatorRuntimeDisposables {
|
interface TerminalEmulatorRuntimeDisposables {
|
||||||
disposeInput: () => void;
|
disposeInput: () => void;
|
||||||
disconnectResizeObserver: () => void;
|
disconnectResizeObserver: () => void;
|
||||||
@@ -128,6 +135,16 @@ function prependTerminalOutput(
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TERMINAL_FIND_SEARCH_OPTIONS: ISearchOptions = {
|
||||||
|
caseSensitive: false,
|
||||||
|
decorations: {
|
||||||
|
matchBackground: "#facc15",
|
||||||
|
matchOverviewRuler: "#facc15",
|
||||||
|
activeMatchBackground: "#38bdf8",
|
||||||
|
activeMatchColorOverviewRuler: "#38bdf8",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_TERMINAL_FONT_FAMILY = [
|
const DEFAULT_TERMINAL_FONT_FAMILY = [
|
||||||
// Prefer common developer fonts, with Nerd Font variants for prompt/TUI glyphs.
|
// Prefer common developer fonts, with Nerd Font variants for prompt/TUI glyphs.
|
||||||
"JetBrains Mono",
|
"JetBrains Mono",
|
||||||
@@ -164,6 +181,7 @@ export class TerminalEmulatorRuntime {
|
|||||||
};
|
};
|
||||||
private terminal: Terminal | null = null;
|
private terminal: Terminal | null = null;
|
||||||
private fitAddon: FitAddon | null = null;
|
private fitAddon: FitAddon | null = null;
|
||||||
|
private searchAddon: SearchAddon | null = null;
|
||||||
private fitAndEmitResize: ((force: boolean) => void) | null = null;
|
private fitAndEmitResize: ((force: boolean) => void) | null = null;
|
||||||
private lastSize: { rows: number; cols: number } | null = null;
|
private lastSize: { rows: number; cols: number } | null = null;
|
||||||
private cleanup: (() => void) | null = null;
|
private cleanup: (() => void) | null = null;
|
||||||
@@ -223,6 +241,7 @@ export class TerminalEmulatorRuntime {
|
|||||||
theme: withOverviewRulerBorderHidden(input.theme),
|
theme: withOverviewRulerBorderHidden(input.theme),
|
||||||
});
|
});
|
||||||
const fitAddon = new FitAddon();
|
const fitAddon = new FitAddon();
|
||||||
|
const searchAddon = new SearchAddon({ highlightLimit: 20_000 });
|
||||||
const unicode11Addon = new Unicode11Addon();
|
const unicode11Addon = new Unicode11Addon();
|
||||||
let webglAddon: WebglAddon | null = null;
|
let webglAddon: WebglAddon | null = null;
|
||||||
let imageAddon: ImageAddon | null = null;
|
let imageAddon: ImageAddon | null = null;
|
||||||
@@ -245,7 +264,7 @@ export class TerminalEmulatorRuntime {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
terminal.loadAddon(new SearchAddon({ highlightLimit: 20_000 }));
|
terminal.loadAddon(searchAddon);
|
||||||
terminal.loadAddon(new ClipboardAddon());
|
terminal.loadAddon(new ClipboardAddon());
|
||||||
try {
|
try {
|
||||||
terminal.loadAddon(new LigaturesAddon());
|
terminal.loadAddon(new LigaturesAddon());
|
||||||
@@ -334,6 +353,7 @@ export class TerminalEmulatorRuntime {
|
|||||||
|
|
||||||
this.terminal = terminal;
|
this.terminal = terminal;
|
||||||
this.fitAddon = fitAddon;
|
this.fitAddon = fitAddon;
|
||||||
|
this.searchAddon = searchAddon;
|
||||||
window.__paseoTerminal = terminal;
|
window.__paseoTerminal = terminal;
|
||||||
|
|
||||||
const fitAndEmitResize = (force: boolean): void => {
|
const fitAndEmitResize = (force: boolean): void => {
|
||||||
@@ -687,6 +707,25 @@ export class TerminalEmulatorRuntime {
|
|||||||
this.terminal?.blur();
|
this.terminal?.blur();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findNext(input: TerminalFindQueryInput): boolean {
|
||||||
|
return this.searchAddon?.findNext(input.query, TERMINAL_FIND_SEARCH_OPTIONS) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
findPrevious(input: TerminalFindQueryInput): boolean {
|
||||||
|
return this.searchAddon?.findPrevious(input.query, TERMINAL_FIND_SEARCH_OPTIONS) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFindDecorations(): void {
|
||||||
|
this.searchAddon?.clearDecorations();
|
||||||
|
}
|
||||||
|
|
||||||
|
onFindResultsChanged(listener: (event: TerminalFindResultChangeEvent) => void): () => void {
|
||||||
|
const disposable: IDisposable | undefined = this.searchAddon?.onDidChangeResults(listener);
|
||||||
|
return () => {
|
||||||
|
disposable?.dispose();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private refreshVisibleRows(): void {
|
private refreshVisibleRows(): void {
|
||||||
const terminal = this.terminal;
|
const terminal = this.terminal;
|
||||||
if (!terminal || terminal.rows <= 0) {
|
if (!terminal || terminal.rows <= 0) {
|
||||||
@@ -739,6 +778,7 @@ export class TerminalEmulatorRuntime {
|
|||||||
}
|
}
|
||||||
this.terminal = null;
|
this.terminal = null;
|
||||||
this.fitAddon = null;
|
this.fitAddon = null;
|
||||||
|
this.searchAddon = null;
|
||||||
this.fitAndEmitResize = null;
|
this.fitAndEmitResize = null;
|
||||||
this.lastSize = null;
|
this.lastSize = null;
|
||||||
this.themeBackgroundElements = [];
|
this.themeBackgroundElements = [];
|
||||||
|
|||||||
@@ -1,16 +1,71 @@
|
|||||||
import { webContents as allWebContents, type WebContents } from "electron";
|
import { webContents as allWebContents, type WebContents } from "electron";
|
||||||
|
|
||||||
|
export const BROWSER_FOUND_IN_PAGE_EVENT = "paseo:event:browser-found-in-page";
|
||||||
|
|
||||||
const browserIdsByWebContentsId = new Map<number, string>();
|
const browserIdsByWebContentsId = new Map<number, string>();
|
||||||
|
const webContentsIdsByBrowserId = new Map<string, number>();
|
||||||
|
const ownerWebContentsIdsByBrowserId = new Map<string, number>();
|
||||||
|
const activeFindBrowserIdsByOwnerWebContentsId = new Map<number, string>();
|
||||||
|
const ownerFoundInPageListenerWebContentsIds = new Set<number>();
|
||||||
let workspaceActiveBrowserId: string | null = null;
|
let workspaceActiveBrowserId: string | null = null;
|
||||||
|
|
||||||
export function listRegisteredPaseoBrowserIds(): string[] {
|
export function listRegisteredPaseoBrowserIds(): string[] {
|
||||||
return Array.from(new Set(browserIdsByWebContentsId.values())).sort();
|
return Array.from(new Set(browserIdsByWebContentsId.values())).sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerPaseoBrowserWebContents(contents: WebContents, browserId: string): void {
|
function ensureOwnerFoundInPageListener(ownerContents: WebContents): void {
|
||||||
|
if (ownerFoundInPageListenerWebContentsIds.has(ownerContents.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ownerFoundInPageListenerWebContentsIds.add(ownerContents.id);
|
||||||
|
const handleFoundInPage = (_event: Electron.Event, result: Electron.Result): void => {
|
||||||
|
const browserId = activeFindBrowserIdsByOwnerWebContentsId.get(ownerContents.id);
|
||||||
|
if (!browserId || ownerContents.isDestroyed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ownerContents.send(BROWSER_FOUND_IN_PAGE_EVENT, {
|
||||||
|
browserId,
|
||||||
|
requestId: result.requestId,
|
||||||
|
activeMatchOrdinal: result.activeMatchOrdinal,
|
||||||
|
matches: result.matches,
|
||||||
|
finalUpdate: result.finalUpdate,
|
||||||
|
});
|
||||||
|
if (result.finalUpdate) {
|
||||||
|
activeFindBrowserIdsByOwnerWebContentsId.delete(ownerContents.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ownerContents.on("found-in-page", handleFoundInPage);
|
||||||
|
ownerContents.once("destroyed", () => {
|
||||||
|
ownerContents.removeListener("found-in-page", handleFoundInPage);
|
||||||
|
ownerFoundInPageListenerWebContentsIds.delete(ownerContents.id);
|
||||||
|
activeFindBrowserIdsByOwnerWebContentsId.delete(ownerContents.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerPaseoBrowserWebContents(
|
||||||
|
contents: WebContents,
|
||||||
|
browserId: string,
|
||||||
|
ownerContents: WebContents,
|
||||||
|
): void {
|
||||||
browserIdsByWebContentsId.set(contents.id, browserId);
|
browserIdsByWebContentsId.set(contents.id, browserId);
|
||||||
|
webContentsIdsByBrowserId.set(browserId, contents.id);
|
||||||
|
if (!ownerContents.isDestroyed()) {
|
||||||
|
ownerWebContentsIdsByBrowserId.set(browserId, ownerContents.id);
|
||||||
|
ensureOwnerFoundInPageListener(ownerContents);
|
||||||
|
}
|
||||||
contents.once("destroyed", () => {
|
contents.once("destroyed", () => {
|
||||||
browserIdsByWebContentsId.delete(contents.id);
|
browserIdsByWebContentsId.delete(contents.id);
|
||||||
|
if (webContentsIdsByBrowserId.get(browserId) === contents.id) {
|
||||||
|
webContentsIdsByBrowserId.delete(browserId);
|
||||||
|
const ownerContentsId = ownerWebContentsIdsByBrowserId.get(browserId);
|
||||||
|
ownerWebContentsIdsByBrowserId.delete(browserId);
|
||||||
|
if (
|
||||||
|
ownerContentsId &&
|
||||||
|
activeFindBrowserIdsByOwnerWebContentsId.get(ownerContentsId) === browserId
|
||||||
|
) {
|
||||||
|
activeFindBrowserIdsByOwnerWebContentsId.delete(ownerContentsId);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (workspaceActiveBrowserId === browserId) {
|
if (workspaceActiveBrowserId === browserId) {
|
||||||
workspaceActiveBrowserId = null;
|
workspaceActiveBrowserId = null;
|
||||||
}
|
}
|
||||||
@@ -29,14 +84,35 @@ export function setWorkspaceActivePaseoBrowserId(browserId: string | null): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getPaseoBrowserWebContents(browserId: string): WebContents | null {
|
export function getPaseoBrowserWebContents(browserId: string): WebContents | null {
|
||||||
for (const [contentsId, registeredBrowserId] of browserIdsByWebContentsId) {
|
const contentsId = webContentsIdsByBrowserId.get(browserId);
|
||||||
if (registeredBrowserId !== browserId) continue;
|
if (!contentsId) {
|
||||||
const contents = allWebContents.fromId(contentsId);
|
return null;
|
||||||
if (contents && !contents.isDestroyed()) {
|
}
|
||||||
return contents;
|
const contents = allWebContents.fromId(contentsId);
|
||||||
}
|
return contents && !contents.isDestroyed() ? contents : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setActivePaseoBrowserFind(browserId: string): void {
|
||||||
|
const ownerContentsId = ownerWebContentsIdsByBrowserId.get(browserId);
|
||||||
|
if (!ownerContentsId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ownerContents = allWebContents.fromId(ownerContentsId);
|
||||||
|
if (!ownerContents || ownerContents.isDestroyed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ensureOwnerFoundInPageListener(ownerContents);
|
||||||
|
activeFindBrowserIdsByOwnerWebContentsId.set(ownerContents.id, browserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearActivePaseoBrowserFind(browserId: string): void {
|
||||||
|
const ownerContentsId = ownerWebContentsIdsByBrowserId.get(browserId);
|
||||||
|
if (!ownerContentsId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activeFindBrowserIdsByOwnerWebContentsId.get(ownerContentsId) === browserId) {
|
||||||
|
activeFindBrowserIdsByOwnerWebContentsId.delete(ownerContentsId);
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWorkspaceActivePaseoBrowserWebContents(): WebContents | null {
|
export function getWorkspaceActivePaseoBrowserWebContents(): WebContents | null {
|
||||||
|
|||||||
@@ -33,10 +33,12 @@ import {
|
|||||||
import { registerOpenerHandlers } from "./features/opener.js";
|
import { registerOpenerHandlers } from "./features/opener.js";
|
||||||
import { setupApplicationMenu } from "./features/menu.js";
|
import { setupApplicationMenu } from "./features/menu.js";
|
||||||
import {
|
import {
|
||||||
|
clearActivePaseoBrowserFind,
|
||||||
getPaseoBrowserIdForWebContents,
|
getPaseoBrowserIdForWebContents,
|
||||||
getPaseoBrowserWebContents,
|
getPaseoBrowserWebContents,
|
||||||
listRegisteredPaseoBrowserIds,
|
listRegisteredPaseoBrowserIds,
|
||||||
registerPaseoBrowserWebContents,
|
registerPaseoBrowserWebContents,
|
||||||
|
setActivePaseoBrowserFind,
|
||||||
setWorkspaceActivePaseoBrowserId,
|
setWorkspaceActivePaseoBrowserId,
|
||||||
} from "./features/browser-webviews.js";
|
} from "./features/browser-webviews.js";
|
||||||
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
|
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
|
||||||
@@ -310,6 +312,48 @@ ipcMain.handle("paseo:browser:clear-partition", async (_event, browserId: unknow
|
|||||||
await session.fromPartition(partition).clearStorageData();
|
await session.fromPartition(partition).clearStorageData();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
"paseo:browser:find-in-page",
|
||||||
|
(_event, browserId: unknown, text: unknown, options: unknown): number | null => {
|
||||||
|
if (typeof browserId !== "string" || typeof text !== "string" || text.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const contents = getPaseoBrowserWebContents(browserId);
|
||||||
|
if (!contents) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
setActivePaseoBrowserFind(browserId);
|
||||||
|
const inputOptions =
|
||||||
|
options && typeof options === "object"
|
||||||
|
? (options as { forward?: unknown; findNext?: unknown; matchCase?: unknown })
|
||||||
|
: {};
|
||||||
|
return contents.findInPage(text, {
|
||||||
|
forward: inputOptions.forward !== false,
|
||||||
|
findNext: inputOptions.findNext === true,
|
||||||
|
matchCase: inputOptions.matchCase === true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
"paseo:browser:stop-find-in-page",
|
||||||
|
(_event, browserId: unknown, action: unknown): null => {
|
||||||
|
if (typeof browserId !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
action !== "clearSelection" &&
|
||||||
|
action !== "keepSelection" &&
|
||||||
|
action !== "activateSelection"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
getPaseoBrowserWebContents(browserId)?.stopFindInPage(action);
|
||||||
|
clearActivePaseoBrowserFind(browserId);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
protocol.registerSchemesAsPrivileged([
|
protocol.registerSchemesAsPrivileged([
|
||||||
{ scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } },
|
{ scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } },
|
||||||
]);
|
]);
|
||||||
@@ -431,7 +475,7 @@ async function createMainWindow(): Promise<void> {
|
|||||||
mainWindow.webContents.on("did-attach-webview", (_event, contents) => {
|
mainWindow.webContents.on("did-attach-webview", (_event, contents) => {
|
||||||
const browserId = pendingBrowserWebviewIds.shift() ?? null;
|
const browserId = pendingBrowserWebviewIds.shift() ?? null;
|
||||||
if (browserId) {
|
if (browserId) {
|
||||||
registerPaseoBrowserWebContents(contents, browserId);
|
registerPaseoBrowserWebContents(contents, browserId, mainWindow.webContents);
|
||||||
log.info("[browser-webview] registered", {
|
log.info("[browser-webview] registered", {
|
||||||
browserId,
|
browserId,
|
||||||
webContentsId: contents.id,
|
webContentsId: contents.id,
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { contextBridge, ipcRenderer, webUtils } from "electron";
|
import { contextBridge, ipcRenderer, webUtils } from "electron";
|
||||||
|
|
||||||
type EventHandler = (payload: unknown) => void;
|
type EventHandler = (payload: unknown) => void;
|
||||||
|
type BrowserFindAction = "clearSelection" | "keepSelection" | "activateSelection";
|
||||||
|
|
||||||
|
interface BrowserFoundInPageResult {
|
||||||
|
requestId: number;
|
||||||
|
activeMatchOrdinal: number;
|
||||||
|
matches: number;
|
||||||
|
finalUpdate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("paseoDesktop", {
|
contextBridge.exposeInMainWorld("paseoDesktop", {
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
@@ -69,5 +77,31 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
|
|||||||
ipcRenderer.invoke("paseo:browser:open-devtools", browserId),
|
ipcRenderer.invoke("paseo:browser:open-devtools", browserId),
|
||||||
clearPartition: (browserId: string) =>
|
clearPartition: (browserId: string) =>
|
||||||
ipcRenderer.invoke("paseo:browser:clear-partition", browserId),
|
ipcRenderer.invoke("paseo:browser:clear-partition", browserId),
|
||||||
|
findInPage: (
|
||||||
|
browserId: string,
|
||||||
|
text: string,
|
||||||
|
options?: { forward?: boolean; findNext?: boolean; matchCase?: boolean },
|
||||||
|
) => ipcRenderer.invoke("paseo:browser:find-in-page", browserId, text, options),
|
||||||
|
stopFindInPage: (browserId: string, action: BrowserFindAction) =>
|
||||||
|
ipcRenderer.invoke("paseo:browser:stop-find-in-page", browserId, action),
|
||||||
|
onFoundInPage: (
|
||||||
|
browserId: string,
|
||||||
|
listener: (result: BrowserFoundInPageResult) => void,
|
||||||
|
): (() => void) => {
|
||||||
|
const ipcListener = (
|
||||||
|
_ipcEvent: Electron.IpcRendererEvent,
|
||||||
|
payload: BrowserFoundInPageResult & { browserId?: unknown },
|
||||||
|
) => {
|
||||||
|
if (payload?.browserId !== browserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { browserId: _browserId, ...result } = payload;
|
||||||
|
listener(result);
|
||||||
|
};
|
||||||
|
ipcRenderer.on("paseo:event:browser-found-in-page", ipcListener);
|
||||||
|
return () => {
|
||||||
|
ipcRenderer.removeListener("paseo:event:browser-found-in-page", ipcListener);
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user