1010import * as NodeFSP from "node:fs/promises" ;
1111
1212import type {
13+ ProjectFileChangeEvent ,
1314 ProjectReadFileInput ,
1415 ProjectReadFileResult ,
1516 ProjectWriteFileInput ,
1617 ProjectWriteFileResult ,
1718} from "@t3tools/contracts" ;
1819import * as Context from "effect/Context" ;
20+ import * as Duration from "effect/Duration" ;
1921import * as Effect from "effect/Effect" ;
2022import * as FileSystem from "effect/FileSystem" ;
2123import * as Layer from "effect/Layer" ;
2224import * as Path from "effect/Path" ;
2325import * as Schema from "effect/Schema" ;
26+ import * as Stream from "effect/Stream" ;
2427
2528import * as WorkspaceEntries from "./WorkspaceEntries.ts" ;
2629import * 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
303435export const layer = Layer . effect ( WorkspaceFileSystem , make ) ;
0 commit comments