feat(app): add find in pane across workspace panes

This commit is contained in:
Mohamed Boudra
2026-05-31 12:45:29 +07:00
parent 526e6070f8
commit 0ecc51bd99
34 changed files with 5698 additions and 471 deletions
+2 -2
View File
@@ -8,8 +8,8 @@ npm run test:e2e --workspace=@getpaseo/app -- find-in-pane-electron.spec.ts --pr
The spec starts a fresh desktop app from the current worktree with:
- isolated `PASEO_HOME` under `/tmp/paseo-find-pane-electron-rerun/home`
- isolated Electron user data under `/tmp/paseo-find-pane-electron-rerun/electron-user-data`
- per-run isolated `PASEO_HOME` under `/tmp/paseo-find-pane-electron-rerun/home-*`
- per-run isolated Electron user data under `/tmp/paseo-find-pane-electron-rerun/electron-user-data-*`
- `PASEO_LISTEN=127.0.0.1:0`, so it must not use port `6767`
- a local HTTP page containing three `electronneedle` matches
File diff suppressed because it is too large Load Diff
+196
View File
@@ -0,0 +1,196 @@
import { expect, test, type Page } from "./fixtures";
import { waitForTabBar } from "./helpers/launcher";
import {
connectTerminalClient,
setupDeterministicPrompt,
waitForTerminalContent,
} from "./helpers/terminal-perf";
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 manual QA", () => {
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 connectTerminalClient();
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 \/ 2|2 \/ 2|Searching\.\.\./)).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,25 @@ const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
autoscrollToTopThreshold: 0,
});
const HISTORY_START_THRESHOLD_PX = 96;
const SCROLL_TO_INDEX_RETRY_DELAY_MS = 80;
interface NativeScrollToIndexFailedInfo {
index: number;
highestMeasuredFrameIndex: number;
averageItemLength: number;
}
function keyExtractor(item: { id: string }): string {
return item.id;
}
export function getNativeScrollToIndexFallbackOffset(input: NativeScrollToIndexFailedInfo) {
if (!Number.isFinite(input.averageItemLength) || input.averageItemLength <= 0) {
return 0;
}
return Math.max(0, input.averageItemLength * input.index);
}
function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrategy }) {
const {
agentId,
@@ -69,6 +83,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
});
const scrollOffsetYRef = useRef(0);
const programmaticScrollEventBudgetRef = useRef(0);
const scrollToIndexRetryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
const historyStartReadyRef = useRef(false);
@@ -87,6 +102,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}
}, []);
const clearScrollToIndexRetry = useCallback(() => {
if (scrollToIndexRetryTimeoutRef.current !== null) {
clearTimeout(scrollToIndexRetryTimeoutRef.current);
scrollToIndexRetryTimeoutRef.current = null;
}
}, []);
const markNativeViewportSettling = useCallback(() => {
clearNativeViewportSettling();
setIsNativeViewportSettling(true);
@@ -149,6 +171,37 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
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(() => {
streamViewportMetricsRef.current = {
containerKey: "native-virtualized",
@@ -161,6 +214,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
};
scrollOffsetYRef.current = 0;
clearNativeViewportSettling();
clearScrollToIndexRetry();
setIsNativeViewportSettling(false);
historyStartReadyRef.current = false;
const frame = requestAnimationFrame(() => {
@@ -169,7 +223,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
return () => {
cancelAnimationFrame(frame);
};
}, [agentId, clearNativeViewportSettling]);
}, [agentId, clearNativeViewportSettling, clearScrollToIndexRetry]);
useEffect(() => {
const keyboardEvents = [
@@ -190,8 +244,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
subscription.remove();
}
clearNativeViewportSettling();
clearScrollToIndexRetry();
};
}, [clearNativeViewportSettling, markNativeViewportSettling]);
}, [clearNativeViewportSettling, clearScrollToIndexRetry, markNativeViewportSettling]);
useEffect(() => {
bottomAnchorController.prepareForStickyContentChange();
@@ -209,6 +264,17 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
bottomAnchorController.prepareForStickyViewportChange();
markNativeViewportSettling();
},
scrollToStreamItem: (target) => {
programmaticScrollEventBudgetRef.current = 3;
if (target.source === "liveHead") {
flatListRef.current?.scrollToOffset({
offset: 0,
animated: true,
});
return;
}
scrollToHistoryIndex(target.index);
},
};
viewportRef.current = handle;
return () => {
@@ -216,7 +282,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
viewportRef.current = null;
}
};
}, [agentId, bottomAnchorController, markNativeViewportSettling, viewportRef]);
}, [
agentId,
bottomAnchorController,
markNativeViewportSettling,
scrollToHistoryIndex,
viewportRef,
]);
const handleScroll = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
@@ -360,6 +432,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
onScroll={handleScroll}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
onScrollToIndexFailed={handleScrollToIndexFailed}
maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION}
initialNumToRender={40}
maxToRenderPerBatch={40}
+33 -5
View File
@@ -1,5 +1,4 @@
import React, {
Fragment,
type CSSProperties,
useCallback,
useEffect,
@@ -67,6 +66,10 @@ function scrollElementToBottom(
});
}
function getStreamItemElementId(itemId: string): string {
return `agent-stream-row-${itemId}`;
}
function syncNearBottom(
scrollContainer: HTMLElement | null,
onNearBottomChange: (value: boolean) => void,
@@ -468,6 +471,21 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
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;
return () => {
@@ -476,7 +494,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
cancelPendingStickToBottom();
};
}, [cancelPendingStickToBottom, forceStickToBottom, scheduleStickToBottom, viewportRef]);
}, [
cancelPendingStickToBottom,
forceStickToBottom,
onNearBottomChange,
rowVirtualizer,
scheduleStickToBottom,
viewportRef,
]);
const contentContainerStyle = useMemo((): CSSProperties => {
return {
@@ -520,14 +545,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
);
const mountedHistoryRows = useMemo(() => {
return segments.historyMounted.map((item, index) => (
<Fragment key={item.id}>
<div id={getStreamItemElementId(item.id)} key={item.id}>
{renderHistoryMountedRow(item, index, segments.historyMounted)}
</Fragment>
</div>
));
}, [renderHistoryMountedRow, segments.historyMounted]);
const liveHeadRows = useMemo(() => {
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]);
const liveAuxiliary = useMemo(() => {
@@ -570,6 +597,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
<div
key={virtualRow.key}
data-index={virtualRow.index}
id={getStreamItemElementId(item.id)}
ref={measureVirtualizedRowElement}
style={renderVirtualRowStyle(virtualRow.start)}
>
@@ -42,6 +42,13 @@ export interface StreamEdgeSlotProps {
export interface StreamViewportHandle {
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
prepareForViewportChange: () => void;
scrollToStreamItem: (target: StreamScrollTarget) => void;
}
export interface StreamScrollTarget {
source: "historyVirtualized" | "historyMounted" | "liveHead";
index: number;
itemId: string;
}
export interface StreamSegmentRenderers {
+356 -2
View File
@@ -36,6 +36,7 @@ import {
CompactionMarker,
MessageOuterSpacingProvider,
type InlinePathTarget,
type MessageFindHighlight,
} from "@/components/message";
import { PlanCard } from "@/components/plan-card";
import type { StreamItem } from "@/types/stream";
@@ -58,6 +59,11 @@ import { resolveStreamRenderStrategy } from "./strategy-resolver";
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
import { CompletedTurnFooterRow, TurnFooter, type TurnContentStrategy } from "./turn-footer";
import { layoutStream, type StreamLayoutItem } from "./layout";
import {
buildAgentStreamSearchModel,
findAgentStreamSearchMatches,
type AgentStreamSearchMatch,
} from "./agent-stream-search-model";
import {
type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest,
@@ -75,6 +81,17 @@ import {
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
FindBar,
type PaneFindMatchState,
type UsePaneFindResult,
usePaneFind,
} from "@/panels/pane-find";
import {
getWorkingIndicatorDotStrength,
WORKING_INDICATOR_CYCLE_MS,
WORKING_INDICATOR_OFFSETS,
} from "@/utils/working-indicator";
import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
@@ -197,6 +214,69 @@ function renderLiveHeadStreamItem(input: {
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 {
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
prepareForViewportChange(): void;
@@ -373,6 +453,173 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
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]);
const inlineWorkingIndicatorItemId = useMemo(() => {
if (agent.status !== "running") {
return null;
}
const footerItem = baseRenderModel.segments.liveHead.find((item, index, items) => {
if (item.kind !== "assistant_message") {
return false;
}
return (
getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
}) === undefined
);
});
return footerItem?.id ?? null;
}, [agent.status, baseRenderModel.segments.liveHead, streamRenderStrategy]);
useImperativeHandle(
ref,
() => ({
@@ -423,10 +670,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
client={client}
isFirstInGroup={layoutItem.isFirstInUserGroup}
isLastInGroup={layoutItem.isLastInUserGroup}
findHighlights={findHighlightsByItemId.get(item.id)}
/>
);
},
[agent.capabilities, agentId, client, resolvedServerId],
[agent.capabilities, agentId, client, findHighlightsByItemId, resolvedServerId],
);
const renderAssistantMessageItem = useCallback(
@@ -446,11 +694,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
serverId={resolvedServerId}
client={client}
spacing={layoutItem.assistantSpacing}
findHighlights={findHighlightsByItemId.get(item.id)}
/>
</AssistantFileLinkResolverProvider>
);
},
[client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot],
[client, findHighlightsByItemId, handleInlinePathPress, resolvedServerId, toast, workspaceRoot],
);
const renderThoughtItem = useCallback(
@@ -700,6 +949,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return (
<ToolCallSheetProvider>
<View style={stylesheet.container}>
<AgentStreamFindBarSlot paneFind={paneFind} />
<MessageOuterSpacingProvider disableOuterSpacing>
{streamRenderStrategy.render({
agentId,
@@ -748,6 +998,110 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
export const AgentStreamView = memo(AgentStreamViewComponent);
AgentStreamView.displayName = "AgentStreamView";
function WorkingIndicator({ variant = "auxiliary" }: { variant?: "auxiliary" | "inline" }) {
const progress = useSharedValue(0);
useEffect(() => {
progress.value = 0;
progress.value = withRepeat(
withTiming(1, {
duration: WORKING_INDICATOR_CYCLE_MS,
easing: Easing.linear,
}),
-1,
false,
);
return () => {
cancelAnimation(progress);
progress.value = 0;
};
}, [progress]);
const translateDistance = -2;
const dotOneStyle = useAnimatedStyle(() => {
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[0]);
return {
opacity: 0.3 + strength * 0.7,
transform: [{ translateY: strength * translateDistance }],
};
});
const dotTwoStyle = useAnimatedStyle(() => {
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[1]);
return {
opacity: 0.3 + strength * 0.7,
transform: [{ translateY: strength * translateDistance }],
};
});
const dotThreeStyle = useAnimatedStyle(() => {
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[2]);
return {
opacity: 0.3 + strength * 0.7,
transform: [{ translateY: strength * translateDistance }],
};
});
const dotOneCombinedStyle = useMemo(() => [stylesheet.workingDot, dotOneStyle], [dotOneStyle]);
const dotTwoCombinedStyle = useMemo(() => [stylesheet.workingDot, dotTwoStyle], [dotTwoStyle]);
const dotThreeCombinedStyle = useMemo(
() => [stylesheet.workingDot, dotThreeStyle],
[dotThreeStyle],
);
const containerStyle =
variant === "inline"
? stylesheet.inlineWorkingIndicatorFrame
: stylesheet.workingIndicatorBubble;
return (
<View style={containerStyle}>
<View style={stylesheet.workingDotsRow}>
<Animated.View style={dotOneCombinedStyle} />
<Animated.View style={dotTwoCombinedStyle} />
<Animated.View style={dotThreeCombinedStyle} />
</View>
</View>
);
}
function InlineWorkingIndicatorSlot() {
return (
<View style={stylesheet.inlineTurnFooter} testID="turn-working-indicator">
<WorkingIndicator variant="inline" />
</View>
);
}
function AgentStreamFindBarSlot({ paneFind }: { paneFind: UsePaneFindResult }) {
return paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null;
}
// Permission Request Card Component
type TurnContentStrategy = Parameters<
typeof collectAssistantTurnContentForStreamRenderStrategy
>[0]["strategy"];
interface TurnCopyButtonSlotProps {
strategy: TurnContentStrategy;
items: StreamItem[];
startIndex: number;
}
function TurnCopyButtonSlot({ strategy, items, startIndex }: TurnCopyButtonSlotProps) {
const getContent = useCallback(
() =>
collectAssistantTurnContentForStreamRenderStrategy({
strategy,
items,
startIndex,
}),
[strategy, items, startIndex],
);
return <TurnCopyButton getContent={getContent} />;
}
interface ToolCallSlotProps extends Omit<
ComponentProps<typeof ToolCall>,
"onInlineDetailsExpandedChange"
@@ -0,0 +1,205 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import {
buildAgentStreamSearchModel,
findAgentStreamSearchMatches,
getAgentStreamItemSearchableText,
} 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 agentToolCall(id: string, detail: ToolCallDetail, seed = 1): StreamItem {
return {
kind: "tool_call",
id,
timestamp: timestamp(seed),
payload: {
source: "agent",
data: {
provider: "codex",
callId: `call-${id}`,
name: "exec_command",
status: "completed",
error: null,
detail,
},
},
};
}
describe("getAgentStreamItemSearchableText", () => {
it("extracts user, assistant, thought, activity, and todo text", () => {
expect(getAgentStreamItemSearchableText(userMessage("u1", "user text"))).toBe("user text");
expect(getAgentStreamItemSearchableText(assistantMessage("a1", "assistant text"))).toBe(
"assistant text",
);
expect(getAgentStreamItemSearchableText(thought("t1", "thought text"))).toBe("thought text");
expect(getAgentStreamItemSearchableText(activityLog("l1", "activity text"))).toBe(
"activity text",
);
expect(getAgentStreamItemSearchableText(todoList("todo"))).toBe(
"Write the red test\nMake search green",
);
});
it("searches minimal visible tool-call text and skips raw hidden payloads", () => {
const shell = agentToolCall("shell", {
type: "shell",
command: "npm run typecheck",
output: "internal output should stay out",
});
expect(getAgentStreamItemSearchableText(shell)).toBe("Shell\nnpm run typecheck");
});
it("includes special tool-call content branches that render as messages or cards", () => {
const speak: StreamItem = {
kind: "tool_call",
id: "speak",
timestamp: timestamp(1),
payload: {
source: "agent",
data: {
provider: "codex",
callId: "call-speak",
name: "speak",
status: "completed",
error: null,
detail: {
type: "unknown",
input: "spoken message",
output: null,
},
},
},
};
const plan = agentToolCall("plan", {
type: "plan",
text: "phase checklist",
});
expect(getAgentStreamItemSearchableText(speak)).toBe("spoken message");
expect(getAgentStreamItemSearchableText(plan)).toBe("Plan\nphase checklist");
});
});
describe("buildAgentStreamSearchModel", () => {
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: [thought("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"]);
});
});
@@ -0,0 +1,294 @@
import type { StreamItem, ToolCallItem } from "@/types/stream";
import { buildToolCallDisplayModel } from "@/utils/tool-call-display";
import {
findMountedWindowStart,
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
} from "./agent-stream-web-virtualization";
export type AgentStreamSearchSource = "historyVirtualized" | "historyMounted" | "liveHead";
export interface AgentStreamSearchTextSegment {
key: string;
text: string;
}
export 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;
}
export interface AgentStreamSearchModel {
entries: AgentStreamSearchEntry[];
segments: {
historyVirtualized: AgentStreamSearchEntry[];
historyMounted: AgentStreamSearchEntry[];
liveHead: AgentStreamSearchEntry[];
};
}
export interface BuildAgentStreamSearchModelInput {
platform: "web" | "native";
isMobileBreakpoint: boolean;
streamItems: StreamItem[];
streamHead: StreamItem[];
optimisticItems?: StreamItem[];
cwd?: string;
}
export interface FindAgentStreamSearchMatchesInput {
model: AgentStreamSearchModel;
query: string;
}
function compactText(parts: Array<string | undefined>): string {
return parts
.map((part) => part?.trim())
.filter((part): part is string => Boolean(part))
.join("\n");
}
function getToolCallSearchableSegments(
item: ToolCallItem,
cwd: string | undefined,
): AgentStreamSearchTextSegment[] {
if (item.payload.source === "agent") {
const { data } = item.payload;
if (
data.name === "speak" &&
data.detail.type === "unknown" &&
typeof data.detail.input === "string" &&
data.detail.input.trim()
) {
return [{ key: "text", text: data.detail.input }];
}
const display = buildToolCallDisplayModel({
name: data.name,
status: data.status,
error: data.error,
detail: data.detail,
metadata: data.metadata,
cwd,
});
const visibleText = compactText([
display.displayName,
display.summary,
data.detail.type === "plan" ? data.detail.text : undefined,
display.errorText,
]);
return visibleText ? [{ key: "tool", text: visibleText }] : [];
}
const { data } = item.payload;
const display = buildToolCallDisplayModel({
name: data.toolName,
status: data.status === "executing" ? "running" : data.status,
error: data.error,
detail: {
type: "unknown",
input: data.arguments,
output: data.result ?? null,
},
cwd,
});
const visibleText = compactText([display.displayName, display.summary, display.errorText]);
return visibleText ? [{ key: "tool", text: visibleText }] : [];
}
export function getAgentStreamItemSearchableSegments(
item: StreamItem,
options: { cwd?: string } = {},
): AgentStreamSearchTextSegment[] {
switch (item.kind) {
case "user_message":
case "assistant_message":
case "thought":
return item.text ? [{ key: "text", text: item.text }] : [];
case "activity_log":
return item.message ? [{ key: "text", text: item.message }] : [];
case "todo_list":
return item.items.map((todo, index) => ({
key: `todo:${index}`,
text: todo.text,
}));
case "tool_call":
return getToolCallSearchableSegments(item, options.cwd);
case "compaction":
return [];
}
}
export function getAgentStreamItemSearchableText(
item: StreamItem,
options: { cwd?: string } = {},
): string {
return getAgentStreamItemSearchableSegments(item, options)
.map((segment) => segment.text)
.filter((text) => text.length > 0)
.join("\n");
}
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, { cwd: input.cwd });
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;
matches.push({
id: `${entry.item.id}:${segment.key}:${occurrenceIndex}:${start}:${end}`,
entry,
segmentKey: segment.key,
occurrenceIndex,
start,
end,
});
occurrenceIndex += 1;
fromIndex = end;
}
}
}
return matches;
}
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import {
createFilePaneFindTokenSegments,
createFilePaneLineFindHighlightMap,
createFilePaneTextRenderData,
findFilePaneTextMatches,
} from "@/components/file-pane-text-render-data";
import type { FilePaneTextLineRenderData } from "@/components/file-pane-text-render-data";
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";
export interface FilePaneTextLineRenderData {
lineNumber: number;
text: string;
tokens: HighlightToken[];
}
export interface FilePaneTextRenderData {
lines: FilePaneTextLineRenderData[];
searchableText: string;
}
export 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,426 @@
/**
* @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 },
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 MockText = ({
children,
style,
}: {
children?: React.ReactNode;
style?: React.CSSProperties;
}) => React.createElement("span", { 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("@/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: () => {},
};
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(filePath = "src/example.ts") {
act(() => {
root?.render(
<PaneProvider value={paneContext}>
<PaneFocusProvider value={paneFocus}>
<FilePane serverId="server" workspaceRoot="/repo" filePath={filePath} />
</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("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("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("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);
});
});
+537 -144
View File
@@ -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 type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
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 { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
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 { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
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 { persistAttachmentFromBytes } from "@/attachments/service";
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 { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import {
FindBar,
type PaneFindMatchState,
type UsePaneFindResult,
usePaneFind,
} from "@/panels/pane-find";
interface CodeLineProps {
tokens: HighlightToken[];
segments: FilePaneFindTokenSegment[];
lineNumber: number;
gutterWidth: number;
highlighted: boolean;
matchBackgroundColor: string;
currentMatchBackgroundColor: string;
onLineRef?: (lineNumber: number, node: View | null) => void;
}
interface FilePreviewBodyProps {
@@ -46,6 +64,43 @@ interface FilePreviewBodyProps {
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 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 {
if (typeof value !== "string") {
return null;
@@ -117,11 +172,20 @@ function clampLineSelection(input: {
}
const CodeLine = React.memo(function CodeLine({
tokens,
segments,
lineNumber,
gutterWidth,
highlighted,
matchBackgroundColor,
currentMatchBackgroundColor,
onLineRef,
}: CodeLineProps) {
const setLineRef = useCallback(
(node: View | null) => {
onLineRef?.(lineNumber, node);
},
[lineNumber, onLineRef],
);
const gutterStyle = useMemo(
() => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })],
[gutterWidth],
@@ -131,19 +195,28 @@ const CodeLine = React.memo(function CodeLine({
[highlighted],
);
const keyedTokens = useMemo(
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
[tokens],
() => segments.map((segment, index) => ({ key: `${index}-${segment.text}`, segment })),
[segments],
);
return (
<View style={lineStyle}>
<View ref={setLineRef} style={lineStyle}>
<View style={gutterStyle}>
<Text numberOfLines={1} style={codeLineStyles.gutterText}>
{String(lineNumber)}
</Text>
</View>
<Text selectable style={codeLineStyles.lineText}>
{keyedTokens.map(({ key, token }) => (
<CodeLineToken key={key} token={token} />
{keyedTokens.map(({ key, segment }) => (
<CodeLineToken
key={key}
backgroundColor={getFindSegmentBackgroundColor({
currentMatchBackgroundColor,
matchBackgroundColor,
segment,
})}
style={segment.style}
text={segment.text}
/>
))}
</Text>
</View>
@@ -151,11 +224,234 @@ const CodeLine = React.memo(function CodeLine({
});
interface CodeLineTokenProps {
token: HighlightToken;
backgroundColor?: string;
style: HighlightToken["style"];
text: string;
}
function CodeLineToken({ token }: CodeLineTokenProps) {
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
function CodeLineToken({ backgroundColor, style, text }: CodeLineTokenProps) {
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) => ({
@@ -186,6 +482,161 @@ 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 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({
preview,
isLoading,
@@ -196,40 +647,55 @@ function FilePreviewBody({
}: FilePreviewBodyProps) {
const { theme } = useUnistyles();
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 markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
const isMarkdownFile =
preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart;
const previewScrollRef = useRef<RNScrollView>(null);
const fallbackScrollRef = useRef<RNScrollView>(null);
const webScrollbarStyle = useWebScrollbarStyle();
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
enabled: showDesktopWebScrollbar,
});
const highlightedLines = useMemo(() => {
const textRenderData = useMemo(() => {
if (!preview || preview.kind !== "text" || isMarkdownFile) {
return null;
}
return highlightCode(preview.content ?? "", filePath);
return createFilePaneTextRenderData(preview.content ?? "", 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 { findHighlightsByLine, paneFind } = useFilePaneFindAdapter({
textRenderData,
textScrollRefs,
});
const gutterWidth = useMemo(() => {
if (!highlightedLines) return 0;
return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.code);
}, [highlightedLines, theme.fontSize.code]);
if (!textRenderData) return 0;
return lineNumberGutterWidth(textRenderData.lines.length, theme.fontSize.code);
}, [textRenderData, theme.fontSize.code]);
const lineHeight = theme.fontSize.code * 1.45;
const lineSelection = useMemo(() => {
if (!highlightedLines) {
if (!textRenderData) {
return null;
}
return clampLineSelection({
lineStart: location.lineStart,
lineEnd: location.lineEnd,
lineCount: highlightedLines.length,
lineCount: textRenderData.lines.length,
});
}, [highlightedLines, location.lineEnd, location.lineStart]);
}, [textRenderData, location.lineEnd, location.lineStart]);
const imageSource = useMemo(
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
@@ -247,139 +713,66 @@ function FilePreviewBody({
});
}, 0);
return () => clearTimeout(timeout);
}, [lineHeight, lineSelection]);
}, [lineHeight, lineSelection, previewScrollRef]);
let content: React.ReactNode;
if (isLoading && !preview) {
return (
<View style={styles.centerState}>
content = (
<FilePaneCenterState>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file…</Text>
</View>
</FilePaneCenterState>
);
}
if (!preview) {
return (
<View style={styles.centerState}>
} else if (!preview) {
content = (
<FilePaneCenterState>
<Text style={styles.emptyText}>No preview available</Text>
</View>
</FilePaneCenterState>
);
}
if (preview.kind === "text") {
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>
} else if (preview.kind === "text") {
content = (
<FilePaneTextPreview
currentMatchBackgroundColor={currentMatchBackgroundColor}
findHighlightsByLine={findHighlightsByLine}
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}
/>
);
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>
} else if (preview.kind === "image") {
content = (
<FilePaneImagePreview
imagePreviewUri={imagePreviewUri}
imageSource={imageSource}
previewScrollRef={previewScrollRef}
scrollbar={scrollbar}
showDesktopWebScrollbar={showDesktopWebScrollbar}
/>
);
}
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>
} else {
content = (
<FilePaneCenterState>
<Text style={styles.emptyText}>Binary preview unavailable</Text>
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
</FilePaneCenterState>
);
}
return (
<View style={styles.centerState}>
<Text style={styles.emptyText}>Binary preview unavailable</Text>
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
<View style={styles.previewScrollContainer}>
<FilePaneFindBarSlot paneFind={paneFind} />
{content}
</View>
);
}
+198 -18
View File
@@ -124,6 +124,7 @@ interface UserMessageProps {
isFirstInGroup?: boolean;
isLastInGroup?: boolean;
disableOuterSpacing?: boolean;
findHighlights?: MessageFindHighlight[];
}
const MessageOuterSpacingContext = createContext(false);
@@ -158,6 +159,19 @@ const MARKDOWN_ALLOWED_IMAGE_HANDLERS = [
] as const;
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 {
children: ReactNode;
style: ReturnType<typeof createMarkdownStyles>;
@@ -209,6 +223,95 @@ const SCROLL_EDGE_EPSILON = 0.5;
export const STREAM_METADATA_FONT_SIZE = 13;
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);
}
export 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;
}
export 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() {
if (isNative) {
return;
@@ -369,6 +472,14 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
lineHeight: 22,
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: {
flexDirection: "row",
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 }) {
const uri = useAttachmentPreviewUrl(image);
const imageSource = useMemo(() => ({ uri: uri ?? "" }), [uri]);
@@ -475,6 +608,7 @@ export const UserMessage = memo(function UserMessage({
isFirstInGroup = true,
isLastInGroup = true,
disableOuterSpacing,
findHighlights,
}: UserMessageProps) {
const isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false);
@@ -498,6 +632,10 @@ export const UserMessage = memo(function UserMessage({
},
[rewindMutation],
);
const highlightedMessageSegments = useMemo(
() => createMessageFindTextSegments(message, findHighlights),
[findHighlights, message],
);
const containerStyle = useMemo(
() => [
@@ -567,7 +705,7 @@ export const UserMessage = memo(function UserMessage({
) : null}
{hasText ? (
<Text selectable style={userMessageStylesheet.text}>
{message}
<MessageFindHighlightedText segments={highlightedMessageSegments} />
</Text>
) : null}
</View>
@@ -752,6 +890,7 @@ interface AssistantMessageProps {
serverId?: string;
client?: DaemonClient | null;
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
findHighlights?: MessageFindHighlight[];
}
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
@@ -1487,6 +1626,7 @@ interface MemoizedMarkdownBlockProps {
rules: RenderRules;
parser: MarkdownIt;
onLinkPress: (url: string) => boolean;
findHighlights?: MessageFindHighlight[];
}
const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
@@ -1494,26 +1634,61 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
rules,
parser,
onLinkPress,
findHighlights,
}: 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 (
<ThemedMarkdown
uniProps={markdownStyleMapping}
rules={rules}
markdownit={parser}
onLinkPress={onLinkPress}
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
>
{text}
</ThemedMarkdown>
<MarkdownFindHighlightContext.Provider value={renderFindHighlightedText}>
<ThemedMarkdown
uniProps={markdownStyleMapping}
rules={rules}
markdownit={parser}
onLinkPress={onLinkPress}
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
>
{text}
</ThemedMarkdown>
</MarkdownFindHighlightContext.Provider>
);
});
const MarkdownFindHighlightContext = createContext<((content: string) => ReactNode) | null>(null);
interface MarkdownInheritedTextProps {
inheritedStyles: TextStyle;
textStyle: TextStyle;
style?: StyleProp<TextStyle>;
children: ReactNode;
disableFindHighlight?: boolean;
}
function MarkdownInheritedText({
@@ -1521,12 +1696,18 @@ function MarkdownInheritedText({
textStyle,
style: overrideStyle,
children,
disableFindHighlight = false,
}: MarkdownInheritedTextProps) {
const renderFindHighlightedText = useContext(MarkdownFindHighlightContext);
const style = useMemo(
() => [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 {
@@ -1559,6 +1740,7 @@ export const AssistantMessage = memo(function AssistantMessage({
serverId,
client,
spacing = "default",
findHighlights,
}: AssistantMessageProps) {
const markdownParser = useMemo(() => {
const parser = MarkdownIt({ typographer: true, linkify: true });
@@ -1696,6 +1878,7 @@ export const AssistantMessage = memo(function AssistantMessage({
key={node.key}
inheritedStyles={inheritedStyles}
textStyle={styles.code_inline}
disableFindHighlight
>
{content}
</MarkdownInheritedText>
@@ -1803,11 +1986,7 @@ export const AssistantMessage = memo(function AssistantMessage({
};
}, [client, fileLinkActions, markdownParser, serverId, workspaceRoot]);
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
const keyedBlocks = useMemo(
() => blocks.map((block, index) => ({ key: `${index}:${block.slice(0, 32)}`, block })),
[blocks],
);
const keyedBlocks = useMemo(() => createMarkdownBlocksWithOffsets(message), [message]);
const assistantContainerStyle = useMemo(
() => [
@@ -1822,7 +2001,7 @@ export const AssistantMessage = memo(function AssistantMessage({
return (
<View testID="assistant-message" style={assistantContainerStyle}>
{keyedBlocks.map(({ key, block }, index) => (
{keyedBlocks.map(({ key, block, startOffset }, index) => (
<AssistantMessageBlockContainer
key={key}
block={block}
@@ -1833,6 +2012,7 @@ export const AssistantMessage = memo(function AssistantMessage({
rules={markdownRules}
parser={markdownParser}
onLinkPress={handleMarkdownLinkPress}
findHighlights={createBlockFindHighlights(startOffset, block.length, findHighlights)}
/>
</AssistantMessageBlockContainer>
))}
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { getNativeScrollToIndexFallbackOffset } from "./stream-strategy-native";
describe("getNativeScrollToIndexFallbackOffset", () => {
it("approximates the offset for an unmeasured native FlatList row", () => {
expect(
getNativeScrollToIndexFallbackOffset({
index: 25,
highestMeasuredFrameIndex: 4,
averageItemLength: 72,
}),
).toBe(1800);
});
it("falls back to the start when the average item length is not useful", () => {
expect(
getNativeScrollToIndexFallbackOffset({
index: 25,
highestMeasuredFrameIndex: 4,
averageItemLength: 0,
}),
).toBe(0);
expect(
getNativeScrollToIndexFallbackOffset({
index: 25,
highestMeasuredFrameIndex: 4,
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 {
TerminalEmulatorRuntime,
type TerminalFindResultChangeEvent,
type TerminalOutputData,
} from "../terminal/runtime/terminal-emulator-runtime";
import type {
@@ -49,6 +50,10 @@ export interface TerminalEmulatorHandle {
renderSnapshot: (state: TerminalState | null) => void;
clear: () => 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;
@@ -307,6 +312,18 @@ export default function TerminalEmulator({
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) ?? (() => {});
},
}),
[],
);
@@ -328,6 +345,18 @@ export default function TerminalEmulator({
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,433 @@
/**
* @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;
}
const { client, findListeners, terminalProps, theme, terminalHandle, resetTerminalHandle } =
vi.hoisted(() => {
const listeners: Array<(event: FindResultChange) => void> = [];
const handle = {
writeOutput: vi.fn(),
renderSnapshot: vi.fn(),
clear: 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 | {
onTerminalKey?: (input: {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
}) => Promise<void> | void;
},
},
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" }),
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) =>
typeof factory === "function" ? (factory as (t: typeof theme) => 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("@/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, { testId?: string; onTerminalKey?: unknown }>(
function TerminalEmulator(props, ref) {
terminalProps.current = props as typeof terminalProps.current;
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: () => {},
};
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()}
/>
</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",
});
});
});
+76 -1
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActivityIndicator,
Pressable,
@@ -47,6 +47,8 @@ import {
type OpenFileDisposition,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
import { FindBar, usePaneFind, type PaneFindMatchState } from "@/panels/pane-find";
import type { TerminalFindResultChangeEvent } from "@/terminal/runtime/terminal-emulator-runtime";
interface TerminalPaneProps {
serverId: string;
@@ -106,10 +108,25 @@ const EMPTY_MODIFIERS: ModifierState = {
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 {
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 {
modifier: keyof ModifierState;
active: boolean;
@@ -212,9 +229,16 @@ export function TerminalPane({
win32InputMode: false,
});
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
const terminalFindQueryRef = useRef("");
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const lastAutoFocusKeyRef = useRef<string | null>(null);
const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId });
const [terminalFindMatchState, setTerminalFindMatchStateValue] =
useState<PaneFindMatchState>(EMPTY_FIND_MATCH_STATE);
const setTerminalFindMatchState = useCallback((nextState: PaneFindMatchState) => {
setTerminalFindMatchStateValue(nextState);
}, []);
useEffect(() => {
terminalIdRef.current = terminalId;
@@ -224,6 +248,19 @@ export function TerminalPane({
};
}, [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(() => {
setFocusRequestToken((current) => current + 1);
}, []);
@@ -251,6 +288,43 @@ export function TerminalPane({
[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(() => {
if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) {
lastAutoFocusKeyRef.current = null;
@@ -748,6 +822,7 @@ export function TerminalPane({
return (
<Animated.View style={containerStyle}>
{paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null}
<View style={styles.outputContainer}>
{isWorkspaceFocused ? (
<View style={styles.terminalGestureContainer}>
@@ -27,6 +27,25 @@ import {
navigateToLastWorkspace,
useActiveWorkspaceSelection,
} 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({
enabled,
@@ -94,6 +113,8 @@ export function useKeyboardShortcuts({
return false;
case "dispatch":
return keyboardActionDispatcher.dispatch(action.action);
case "pane-find-open":
return handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
case "navigate-workspace":
navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname });
return true;
@@ -182,10 +203,7 @@ export function useKeyboardShortcuts({
chordStateRef.current = result.nextChordState;
if (result.preventDefault) {
event.preventDefault();
event.stopPropagation();
}
applyPendingChordEventHandling(event, result);
if (!result.match) {
return;
@@ -208,12 +226,7 @@ export function useKeyboardShortcuts({
return;
}
if (result.match.preventDefault) {
event.preventDefault();
}
if (result.match.stopPropagation) {
event.stopPropagation();
}
applyHandledShortcutEventHandling(event, result);
};
const handleKeyUp = (event: KeyboardEvent) => {
+1
View File
@@ -20,6 +20,7 @@ export type KeyboardActionId =
| "agent.new"
| "workspace.tab.new"
| "workspace.tab.close.current"
| "workspace.find.open"
| "workspace.tab.navigate.index"
| "workspace.tab.navigate.relative"
| "workspace.pane.split.right"
@@ -11,6 +11,7 @@ export type KeyboardActionId =
| "message-input.voice-mute-toggle"
| "workspace.tab.new"
| "workspace.tab.close-current"
| "workspace.find.open"
| "workspace.tab.navigate-index"
| "workspace.tab.navigate-relative"
| "workspace.pane.split.right"
@@ -40,6 +41,7 @@ export type KeyboardActionDefinition =
| { id: "message-input.voice-mute-toggle"; scope: KeyboardActionScope }
| { id: "workspace.tab.new"; scope: KeyboardActionScope }
| { id: "workspace.tab.close-current"; scope: KeyboardActionScope }
| { id: "workspace.find.open"; scope: KeyboardActionScope }
| { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number }
| { id: "workspace.tab.navigate-relative"; scope: KeyboardActionScope; delta: 1 | -1 }
| { id: "workspace.pane.split.right"; scope: KeyboardActionScope }
@@ -206,6 +206,18 @@ describe("keyboard-shortcuts", () => {
context: { isMac: true, isDesktop: true },
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",
event: { key: "w", code: "KeyW", ctrlKey: true },
@@ -540,6 +552,7 @@ describe("keyboard-shortcut help sections", () => {
"workspace-jump-index": ["alt", "1-9"],
"workspace-tab-jump-index": ["alt", "shift", "1-9"],
"workspace-tab-close-current": ["alt", "shift", "W"],
"workspace-find-open": ["mod", "F"],
"workspace-pane-split-right": ["mod", "\\"],
"workspace-pane-close": ["mod", "shift", "W"],
},
@@ -553,6 +566,7 @@ describe("keyboard-shortcut help sections", () => {
"workspace-jump-index": ["mod", "1-9"],
"workspace-tab-jump-index": ["mod", "alt", "1-9"],
"workspace-tab-close-current": ["meta", "W"],
"workspace-find-open": ["mod", "F"],
"workspace-pane-split-right": ["mod", "\\"],
"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 ---
{
id: "workspace-navigate-index-cmd-digit-mac",
+4 -1
View File
@@ -37,7 +37,8 @@ export type ShortcutAction =
| { kind: "open-project-picker" }
| { kind: "callback"; name: ShortcutCallbackName }
| { 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" };
@@ -201,6 +202,8 @@ export function routeKeyboardShortcut(
return { kind: "command-center-toggle", nextOpen: !ctx.commandCenterOpen };
case "shortcuts.dialog.toggle":
return { kind: "shortcuts-dialog-toggle", nextOpen: !ctx.shortcutsDialogOpen };
case "workspace.find.open":
return { kind: "pane-find-open" };
default:
return NONE;
}
+1
View File
@@ -6,6 +6,7 @@ import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
export interface PaneContextValue {
serverId: string;
workspaceId: string;
paneInstanceId: string | null;
tabId: string;
target: WorkspaceTabTarget;
openTab: (target: WorkspaceTabTarget) => void;
@@ -0,0 +1,121 @@
import { describe, expect, it, vi } from "vitest";
import {
clearActivePaneFindPaneId,
createPaneFindRegistry,
handlePaneFindKeyboardAction,
paneFindRegistry,
setActivePaneFindPaneId,
type PaneFindController,
} from "@/panels/pane-find-registry";
function createController(): PaneFindController {
return {
openFind: vi.fn(() => 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();
const right = createController();
registry.register({
paneId: "server:workspace:left",
controller: left,
});
registry.register({
paneId: "server:workspace:right",
controller: right,
});
expect(registry.openFindInActivePane()).toBe(true);
expect(left.openFind).toHaveBeenCalledTimes(1);
expect(right.openFind).not.toHaveBeenCalled();
});
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.openFind).not.toHaveBeenCalled();
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();
const right = createController();
registry.register({
paneId: "server:workspace:left",
controller: left,
});
registry.register({
paneId: "server:workspace:right",
controller: right,
});
expect(registry.openFindInActivePane()).toBe(true);
expect(left.openFind).not.toHaveBeenCalled();
expect(right.openFind).toHaveBeenCalledTimes(1);
});
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,81 @@
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
export interface PaneFindController {
openFind(): boolean;
closeFind(): boolean;
}
export interface RegisterPaneFindInput {
paneId: string;
controller: PaneFindController;
}
export 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: KeyboardActionDefinition): boolean {
if (action.id !== "workspace.find.open") {
return false;
}
return paneFindRegistry.openFindInActivePane();
}
+496
View File
@@ -0,0 +1,496 @@
/**
* @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(): FakeSearchController {
return {
query: vi.fn((query: string) =>
query === "missing" ? { status: "no-match" } : { status: "matched", current: 1, total: 3 },
),
next: vi.fn(() => ({ status: "matched", current: 2, total: 3 })),
prev: vi.fn(() => ({ status: "matched", current: 3, total: 3 })),
close: vi.fn(),
};
}
function FakeFindPanel() {
const paneContext = usePaneContext();
const controller = controllers.get(paneContext.paneInstanceId ?? "");
const paneFind = usePaneFind({
onQuery: controller?.query ?? createController().query,
onNext: controller?.next ?? createController().next,
onPrev: controller?.prev ?? createController().prev,
onClose: controller?.close ?? createController().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: () => {},
};
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(controller.next).toHaveBeenCalledTimes(2);
act(() => {
button("pane-find-prev").dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(controller.prev).toHaveBeenCalledTimes(2);
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(controller.close).toHaveBeenCalledTimes(2);
});
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();
const right = createController();
const leftContent = buildWorkspacePaneContentModel({
tab,
paneId: "left",
normalizedServerId: "server-a",
normalizedWorkspaceId: "workspace-a",
onOpenTab: vi.fn(),
onCloseCurrentTab: vi.fn(),
onRetargetCurrentTab: vi.fn(),
onOpenWorkspaceFile: 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(),
});
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(left.query).not.toHaveBeenCalled();
expect(right.query).toHaveBeenCalledWith("abc");
expect(focusLeft).not.toHaveBeenCalled();
expect(focusRight).not.toHaveBeenCalled();
});
});
+310
View File
@@ -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,
focusPane: expect.any(Function),
});
expect(snapshots[0]?.paneContextValue.paneInstanceId).toBeNull();
expect(snapshots[1]?.focus).toEqual({
isWorkspaceFocused: 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 {
createPaneFocusContextValue,
@@ -10,6 +10,11 @@ import { getPanelRegistration } from "@/panels/panel-registry";
import { ensurePanelsRegistered } from "@/panels/register-panels";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
import {
clearActivePaneFindPaneId,
createPaneFindPaneId,
setActivePaneFindPaneId,
} from "@/panels/pane-find-registry";
export interface WorkspacePaneContentModel {
key: string;
@@ -19,6 +24,7 @@ export interface WorkspacePaneContentModel {
export interface BuildWorkspacePaneContentModelInput {
tab: WorkspaceTabDescriptor;
paneId?: string | null;
normalizedServerId: string;
normalizedWorkspaceId: string;
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
@@ -30,6 +36,7 @@ export interface BuildWorkspacePaneContentModelInput {
export function buildWorkspacePaneContentModel({
tab,
paneId,
normalizedServerId,
normalizedWorkspaceId,
onOpenTab,
@@ -41,12 +48,20 @@ export function buildWorkspacePaneContentModel({
ensurePanelsRegistered();
const registration = getPanelRegistration(tab.kind);
invariant(registration, `No panel registration for kind: ${tab.kind}`);
const paneInstanceId = paneId
? createPaneFindPaneId({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
paneId,
})
: null;
return {
key: `${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}`,
Component: registration.component,
paneContextValue: {
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
paneInstanceId,
tabId: tab.tabId,
target: tab.target,
openTab: onOpenTab,
@@ -81,6 +96,16 @@ export function WorkspacePaneContent({
}),
[isPaneFocused, isWorkspaceFocused, onFocusPane],
);
useEffect(() => {
if (!paneContextValue.paneInstanceId || !isWorkspaceFocused || !isPaneFocused) {
return;
}
const paneInstanceId = paneContextValue.paneInstanceId;
setActivePaneFindPaneId(paneInstanceId);
return () => {
clearActivePaneFindPaneId(paneInstanceId);
};
}, [isPaneFocused, isWorkspaceFocused, paneContextValue.paneInstanceId]);
return (
<PaneProvider value={paneContextValue}>
@@ -2827,6 +2827,7 @@ function WorkspaceScreenContent({
}) =>
buildWorkspacePaneContentModel({
tab: input.tab,
paneId: input.paneId,
normalizedServerId,
normalizedWorkspaceId,
onOpenTab: (target) => {
@@ -26,6 +26,16 @@ vi.mock("@xterm/addon-ligatures/lib/addon-ligatures.mjs", () => ({
vi.mock("@xterm/addon-search", () => ({
SearchAddon: class SearchAddon {
findNext(): boolean {
return false;
}
findPrevious(): boolean {
return false;
}
clearDecorations(): void {}
onDidChangeResults(): { dispose: () => void } {
return { dispose: () => {} };
}
dispose(): void {}
},
}));
@@ -91,6 +101,15 @@ interface StubTerminal {
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(): {
runtime: TerminalEmulatorRuntime;
terminal: StubTerminal & {
@@ -145,6 +164,55 @@ function decodeTerminalOutput(data: string | Uint8Array): string {
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", () => {
const originalWindow = (globalThis as { window?: unknown }).window;
@@ -418,4 +486,71 @@ describe("terminal-emulator-runtime", () => {
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 { ImageAddon } from "@xterm/addon-image";
import { SearchAddon } from "@xterm/addon-search";
import type { ISearchOptions, ISearchResultChangeEvent } from "@xterm/addon-search";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
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 TerminalInputModeState,
@@ -61,6 +62,12 @@ export interface TerminalEmulatorRuntimeCallbacks {
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
}
export type TerminalFindResultChangeEvent = ISearchResultChangeEvent;
export interface TerminalFindQueryInput {
query: string;
}
interface TerminalEmulatorRuntimeDisposables {
disposeInput: () => void;
disconnectResizeObserver: () => void;
@@ -128,6 +135,16 @@ function prependTerminalOutput(
return output;
}
const TERMINAL_FIND_SEARCH_OPTIONS: ISearchOptions = {
caseSensitive: false,
decorations: {
matchBackground: "#facc15",
matchOverviewRuler: "#facc15",
activeMatchBackground: "#38bdf8",
activeMatchColorOverviewRuler: "#38bdf8",
},
};
const DEFAULT_TERMINAL_FONT_FAMILY = [
// Prefer common developer fonts, with Nerd Font variants for prompt/TUI glyphs.
"JetBrains Mono",
@@ -164,6 +181,7 @@ export class TerminalEmulatorRuntime {
};
private terminal: Terminal | null = null;
private fitAddon: FitAddon | null = null;
private searchAddon: SearchAddon | null = null;
private fitAndEmitResize: ((force: boolean) => void) | null = null;
private lastSize: { rows: number; cols: number } | null = null;
private cleanup: (() => void) | null = null;
@@ -223,6 +241,7 @@ export class TerminalEmulatorRuntime {
theme: withOverviewRulerBorderHidden(input.theme),
});
const fitAddon = new FitAddon();
const searchAddon = new SearchAddon({ highlightLimit: 20_000 });
const unicode11Addon = new Unicode11Addon();
let webglAddon: WebglAddon | 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());
try {
terminal.loadAddon(new LigaturesAddon());
@@ -334,6 +353,7 @@ export class TerminalEmulatorRuntime {
this.terminal = terminal;
this.fitAddon = fitAddon;
this.searchAddon = searchAddon;
window.__paseoTerminal = terminal;
const fitAndEmitResize = (force: boolean): void => {
@@ -687,6 +707,25 @@ export class TerminalEmulatorRuntime {
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 {
const terminal = this.terminal;
if (!terminal || terminal.rows <= 0) {
@@ -739,6 +778,7 @@ export class TerminalEmulatorRuntime {
}
this.terminal = null;
this.fitAddon = null;
this.searchAddon = null;
this.fitAndEmitResize = null;
this.lastSize = null;
this.themeBackgroundElements = [];