Skip to content
Open
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
12 changes: 8 additions & 4 deletions apps/mobile/src/features/files/preload-workspace-file.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
import type { EnvironmentId } from "@t3tools/contracts";
import { Atom } from "effect/unstable/reactivity";

import { appAtomRegistry } from "../../state/atom-registry";
import { projectEnvironment } from "../../state/projects";
Expand All @@ -10,6 +11,7 @@ import type { ReviewDiffTheme } from "../review/shikiReviewHighlighter";

const inFlightPreloads = new Map<string, Promise<void>>();
const MAX_HIGHLIGHT_PRELOAD_CHARACTERS = 256 * 1024;
const WORKSPACE_FILE_PRELOAD_RETAIN_MS = 1_000;

function preloadKey(input: {
readonly cwd: string;
Expand All @@ -36,10 +38,12 @@ export function preloadWorkspaceFileContents(input: {

const preload = executeAtomQuery(
appAtomRegistry,
projectEnvironment.readFile({
environmentId: input.environmentId,
input: { cwd: input.cwd, relativePath: input.relativePath },
}),
projectEnvironment
.readFile({
environmentId: input.environmentId,
input: { cwd: input.cwd, relativePath: input.relativePath },
})
.pipe(Atom.setIdleTTL(WORKSPACE_FILE_PRELOAD_RETAIN_MS)),
{
label: "workspace file preload",
reportDefect: false,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope,
[WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope,
[WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope,
[WS_METHODS.projectsWatchFile]: AuthOrchestrationReadScope,
[WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope,
[WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope,
[WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope,
Expand Down
41 changes: 41 additions & 0 deletions apps/server/src/workspace/WorkspaceFileSystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import { it, describe, expect } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Stream from "effect/Stream";

import * as ServerConfig from "../config.ts";
import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
Expand Down Expand Up @@ -265,4 +268,42 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
}),
);
});

describe("watchFile", () => {
it.effect("emits when a missing workspace file is created", () =>
Effect.gen(function* () {
const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
const cwd = yield* makeTempDir;
const eventFiber = yield* workspaceFileSystem
.watchFile({ cwd, relativePath: "t3.json" })
.pipe(Stream.runHead, Effect.timeout("5 seconds"), Effect.forkChild);

yield* Effect.sleep("100 millis");
yield* writeTextFile(cwd, "t3.json", '{"scripts":[]}');

const event = yield* Fiber.join(eventFiber);
expect(Option.getOrUndefined(event)).toEqual({ relativePath: "t3.json" });
}),
);

it.effect("emits when an in-workspace symlink target changes", () =>
Effect.gen(function* () {
const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const cwd = yield* makeTempDir;
yield* writeTextFile(cwd, "src/target.txt", "initial");
yield* fileSystem.symlink(path.join(cwd, "src/target.txt"), path.join(cwd, "linked.txt"));
const eventFiber = yield* workspaceFileSystem
.watchFile({ cwd, relativePath: "linked.txt" })
.pipe(Stream.runHead, Effect.timeout("5 seconds"), Effect.forkChild);

yield* Effect.sleep("100 millis");
yield* writeTextFile(cwd, "src/target.txt", "updated");

const event = yield* Fiber.join(eventFiber);
expect(Option.getOrUndefined(event)).toEqual({ relativePath: "linked.txt" });
}),
);
});
});
134 changes: 133 additions & 1 deletion apps/server/src/workspace/WorkspaceFileSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,20 @@
import * as NodeFSP from "node:fs/promises";

import type {
ProjectFileChangeEvent,
ProjectReadFileInput,
ProjectReadFileResult,
ProjectWriteFileInput,
ProjectWriteFileResult,
} from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";

import * as WorkspaceEntries from "./WorkspaceEntries.ts";
import * as WorkspacePaths from "./WorkspacePaths.ts";
Expand All @@ -37,10 +40,12 @@ export class WorkspaceFileSystemOperationError extends Schema.TaggedErrorClass<W
operation: Schema.Literals([
"realpath-workspace-root",
"realpath-target",
"realpath-watch-directory",
"open",
"stat",
"read",
"close",
"watch",
"make-directory",
"write-file",
]),
Expand Down Expand Up @@ -111,6 +116,13 @@ export class WorkspaceFileSystem extends Context.Service<
ProjectReadFileResult,
WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError
>;
/** Watch a workspace-relative file and emit after its directory entry changes. */
readonly watchFile: (
input: ProjectReadFileInput,
) => Stream.Stream<
ProjectFileChangeEvent,
WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError
>;
/**
* Write a file relative to the workspace root.
*
Expand Down Expand Up @@ -259,6 +271,126 @@ export const make = Effect.gen(function* () {
);
});

const watchFile: WorkspaceFileSystem["Service"]["watchFile"] = (input) =>
Stream.unwrap(
Effect.gen(function* () {
const target = yield* workspacePaths.resolveRelativePathWithinRoot({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
});
const watchDirectory = path.dirname(target.absolutePath);
const realWorkspaceRoot = yield* Effect.tryPromise({
try: () => NodeFSP.realpath(input.cwd),
catch: (cause) =>
new WorkspaceFileSystemOperationError({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
resolvedPath: target.absolutePath,
operationPath: input.cwd,
operation: "realpath-workspace-root",
cause,
}),
});
const realWatchDirectory = yield* Effect.tryPromise({
try: () => NodeFSP.realpath(watchDirectory),
catch: (cause) =>
new WorkspaceFileSystemOperationError({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
resolvedPath: target.absolutePath,
operationPath: watchDirectory,
operation: "realpath-watch-directory",
cause,
}),
});
const relativeRealDirectory = path.relative(realWorkspaceRoot, realWatchDirectory);
if (
relativeRealDirectory.startsWith(`..${path.sep}`) ||
relativeRealDirectory === ".." ||
path.isAbsolute(relativeRealDirectory)
) {
return yield* new WorkspaceFilePathEscapeError({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
resolvedWorkspaceRoot: realWorkspaceRoot,
resolvedPath: realWatchDirectory,
});
}

const realTargetPath = yield* Effect.tryPromise({
try: async () => {
try {
return await NodeFSP.realpath(target.absolutePath);
} catch (cause) {
if ((cause as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw cause;
}
},
catch: (cause) =>
new WorkspaceFileSystemOperationError({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
resolvedPath: target.absolutePath,
operationPath: target.absolutePath,
operation: "realpath-target",
cause,
}),
});
if (realTargetPath !== null) {
const relativeRealTarget = path.relative(realWorkspaceRoot, realTargetPath);
if (
relativeRealTarget.startsWith(`..${path.sep}`) ||
relativeRealTarget === ".." ||
path.isAbsolute(relativeRealTarget)
) {
return yield* new WorkspaceFilePathEscapeError({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
resolvedWorkspaceRoot: realWorkspaceRoot,
resolvedPath: realTargetPath,
});
}
}

const watchEntry = (watchPath: string, fileName: string) => {
const watchedAbsolutePath = path.join(watchPath, fileName);
return fileSystem.watch(watchPath).pipe(
Stream.filter((event) => {
return (
event.path === fileName ||
event.path === watchedAbsolutePath ||
path.resolve(watchPath, event.path) === watchedAbsolutePath
);
}),
Stream.mapError(
(cause) =>
new WorkspaceFileSystemOperationError({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
resolvedPath: target.absolutePath,
operationPath: watchPath,
operation: "watch",
cause,
}),
),
);
};

const lexicalTargetPath = path.join(realWatchDirectory, path.basename(target.absolutePath));
const lexicalEvents = watchEntry(realWatchDirectory, path.basename(target.absolutePath));
const resolvedTargetEvents =
realTargetPath !== null && realTargetPath !== lexicalTargetPath
? watchEntry(path.dirname(realTargetPath), path.basename(realTargetPath))
: Stream.empty;
return Stream.merge(lexicalEvents, resolvedTargetEvents).pipe(
Stream.debounce(Duration.millis(100)),
Stream.map(() => ({ relativePath: target.relativePath })),
);
}),
);

const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn(
"WorkspaceFileSystem.writeFile",
)(function* (input) {
Expand Down Expand Up @@ -297,7 +429,7 @@ export const make = Effect.gen(function* () {
return { relativePath: target.relativePath };
});

return WorkspaceFileSystem.of({ readFile, writeFile });
return WorkspaceFileSystem.of({ readFile, watchFile, writeFile });
});

export const layer = Layer.effect(WorkspaceFileSystem, make);
15 changes: 15 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1664,6 +1664,21 @@ const makeWsRpcLayer = (
),
{ "rpc.aggregate": "workspace" },
),
[WS_METHODS.projectsWatchFile]: (input) =>
observeRpcStream(
WS_METHODS.projectsWatchFile,
workspaceFileSystem.watchFile(input).pipe(
Stream.mapError(
(cause) =>
new ProjectReadFileError({
...input,
...projectFileFailureContext(cause),
cause,
}),
),
),
{ "rpc.aggregate": "workspace" },
),
[WS_METHODS.projectsWriteFile]: (input) =>
observeRpcEffect(
WS_METHODS.projectsWriteFile,
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/components/files/projectFilesQueryState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,10 @@ export function useProjectFileQuery(
relativePath: string | null,
enabled = true,
): ProjectQueryState<ProjectReadFileResult> {
const atom = enabled
? getProjectFileQueryAtom(environmentId, cwd, relativePath)
: EMPTY_PROJECT_FILE_QUERY_ATOM;
const atom =
enabled && relativePath !== null
? getProjectFileQueryAtom(environmentId, cwd, relativePath)
: EMPTY_PROJECT_FILE_QUERY_ATOM;
const result = useAtomValue(atom);
const refreshAtom = useAtomRefresh(atom);
const refresh = useCallback(() => refreshAtom(), [refreshAtom]);
Expand Down
1 change: 1 addition & 0 deletions packages/client-runtime/src/rpc/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export type EnvironmentSubscriptionRpcTag =
| typeof WS_METHODS.subscribePreviewEvents
| typeof WS_METHODS.subscribeDiscoveredLocalServers
| typeof WS_METHODS.subscribeResourceTelemetry
| typeof WS_METHODS.projectsWatchFile
| typeof WS_METHODS.previewAutomationConnect
| typeof WS_METHODS.subscribeVcsStatus
| typeof WS_METHODS.terminalAttach;
Expand Down
35 changes: 29 additions & 6 deletions packages/client-runtime/src/state/projectCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
createEnvironmentCommand,
createEnvironmentRpcCommand,
createEnvironmentRpcQueryAtomFamily,
createEnvironmentRpcSubscriptionAtomFamily,
refreshQueryOnSuccess,
} from "./runtime.ts";
import {
type CreateProjectInput,
Expand Down Expand Up @@ -54,6 +56,32 @@ export function createProjectEnvironmentAtoms<R, E>(
key: ({ environmentId, input }: { environmentId: string; input: { projectId: string } }) =>
JSON.stringify([environmentId, input.projectId]),
};
const readFileQuery = createEnvironmentRpcQueryAtomFamily(runtime, {
label: "environment-data:projects:read-file-query",
tag: WS_METHODS.projectsReadFile,
// Workspace files can change outside T3 Code, so always revalidate cached reads on mount.
staleTimeMs: 0,
idleTtlMs: 0,
Comment thread
cursor[bot] marked this conversation as resolved.
});
const fileChanges = createEnvironmentRpcSubscriptionAtomFamily(runtime, {
label: "environment-data:projects:file-changes",
tag: WS_METHODS.projectsWatchFile,
idleTtlMs: 0,
});
type ReadFileAtom = ReturnType<typeof readFileQuery>;
const liveReadFileAtoms = new WeakMap<ReadFileAtom, ReadFileAtom>();
const readFile = (target: Parameters<typeof readFileQuery>[0]): ReadFileAtom => {
const queryAtom = readFileQuery(target);
const cached = liveReadFileAtoms.get(queryAtom);
if (cached) return cached;

const changesAtom = fileChanges(target);
const liveAtom = refreshQueryOnSuccess(queryAtom, changesAtom).pipe(
Atom.withLabel(`environment-data:projects:read-file:${target.input.relativePath}`),
);
liveReadFileAtoms.set(queryAtom, liveAtom);
return liveAtom;
};
Comment thread
PixPMusic marked this conversation as resolved.
return {
searchEntries: createEnvironmentRpcQueryAtomFamily(runtime, {
label: "environment-data:projects:search-entries",
Expand All @@ -66,12 +94,7 @@ export function createProjectEnvironmentAtoms<R, E>(
staleTimeMs: 30_000,
idleTtlMs: 5 * 60_000,
}),
readFile: createEnvironmentRpcQueryAtomFamily(runtime, {
label: "environment-data:projects:read-file",
tag: WS_METHODS.projectsReadFile,
staleTimeMs: 30_000,
idleTtlMs: 5 * 60_000,
}),
readFile,
optimisticFile: (target: OptimisticProjectFileTarget) =>
optimisticFileFamily(optimisticProjectFileKey(target)),
create: createEnvironmentCommand(runtime, {
Expand Down
Loading
Loading