Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 92ade69cd6 test(app): reproduce cursor separation on unequal restore sizes
Replay a 41-row shell restore into both 41-row and 35-row native emulators. The smaller replay leaves the cursor six rows below the prompt after the viewport grows. This intentionally failing reproduction preserves the unresolved restore sizing defect for investigation.
2026-10-24 20:16:32 +00:00
paseo-bot[bot] eccd3337d7 fix(app): restore the native terminal cursor beside the prompt
Measure the font sample outside the grid whose width is derived from those metrics. On cold Android startup the one-column grid wrapped the sample, temporarily shrinking the emulator and clamping the restored cursor before the final layout.
2026-10-24 18:32:46 +00:00
18 changed files with 209 additions and 843 deletions
@@ -229,143 +229,6 @@ test("captures the desktop import sheet and command-center entry", async ({ page
});
});
test("large import lists keep mounted rows bounded and remain scrollable", async ({
page,
}, testInfo) => {
const fixtureDirectory = path.join(claudeConfigDirectory, "projects", "virtualization-fixture");
await mkdir(fixtureDirectory, { recursive: true });
try {
const newest = Date.now();
for (let index = 0; index < 200; index += 1) {
const id = `virtualized-${String(index).padStart(3, "0")}`;
const file = path.join(fixtureDirectory, `${id}.jsonl`);
await writeFile(
file,
`${JSON.stringify({
type: "user",
sessionId: id,
cwd: index % 2 === 0 ? scenario.projectRoot : scenario.worktreeDirectory,
message: {
role: "user",
content: `Virtualized session ${index}${index % 3 === 0 ? " with a longer preview that wraps onto a second line in a compact sheet" : ""}`,
},
})}\n`,
);
const timestamp = new Date(newest - index * 10);
await utimes(file, timestamp, timestamp);
}
for (const width of [1280, 390]) {
const flow = new ImportSessionFlow(page);
await flow.openWorkspace(scenario.project.workspaceId, { width, height: 844 });
await flow.openGlobally();
const rows = page.locator('[data-testid^="import-session-session-"]');
const scrollToEnd = async (end = true) => {
// Scroll the body's real overflow element. Virtual rows need not exist yet.
await rows.first().evaluate((row, atEnd) => {
let element = row.parentElement;
while (element && !["auto", "scroll"].includes(getComputedStyle(element).overflowY)) {
element = element.parentElement;
}
if (!element) throw new Error("Import list has no scroll owner");
element.scrollTop = atEnd ? element.scrollHeight : 0;
element.dispatchEvent(new Event("scroll"));
}, end);
};
for (let pageIndex = 0; pageIndex < 3; pageIndex += 1) {
const loadMore = page.getByTestId("import-session-load-more");
await expect(async () => {
await scrollToEnd();
await expect(loadMore).toBeInViewport({ timeout: 500 });
}).toPass({ timeout: 10_000 });
await loadMore.click();
if (pageIndex < 2) await expect(loadMore).toBeEnabled();
}
await expect(async () => {
await scrollToEnd();
await expect(
page.getByTestId("import-session-session-claude-virtualized-199"),
).toBeInViewport({ timeout: 500 });
}).toPass({ timeout: 10_000 });
await expect.poll(() => rows.count()).toBeLessThan(100);
await page.screenshot({ path: testInfo.outputPath(`virtualized-${width}.png`) });
if (width === 1280) {
const row = (index: number) =>
page.getByTestId(
`import-session-session-claude-virtualized-${String(index).padStart(3, "0")}`,
);
await expect(async () => {
await scrollToEnd(false);
await expect(row(0)).toBeInViewport({ timeout: 500 });
}).toPass({ timeout: 10_000 });
await row(0).focus();
for (let index = 1; index <= 30; index += 1) {
await page.keyboard.press("Tab");
await expect(row(index)).toBeFocused();
}
for (let index = 29; index >= 15; index -= 1) {
await page.keyboard.press("Shift+Tab");
await expect(row(index)).toBeFocused();
}
const list = page.getByTestId("import-session-list");
const scrollWithKey = async (event: "scroll" | "scrollend", key: string) => {
const listener = await list.evaluateHandle((node, eventName) => {
const promise = new Promise<void>((resolve) => {
node.addEventListener(eventName, () => resolve(), { once: true });
});
return { promise };
}, event);
try {
await page.keyboard.press(key);
await listener.evaluate((value) => value.promise);
} finally {
await listener.dispose();
}
};
const beforePageDown = await list.evaluate((node) => node.scrollTop);
await scrollWithKey("scroll", "PageDown");
await expect
.poll(() => list.evaluate((node) => node.scrollTop))
.toBeGreaterThan(beforePageDown);
await expect(row(15)).toBeFocused();
await page.keyboard.press("End");
await expect(row(15)).toBeFocused();
await expect(row(199)).toBeInViewport();
// Home must remain at the first row after the native page scroll ends.
await scrollWithKey("scrollend", "Home");
await expect(row(0)).toBeInViewport();
await expect(row(15)).toBeFocused();
await page.keyboard.press("End");
await expect(row(199)).toBeInViewport();
await row(199).focus();
await scrollWithKey("scroll", "PageUp");
await page.keyboard.press("Home");
await expect(row(0)).toBeInViewport();
await scrollWithKey("scrollend", "End");
await expect(row(199)).toBeInViewport();
await expect(row(199)).toBeFocused();
await testInfo.attach("keyboard-scroll-owner", {
body: JSON.stringify(
await list.evaluate((node) => ({
focusedRow: document.activeElement?.getAttribute("data-testid"),
scrollTop: node.scrollTop,
scrollHeight: node.scrollHeight,
clientHeight: node.clientHeight,
})),
),
contentType: "application/json",
});
await page.screenshot({ path: testInfo.outputPath("virtualized-keyboard-end.png") });
await page.keyboard.press("Home");
await expect(row(0)).toBeInViewport();
}
await flow.close();
}
} finally {
await rm(fixtureDirectory, { recursive: true, force: true });
}
});
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const outputPath = testInfo.outputPath(name);
await page.screenshot({ path: outputPath, fullPage: true });
@@ -27,7 +27,7 @@ export class ImportSessionFlow {
await expectMobileAgentSidebarVisible(this.page);
}
await openGlobalNewWorkspaceComposer(this.page);
const button = this.page.getByTestId("new-workspace-import-session").filter({ visible: true });
const button = this.page.getByTestId("new-workspace-import-session");
await expect(button).toHaveAccessibleName("Import session");
await expect(button).toBeInViewport();
}
@@ -36,7 +36,7 @@ export class ImportSessionFlow {
await this.openFromNewWorkspace();
}
async openFromNewWorkspace() {
await this.page.getByTestId("new-workspace-import-session").filter({ visible: true }).click();
await this.page.getByTestId("new-workspace-import-session").click();
await this.expectSheetReady();
}
async openFromWorkspaceHeader() {
@@ -86,29 +86,7 @@ export class ImportSessionFlow {
expect((bounds?.x ?? width) + (bounds?.width ?? 1)).toBeLessThanOrEqual(width);
}
async revealSession(id: string) {
const target = this.page.getByTestId(rowTestId(id));
if ((await target.count()) === 0) {
await this.scrollList(false);
await expect(async () => {
if ((await target.count()) === 0) await this.scrollList(true);
await expect(target).toHaveCount(1, { timeout: 100 });
}).toPass({ timeout: 10_000 });
}
await target.scrollIntoViewIfNeeded();
await expect(target).toBeInViewport();
}
private async scrollList(forward: boolean) {
await this.page
.locator('[data-testid^="import-session-session-"]')
.first()
.evaluate((row, advance) => {
let element = row.parentElement;
while (element && !["auto", "scroll"].includes(getComputedStyle(element).overflowY))
element = element.parentElement;
if (!element) throw new Error("Import list has no scroll owner");
element.scrollTop = advance ? element.scrollTop + element.clientHeight * 0.8 : 0;
element.dispatchEvent(new Event("scroll"));
}, forward);
await this.page.getByTestId(rowTestId(id)).scrollIntoViewIfNeeded();
}
async search(query: string) {
await this.page.getByTestId("import-session-search").fill(query);
@@ -122,8 +100,7 @@ export class ImportSessionFlow {
}
async loadMore() {
await this.page.getByTestId("import-session-load-more").click();
await this.revealSession("fixture-root-20");
await expect(this.page.getByText("Root session 20", { exact: true })).toBeInViewport();
await expect(this.page.getByText("Root session 20", { exact: true })).toBeVisible();
await expect(this.page.getByTestId("import-session-load-more")).toHaveCount(0);
}
async retryProvider(provider: string, label: string) {
@@ -134,7 +111,6 @@ export class ImportSessionFlow {
await expect(this.page.getByRole("progressbar")).toHaveCount(0);
}
async importSession(id: string) {
await this.revealSession(id);
await this.page.getByTestId(rowTestId(id)).click();
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0, {
timeout: 30_000,
@@ -162,7 +138,7 @@ export class ImportSessionFlow {
);
}
async close() {
await this.page.getByRole("button", { name: "Close", exact: true }).last().click();
await this.page.keyboard.press("Escape");
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0);
}
async expectCommandCenterMatch() {
@@ -1,4 +0,0 @@
import type { ImportSessionListComponent } from "./import-session-list.types";
// Native keeps the sheet-owned ScrollView until its gestures and keyboard are verified.
export const ImportSessionList: ImportSessionListComponent | null = null;
@@ -1,13 +0,0 @@
import type { ReactElement, ReactNode } from "react";
export interface ImportSessionListProps<Item> {
data: ReadonlyArray<Item>;
keyExtractor: (item: Item) => string;
renderItem: (item: Item) => ReactNode;
header: ReactElement | null;
footer: ReactElement | null;
}
export type ImportSessionListComponent = <Item>(
props: ImportSessionListProps<Item>,
) => ReactElement;
@@ -1,141 +0,0 @@
import React, {
useCallback,
useMemo,
useRef,
useState,
type CSSProperties,
type FocusEvent,
type KeyboardEvent,
} from "react";
import { View, type LayoutChangeEvent } from "react-native";
import { defaultRangeExtractor, useVirtualizer, type Range } from "@tanstack/react-virtual";
import type { ImportSessionListProps } from "./import-session-list.types";
const SCROLLER_STYLE: CSSProperties = {
flex: 1,
minHeight: 0,
overflowY: "auto",
overflowX: "hidden",
overscrollBehavior: "contain",
};
const CONTENT_STYLE: CSSProperties = { width: "100%", position: "relative" };
export function ImportSessionList<Item>({
data,
keyExtractor,
renderItem,
header,
footer,
}: ImportSessionListProps<Item>) {
const scrollRef = useRef<HTMLDivElement>(null);
const [headerHeight, setHeaderHeight] = useState(0);
const [focusedKey, setFocusedKey] = useState<string | null>(null);
const indexByKey = useMemo(
() => new Map(data.map((item, index) => [keyExtractor(item), index])),
[data, keyExtractor],
);
const focusedIndex = focusedKey === null ? undefined : indexByKey.get(focusedKey);
const rangeExtractor = useCallback(
(range: Range) => {
const visible = defaultRangeExtractor(range);
if (focusedIndex === undefined) return visible;
// Keep focus and its Tab neighbours mounted when PageDown/End moves the window.
const focused = [focusedIndex - 1, focusedIndex, focusedIndex + 1].filter(
(index) => index >= 0 && index < data.length,
);
return [...new Set([...visible, ...focused])].sort((left, right) => left - right);
},
[data.length, focusedIndex],
);
const handleFocus = useCallback((event: FocusEvent<HTMLDivElement>) => {
const row = event.target.closest<HTMLElement>("[data-session-key]");
setFocusedKey(row?.dataset.sessionKey ?? null);
}, []);
const handleBlur = useCallback((event: FocusEvent<HTMLDivElement>) => {
if (!event.currentTarget.contains(event.relatedTarget)) setFocusedKey(null);
}, []);
const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => {
setHeaderHeight(event.nativeEvent.layout.height);
}, []);
const getScrollElement = useCallback(() => scrollRef.current, []);
const getItemKey = useCallback(
(index: number) => keyExtractor(data[index]!),
[data, keyExtractor],
);
const estimateSize = useCallback(() => 88, []);
const virtualizer = useVirtualizer({
count: data.length,
getScrollElement,
getItemKey,
estimateSize,
scrollMargin: headerHeight,
overscan: 5,
rangeExtractor,
});
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
const target = event.target;
if (
event.defaultPrevented ||
!(target instanceof HTMLElement) ||
!event.currentTarget.contains(target) ||
target.isContentEditable ||
target.closest("input, textarea, select")
)
return;
if (event.key === "End") {
event.preventDefault();
// Reconcile the landing position as variable-height rows are measured.
virtualizer.scrollToIndex(data.length - 1, { align: "end" });
} else if (event.key === "Home") {
event.preventDefault();
virtualizer.scrollToOffset(0);
} else if (event.key === "PageDown" || event.key === "PageUp") {
event.preventDefault();
// Native page-scroll animations can keep moving after Home or End.
const direction = event.key === "PageDown" ? 1 : -1;
virtualizer.scrollBy(direction * event.currentTarget.clientHeight);
}
},
[data.length, virtualizer],
);
const virtualRows = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
const contentStyle = useMemo(() => ({ ...CONTENT_STYLE, height: totalSize }), [totalSize]);
const rowStyle = useCallback(
(offset: number): CSSProperties => ({
...CONTENT_STYLE,
position: "absolute",
top: 0,
transform: `translateY(${offset}px)`,
}),
[],
);
return (
<div
ref={scrollRef}
style={SCROLLER_STYLE}
data-testid="import-session-list"
onFocusCapture={handleFocus}
onBlurCapture={handleBlur}
onKeyDown={handleKeyDown}
>
<View onLayout={handleHeaderLayout}>{header}</View>
<div style={contentStyle}>
{virtualRows.map((row) => (
<div
key={row.key}
data-index={row.index}
data-session-key={row.key}
ref={virtualizer.measureElement}
style={rowStyle(row.start - headerHeight)}
>
{renderItem(data[row.index]!)}
</div>
))}
</div>
<div>{footer}</div>
</div>
);
}
@@ -1,4 +1,4 @@
import React, { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
import { keepPreviousData, useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
@@ -11,7 +11,6 @@ import { ChevronDown, Inbox, Layers, RotateCw } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { ImportSessionList } from "@/components/import-session-list";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { useProviderIcon, useProviderIcons } from "@/components/provider-icons";
@@ -372,33 +371,7 @@ function ImportSessionActivityTime({ date }: { date: string }) {
return <Text style={styles.rowMeta}>{label}</Text>;
}
function getSessionRowKey(entry: FetchRecentProviderSessionEntry): string {
return `${entry.providerId}:${entry.providerHandleId}`;
}
function hasListHeader(
input: SheetStatusMessagesProps & {
showFilter: boolean;
hasProviderErrors: boolean;
},
): boolean {
return (
input.showFilter ||
!input.isClientReady ||
input.isSnapshotUnsupported ||
input.hasNoImportableProviders ||
(input.isLoadingSessions && !input.hasRows) ||
input.importErrored ||
input.hasProviderErrors
);
}
function SessionRows({
visible,
onClose,
header,
listHeader,
listFooter,
serverId,
entries,
disabled,
@@ -406,11 +379,6 @@ function SessionRows({
resolveFolder,
onImportSession,
}: {
visible: boolean;
onClose: () => void;
header: SheetHeader;
listHeader: ReactNode;
listFooter: ReactNode;
serverId: string | null;
entries: ReadonlyArray<FetchRecentProviderSessionEntry>;
disabled: boolean;
@@ -418,69 +386,20 @@ function SessionRows({
resolveFolder: (entry: FetchRecentProviderSessionEntry) => string | null;
onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
}) {
// Keep short results content-sized; only long lists need a bounded viewport.
const virtualized = ImportSessionList !== null && entries.length > 8;
const renderSessionRow = useCallback(
(entry: FetchRecentProviderSessionEntry) => (
<ImportSessionSheetRow
key={getSessionRowKey(entry)}
serverId={serverId}
entry={entry}
disabled={disabled}
importing={importingSessionKey === getSessionRowKey(entry)}
folder={resolveFolder(entry)}
onImportSession={onImportSession}
/>
),
[serverId, disabled, importingSessionKey, resolveFolder, onImportSession],
);
const renderItem = useCallback(
(item: FetchRecentProviderSessionEntry) => (
<View style={styles.listRow}>{renderSessionRow(item)}</View>
),
[renderSessionRow],
);
const virtualHeader = useMemo(
() => (listHeader ? <View style={styles.listHeader}>{listHeader}</View> : null),
[listHeader],
);
const virtualFooter = useMemo(
() => <View style={styles.listFooter}>{listFooter}</View>,
[listFooter],
);
return (
<AdaptiveModalSheet
visible={visible}
onClose={onClose}
header={header}
testID="import-session-sheet"
desktopMaxWidth={560}
desktopHeight={virtualized ? "85%" : undefined}
snapPoints={IMPORT_SHEET_SNAP_POINTS}
scrollable={!virtualized}
contentStyle={virtualized ? styles.listBody : undefined}
>
{ImportSessionList && virtualized ? (
<View style={styles.list}>
<ImportSessionList
data={entries}
keyExtractor={getSessionRowKey}
renderItem={renderItem}
header={virtualHeader}
footer={virtualFooter}
/>
</View>
) : (
<>
{listHeader}
{entries.length > 0 ? (
<View style={styles.smallList}>{entries.map(renderSessionRow)}</View>
) : null}
{listFooter}
</>
)}
</AdaptiveModalSheet>
<View style={styles.list}>
{entries.map((entry) => (
<ImportSessionSheetRow
key={`${entry.providerId}:${entry.providerHandleId}`}
serverId={serverId}
entry={entry}
disabled={disabled}
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`}
folder={resolveFolder(entry)}
onImportSession={onImportSession}
/>
))}
</View>
);
}
@@ -807,124 +726,85 @@ export function ImportSessionSheet({
});
const showFilter = filterProviders.length > 1;
const showLoadMore = hasMoreSessions(queries, pageLimit);
const showListHeader = hasListHeader({
showFilter,
isClientReady: Boolean(client),
isSnapshotUnsupported,
hasNoImportableProviders,
isLoadingSessions,
hasRows: visibleEntries.length > 0,
importErrored: importMutation.isError,
hasProviderErrors: providerErrorRows.length > 0,
});
const listHeader = useMemo(
() =>
showListHeader ? (
<View style={styles.listHeaderContent}>
{showFilter ? (
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable
onPress={handleFilterOpen}
style={filterTriggerStyle}
testID="import-session-filter-trigger"
accessibilityRole="button"
accessibilityLabel={`Filter: ${selectedProviderLabel}`}
>
{selectedProvider === ALL_FILTER_VALUE ? (
<Layers size={14} color={theme.colors.foregroundMuted} />
) : (
<SelectedProviderIcon size={14} color={theme.colors.foregroundMuted} />
)}
<Text style={styles.filterTriggerText} numberOfLines={1}>
{selectedProviderLabel}
</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</Pressable>
<Combobox
options={filterComboboxOptions}
value={selectedProvider}
onSelect={handleFilterSelect}
renderOption={renderFilterOption}
searchable={false}
title="Filter by provider"
open={isFilterOpen}
onOpenChange={setIsFilterOpen}
anchorRef={filterAnchorRef}
desktopPlacement="bottom-start"
desktopPreventInitialFlash
/>
</View>
) : null}
<SheetStatusMessages
isClientReady={Boolean(client)}
isSnapshotUnsupported={isSnapshotUnsupported}
hasNoImportableProviders={hasNoImportableProviders}
isLoadingSessions={isLoadingSessions}
hasRows={visibleEntries.length > 0}
importErrored={importMutation.isError}
/>
{providerErrorRows.length > 0 ? (
<ProviderErrorBanner rows={providerErrorRows} onRetry={handleRetryProvider} />
) : null}
</View>
) : null,
[
showListHeader,
showFilter,
filterTriggerStyle,
handleFilterOpen,
selectedProvider,
theme.colors.foregroundMuted,
SelectedProviderIcon,
selectedProviderLabel,
filterComboboxOptions,
handleFilterSelect,
renderFilterOption,
isFilterOpen,
client,
isSnapshotUnsupported,
hasNoImportableProviders,
isLoadingSessions,
visibleEntries.length,
importMutation.isError,
providerErrorRows,
handleRetryProvider,
],
);
const listFooter = useMemo(
() => (
<>
{showLoadMore ? (
<View style={styles.footer}>
<Button
variant="ghost"
onPress={handleLoadMore}
disabled={isRefreshing}
testID="import-session-load-more"
>
{t("importSession.actions.loadMore")}
</Button>
</View>
) : null}
{showEmptyState ? <SheetEmptyState title={emptyStateTitle} /> : null}
</>
),
[showLoadMore, handleLoadMore, isRefreshing, t, showEmptyState, emptyStateTitle],
);
return (
<SessionRows
<AdaptiveModalSheet
visible={visible}
onClose={onClose}
header={header}
listHeader={listHeader}
listFooter={listFooter}
serverId={serverId}
entries={visibleEntries}
disabled={importMutation.isPending}
importingSessionKey={importingSessionKey}
resolveFolder={resolveFolder}
onImportSession={handleImportSession}
/>
testID="import-session-sheet"
desktopMaxWidth={560}
snapPoints={IMPORT_SHEET_SNAP_POINTS}
>
{showFilter ? (
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable
onPress={handleFilterOpen}
style={filterTriggerStyle}
testID="import-session-filter-trigger"
accessibilityRole="button"
accessibilityLabel={`Filter: ${selectedProviderLabel}`}
>
{selectedProvider === ALL_FILTER_VALUE ? (
<Layers size={14} color={theme.colors.foregroundMuted} />
) : (
<SelectedProviderIcon size={14} color={theme.colors.foregroundMuted} />
)}
<Text style={styles.filterTriggerText} numberOfLines={1}>
{selectedProviderLabel}
</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</Pressable>
<Combobox
options={filterComboboxOptions}
value={selectedProvider}
onSelect={handleFilterSelect}
renderOption={renderFilterOption}
searchable={false}
title="Filter by provider"
open={isFilterOpen}
onOpenChange={setIsFilterOpen}
anchorRef={filterAnchorRef}
desktopPlacement="bottom-start"
desktopPreventInitialFlash
/>
</View>
) : null}
<SheetStatusMessages
isClientReady={Boolean(client)}
isSnapshotUnsupported={isSnapshotUnsupported}
hasNoImportableProviders={hasNoImportableProviders}
isLoadingSessions={isLoadingSessions}
hasRows={visibleEntries.length > 0}
importErrored={importMutation.isError}
/>
{providerErrorRows.length > 0 ? (
<ProviderErrorBanner rows={providerErrorRows} onRetry={handleRetryProvider} />
) : null}
{visibleEntries.length > 0 ? (
<SessionRows
serverId={serverId}
entries={visibleEntries}
disabled={importMutation.isPending}
importingSessionKey={importingSessionKey}
resolveFolder={resolveFolder}
onImportSession={handleImportSession}
/>
) : null}
{showLoadMore ? (
<View style={styles.footer}>
<Button
variant="ghost"
onPress={handleLoadMore}
disabled={isRefreshing}
testID="import-session-load-more"
>
{t("importSession.actions.loadMore")}
</Button>
</View>
) : null}
{showEmptyState ? <SheetEmptyState title={emptyStateTitle} /> : null}
</AdaptiveModalSheet>
);
}
@@ -973,31 +853,9 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
},
listBody: {
gap: 0,
},
list: {
flex: 1,
minHeight: 0,
marginHorizontal: -theme.spacing[2],
},
listHeader: {
marginHorizontal: theme.spacing[2],
paddingBottom: theme.spacing[4],
},
listHeaderContent: {
gap: theme.spacing[4],
},
smallList: {
gap: theme.spacing[1],
},
listFooter: {
marginHorizontal: theme.spacing[2],
},
listRow: {
marginHorizontal: theme.spacing[2],
paddingBottom: theme.spacing[1],
},
footer: {
paddingTop: theme.spacing[2],
alignItems: "center",
@@ -249,35 +249,6 @@ describe("bare URL sentence punctuation", () => {
]);
});
it.each([
["http://127.0.0.1:8059/?token=t-1", ""],
["http://example.com:8080/a", "."],
["http://localhost:8059/?token=t-1", "."],
["http://localhost:8059/?token=t-1", ")"],
["http://user@127.0.0.1:8059/?token=t-1", '"'],
["http://user:pass@127.0.0.1:8059/?token=t-1", ""],
["http://localhost", ":"],
["http://[::1]:8059/?token=t-1", "'"],
["https://github.com/o/r/compare/main...user:repo:fix/branch", ""],
["https://example.com/a:b/c", ":"],
])("links %s whole and leaves %j outside", async (url, punctuation) => {
const prepared = prepareMarkdownSource(`See ${url}${punctuation} now`);
const tree = recognizeTextLinks(
await parseMarkdown(prepared.render(), { latexMath: false }),
prepared.linkRecognition.text,
prepared.linkRecognition.inlineCode,
);
expect(tree.children?.[0]?.children).toEqual([
{ type: "Text", content: "See " },
{
type: "Link",
attributes: expect.objectContaining({ url }),
children: [{ type: "Text", content: url }],
},
{ type: "Text", content: `${punctuation} now` },
]);
});
it.each(["[Authored](https://example.com/a.)", "<https://example.com/a.>"])(
"preserves punctuation in explicit destinations: %s",
async (source) => {
@@ -27,17 +27,11 @@ const PROSE_PATH = String.raw`(?:${PATH_PREFIX}${HAS_PATH_LETTER}${SEPARATED_PAT
function createFilePattern(path: string): RegExp {
// The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape.
return new RegExp(
String.raw`(?:${BARE_URL}|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
String.raw`(?:https?://[^\s<>]+|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
"g",
);
}
// Matches bare URLs that MD4C does not autolink, like single-label or bracketed hosts.
// Quotes and unbalanced closing parentheses end the URL. Trailing sentence punctuation stays outside.
const URL_CHARACTER = String.raw`[^\s<>()"'\x60]`;
const URL_GROUP = String.raw`\(${URL_CHARACTER}*\)`;
const BARE_URL = String.raw`https?://(?:${URL_CHARACTER}|${URL_GROUP})*(?:[^\s<>()"'\x60.,;:!?]|${URL_GROUP})`;
const FILE_PATTERN = createFilePattern(PROSE_PATH);
function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
// The engine accepts regexes on both platforms. Classify complete code spans
@@ -107,7 +107,7 @@ function createScreenRestorer(
return {
async restore(snapshot: Awaited<ReturnType<typeof benchmarkSnapshot>>) {
drain.restoreSnapshot(snapshot);
drain.restoreText(renderTerminalSnapshotToAnsi(snapshot));
await drain.flush();
frameRecorder.frames[0]?.();
return screen;
@@ -116,6 +116,38 @@ function createScreenRestorer(
}
describe("native terminal output drain", () => {
test.each([41, 35])(
"keeps a restored shell cursor beside its prompt after replay at %i rows",
async (rows) => {
const source = createNativeHeadlessTerminal({ rows: 41, cols: 49 });
await source.write(Array.from({ length: 16 }, (_, i) => `probe ${i}\r\n`).join("") + "$ ");
const snapshot = source.getState();
const terminal = createNativeHeadlessTerminal({ rows, cols: 49 });
const frameRecorder = createFrameRecorder();
const drain = createNativeTerminalOutputDrain({
write: (chunk) => terminal.write(chunk),
reset: () => terminal.reset(),
getViewportState: () => terminal.getViewportState(),
onPaint: () => {},
scheduleFrame: frameRecorder.scheduleFrame,
cancelFrame: frameRecorder.cancelFrame,
});
try {
drain.restoreText(renderTerminalSnapshotToAnsi(snapshot));
await drain.flush();
terminal.resize({ rows: 41, cols: 49 });
const restored = terminal.getState();
const promptRow = restored.grid.findIndex((row) => rowText(row) === "$ ".trimEnd());
expect(promptRow).toBeGreaterThanOrEqual(0);
expect(restored.cursor).toEqual({ row: promptRow, col: 2 });
} finally {
drain.dispose();
terminal.dispose();
source.dispose();
}
},
);
test("drains large bursts without waiting one frame per 2KB parse slice", async () => {
const writes: string[] = [];
const parseSamples: Array<{ parsedChars: number; queuedChars: number }> = [];
@@ -0,0 +1,54 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, test } from "vitest";
import { TerminalGridView } from "./terminal-grid-view.native";
import type { TerminalViewportState } from "./headless-terminal-state";
import type { TerminalGridCellMetrics } from "./terminal-grid-metrics";
test("measures a single font cell before the terminal has its viewport columns", async () => {
const container = document.createElement("div");
container.style.cssText = "width: 400px; height: 700px; display: flex";
document.body.appendChild(container);
const root = createRoot(container);
const measurements: TerminalGridCellMetrics[] = [];
const state: TerminalViewportState = {
rows: 1,
cols: 1,
firstRow: 0,
oldestRow: 0,
newestRow: 0,
grid: [[{ char: " " }]],
cursor: { row: 0, col: 0 },
};
const style = { flex: 1 };
const recordMeasurement = (metrics: TerminalGridCellMetrics) => measurements.push(metrics);
try {
act(() =>
root.render(
React.createElement(TerminalGridView, {
state,
fontSize: 12,
style,
onCellMetricsChange: recordMeasurement,
}),
),
);
await expect.poll(() => measurements.length).toBeGreaterThan(0);
const sample = [...container.querySelectorAll("div")].find(
(element) => element.textContent === "mmmmmmmmmm" && element.childElementCount === 0,
);
expect(sample).toBeDefined();
// Native Text wraps to its containing block. Font measurement must fit even
// when the terminal starts with one column, before layout determines its size.
expect(sample!.parentElement!.getBoundingClientRect().width).toBeGreaterThanOrEqual(
sample!.getBoundingClientRect().width,
);
// The first measurement is used to size the emulator before restore data arrives.
// A wrapped ten-character sample reports a fraction of a cell and multiple lines.
expect(measurements[0].cellWidth).toBeGreaterThan(5);
expect(measurements[0].cellHeight).toBeLessThan(24);
} finally {
act(() => root.unmount());
container.remove();
}
});
@@ -407,10 +407,11 @@ export function TerminalGridView({
return (
<View onLayout={handleContainerLayout} pointerEvents="none" style={containerStyle}>
{/* Measure against the viewport, independently of the grid width derived from these metrics. */}
<Text onLayout={handleMeasure} pointerEvents="none" style={measureStyle}>
{MEASURE_TEXT}
</Text>
<View style={gridStyle}>
<Text onLayout={handleMeasure} pointerEvents="none" style={measureStyle}>
{MEASURE_TEXT}
</Text>
{rows.map((row) => (
<MemoTerminalGridRow
key={row.index}
@@ -1,55 +0,0 @@
import { execFile, spawnSync } from "node:child_process";
import { promisify } from "node:util";
import { expect, test } from "vitest";
const run = promisify(execFile);
const hasSsh = !spawnSync("ssh", ["-V"]).error;
test.skipIf(!hasSsh)(
"an SSH peer abort during a pending write closes the tunnel without crashing its caller",
async () => {
// Isolate the caller so an unhandled stream error fails this assertion instead
// of crashing the test worker. Both the TCP peer and OpenSSH are real.
const { stdout } = await run(
process.execPath,
[
"--import",
"tsx",
"--input-type=module",
"-e",
`
import { createServer, createConnection } from 'node:net';
import { once } from 'node:events';
import { createSshTunnel } from ${JSON.stringify(new URL("./ssh-tunnel.ts", import.meta.url).href)};
const remote = createServer(socket => socket.destroy());
remote.listen(0, '127.0.0.1');
await once(remote, 'listening');
const tunnel = await createSshTunnel({
host: '127.0.0.1', sshPort: remote.address().port, daemonPort: 6767,
});
const [host, port] = tunnel.endpoint.split(':');
const client = createConnection({ host, port: Number(port) });
client.on('error', () => undefined);
try {
const closed = new Promise(resolve => client.once('close', resolve));
await once(client, 'connect');
client.write(Buffer.alloc(8 * 1024 * 1024, 65));
await closed;
console.log(JSON.stringify({ closed: client.destroyed, failure: tunnel.failureDetail() }));
} finally {
client.destroy();
tunnel.close();
await new Promise(resolve => remote.close(resolve));
}
`,
],
{ timeout: 10_000 },
);
expect(JSON.parse(stdout)).toEqual({
closed: true,
failure: expect.any(String),
});
expect(JSON.parse(stdout).failure).not.toBe("");
},
);
+2 -4
View File
@@ -54,12 +54,10 @@ export function createSshTunnel(target: SshTransportTarget): Promise<SshTunnel>
child.stderr.on("data", (chunk: Buffer | string) => {
stderr = `${stderr}${chunk.toString()}`.slice(-SSH_STDERR_LIMIT);
});
const fail = (error: Error) => {
child.on("error", (error) => {
failure = error.message;
acceptedSocket.destroy(error);
};
child.on("error", fail);
child.stdin.on("error", fail);
});
child.on("exit", (code, signal) => {
if (code !== 0 || signal) failure = formatSshFailure(stderr, code, signal);
acceptedSocket.destroy(failure ? new Error(failure) : undefined);
@@ -16,6 +16,7 @@ interface FakeSpawnBehavior {
interface FakeSpawnController {
activeCount: number;
nextPid: number;
peakActiveCount: number;
processes: FakeChildProcess[];
queue: FakeSpawnBehavior[];
@@ -24,6 +25,7 @@ interface FakeSpawnController {
const fakeSpawnController = vi.hoisted<FakeSpawnController>(() => ({
activeCount: 0,
nextPid: 1000,
peakActiveCount: 0,
processes: [],
queue: [],
@@ -33,6 +35,7 @@ const fakeSpawnController = vi.hoisted<FakeSpawnController>(() => ({
}
this.activeCount = 0;
this.nextPid = 1000;
this.peakActiveCount = 0;
this.processes = [];
this.queue = [];
@@ -40,8 +43,7 @@ const fakeSpawnController = vi.hoisted<FakeSpawnController>(() => ({
}));
class FakeChildProcess extends EventEmitter {
// This is not an OS process; tree cleanup must use the direct-child fallback.
public readonly pid = undefined;
public readonly pid: number;
public readonly stderr = new EventEmitter();
public readonly stdout = new EventEmitter();
public killed = false;
@@ -55,6 +57,8 @@ class FakeChildProcess extends EventEmitter {
public constructor(behavior: FakeSpawnBehavior) {
super();
this.behavior = behavior;
this.pid = fakeSpawnController.nextPid;
fakeSpawnController.nextPid += 1;
fakeSpawnController.activeCount += 1;
fakeSpawnController.peakActiveCount = Math.max(
+1 -2
View File
@@ -13,7 +13,6 @@ import {
submitGitCommandTrace,
} from "./git-command-trace.js";
import { spawnProcess } from "./spawn.js";
import { signalProcessTree } from "./tree-kill.js";
import {
GitProcessScheduler,
type GitProcessPriority,
@@ -423,7 +422,7 @@ function executeGitCommand<Output>(
timer = setTimeout(() => {
timeoutError = new Error(`Git command timed out after ${timeout}ms: ${command}`);
void signalProcessTree(child, "SIGKILL");
child.kill("SIGKILL");
settle(() => reject(timeoutError));
if (processExit) {
settleTimeoutTrace(processExit.exitCode, processExit.signal);
@@ -1,17 +1,12 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { createServer, type Socket } from "node:net";
import { afterEach, describe, expect, it } from "vitest";
import { runGitCommand } from "./run-git-command.js";
const tempDirs: string[] = [];
function allowConnectionReset(error: NodeJS.ErrnoException): void {
if (error.code !== "ECONNRESET") throw error;
}
function makeTempRepo(): string {
const repo = mkdtempSync(path.join(tmpdir(), "paseo-git-shell-"));
tempDirs.push(repo);
@@ -25,49 +20,6 @@ afterEach(() => {
});
describe("runGitCommand shell behavior", () => {
it.each(["http", "ssh"])(
"closes a stalled %s fetch connection on timeout and admits the next command",
async (protocol) => {
const repo = makeTempRepo();
await runGitCommand(["init"], { cwd: repo });
const sockets: Socket[] = [];
const server = createServer((socket) => {
sockets.push(socket);
// Force-killing a Windows peer resets the connection instead of sending EOF.
socket.on("error", allowConnectionReset);
// Consume the request but never answer, keeping the real transport helper busy.
socket.resume();
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing TCP address");
const remote = `${protocol}://127.0.0.1:${address.port}/repo.git`;
try {
await expect(
runGitCommand(["fetch", remote, "main"], {
cwd: repo,
timeout: 2000,
envOverlay: {
NO_PROXY: "127.0.0.1",
no_proxy: "127.0.0.1",
GIT_SSH_COMMAND: "ssh -F none -o BatchMode=yes",
},
}),
).rejects.toThrow("Git command timed out after 2000ms");
expect(sockets).toHaveLength(1);
await expect.poll(() => sockets[0]?.destroyed, { timeout: 3000 }).toBe(true);
await expect(runGitCommand(["--version"], { cwd: repo })).resolves.toMatchObject({
exitCode: 0,
truncated: false,
});
} finally {
for (const socket of sockets) socket.destroy();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
},
);
it("passes git arguments directly instead of through the platform shell", async () => {
const repo = makeTempRepo();
const literalName = "%PASEO_GIT_SHELL_SENTINEL%";
+1 -9
View File
@@ -55,18 +55,10 @@ function createLegacyWorktreeForTest(
});
}
function allowConnectionReset(error: NodeJS.ErrnoException): void {
if (error.code !== "ECONNRESET") throw error;
}
// A remote that accepts connections and never answers, like a VPN-only host while off the VPN.
async function startSilentRemote(): Promise<{ url: string; close: () => Promise<void> }> {
const connections = new Set<Socket>();
const server = createServer((socket) => {
connections.add(socket);
// Timeout cleanup force-kills the peer, which can reset its connection on Windows.
socket.on("error", allowConnectionReset);
});
const server = createServer((socket) => connections.add(socket));
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as AddressInfo;
return {
File diff suppressed because one or more lines are too long