Skip to content
Source
Sign inSign up

filesystem.ts

160 lines5.1 KB

1import {
2 AuthFilesystemReadScope,
3 type FilesystemBrowseEntry,
4 WS_METHODS,
5 sessionGrantsScope,
6 type SessionGrantInput,
7} from "@t3tools/contracts";
8import { Atom } from "effect/reactivity";
9​
10import type {
11 EnvironmentConnectionPhase,
12 EnvironmentConnectionPresentation,
13} from "../connection/presentation.ts";
14import type { EnvironmentRegistry } from "../connection/registry.ts";
15import {
16 canNavigateUp,
17 getBrowseDirectoryPath,
18 getBrowseLeafPathSegment,
19 getBrowseParentPath,
20 hasTrailingPathSeparator,
21 isFilesystemBrowseQuery,
22} from "./projects.ts";
23import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts";
24​
25const BROWSE_IDLE_TTL_MS = 5_000;
26​
27export function resolveFilesystemReadAccess(input: {
28 readonly isCatalogReady: boolean;
29 readonly connection: Pick<EnvironmentConnectionPresentation, "phase" | "error"> | null;
30 readonly session: SessionGrantInput | null;
31 readonly sessionError: string | null;
32}) {
33 if (input.sessionError !== null) {
34 return { canReadFiles: false, isPending: false, error: input.sessionError };
35 }
36 if (input.session === null) {
37 // Wait for the catalog before interpreting a missing presentation as offline.
38 // Once ready, an offline environment cannot finish its session check.
39 const isPending =
40 !input.isCatalogReady ||
41 input.connection?.phase === "connected" ||
42 input.connection?.phase === "connecting" ||
43 input.connection?.phase === "reconnecting";
44 return {
45 canReadFiles: false,
46 isPending,
47 error: isPending ? null : (input.connection?.error ?? "This environment is not connected."),
48 };
49 }
50 return {
51 canReadFiles: sessionGrantsScope(input.session, AuthFilesystemReadScope),
52 isPending: false,
53 error: null,
54 };
56​
57export function getFilesystemBrowsePath(query: string, platform = "", enabled = true) {
58 const isBrowsing = enabled && isFilesystemBrowseQuery(query, platform);
59 const directoryPath = isBrowsing ? getBrowseDirectoryPath(query) : "";
60 const filterQuery =
61 isBrowsing && !hasTrailingPathSeparator(query) ? getBrowseLeafPathSegment(query) : "";
62 const parentPath = isBrowsing ? getBrowseParentPath(directoryPath) : null;
63​
64 return {
65 isBrowsing,
66 directoryPath,
67 filterQuery,
68 parentPath,
69 canBrowseUp: isBrowsing && canNavigateUp(directoryPath),
70 };
72​
73export function filterFilesystemBrowseEntries(
74 entries: ReadonlyArray<FilesystemBrowseEntry>,
75 query: string,
76) {
77 const lowerQuery = query.toLowerCase();
78 const showHidden = query.startsWith(".");
79 const visibleEntries = entries.filter(
80 (entry) =>
81 entry.name.toLowerCase().startsWith(lowerQuery) &&
82 (showHidden || !entry.name.startsWith(".")),
83 );
84 const exactEntry =
85 query.length > 0 ? (visibleEntries.find((entry) => entry.name === query) ?? null) : null;
86​
87 return { visibleEntries, exactEntry };
89​
90/** Shell-style Tab: a single match enters the directory, several extend to their common prefix. */
91export function getFilesystemBrowseTabCompletion(
92 visibleEntries: ReadonlyArray<FilesystemBrowseEntry>,
93 filterQuery: string,
94): { readonly name: string; readonly isComplete: boolean } | null {
95 const [first, ...rest] = visibleEntries;
96 if (first === undefined) return null;
97 if (rest.length === 0) return { name: first.name, isComplete: true };
98 let prefix = Array.from(first.name);
99 for (const entry of rest) {
100 const characters = Array.from(entry.name);
101 let length = 0;
102 while (
103 length < prefix.length &&
104 prefix[length]?.toLowerCase() === characters[length]?.toLowerCase()
105 )
106 length += 1;
107 prefix = prefix.slice(0, length);
108 }
109 const name = prefix.join("");
110 const lowerName = name.toLowerCase();
111 return lowerName.length > filterQuery.toLowerCase().length &&
112 visibleEntries.every((entry) => entry.name.toLowerCase().startsWith(lowerName))
113 ? { name, isComplete: false }
114 : null;
116​
117export function createBrowseNavigationCoordinator() {
118 let generation = 0;
119​
120 return {
121 invalidate: () => {
122 generation += 1;
123 },
124 run: async (load: () => Promise<void>, commit: () => void) => {
125 const navigationGeneration = ++generation;
126 await load();
127 if (navigationGeneration !== generation) {
128 return false;
129 }
130 commit();
131 return true;
132 },
133 };
135​
136export function canPreloadBrowsePath(
137 connectionPhase: EnvironmentConnectionPhase | null | undefined,
138): boolean {
139 return connectionPhase === "connected";
141​
142export function createFilesystemEnvironmentAtoms<R, E>(
143 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
144) {
145 return {
146 getMetadata: createEnvironmentRpcQueryAtomFamily(runtime, {
147 label: "environment-data:filesystem:get-metadata",
148 tag: WS_METHODS.filesystemGetMetadata,
149 }),
150 browse: createEnvironmentRpcQueryAtomFamily(runtime, {
151 label: "environment-data:filesystem:browse",
152 tag: WS_METHODS.filesystemBrowse,
153 // A listing mirrors a directory that can change outside the app, so reopening a picker
154 // lists it again. The short TTL only keeps the old listing on screen while it refreshes.
155 idleTtlMs: BROWSE_IDLE_TTL_MS,
156 staleTimeMs: 0,
157 revalidateOnRemount: true,
158 }),
159 };