diff --git a/packages/app/e2e/browser/command-center-file-search.spec.ts b/packages/app/e2e/browser/command-center-file-search.spec.ts index 5964dcc978..5949882492 100644 --- a/packages/app/e2e/browser/command-center-file-search.spec.ts +++ b/packages/app/e2e/browser/command-center-file-search.spec.ts @@ -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(); + } +}); diff --git a/packages/app/e2e/browser/command-center-scroll.spec.ts b/packages/app/e2e/browser/command-center-scroll.spec.ts index f6762bb4cc..993dd60835 100644 --- a/packages/app/e2e/browser/command-center-scroll.spec.ts +++ b/packages/app/e2e/browser/command-center-scroll.spec.ts @@ -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(); } diff --git a/packages/app/e2e/support/helpers/command-center-file-search.ts b/packages/app/e2e/support/helpers/command-center-file-search.ts index ec6ade4102..eb0b888d24 100644 --- a/packages/app/e2e/support/helpers/command-center-file-search.ts +++ b/packages/app/e2e/support/helpers/command-center-file-search.ts @@ -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(); + }, + }; +} diff --git a/packages/app/e2e/support/helpers/command-center-scroll.ts b/packages/app/e2e/support/helpers/command-center-scroll.ts index dd4712fb36..42287ca693 100644 --- a/packages/app/e2e/support/helpers/command-center-scroll.ts +++ b/packages/app/e2e/support/helpers/command-center-scroll.ts @@ -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 { + return panel.getByTestId("command-center-results").evaluate(async (viewport) => { + const samples: number[] = []; + for (let frame = 0; frame < 30; frame += 1) { + await new Promise((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 }); +} diff --git a/packages/app/e2e/support/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts index 4aec3fff01..8cf2bebb81 100644 --- a/packages/app/e2e/support/helpers/seed-client.ts +++ b/packages/app/e2e/support/helpers/seed-client.ts @@ -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; setWorkspaceLabel(input: { diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx index 58f1a4ce54..88ee10d8bb 100644 --- a/packages/app/src/command-center/command-center.tsx +++ b/packages/app/src/command-center/command-center.tsx @@ -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; offsets: readonly number[]; inputRef: React.RefObject; 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(null); const previousOpenRef = useRef(open); - const [query, setQueryState] = useState(""); - const [activeId, setActiveId] = useState(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>(null); const bottomSheetListRef = useRef(null); const bottomSheetInputRef = useRef(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) => { + interaction.scrolled(event.nativeEvent.contentOffset.y); + }, + [interaction], ); - const handleListScroll = useCallback((event: NativeSyntheticEvent) => { - 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( diff --git a/packages/app/src/command-center/interaction.test.ts b/packages/app/src/command-center/interaction.test.ts new file mode 100644 index 0000000000..5b5d8bdef9 --- /dev/null +++ b/packages/app/src/command-center/interaction.test.ts @@ -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((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([]); + }); +}); diff --git a/packages/app/src/command-center/interaction.ts b/packages/app/src/command-center/interaction.ts new file mode 100644 index 0000000000..8ac0acce15 --- /dev/null +++ b/packages/app/src/command-center/interaction.ts @@ -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;