| author | |
| committer | |
| log | 936593c2583c6640a07fc310f97bd5d0c27a7c31 |
| tree | 74432dc149083415e362965b655d7f413ebab396 |
| parent | 2919147c80e4daae326a25f870a1c7b19cd16f42 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>6 files changed, 196 insertions(+), 204 deletions(-)
apps/mobile/src/features/projects/AddProjectScreen.tsx+23-45| ... | ... | @@ -10,16 +10,14 @@ import { |
| 10 | 10 | sortAddProjectProviderSources, |
| 11 | 11 | type AddProjectRemoteSource, |
| 12 | 12 | } from "@t3tools/client-runtime/operations/projects"; |
| 13 | import { | |
| 14 | filterFilesystemBrowseEntries, | |
| 15 | getFilesystemBrowsePath, | |
| 16 | } from "@t3tools/client-runtime/state/filesystem"; | |
| 13 | 17 | import { |
| 14 | 18 | appendBrowsePathSegment, |
| 15 | canNavigateUp, | |
| 16 | 19 | ensureBrowseDirectoryPath, |
| 17 | getBrowseDirectoryPath, | |
| 18 | getBrowseLeafPathSegment, | |
| 19 | getBrowseParentPath, | |
| 20 | hasTrailingPathSeparator, | |
| 21 | 20 | inferProjectTitleFromPath, |
| 22 | isFilesystemBrowseQuery, | |
| 23 | 21 | } from "@t3tools/client-runtime/state/projects"; |
| 24 | 22 | import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 25 | 23 | import { StackActions, useNavigation } from "@react-navigation/native"; |
| ... | ... | @@ -61,11 +59,6 @@ const environmentOptionOrder = Order.mapInput( |
| 61 | 59 | (environment: EnvironmentOption) => ({ label: environment.label }), |
| 62 | 60 | ); |
| 63 | 61 | |
| 64 | const browseEntryOrder = Order.mapInput( | |
| 65 | Order.String, | |
| 66 | (entry: { readonly name: string }) => entry.name, | |
| 67 | ); | |
| 68 | ||
| 69 | 62 | function platformFromOs(os: string | null | undefined): string { |
| 70 | 63 | if (os === "windows") return "Win32"; |
| 71 | 64 | if (os === "darwin") return "MacIntel"; |
| ... | ... | @@ -594,20 +587,13 @@ function FolderBrowser(props: { |
| 594 | 587 | readonly setPathInput: (path: string) => void; |
| 595 | 588 | }) { |
| 596 | 589 | const accentColor = useThemeColor("--color-icon-muted"); |
| 597 | const browseDirectoryPath = useMemo( | |
| 598 | () => | |
| 599 | isFilesystemBrowseQuery(props.pathInput, props.environment.platform) | |
| 600 | ? getBrowseDirectoryPath(props.pathInput) | |
| 601 | : "", | |
| 590 | const browsePath = useMemo( | |
| 591 | () => getFilesystemBrowsePath(props.pathInput, props.environment.platform), | |
| 602 | 592 | [props.environment.platform, props.pathInput], |
| 603 | 593 | ); |
| 604 | const browseFilterQuery = | |
| 605 | browseDirectoryPath.length > 0 && !hasTrailingPathSeparator(props.pathInput) | |
| 606 | ? getBrowseLeafPathSegment(props.pathInput).toLowerCase() | |
| 607 | : ""; | |
| 608 | 594 | const browseInput = useMemo( |
| 609 | () => (browseDirectoryPath.length > 0 ? { partialPath: browseDirectoryPath } : null), | |
| 610 | [browseDirectoryPath], | |
| 595 | () => (browsePath.directoryPath.length > 0 ? { partialPath: browsePath.directoryPath } : null), | |
| 596 | [browsePath.directoryPath], | |
| 611 | 597 | ); |
| 612 | 598 | const browseState = useEnvironmentQuery( |
| 613 | 599 | browseInput === null |
| ... | ... | @@ -617,20 +603,10 @@ function FolderBrowser(props: { |
| 617 | 603 | input: browseInput, |
| 618 | 604 | }), |
| 619 | 605 | ); |
| 620 | const visibleBrowseEntries = useMemo( | |
| 621 | () => | |
| 622 | Arr.sort( | |
| 623 | Arr.filter( | |
| 624 | browseState.data?.entries ?? [], | |
| 625 | (entry) => | |
| 626 | !entry.name.startsWith(".") && entry.name.toLowerCase().startsWith(browseFilterQuery), | |
| 627 | ), | |
| 628 | browseEntryOrder, | |
| 629 | ), | |
| 630 | [browseFilterQuery, browseState.data?.entries], | |
| 606 | const { visibleEntries: visibleBrowseEntries } = useMemo( | |
| 607 | () => filterFilesystemBrowseEntries(browseState.data?.entries ?? [], browsePath.filterQuery), | |
| 608 | [browsePath.filterQuery, browseState.data?.entries], | |
| 631 | 609 | ); |
| 632 | const parentBrowsePath = getBrowseParentPath(browseDirectoryPath); | |
| 633 | const canBrowseUpPath = canNavigateUp(browseDirectoryPath); | |
| 634 | 610 | |
| 635 | 611 | return ( |
| 636 | 612 | <> |
| ... | ... | @@ -642,7 +618,7 @@ function FolderBrowser(props: { |
| 642 | 618 | <ActivityIndicator color={accentColor} /> |
| 643 | 619 | </View> |
| 644 | 620 | ) : null} |
| 645 | {canBrowseUpPath ? ( | |
| 621 | {browsePath.canBrowseUp ? ( | |
| 646 | 622 | <ListRow |
| 647 | 623 | title=".." |
| 648 | 624 | icon={ |
| ... | ... | @@ -656,7 +632,9 @@ function FolderBrowser(props: { |
| 656 | 632 | isFirst |
| 657 | 633 | right={null} |
| 658 | 634 | onPress={() => { |
| 659 | if (parentBrowsePath) props.setPathInput(parentBrowsePath); | |
| 635 | if (browsePath.parentPath) { | |
| 636 | props.setPathInput(browsePath.parentPath); | |
| 637 | } | |
| 660 | 638 | }} |
| 661 | 639 | /> |
| 662 | 640 | ) : null} |
| ... | ... | @@ -665,15 +643,15 @@ function FolderBrowser(props: { |
| 665 | 643 | key={entry.fullPath} |
| 666 | 644 | title={entry.name} |
| 667 | 645 | icon={<SymbolView name="folder" size={17} tintColor={accentColor} type="monochrome" />} |
| 668 | isFirst={index === 0 && !canBrowseUpPath} | |
| 646 | isFirst={index === 0 && !browsePath.canBrowseUp} | |
| 669 | 647 | right={null} |
| 670 | onPress={() => | |
| 671 | props.setPathInput( | |
| 672 | browseDirectoryPath.length > 0 | |
| 673 | ? appendBrowsePathSegment(browseDirectoryPath, entry.name) | |
| 674 | : ensureBrowseDirectoryPath(entry.fullPath), | |
| 675 | ) | |
| 676 | } | |
| 648 | onPress={() => { | |
| 649 | const nextPath = | |
| 650 | browsePath.directoryPath.length > 0 | |
| 651 | ? appendBrowsePathSegment(browsePath.directoryPath, entry.name) | |
| 652 | : ensureBrowseDirectoryPath(entry.fullPath); | |
| 653 | props.setPathInput(nextPath); | |
| 654 | }} | |
| 677 | 655 | /> |
| 678 | 656 | ))} |
| 679 | 657 | </ListSection> |
apps/web/src/components/CommandPalette.logic.test.ts-64| ... | ... | @@ -4,8 +4,6 @@ import type { Thread } from "../types"; |
| 4 | 4 | import { |
| 5 | 5 | buildBrowseGroups, |
| 6 | 6 | buildThreadActionItems, |
| 7 | canPreloadBrowsePath, | |
| 8 | createBrowseNavigationCoordinator, | |
| 9 | 7 | enumerateCommandPaletteItems, |
| 10 | 8 | filterCommandPaletteGroups, |
| 11 | 9 | type CommandPaletteGroup, |
| ... | ... | @@ -234,65 +232,3 @@ describe("buildBrowseGroups", () => { |
| 234 | 232 | expect(actionSettled).toBe(true); |
| 235 | 233 | }); |
| 236 | 234 | }); |
| 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+1-59| ... | ... | @@ -1,9 +1,8 @@ |
| 1 | 1 | import { |
| 2 | type KeybindingCommand, | |
| 3 | 2 | type FilesystemBrowseEntry, |
| 3 | type KeybindingCommand, | |
| 4 | 4 | THREAD_JUMP_KEYBINDING_COMMANDS, |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; | |
| 7 | 6 | import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; |
| 8 | 7 | import * as Arr from "effect/Array"; |
| 9 | 8 | import * as Result from "effect/Result"; |
| ... | ... | @@ -16,39 +15,6 @@ export const RECENT_THREAD_LIMIT = 12; |
| 16 | 15 | export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; |
| 17 | 16 | export const ADDON_ICON_CLASS = "size-4"; |
| 18 | 17 | |
| 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 | ||
| 52 | 18 | export interface CommandPaletteItem { |
| 53 | 19 | readonly kind: "action" | "submenu"; |
| 54 | 20 | readonly value: string; |
| ... | ... | @@ -104,30 +70,6 @@ export function enumerateCommandPaletteItems( |
| 104 | 70 | |
| 105 | 71 | export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse"; |
| 106 | 72 | |
| 107 | export function filterBrowseEntries(input: { | |
| 108 | browseEntries: ReadonlyArray<FilesystemBrowseEntry>; | |
| 109 | browseFilterQuery: string; | |
| 110 | }): { | |
| 111 | filteredEntries: FilesystemBrowseEntry[]; | |
| 112 | exactEntry: FilesystemBrowseEntry | null; | |
| 113 | } { | |
| 114 | const lowerFilter = input.browseFilterQuery.toLowerCase(); | |
| 115 | const showHidden = input.browseFilterQuery.startsWith("."); | |
| 116 | ||
| 117 | const filteredEntries = input.browseEntries.filter( | |
| 118 | (entry) => | |
| 119 | entry.name.toLowerCase().startsWith(lowerFilter) && | |
| 120 | (showHidden || !entry.name.startsWith(".")), | |
| 121 | ); | |
| 122 | ||
| 123 | const exactEntry = | |
| 124 | input.browseFilterQuery.length > 0 | |
| 125 | ? (filteredEntries.find((entry) => entry.name === input.browseFilterQuery) ?? null) | |
| 126 | : null; | |
| 127 | ||
| 128 | return { filteredEntries, exactEntry }; | |
| 129 | } | |
| 130 | ||
| 131 | 73 | export function normalizeSearchText(value: string): string { |
| 132 | 74 | return value.trim().toLowerCase().replace(/\s+/g, " "); |
| 133 | 75 | } |
apps/web/src/components/CommandPalette.tsx+33-34| ... | ... | @@ -1,6 +1,12 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 4 | import { | |
| 5 | canPreloadBrowsePath, | |
| 6 | createBrowseNavigationCoordinator, | |
| 7 | filterFilesystemBrowseEntries, | |
| 8 | getFilesystemBrowsePath, | |
| 9 | } from "@t3tools/client-runtime/state/filesystem"; | |
| 4 | 10 | import { |
| 5 | 11 | isAtomCommandInterrupted, |
| 6 | 12 | settlePromise, |
| ... | ... | @@ -61,16 +67,12 @@ import { useProjects, useThreadShells } from "../state/entities"; |
| 61 | 67 | import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; |
| 62 | 68 | import { |
| 63 | 69 | appendBrowsePathSegment, |
| 64 | canNavigateUp, | |
| 65 | 70 | ensureBrowseDirectoryPath, |
| 66 | 71 | findProjectByPath, |
| 67 | 72 | getBrowseDirectoryPath, |
| 68 | getBrowseLeafPathSegment, | |
| 69 | getBrowseParentPath, | |
| 70 | 73 | hasTrailingPathSeparator, |
| 71 | 74 | inferProjectTitleFromPath, |
| 72 | 75 | isExplicitRelativeProjectPath, |
| 73 | isFilesystemBrowseQuery, | |
| 74 | 76 | isUnsupportedWindowsProjectPath, |
| 75 | 77 | resolveProjectPathForDispatch, |
| 76 | 78 | } from "../lib/projectPaths"; |
| ... | ... | @@ -92,13 +94,10 @@ import { |
| 92 | 94 | buildProjectActionItems, |
| 93 | 95 | buildRootGroups, |
| 94 | 96 | buildThreadActionItems, |
| 95 | canPreloadBrowsePath, | |
| 96 | createBrowseNavigationCoordinator, | |
| 97 | 97 | enumerateCommandPaletteItems, |
| 98 | 98 | type CommandPaletteActionItem, |
| 99 | 99 | type CommandPaletteSubmenuItem, |
| 100 | 100 | type CommandPaletteView, |
| 101 | filterBrowseEntries, | |
| 102 | 101 | filterCommandPaletteGroups, |
| 103 | 102 | getCommandPaletteInputPlaceholder, |
| 104 | 103 | getCommandPaletteMode, |
| ... | ... | @@ -684,8 +683,12 @@ function OpenCommandPaletteDialog(props: { |
| 684 | 683 | ); |
| 685 | 684 | const isRemoteProjectCloneFlow = addProjectCloneFlow !== null; |
| 686 | 685 | const isRemoteProjectRepositoryStep = addProjectCloneFlow?.step === "repository"; |
| 687 | const isBrowsing = | |
| 688 | !isRemoteProjectRepositoryStep && isFilesystemBrowseQuery(query, browseEnvironmentPlatform); | |
| 686 | const browsePath = useMemo( | |
| 687 | () => getFilesystemBrowsePath(query, browseEnvironmentPlatform, !isRemoteProjectRepositoryStep), | |
| 688 | [browseEnvironmentPlatform, isRemoteProjectRepositoryStep, query], | |
| 689 | ); | |
| 690 | const isBrowsing = browsePath.isBrowsing; | |
| 691 | const browseDirectoryPath = browsePath.directoryPath; | |
| 689 | 692 | const paletteMode = getCommandPaletteMode({ currentView, isBrowsing }); |
| 690 | 693 | const getAddProjectInitialQueryForEnvironment = useCallback( |
| 691 | 694 | (environmentId: EnvironmentId | null): string => { |
| ... | ... | @@ -730,18 +733,15 @@ function OpenCommandPaletteDialog(props: { |
| 730 | 733 | ); |
| 731 | 734 | const relativePathNeedsActiveProject = |
| 732 | 735 | isExplicitRelativeProjectPath(query.trim()) && currentProjectCwdForBrowse === null; |
| 733 | const browseDirectoryPath = isBrowsing ? getBrowseDirectoryPath(query) : ""; | |
| 734 | const browseFilterQuery = | |
| 735 | isBrowsing && !hasTrailingPathSeparator(query) ? getBrowseLeafPathSegment(query) : ""; | |
| 736 | 736 | const browseQuery = useEnvironmentQuery( |
| 737 | 737 | isBrowsing && |
| 738 | browseDirectoryPath.length > 0 && | |
| 738 | browsePath.directoryPath.length > 0 && | |
| 739 | 739 | browseEnvironmentId !== null && |
| 740 | 740 | !relativePathNeedsActiveProject |
| 741 | 741 | ? filesystemEnvironment.browse({ |
| 742 | 742 | environmentId: browseEnvironmentId, |
| 743 | 743 | input: { |
| 744 | partialPath: browseDirectoryPath, | |
| 744 | partialPath: browsePath.directoryPath, | |
| 745 | 745 | ...(currentProjectCwdForBrowse ? { cwd: currentProjectCwdForBrowse } : {}), |
| 746 | 746 | }, |
| 747 | 747 | }) |
| ... | ... | @@ -750,9 +750,9 @@ function OpenCommandPaletteDialog(props: { |
| 750 | 750 | const browseResult = browseQuery.data; |
| 751 | 751 | const isBrowsePending = browseQuery.isPending; |
| 752 | 752 | const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES; |
| 753 | const { filteredEntries: filteredBrowseEntries, exactEntry: exactBrowseEntry } = useMemo( | |
| 754 | () => filterBrowseEntries({ browseEntries, browseFilterQuery }), | |
| 755 | [browseEntries, browseFilterQuery], | |
| 753 | const { visibleEntries: visibleBrowseEntries, exactEntry: exactBrowseEntry } = useMemo( | |
| 754 | () => filterFilesystemBrowseEntries(browseEntries, browsePath.filterQuery), | |
| 755 | [browseEntries, browsePath.filterQuery], | |
| 756 | 756 | ); |
| 757 | 757 | |
| 758 | 758 | const prefetchBrowsePath = useCallback( |
| ... | ... | @@ -973,17 +973,17 @@ function OpenCommandPaletteDialog(props: { |
| 973 | 973 | initialQuery, |
| 974 | 974 | }; |
| 975 | 975 | |
| 976 | await browseNavigation.run({ | |
| 977 | load: () => | |
| 976 | await browseNavigation.run( | |
| 977 | () => | |
| 978 | 978 | initialBrowsePath.length > 0 |
| 979 | 979 | ? prefetchBrowsePath(initialBrowsePath, environmentId, browseCwd) |
| 980 | 980 | : Promise.resolve(), |
| 981 | commit: () => { | |
| 981 | () => { | |
| 982 | 982 | setAddProjectEnvironmentId(environmentId); |
| 983 | 983 | setAddProjectCloneFlow(null); |
| 984 | 984 | pushPaletteView(view); |
| 985 | 985 | }, |
| 986 | }); | |
| 986 | ); | |
| 987 | 987 | }, |
| 988 | 988 | [ |
| 989 | 989 | browseNavigation, |
| ... | ... | @@ -1630,33 +1630,33 @@ function OpenCommandPaletteDialog(props: { |
| 1630 | 1630 | const browseTo = useCallback( |
| 1631 | 1631 | async (name: string): Promise<void> => { |
| 1632 | 1632 | const nextQuery = appendBrowsePathSegment(query, name); |
| 1633 | await browseNavigation.run({ | |
| 1634 | load: () => prefetchBrowsePath(getBrowseDirectoryPath(nextQuery)), | |
| 1635 | commit: () => { | |
| 1633 | await browseNavigation.run( | |
| 1634 | () => prefetchBrowsePath(getBrowseDirectoryPath(nextQuery)), | |
| 1635 | () => { | |
| 1636 | 1636 | setHighlightedItemValue(null); |
| 1637 | 1637 | setQuery(nextQuery); |
| 1638 | 1638 | setBrowseGeneration((generation) => generation + 1); |
| 1639 | 1639 | }, |
| 1640 | }); | |
| 1640 | ); | |
| 1641 | 1641 | }, |
| 1642 | 1642 | [browseNavigation, prefetchBrowsePath, query], |
| 1643 | 1643 | ); |
| 1644 | 1644 | |
| 1645 | 1645 | const browseUp = useCallback(async (): Promise<void> => { |
| 1646 | const parentPath = getBrowseParentPath(query); | |
| 1646 | const parentPath = browsePath.parentPath; | |
| 1647 | 1647 | if (parentPath === null) { |
| 1648 | 1648 | return; |
| 1649 | 1649 | } |
| 1650 | 1650 | |
| 1651 | await browseNavigation.run({ | |
| 1652 | load: () => prefetchBrowsePath(parentPath), | |
| 1653 | commit: () => { | |
| 1651 | await browseNavigation.run( | |
| 1652 | () => prefetchBrowsePath(parentPath), | |
| 1653 | () => { | |
| 1654 | 1654 | setHighlightedItemValue(null); |
| 1655 | 1655 | setQuery(parentPath); |
| 1656 | 1656 | setBrowseGeneration((generation) => generation + 1); |
| 1657 | 1657 | }, |
| 1658 | }); | |
| 1659 | }, [browseNavigation, prefetchBrowsePath, query]); | |
| 1658 | ); | |
| 1659 | }, [browseNavigation, browsePath.parentPath, prefetchBrowsePath]); | |
| 1660 | 1660 | |
| 1661 | 1661 | // Resolve the add-project path from browse data when available. When the |
| 1662 | 1662 | // query has a trailing separator (e.g. "~/projects/foo/"), parentPath is the |
| ... | ... | @@ -1666,11 +1666,10 @@ function OpenCommandPaletteDialog(props: { |
| 1666 | 1666 | ? (browseResult?.parentPath ?? query.trim()) |
| 1667 | 1667 | : (exactBrowseEntry?.fullPath ?? query.trim()); |
| 1668 | 1668 | |
| 1669 | const canBrowseUp = | |
| 1670 | isBrowsing && !relativePathNeedsActiveProject && canNavigateUp(browseDirectoryPath); | |
| 1669 | const canBrowseUp = !relativePathNeedsActiveProject && browsePath.canBrowseUp; | |
| 1671 | 1670 | |
| 1672 | 1671 | const browseGroups = buildBrowseGroups({ |
| 1673 | browseEntries: filteredBrowseEntries, | |
| 1672 | browseEntries: visibleBrowseEntries, | |
| 1674 | 1673 | browseQuery: query, |
| 1675 | 1674 | canBrowseUp, |
| 1676 | 1675 | upIcon: <CornerLeftUpIcon className={ITEM_ICON_CLASS} />, |
packages/client-runtime/src/state/filesystem.test.ts created+70| ... | ... | @@ -0,0 +1,70 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | canPreloadBrowsePath, | |
| 5 | createBrowseNavigationCoordinator, | |
| 6 | filterFilesystemBrowseEntries, | |
| 7 | getFilesystemBrowsePath, | |
| 8 | } from "./filesystem.ts"; | |
| 9 | ||
| 10 | describe("filesystem browse model", () => { | |
| 11 | it("derives the browse target and navigation state", () => { | |
| 12 | expect(getFilesystemBrowsePath("~/projects/t3")).toEqual({ | |
| 13 | isBrowsing: true, | |
| 14 | directoryPath: "~/projects/", | |
| 15 | filterQuery: "t3", | |
| 16 | parentPath: "~/", | |
| 17 | canBrowseUp: true, | |
| 18 | }); | |
| 19 | expect(getFilesystemBrowsePath("C:\\Users\\test", "MacIntel").isBrowsing).toBe(false); | |
| 20 | expect(getFilesystemBrowsePath("~/projects/", "", false).isBrowsing).toBe(false); | |
| 21 | }); | |
| 22 | ||
| 23 | it("filters names, hidden directories, and exact matches consistently", () => { | |
| 24 | const entries = [ | |
| 25 | { name: ".config", fullPath: "/Users/test/.config" }, | |
| 26 | { name: "Code", fullPath: "/Users/test/Code" }, | |
| 27 | { name: "codething", fullPath: "/Users/test/codething" }, | |
| 28 | ]; | |
| 29 | ||
| 30 | expect(filterFilesystemBrowseEntries(entries, "co")).toEqual({ | |
| 31 | visibleEntries: entries.slice(1, 3), | |
| 32 | exactEntry: null, | |
| 33 | }); | |
| 34 | expect(filterFilesystemBrowseEntries(entries, "").visibleEntries).toEqual(entries.slice(1)); | |
| 35 | expect(filterFilesystemBrowseEntries(entries, ".").visibleEntries).toEqual(entries.slice(0, 1)); | |
| 36 | expect(filterFilesystemBrowseEntries(entries, "Code").exactEntry).toEqual(entries[1]); | |
| 37 | }); | |
| 38 | }); | |
| 39 | ||
| 40 | describe("browse navigation", () => { | |
| 41 | it("only commits the latest valid navigation", async () => { | |
| 42 | const navigation = createBrowseNavigationCoordinator(); | |
| 43 | const first = Promise.withResolvers<void>(); | |
| 44 | const second = Promise.withResolvers<void>(); | |
| 45 | const commits: string[] = []; | |
| 46 | const commit = (name: string) => () => commits.push(name); | |
| 47 | const firstRun = navigation.run(() => first.promise, commit("first")); | |
| 48 | const secondRun = navigation.run(() => second.promise, commit("second")); | |
| 49 | ||
| 50 | second.resolve(); | |
| 51 | await expect(secondRun).resolves.toBe(true); | |
| 52 | first.resolve(); | |
| 53 | await expect(firstRun).resolves.toBe(false); | |
| 54 | ||
| 55 | const invalidated = Promise.withResolvers<void>(); | |
| 56 | const invalidatedRun = navigation.run(() => invalidated.promise, commit("stale")); | |
| 57 | navigation.invalidate(); | |
| 58 | invalidated.resolve(); | |
| 59 | ||
| 60 | await expect(invalidatedRun).resolves.toBe(false); | |
| 61 | expect(commits).toEqual(["second"]); | |
| 62 | }); | |
| 63 | ||
| 64 | it("only preloads connected environments", () => { | |
| 65 | expect(canPreloadBrowsePath("connected")).toBe(true); | |
| 66 | expect(canPreloadBrowsePath("offline")).toBe(false); | |
| 67 | expect(canPreloadBrowsePath("reconnecting")).toBe(false); | |
| 68 | expect(canPreloadBrowsePath(null)).toBe(false); | |
| 69 | }); | |
| 70 | }); |
packages/client-runtime/src/state/filesystem.ts+69-2| ... | ... | @@ -1,8 +1,75 @@ |
| 1 | import { WS_METHODS } from "@t3tools/contracts"; | |
| 1 | import { type FilesystemBrowseEntry, WS_METHODS } from "@t3tools/contracts"; | |
| 2 | 2 | import { Atom } from "effect/unstable/reactivity"; |
| 3 | 3 | |
| 4 | import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; | |
| 4 | import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; | |
| 5 | 5 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| 6 | import { | |
| 7 | canNavigateUp, | |
| 8 | getBrowseDirectoryPath, | |
| 9 | getBrowseLeafPathSegment, | |
| 10 | getBrowseParentPath, | |
| 11 | hasTrailingPathSeparator, | |
| 12 | isFilesystemBrowseQuery, | |
| 13 | } from "./projects.ts"; | |
| 14 | import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; | |
| 15 | ||
| 16 | export function getFilesystemBrowsePath(query: string, platform = "", enabled = true) { | |
| 17 | const isBrowsing = enabled && isFilesystemBrowseQuery(query, platform); | |
| 18 | const directoryPath = isBrowsing ? getBrowseDirectoryPath(query) : ""; | |
| 19 | const filterQuery = | |
| 20 | isBrowsing && !hasTrailingPathSeparator(query) ? getBrowseLeafPathSegment(query) : ""; | |
| 21 | const parentPath = isBrowsing ? getBrowseParentPath(directoryPath) : null; | |
| 22 | ||
| 23 | return { | |
| 24 | isBrowsing, | |
| 25 | directoryPath, | |
| 26 | filterQuery, | |
| 27 | parentPath, | |
| 28 | canBrowseUp: isBrowsing && canNavigateUp(directoryPath), | |
| 29 | }; | |
| 30 | } | |
| 31 | ||
| 32 | export function filterFilesystemBrowseEntries( | |
| 33 | entries: ReadonlyArray<FilesystemBrowseEntry>, | |
| 34 | query: string, | |
| 35 | ) { | |
| 36 | const lowerQuery = query.toLowerCase(); | |
| 37 | const showHidden = query.startsWith("."); | |
| 38 | const visibleEntries = entries.filter( | |
| 39 | (entry) => | |
| 40 | entry.name.toLowerCase().startsWith(lowerQuery) && | |
| 41 | (showHidden || !entry.name.startsWith(".")), | |
| 42 | ); | |
| 43 | const exactEntry = | |
| 44 | query.length > 0 ? (visibleEntries.find((entry) => entry.name === query) ?? null) : null; | |
| 45 | ||
| 46 | return { visibleEntries, exactEntry }; | |
| 47 | } | |
| 48 | ||
| 49 | export function createBrowseNavigationCoordinator() { | |
| 50 | let generation = 0; | |
| 51 | ||
| 52 | return { | |
| 53 | invalidate: () => { | |
| 54 | generation += 1; | |
| 55 | }, | |
| 56 | run: async (load: () => Promise<void>, commit: () => void) => { | |
| 57 | const navigationGeneration = ++generation; | |
| 58 | await load(); | |
| 59 | if (navigationGeneration !== generation) { | |
| 60 | return false; | |
| 61 | } | |
| 62 | commit(); | |
| 63 | return true; | |
| 64 | }, | |
| 65 | }; | |
| 66 | } | |
| 67 | ||
| 68 | export function canPreloadBrowsePath( | |
| 69 | connectionPhase: EnvironmentConnectionPhase | null | undefined, | |
| 70 | ): boolean { | |
| 71 | return connectionPhase === "connected"; | |
| 72 | } | |
| 6 | 73 | |
| 7 | 74 | export function createFilesystemEnvironmentAtoms<R, E>( |
| 8 | 75 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, |