mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
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>
This commit is contained in:
co-authored by
royenheart
parent
4845e1da58
commit
5dc350034e
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user