| ... | ... | @@ -11,6 +11,7 @@ import * as Context from "effect/Context"; |
| 11 | 11 | import * as Duration from "effect/Duration"; |
| 12 | 12 | import * as Effect from "effect/Effect"; |
| 13 | 13 | import * as Exit from "effect/Exit"; |
| 14 | import * as Fiber from "effect/Fiber"; |
| 14 | 15 | import * as FileSystem from "effect/FileSystem"; |
| 15 | 16 | import * as Layer from "effect/Layer"; |
| 16 | 17 | import * as Option from "effect/Option"; |
| ... | ... | @@ -21,8 +22,8 @@ import * as Schema from "effect/Schema"; |
| 21 | 22 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 22 | 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 | | // costs ~20 filesystem probes. AssetAccess resolves on every project-favicon |
| 25 | // Resolution probes 21 well-known paths plus 7 source files, so a miss |
| 26 | // costs ~30 filesystem probes. AssetAccess resolves on every project-favicon |
| 26 | 27 | // asset URL, and a project's icon does not move, so the answer is cached. |
| 27 | 28 | const FAVICON_CACHE_CAPACITY = 512; |
| 28 | 29 | const FAVICON_POSITIVE_CACHE_TTL = Duration.minutes(10); |
| ... | ... | @@ -205,6 +206,69 @@ export const make = Effect.gen(function* () { |
| 205 | 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 | 272 | const resolvePathUncached = Effect.fn("ProjectFaviconResolver.resolvePathUncached")(function* ( |
| 209 | 273 | cwd: string, |
| 210 | 274 | faviconPath?: string, |
| ... | ... | @@ -241,58 +305,16 @@ export const make = Effect.gen(function* () { |
| 241 | 305 | } |
| 242 | 306 | } |
| 243 | 307 | |
| 244 | | for (const candidate of FAVICON_CANDIDATES) { |
| 245 | | const existing = yield* findExistingFile(projectCwd, [candidate], "workspace"); |
| 246 | | if (existing) { |
| 247 | | return existing; |
| 248 | | } |
| 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 | | } |
| 308 | const wellKnown = yield* firstInOrder(FAVICON_CANDIDATES, (candidate) => |
| 309 | findExistingFile(projectCwd, [candidate], "workspace"), |
| 310 | ); |
| 311 | if (wellKnown) { |
| 312 | return wellKnown; |
| 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 | 320 | const faviconCache = yield* Cache.makeWith<string, string | null, ProjectFaviconResolutionError>( |