Skip to content

Commit 34fedde

Browse files
omegent-app[bot]enricopolanskipatroza
committed
fix(client): refresh workspace files after disk changes (upstream pingdotgg#4379) (#312)
Imported from pingdotgg#4379 at a27510d. Open file previews revalidate on mount and subscribe to debounced native filesystem watches so external edits (editors, git, agents) show without a manual refresh. Co-authored-by: omegent-app[bot] <306514130+omegent-app[bot]@users.noreply.github.com> Co-authored-by: Enrico Polanski <16064771+enricopolanski@users.noreply.github.com> Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com>
1 parent 3dee214 commit 34fedde

13 files changed

Lines changed: 408 additions & 14 deletions

File tree

.github/upstream-candidates.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@
3030
"sourceSha": "6be48eb238cf310a60cc9571240601e774c7d381",
3131
"status": "active",
3232
"purpose": "Queue follow-up messages server-side during active turns with explicit steer controls"
33+
},
34+
{
35+
"upstreamPr": 4379,
36+
"sourceSha": "a27510d060645809ae1472bba4dbb248dc624e25",
37+
"status": "active",
38+
"purpose": "Refresh open workspace file previews when disk contents change"
3339
}
3440
]
3541
}

apps/mobile/src/features/files/preload-workspace-file.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
22
import type { EnvironmentId } from "@t3tools/contracts";
3+
import { Atom } from "effect/unstable/reactivity";
34

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

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

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

3739
const preload = executeAtomQuery(
3840
appAtomRegistry,
39-
projectEnvironment.readFile({
40-
environmentId: input.environmentId,
41-
input: { cwd: input.cwd, relativePath: input.relativePath },
42-
}),
41+
projectEnvironment
42+
.readFile({
43+
environmentId: input.environmentId,
44+
input: { cwd: input.cwd, relativePath: input.relativePath },
45+
})
46+
.pipe(Atom.setIdleTTL(WORKSPACE_FILE_PRELOAD_RETAIN_MS)),
4347
{
4448
label: "workspace file preload",
4549
reportDefect: false,

apps/server/src/auth/RpcAuthorization.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export const RPC_REQUIRED_SCOPES = {
5757
[WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope,
5858
[WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope,
5959
[WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope,
60+
[WS_METHODS.projectsWatchFile]: AuthOrchestrationReadScope,
6061
[WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope,
6162
[WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope,
6263
[WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope,

apps/server/src/workspace/WorkspaceFileSystem.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
22
import { it, describe, expect } from "@effect/vitest";
33
import * as Effect from "effect/Effect";
44
import * as FileSystem from "effect/FileSystem";
5+
import * as Fiber from "effect/Fiber";
56
import * as Layer from "effect/Layer";
7+
import * as Option from "effect/Option";
68
import * as Path from "effect/Path";
9+
import * as Stream from "effect/Stream";
710

811
import * as ServerConfig from "../config.ts";
912
import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
@@ -265,4 +268,42 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
265268
}),
266269
);
267270
});
271+
272+
describe("watchFile", () => {
273+
it.effect("emits when a missing workspace file is created", () =>
274+
Effect.gen(function* () {
275+
const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
276+
const cwd = yield* makeTempDir;
277+
const eventFiber = yield* workspaceFileSystem
278+
.watchFile({ cwd, relativePath: "t3.json" })
279+
.pipe(Stream.runHead, Effect.timeout("5 seconds"), Effect.forkChild);
280+
281+
yield* Effect.sleep("100 millis");
282+
yield* writeTextFile(cwd, "t3.json", '{"scripts":[]}');
283+
284+
const event = yield* Fiber.join(eventFiber);
285+
expect(Option.getOrUndefined(event)).toEqual({ relativePath: "t3.json" });
286+
}),
287+
);
288+
289+
it.effect("emits when an in-workspace symlink target changes", () =>
290+
Effect.gen(function* () {
291+
const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
292+
const fileSystem = yield* FileSystem.FileSystem;
293+
const path = yield* Path.Path;
294+
const cwd = yield* makeTempDir;
295+
yield* writeTextFile(cwd, "src/target.txt", "initial");
296+
yield* fileSystem.symlink(path.join(cwd, "src/target.txt"), path.join(cwd, "linked.txt"));
297+
const eventFiber = yield* workspaceFileSystem
298+
.watchFile({ cwd, relativePath: "linked.txt" })
299+
.pipe(Stream.runHead, Effect.timeout("5 seconds"), Effect.forkChild);
300+
301+
yield* Effect.sleep("100 millis");
302+
yield* writeTextFile(cwd, "src/target.txt", "updated");
303+
304+
const event = yield* Fiber.join(eventFiber);
305+
expect(Option.getOrUndefined(event)).toEqual({ relativePath: "linked.txt" });
306+
}),
307+
);
308+
});
268309
});

apps/server/src/workspace/WorkspaceFileSystem.ts

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,20 @@
1010
import * as NodeFSP from "node:fs/promises";
1111

1212
import type {
13+
ProjectFileChangeEvent,
1314
ProjectReadFileInput,
1415
ProjectReadFileResult,
1516
ProjectWriteFileInput,
1617
ProjectWriteFileResult,
1718
} from "@t3tools/contracts";
1819
import * as Context from "effect/Context";
20+
import * as Duration from "effect/Duration";
1921
import * as Effect from "effect/Effect";
2022
import * as FileSystem from "effect/FileSystem";
2123
import * as Layer from "effect/Layer";
2224
import * as Path from "effect/Path";
2325
import * as Schema from "effect/Schema";
26+
import * as Stream from "effect/Stream";
2427

2528
import * as WorkspaceEntries from "./WorkspaceEntries.ts";
2629
import * as WorkspacePaths from "./WorkspacePaths.ts";
@@ -37,10 +40,12 @@ export class WorkspaceFileSystemOperationError extends Schema.TaggedErrorClass<W
3740
operation: Schema.Literals([
3841
"realpath-workspace-root",
3942
"realpath-target",
43+
"realpath-watch-directory",
4044
"open",
4145
"stat",
4246
"read",
4347
"close",
48+
"watch",
4449
"make-directory",
4550
"write-file",
4651
]),
@@ -111,6 +116,13 @@ export class WorkspaceFileSystem extends Context.Service<
111116
ProjectReadFileResult,
112117
WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError
113118
>;
119+
/** Watch a workspace-relative file and emit after its directory entry changes. */
120+
readonly watchFile: (
121+
input: ProjectReadFileInput,
122+
) => Stream.Stream<
123+
ProjectFileChangeEvent,
124+
WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError
125+
>;
114126
/**
115127
* Write a file relative to the workspace root.
116128
*
@@ -259,6 +271,126 @@ export const make = Effect.gen(function* () {
259271
);
260272
});
261273

274+
const watchFile: WorkspaceFileSystem["Service"]["watchFile"] = (input) =>
275+
Stream.unwrap(
276+
Effect.gen(function* () {
277+
const target = yield* workspacePaths.resolveRelativePathWithinRoot({
278+
workspaceRoot: input.cwd,
279+
relativePath: input.relativePath,
280+
});
281+
const watchDirectory = path.dirname(target.absolutePath);
282+
const realWorkspaceRoot = yield* Effect.tryPromise({
283+
try: () => NodeFSP.realpath(input.cwd),
284+
catch: (cause) =>
285+
new WorkspaceFileSystemOperationError({
286+
workspaceRoot: input.cwd,
287+
relativePath: input.relativePath,
288+
resolvedPath: target.absolutePath,
289+
operationPath: input.cwd,
290+
operation: "realpath-workspace-root",
291+
cause,
292+
}),
293+
});
294+
const realWatchDirectory = yield* Effect.tryPromise({
295+
try: () => NodeFSP.realpath(watchDirectory),
296+
catch: (cause) =>
297+
new WorkspaceFileSystemOperationError({
298+
workspaceRoot: input.cwd,
299+
relativePath: input.relativePath,
300+
resolvedPath: target.absolutePath,
301+
operationPath: watchDirectory,
302+
operation: "realpath-watch-directory",
303+
cause,
304+
}),
305+
});
306+
const relativeRealDirectory = path.relative(realWorkspaceRoot, realWatchDirectory);
307+
if (
308+
relativeRealDirectory.startsWith(`..${path.sep}`) ||
309+
relativeRealDirectory === ".." ||
310+
path.isAbsolute(relativeRealDirectory)
311+
) {
312+
return yield* new WorkspaceFilePathEscapeError({
313+
workspaceRoot: input.cwd,
314+
relativePath: input.relativePath,
315+
resolvedWorkspaceRoot: realWorkspaceRoot,
316+
resolvedPath: realWatchDirectory,
317+
});
318+
}
319+
320+
const realTargetPath = yield* Effect.tryPromise({
321+
try: async () => {
322+
try {
323+
return await NodeFSP.realpath(target.absolutePath);
324+
} catch (cause) {
325+
if ((cause as NodeJS.ErrnoException).code === "ENOENT") {
326+
return null;
327+
}
328+
throw cause;
329+
}
330+
},
331+
catch: (cause) =>
332+
new WorkspaceFileSystemOperationError({
333+
workspaceRoot: input.cwd,
334+
relativePath: input.relativePath,
335+
resolvedPath: target.absolutePath,
336+
operationPath: target.absolutePath,
337+
operation: "realpath-target",
338+
cause,
339+
}),
340+
});
341+
if (realTargetPath !== null) {
342+
const relativeRealTarget = path.relative(realWorkspaceRoot, realTargetPath);
343+
if (
344+
relativeRealTarget.startsWith(`..${path.sep}`) ||
345+
relativeRealTarget === ".." ||
346+
path.isAbsolute(relativeRealTarget)
347+
) {
348+
return yield* new WorkspaceFilePathEscapeError({
349+
workspaceRoot: input.cwd,
350+
relativePath: input.relativePath,
351+
resolvedWorkspaceRoot: realWorkspaceRoot,
352+
resolvedPath: realTargetPath,
353+
});
354+
}
355+
}
356+
357+
const watchEntry = (watchPath: string, fileName: string) => {
358+
const watchedAbsolutePath = path.join(watchPath, fileName);
359+
return fileSystem.watch(watchPath).pipe(
360+
Stream.filter((event) => {
361+
return (
362+
event.path === fileName ||
363+
event.path === watchedAbsolutePath ||
364+
path.resolve(watchPath, event.path) === watchedAbsolutePath
365+
);
366+
}),
367+
Stream.mapError(
368+
(cause) =>
369+
new WorkspaceFileSystemOperationError({
370+
workspaceRoot: input.cwd,
371+
relativePath: input.relativePath,
372+
resolvedPath: target.absolutePath,
373+
operationPath: watchPath,
374+
operation: "watch",
375+
cause,
376+
}),
377+
),
378+
);
379+
};
380+
381+
const lexicalTargetPath = path.join(realWatchDirectory, path.basename(target.absolutePath));
382+
const lexicalEvents = watchEntry(realWatchDirectory, path.basename(target.absolutePath));
383+
const resolvedTargetEvents =
384+
realTargetPath !== null && realTargetPath !== lexicalTargetPath
385+
? watchEntry(path.dirname(realTargetPath), path.basename(realTargetPath))
386+
: Stream.empty;
387+
return Stream.merge(lexicalEvents, resolvedTargetEvents).pipe(
388+
Stream.debounce(Duration.millis(100)),
389+
Stream.map(() => ({ relativePath: target.relativePath })),
390+
);
391+
}),
392+
);
393+
262394
const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn(
263395
"WorkspaceFileSystem.writeFile",
264396
)(function* (input) {
@@ -297,7 +429,7 @@ export const make = Effect.gen(function* () {
297429
return { relativePath: target.relativePath };
298430
});
299431

300-
return WorkspaceFileSystem.of({ readFile, writeFile });
432+
return WorkspaceFileSystem.of({ readFile, watchFile, writeFile });
301433
});
302434

303435
export const layer = Layer.effect(WorkspaceFileSystem, make);

apps/server/src/ws.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1764,6 +1764,21 @@ const makeWsRpcLayer = (
17641764
),
17651765
{ "rpc.aggregate": "workspace" },
17661766
),
1767+
[WS_METHODS.projectsWatchFile]: (input) =>
1768+
observeRpcStream(
1769+
WS_METHODS.projectsWatchFile,
1770+
workspaceFileSystem.watchFile(input).pipe(
1771+
Stream.mapError(
1772+
(cause) =>
1773+
new ProjectReadFileError({
1774+
...input,
1775+
...projectFileFailureContext(cause),
1776+
cause,
1777+
}),
1778+
),
1779+
),
1780+
{ "rpc.aggregate": "workspace" },
1781+
),
17671782
[WS_METHODS.projectsWriteFile]: (input) =>
17681783
observeRpcEffect(
17691784
WS_METHODS.projectsWriteFile,

apps/web/src/components/files/projectFilesQueryState.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,9 +169,10 @@ export function useProjectFileQuery(
169169
relativePath: string | null,
170170
enabled = true,
171171
): ProjectQueryState<ProjectReadFileResult> {
172-
const atom = enabled
173-
? getProjectFileQueryAtom(environmentId, cwd, relativePath)
174-
: EMPTY_PROJECT_FILE_QUERY_ATOM;
172+
const atom =
173+
enabled && relativePath !== null
174+
? getProjectFileQueryAtom(environmentId, cwd, relativePath)
175+
: EMPTY_PROJECT_FILE_QUERY_ATOM;
175176
const result = useAtomValue(atom);
176177
const refreshAtom = useAtomRefresh(atom);
177178
const refresh = useCallback(() => refreshAtom(), [refreshAtom]);

packages/client-runtime/src/rpc/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export type EnvironmentSubscriptionRpcTag =
5050
| typeof WS_METHODS.subscribePreviewEvents
5151
| typeof WS_METHODS.subscribeDiscoveredLocalServers
5252
| typeof WS_METHODS.subscribeResourceTelemetry
53+
| typeof WS_METHODS.projectsWatchFile
5354
| typeof WS_METHODS.previewAutomationConnect
5455
| typeof WS_METHODS.subscribeVcsStatus
5556
| typeof WS_METHODS.terminalAttach;

packages/client-runtime/src/state/projectCommands.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
createEnvironmentCommand,
88
createEnvironmentRpcCommand,
99
createEnvironmentRpcQueryAtomFamily,
10+
createEnvironmentRpcSubscriptionAtomFamily,
11+
refreshQueryOnSuccess,
1012
} from "./runtime.ts";
1113
import {
1214
type CreateProjectInput,
@@ -54,6 +56,32 @@ export function createProjectEnvironmentAtoms<R, E>(
5456
key: ({ environmentId, input }: { environmentId: string; input: { projectId: string } }) =>
5557
JSON.stringify([environmentId, input.projectId]),
5658
};
59+
const readFileQuery = createEnvironmentRpcQueryAtomFamily(runtime, {
60+
label: "environment-data:projects:read-file-query",
61+
tag: WS_METHODS.projectsReadFile,
62+
// Workspace files can change outside T3 Code, so always revalidate cached reads on mount.
63+
staleTimeMs: 0,
64+
idleTtlMs: 0,
65+
});
66+
const fileChanges = createEnvironmentRpcSubscriptionAtomFamily(runtime, {
67+
label: "environment-data:projects:file-changes",
68+
tag: WS_METHODS.projectsWatchFile,
69+
idleTtlMs: 0,
70+
});
71+
type ReadFileAtom = ReturnType<typeof readFileQuery>;
72+
const liveReadFileAtoms = new WeakMap<ReadFileAtom, ReadFileAtom>();
73+
const readFile = (target: Parameters<typeof readFileQuery>[0]): ReadFileAtom => {
74+
const queryAtom = readFileQuery(target);
75+
const cached = liveReadFileAtoms.get(queryAtom);
76+
if (cached) return cached;
77+
78+
const changesAtom = fileChanges(target);
79+
const liveAtom = refreshQueryOnSuccess(queryAtom, changesAtom).pipe(
80+
Atom.withLabel(`environment-data:projects:read-file:${target.input.relativePath}`),
81+
);
82+
liveReadFileAtoms.set(queryAtom, liveAtom);
83+
return liveAtom;
84+
};
5785
return {
5886
searchEntries: createEnvironmentRpcQueryAtomFamily(runtime, {
5987
label: "environment-data:projects:search-entries",
@@ -66,12 +94,7 @@ export function createProjectEnvironmentAtoms<R, E>(
6694
staleTimeMs: 30_000,
6795
idleTtlMs: 5 * 60_000,
6896
}),
69-
readFile: createEnvironmentRpcQueryAtomFamily(runtime, {
70-
label: "environment-data:projects:read-file",
71-
tag: WS_METHODS.projectsReadFile,
72-
staleTimeMs: 30_000,
73-
idleTtlMs: 5 * 60_000,
74-
}),
97+
readFile,
7598
optimisticFile: (target: OptimisticProjectFileTarget) =>
7699
optimisticFileFamily(optimisticProjectFileKey(target)),
77100
create: createEnvironmentCommand(runtime, {

0 commit comments

Comments
 (0)