Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] f71591d5a3 test(server): accept reset connections from timed-out Git helpers
Force-killed Windows helpers reset their TCP sockets. Silent-remote fixtures must handle ECONNRESET while continuing to fail on unexpected socket errors.
2026-10-25 16:26:14 +00:00
paseo-bot[bot] d5a83362a7 fix(server): stop Git transport helpers on command timeout
Killing only the direct Git process left HTTP and SSH helpers holding connections after a fetch timed out. Use the existing process-tree cleanup so repeated refreshes do not accumulate those helpers.
2026-10-25 16:26:14 +00:00
paseo-bot[bot] d0fd2e5320 fix(client): close failed SSH tunnels without crashing callers (#7876)
Route SSH stdin stream errors through the existing tunnel failure handler so pending writes cannot emit an unhandled EPIPE. Reproduce the peer abort using real OpenSSH in an isolated caller process.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-25 15:50:06 +00:00
paseo-bot[bot]androyenheart 5dc350034e Keep large Import Session lists responsive on Web (#7872)
* Virtualize long Import Session Web lists while preserving native and short-list scrolling

* fix(app): measure import list headers through the existing layout event

Keep the virtualized import list usable in the existing component harness without introducing an observer mock. Import React for the unit JSX transform.

Co-authored-by: royenheart <royenheart@outlook.com>

---------

Co-authored-by: royenheart <royenheart@outlook.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-25 15:05:12 +00:00
4845e1da58 fix(app): link bare URLs with ports, colons and user info as one link (#7859)
MD4C's permissive URL autolink read the host, never a ":port", and
accepted ":" as a link boundary. "http://127.0.0.1:8059/?token=abc"
linked only "http://127.0.0.1". A colon in the path ended the link the
same way.

Port the upstream md4c fix into the dependency patch:
- 3da1937: accept single-component hosts and a numeric port after an
  explicit scheme
- f895c2b: scan the URL tail continuously and trim terminal
  punctuation and excess closing parentheses
- 1645761: refuse an empty hostname
- f9e5eb2: stop the tail rescan from going quadratic on long lines

Also refuse the autolink when a colon after the host continues the URL
("user:pass@host"). Upstream does not parse user info, and the
single-component host rule would link only "http://user". The
text-link pattern links these URLs whole.

Rebuild the web parser with emsdk 4.0.10, which reproduces the
existing glue. That version has no GROWABLE_ARRAYBUFFERS setting, and
the existing artifact was built without it.

The fallback text-link pattern handles URLs MD4C rejects, like
user@host and IPv6 hosts. It now stops at quotes and unbalanced
parentheses and leaves trailing sentence punctuation outside the link.

Closes #7440

Co-authored-by: Mariotaku <git@mariotaku.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 10:01:31 +00:00
15 changed files with 840 additions and 119 deletions
@@ -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
@@ -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("");
},
);
+4 -2
View File
@@ -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(
+2 -1
View File
@@ -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%";
+9 -1
View File
@@ -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