History

fix(web): defer command palette filesystem navigation (#2109)

Julius Marminge committed 3 months ago

Commit 1ba3d011 parent 5fcdefd3 files · +276 −48Browse files

Unified · Split

3 files
  1. apps/web/src/components/CommandPalette.logic.test.ts +103 −0
  2. apps/web/src/components/CommandPalette.logic.ts +39 −13
  3. apps/web/src/components/CommandPalette.tsx +134 −35
apps/web/src/components/CommandPalette.logic.test.ts+103−0
@@ -2,7 +2,10 @@ import { describe, expect, it, vi } from "vite-plus/test"; · 1 unmodified line
22 import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
33 import type { Thread } from "../types";
44 import {
5+ buildBrowseGroups,
56 buildThreadActionItems,
7+ canPreloadBrowsePath,
8+ createBrowseNavigationCoordinator,
69 enumerateCommandPaletteItems,
710 filterCommandPaletteGroups,
811 type CommandPaletteGroup,
@@ -193,3 +196,103 @@ describe("buildThreadActionItems", () => { · 184 unmodified lines
193196 expect(items.map((item) => item.value)).toEqual(["thread:thread-active"]);
194197 });
195198 });
199+
200+describe("buildBrowseGroups", () => {
201+ it("waits for asynchronous browse navigation actions", async () => {
202+ let finishNavigation: (() => void) | undefined;
203+ const browseTo = vi.fn(
204+ () =>
205+ new Promise<void>((resolve) => {
206+ finishNavigation = resolve;
207+ }),
208+ );
209+ const groups = buildBrowseGroups({
210+ browseEntries: [{ name: "Downloads", fullPath: "/Users/test/Downloads" }],
211+ browseQuery: "~/",
212+ canBrowseUp: false,
213+ upIcon: null,
214+ directoryIcon: null,
215+ browseUp: vi.fn(),
216+ browseTo,
217+ });
218+ const item = groups[0]?.items[0];
219+ if (!item || item.kind !== "action") {
220+ throw new Error("Expected a browse action");
221+ }
222+
223+ let actionSettled = false;
224+ const action = item.run().then(() => {
225+ actionSettled = true;
226+ });
227+ await Promise.resolve();
228+
229+ expect(browseTo).toHaveBeenCalledWith("Downloads");
230+ expect(actionSettled).toBe(false);
231+
232+ finishNavigation?.();
233+ await action;
234+ expect(actionSettled).toBe(true);
235+ });
236+});
237+
238+describe("createBrowseNavigationCoordinator", () => {
239+ it("only commits the latest overlapping navigation", async () => {
240+ const coordinator = createBrowseNavigationCoordinator();
241+ let finishFirst: (() => void) | undefined;
242+ let finishSecond: (() => void) | undefined;
243+ const commits: string[] = [];
244+
245+ const first = coordinator.run({
246+ load: () =>
247+ new Promise<void>((resolve) => {
248+ finishFirst = resolve;
249+ }),
250+ commit: () => {
251+ commits.push("first");
252+ },
253+ });
254+ const second = coordinator.run({
255+ load: () =>
256+ new Promise<void>((resolve) => {
257+ finishSecond = resolve;
258+ }),
259+ commit: () => {
260+ commits.push("second");
261+ },
262+ });
263+
264+ finishSecond?.();
265+ await expect(second).resolves.toBe(true);
266+ finishFirst?.();
267+ await expect(first).resolves.toBe(false);
268+ expect(commits).toEqual(["second"]);
269+ });
270+
271+ it("does not commit after newer user input invalidates the navigation", async () => {
272+ const coordinator = createBrowseNavigationCoordinator();
273+ let finishNavigation: (() => void) | undefined;
274+ const commit = vi.fn();
275+ const navigation = coordinator.run({
276+ load: () =>
277+ new Promise<void>((resolve) => {
278+ finishNavigation = resolve;
279+ }),
280+ commit,
281+ });
282+
283+ coordinator.invalidate();
284+ finishNavigation?.();
285+
286+ await expect(navigation).resolves.toBe(false);
287+ expect(commit).not.toHaveBeenCalled();
288+ });
289+});
290+
291+describe("canPreloadBrowsePath", () => {
292+ it("only preloads paths for connected environments", () => {
293+ expect(canPreloadBrowsePath("connected")).toBe(true);
294+ expect(canPreloadBrowsePath("offline")).toBe(false);
295+ expect(canPreloadBrowsePath("reconnecting")).toBe(false);
296+ expect(canPreloadBrowsePath(null)).toBe(false);
297+ });
298+});
apps/web/src/components/CommandPalette.logic.ts+39−13
@@ -3,6 +3,7 @@ import { · 2 unmodified lines
33 type FilesystemBrowseEntry,
44 THREAD_JUMP_KEYBINDING_COMMANDS,
55 } from "@t3tools/contracts";
6+import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
67 import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings";
78 import * as Arr from "effect/Array";
89 import * as Result from "effect/Result";
@@ -15,6 +16,39 @@ export const RECENT_THREAD_LIMIT = 12; · 6 unmodified lines
1516 export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80";
1617 export const ADDON_ICON_CLASS = "size-4";
1718
19+export interface BrowseNavigationCoordinator {
20+ readonly invalidate: () => void;
21+ readonly run: (input: {
22+ readonly load: () => Promise<void>;
23+ readonly commit: () => void;
24+ }) => Promise<boolean>;
25+}
26+
27+export function createBrowseNavigationCoordinator(): BrowseNavigationCoordinator {
28+ let generation = 0;
29+
30+ return {
31+ invalidate: () => {
32+ generation += 1;
33+ },
34+ run: async (input) => {
35+ const navigationGeneration = ++generation;
36+ await input.load();
37+ if (navigationGeneration !== generation) {
38+ return false;
39+ }
40+ input.commit();
41+ return true;
42+ },
43+ };
44+}
45+
46+export function canPreloadBrowsePath(
47+ connectionPhase: EnvironmentConnectionPhase | null | undefined,
48+): boolean {
49+ return connectionPhase === "connected";
50+}
51+
1852 export interface CommandPaletteItem {
1953 readonly kind: "action" | "submenu";
2054 readonly value: string;
@@ -73,10 +107,8 @@ export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-b · 52 unmodified lines
73107 export function filterBrowseEntries(input: {
74108 browseEntries: ReadonlyArray<FilesystemBrowseEntry>;
75109 browseFilterQuery: string;
76− highlightedItemValue: string | null;
77110 }): {
78111 filteredEntries: FilesystemBrowseEntry[];
79− highlightedEntry: FilesystemBrowseEntry | null;
80112 exactEntry: FilesystemBrowseEntry | null;
81113 } {
82114 const lowerFilter = input.browseFilterQuery.toLowerCase();
@@ -88,18 +120,12 @@ export function filterBrowseEntries(input: { · 5 unmodified lines
88120 (showHidden || !entry.name.startsWith(".")),
89121 );
90122
91− let highlightedEntry: FilesystemBrowseEntry | null = null;
92− if (input.highlightedItemValue?.startsWith("browse:")) {
93− const highlightedPath = input.highlightedItemValue.slice("browse:".length);
94− highlightedEntry = filteredEntries.find((entry) => entry.fullPath === highlightedPath) ?? null;
95− }
96−
97123 const exactEntry =
98124 input.browseFilterQuery.length > 0
99125 ? (filteredEntries.find((entry) => entry.name === input.browseFilterQuery) ?? null)
100126 : null;
101127
102− return { filteredEntries, highlightedEntry, exactEntry };
128+ return { filteredEntries, exactEntry };
103129 }
104130
105131 export function normalizeSearchText(value: string): string {
@@ -302,8 +328,8 @@ export function buildBrowseGroups(input: { · 196 unmodified lines
302328 canBrowseUp: boolean;
303329 upIcon: ReactNode;
304330 directoryIcon: ReactNode;
305− browseUp: () => void;
306− browseTo: (name: string) => void;
331+ browseUp: () => void | Promise<void>;
332+ browseTo: (name: string) => void | Promise<void>;
307333 }): CommandPaletteGroup[] {
308334 const items: CommandPaletteActionItem[] = [];
309335
@@ -316,7 +342,7 @@ export function buildBrowseGroups(input: { · 6 unmodified lines
316342 icon: input.upIcon,
317343 keepOpen: true,
318344 run: async () => {
319− input.browseUp();
345+ await input.browseUp();
320346 },
321347 });
322348 }
@@ -330,7 +356,7 @@ export function buildBrowseGroups(input: { · 7 unmodified lines
330356 icon: input.directoryIcon,
331357 keepOpen: true,
332358 run: async () => {
333− input.browseTo(entry.name);
359+ await input.browseTo(entry.name);
334360 },
335361 });
336362 }
apps/web/src/components/CommandPalette.tsx+134−35
@@ -92,6 +92,8 @@ import { · 91 unmodified lines
9292 buildProjectActionItems,
9393 buildRootGroups,
9494 buildThreadActionItems,
95+ canPreloadBrowsePath,
96+ createBrowseNavigationCoordinator,
9597 enumerateCommandPaletteItems,
9698 type CommandPaletteActionItem,
9799 type CommandPaletteSubmenuItem,
@@ -486,6 +488,10 @@ function OpenCommandPaletteDialog(props: { · 388 unmodified lines
486488 const lookupRepository = useAtomQueryRunner(sourceControlEnvironment.repository, {
487489 reportFailure: false,
488490 });
491+ const loadBrowsePath = useAtomQueryRunner(filesystemEnvironment.browse, {
492+ reportFailure: false,
493+ reportDefect: false,
494+ });
489495 const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, {
490496 reportFailure: false,
491497 });
@@ -502,6 +508,13 @@ function OpenCommandPaletteDialog(props: { · 10 unmodified lines
502508 const [viewStack, setViewStack] = useState<CommandPaletteView[]>([]);
503509 const currentView = viewStack.at(-1) ?? null;
504510 const [browseGeneration, setBrowseGeneration] = useState(0);
511+ const browseNavigationRef = useRef<ReturnType<typeof createBrowseNavigationCoordinator> | null>(
512+ null,
513+ );
514+ if (browseNavigationRef.current === null) {
515+ browseNavigationRef.current = createBrowseNavigationCoordinator();
516+ }
517+ const browseNavigation = browseNavigationRef.current;
505518 const [addProjectEnvironmentId, setAddProjectEnvironmentId] = useState<EnvironmentId | null>(
506519 null,
507520 );
@@ -710,6 +723,11 @@ function OpenCommandPaletteDialog(props: { · 202 unmodified lines
710723 browseEnvironmentId && currentProjectEnvironmentId === browseEnvironmentId
711724 ? currentProjectCwd
712725 : null;
726+ const getBrowseCwdForEnvironment = useCallback(
727+ (environmentId: EnvironmentId | null): string | null =>
728+ environmentId && currentProjectEnvironmentId === environmentId ? currentProjectCwd : null,
729+ [currentProjectCwd, currentProjectEnvironmentId],
730+ );
713731 const relativePathNeedsActiveProject =
714732 isExplicitRelativeProjectPath(query.trim()) && currentProjectCwdForBrowse === null;
715733 const browseDirectoryPath = isBrowsing ? getBrowseDirectoryPath(query) : "";
@@ -733,8 +751,42 @@ function OpenCommandPaletteDialog(props: { · 17 unmodified lines
733751 const isBrowsePending = browseQuery.isPending;
734752 const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES;
735753 const { filteredEntries: filteredBrowseEntries, exactEntry: exactBrowseEntry } = useMemo(
736− () => filterBrowseEntries({ browseEntries, browseFilterQuery, highlightedItemValue }),
737− [browseEntries, browseFilterQuery, highlightedItemValue],
754+ () => filterBrowseEntries({ browseEntries, browseFilterQuery }),
755+ [browseEntries, browseFilterQuery],
756+ );
757+
758+ const prefetchBrowsePath = useCallback(
759+ async (
760+ partialPath: string,
761+ environmentId: EnvironmentId | null = browseEnvironmentId,
762+ cwd: string | null = currentProjectCwdForBrowse,
763+ ): Promise<void> => {
764+ if (!environmentId) {
765+ return;
766+ }
767+ const environment = environments.find(
768+ (candidate) => candidate.environmentId === environmentId,
769+ );
770+ if (!canPreloadBrowsePath(environment?.connection.phase)) {
771+ return;
772+ }
773+
774+ await loadBrowsePath({
775+ environmentId,
776+ input: {
777+ partialPath,
778+ ...(cwd ? { cwd } : {}),
779+ },
780+ });
781+ },
782+ [browseEnvironmentId, currentProjectCwdForBrowse, environments, loadBrowsePath],
783+ );
784+
785+ useEffect(
786+ () => () => {
787+ browseNavigation.invalidate();
788+ },
789+ [browseNavigation],
738790 );
739791
740792 const openProjectFromSearch = useMemo(
@@ -865,18 +917,22 @@ function OpenCommandPaletteDialog(props: { · 124 unmodified lines
865917 );
866918 const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT);
867919
868− function pushPaletteView(view: CommandPaletteView): void {
869− setViewStack((previousViews) => [
870− ...previousViews,
871− {
872− addonIcon: view.addonIcon,
873− groups: view.groups,
874− ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}),
875− },
876− ]);
877− setHighlightedItemValue(null);
878− setQuery(view.initialQuery ?? "");
879− }
920+ const pushPaletteView = useCallback(
921+ (view: CommandPaletteView): void => {
922+ browseNavigation.invalidate();
923+ setViewStack((previousViews) => [
924+ ...previousViews,
925+ {
926+ addonIcon: view.addonIcon,
927+ groups: view.groups,
928+ ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}),
929+ },
930+ ]);
931+ setHighlightedItemValue(null);
932+ setQuery(view.initialQuery ?? "");
933+ },
934+ [browseNavigation],
935+ );
880936
881937 function pushView(item: CommandPaletteSubmenuItem): void {
882938 pushPaletteView({
@@ -887,6 +943,7 @@ function OpenCommandPaletteDialog(props: { · 4 unmodified lines
887943 }
888944
889945 function popView(): void {
946+ browseNavigation.invalidate();
890947 setAddProjectCloneFlow(null);
891948 if (viewStack.length <= 1) {
892949 setAddProjectEnvironmentId(null);
@@ -897,6 +954,7 @@ function OpenCommandPaletteDialog(props: { · 4 unmodified lines
897954 }
898955
899956 function handleQueryChange(nextQuery: string): void {
957+ browseNavigation.invalidate();
900958 setHighlightedItemValue(null);
901959 setQuery(nextQuery);
902960 if (nextQuery === "" && currentView?.initialQuery) {
@@ -905,16 +963,35 @@ function OpenCommandPaletteDialog(props: { · 2 unmodified lines
905963 }
906964
907965 const startAddProjectBrowse = useCallback(
908− (environmentId: EnvironmentId): void => {
909− setAddProjectEnvironmentId(environmentId);
910− setAddProjectCloneFlow(null);
911− pushPaletteView({
966+ async (environmentId: EnvironmentId): Promise<void> => {
967+ const initialQuery = getAddProjectInitialQueryForEnvironment(environmentId);
968+ const initialBrowsePath = getBrowseDirectoryPath(initialQuery);
969+ const browseCwd = getBrowseCwdForEnvironment(environmentId);
970+ const view: CommandPaletteView = {
912971 addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />,
913972 groups: [],
914− initialQuery: getAddProjectInitialQueryForEnvironment(environmentId),
973+ initialQuery,
974+ };
975+
976+ await browseNavigation.run({
977+ load: () =>
978+ initialBrowsePath.length > 0
979+ ? prefetchBrowsePath(initialBrowsePath, environmentId, browseCwd)
980+ : Promise.resolve(),
981+ commit: () => {
982+ setAddProjectEnvironmentId(environmentId);
983+ setAddProjectCloneFlow(null);
984+ pushPaletteView(view);
985+ },
915986 });
916987 },
917− [getAddProjectInitialQueryForEnvironment],
988+ [
989+ browseNavigation,
990+ getAddProjectInitialQueryForEnvironment,
991+ getBrowseCwdForEnvironment,
992+ prefetchBrowsePath,
993+ pushPaletteView,
994+ ],
918995 );
919996
920997 const startAddProjectClone = useCallback(
@@ -927,7 +1004,7 @@ function OpenCommandPaletteDialog(props: { · 6 unmodified lines
9271004 initialQuery: "",
9281005 });
9291006 },
930− [],
1007+ [pushPaletteView],
9311008 );
9321009
9331010 const openSourceControlSettings = useCallback(() => {
@@ -950,7 +1027,7 @@ function OpenCommandPaletteDialog(props: { · 16 unmodified lines
9501027 icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />,
9511028 keepOpen: true,
9521029 run: async () => {
953− startAddProjectBrowse(environmentId);
1030+ await startAddProjectBrowse(environmentId);
9541031 },
9551032 },
9561033 ];
@@ -1043,7 +1120,12 @@ function OpenCommandPaletteDialog(props: { · 86 unmodified lines
10431120 ),
10441121 });
10451122 },
1046− [browseEnvironmentId, buildAddProjectSourceGroups, sourceControlDiscovery.data],
1123+ [
1124+ browseEnvironmentId,
1125+ buildAddProjectSourceGroups,
1126+ pushPaletteView,
1127+ sourceControlDiscovery.data,
1128+ ],
10471129 );
10481130
10491131 const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map(
@@ -1098,6 +1180,7 @@ function OpenCommandPaletteDialog(props: { · 48 unmodified lines
10981180 addProjectEnvironmentGroups,
10991181 addProjectEnvironmentOptions.length,
11001182 defaultAddProjectEnvironmentId,
1183+ pushPaletteView,
11011184 startAddProjectSourceSelection,
11021185 ]);
11031186
@@ -1114,6 +1197,7 @@ function OpenCommandPaletteDialog(props: { · 10 unmodified lines
11141197 return;
11151198 }
11161199 clearOpenIntent();
1200+ browseNavigation.invalidate();
11171201 setAddProjectCloneFlow(null);
11181202 setViewStack([]);
11191203 setQuery("");
@@ -1139,10 +1223,12 @@ function OpenCommandPaletteDialog(props: { · 19 unmodified lines
11391223 });
11401224 }, [
11411225 clearOpenIntent,
1226+ browseNavigation,
11421227 currentProjectEnvironmentId,
11431228 currentProjectId,
11441229 openIntent,
11451230 projectThreadItems,
1231+ pushPaletteView,
11461232 ]);
11471233
11481234 const actionItems: Array<CommandPaletteActionItem | CommandPaletteSubmenuItem> = [];
@@ -1225,7 +1311,7 @@ function OpenCommandPaletteDialog(props: { · 76 unmodified lines
12251311 icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />,
12261312 keepOpen: true,
12271313 run: async () => {
1228− startAddProjectBrowse(wslAddProjectEnvironmentOption.environmentId);
1314+ await startAddProjectBrowse(wslAddProjectEnvironmentOption.environmentId);
12291315 },
12301316 });
12311317 }
@@ -1541,23 +1627,36 @@ function OpenCommandPaletteDialog(props: { · 309 unmodified lines
15411627 await handleAddProject(cloneResult.value.cwd);
15421628 }
15431629
1544− function browseTo(name: string): void {
1545− const nextQuery = appendBrowsePathSegment(query, name);
1546− setHighlightedItemValue(null);
1547− setQuery(nextQuery);
1548− setBrowseGeneration((generation) => generation + 1);
1549− }
1630+ const browseTo = useCallback(
1631+ async (name: string): Promise<void> => {
1632+ const nextQuery = appendBrowsePathSegment(query, name);
1633+ await browseNavigation.run({
1634+ load: () => prefetchBrowsePath(getBrowseDirectoryPath(nextQuery)),
1635+ commit: () => {
1636+ setHighlightedItemValue(null);
1637+ setQuery(nextQuery);
1638+ setBrowseGeneration((generation) => generation + 1);
1639+ },
1640+ });
1641+ },
1642+ [browseNavigation, prefetchBrowsePath, query],
1643+ );
15501644
1551− function browseUp(): void {
1645+ const browseUp = useCallback(async (): Promise<void> => {
15521646 const parentPath = getBrowseParentPath(query);
15531647 if (parentPath === null) {
15541648 return;
15551649 }
15561650
1557− setHighlightedItemValue(null);
1558− setQuery(parentPath);
1559− setBrowseGeneration((generation) => generation + 1);
1560− }
1651+ await browseNavigation.run({
1652+ load: () => prefetchBrowsePath(parentPath),
1653+ commit: () => {
1654+ setHighlightedItemValue(null);
1655+ setQuery(parentPath);
1656+ setBrowseGeneration((generation) => generation + 1);
1657+ },
1658+ });
1659+ }, [browseNavigation, prefetchBrowsePath, query]);
15611660
15621661 // Resolve the add-project path from browse data when available. When the
15631662 // query has a trailing separator (e.g. "~/projects/foo/"), parentPath is the