refactor(app): clean up find-in-pane slop

- A bridge: remove renderer webview find fallback and require the Electron bridge path

- B core: tighten pane-find assertions and hide internal search/render types

- C adapters: align chat/file search registration with highlightable content

- D harness: delete flaky Electron QA harness and simplify keyboard dispatcher scope
This commit is contained in:
Mohamed Boudra
2026-05-02 23:39:57 +07:00
parent 0ecc51bd99
commit 33674e0b10
19 changed files with 220 additions and 1923 deletions

View File

@@ -1,30 +0,0 @@
# Electron Find QA
Run only the Electron browser find harness:
```bash
npm run test:e2e --workspace=@getpaseo/app -- find-in-pane-electron.spec.ts --project "Desktop Chrome" --workers=1
```
The spec starts a fresh desktop app from the current worktree with:
- 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
Expected pass output: one Playwright test passes. Evidence lands in
`/tmp/paseo-find-pane-electron-rerun/`:
- `electron-find-evidence.json` with timestamps, listener counts, request IDs, match events, and cleanup calls
- `diagnostic-<timestamp>.md` with the latest run diagnosis
- `electron-find-*.png` screenshots
- `electron-dev.log` from the spawned desktop process
Failure modes:
- Timeout waiting for `workspace-new-browser`: the desktop app opened, but the workspace did not render tab actions.
- LogBox screenshot after browser open: browser pane crashed before find could run.
- Timeout waiting for `foundEvents`: `webview.findInPage()` returned a request ID, but renderer-side `found-in-page` never arrived.
- Counter mismatch: `found-in-page` arrived, but the shared `FindBar` state did not update to the expected current/total.
- Close/Esc mismatch: native selection or shared find cleanup did not finish.

File diff suppressed because it is too large Load Diff

View File

@@ -44,7 +44,7 @@ async function typeFindQuery(page: Page, query: string): Promise<void> {
await input.fill(query);
}
test.describe("in-pane find manual QA", () => {
test.describe("in-pane find", () => {
test("walks chat, file, terminal, split-pane, and browser-web find flows in the running app", async ({
page,
}, testInfo) => {
@@ -120,7 +120,7 @@ test.describe("in-pane find manual QA", () => {
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({
await expect(page.getByText(/1 \/ 2|2 \/ 2/)).toBeVisible({
timeout: 10_000,
});
await page.getByTestId("workspace-file-pane").click();

View File

@@ -40,11 +40,16 @@ interface NativeScrollToIndexFailedInfo {
averageItemLength: number;
}
interface NativeScrollIndexFallbackInput {
index: number;
averageItemLength: number;
}
function keyExtractor(item: { id: string }): string {
return item.id;
}
export function getNativeScrollToIndexFallbackOffset(input: NativeScrollToIndexFailedInfo) {
export function getNativeScrollToIndexFallbackOffset(input: NativeScrollIndexFallbackInput) {
if (!Number.isFinite(input.averageItemLength) || input.averageItemLength <= 0) {
return 0;
}

View File

@@ -1,10 +1,8 @@
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 {
@@ -63,82 +61,53 @@ function todoList(id: string, seed = 1): StreamItem {
};
}
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,
},
},
};
function getSearchableText(item: StreamItem): string {
const model = buildAgentStreamSearchModel({
streamItems: [item],
streamHead: [],
platform: "web",
isMobileBreakpoint: true,
});
return model.entries[0]?.text ?? "";
}
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",
);
describe("buildAgentStreamSearchModel", () => {
it("indexes user and assistant message text", () => {
expect(getSearchableText(userMessage("u1", "user text"))).toBe("user text");
expect(getSearchableText(assistantMessage("a1", "assistant text"))).toBe("assistant text");
});
it("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("excludes non-message rows that do not render find highlights", () => {
expect(getSearchableText(thought("t1", "thought text"))).toBe("");
expect(getSearchableText(activityLog("l1", "activity text"))).toBe("");
expect(getSearchableText(todoList("todo"))).toBe("");
});
it("includes special tool-call content branches that render as messages or cards", () => {
const speak: StreamItem = {
it("excludes tool call rows from the search index", () => {
const toolCall: StreamItem = {
kind: "tool_call",
id: "speak",
id: "shell",
timestamp: timestamp(1),
payload: {
source: "agent",
data: {
provider: "codex",
callId: "call-speak",
name: "speak",
callId: "call-shell",
name: "exec_command",
status: "completed",
error: null,
detail: {
type: "unknown",
input: "spoken message",
output: null,
type: "shell",
command: "npm run typecheck",
output: "internal output should stay out",
},
},
},
};
const plan = agentToolCall("plan", {
type: "plan",
text: "phase checklist",
});
expect(getAgentStreamItemSearchableText(speak)).toBe("spoken message");
expect(getAgentStreamItemSearchableText(plan)).toBe("Plan\nphase checklist");
expect(getSearchableText(toolCall)).toBe("");
});
});
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) {
@@ -185,7 +154,7 @@ 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")],
streamHead: [assistantMessage("h1", "alpha live")],
platform: "web",
isMobileBreakpoint: true,
});
@@ -202,4 +171,20 @@ describe("findAgentStreamSearchMatches", () => {
]);
expect(matches.map((match) => match.entry.item.id)).toEqual(["a1", "a1", "h1"]);
});
it("skips fenced code blocks while preserving message offsets for highlights", () => {
const model = buildAgentStreamSearchModel({
streamItems: [assistantMessage("a1", "before alpha\n```\nalpha\n```\nafter alpha")],
streamHead: [],
platform: "web",
isMobileBreakpoint: true,
});
const matches = findAgentStreamSearchMatches({
model,
query: "alpha",
});
expect(matches.map((match) => match.id)).toEqual(["a1:text:0:7:12", "a1:text:0:33:38"]);
});
});

View File

@@ -1,19 +1,19 @@
import type { StreamItem, ToolCallItem } from "@/types/stream";
import { buildToolCallDisplayModel } from "@/utils/tool-call-display";
import type { StreamItem } from "@/types/stream";
import {
findMountedWindowStart,
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
} from "./agent-stream-web-virtualization";
export type AgentStreamSearchSource = "historyVirtualized" | "historyMounted" | "liveHead";
type AgentStreamSearchSource = "historyVirtualized" | "historyMounted" | "liveHead";
export interface AgentStreamSearchTextSegment {
interface AgentStreamSearchTextSegment {
key: string;
text: string;
startOffset: number;
}
export interface AgentStreamSearchEntry {
interface AgentStreamSearchEntry {
item: StreamItem;
source: AgentStreamSearchSource;
index: number;
@@ -30,7 +30,7 @@ export interface AgentStreamSearchMatch {
end: number;
}
export interface AgentStreamSearchModel {
interface AgentStreamSearchModel {
entries: AgentStreamSearchEntry[];
segments: {
historyVirtualized: AgentStreamSearchEntry[];
@@ -39,7 +39,7 @@ export interface AgentStreamSearchModel {
};
}
export interface BuildAgentStreamSearchModelInput {
interface BuildAgentStreamSearchModelInput {
platform: "web" | "native";
isMobileBreakpoint: boolean;
streamItems: StreamItem[];
@@ -48,99 +48,79 @@ export interface BuildAgentStreamSearchModelInput {
cwd?: string;
}
export interface FindAgentStreamSearchMatchesInput {
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 getFenceDelimiter(line: string): string | null {
const match = /^( {0,3})(`{3,}|~{3,})/.exec(line);
return match?.[2] ?? null;
}
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 }];
function getMessageSearchableSegments(text: string): AgentStreamSearchTextSegment[] {
const segments: AgentStreamSearchTextSegment[] = [];
let activeFenceCharacter: "`" | "~" | null = null;
let activeFenceLength = 0;
let currentText = "";
let currentStartOffset = 0;
let offset = 0;
const flush = () => {
if (currentText.length > 0) {
segments.push({ key: "text", text: currentText, startOffset: currentStartOffset });
currentText = "";
}
};
for (const line of text.split("\n")) {
const lineWithBreak = offset + line.length < text.length ? `${line}\n` : line;
const fenceDelimiter = getFenceDelimiter(line);
const isClosingFence =
activeFenceCharacter &&
fenceDelimiter?.[0] === activeFenceCharacter &&
fenceDelimiter.length >= activeFenceLength;
const isOpeningFence = !activeFenceCharacter && fenceDelimiter;
const isIndentedCode = !activeFenceCharacter && (/^( {4,}|\t)/.test(line) || line === " ");
if (isOpeningFence || activeFenceCharacter || isIndentedCode) {
flush();
} else {
if (currentText.length === 0) {
currentStartOffset = offset;
}
currentText += lineWithBreak;
}
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 }] : [];
if (isOpeningFence) {
activeFenceCharacter = fenceDelimiter[0] as "`" | "~";
activeFenceLength = fenceDelimiter.length;
} else if (isClosingFence) {
activeFenceCharacter = null;
activeFenceLength = 0;
}
offset += lineWithBreak.length;
}
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 }] : [];
flush();
return segments;
}
export function getAgentStreamItemSearchableSegments(
item: StreamItem,
options: { cwd?: string } = {},
): AgentStreamSearchTextSegment[] {
function getAgentStreamItemSearchableSegments(item: StreamItem): AgentStreamSearchTextSegment[] {
switch (item.kind) {
case "user_message":
case "assistant_message":
return item.text ? getMessageSearchableSegments(item.text) : [];
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;
@@ -163,7 +143,7 @@ function buildEntries(input: {
cwd: string | undefined;
}): AgentStreamSearchEntry[] {
return input.items.map((item, offset) => {
const segments = getAgentStreamItemSearchableSegments(item, { cwd: input.cwd });
const segments = getAgentStreamItemSearchableSegments(item);
return {
item,
source: input.source,
@@ -277,13 +257,15 @@ export function findAgentStreamSearchMatches(
break;
}
const end = start + input.query.length;
const absoluteStart = segment.startOffset + start;
const absoluteEnd = segment.startOffset + end;
matches.push({
id: `${entry.item.id}:${segment.key}:${occurrenceIndex}:${start}:${end}`,
id: `${entry.item.id}:${segment.key}:${occurrenceIndex}:${absoluteStart}:${absoluteEnd}`,
entry,
segmentKey: segment.key,
occurrenceIndex,
start,
end,
start: absoluteStart,
end: absoluteEnd,
});
occurrenceIndex += 1;
fromIndex = end;

View File

@@ -207,8 +207,6 @@ vi.mock("@/stores/browser-store", () => ({
}));
type FakeWebview = HTMLDivElement & {
findInPage: ReturnType<typeof vi.fn<(text: string, options?: unknown) => number>>;
stopFindInPage: ReturnType<typeof vi.fn<(action: string) => void>>;
getURL: ReturnType<typeof vi.fn<() => string>>;
canGoBack: ReturnType<typeof vi.fn<() => boolean>>;
canGoForward: ReturnType<typeof vi.fn<() => boolean>>;
@@ -248,8 +246,6 @@ function installWebviewElementFactory(): void {
return originalCreateElement(tagName, options);
}
const element = originalCreateElement("div") as FakeWebview;
element.findInPage = vi.fn(() => nextRequestId++);
element.stopFindInPage = vi.fn();
element.getURL = vi.fn(() => "https://example.com");
element.canGoBack = vi.fn(() => false);
element.canGoForward = vi.fn(() => false);
@@ -423,23 +419,6 @@ describe("BrowserPane Electron find", () => {
expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull();
});
it("ignores requestId-less found-in-page events while a newer requestId search is active", () => {
renderBrowserPane();
markWebviewDomReady();
openFind();
changeInput("needle");
dispatchFoundInPage({ requestId: 1, activeMatchOrdinal: 1, matches: 2 });
expect(container?.textContent).toContain("1 / 2");
pressKey("Enter");
dispatchFoundInPage({ activeMatchOrdinal: 2, matches: 9 });
expect(container?.textContent).toContain("Searching...");
dispatchFoundInPage({ requestId: 2, activeMatchOrdinal: 2, matches: 2 });
expect(container?.textContent).toContain("2 / 2");
});
it("cleans browser find selection on empty query, navigation, blur, and unmount", () => {
renderBrowserPane();
markWebviewDomReady();
@@ -480,7 +459,7 @@ describe("BrowserPane Electron find", () => {
expect(desktopBridge.foundInPageListeners.size).toBe(0);
});
it("does not call webview find methods before dom-ready", () => {
it("does not call the browser find bridge before dom-ready", () => {
renderBrowserPane();
openFind();

View File

@@ -28,12 +28,6 @@ import { isDev } from "@/constants/platform";
import { FindBar, usePaneFind, type PaneFindMatchState } from "@/panels/pane-find";
import { useBrowserStore, normalizeWorkspaceBrowserUrl } from "@/stores/browser-store";
interface ElectronFindOptions {
forward?: boolean;
findNext?: boolean;
matchCase?: boolean;
}
type ElectronWebview = HTMLElement & {
canGoBack?: () => boolean;
canGoForward?: () => boolean;
@@ -44,20 +38,11 @@ type ElectronWebview = HTMLElement & {
loadURL?: (url: string) => Promise<void>;
getURL?: () => string;
executeJavaScript?: (code: string) => Promise<unknown>;
findInPage?: (text: string, options?: ElectronFindOptions) => number;
stopFindInPage?: (action: "clearSelection" | "keepSelection" | "activateSelection") => void;
focus?: () => void;
addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void;
removeEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void;
};
interface ElectronFoundInPageResult extends DesktopBrowserFoundInPageResult {
requestId?: number;
activeMatchOrdinal?: number;
matches?: number;
finalUpdate?: boolean;
}
type WebTextInput = TextInput & {
getNativeRef?: () => unknown;
};
@@ -261,25 +246,16 @@ function isDesktopBrowserShortcutEvent(payload: unknown): payload is DesktopBrow
return event.action === "focus-url";
}
function getFoundInPageResult(event: Event): ElectronFoundInPageResult | null {
const result = (event as Event & { result?: unknown }).result;
if (!result || typeof result !== "object") {
return null;
}
return result as ElectronFoundInPageResult;
}
function stopBrowserFindInPage(input: {
browserId: string;
webview: ElectronWebview | null;
action: DesktopBrowserFindAction;
}): void {
const bridge = getDesktopHost()?.browser;
if (bridge?.stopFindInPage) {
void bridge.stopFindInPage(input.browserId, input.action);
if (!bridge?.stopFindInPage) {
console.warn("Electron browser find bridge is unavailable; cannot stop find-in-page.");
return;
}
input.webview?.stopFindInPage?.(input.action);
void bridge.stopFindInPage(input.browserId, input.action);
}
function startSelectorResultPolling(input: {
@@ -436,7 +412,6 @@ export function BrowserPane({
if (domReadyRef.current) {
stopBrowserFindInPage({
browserId: browserIdRef.current,
webview: webviewRef.current,
action: "clearSelection",
});
}
@@ -450,13 +425,13 @@ export function BrowserPane({
return;
}
const webview = webviewRef.current;
if (!domReadyRef.current) {
setBrowserFindMatchState(PENDING_FIND_MATCH_STATE);
return;
}
const bridgeFindInPage = getDesktopHost()?.browser?.findInPage;
if (!bridgeFindInPage && !webview?.findInPage) {
if (!bridgeFindInPage) {
console.warn("Electron browser find bridge is unavailable; cannot start find-in-page.");
setBrowserFindMatchState(NO_FIND_MATCH_STATE);
return;
}
@@ -474,9 +449,7 @@ export function BrowserPane({
findNext: !input?.reset,
matchCase: false,
};
const requestIdResult = bridgeFindInPage
? bridgeFindInPage(browserIdRef.current, query, options)
: webview?.findInPage?.(query, options);
const requestIdResult = bridgeFindInPage(browserIdRef.current, query, options);
if (typeof requestIdResult === "number") {
activeBrowserFindRef.current = {
generation,
@@ -504,7 +477,7 @@ export function BrowserPane({
[clearBrowserFindSelection],
);
const handleFoundInPageResult = useCallback((result: ElectronFoundInPageResult) => {
const handleFoundInPageResult = useCallback((result: DesktopBrowserFoundInPageResult) => {
const activeFind = activeBrowserFindRef.current;
if (
!activeFind ||
@@ -513,12 +486,11 @@ export function BrowserPane({
) {
return;
}
const eventRequestId = typeof result.requestId === "number" ? result.requestId : null;
if (typeof activeFind.requestId === "number") {
if (eventRequestId !== activeFind.requestId) {
return;
}
} else if (eventRequestId !== null) {
const requestId = result.requestId;
if (typeof requestId !== "number") {
return;
}
if (typeof activeFind.requestId !== "number" || requestId !== activeFind.requestId) {
return;
}
@@ -539,17 +511,6 @@ export function BrowserPane({
});
}, []);
const handleFoundInPage = useCallback(
(event: Event) => {
const result = getFoundInPageResult(event);
if (!result) {
return;
}
handleFoundInPageResult(result);
},
[handleFoundInPageResult],
);
useEffect(() => {
if (!isElectronRuntime()) {
return;
@@ -691,7 +652,7 @@ export function BrowserPane({
});
}
} else {
webview.addEventListener("found-in-page", handleFoundInPage);
console.warn("Electron browser find bridge is unavailable; found-in-page events disabled.");
}
host.appendChild(webview);
@@ -714,15 +675,11 @@ export function BrowserPane({
webview.removeEventListener("dom-ready", handleDomReady);
didCleanupFoundInPage = true;
unsubscribeFoundInPage?.();
if (!foundInPageBridge) {
webview.removeEventListener("found-in-page", handleFoundInPage);
}
webview.removeEventListener("focus", handleWebviewFocus);
webview.removeEventListener("mousedown", handleWebviewFocus);
if (domReadyRef.current) {
stopBrowserFindInPage({
browserId: browserIdRef.current,
webview,
action: "clearSelection",
});
}

View File

@@ -5,7 +5,8 @@ import {
createFilePaneTextRenderData,
findFilePaneTextMatches,
} from "@/components/file-pane-text-render-data";
import type { FilePaneTextLineRenderData } from "@/components/file-pane-text-render-data";
type FilePaneTextLineRenderData = ReturnType<typeof createFilePaneTextRenderData>["lines"][number];
function tokenText(line: FilePaneTextLineRenderData): string {
return line.tokens.map(({ text }) => text).join("");

View File

@@ -1,17 +1,17 @@
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
export interface FilePaneTextLineRenderData {
interface FilePaneTextLineRenderData {
lineNumber: number;
text: string;
tokens: HighlightToken[];
}
export interface FilePaneTextRenderData {
interface FilePaneTextRenderData {
lines: FilePaneTextLineRenderData[];
searchableText: string;
}
export interface FilePaneFindLineSpan {
interface FilePaneFindLineSpan {
lineNumber: number;
startColumn: number;
endColumn: number;

View File

@@ -93,6 +93,13 @@ interface FilePaneTextPreviewProps {
webScrollbarStyle: object;
}
interface FilePaneSearchableTextPreviewProps extends Omit<
FilePaneTextPreviewProps,
"findHighlightsByLine" | "textRenderData"
> {
textRenderData: ReturnType<typeof createFilePaneTextRenderData>;
}
interface FilePaneImagePreviewProps {
imagePreviewUri: string | null;
imageSource: { uri: string } | null;
@@ -598,6 +605,20 @@ function FilePaneTextPreview({
);
}
function FilePaneSearchableTextPreview(props: FilePaneSearchableTextPreviewProps) {
const { findHighlightsByLine, paneFind } = useFilePaneFindAdapter({
textRenderData: props.textRenderData,
textScrollRefs: props.textScrollRefs,
});
return (
<>
<FilePaneFindBarSlot paneFind={paneFind} />
<FilePaneTextPreview {...props} findHighlightsByLine={findHighlightsByLine} />
</>
);
}
function FilePaneImagePreview({
imagePreviewUri,
imageSource,
@@ -676,11 +697,6 @@ function FilePreviewBody({
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
enabled: showDesktopWebScrollbar,
});
const { findHighlightsByLine, paneFind } = useFilePaneFindAdapter({
textRenderData,
textScrollRefs,
});
const gutterWidth = useMemo(() => {
if (!textRenderData) return 0;
return lineNumberGutterWidth(textRenderData.lines.length, theme.fontSize.code);
@@ -729,11 +745,32 @@ function FilePreviewBody({
<Text style={styles.emptyText}>No preview available</Text>
</FilePaneCenterState>
);
} else if (preview.kind === "text" && textRenderData) {
content = (
<FilePaneSearchableTextPreview
baseColor={baseColor}
colorMap={colorMap}
currentMatchBackgroundColor={currentMatchBackgroundColor}
gutterWidth={gutterWidth}
isMarkdownFile={isMarkdownFile}
isMobile={isMobile}
markdownParser={markdownParser}
markdownStyles={markdownStyles}
matchBackgroundColor={matchBackgroundColor}
preview={preview}
previewScrollRef={previewScrollRef}
scrollbar={scrollbar}
showDesktopWebScrollbar={showDesktopWebScrollbar}
textRenderData={textRenderData}
textScrollRefs={textScrollRefs}
webScrollbarStyle={webScrollbarStyle}
/>
);
} else if (preview.kind === "text") {
content = (
<FilePaneTextPreview
currentMatchBackgroundColor={currentMatchBackgroundColor}
findHighlightsByLine={findHighlightsByLine}
findHighlightsByLine={new Map()}
gutterWidth={gutterWidth}
isMarkdownFile={isMarkdownFile}
isMobile={isMobile}
@@ -769,12 +806,7 @@ function FilePreviewBody({
);
}
return (
<View style={styles.previewScrollContainer}>
<FilePaneFindBarSlot paneFind={paneFind} />
{content}
</View>
);
return <View style={styles.previewScrollContainer}>{content}</View>;
}
export function FilePane({

View File

@@ -241,7 +241,7 @@ function normalizeFindHighlights(
.sort((left, right) => left.start - right.start || left.end - right.end);
}
export function createMessageFindTextSegments(
function createMessageFindTextSegments(
text: string,
highlights: MessageFindHighlight[] | undefined,
): MessageFindTextSegment[] {
@@ -283,7 +283,7 @@ interface MarkdownBlockWithOffset {
startOffset: number;
}
export function createMarkdownBlocksWithOffsets(message: string): MarkdownBlockWithOffset[] {
function createMarkdownBlocksWithOffsets(message: string): MarkdownBlockWithOffset[] {
let cursor = 0;
return splitMarkdownBlocks(message).map((block, index) => {
const startOffset = Math.max(cursor, message.indexOf(block, cursor));

View File

@@ -6,7 +6,6 @@ describe("getNativeScrollToIndexFallbackOffset", () => {
expect(
getNativeScrollToIndexFallbackOffset({
index: 25,
highestMeasuredFrameIndex: 4,
averageItemLength: 72,
}),
).toBe(1800);
@@ -16,14 +15,12 @@ describe("getNativeScrollToIndexFallbackOffset", () => {
expect(
getNativeScrollToIndexFallbackOffset({
index: 25,
highestMeasuredFrameIndex: 4,
averageItemLength: 0,
}),
).toBe(0);
expect(
getNativeScrollToIndexFallbackOffset({
index: 25,
highestMeasuredFrameIndex: 4,
averageItemLength: Number.NaN,
}),
).toBe(0);

View File

@@ -111,13 +111,13 @@ export interface DesktopBrowserBridge {
setWorkspaceActiveBrowser?: (browserId: string | null) => Promise<void>;
openDevTools?: (browserId: string) => Promise<unknown>;
clearPartition?: (browserId: string) => Promise<void>;
findInPage?: (
findInPage: (
browserId: string,
text: string,
options?: DesktopBrowserFindOptions,
) => Promise<number | null> | number | null;
stopFindInPage?: (browserId: string, action: DesktopBrowserFindAction) => Promise<void> | void;
onFoundInPage?: (
stopFindInPage: (browserId: string, action: DesktopBrowserFindAction) => Promise<void> | void;
onFoundInPage: (
browserId: string,
listener: (result: DesktopBrowserFoundInPageResult) => void,
) => Promise<() => void> | (() => void);

View File

@@ -11,7 +11,6 @@ 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"
@@ -41,7 +40,6 @@ 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 }

View File

@@ -9,9 +9,9 @@ import {
type PaneFindController,
} from "@/panels/pane-find-registry";
function createController(): PaneFindController {
function createController(input?: { openResult?: boolean }): PaneFindController {
return {
openFind: vi.fn(() => true),
openFind: vi.fn(() => input?.openResult ?? true),
closeFind: vi.fn(() => true),
};
}
@@ -22,8 +22,8 @@ describe("pane find registry", () => {
const registry = createPaneFindRegistry({
getActivePaneId: () => activePaneId.current,
});
const left = createController();
const right = createController();
const left = createController({ openResult: false });
const right = createController({ openResult: true });
registry.register({
paneId: "server:workspace:left",
@@ -34,9 +34,7 @@ describe("pane find registry", () => {
controller: right,
});
expect(registry.openFindInActivePane()).toBe(true);
expect(left.openFind).toHaveBeenCalledTimes(1);
expect(right.openFind).not.toHaveBeenCalled();
expect(registry.openFindInActivePane()).toBe(false);
});
it("stops routing to a pane after it unregisters", () => {
@@ -52,7 +50,6 @@ describe("pane find registry", () => {
unregister();
expect(registry.openFindInActivePane()).toBe(false);
expect(controller.openFind).not.toHaveBeenCalled();
expect(controller.closeFind).toHaveBeenCalledTimes(1);
});
@@ -61,8 +58,8 @@ describe("pane find registry", () => {
const registry = createPaneFindRegistry({
getActivePaneId: () => activePaneId.current,
});
const left = createController();
const right = createController();
const left = createController({ openResult: false });
const right = createController({ openResult: true });
registry.register({
paneId: "server:workspace:left",
@@ -74,8 +71,6 @@ describe("pane find registry", () => {
});
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", () => {

View File

@@ -1,16 +1,19 @@
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
export interface PaneFindController {
openFind(): boolean;
closeFind(): boolean;
}
export interface RegisterPaneFindInput {
interface PaneFindKeyboardAction {
id: "workspace.find.open";
scope: "workspace";
}
interface RegisterPaneFindInput {
paneId: string;
controller: PaneFindController;
}
export interface PaneFindRegistry {
interface PaneFindRegistry {
register(input: RegisterPaneFindInput): () => void;
openFindInActivePane(): boolean;
closeFindInPane(paneId: string): boolean;
@@ -73,9 +76,7 @@ export function clearActivePaneFindPaneId(paneId: string) {
}
}
export function handlePaneFindKeyboardAction(action: KeyboardActionDefinition): boolean {
if (action.id !== "workspace.find.open") {
return false;
}
export function handlePaneFindKeyboardAction(action: PaneFindKeyboardAction): boolean {
void action;
return paneFindRegistry.openFindInActivePane();
}

View File

@@ -145,13 +145,14 @@ interface FakeSearchController {
const controllers = new Map<string, FakeSearchController>();
function createController(): FakeSearchController {
function createController(input?: { total?: number }): FakeSearchController {
const total = input?.total ?? 3;
return {
query: vi.fn((query: string) =>
query === "missing" ? { status: "no-match" } : { status: "matched", current: 1, total: 3 },
query === "missing" ? { status: "no-match" } : { status: "matched", current: 1, total },
),
next: vi.fn(() => ({ status: "matched", current: 2, total: 3 })),
prev: vi.fn(() => ({ status: "matched", current: 3, total: 3 })),
next: vi.fn(() => ({ status: "matched", current: 2, total })),
prev: vi.fn(() => ({ status: "matched", current: 3, total })),
close: vi.fn(),
};
}
@@ -159,11 +160,14 @@ function createController(): FakeSearchController {
function FakeFindPanel() {
const paneContext = usePaneContext();
const controller = controllers.get(paneContext.paneInstanceId ?? "");
if (!controller) {
throw new Error(`Missing fake find controller for pane ${paneContext.paneInstanceId}`);
}
const paneFind = usePaneFind({
onQuery: controller?.query ?? createController().query,
onNext: controller?.next ?? createController().next,
onPrev: controller?.prev ?? createController().prev,
onClose: controller?.close ?? createController().close,
onQuery: controller.query,
onNext: controller.next,
onPrev: controller.prev,
onClose: controller.close,
});
return (
@@ -350,12 +354,12 @@ describe("FindBar", () => {
act(() => {
button("pane-find-next").dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(controller.next).toHaveBeenCalledTimes(2);
expect(container?.textContent).toContain("2 / 3");
act(() => {
button("pane-find-prev").dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(controller.prev).toHaveBeenCalledTimes(2);
expect(container?.textContent).toContain("3 / 3");
pressKey("Escape");
expect(controller.close).toHaveBeenCalledTimes(1);
@@ -367,7 +371,7 @@ describe("FindBar", () => {
act(() => {
button("pane-find-close").dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(controller.close).toHaveBeenCalledTimes(2);
expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull();
});
it("cleans up the active find adapter on pane deactivation and unmount", () => {
@@ -436,8 +440,8 @@ describe("FindBar", () => {
});
it("routes open find through the focused workspace pane without replacing pane focus", () => {
const left = createController();
const right = createController();
const left = createController({ total: 7 });
const right = createController({ total: 5 });
const leftContent = buildWorkspacePaneContentModel({
tab,
paneId: "left",
@@ -488,8 +492,8 @@ describe("FindBar", () => {
});
changeInput("abc");
expect(left.query).not.toHaveBeenCalled();
expect(right.query).toHaveBeenCalledWith("abc");
expect(container?.textContent).toContain("1 / 5");
expect(container?.textContent).not.toContain("1 / 7");
expect(focusLeft).not.toHaveBeenCalled();
expect(focusRight).not.toHaveBeenCalled();
});

View File

@@ -45,11 +45,11 @@ function ensureOwnerFoundInPageListener(ownerContents: WebContents): void {
export function registerPaseoBrowserWebContents(
contents: WebContents,
browserId: string,
ownerContents?: WebContents,
ownerContents: WebContents,
): void {
browserIdsByWebContentsId.set(contents.id, browserId);
webContentsIdsByBrowserId.set(browserId, contents.id);
if (ownerContents && !ownerContents.isDestroyed()) {
if (!ownerContents.isDestroyed()) {
ownerWebContentsIdsByBrowserId.set(browserId, ownerContents.id);
ensureOwnerFoundInPageListener(ownerContents);
}
@@ -92,18 +92,17 @@ export function getPaseoBrowserWebContents(browserId: string): WebContents | nul
return contents && !contents.isDestroyed() ? contents : null;
}
export function setActivePaseoBrowserFind(browserId: string): boolean {
export function setActivePaseoBrowserFind(browserId: string): void {
const ownerContentsId = ownerWebContentsIdsByBrowserId.get(browserId);
if (!ownerContentsId) {
return false;
return;
}
const ownerContents = allWebContents.fromId(ownerContentsId);
if (!ownerContents || ownerContents.isDestroyed()) {
return false;
return;
}
ensureOwnerFoundInPageListener(ownerContents);
activeFindBrowserIdsByOwnerWebContentsId.set(ownerContents.id, browserId);
return true;
}
export function clearActivePaseoBrowserFind(browserId: string): void {