diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 4f9d994a6a7..bd6932a98b1 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -10,16 +10,14 @@ import { sortAddProjectProviderSources, type AddProjectRemoteSource, } from "@t3tools/client-runtime/operations/projects"; +import { + filterFilesystemBrowseEntries, + getFilesystemBrowsePath, +} from "@t3tools/client-runtime/state/filesystem"; import { appendBrowsePathSegment, - canNavigateUp, ensureBrowseDirectoryPath, - getBrowseDirectoryPath, - getBrowseLeafPathSegment, - getBrowseParentPath, - hasTrailingPathSeparator, inferProjectTitleFromPath, - isFilesystemBrowseQuery, } from "@t3tools/client-runtime/state/projects"; import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; import { StackActions, useNavigation } from "@react-navigation/native"; @@ -61,11 +59,6 @@ const environmentOptionOrder = Order.mapInput( (environment: EnvironmentOption) => ({ label: environment.label }), ); -const browseEntryOrder = Order.mapInput( - Order.String, - (entry: { readonly name: string }) => entry.name, -); - function platformFromOs(os: string | null | undefined): string { if (os === "windows") return "Win32"; if (os === "darwin") return "MacIntel"; @@ -594,20 +587,13 @@ function FolderBrowser(props: { readonly setPathInput: (path: string) => void; }) { const accentColor = useThemeColor("--color-icon-muted"); - const browseDirectoryPath = useMemo( - () => - isFilesystemBrowseQuery(props.pathInput, props.environment.platform) - ? getBrowseDirectoryPath(props.pathInput) - : "", + const browsePath = useMemo( + () => getFilesystemBrowsePath(props.pathInput, props.environment.platform), [props.environment.platform, props.pathInput], ); - const browseFilterQuery = - browseDirectoryPath.length > 0 && !hasTrailingPathSeparator(props.pathInput) - ? getBrowseLeafPathSegment(props.pathInput).toLowerCase() - : ""; const browseInput = useMemo( - () => (browseDirectoryPath.length > 0 ? { partialPath: browseDirectoryPath } : null), - [browseDirectoryPath], + () => (browsePath.directoryPath.length > 0 ? { partialPath: browsePath.directoryPath } : null), + [browsePath.directoryPath], ); const browseState = useEnvironmentQuery( browseInput === null @@ -617,20 +603,10 @@ function FolderBrowser(props: { input: browseInput, }), ); - const visibleBrowseEntries = useMemo( - () => - Arr.sort( - Arr.filter( - browseState.data?.entries ?? [], - (entry) => - !entry.name.startsWith(".") && entry.name.toLowerCase().startsWith(browseFilterQuery), - ), - browseEntryOrder, - ), - [browseFilterQuery, browseState.data?.entries], + const { visibleEntries: visibleBrowseEntries } = useMemo( + () => filterFilesystemBrowseEntries(browseState.data?.entries ?? [], browsePath.filterQuery), + [browsePath.filterQuery, browseState.data?.entries], ); - const parentBrowsePath = getBrowseParentPath(browseDirectoryPath); - const canBrowseUpPath = canNavigateUp(browseDirectoryPath); return ( <> @@ -642,7 +618,7 @@ function FolderBrowser(props: { ) : null} - {canBrowseUpPath ? ( + {browsePath.canBrowseUp ? ( { - if (parentBrowsePath) props.setPathInput(parentBrowsePath); + if (browsePath.parentPath) { + props.setPathInput(browsePath.parentPath); + } }} /> ) : null} @@ -665,15 +643,15 @@ function FolderBrowser(props: { key={entry.fullPath} title={entry.name} icon={} - isFirst={index === 0 && !canBrowseUpPath} + isFirst={index === 0 && !browsePath.canBrowseUp} right={null} - onPress={() => - props.setPathInput( - browseDirectoryPath.length > 0 - ? appendBrowsePathSegment(browseDirectoryPath, entry.name) - : ensureBrowseDirectoryPath(entry.fullPath), - ) - } + onPress={() => { + const nextPath = + browsePath.directoryPath.length > 0 + ? appendBrowsePathSegment(browsePath.directoryPath, entry.name) + : ensureBrowseDirectoryPath(entry.fullPath); + props.setPathInput(nextPath); + }} /> ))} diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 3d03c877c25..04de1784715 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -4,8 +4,6 @@ import type { Thread } from "../types"; import { buildBrowseGroups, buildThreadActionItems, - canPreloadBrowsePath, - createBrowseNavigationCoordinator, enumerateCommandPaletteItems, filterCommandPaletteGroups, type CommandPaletteGroup, @@ -234,65 +232,3 @@ describe("buildBrowseGroups", () => { expect(actionSettled).toBe(true); }); }); - -describe("createBrowseNavigationCoordinator", () => { - it("only commits the latest overlapping navigation", async () => { - const coordinator = createBrowseNavigationCoordinator(); - let finishFirst: (() => void) | undefined; - let finishSecond: (() => void) | undefined; - const commits: string[] = []; - - const first = coordinator.run({ - load: () => - new Promise((resolve) => { - finishFirst = resolve; - }), - commit: () => { - commits.push("first"); - }, - }); - const second = coordinator.run({ - load: () => - new Promise((resolve) => { - finishSecond = resolve; - }), - commit: () => { - commits.push("second"); - }, - }); - - finishSecond?.(); - await expect(second).resolves.toBe(true); - finishFirst?.(); - await expect(first).resolves.toBe(false); - expect(commits).toEqual(["second"]); - }); - - it("does not commit after newer user input invalidates the navigation", async () => { - const coordinator = createBrowseNavigationCoordinator(); - let finishNavigation: (() => void) | undefined; - const commit = vi.fn(); - const navigation = coordinator.run({ - load: () => - new Promise((resolve) => { - finishNavigation = resolve; - }), - commit, - }); - - coordinator.invalidate(); - finishNavigation?.(); - - await expect(navigation).resolves.toBe(false); - expect(commit).not.toHaveBeenCalled(); - }); -}); - -describe("canPreloadBrowsePath", () => { - it("only preloads paths for connected environments", () => { - expect(canPreloadBrowsePath("connected")).toBe(true); - expect(canPreloadBrowsePath("offline")).toBe(false); - expect(canPreloadBrowsePath("reconnecting")).toBe(false); - expect(canPreloadBrowsePath(null)).toBe(false); - }); -}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index f6f5a08352c..058322744bb 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -1,9 +1,8 @@ import { - type KeybindingCommand, type FilesystemBrowseEntry, + type KeybindingCommand, THREAD_JUMP_KEYBINDING_COMMANDS, } from "@t3tools/contracts"; -import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; @@ -16,39 +15,6 @@ export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; export const ADDON_ICON_CLASS = "size-4"; -export interface BrowseNavigationCoordinator { - readonly invalidate: () => void; - readonly run: (input: { - readonly load: () => Promise; - readonly commit: () => void; - }) => Promise; -} - -export function createBrowseNavigationCoordinator(): BrowseNavigationCoordinator { - let generation = 0; - - return { - invalidate: () => { - generation += 1; - }, - run: async (input) => { - const navigationGeneration = ++generation; - await input.load(); - if (navigationGeneration !== generation) { - return false; - } - input.commit(); - return true; - }, - }; -} - -export function canPreloadBrowsePath( - connectionPhase: EnvironmentConnectionPhase | null | undefined, -): boolean { - return connectionPhase === "connected"; -} - export interface CommandPaletteItem { readonly kind: "action" | "submenu"; readonly value: string; @@ -104,30 +70,6 @@ export function enumerateCommandPaletteItems( export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse"; -export function filterBrowseEntries(input: { - browseEntries: ReadonlyArray; - browseFilterQuery: string; -}): { - filteredEntries: FilesystemBrowseEntry[]; - exactEntry: FilesystemBrowseEntry | null; -} { - const lowerFilter = input.browseFilterQuery.toLowerCase(); - const showHidden = input.browseFilterQuery.startsWith("."); - - const filteredEntries = input.browseEntries.filter( - (entry) => - entry.name.toLowerCase().startsWith(lowerFilter) && - (showHidden || !entry.name.startsWith(".")), - ); - - const exactEntry = - input.browseFilterQuery.length > 0 - ? (filteredEntries.find((entry) => entry.name === input.browseFilterQuery) ?? null) - : null; - - return { filteredEntries, exactEntry }; -} - export function normalizeSearchText(value: string): string { return value.trim().toLowerCase().replace(/\s+/g, " "); } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 5cbce71e5a3..b4e874017d4 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,6 +1,12 @@ "use client"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + canPreloadBrowsePath, + createBrowseNavigationCoordinator, + filterFilesystemBrowseEntries, + getFilesystemBrowsePath, +} from "@t3tools/client-runtime/state/filesystem"; import { isAtomCommandInterrupted, settlePromise, @@ -61,16 +67,12 @@ import { useProjects, useThreadShells } from "../state/entities"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { appendBrowsePathSegment, - canNavigateUp, ensureBrowseDirectoryPath, findProjectByPath, getBrowseDirectoryPath, - getBrowseLeafPathSegment, - getBrowseParentPath, hasTrailingPathSeparator, inferProjectTitleFromPath, isExplicitRelativeProjectPath, - isFilesystemBrowseQuery, isUnsupportedWindowsProjectPath, resolveProjectPathForDispatch, } from "../lib/projectPaths"; @@ -92,13 +94,10 @@ import { buildProjectActionItems, buildRootGroups, buildThreadActionItems, - canPreloadBrowsePath, - createBrowseNavigationCoordinator, enumerateCommandPaletteItems, type CommandPaletteActionItem, type CommandPaletteSubmenuItem, type CommandPaletteView, - filterBrowseEntries, filterCommandPaletteGroups, getCommandPaletteInputPlaceholder, getCommandPaletteMode, @@ -684,8 +683,12 @@ function OpenCommandPaletteDialog(props: { ); const isRemoteProjectCloneFlow = addProjectCloneFlow !== null; const isRemoteProjectRepositoryStep = addProjectCloneFlow?.step === "repository"; - const isBrowsing = - !isRemoteProjectRepositoryStep && isFilesystemBrowseQuery(query, browseEnvironmentPlatform); + const browsePath = useMemo( + () => getFilesystemBrowsePath(query, browseEnvironmentPlatform, !isRemoteProjectRepositoryStep), + [browseEnvironmentPlatform, isRemoteProjectRepositoryStep, query], + ); + const isBrowsing = browsePath.isBrowsing; + const browseDirectoryPath = browsePath.directoryPath; const paletteMode = getCommandPaletteMode({ currentView, isBrowsing }); const getAddProjectInitialQueryForEnvironment = useCallback( (environmentId: EnvironmentId | null): string => { @@ -730,18 +733,15 @@ function OpenCommandPaletteDialog(props: { ); const relativePathNeedsActiveProject = isExplicitRelativeProjectPath(query.trim()) && currentProjectCwdForBrowse === null; - const browseDirectoryPath = isBrowsing ? getBrowseDirectoryPath(query) : ""; - const browseFilterQuery = - isBrowsing && !hasTrailingPathSeparator(query) ? getBrowseLeafPathSegment(query) : ""; const browseQuery = useEnvironmentQuery( isBrowsing && - browseDirectoryPath.length > 0 && + browsePath.directoryPath.length > 0 && browseEnvironmentId !== null && !relativePathNeedsActiveProject ? filesystemEnvironment.browse({ environmentId: browseEnvironmentId, input: { - partialPath: browseDirectoryPath, + partialPath: browsePath.directoryPath, ...(currentProjectCwdForBrowse ? { cwd: currentProjectCwdForBrowse } : {}), }, }) @@ -750,9 +750,9 @@ function OpenCommandPaletteDialog(props: { const browseResult = browseQuery.data; const isBrowsePending = browseQuery.isPending; const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES; - const { filteredEntries: filteredBrowseEntries, exactEntry: exactBrowseEntry } = useMemo( - () => filterBrowseEntries({ browseEntries, browseFilterQuery }), - [browseEntries, browseFilterQuery], + const { visibleEntries: visibleBrowseEntries, exactEntry: exactBrowseEntry } = useMemo( + () => filterFilesystemBrowseEntries(browseEntries, browsePath.filterQuery), + [browseEntries, browsePath.filterQuery], ); const prefetchBrowsePath = useCallback( @@ -973,17 +973,17 @@ function OpenCommandPaletteDialog(props: { initialQuery, }; - await browseNavigation.run({ - load: () => + await browseNavigation.run( + () => initialBrowsePath.length > 0 ? prefetchBrowsePath(initialBrowsePath, environmentId, browseCwd) : Promise.resolve(), - commit: () => { + () => { setAddProjectEnvironmentId(environmentId); setAddProjectCloneFlow(null); pushPaletteView(view); }, - }); + ); }, [ browseNavigation, @@ -1630,33 +1630,33 @@ function OpenCommandPaletteDialog(props: { const browseTo = useCallback( async (name: string): Promise => { const nextQuery = appendBrowsePathSegment(query, name); - await browseNavigation.run({ - load: () => prefetchBrowsePath(getBrowseDirectoryPath(nextQuery)), - commit: () => { + await browseNavigation.run( + () => prefetchBrowsePath(getBrowseDirectoryPath(nextQuery)), + () => { setHighlightedItemValue(null); setQuery(nextQuery); setBrowseGeneration((generation) => generation + 1); }, - }); + ); }, [browseNavigation, prefetchBrowsePath, query], ); const browseUp = useCallback(async (): Promise => { - const parentPath = getBrowseParentPath(query); + const parentPath = browsePath.parentPath; if (parentPath === null) { return; } - await browseNavigation.run({ - load: () => prefetchBrowsePath(parentPath), - commit: () => { + await browseNavigation.run( + () => prefetchBrowsePath(parentPath), + () => { setHighlightedItemValue(null); setQuery(parentPath); setBrowseGeneration((generation) => generation + 1); }, - }); - }, [browseNavigation, prefetchBrowsePath, query]); + ); + }, [browseNavigation, browsePath.parentPath, prefetchBrowsePath]); // Resolve the add-project path from browse data when available. When the // query has a trailing separator (e.g. "~/projects/foo/"), parentPath is the @@ -1666,11 +1666,10 @@ function OpenCommandPaletteDialog(props: { ? (browseResult?.parentPath ?? query.trim()) : (exactBrowseEntry?.fullPath ?? query.trim()); - const canBrowseUp = - isBrowsing && !relativePathNeedsActiveProject && canNavigateUp(browseDirectoryPath); + const canBrowseUp = !relativePathNeedsActiveProject && browsePath.canBrowseUp; const browseGroups = buildBrowseGroups({ - browseEntries: filteredBrowseEntries, + browseEntries: visibleBrowseEntries, browseQuery: query, canBrowseUp, upIcon: , diff --git a/packages/client-runtime/src/state/filesystem.test.ts b/packages/client-runtime/src/state/filesystem.test.ts new file mode 100644 index 00000000000..44e3df6ab26 --- /dev/null +++ b/packages/client-runtime/src/state/filesystem.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + canPreloadBrowsePath, + createBrowseNavigationCoordinator, + filterFilesystemBrowseEntries, + getFilesystemBrowsePath, +} from "./filesystem.ts"; + +describe("filesystem browse model", () => { + it("derives the browse target and navigation state", () => { + expect(getFilesystemBrowsePath("~/projects/t3")).toEqual({ + isBrowsing: true, + directoryPath: "~/projects/", + filterQuery: "t3", + parentPath: "~/", + canBrowseUp: true, + }); + expect(getFilesystemBrowsePath("C:\\Users\\test", "MacIntel").isBrowsing).toBe(false); + expect(getFilesystemBrowsePath("~/projects/", "", false).isBrowsing).toBe(false); + }); + + it("filters names, hidden directories, and exact matches consistently", () => { + const entries = [ + { name: ".config", fullPath: "/Users/test/.config" }, + { name: "Code", fullPath: "/Users/test/Code" }, + { name: "codething", fullPath: "/Users/test/codething" }, + ]; + + expect(filterFilesystemBrowseEntries(entries, "co")).toEqual({ + visibleEntries: entries.slice(1, 3), + exactEntry: null, + }); + expect(filterFilesystemBrowseEntries(entries, "").visibleEntries).toEqual(entries.slice(1)); + expect(filterFilesystemBrowseEntries(entries, ".").visibleEntries).toEqual(entries.slice(0, 1)); + expect(filterFilesystemBrowseEntries(entries, "Code").exactEntry).toEqual(entries[1]); + }); +}); + +describe("browse navigation", () => { + it("only commits the latest valid navigation", async () => { + const navigation = createBrowseNavigationCoordinator(); + const first = Promise.withResolvers(); + const second = Promise.withResolvers(); + const commits: string[] = []; + const commit = (name: string) => () => commits.push(name); + const firstRun = navigation.run(() => first.promise, commit("first")); + const secondRun = navigation.run(() => second.promise, commit("second")); + + second.resolve(); + await expect(secondRun).resolves.toBe(true); + first.resolve(); + await expect(firstRun).resolves.toBe(false); + + const invalidated = Promise.withResolvers(); + const invalidatedRun = navigation.run(() => invalidated.promise, commit("stale")); + navigation.invalidate(); + invalidated.resolve(); + + await expect(invalidatedRun).resolves.toBe(false); + expect(commits).toEqual(["second"]); + }); + + it("only preloads connected environments", () => { + expect(canPreloadBrowsePath("connected")).toBe(true); + expect(canPreloadBrowsePath("offline")).toBe(false); + expect(canPreloadBrowsePath("reconnecting")).toBe(false); + expect(canPreloadBrowsePath(null)).toBe(false); + }); +}); diff --git a/packages/client-runtime/src/state/filesystem.ts b/packages/client-runtime/src/state/filesystem.ts index c78b66cf316..794dc404147 100644 --- a/packages/client-runtime/src/state/filesystem.ts +++ b/packages/client-runtime/src/state/filesystem.ts @@ -1,8 +1,75 @@ -import { WS_METHODS } from "@t3tools/contracts"; +import { type FilesystemBrowseEntry, WS_METHODS } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; -import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; +import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { + canNavigateUp, + getBrowseDirectoryPath, + getBrowseLeafPathSegment, + getBrowseParentPath, + hasTrailingPathSeparator, + isFilesystemBrowseQuery, +} from "./projects.ts"; +import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; + +export function getFilesystemBrowsePath(query: string, platform = "", enabled = true) { + const isBrowsing = enabled && isFilesystemBrowseQuery(query, platform); + const directoryPath = isBrowsing ? getBrowseDirectoryPath(query) : ""; + const filterQuery = + isBrowsing && !hasTrailingPathSeparator(query) ? getBrowseLeafPathSegment(query) : ""; + const parentPath = isBrowsing ? getBrowseParentPath(directoryPath) : null; + + return { + isBrowsing, + directoryPath, + filterQuery, + parentPath, + canBrowseUp: isBrowsing && canNavigateUp(directoryPath), + }; +} + +export function filterFilesystemBrowseEntries( + entries: ReadonlyArray, + query: string, +) { + const lowerQuery = query.toLowerCase(); + const showHidden = query.startsWith("."); + const visibleEntries = entries.filter( + (entry) => + entry.name.toLowerCase().startsWith(lowerQuery) && + (showHidden || !entry.name.startsWith(".")), + ); + const exactEntry = + query.length > 0 ? (visibleEntries.find((entry) => entry.name === query) ?? null) : null; + + return { visibleEntries, exactEntry }; +} + +export function createBrowseNavigationCoordinator() { + let generation = 0; + + return { + invalidate: () => { + generation += 1; + }, + run: async (load: () => Promise, commit: () => void) => { + const navigationGeneration = ++generation; + await load(); + if (navigationGeneration !== generation) { + return false; + } + commit(); + return true; + }, + }; +} + +export function canPreloadBrowsePath( + connectionPhase: EnvironmentConnectionPhase | null | undefined, +): boolean { + return connectionPhase === "connected"; +} export function createFilesystemEnvironmentAtoms( runtime: Atom.AtomRuntime,