| ... | @@ -11,6 +11,7 @@ import * as Context from "effect/Context"; | ... | @@ -11,6 +11,7 @@ import * as Context from "effect/Context"; |
| 11 | import * as Duration from "effect/Duration"; | 11 | import * as Duration from "effect/Duration"; |
| 12 | import * as Effect from "effect/Effect"; | 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Exit from "effect/Exit"; | 13 | import * as Exit from "effect/Exit"; |
| | 14 | import * as Fiber from "effect/Fiber"; |
| 14 | import * as FileSystem from "effect/FileSystem"; | 15 | import * as FileSystem from "effect/FileSystem"; |
| 15 | import * as Layer from "effect/Layer"; | 16 | import * as Layer from "effect/Layer"; |
| 16 | import * as Option from "effect/Option"; | 17 | import * as Option from "effect/Option"; |
| ... | @@ -21,8 +22,8 @@ import * as Schema from "effect/Schema"; | ... | @@ -21,8 +22,8 @@ import * as Schema from "effect/Schema"; |
| 21 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; | 22 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 22 | import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts"; | 23 | import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts"; |
| 23 | | 24 | |
| 24 | // Resolution walks up to 12 well-known paths plus 7 source files, so a miss | 25 | // Resolution probes 21 well-known paths plus 7 source files, so a miss |
| 25 | // costs ~20 filesystem probes. AssetAccess resolves on every project-favicon | 26 | // costs ~30 filesystem probes. AssetAccess resolves on every project-favicon |
| 26 | // asset URL, and a project's icon does not move, so the answer is cached. | 27 | // asset URL, and a project's icon does not move, so the answer is cached. |
| 27 | const FAVICON_CACHE_CAPACITY = 512; | 28 | const FAVICON_CACHE_CAPACITY = 512; |
| 28 | const FAVICON_POSITIVE_CACHE_TTL = Duration.minutes(10); | 29 | const FAVICON_POSITIVE_CACHE_TTL = Duration.minutes(10); |
| ... | @@ -205,6 +206,69 @@ export const make = Effect.gen(function* () { | ... | @@ -205,6 +206,69 @@ export const make = Effect.gen(function* () { |
| 205 | return null; | 206 | return null; |
| 206 | }); | 207 | }); |
| 207 | | 208 | |
| | 209 | // Reads one source file and resolves the icon it declares, if any. |
| | 210 | const findIconFromSource = Effect.fn("ProjectFaviconResolver.findIconFromSource")(function* ( |
| | 211 | projectCwd: string, |
| | 212 | sourceFile: string, |
| | 213 | ): Effect.fn.Return<string | null, ProjectFaviconResolutionError> { |
| | 214 | const sourcePath = yield* workspacePaths |
| | 215 | .resolveRelativePathWithinRoot({ |
| | 216 | workspaceRoot: projectCwd, |
| | 217 | relativePath: sourceFile, |
| | 218 | }) |
| | 219 | .pipe( |
| | 220 | Effect.mapError( |
| | 221 | (cause) => |
| | 222 | new ProjectFaviconResolutionError({ |
| | 223 | operation: "resolve-path", |
| | 224 | workspaceRoot: projectCwd, |
| | 225 | relativePath: sourceFile, |
| | 226 | cause, |
| | 227 | }), |
| | 228 | ), |
| | 229 | ); |
| | 230 | const source = yield* optionOnNotFound(fileSystem.readFileString(sourcePath.absolutePath)).pipe( |
| | 231 | Effect.mapError( |
| | 232 | (cause) => |
| | 233 | new ProjectFaviconResolutionError({ |
| | 234 | operation: "read-source", |
| | 235 | workspaceRoot: projectCwd, |
| | 236 | relativePath: sourceFile, |
| | 237 | absolutePath: sourcePath.absolutePath, |
| | 238 | cause, |
| | 239 | }), |
| | 240 | ), |
| | 241 | ); |
| | 242 | if (Option.isNone(source)) { |
| | 243 | return null; |
| | 244 | } |
| | 245 | const href = extractIconHref(source.value); |
| | 246 | if (!href) { |
| | 247 | return null; |
| | 248 | } |
| | 249 | return yield* findExistingFile(projectCwd, resolveIconHref(href), "workspace"); |
| | 250 | }); |
| | 251 | |
| | 252 | // Starts every probe at once and returns the first hit in list order. A miss |
| | 253 | // costs one round of filesystem latency instead of one per probe, which |
| | 254 | // matters when the disk is slow. Fibers are joined in order, so a hit returns |
| | 255 | // without waiting for lower-ranked probes (the scope interrupts them), and a |
| | 256 | // failure surfaces only when no earlier probe found a file. |
| | 257 | const firstInOrder = <A>( |
| | 258 | items: ReadonlyArray<A>, |
| | 259 | probe: (item: A) => Effect.Effect<string | null, ProjectFaviconResolutionError>, |
| | 260 | ) => |
| | 261 | Effect.gen(function* () { |
| | 262 | const fibers = yield* Effect.forEach(items, (item) => Effect.forkScoped(probe(item))); |
| | 263 | for (const fiber of fibers) { |
| | 264 | const found = yield* Fiber.join(fiber); |
| | 265 | if (found) { |
| | 266 | return found; |
| | 267 | } |
| | 268 | } |
| | 269 | return null; |
| | 270 | }).pipe(Effect.scoped); |
| | 271 | |
| 208 | const resolvePathUncached = Effect.fn("ProjectFaviconResolver.resolvePathUncached")(function* ( | 272 | const resolvePathUncached = Effect.fn("ProjectFaviconResolver.resolvePathUncached")(function* ( |
| 209 | cwd: string, | 273 | cwd: string, |
| 210 | faviconPath?: string, | 274 | faviconPath?: string, |
| ... | @@ -241,58 +305,16 @@ export const make = Effect.gen(function* () { | ... | @@ -241,58 +305,16 @@ export const make = Effect.gen(function* () { |
| 241 | } | 305 | } |
| 242 | } | 306 | } |
| 243 | | 307 | |
| 244 | for (const candidate of FAVICON_CANDIDATES) { | 308 | const wellKnown = yield* firstInOrder(FAVICON_CANDIDATES, (candidate) => |
| 245 | const existing = yield* findExistingFile(projectCwd, [candidate], "workspace"); | 309 | findExistingFile(projectCwd, [candidate], "workspace"), |
| 246 | if (existing) { | 310 | ); |
| 247 | return existing; | 311 | if (wellKnown) { |
| 248 | } | 312 | return wellKnown; |
| 249 | } | | |
| 250 | | | |
| 251 | for (const sourceFile of ICON_SOURCE_FILES) { | | |
| 252 | const sourcePath = yield* workspacePaths | | |
| 253 | .resolveRelativePathWithinRoot({ | | |
| 254 | workspaceRoot: projectCwd, | | |
| 255 | relativePath: sourceFile, | | |
| 256 | }) | | |
| 257 | .pipe( | | |
| 258 | Effect.mapError( | | |
| 259 | (cause) => | | |
| 260 | new ProjectFaviconResolutionError({ | | |
| 261 | operation: "resolve-path", | | |
| 262 | workspaceRoot: projectCwd, | | |
| 263 | relativePath: sourceFile, | | |
| 264 | cause, | | |
| 265 | }), | | |
| 266 | ), | | |
| 267 | ); | | |
| 268 | const source = yield* optionOnNotFound( | | |
| 269 | fileSystem.readFileString(sourcePath.absolutePath), | | |
| 270 | ).pipe( | | |
| 271 | Effect.mapError( | | |
| 272 | (cause) => | | |
| 273 | new ProjectFaviconResolutionError({ | | |
| 274 | operation: "read-source", | | |
| 275 | workspaceRoot: projectCwd, | | |
| 276 | relativePath: sourceFile, | | |
| 277 | absolutePath: sourcePath.absolutePath, | | |
| 278 | cause, | | |
| 279 | }), | | |
| 280 | ), | | |
| 281 | ); | | |
| 282 | if (Option.isNone(source)) { | | |
| 283 | continue; | | |
| 284 | } | | |
| 285 | const href = extractIconHref(source.value); | | |
| 286 | if (!href) { | | |
| 287 | continue; | | |
| 288 | } | | |
| 289 | const existing = yield* findExistingFile(projectCwd, resolveIconHref(href), "workspace"); | | |
| 290 | if (existing) { | | |
| 291 | return existing; | | |
| 292 | } | | |
| 293 | } | 313 | } |
| 294 | | 314 | |
| 295 | return null; | 315 | return yield* firstInOrder(ICON_SOURCE_FILES, (sourceFile) => |
| | 316 | findIconFromSource(projectCwd, sourceFile), |
| | 317 | ); |
| 296 | }); | 318 | }); |
| 297 | | 319 | |
| 298 | const faviconCache = yield* Cache.makeWith<string, string | null, ProjectFaviconResolutionError>( | 320 | const faviconCache = yield* Cache.makeWith<string, string | null, ProjectFaviconResolutionError>( |