mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 09:42:53 +00:00
fix(app): preserve manual Command Center scrolling during live updates (#7688)
Treat selection and permission to reveal it separately. Result publication and layout updates must not pull a manually scrolled viewport back to the active row; user navigation requests a one-shot reveal.
This commit is contained in:
@@ -2,11 +2,17 @@ import { expect, test } from "../support/fixtures";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import {
|
||||
delayDirectorySuggestionResponses,
|
||||
holdDirectorySuggestionResponses,
|
||||
expectOneLineTruncatedFileResult,
|
||||
expectStableCommandCenterLayout,
|
||||
failDirectorySuggestionRequests,
|
||||
startCommandCenterLayoutObservation,
|
||||
} from "../support/helpers/command-center-file-search";
|
||||
import {
|
||||
scrollCommandCenterAwayFromSelection,
|
||||
expectCommandCenterScrollToStayAt,
|
||||
expectKeyboardNavigationToResume,
|
||||
} from "../support/helpers/command-center-scroll";
|
||||
import { expectFileTabOpen } from "../support/helpers/file-explorer";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
@@ -137,3 +143,62 @@ test("unscoped command search keeps commands visible and reports file-search fai
|
||||
await Promise.allSettled(seeded.map((workspace) => workspace.cleanup()));
|
||||
}
|
||||
});
|
||||
|
||||
test("manual scrolling cancels a pending file-search reveal and query editing restores it", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "command-center-delayed-scroll-",
|
||||
title: "Delayed file search scroll",
|
||||
repo: { files: paseoSizedFiles() },
|
||||
});
|
||||
try {
|
||||
const responses = await holdDirectorySuggestionResponses(page);
|
||||
await gotoWorkspace(page, seeded.workspaceId);
|
||||
await page.keyboard.press("Meta+P");
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible();
|
||||
await panel.getByTestId("command-center-input").fill("index");
|
||||
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
|
||||
await expect(
|
||||
panel.getByRole("button", {
|
||||
name: "index.ts packages/app/src/features/feature-000",
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
responses.hold();
|
||||
await panel.getByTestId("command-center-input").fill("index.");
|
||||
await expect(panel.getByTestId("command-center-file-search-loading")).toBeVisible();
|
||||
const offset = await scrollCommandCenterAwayFromSelection(page, panel);
|
||||
await responses.release();
|
||||
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
|
||||
await expectCommandCenterScrollToStayAt(panel, offset);
|
||||
await expectKeyboardNavigationToResume(page, panel, offset);
|
||||
|
||||
const nextOffset = await scrollCommandCenterAwayFromSelection(page, panel);
|
||||
await panel.getByTestId("command-center-input").fill("index.ts");
|
||||
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
|
||||
await expect
|
||||
.poll(() => panel.getByTestId("command-center-results").evaluate((el) => el.scrollTop))
|
||||
.toBeLessThan(nextOffset);
|
||||
await expect(
|
||||
panel.getByRole("button", {
|
||||
name: "index.ts packages/app/src/features/feature-000",
|
||||
exact: true,
|
||||
}),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.press("Meta+P");
|
||||
await expect(panel.getByTestId("command-center-input")).toHaveValue("");
|
||||
await panel.getByTestId("command-center-input").fill("index");
|
||||
await expect(
|
||||
panel.getByRole("button", {
|
||||
name: "index.ts packages/app/src/features/feature-000",
|
||||
exact: true,
|
||||
}),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
expectCommandCenterToRemainAtTheTop,
|
||||
scrollCommandCenterAwayFromSelection,
|
||||
expectCommandCenterScrollToStayAt,
|
||||
expectKeyboardNavigationToResume,
|
||||
expectPrimaryCommandCenterActions,
|
||||
expectTightTwoLineResultSpacing,
|
||||
expectVisibleKeyboardNavigationNotToScroll,
|
||||
@@ -16,12 +20,15 @@ test.use({
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/145.0 Safari/537.36",
|
||||
});
|
||||
|
||||
test("opening the command center keeps its first result fully visible", async ({ page }) => {
|
||||
test("Command Center preserves manual scrolling through live updates and resumes keyboard navigation", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "command-center-scroll-",
|
||||
title: "Command Center Scroll 00",
|
||||
});
|
||||
|
||||
let newestWorkspaceId = seeded.workspaceId;
|
||||
try {
|
||||
for (let index = 1; index <= 8; index += 1) {
|
||||
const created = await seeded.client.createWorkspace({
|
||||
@@ -36,6 +43,7 @@ test("opening the command center keeps its first result fully visible", async ({
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? `Failed to seed workspace ${index}`);
|
||||
}
|
||||
newestWorkspaceId = created.workspace.id;
|
||||
}
|
||||
|
||||
await gotoAppShell(page);
|
||||
@@ -48,6 +56,11 @@ test("opening the command center keeps its first result fully visible", async ({
|
||||
// The empty query lists the most recent workspaces, so the last one seeded leads it.
|
||||
await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 08");
|
||||
await expectWorkspaceResultsToBeLimitedUntilSearch(panel);
|
||||
const offset = await scrollCommandCenterAwayFromSelection(page, panel);
|
||||
await seeded.client.setWorkspaceTitle(newestWorkspaceId, "Command Center Scroll renamed");
|
||||
await expect(panel.getByText("Command Center Scroll renamed", { exact: true })).toHaveCount(1);
|
||||
await expectCommandCenterScrollToStayAt(panel, offset);
|
||||
await expectKeyboardNavigationToResume(page, panel, offset);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
|
||||
@@ -210,3 +210,30 @@ export async function expectOneLineTruncatedFileResult(
|
||||
expect(overflow.overflow).toBe("hidden");
|
||||
expect(overflow.whiteSpace).toBe("nowrap");
|
||||
}
|
||||
|
||||
/** Hold real daemon responses until the user has finished the interaction under test. */
|
||||
export async function holdDirectorySuggestionResponses(page: Page) {
|
||||
let holding = false;
|
||||
const pending: Array<() => void> = [];
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
if (holding && parseSessionMessage(message)?.type === "directory_suggestions_response") {
|
||||
pending.push(() => ws.send(message));
|
||||
} else {
|
||||
ws.send(message);
|
||||
}
|
||||
});
|
||||
});
|
||||
return {
|
||||
hold() {
|
||||
holding = true;
|
||||
},
|
||||
async release() {
|
||||
await expect.poll(() => pending.length).toBeGreaterThan(0);
|
||||
holding = false;
|
||||
for (const send of pending.splice(0)) send();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -169,3 +169,42 @@ export async function expectTightTwoLineResultSpacing(
|
||||
.evaluate((element) => element.getBoundingClientRect().top);
|
||||
expect(subtitleTop - titleTop).toBeLessThanOrEqual(18);
|
||||
}
|
||||
|
||||
/** Wait for the browser to deliver wheel events before publishing a daemon update. */
|
||||
export async function scrollCommandCenterAwayFromSelection(page: Page, panel: Locator) {
|
||||
await page.setViewportSize({ width: 1280, height: 500 });
|
||||
const viewport = panel.getByTestId("command-center-results");
|
||||
await viewport.hover();
|
||||
await page.mouse.wheel(0, 300);
|
||||
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeGreaterThan(100);
|
||||
const samples = await sampleCommandCenterScroll(panel);
|
||||
const offset = samples.at(-1)!;
|
||||
expect(offset).toBeGreaterThan(100);
|
||||
return offset;
|
||||
}
|
||||
|
||||
async function sampleCommandCenterScroll(panel: Locator): Promise<number[]> {
|
||||
return panel.getByTestId("command-center-results").evaluate(async (viewport) => {
|
||||
const samples: number[] = [];
|
||||
for (let frame = 0; frame < 30; frame += 1) {
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
samples.push(viewport.scrollTop);
|
||||
}
|
||||
return samples;
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectCommandCenterScrollToStayAt(panel: Locator, offset: number) {
|
||||
const samples = await sampleCommandCenterScroll(panel);
|
||||
expect(Math.min(...samples)).toBe(offset);
|
||||
expect(Math.max(...samples)).toBe(offset);
|
||||
}
|
||||
|
||||
export async function expectKeyboardNavigationToResume(page: Page, panel: Locator, offset: number) {
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect
|
||||
.poll(() => panel.getByTestId("command-center-results").evaluate((el) => el.scrollTop))
|
||||
.toBeLessThan(offset);
|
||||
const second = panel.getByTestId("command-center-results").getByRole("button").nth(1);
|
||||
await expect(second).toBeInViewport({ ratio: 1 });
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface SeedDaemonClient {
|
||||
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
|
||||
entries: SeedWorkspaceDescriptor[];
|
||||
}>;
|
||||
setWorkspaceTitle(workspaceId: string, title: string | null): Promise<{ title: string | null }>;
|
||||
setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>;
|
||||
clearWorkspaceAttention(workspaceId: string): Promise<void>;
|
||||
setWorkspaceLabel(input: {
|
||||
|
||||
@@ -10,7 +10,16 @@ import {
|
||||
type NativeSyntheticEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, ChevronRight, Folder, X } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
@@ -69,9 +78,7 @@ import {
|
||||
buildContributionSections,
|
||||
filterAndRankBuiltInResults,
|
||||
joinSubtitleParts,
|
||||
moveActiveResultId,
|
||||
PINNED_SECTION_BAND,
|
||||
preserveActiveResultId,
|
||||
projectCommandCenterRows,
|
||||
type CommandCenterAgentResult,
|
||||
type CommandCenterFileResult,
|
||||
@@ -81,6 +88,7 @@ import {
|
||||
type CommandCenterSearchFields,
|
||||
type CommandCenterWorkspaceResult,
|
||||
} from "./results";
|
||||
import { createCommandCenterInteraction, type CommandCenterInteraction } from "./interaction";
|
||||
import { useWorkspaceFileSearch } from "./workspace-file-search";
|
||||
|
||||
const ThemedBottomSheetTextInput = withUnistyles(TextInput, (theme) => ({
|
||||
@@ -227,8 +235,6 @@ interface CommandCenterState {
|
||||
setQuery(query: string): void;
|
||||
activeId: string | null;
|
||||
rows: readonly CommandCenterListRow[];
|
||||
results: readonly CommandCenterResult[];
|
||||
rowIndexByResultId: ReadonlyMap<string, number>;
|
||||
offsets: readonly number[];
|
||||
inputRef: React.RefObject<EditingTextInputHandle | null>;
|
||||
fileSearchLoading: boolean;
|
||||
@@ -238,7 +244,10 @@ interface CommandCenterState {
|
||||
key(key: string): boolean;
|
||||
}
|
||||
|
||||
function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCenterState {
|
||||
function useCommandCenterState(
|
||||
listMetrics: CommandCenterListMetrics,
|
||||
interaction: CommandCenterInteraction,
|
||||
): CommandCenterState {
|
||||
const keyboardActionDispatcher = useKeyboardActionDispatcher();
|
||||
const { t } = useTranslation();
|
||||
const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen);
|
||||
@@ -248,8 +257,7 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe
|
||||
const snapshot = useCommandCenterContributions();
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const previousOpenRef = useRef(open);
|
||||
const [query, setQueryState] = useState("");
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const { query, activeId } = useSyncExternalStore(interaction.subscribe, interaction.getSnapshot);
|
||||
const builtInSections = useBuiltInSections(open, query);
|
||||
const {
|
||||
entries: fileSearchEntries,
|
||||
@@ -296,15 +304,9 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe
|
||||
),
|
||||
[builtInSections, contributionSections, fileSections, listMetrics, scope],
|
||||
);
|
||||
const resolvedActiveId = preserveActiveResultId(activeId, projection.selectableResults);
|
||||
|
||||
// Editing the query re-ranks everything, so an arrow-key selection made under the previous
|
||||
// query is no longer meaningful. Reset it in the same update as the query rather than in an
|
||||
// effect, so there is no intermediate render holding the stale selection.
|
||||
const setQuery = useCallback((next: string) => {
|
||||
setQueryState(next);
|
||||
setActiveId(null);
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
interaction.update({ open, scope, query, projection, loading: fileSearchLoading });
|
||||
}, [interaction, open, scope, query, projection, fileSearchLoading]);
|
||||
|
||||
const close = useCallback(() => setOpen(false), [setOpen]);
|
||||
const select = useCallback(
|
||||
@@ -327,18 +329,25 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe
|
||||
return true;
|
||||
}
|
||||
if (pressed === "Enter") {
|
||||
const selected = results.find((result) => result.id === resolvedActiveId);
|
||||
const selected = results.find((result) => result.id === activeId);
|
||||
if (!selected) return false;
|
||||
select(selected);
|
||||
return true;
|
||||
}
|
||||
if (pressed !== "ArrowDown" && pressed !== "ArrowUp") return false;
|
||||
if (results.length === 0) return false;
|
||||
const direction = pressed === "ArrowDown" ? "next" : "previous";
|
||||
setActiveId(moveActiveResultId(resolvedActiveId, results, direction));
|
||||
return true;
|
||||
return interaction.move(pressed === "ArrowDown" ? "next" : "previous");
|
||||
},
|
||||
[close, open, projection.selectableResults, query, resolvedActiveId, scope, select, setScope],
|
||||
[
|
||||
close,
|
||||
open,
|
||||
projection.selectableResults,
|
||||
query,
|
||||
activeId,
|
||||
scope,
|
||||
select,
|
||||
setScope,
|
||||
interaction,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -348,8 +357,6 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 0);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setQueryState("");
|
||||
setActiveId(null);
|
||||
if (!wasOpen) return;
|
||||
const element = takeCommandCenterFocusRestoreElement();
|
||||
if (!element) return;
|
||||
@@ -367,11 +374,9 @@ function useCommandCenterState(listMetrics: CommandCenterListMetrics): CommandCe
|
||||
scope,
|
||||
clearScope: () => setScope(null),
|
||||
query,
|
||||
setQuery,
|
||||
activeId: resolvedActiveId,
|
||||
setQuery: interaction.setQuery,
|
||||
activeId,
|
||||
rows: projection.rows,
|
||||
results: projection.selectableResults,
|
||||
rowIndexByResultId: projection.rowIndexByResultId,
|
||||
offsets: projection.offsets,
|
||||
inputRef,
|
||||
fileSearchLoading,
|
||||
@@ -669,62 +674,39 @@ function SectionRow({ row }: { row: Extract<CommandCenterListRow, { kind: "secti
|
||||
export function CommandCenter() {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const state = useCommandCenterState(
|
||||
isCompact ? COMMAND_CENTER_LIST_METRICS.compact : COMMAND_CENTER_LIST_METRICS.desktop,
|
||||
);
|
||||
const showBottomSheet = isCompact && isNative;
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
|
||||
const listRef = useRef<FlatList<CommandCenterListRow>>(null);
|
||||
const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null);
|
||||
const bottomSheetInputRef = useRef<EditingTextInputHandle>(null);
|
||||
const scrollMetricsRef = useRef({ offset: 0, visibleLength: 0 });
|
||||
const [interaction] = useState(() =>
|
||||
createCommandCenterInteraction((offset) => {
|
||||
const list = listRef.current ?? bottomSheetListRef.current;
|
||||
list?.scrollToOffset({ offset, animated: false });
|
||||
}),
|
||||
);
|
||||
const state = useCommandCenterState(
|
||||
isCompact ? COMMAND_CENTER_LIST_METRICS.compact : COMMAND_CENTER_LIST_METRICS.desktop,
|
||||
interaction,
|
||||
);
|
||||
const showBottomSheet = isCompact && isNative;
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
|
||||
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
||||
visible: state.open,
|
||||
isEnabled: showBottomSheet,
|
||||
onClose: state.close,
|
||||
});
|
||||
|
||||
const revealActiveResult = useCallback(() => {
|
||||
if (!state.open || !state.activeId) return;
|
||||
const index = state.rowIndexByResultId.get(state.activeId);
|
||||
if (index === undefined) return;
|
||||
const { offset, visibleLength } = scrollMetricsRef.current;
|
||||
if (visibleLength <= 0) return;
|
||||
const rowTop = state.offsets[index];
|
||||
const rowBottom = rowTop + state.rows[index].height;
|
||||
let nextOffset: number | null = null;
|
||||
if (rowTop < offset) nextOffset = rowTop;
|
||||
if (rowBottom > offset + visibleLength) nextOffset = rowBottom - visibleLength;
|
||||
if (nextOffset === null) return;
|
||||
const ref = showBottomSheet ? bottomSheetListRef.current : listRef.current;
|
||||
const boundedOffset = Math.max(0, nextOffset);
|
||||
scrollMetricsRef.current.offset = boundedOffset;
|
||||
ref?.scrollToOffset({ offset: boundedOffset, animated: false });
|
||||
}, [
|
||||
showBottomSheet,
|
||||
state.activeId,
|
||||
state.offsets,
|
||||
state.open,
|
||||
state.rowIndexByResultId,
|
||||
state.rows,
|
||||
]);
|
||||
useEffect(() => {
|
||||
if (!state.open) {
|
||||
scrollMetricsRef.current = { offset: 0, visibleLength: 0 };
|
||||
return;
|
||||
}
|
||||
revealActiveResult();
|
||||
}, [revealActiveResult, state.open]);
|
||||
const handleListLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
scrollMetricsRef.current.visibleLength = event.nativeEvent.layout.height;
|
||||
revealActiveResult();
|
||||
interaction.layout(event.nativeEvent.layout.height);
|
||||
},
|
||||
[revealActiveResult],
|
||||
[interaction],
|
||||
);
|
||||
const handleListScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
interaction.scrolled(event.nativeEvent.contentOffset.y);
|
||||
},
|
||||
[interaction],
|
||||
);
|
||||
const handleListScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
scrollMetricsRef.current.offset = event.nativeEvent.contentOffset.y;
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!showBottomSheet || !state.open) return;
|
||||
const timer = setTimeout(() => bottomSheetInputRef.current?.focus(), 300);
|
||||
@@ -790,6 +772,14 @@ export function CommandCenter() {
|
||||
windowSize: 5,
|
||||
onLayout: handleListLayout,
|
||||
onScroll: handleListScroll,
|
||||
onScrollBeginDrag: interaction.beginManualScroll,
|
||||
onTouchStart: interaction.beginManualScroll,
|
||||
...(isWeb
|
||||
? {
|
||||
onWheel: interaction.beginManualScroll,
|
||||
onMouseDown: interaction.beginManualScroll,
|
||||
}
|
||||
: {}),
|
||||
scrollEventThrottle: 16,
|
||||
};
|
||||
const keyPress = useCallback(
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCommandCenterInteraction } from "./interaction";
|
||||
import { projectCommandCenterRows, type CommandCenterFileResult } from "./results";
|
||||
import type { CommandCenterScope } from "@/stores/keyboard-shortcuts-store";
|
||||
|
||||
function results(ids = ["a", "b", "c", "d"]) {
|
||||
return projectCommandCenterRows(
|
||||
[
|
||||
{
|
||||
id: "files",
|
||||
band: 0,
|
||||
rank: 0,
|
||||
title: "Files",
|
||||
results: ids.map<CommandCenterFileResult>((id) => ({
|
||||
id,
|
||||
kind: "file",
|
||||
filePath: id,
|
||||
title: id,
|
||||
subtitle: "",
|
||||
run() {},
|
||||
})),
|
||||
},
|
||||
],
|
||||
{ singleLineRow: 40, twoLineRow: 60, sectionTitle: 20, sectionDivider: 10 },
|
||||
);
|
||||
}
|
||||
|
||||
function setup(options: { scope?: CommandCenterScope; measured?: boolean } = {}) {
|
||||
const offsets: number[] = [];
|
||||
const model = createCommandCenterInteraction((offset) => offsets.push(offset));
|
||||
const publish = (ids = ["a", "b", "c", "d"], loading = false) =>
|
||||
model.update({
|
||||
open: true,
|
||||
scope: options.scope ?? null,
|
||||
query: model.getSnapshot().query,
|
||||
projection: results(ids),
|
||||
loading,
|
||||
});
|
||||
publish();
|
||||
if (options.measured !== false) model.layout(80);
|
||||
return { model, offsets, publish };
|
||||
}
|
||||
|
||||
describe("Command Center interaction", () => {
|
||||
it("keeps manual scrolling through result refresh, removal, and resizing", () => {
|
||||
const { model, offsets, publish } = setup();
|
||||
model.beginManualScroll();
|
||||
model.scrolled(100);
|
||||
publish();
|
||||
publish(["b", "c", "d"]);
|
||||
model.layout(60);
|
||||
expect(model.getSnapshot().activeId).toBe("b");
|
||||
expect(offsets).toEqual([]);
|
||||
});
|
||||
|
||||
it("reveals only clipped keyboard selections and wraps around", () => {
|
||||
const { model, offsets } = setup();
|
||||
expect(model.move("next")).toBe(true);
|
||||
expect(offsets).toEqual([20]);
|
||||
model.scrolled(20); // The programmatic scroll callback is only a measurement.
|
||||
model.move("next");
|
||||
expect(offsets).toEqual([20, 60]);
|
||||
model.move("previous");
|
||||
expect(offsets).toEqual([20, 60]);
|
||||
model.move("previous");
|
||||
expect(offsets).toEqual([20, 60, 20]);
|
||||
model.move("previous");
|
||||
expect(model.getSnapshot().activeId).toBe("d");
|
||||
expect(offsets).toEqual([20, 60, 20, 100]);
|
||||
});
|
||||
|
||||
it("keyboard navigation takes control again after manual scrolling", () => {
|
||||
const { model, offsets } = setup();
|
||||
model.beginManualScroll();
|
||||
model.scrolled(100);
|
||||
model.move("next");
|
||||
expect(model.getSnapshot().activeId).toBe("b");
|
||||
expect(offsets).toEqual([60]);
|
||||
});
|
||||
|
||||
it("a new query reveals even when the active ID stays the same", () => {
|
||||
const { model, offsets, publish } = setup();
|
||||
model.scrolled(100);
|
||||
model.setQuery("a");
|
||||
expect(offsets).toEqual([]);
|
||||
publish(["a", "b"]);
|
||||
expect(model.getSnapshot()).toEqual({ query: "a", activeId: "a" });
|
||||
expect(offsets).toEqual([20]);
|
||||
model.scrolled(100);
|
||||
publish(["a", "b"]);
|
||||
expect(offsets).toEqual([20]);
|
||||
});
|
||||
|
||||
it("an arrow command reveals a single result even when its ID cannot change", () => {
|
||||
const { model, offsets, publish } = setup();
|
||||
publish(["a"]);
|
||||
model.scrolled(30);
|
||||
model.move("next");
|
||||
expect(offsets).toEqual([20]);
|
||||
});
|
||||
|
||||
it("defers reveals until layout exists and consumes them only once", () => {
|
||||
const { model, offsets, publish } = setup({ measured: false });
|
||||
model.move("previous");
|
||||
expect(offsets).toEqual([]);
|
||||
model.layout(80);
|
||||
expect(offsets).toEqual([100]);
|
||||
model.scrolled(0);
|
||||
publish();
|
||||
model.layout(60);
|
||||
expect(offsets).toEqual([100]);
|
||||
});
|
||||
|
||||
it("manual interaction cancels an unmeasured request", () => {
|
||||
const { model, offsets } = setup({ measured: false });
|
||||
model.move("previous");
|
||||
model.beginManualScroll();
|
||||
model.layout(80);
|
||||
expect(offsets).toEqual([]);
|
||||
});
|
||||
|
||||
it("waits for scoped search results instead of revealing the previous query's rows", () => {
|
||||
const { model, offsets, publish } = setup({ scope: "files" });
|
||||
model.scrolled(100);
|
||||
model.setQuery("new");
|
||||
publish(["a", "b"], true);
|
||||
expect(offsets).toEqual([]);
|
||||
expect(model.getSnapshot().activeId).toBeNull();
|
||||
publish(["b", "a"]);
|
||||
expect(model.getSnapshot().activeId).toBe("b");
|
||||
expect(offsets).toEqual([20]);
|
||||
});
|
||||
|
||||
it("manual interaction cancels a reveal awaiting search results", () => {
|
||||
const { model, offsets, publish } = setup({ scope: "files" });
|
||||
model.setQuery("new");
|
||||
publish([], true);
|
||||
model.beginManualScroll();
|
||||
model.scrolled(100);
|
||||
publish(["new"]);
|
||||
expect(model.getSnapshot().activeId).toBe("new");
|
||||
expect(offsets).toEqual([]);
|
||||
});
|
||||
|
||||
it("empty searches complete without granting future passive results permission to scroll", () => {
|
||||
const { model, offsets, publish } = setup();
|
||||
model.setQuery("missing");
|
||||
publish([]);
|
||||
expect(model.move("next")).toBe(false);
|
||||
expect(model.getSnapshot().activeId).toBeNull();
|
||||
model.scrolled(100);
|
||||
publish(["late"]);
|
||||
expect(offsets).toEqual([]);
|
||||
});
|
||||
|
||||
it("scope changes reset selection and request a reveal", () => {
|
||||
const { model, offsets } = setup();
|
||||
model.move("next");
|
||||
model.scrolled(100);
|
||||
model.update({ open: true, scope: "files", query: "", projection: results(), loading: false });
|
||||
expect(model.getSnapshot().activeId).toBe("a");
|
||||
expect(offsets).toEqual([20, 20]);
|
||||
});
|
||||
|
||||
it("close discards the old query, metrics, and deferred reveal", () => {
|
||||
const { model, offsets, publish } = setup({ measured: false });
|
||||
model.setQuery("old");
|
||||
publish();
|
||||
model.move("previous");
|
||||
model.update({ open: false, scope: null, query: "old", projection: results(), loading: false });
|
||||
model.layout(80);
|
||||
expect(model.getSnapshot()).toEqual({ query: "", activeId: null });
|
||||
expect(offsets).toEqual([]);
|
||||
publish();
|
||||
expect(model.getSnapshot().activeId).toBe("a");
|
||||
expect(offsets).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { CommandCenterScope } from "@/stores/keyboard-shortcuts-store";
|
||||
import {
|
||||
moveActiveResultId,
|
||||
preserveActiveResultId,
|
||||
type CommandCenterListProjection,
|
||||
} from "./results";
|
||||
|
||||
interface Snapshot {
|
||||
query: string;
|
||||
activeId: string | null;
|
||||
}
|
||||
|
||||
interface ResultsInput {
|
||||
open: boolean;
|
||||
scope: CommandCenterScope;
|
||||
query: string;
|
||||
projection: CommandCenterListProjection;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/** Selection persists; permission to move the viewport lasts for one user command. */
|
||||
export function createCommandCenterInteraction(scrollToOffset: (offset: number) => void) {
|
||||
let snapshot: Snapshot = { query: "", activeId: null };
|
||||
const listeners = new Set<() => void>();
|
||||
let open = false;
|
||||
let scope: CommandCenterScope = null;
|
||||
let projection: CommandCenterListProjection | null = null;
|
||||
let loading = false;
|
||||
let revealPending = false;
|
||||
let offset = 0;
|
||||
let visibleLength = 0;
|
||||
|
||||
function publish(next: Snapshot) {
|
||||
if (next.query === snapshot.query && next.activeId === snapshot.activeId) return;
|
||||
snapshot = next;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function reveal() {
|
||||
if (!open || !revealPending || !projection || visibleLength <= 0) return;
|
||||
// Scoped file search keeps its previous entries while the next query is loading.
|
||||
if (scope === "files" && loading) return;
|
||||
if (!snapshot.activeId) {
|
||||
if (!loading) revealPending = false;
|
||||
return;
|
||||
}
|
||||
const index = projection.rowIndexByResultId.get(snapshot.activeId);
|
||||
if (index === undefined) return;
|
||||
const top = projection.offsets[index];
|
||||
const bottom = top + projection.rows[index].height;
|
||||
revealPending = false;
|
||||
let nextOffset = offset;
|
||||
if (top < offset) nextOffset = top;
|
||||
if (bottom > offset + visibleLength) nextOffset = bottom - visibleLength;
|
||||
nextOffset = Math.max(0, nextOffset);
|
||||
if (nextOffset === offset) return;
|
||||
offset = nextOffset;
|
||||
scrollToOffset(nextOffset);
|
||||
}
|
||||
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
update(input: ResultsInput) {
|
||||
const resetSelection = input.open && (!open || scope !== input.scope);
|
||||
open = input.open;
|
||||
scope = input.scope;
|
||||
if (!open) {
|
||||
projection = null;
|
||||
revealPending = false;
|
||||
offset = 0;
|
||||
visibleLength = 0;
|
||||
publish({ query: "", activeId: null });
|
||||
return;
|
||||
}
|
||||
if (resetSelection) {
|
||||
revealPending = true;
|
||||
publish({ ...snapshot, activeId: null });
|
||||
}
|
||||
// A query command invalidates the old projection until React supplies its new results.
|
||||
if (input.query !== snapshot.query) return;
|
||||
projection = input.projection;
|
||||
loading = input.loading;
|
||||
if (scope !== "files" || !loading) {
|
||||
publish({
|
||||
...snapshot,
|
||||
activeId: preserveActiveResultId(snapshot.activeId, projection.selectableResults),
|
||||
});
|
||||
}
|
||||
reveal();
|
||||
},
|
||||
setQuery(query: string) {
|
||||
projection = null;
|
||||
revealPending = true;
|
||||
publish({ query, activeId: null });
|
||||
},
|
||||
move(direction: "next" | "previous"): boolean {
|
||||
if (
|
||||
!open ||
|
||||
!projection ||
|
||||
(scope === "files" && loading) ||
|
||||
projection.selectableResults.length === 0
|
||||
)
|
||||
return false;
|
||||
publish({
|
||||
...snapshot,
|
||||
activeId: moveActiveResultId(snapshot.activeId, projection.selectableResults, direction),
|
||||
});
|
||||
revealPending = true;
|
||||
reveal();
|
||||
return true;
|
||||
},
|
||||
layout(height: number) {
|
||||
visibleLength = height;
|
||||
reveal();
|
||||
},
|
||||
scrolled(nextOffset: number) {
|
||||
offset = nextOffset;
|
||||
},
|
||||
beginManualScroll() {
|
||||
revealPending = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type CommandCenterInteraction = ReturnType<typeof createCommandCenterInteraction>;
|
||||
Reference in New Issue
Block a user