authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-29 00:58:38+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-29 00:58:38+02:00
log1ba3d01bc61d142eacda7c45aa3af748e4afa1bb
tree3e9188f25e8c5d83df25531aab8a5a769c360148
parent5fcdefd057751eb511de9e61142ef797f142059b
signature Signed by PGP key B5690EEEBB952194

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

Co-authored-by: codex <codex@users.noreply.github.com>

3 files changed, 276 insertions(+), 48 deletions(-)

apps/web/src/components/CommandPalette.logic.test.ts+103
......@@ -2,7 +2,10 @@ import { describe, expect, it, vi } from "vite-plus/test";
22import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
33import type { Thread } from "../types";
44import {
5 buildBrowseGroups,
56 buildThreadActionItems,
7 canPreloadBrowsePath,
8 createBrowseNavigationCoordinator,
69 enumerateCommandPaletteItems,
710 filterCommandPaletteGroups,
811 type CommandPaletteGroup,
......@@ -193,3 +196,103 @@ describe("buildThreadActionItems", () => {
193196 expect(items.map((item) => item.value)).toEqual(["thread:thread-active"]);
194197 });
195198});
199
200describe("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
238describe("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
291describe("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 {
33 type FilesystemBrowseEntry,
44 THREAD_JUMP_KEYBINDING_COMMANDS,
55} from "@t3tools/contracts";
6import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
67import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings";
78import * as Arr from "effect/Array";
89import * as Result from "effect/Result";
......@@ -15,6 +16,39 @@ export const RECENT_THREAD_LIMIT = 12;
1516export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80";
1617export const ADDON_ICON_CLASS = "size-4";
1718
19export interface BrowseNavigationCoordinator {
20 readonly invalidate: () => void;
21 readonly run: (input: {
22 readonly load: () => Promise<void>;
23 readonly commit: () => void;
24 }) => Promise<boolean>;
25}
26
27export 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
46export function canPreloadBrowsePath(
47 connectionPhase: EnvironmentConnectionPhase | null | undefined,
48): boolean {
49 return connectionPhase === "connected";
50}
51
1852export interface CommandPaletteItem {
1953 readonly kind: "action" | "submenu";
2054 readonly value: string;
......@@ -73,10 +107,8 @@ export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-b
73107export 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: {
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
105131export function normalizeSearchText(value: string): string {
......@@ -302,8 +328,8 @@ export function buildBrowseGroups(input: {
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: {
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: {
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 {
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: {
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: {
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: {
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: {
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: {
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: {
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: {
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: {
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: {
9271004 initialQuery: "",
9281005 });
9291006 },
930 [],
1007 [pushPaletteView],
9311008 );
9321009
9331010 const openSourceControlSettings = useCallback(() => {
......@@ -950,7 +1027,7 @@ function OpenCommandPaletteDialog(props: {
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: {
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: {
10981180 addProjectEnvironmentGroups,
10991181 addProjectEnvironmentOptions.length,
11001182 defaultAddProjectEnvironmentId,
1183 pushPaletteView,
11011184 startAddProjectSourceSelection,
11021185 ]);
11031186
......@@ -1114,6 +1197,7 @@ function OpenCommandPaletteDialog(props: {
11141197 return;
11151198 }
11161199 clearOpenIntent();
1200 browseNavigation.invalidate();
11171201 setAddProjectCloneFlow(null);
11181202 setViewStack([]);
11191203 setQuery("");
......@@ -1139,10 +1223,12 @@ function OpenCommandPaletteDialog(props: {
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: {
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: {
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