Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions apps/mobile/src/features/projects/AddProjectScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
import {
canPreloadBrowsePath,
createBrowseNavigationCoordinator,
createFilesystemBrowseSession,
filterFilesystemBrowseEntries,
getFilesystemBrowsePath,
resolveFilesystemReadAccess,
Expand Down Expand Up @@ -309,7 +310,8 @@ function useBrowsePathInput(environment: EnvironmentOption | null, pinnedDirecto
);
const previousEnvironmentIdRef = useRef(environmentId);
const environmentRuntime = useRemoteEnvironmentRuntime(environmentId);
const loadBrowsePath = useAtomQueryRunner(filesystemEnvironment.browse, {
const [browse] = useState(() => createFilesystemBrowseSession(filesystemEnvironment.browse));
const loadBrowsePath = useAtomQueryRunner(browse, {
reportFailure: false,
reportDefect: false,
});
Expand Down Expand Up @@ -377,7 +379,7 @@ function useBrowsePathInput(environment: EnvironmentOption | null, pinnedDirecto
[browseNavigation],
);

return { isBrowseNavigating, pathInput, setPathInput, navigateToBrowsePath };
return { browse, isBrowseNavigating, pathInput, setPathInput, navigateToBrowsePath };
}

function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> {
Expand Down Expand Up @@ -860,6 +862,7 @@ export function AddProjectRepositoryScreen(props: {
}

function FolderBrowser(props: {
readonly browse: typeof filesystemEnvironment.browse;
readonly environment: EnvironmentOption;
readonly pathInput: string;
readonly setPathInput: (path: string) => void;
Expand All @@ -886,7 +889,7 @@ function FolderBrowser(props: {
const browseState = useEnvironmentQuery(
!canReadFiles || browseInput === null
? null
: filesystemEnvironment.browse({
: props.browse({
environmentId: props.environment.environmentId,
input: browseInput,
}),
Expand Down Expand Up @@ -1221,7 +1224,7 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st
AuthOrchestrationOperateScope,
);
const createProject = useCreateProject(environment);
const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } =
const { browse, isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } =
useBrowsePathInput(environment);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -1263,6 +1266,7 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st
loading={isSubmitting}
/>
<FolderBrowser
browse={browse}
environment={environment}
navigateToBrowsePath={navigateToBrowsePath}
pathInput={pathInput}
Expand Down Expand Up @@ -1305,10 +1309,8 @@ export function AddProjectDestinationScreen(props: {
const repositoryTitle = stringParam(props.repositoryTitle);
const prompt = stringParam(props.prompt);
const repositoryName = stringParam(props.repositoryName)?.trim() ?? "";
const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } = useBrowsePathInput(
environment,
repositoryName,
);
const { browse, isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } =
useBrowsePathInput(environment, repositoryName);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);

Expand Down Expand Up @@ -1424,6 +1426,7 @@ export function AddProjectDestinationScreen(props: {
</Text>
) : null}
<FolderBrowser
browse={browse}
environment={environment}
navigateToBrowsePath={navigateToBrowsePath}
pathInput={pathInput}
Expand Down
39 changes: 25 additions & 14 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@ import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReferenc
import {
canPreloadBrowsePath,
createBrowseNavigationCoordinator,
createFilesystemBrowseSession,
filterFilesystemBrowseEntries,
getFilesystemBrowsePath,
} from "@t3tools/client-runtime/state/filesystem";
import {
executeAtomQuery,
isAtomCommandInterrupted,
settlePromise,
squashAtomCommandFailure,
Expand Down Expand Up @@ -80,6 +82,7 @@ import {
} from "lucide-react";
import { requestThreadFindOpen } from "./chat/threadFindActionBus";
import {
useContext,
useDeferredValue,
useEffect,
useEffectEvent,
Expand All @@ -91,7 +94,7 @@ import {
type ReactNode,
} from "react";
import type { LegendListRef } from "@legendapp/list/react";
import { useAtomValue } from "@effect/atom-react";
import { RegistryContext, useAtomValue } from "@effect/atom-react";

import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps";
Expand Down Expand Up @@ -723,10 +726,10 @@ function OpenCommandPaletteDialog(props: {
const lookupRepository = useAtomQueryRunner(sourceControlEnvironment.repository, {
reportFailure: false,
});
const loadBrowsePath = useAtomQueryRunner(filesystemEnvironment.browse, {
reportFailure: false,
reportDefect: false,
});
const registry = useContext(RegistryContext);
const [browse, setBrowse] = useState(() =>
createFilesystemBrowseSession(filesystemEnvironment.browse),
);
const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, {
reportFailure: false,
});
Expand Down Expand Up @@ -1007,7 +1010,7 @@ function OpenCommandPaletteDialog(props: {
!relativePathNeedsActiveProject;
const browseQuery = useEnvironmentQuery(
browseAccess.canReadFiles && hasBrowseTarget
? filesystemEnvironment.browse({
? browse({
environmentId: browseEnvironmentId,
input: {
partialPath: browsePath.directoryPath,
Expand Down Expand Up @@ -1037,6 +1040,7 @@ function OpenCommandPaletteDialog(props: {
partialPath: string,
environmentId: EnvironmentId | null = browseEnvironmentId,
cwd: string | null = currentProjectCwdForBrowse,
session = browse,
): Promise<void> => {
if (!environmentId) {
return;
Expand All @@ -1049,13 +1053,17 @@ function OpenCommandPaletteDialog(props: {
return;
}

await loadBrowsePath({
environmentId,
input: {
partialPath,
...(cwd ? { cwd } : {}),
},
});
await executeAtomQuery(
registry,
session({
environmentId,
input: {
partialPath,
...(cwd ? { cwd } : {}),
},
}),
{ reportFailure: false, reportDefect: false },
);
};

useEffect(
Expand Down Expand Up @@ -1325,6 +1333,7 @@ function OpenCommandPaletteDialog(props: {
}

const startAddProjectBrowse = async (environmentId: EnvironmentId): Promise<void> => {
const session = createFilesystemBrowseSession(filesystemEnvironment.browse);
const initialQuery = getAddProjectInitialQueryForEnvironment(environmentId);
const initialBrowsePath = getBrowseDirectoryPath(initialQuery);
const browseCwd = getBrowseCwdForEnvironment(environmentId);
Expand All @@ -1337,9 +1346,10 @@ function OpenCommandPaletteDialog(props: {
await browseNavigation.run(
() =>
initialBrowsePath.length > 0
? prefetchBrowsePath(initialBrowsePath, environmentId, browseCwd)
? prefetchBrowsePath(initialBrowsePath, environmentId, browseCwd, session)
: Promise.resolve(),
() => {
setBrowse(() => session);
setAddProjectEnvironmentId(environmentId);
setAddProjectCloneFlow(null);
pushPaletteView(view);
Expand All @@ -1351,6 +1361,7 @@ function OpenCommandPaletteDialog(props: {
environmentId: EnvironmentId,
source: AddProjectRemoteSource,
): void => {
setBrowse(() => createFilesystemBrowseSession(filesystemEnvironment.browse));
setAddProjectEnvironmentId(environmentId);
setAddProjectCloneFlow({ step: "repository", environmentId, source });
pushPaletteView({
Expand Down
90 changes: 89 additions & 1 deletion packages/client-runtime/src/state/filesystem.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { describe, expect, it } from "vite-plus/test";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
import * as Effect from "effect/Effect";
import { Atom, AtomRegistry } from "effect/reactivity";
import { AuthFilesystemReadScope, AuthOrchestrationReadScope } from "@t3tools/contracts";

import {
canPreloadBrowsePath,
createBrowseNavigationCoordinator,
createFilesystemBrowseSession,
filterFilesystemBrowseEntries,
getFilesystemBrowsePath,
resolveFilesystemReadAccess,
} from "./filesystem.ts";
import { executeAtomQuery } from "./runtime.ts";

const SPLIT_SCOPES_SERVER = { serverUpdateScope: "environment:maintain" } as const;

Expand Down Expand Up @@ -188,3 +192,87 @@ describe("browse navigation", () => {
expect(canPreloadBrowsePath(null)).toBe(false);
});
});

describe("filesystem browse sessions", () => {
const registries: AtomRegistry.AtomRegistry[] = [];
const makeRegistry = () => {
const registry = AtomRegistry.make();
registries.push(registry);
return registry;
};
afterEach(() => {
for (const registry of registries.splice(0)) registry.dispose();
vi.useRealTimers();
});

const cachedQuery = <A, E>(effect: Effect.Effect<A, E>) =>
Atom.make(effect).pipe(
Atom.swr({ staleTime: 30_000, revalidateOnMount: true }),
Atom.setIdleTTL(5 * 60_000),
);

it("shows newly created folders on reopen and refreshes only once per session", async () => {
vi.useFakeTimers();
const registry = makeRegistry();
let entries = ["existing"];
const request = vi.fn(() => entries);
const source = cachedQuery(Effect.sync(request));
const first = createFilesystemBrowseSession(() => source)(undefined);
const close = registry.mount(first);
expect(registry.get(first)).toMatchObject({ value: ["existing"] });
expect(request).toHaveBeenCalledTimes(1);
close();
await vi.advanceTimersByTimeAsync(2_000);

entries = ["existing", "new-folder"];
const reopened = createFilesystemBrowseSession(() => source)(undefined);
const rendered = vi.fn();
const leaveDirectory = registry.subscribe(reopened, rendered, { immediate: true });
expect(rendered).toHaveBeenLastCalledWith(
expect.objectContaining({ value: entries, waiting: false }),
);
expect(request).toHaveBeenCalledTimes(2);

leaveDirectory();
await vi.advanceTimersByTimeAsync(2_000);
registry.mount(reopened);
expect(registry.get(reopened)).toMatchObject({ value: entries, waiting: false });
expect(request).toHaveBeenCalledTimes(2);
});

it("shares a cold request with another picker opened while it is pending", async () => {
const registry = makeRegistry();
const pending = Promise.withResolvers<string[]>();
const request = vi.fn(() => pending.promise);
const source = cachedQuery(Effect.promise(request));
const first = createFilesystemBrowseSession(() => source)(undefined);
const second = createFilesystemBrowseSession(() => source)(undefined);
const firstResult = executeAtomQuery(registry, first);
const secondResult = executeAtomQuery(registry, second);
expect(request).toHaveBeenCalledTimes(1);

pending.resolve(["folder"]);
for (const result of await Promise.all([firstResult, secondResult])) {
expect(result).toMatchObject({ _tag: "Success", value: ["folder"] });
}
expect(request).toHaveBeenCalledTimes(1);
});

it("renders a completed navigation preload without requesting the directory again", async () => {
vi.useFakeTimers();
const registry = makeRegistry();
const request = vi.fn((path: string) => [path]);
const family = Atom.family((path: string) => cachedQuery(Effect.sync(() => request(path))));
const browse = createFilesystemBrowseSession(family);
registry.mount(browse("/"));

expect(await executeAtomQuery(registry, browse("/next/"))).toMatchObject({
_tag: "Success",
value: ["/next/"],
});
await vi.advanceTimersByTimeAsync(2_000);
registry.mount(browse("/next/"));
expect(registry.get(browse("/next/"))).toMatchObject({ value: ["/next/"] });
expect(request.mock.calls).toEqual([["/"], ["/next/"]]);
});
});
25 changes: 24 additions & 1 deletion packages/client-runtime/src/state/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
sessionGrantsScope,
type SessionGrantInput,
} from "@t3tools/contracts";
import { Atom } from "effect/reactivity";
import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity";

import type {
EnvironmentConnectionPhase,
Expand Down Expand Up @@ -111,6 +111,29 @@ export function canPreloadBrowsePath(
return connectionPhase === "connected";
}

export function createFilesystemBrowseSession<T, A, E>(
browse: (target: T) => Atom.Atom<AsyncResult.AsyncResult<A, E>>,
) {
const family = Atom.family((source: Atom.Atom<AsyncResult.AsyncResult<A, E>>) => {
const visited = new WeakSet<AtomRegistry.AtomRegistry>();
return Atom.transform(source, (get) => {
// Idle-TTL nodes outlive the picker without remounting SWR. Check before
// reading so a synchronous cold load isn't mistaken for cached data.
const cached = get.registry.getNodes().get(source)?.currentState() === "valid";
const result = get.once(source);
if (!visited.has(get.registry)) {
visited.add(get.registry);
if (cached && !result.waiting && !AsyncResult.isInitial(result)) {
get.refresh(source);
}
}
get.subscribe(source, (value) => get.setSelf(value));
return get.once(source);
});
});
return (target: T) => family(browse(target));
}

export function createFilesystemEnvironmentAtoms<R, E>(
runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
) {
Expand Down
Loading