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,