mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 18:52:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f71591d5a3 | ||
|
|
d5a83362a7 | ||
|
|
d0fd2e5320 | ||
|
|
5dc350034e | ||
|
|
4845e1da58 |
@@ -229,6 +229,143 @@ 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");
|
||||
const button = this.page.getByTestId("new-workspace-import-session").filter({ visible: true });
|
||||
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").click();
|
||||
await this.page.getByTestId("new-workspace-import-session").filter({ visible: true }).click();
|
||||
await this.expectSheetReady();
|
||||
}
|
||||
async openFromWorkspaceHeader() {
|
||||
@@ -86,7 +86,29 @@ export class ImportSessionFlow {
|
||||
expect((bounds?.x ?? width) + (bounds?.width ?? 1)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
async revealSession(id: string) {
|
||||
await this.page.getByTestId(rowTestId(id)).scrollIntoViewIfNeeded();
|
||||
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);
|
||||
}
|
||||
async search(query: string) {
|
||||
await this.page.getByTestId("import-session-search").fill(query);
|
||||
@@ -100,7 +122,8 @@ export class ImportSessionFlow {
|
||||
}
|
||||
async loadMore() {
|
||||
await this.page.getByTestId("import-session-load-more").click();
|
||||
await expect(this.page.getByText("Root session 20", { exact: true })).toBeVisible();
|
||||
await this.revealSession("fixture-root-20");
|
||||
await expect(this.page.getByText("Root session 20", { exact: true })).toBeInViewport();
|
||||
await expect(this.page.getByTestId("import-session-load-more")).toHaveCount(0);
|
||||
}
|
||||
async retryProvider(provider: string, label: string) {
|
||||
@@ -111,6 +134,7 @@ 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,
|
||||
@@ -138,7 +162,7 @@ export class ImportSessionFlow {
|
||||
);
|
||||
}
|
||||
async close() {
|
||||
await this.page.keyboard.press("Escape");
|
||||
await this.page.getByRole("button", { name: "Close", exact: true }).last().click();
|
||||
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0);
|
||||
}
|
||||
async expectCommandCenterMatch() {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
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;
|
||||
@@ -0,0 +1,13 @@
|
||||
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;
|
||||
@@ -0,0 +1,141 @@
|
||||
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, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { type ReactNode, 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,6 +11,7 @@ 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";
|
||||
@@ -371,7 +372,33 @@ 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,
|
||||
@@ -379,6 +406,11 @@ function SessionRows({
|
||||
resolveFolder,
|
||||
onImportSession,
|
||||
}: {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
header: SheetHeader;
|
||||
listHeader: ReactNode;
|
||||
listFooter: ReactNode;
|
||||
serverId: string | null;
|
||||
entries: ReadonlyArray<FetchRecentProviderSessionEntry>;
|
||||
disabled: boolean;
|
||||
@@ -386,20 +418,69 @@ 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 (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -726,85 +807,124 @@ 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 (
|
||||
<AdaptiveModalSheet
|
||||
<SessionRows
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
header={header}
|
||||
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>
|
||||
listHeader={listHeader}
|
||||
listFooter={listFooter}
|
||||
serverId={serverId}
|
||||
entries={visibleEntries}
|
||||
disabled={importMutation.isPending}
|
||||
importingSessionKey={importingSessionKey}
|
||||
resolveFolder={resolveFolder}
|
||||
onImportSession={handleImportSession}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -853,9 +973,31 @@ 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,6 +249,35 @@ 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,11 +27,17 @@ 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`(?:https?://[^\s<>]+|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
|
||||
String.raw`(?:${BARE_URL}|(?<![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
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
resolveTerminalGridMetricsMeasurement,
|
||||
resolveMeasuredTerminalCellMetrics,
|
||||
resolveTerminalCursorOffset,
|
||||
resolveTerminalTextLetterSpacing,
|
||||
} from "./terminal-grid-metrics";
|
||||
|
||||
describe("native terminal grid metrics", () => {
|
||||
@@ -64,39 +63,6 @@ describe("native terminal grid metrics", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([1, 2, 2.75, 3, 3.5])(
|
||||
"spaces font glyphs so text ends on the pixel-snapped cell grid at density %s",
|
||||
(density) => {
|
||||
const measurement = {
|
||||
measuredTextWidth: 72.246,
|
||||
measuredTextHeight: 16.2,
|
||||
measureTextLength: 10,
|
||||
roundToNearestPixel: (value: number) => Math.round(value * density) / density,
|
||||
};
|
||||
const { cellWidth } = resolveMeasuredTerminalCellMetrics(measurement);
|
||||
const letterSpacing = resolveTerminalTextLetterSpacing({
|
||||
...measurement,
|
||||
cellWidth,
|
||||
cellsPerGlyph: 1,
|
||||
});
|
||||
const glyphAdvance = measurement.measuredTextWidth / measurement.measureTextLength;
|
||||
|
||||
expect(80 * (glyphAdvance + letterSpacing)).toBeCloseTo(80 * cellWidth, 10);
|
||||
},
|
||||
);
|
||||
|
||||
it("spaces wide fallback glyphs to exactly two cells each", () => {
|
||||
const cellWidth = 22 / 3;
|
||||
const letterSpacing = resolveTerminalTextLetterSpacing({
|
||||
measuredTextWidth: 103.8,
|
||||
measureTextLength: 10,
|
||||
cellWidth,
|
||||
cellsPerGlyph: 2,
|
||||
});
|
||||
|
||||
expect(24 * (10.38 + letterSpacing)).toBeCloseTo(48 * cellWidth, 10);
|
||||
});
|
||||
|
||||
it("emits measured metrics once until the measurement changes", () => {
|
||||
const measured = { cellWidth: 7.2, cellHeight: 16 };
|
||||
|
||||
|
||||
@@ -44,21 +44,6 @@ export function resolveMeasuredTerminalCellMetrics(
|
||||
};
|
||||
}
|
||||
|
||||
export interface TerminalTextLetterSpacingInput {
|
||||
measuredTextWidth: number;
|
||||
measureTextLength: number;
|
||||
cellWidth: number;
|
||||
cellsPerGlyph: 1 | 2;
|
||||
}
|
||||
|
||||
// Cells are snapped to the pixel grid but glyphs advance by their font's natural width, so a run
|
||||
// of N glyphs ends N * (cells * cellWidth - advance) away from its cells and the cursor. The
|
||||
// letter spacing makes up that difference per glyph.
|
||||
export function resolveTerminalTextLetterSpacing(input: TerminalTextLetterSpacingInput): number {
|
||||
const textLength = Math.max(1, input.measureTextLength);
|
||||
return input.cellsPerGlyph * input.cellWidth - input.measuredTextWidth / textLength;
|
||||
}
|
||||
|
||||
export function resolveTerminalGridMetricsMeasurement(
|
||||
previous: TerminalGridCellMetrics | null,
|
||||
next: TerminalGridCellMetrics,
|
||||
|
||||
@@ -25,18 +25,14 @@ import {
|
||||
resolveTerminalCustomGlyphCellTransform,
|
||||
resolveTerminalGridMetricsMeasurement,
|
||||
resolveTerminalCursorOffset,
|
||||
resolveTerminalTextLetterSpacing,
|
||||
type TerminalGridCellMetrics,
|
||||
} from "./terminal-grid-metrics";
|
||||
import {
|
||||
buildRows,
|
||||
TERMINAL_WIDE_GLYPH_CLASSES,
|
||||
type TerminalCustomGlyphCell,
|
||||
type TerminalCustomGlyphRun,
|
||||
type TerminalRowModel,
|
||||
type TerminalRun,
|
||||
type TerminalTextRun,
|
||||
type TerminalWideGlyphClass,
|
||||
} from "./terminal-row-model";
|
||||
import type { TerminalSelectionRange } from "./terminal-selection";
|
||||
|
||||
@@ -44,17 +40,12 @@ const MEASURE_TEXT = "mmmmmmmmmm";
|
||||
const DEFAULT_FONT_SIZE = 12;
|
||||
const INITIAL_CELL_WIDTH_RATIO = 0.62;
|
||||
const INITIAL_CELL_HEIGHT_RATIO = 1.35;
|
||||
// Keep native line layout clear of the cell boundary: rounding and trailing letter spacing can
|
||||
// otherwise wrap a short run. The run box clips ordinary text; isolated fallback glyphs can spill.
|
||||
const TEXT_LAYOUT_OVERFLOW_CELLS = 2;
|
||||
|
||||
interface CellMetrics {
|
||||
cellWidth: number;
|
||||
cellHeight: number;
|
||||
}
|
||||
|
||||
type TerminalWideGlyphStyles = Readonly<Record<TerminalWideGlyphClass, TextStyle>>;
|
||||
|
||||
interface TerminalGridViewport {
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -66,8 +57,6 @@ interface TerminalGridRowProps {
|
||||
cellHeight: number;
|
||||
fontFamily?: string;
|
||||
fontSize: number;
|
||||
letterSpacing: number;
|
||||
wideGlyphStyles: TerminalWideGlyphStyles;
|
||||
styleEpoch: string;
|
||||
}
|
||||
|
||||
@@ -76,7 +65,6 @@ interface TerminalGridRunProps {
|
||||
cellWidth: number;
|
||||
cellHeight: number;
|
||||
textStyle: StyleProp<TextStyle>;
|
||||
wideGlyphStyles: TerminalWideGlyphStyles;
|
||||
}
|
||||
|
||||
export interface TerminalGridViewProps {
|
||||
@@ -111,55 +99,21 @@ function resolveVisibleCols(input: {
|
||||
return Math.min(input.gridCols, Math.max(1, Math.floor(input.viewportWidth / input.cellWidth)));
|
||||
}
|
||||
|
||||
function renderTerminalTextSegments(
|
||||
run: TerminalTextRun,
|
||||
wideGlyphStyles: TerminalWideGlyphStyles,
|
||||
) {
|
||||
if (!run.segments.some((segment) => segment.wideGlyphClass)) {
|
||||
return run.text;
|
||||
}
|
||||
let offset = 0;
|
||||
return run.segments.map((segment) => {
|
||||
const key = `${offset}:${segment.wideGlyphClass ?? "font"}`;
|
||||
offset += segment.text.length;
|
||||
return (
|
||||
<Text
|
||||
key={key}
|
||||
style={segment.wideGlyphClass ? wideGlyphStyles[segment.wideGlyphClass] : undefined}
|
||||
>
|
||||
{segment.text}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function TerminalGridRun({
|
||||
run,
|
||||
cellWidth,
|
||||
cellHeight,
|
||||
textStyle,
|
||||
wideGlyphStyles,
|
||||
}: TerminalGridRunProps) {
|
||||
const isolated = run.renderKind === "text" && run.isolated;
|
||||
function TerminalGridRun({ run, cellWidth, cellHeight, textStyle }: TerminalGridRunProps) {
|
||||
const runStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [
|
||||
isolated ? styles.isolatedRun : styles.run,
|
||||
styles.run,
|
||||
{
|
||||
backgroundColor: run.style.backgroundColor,
|
||||
height: cellHeight,
|
||||
width: run.cellCount * cellWidth,
|
||||
},
|
||||
],
|
||||
[cellHeight, cellWidth, isolated, run.cellCount, run.style.backgroundColor],
|
||||
[cellHeight, cellWidth, run.cellCount, run.style.backgroundColor],
|
||||
);
|
||||
const runTextStyle = useMemo<StyleProp<TextStyle>>(
|
||||
() => [
|
||||
textStyle,
|
||||
run.style,
|
||||
styles.runText,
|
||||
{ width: (run.cellCount + TEXT_LAYOUT_OVERFLOW_CELLS) * cellWidth },
|
||||
],
|
||||
[cellWidth, run.cellCount, run.style, textStyle],
|
||||
() => [textStyle, run.style],
|
||||
[run.style, textStyle],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -167,8 +121,8 @@ function TerminalGridRun({
|
||||
{run.renderKind === "custom-glyph" ? (
|
||||
<TerminalGridCustomGlyphRun run={run} cellWidth={cellWidth} cellHeight={cellHeight} />
|
||||
) : (
|
||||
<Text numberOfLines={1} ellipsizeMode="clip" style={runTextStyle}>
|
||||
{renderTerminalTextSegments(run, wideGlyphStyles)}
|
||||
<Text numberOfLines={1} style={runTextStyle}>
|
||||
{run.text}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
@@ -271,8 +225,7 @@ const MemoTerminalGridRun = memo(TerminalGridRun, (previous, next) => {
|
||||
previous.run === next.run &&
|
||||
previous.cellWidth === next.cellWidth &&
|
||||
previous.cellHeight === next.cellHeight &&
|
||||
previous.textStyle === next.textStyle &&
|
||||
previous.wideGlyphStyles === next.wideGlyphStyles
|
||||
previous.textStyle === next.textStyle
|
||||
);
|
||||
});
|
||||
|
||||
@@ -282,8 +235,6 @@ function TerminalGridRow({
|
||||
cellHeight,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
letterSpacing,
|
||||
wideGlyphStyles,
|
||||
}: TerminalGridRowProps) {
|
||||
const rowStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.row, { height: cellHeight }],
|
||||
@@ -297,10 +248,9 @@ function TerminalGridRow({
|
||||
lineHeight: cellHeight,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
letterSpacing,
|
||||
},
|
||||
],
|
||||
[cellHeight, fontFamily, fontSize, letterSpacing],
|
||||
[cellHeight, fontFamily, fontSize],
|
||||
);
|
||||
const accessibilityLabel = useMemo(
|
||||
() =>
|
||||
@@ -325,7 +275,6 @@ function TerminalGridRow({
|
||||
cellWidth={cellWidth}
|
||||
cellHeight={cellHeight}
|
||||
textStyle={textStyle}
|
||||
wideGlyphStyles={wideGlyphStyles}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
@@ -339,38 +288,10 @@ const MemoTerminalGridRow = memo(TerminalGridRow, (previous, next) => {
|
||||
previous.cellHeight === next.cellHeight &&
|
||||
previous.fontFamily === next.fontFamily &&
|
||||
previous.fontSize === next.fontSize &&
|
||||
previous.letterSpacing === next.letterSpacing &&
|
||||
previous.wideGlyphStyles === next.wideGlyphStyles &&
|
||||
previous.styleEpoch === next.styleEpoch
|
||||
);
|
||||
});
|
||||
|
||||
interface TerminalWideGlyphMeasureProps {
|
||||
glyphClass: TerminalWideGlyphClass;
|
||||
sample: string;
|
||||
style: StyleProp<TextStyle>;
|
||||
onMeasure: (glyphClass: TerminalWideGlyphClass, measuredTextWidth: number) => void;
|
||||
}
|
||||
|
||||
// Measures one wide script's advance in the terminal font's fallback, like MEASURE_TEXT does for
|
||||
// the terminal font itself.
|
||||
function TerminalWideGlyphMeasure({
|
||||
glyphClass,
|
||||
sample,
|
||||
style,
|
||||
onMeasure,
|
||||
}: TerminalWideGlyphMeasureProps) {
|
||||
const handleLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => onMeasure(glyphClass, event.nativeEvent.layout.width),
|
||||
[glyphClass, onMeasure],
|
||||
);
|
||||
return (
|
||||
<Text onLayout={handleLayout} pointerEvents="none" style={style}>
|
||||
{sample.repeat(MEASURE_TEXT.length)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function TerminalGridView({
|
||||
state,
|
||||
xtermTheme = DEFAULT_TERMINAL_THEME,
|
||||
@@ -382,10 +303,6 @@ export function TerminalGridView({
|
||||
}: TerminalGridViewProps) {
|
||||
const [metrics, setMetrics] = useState<CellMetrics>(() => estimateCellMetrics(fontSize));
|
||||
const measuredMetricsRef = useRef<TerminalGridCellMetrics | null>(null);
|
||||
const [letterSpacing, setLetterSpacing] = useState(0);
|
||||
const [wideGlyphWidths, setWideGlyphWidths] = useState<
|
||||
Partial<Record<TerminalWideGlyphClass, number>>
|
||||
>({});
|
||||
const [viewport, setViewport] = useState<TerminalGridViewport | null>(null);
|
||||
const resolvedFontFamily = useMemo(
|
||||
() => resolveNativeTerminalFontFamily(fontFamily),
|
||||
@@ -464,14 +381,6 @@ export function TerminalGridView({
|
||||
measureTextLength: MEASURE_TEXT.length,
|
||||
roundToNearestPixel: (value) => PixelRatio.roundToNearestPixel(value),
|
||||
});
|
||||
setLetterSpacing(
|
||||
resolveTerminalTextLetterSpacing({
|
||||
measuredTextWidth: event.nativeEvent.layout.width,
|
||||
measureTextLength: MEASURE_TEXT.length,
|
||||
cellWidth: nextMetrics.cellWidth,
|
||||
cellsPerGlyph: 1,
|
||||
}),
|
||||
);
|
||||
const changedMetrics = resolveTerminalGridMetricsMeasurement(
|
||||
measuredMetricsRef.current,
|
||||
nextMetrics,
|
||||
@@ -486,35 +395,6 @@ export function TerminalGridView({
|
||||
[onCellMetricsChange],
|
||||
);
|
||||
|
||||
const handleWideGlyphMeasure = useCallback(
|
||||
(glyphClass: TerminalWideGlyphClass, measuredTextWidth: number) => {
|
||||
setWideGlyphWidths((current) =>
|
||||
current[glyphClass] === measuredTextWidth
|
||||
? current
|
||||
: { ...current, [glyphClass]: measuredTextWidth },
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const wideGlyphStyles = useMemo(() => {
|
||||
const entries = TERMINAL_WIDE_GLYPH_CLASSES.map((spec) => {
|
||||
const measuredTextWidth = wideGlyphWidths[spec.name];
|
||||
const spacingStyle: TextStyle = {
|
||||
letterSpacing:
|
||||
measuredTextWidth === undefined
|
||||
? 0
|
||||
: resolveTerminalTextLetterSpacing({
|
||||
measuredTextWidth,
|
||||
measureTextLength: MEASURE_TEXT.length,
|
||||
cellWidth: metrics.cellWidth,
|
||||
cellsPerGlyph: 2,
|
||||
}),
|
||||
};
|
||||
return [spec.name, spacingStyle] as const;
|
||||
});
|
||||
return Object.fromEntries(entries) as TerminalWideGlyphStyles;
|
||||
}, [metrics.cellWidth, wideGlyphWidths]);
|
||||
|
||||
const handleContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
const { width, height } = event.nativeEvent.layout;
|
||||
setViewport((current) => {
|
||||
@@ -531,15 +411,6 @@ export function TerminalGridView({
|
||||
<Text onLayout={handleMeasure} pointerEvents="none" style={measureStyle}>
|
||||
{MEASURE_TEXT}
|
||||
</Text>
|
||||
{TERMINAL_WIDE_GLYPH_CLASSES.map((spec) => (
|
||||
<TerminalWideGlyphMeasure
|
||||
key={spec.name}
|
||||
glyphClass={spec.name}
|
||||
sample={spec.sample}
|
||||
style={measureStyle}
|
||||
onMeasure={handleWideGlyphMeasure}
|
||||
/>
|
||||
))}
|
||||
{rows.map((row) => (
|
||||
<MemoTerminalGridRow
|
||||
key={row.index}
|
||||
@@ -548,8 +419,6 @@ export function TerminalGridView({
|
||||
cellHeight={metrics.cellHeight}
|
||||
fontFamily={resolvedFontFamily}
|
||||
fontSize={fontSize}
|
||||
letterSpacing={letterSpacing}
|
||||
wideGlyphStyles={wideGlyphStyles}
|
||||
styleEpoch={resolver.themeKey}
|
||||
/>
|
||||
))}
|
||||
@@ -572,17 +441,6 @@ const styles = StyleSheet.create({
|
||||
run: {
|
||||
overflow: "hidden",
|
||||
},
|
||||
isolatedRun: {
|
||||
overflow: "visible",
|
||||
},
|
||||
// The run box owns cell dimensions and background. Text gets extra layout room without
|
||||
// changing those dimensions; only isolated fallback glyphs paint beyond the run box.
|
||||
runText: {
|
||||
backgroundColor: "transparent",
|
||||
left: 0,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
},
|
||||
customGlyphRun: {
|
||||
flex: 1,
|
||||
position: "relative",
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createNativeHeadlessTerminal } from "./headless-terminal-state";
|
||||
import { TerminalGridView } from "./terminal-grid-view.native";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("native terminal grid text layout", () => {
|
||||
it("gives short runs between accented glyphs layout room without widening their cells", async () => {
|
||||
const terminal = createNativeHeadlessTerminal({ rows: 1, cols: 30 });
|
||||
try {
|
||||
await terminal.write("café naïve|END");
|
||||
const view = render(<TerminalGridView state={terminal.getViewportState()} />);
|
||||
const text = view.getByText(" na", { normalizer: (value) => value });
|
||||
const run = text.parentElement!;
|
||||
const accentRun = view.getByText("é").parentElement!;
|
||||
const cellWidth = parseFloat(getComputedStyle(accentRun).width);
|
||||
expect(parseFloat(getComputedStyle(run).width)).toBe(3 * cellWidth);
|
||||
|
||||
// Native Text wraps or ellipsizes when rounding or trailing letter spacing puts this
|
||||
// leading-space run just past its layout width. Layout has room; the cell box clips paint.
|
||||
expect(parseFloat(getComputedStyle(text).width)).toBeGreaterThan(3 * cellWidth);
|
||||
expect(getComputedStyle(text).position).toBe("absolute");
|
||||
expect(getComputedStyle(run).overflowX).toBe("hidden");
|
||||
expect(view.getByTestId("terminal-row-0").getAttribute("aria-label")).toBe("café naïve|END");
|
||||
} finally {
|
||||
terminal.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -37,95 +37,7 @@ describe("terminal row model", () => {
|
||||
});
|
||||
|
||||
expect(rows[0].runs.map((run) => ({ text: run.text, cellCount: run.cellCount }))).toEqual([
|
||||
{ text: "A", cellCount: 2 },
|
||||
{ text: "界", cellCount: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps wide scripts in the run as segments the renderer spaces to two cells", () => {
|
||||
const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME);
|
||||
|
||||
const rows = buildRows({
|
||||
grid: [
|
||||
[
|
||||
cell("한", { width: 2 }),
|
||||
cell(" ", { width: 0 }),
|
||||
cell("글", { width: 2 }),
|
||||
cell(" ", { width: 0 }),
|
||||
cell(" ", { width: 1 }),
|
||||
cell("中", { width: 2 }),
|
||||
cell(" ", { width: 0 }),
|
||||
cell("あ", { width: 2 }),
|
||||
cell(" ", { width: 0 }),
|
||||
cell("o", { width: 1 }),
|
||||
cell("k", { width: 1 }),
|
||||
],
|
||||
],
|
||||
resolver,
|
||||
});
|
||||
|
||||
expect(
|
||||
rows[0].runs.map((run) => ({
|
||||
text: run.text,
|
||||
cellCount: run.cellCount,
|
||||
segments: run.renderKind === "text" ? run.segments : null,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
text: "한글 中あok",
|
||||
cellCount: 11,
|
||||
segments: [
|
||||
{ text: "한글", wideGlyphClass: "hangul" },
|
||||
{ text: " ", wideGlyphClass: null },
|
||||
{ text: "中", wideGlyphClass: "han" },
|
||||
{ text: "あ", wideGlyphClass: "kana" },
|
||||
{ text: "ok", wideGlyphClass: null },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("gives wide glyphs outside the spaced scripts their own box", () => {
|
||||
const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME);
|
||||
|
||||
const [row] = buildRows({
|
||||
grid: [
|
||||
[
|
||||
cell("ㄱ", { width: 2 }),
|
||||
cell(" ", { width: 0 }),
|
||||
cell("A", { width: 2 }),
|
||||
cell(" ", { width: 0 }),
|
||||
cell("가", { width: 2 }),
|
||||
cell(" ", { width: 0 }),
|
||||
],
|
||||
],
|
||||
resolver,
|
||||
});
|
||||
|
||||
expect(
|
||||
row.runs.map((run) => [run.text, run.cellCount, run.renderKind === "text" && run.isolated]),
|
||||
).toEqual([
|
||||
["ㄱ", 2, true],
|
||||
["A", 2, true],
|
||||
["가", 2, false],
|
||||
]);
|
||||
});
|
||||
|
||||
test("gives non-ASCII narrow glyphs their own cell", () => {
|
||||
const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME);
|
||||
|
||||
const [row] = buildRows({
|
||||
grid: [
|
||||
["⏺", " ", "e\u0301", " ", "d", "o", "n", "e"].map((char) => cell(char, { width: 1 })),
|
||||
],
|
||||
resolver,
|
||||
});
|
||||
|
||||
expect(row.runs.map((run) => [run.text, run.cellCount])).toEqual([
|
||||
["⏺", 1],
|
||||
[" ", 1],
|
||||
["e\u0301", 1],
|
||||
[" done", 5],
|
||||
{ text: "A界", cellCount: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -27,40 +27,8 @@ interface TerminalRunBase {
|
||||
foregroundColor: string;
|
||||
}
|
||||
|
||||
export type TerminalWideGlyphClass = "hangul" | "han" | "kana";
|
||||
|
||||
interface TerminalWideGlyphClassSpec {
|
||||
name: TerminalWideGlyphClass;
|
||||
sample: string;
|
||||
ranges: ReadonlyArray<readonly [number, number]>;
|
||||
}
|
||||
|
||||
// Wide scripts whose glyphs share one advance within a font. The renderer measures each sample
|
||||
// once and letter-spaces the script so every glyph advances exactly two cells.
|
||||
export const TERMINAL_WIDE_GLYPH_CLASSES: ReadonlyArray<TerminalWideGlyphClassSpec> = [
|
||||
{ name: "hangul", sample: "가", ranges: [[0xac00, 0xd7a3]] },
|
||||
{
|
||||
name: "han",
|
||||
sample: "中",
|
||||
ranges: [
|
||||
[0x3400, 0x4dbf],
|
||||
[0x4e00, 0x9fff],
|
||||
],
|
||||
},
|
||||
{ name: "kana", sample: "あ", ranges: [[0x3041, 0x30ff]] },
|
||||
];
|
||||
|
||||
export interface TerminalTextSegment {
|
||||
text: string;
|
||||
/** Null for terminal-font text, which advances one cell per glyph. */
|
||||
wideGlyphClass: TerminalWideGlyphClass | null;
|
||||
}
|
||||
|
||||
export interface TerminalTextRun extends TerminalRunBase {
|
||||
renderKind: "text";
|
||||
/** Holds one glyph whose fallback-font advance is unknown, drawn in its own box. */
|
||||
isolated: boolean;
|
||||
segments: TerminalTextSegment[];
|
||||
}
|
||||
|
||||
export interface TerminalCustomGlyphRun extends TerminalRunBase {
|
||||
@@ -110,43 +78,6 @@ function terminalCharWidth(char: string): number {
|
||||
return isWide ? 2 : 1;
|
||||
}
|
||||
|
||||
// Text runs render with the font's natural advances. Printable ASCII comes from the terminal font
|
||||
// and the spaced wide scripts get a measured letter spacing, so both stay on the cell grid. Any
|
||||
// other glyph (symbols, emoji, jamo) falls back to a font with an unknown advance; inside a run it
|
||||
// would shift every glyph after it and pull the text away from the cursor.
|
||||
function resolveGlyphSpacing(
|
||||
char: string,
|
||||
cellCount: number,
|
||||
): TerminalWideGlyphClass | null | "isolated" {
|
||||
if (char.length !== 1) {
|
||||
return "isolated";
|
||||
}
|
||||
const code = char.charCodeAt(0);
|
||||
if (cellCount === 1 && code >= 0x20 && code <= 0x7e) {
|
||||
return null;
|
||||
}
|
||||
if (cellCount !== 2) {
|
||||
return "isolated";
|
||||
}
|
||||
const glyphClass = TERMINAL_WIDE_GLYPH_CLASSES.find((spec) =>
|
||||
spec.ranges.some(([start, end]) => code >= start && code <= end),
|
||||
);
|
||||
return glyphClass?.name ?? "isolated";
|
||||
}
|
||||
|
||||
function appendSegment(
|
||||
segments: TerminalTextSegment[],
|
||||
text: string,
|
||||
wideGlyphClass: TerminalWideGlyphClass | null,
|
||||
): void {
|
||||
const last = segments[segments.length - 1];
|
||||
if (last && last.wideGlyphClass === wideGlyphClass) {
|
||||
last.text += text;
|
||||
return;
|
||||
}
|
||||
segments.push({ text, wideGlyphClass });
|
||||
}
|
||||
|
||||
function shouldSkipSpacerCell(cells: TerminalRenderableCell[], col: number, char: string): boolean {
|
||||
if (terminalCharWidth(char) < 2) {
|
||||
return false;
|
||||
@@ -176,23 +107,15 @@ function appendRun(input: {
|
||||
col: number;
|
||||
}): void {
|
||||
const renderKind = input.customGlyph ? "custom-glyph" : "text";
|
||||
const spacing = input.customGlyph ? null : resolveGlyphSpacing(input.text, input.cellCount);
|
||||
const isolated = spacing === "isolated";
|
||||
const wideGlyphClass = spacing === "isolated" ? null : spacing;
|
||||
const previousRun = input.runs[input.runs.length - 1];
|
||||
if (
|
||||
!isolated &&
|
||||
previousRun &&
|
||||
previousRun.styleKey === input.styleKey &&
|
||||
previousRun.renderKind === renderKind &&
|
||||
!(previousRun.renderKind === "text" && previousRun.isolated)
|
||||
previousRun.renderKind === renderKind
|
||||
) {
|
||||
const offset = previousRun.cellCount;
|
||||
previousRun.text += input.text;
|
||||
previousRun.cellCount += input.cellCount;
|
||||
if (previousRun.renderKind === "text") {
|
||||
appendSegment(previousRun.segments, input.text, wideGlyphClass);
|
||||
}
|
||||
if (previousRun.renderKind === "custom-glyph" && input.customGlyph) {
|
||||
previousRun.glyphs.push({
|
||||
key: `${input.col}:${input.text}`,
|
||||
@@ -219,12 +142,7 @@ function appendRun(input: {
|
||||
});
|
||||
return;
|
||||
}
|
||||
input.runs.push({
|
||||
...baseRun,
|
||||
renderKind: "text",
|
||||
isolated,
|
||||
segments: [{ text: input.text, wideGlyphClass }],
|
||||
});
|
||||
input.runs.push({ ...baseRun, renderKind: "text" });
|
||||
}
|
||||
|
||||
function cellIntersectsSelection(input: {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
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("");
|
||||
},
|
||||
);
|
||||
@@ -54,10 +54,12 @@ export function createSshTunnel(target: SshTransportTarget): Promise<SshTunnel>
|
||||
child.stderr.on("data", (chunk: Buffer | string) => {
|
||||
stderr = `${stderr}${chunk.toString()}`.slice(-SSH_STDERR_LIMIT);
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
const fail = (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,7 +16,6 @@ interface FakeSpawnBehavior {
|
||||
|
||||
interface FakeSpawnController {
|
||||
activeCount: number;
|
||||
nextPid: number;
|
||||
peakActiveCount: number;
|
||||
processes: FakeChildProcess[];
|
||||
queue: FakeSpawnBehavior[];
|
||||
@@ -25,7 +24,6 @@ interface FakeSpawnController {
|
||||
|
||||
const fakeSpawnController = vi.hoisted<FakeSpawnController>(() => ({
|
||||
activeCount: 0,
|
||||
nextPid: 1000,
|
||||
peakActiveCount: 0,
|
||||
processes: [],
|
||||
queue: [],
|
||||
@@ -35,7 +33,6 @@ const fakeSpawnController = vi.hoisted<FakeSpawnController>(() => ({
|
||||
}
|
||||
|
||||
this.activeCount = 0;
|
||||
this.nextPid = 1000;
|
||||
this.peakActiveCount = 0;
|
||||
this.processes = [];
|
||||
this.queue = [];
|
||||
@@ -43,7 +40,8 @@ const fakeSpawnController = vi.hoisted<FakeSpawnController>(() => ({
|
||||
}));
|
||||
|
||||
class FakeChildProcess extends EventEmitter {
|
||||
public readonly pid: number;
|
||||
// This is not an OS process; tree cleanup must use the direct-child fallback.
|
||||
public readonly pid = undefined;
|
||||
public readonly stderr = new EventEmitter();
|
||||
public readonly stdout = new EventEmitter();
|
||||
public killed = false;
|
||||
@@ -57,8 +55,6 @@ 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(
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
submitGitCommandTrace,
|
||||
} from "./git-command-trace.js";
|
||||
import { spawnProcess } from "./spawn.js";
|
||||
import { signalProcessTree } from "./tree-kill.js";
|
||||
import {
|
||||
GitProcessScheduler,
|
||||
type GitProcessPriority,
|
||||
@@ -422,7 +423,7 @@ function executeGitCommand<Output>(
|
||||
|
||||
timer = setTimeout(() => {
|
||||
timeoutError = new Error(`Git command timed out after ${timeout}ms: ${command}`);
|
||||
child.kill("SIGKILL");
|
||||
void signalProcessTree(child, "SIGKILL");
|
||||
settle(() => reject(timeoutError));
|
||||
if (processExit) {
|
||||
settleTimeoutTrace(processExit.exitCode, processExit.signal);
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
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);
|
||||
@@ -20,6 +25,49 @@ 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%";
|
||||
|
||||
@@ -55,10 +55,18 @@ 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));
|
||||
const server = createServer((socket) => {
|
||||
connections.add(socket);
|
||||
// Timeout cleanup force-kills the peer, which can reset its connection on Windows.
|
||||
socket.on("error", allowConnectionReset);
|
||||
});
|
||||
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
Reference in New Issue
Block a user