| author | |
| committer | |
| log | b76f161d551235e1de89ed85c747f14041ac5d99 |
| tree | 45f42ac16e9b8c4f0cab5691e19ec4d6aee7ac66 |
| parent | 75257d64e9108b6e1620acbf423462fba3e0b62e |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: Julius Marminge <jmarminge@gmail.com>7 files changed, 130 insertions(+), 27 deletions(-)
apps/desktop/src/backend/DesktopServerExposure.test.ts+41-2| ... | @@ -64,6 +64,13 @@ function mockSpawnerLayer(statusJson = "{}") { | ... | @@ -64,6 +64,13 @@ function mockSpawnerLayer(statusJson = "{}") { |
| 64 | ); | 64 | ); |
| 65 | } | 65 | } |
| 66 | 66 | ||
| 67 | function dieOnSpawnLayer() { | ||
| 68 | return Layer.succeed( | ||
| 69 | ChildProcessSpawner.ChildProcessSpawner, | ||
| 70 | ChildProcessSpawner.make(() => Effect.die("unexpected tailscale spawn")), | ||
| 71 | ); | ||
| 72 | } | ||
| 73 | |||
| 67 | function makeEnvironmentLayer(baseDir: string, env: Record<string, string | undefined> = {}) { | 74 | function makeEnvironmentLayer(baseDir: string, env: Record<string, string | undefined> = {}) { |
| 68 | return makeDesktopEnvironmentLayer({ | 75 | return makeDesktopEnvironmentLayer({ |
| 69 | dirname: "/repo/apps/desktop/src", | 76 | dirname: "/repo/apps/desktop/src", |
| ... | @@ -86,6 +93,7 @@ function makeLayer(input: { | ... | @@ -86,6 +93,7 @@ function makeLayer(input: { |
| 86 | readonly baseDir: string; | 93 | readonly baseDir: string; |
| 87 | readonly networkInterfaces?: DesktopNetworkInterfaces; | 94 | readonly networkInterfaces?: DesktopNetworkInterfaces; |
| 88 | readonly env?: Record<string, string | undefined>; | 95 | readonly env?: Record<string, string | undefined>; |
| 96 | readonly spawnerLayer?: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>; | ||
| 89 | }) { | 97 | }) { |
| 90 | const env = { T3CODE_HOME: input.baseDir, ...input.env }; | 98 | const env = { T3CODE_HOME: input.baseDir, ...input.env }; |
| 91 | const environmentLayer = makeEnvironmentLayer(input.baseDir, env); | 99 | const environmentLayer = makeEnvironmentLayer(input.baseDir, env); |
| ... | @@ -97,7 +105,7 @@ function makeLayer(input: { | ... | @@ -97,7 +105,7 @@ function makeLayer(input: { |
| 97 | Layer.provideMerge(DesktopAppSettings.layer), | 105 | Layer.provideMerge(DesktopAppSettings.layer), |
| 98 | Layer.provideMerge(NodeFileSystem.layer), | 106 | Layer.provideMerge(NodeFileSystem.layer), |
| 99 | Layer.provideMerge(NodeHttpClient.layerUndici), | 107 | Layer.provideMerge(NodeHttpClient.layerUndici), |
| 100 | Layer.provideMerge(mockSpawnerLayer()), | 108 | Layer.provideMerge(input.spawnerLayer ?? mockSpawnerLayer()), |
| 101 | Layer.provideMerge(networkLayer), | 109 | Layer.provideMerge(networkLayer), |
| 102 | Layer.provideMerge(DesktopConfig.layerTest(env)), | 110 | Layer.provideMerge(DesktopConfig.layerTest(env)), |
| 103 | Layer.provideMerge(environmentLayer), | 111 | Layer.provideMerge(environmentLayer), |
| ... | @@ -116,13 +124,23 @@ const withHarness = <A, E, R>( | ... | @@ -116,13 +124,23 @@ const withHarness = <A, E, R>( |
| 116 | | DesktopAppSettings.DesktopAppSettings | 124 | | DesktopAppSettings.DesktopAppSettings |
| 117 | >, | 125 | >, |
| 118 | env: Record<string, string | undefined> = {}, | 126 | env: Record<string, string | undefined> = {}, |
| 127 | spawnerLayer?: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>, | ||
| 119 | ) => | 128 | ) => |
| 120 | Effect.gen(function* () { | 129 | Effect.gen(function* () { |
| 121 | const fileSystem = yield* FileSystem.FileSystem; | 130 | const fileSystem = yield* FileSystem.FileSystem; |
| 122 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | 131 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ |
| 123 | prefix: "t3-desktop-server-exposure-test-", | 132 | prefix: "t3-desktop-server-exposure-test-", |
| 124 | }); | 133 | }); |
| 125 | return yield* effect.pipe(Effect.provide(makeLayer({ baseDir, networkInterfaces, env }))); | 134 | return yield* effect.pipe( |
| 135 | Effect.provide( | ||
| 136 | makeLayer({ | ||
| 137 | baseDir, | ||
| 138 | networkInterfaces, | ||
| 139 | env, | ||
| 140 | ...(spawnerLayer ? { spawnerLayer } : {}), | ||
| 141 | }), | ||
| 142 | ), | ||
| 143 | ); | ||
| 126 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); | 144 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); |
| 127 | 145 | ||
| 128 | describe("DesktopServerExposure", () => { | 146 | describe("DesktopServerExposure", () => { |
| ... | @@ -239,6 +257,27 @@ describe("DesktopServerExposure", () => { | ... | @@ -239,6 +257,27 @@ describe("DesktopServerExposure", () => { |
| 239 | ), | 257 | ), |
| 240 | ); | 258 | ); |
| 241 | 259 | ||
| 260 | it.effect("does not spawn the tailscale CLI while server exposure is local-only", () => | ||
| 261 | withHarness( | ||
| 262 | lanNetworkInterfaces, | ||
| 263 | Effect.gen(function* () { | ||
| 264 | const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; | ||
| 265 | yield* serverExposure.configureFromSettings({ port: 4173 }); | ||
| 266 | // mode stays at default "local-only", tailscaleServeEnabled stays false. | ||
| 267 | |||
| 268 | const endpoints = yield* serverExposure.getAdvertisedEndpoints; | ||
| 269 | // Only the loopback endpoint; no tailscale spawn means the dieOnSpawnLayer | ||
| 270 | // would have crashed the test if the gate was missing. | ||
| 271 | assert.deepEqual( | ||
| 272 | endpoints.map((endpoint) => endpoint.httpBaseUrl), | ||
| 273 | ["http://127.0.0.1:4173/"], | ||
| 274 | ); | ||
| 275 | }), | ||
| 276 | {}, | ||
| 277 | dieOnSpawnLayer(), | ||
| 278 | ), | ||
| 279 | ); | ||
| 280 | |||
| 242 | it.effect("uses ConfigProvider desktop exposure overrides", () => | 281 | it.effect("uses ConfigProvider desktop exposure overrides", () => |
| 243 | withHarness( | 282 | withHarness( |
| 244 | lanNetworkInterfaces, | 283 | lanNetworkInterfaces, |
apps/desktop/src/backend/DesktopServerExposure.ts+25| ... | @@ -12,6 +12,7 @@ import type { | ... | @@ -12,6 +12,7 @@ import type { |
| 12 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 13 | import * as Context from "effect/Context"; | 13 | import * as Context from "effect/Context"; |
| 14 | import * as Data from "effect/Data"; | 14 | import * as Data from "effect/Data"; |
| 15 | import * as Duration from "effect/Duration"; | ||
| 15 | import * as Effect from "effect/Effect"; | 16 | import * as Effect from "effect/Effect"; |
| 16 | import * as Layer from "effect/Layer"; | 17 | import * as Layer from "effect/Layer"; |
| 17 | import * as Option from "effect/Option"; | 18 | import * as Option from "effect/Option"; |
| ... | @@ -22,8 +23,11 @@ import { ChildProcessSpawner } from "effect/unstable/process"; | ... | @@ -22,8 +23,11 @@ import { ChildProcessSpawner } from "effect/unstable/process"; |
| 22 | import { DEFAULT_DESKTOP_SETTINGS, type DesktopSettings } from "../settings/DesktopAppSettings.ts"; | 23 | import { DEFAULT_DESKTOP_SETTINGS, type DesktopSettings } from "../settings/DesktopAppSettings.ts"; |
| 23 | import * as DesktopConfig from "../app/DesktopConfig.ts"; | 24 | import * as DesktopConfig from "../app/DesktopConfig.ts"; |
| 24 | import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; | 25 | import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; |
| 26 | import { readTailscaleStatus } from "@t3tools/tailscale"; | ||
| 25 | import * as DesktopAppSettingsService from "../settings/DesktopAppSettings.ts"; | 27 | import * as DesktopAppSettingsService from "../settings/DesktopAppSettings.ts"; |
| 26 | 28 | ||
| 29 | const TAILSCALE_STATUS_CACHE_TTL = Duration.seconds(60); | ||
| 30 | |||
| 27 | export const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; | 31 | export const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; |
| 28 | const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; | 32 | const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; |
| 29 | 33 | ||
| ... | @@ -412,6 +416,18 @@ const make = Effect.gen(function* () { | ... | @@ -412,6 +416,18 @@ const make = Effect.gen(function* () { |
| 412 | const desktopSettings = yield* DesktopAppSettingsService.DesktopAppSettings; | 416 | const desktopSettings = yield* DesktopAppSettingsService.DesktopAppSettings; |
| 413 | const stateRef = yield* Ref.make(initialRuntimeState()); | 417 | const stateRef = yield* Ref.make(initialRuntimeState()); |
| 414 | 418 | ||
| 419 | // Cache the `tailscale status` spawn for the TTL. On macOS, the Mac App | ||
| 420 | // Store Tailscale CLI lives inside Tailscale's sandbox container, so each | ||
| 421 | // spawn re-triggers the "Other apps" TCC prompt. | ||
| 422 | const cachedReadMagicDnsName = yield* Effect.cachedWithTTL( | ||
| 423 | readTailscaleStatus.pipe( | ||
| 424 | Effect.map((status) => status.magicDnsName), | ||
| 425 | Effect.orElseSucceed(() => null), | ||
| 426 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), | ||
| 427 | ), | ||
| 428 | TAILSCALE_STATUS_CACHE_TTL, | ||
| 429 | ); | ||
| 430 | |||
| 415 | const readNetworkInterfaces = networkInterfaces.read; | 431 | const readNetworkInterfaces = networkInterfaces.read; |
| 416 | 432 | ||
| 417 | const getState = Ref.get(stateRef).pipe(Effect.map(toContractState)); | 433 | const getState = Ref.get(stateRef).pipe(Effect.map(toContractState)); |
| ... | @@ -516,11 +532,20 @@ const make = Effect.gen(function* () { | ... | @@ -516,11 +532,20 @@ const make = Effect.gen(function* () { |
| 516 | exposure: toResolvedExposure(state), | 532 | exposure: toResolvedExposure(state), |
| 517 | customHttpsEndpointUrls: config.desktopHttpsEndpointUrls, | 533 | customHttpsEndpointUrls: config.desktopHttpsEndpointUrls, |
| 518 | }); | 534 | }); |
| 535 | |||
| 536 | // Don't spawn the Tailscale CLI when the user hasn't opted into any | ||
| 537 | // network exposure. The spawn itself triggers a macOS "Other apps" | ||
| 538 | // TCC prompt on Mac App Store Tailscale builds. | ||
| 539 | if (state.mode !== "network-accessible" && !state.tailscaleServeEnabled) { | ||
| 540 | return coreEndpoints; | ||
| 541 | } | ||
| 542 | |||
| 519 | const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({ | 543 | const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({ |
| 520 | port: state.port, | 544 | port: state.port, |
| 521 | serveEnabled: state.tailscaleServeEnabled, | 545 | serveEnabled: state.tailscaleServeEnabled, |
| 522 | servePort: state.tailscaleServePort, | 546 | servePort: state.tailscaleServePort, |
| 523 | networkInterfaces: currentNetworkInterfaces, | 547 | networkInterfaces: currentNetworkInterfaces, |
| 548 | readMagicDnsName: cachedReadMagicDnsName, | ||
| 524 | }).pipe( | 549 | }).pipe( |
| 525 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), | 550 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), |
| 526 | Effect.provideService(HttpClient.HttpClient, httpClient), | 551 | Effect.provideService(HttpClient.HttpClient, httpClient), |
apps/desktop/src/backend/tailscaleEndpointProvider.test.ts+19| ... | @@ -104,6 +104,25 @@ describe("tailscale endpoint provider", () => { | ... | @@ -104,6 +104,25 @@ describe("tailscale endpoint provider", () => { |
| 104 | }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)), | 104 | }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)), |
| 105 | ); | 105 | ); |
| 106 | 106 | ||
| 107 | it.effect("uses an injected magic DNS name reader instead of spawning tailscale", () => | ||
| 108 | Effect.gen(function* () { | ||
| 109 | let readerCalls = 0; | ||
| 110 | const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ | ||
| 111 | port: 3773, | ||
| 112 | networkInterfaces: {}, | ||
| 113 | readMagicDnsName: Effect.sync(() => { | ||
| 114 | readerCalls += 1; | ||
| 115 | return "desktop.tail.ts.net"; | ||
| 116 | }), | ||
| 117 | }); | ||
| 118 | assert.equal(readerCalls, 1); | ||
| 119 | assert.deepEqual( | ||
| 120 | endpoints.map((endpoint) => endpoint.httpBaseUrl), | ||
| 121 | ["https://desktop.tail.ts.net/"], | ||
| 122 | ); | ||
| 123 | }).pipe(Effect.provide(unusedTailscaleExternalServicesLayer)), | ||
| 124 | ); | ||
| 125 | |||
| 107 | it.effect( | 126 | it.effect( |
| 108 | "marks the Tailscale HTTPS endpoint available after Serve is enabled and reachable", | 127 | "marks the Tailscale HTTPS endpoint available after Serve is enabled and reachable", |
| 109 | () => | 128 | () => |
apps/desktop/src/backend/tailscaleEndpointProvider.ts+12-4| ... | @@ -105,6 +105,11 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd | ... | @@ -105,6 +105,11 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd |
| 105 | readonly servePort?: number; | 105 | readonly servePort?: number; |
| 106 | readonly networkInterfaces: DesktopNetworkInterfaces; | 106 | readonly networkInterfaces: DesktopNetworkInterfaces; |
| 107 | readonly statusJson?: string | null; | 107 | readonly statusJson?: string | null; |
| 108 | readonly readMagicDnsName?: Effect.Effect< | ||
| 109 | string | null, | ||
| 110 | never, | ||
| 111 | ChildProcessSpawner.ChildProcessSpawner | ||
| 112 | >; | ||
| 108 | readonly probe?: (baseUrl: string) => Effect.Effect<boolean, never, HttpClient.HttpClient>; | 113 | readonly probe?: (baseUrl: string) => Effect.Effect<boolean, never, HttpClient.HttpClient>; |
| 109 | }): Effect.fn.Return< | 114 | }): Effect.fn.Return< |
| 110 | readonly AdvertisedEndpoint[], | 115 | readonly AdvertisedEndpoint[], |
| ... | @@ -112,12 +117,15 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd | ... | @@ -112,12 +117,15 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd |
| 112 | ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient | 117 | ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient |
| 113 | > { | 118 | > { |
| 114 | const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints(input); | 119 | const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints(input); |
| 120 | const readDnsName = | ||
| 121 | input.readMagicDnsName ?? | ||
| 122 | readTailscaleStatus.pipe( | ||
| 123 | Effect.map((status) => status.magicDnsName), | ||
| 124 | Effect.catch(() => Effect.succeed<string | null>(null)), | ||
| 125 | ); | ||
| 115 | const dnsName = | 126 | const dnsName = |
| 116 | input.statusJson === undefined | 127 | input.statusJson === undefined |
| 117 | ? yield* readTailscaleStatus.pipe( | 128 | ? yield* readDnsName |
| 118 | Effect.map((status) => status.magicDnsName), | ||
| 119 | Effect.orElseSucceed(() => null), | ||
| 120 | ) | ||
| 121 | : input.statusJson | 129 | : input.statusJson |
| 122 | ? yield* parseTailscaleMagicDnsName(input.statusJson).pipe( | 130 | ? yield* parseTailscaleMagicDnsName(input.statusJson).pipe( |
| 123 | Effect.orElseSucceed(() => null), | 131 | Effect.orElseSucceed(() => null), |
apps/server/src/workspace/Layers/WorkspaceEntries.test.ts+15| ... | @@ -392,5 +392,20 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { | ... | @@ -392,5 +392,20 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { |
| 392 | expect(error.detail).toBe("Relative filesystem browse paths require a current project."); | 392 | expect(error.detail).toBe("Relative filesystem browse paths require a current project."); |
| 393 | }), | 393 | }), |
| 394 | ); | 394 | ); |
| 395 | |||
| 396 | it.effect("returns an empty listing when the OS denies directory access", () => | ||
| 397 | Effect.gen(function* () { | ||
| 398 | const workspaceEntries = yield* WorkspaceEntries; | ||
| 399 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-eacces-" }); | ||
| 400 | |||
| 401 | const denied = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }); | ||
| 402 | vi.spyOn(fsPromises, "readdir").mockRejectedValueOnce(denied); | ||
| 403 | |||
| 404 | const result = yield* workspaceEntries.browse({ | ||
| 405 | partialPath: appendSeparator(cwd), | ||
| 406 | }); | ||
| 407 | expect(result).toEqual({ parentPath: cwd, entries: [] }); | ||
| 408 | }), | ||
| 409 | ); | ||
| 395 | }); | 410 | }); |
| 396 | }); | 411 | }); |
apps/server/src/workspace/Layers/WorkspaceEntries.ts+12-1| ... | @@ -457,7 +457,18 @@ export const makeWorkspaceEntries = Effect.gen(function* () { | ... | @@ -457,7 +457,18 @@ export const makeWorkspaceEntries = Effect.gen(function* () { |
| 457 | detail: `Unable to browse '${parentPath}': ${cause instanceof Error ? cause.message : String(cause)}`, | 457 | detail: `Unable to browse '${parentPath}': ${cause instanceof Error ? cause.message : String(cause)}`, |
| 458 | cause, | 458 | cause, |
| 459 | }), | 459 | }), |
| 460 | }); | 460 | }).pipe( |
| 461 | // The user can deny macOS TCC prompts for the target dir (Documents, | ||
| 462 | // Downloads, Music, etc.); surface an empty listing instead of an | ||
| 463 | // error so the caller doesn't retry-loop the prompt. | ||
| 464 | Effect.catchIf( | ||
| 465 | (error) => { | ||
| 466 | const code = (error.cause as NodeJS.ErrnoException | undefined)?.code; | ||
| 467 | return code === "EACCES" || code === "EPERM"; | ||
| 468 | }, | ||
| 469 | () => Effect.succeed<Dirent[]>([]), | ||
| 470 | ), | ||
| 471 | ); | ||
| 461 | 472 | ||
| 462 | const showHidden = endsWithSeparator || prefix.startsWith("."); | 473 | const showHidden = endsWithSeparator || prefix.startsWith("."); |
| 463 | const lowerPrefix = prefix.toLowerCase(); | 474 | const lowerPrefix = prefix.toLowerCase(); |
apps/web/src/components/CommandPalette.tsx+6-20| ... | @@ -562,11 +562,7 @@ function OpenCommandPaletteDialog() { | ... | @@ -562,11 +562,7 @@ function OpenCommandPaletteDialog() { |
| 562 | !relativePathNeedsActiveProject, | 562 | !relativePathNeedsActiveProject, |
| 563 | }); | 563 | }); |
| 564 | const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES; | 564 | const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES; |
| 565 | const { | 565 | const { filteredEntries: filteredBrowseEntries, exactEntry: exactBrowseEntry } = useMemo( |
| 566 | filteredEntries: filteredBrowseEntries, | ||
| 567 | highlightedEntry: highlightedBrowseEntry, | ||
| 568 | exactEntry: exactBrowseEntry, | ||
| 569 | } = useMemo( | ||
| 570 | () => filterBrowseEntries({ browseEntries, browseFilterQuery, highlightedItemValue }), | 566 | () => filterBrowseEntries({ browseEntries, browseFilterQuery, highlightedItemValue }), |
| 571 | [browseEntries, browseFilterQuery, highlightedItemValue], | 567 | [browseEntries, browseFilterQuery, highlightedItemValue], |
| 572 | ); | 568 | ); |
| ... | @@ -587,27 +583,17 @@ function OpenCommandPaletteDialog() { | ... | @@ -587,27 +583,17 @@ function OpenCommandPaletteDialog() { |
| 587 | [browseEnvironmentId, currentProjectCwdForBrowse, fetchBrowseResult, queryClient], | 583 | [browseEnvironmentId, currentProjectCwdForBrowse, fetchBrowseResult, queryClient], |
| 588 | ); | 584 | ); |
| 589 | 585 | ||
| 590 | // Prefetch the parent and the most likely next child so browse navigation | 586 | // Prefetch only the parent (for back-navigation). Prefetching the |
| 591 | // stays warm without scanning every child directory in large trees. | 587 | // highlighted child on every arrow-key press triggers a macOS TCC prompt |
| 588 | // whenever the highlighted entry is a permission-gated home dir (Music, | ||
| 589 | // Documents, Downloads, Desktop, etc.), so we wait for explicit navigation. | ||
| 592 | useEffect(() => { | 590 | useEffect(() => { |
| 593 | if (!isBrowsing || filteredBrowseEntries.length === 0) return; | 591 | if (!isBrowsing || filteredBrowseEntries.length === 0) return; |
| 594 | 592 | ||
| 595 | if (canNavigateUp(query)) { | 593 | if (canNavigateUp(query)) { |
| 596 | prefetchBrowsePath(getBrowseParentPath(query)!); | 594 | prefetchBrowsePath(getBrowseParentPath(query)!); |
| 597 | } | 595 | } |
| 598 | 596 | }, [filteredBrowseEntries.length, isBrowsing, prefetchBrowsePath, query]); | |
| 599 | const nextChild = highlightedBrowseEntry ?? exactBrowseEntry; | ||
| 600 | if (nextChild) { | ||
| 601 | prefetchBrowsePath(appendBrowsePathSegment(query, nextChild.name)); | ||
| 602 | } | ||
| 603 | }, [ | ||
| 604 | exactBrowseEntry, | ||
| 605 | filteredBrowseEntries.length, | ||
| 606 | highlightedBrowseEntry, | ||
| 607 | isBrowsing, | ||
| 608 | prefetchBrowsePath, | ||
| 609 | query, | ||
| 610 | ]); | ||
| 611 | 597 | ||
| 612 | const openProjectFromSearch = useMemo( | 598 | const openProjectFromSearch = useMemo( |
| 613 | () => async (project: (typeof projects)[number]) => { | 599 | () => async (project: (typeof projects)[number]) => { |