From 98e83b0b4eaabe90d9d8c59e6dcf891942eafd18 Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:20:34 +0200 Subject: [PATCH 1/8] chore(f7d37879-desktop-deeplinks): reapply product after upstream restack (no docs reorg) --- .agents/skills/test-t3-mobile/SKILL.md | 2 +- apps/desktop/src/app/DesktopApp.test.ts | 22 + apps/desktop/src/app/DesktopApp.ts | 38 +- .../src/app/DesktopAppIdentity.test.ts | 1 + apps/desktop/src/app/DesktopAppIdentity.ts | 46 +- apps/desktop/src/app/DesktopClerk.test.ts | 243 ++++++---- apps/desktop/src/app/DesktopClerk.ts | 53 ++- apps/desktop/src/app/DesktopDeepLinks.test.ts | 363 +++++++++++++++ apps/desktop/src/app/DesktopDeepLinks.ts | 422 ++++++++++++++++++ apps/desktop/src/app/DesktopLifecycle.test.ts | 3 + .../src/backend/DesktopBackendPool.test.ts | 2 + .../src/electron/DesktopEarlyStartup.test.ts | 53 +++ .../src/electron/DesktopEarlyStartup.ts | 27 ++ apps/desktop/src/electron/ElectronApp.test.ts | 4 + apps/desktop/src/electron/ElectronApp.ts | 2 + apps/desktop/src/electron/ElectronProtocol.ts | 28 ++ apps/desktop/src/main.ts | 14 + .../DesktopTelemetryPublisher.test.ts | 1 + .../src/window/DesktopApplicationMenu.test.ts | 3 + apps/desktop/src/window/DesktopWindow.test.ts | 90 ++++ apps/desktop/src/window/DesktopWindow.ts | 144 +++++- apps/server/src/os-jank.ts | 19 - .../src/workspace/WorkspaceEntries.test.ts | 344 +------------- apps/server/src/workspace/WorkspaceEntries.ts | 105 ++--- .../workspace/WorkspaceSearchIndex.test.ts | 143 +----- .../src/workspace/WorkspaceSearchIndex.ts | 315 ++----------- apps/server/src/ws.ts | 18 - .../src/components/CommandPalette.logic.ts | 2 +- apps/web/src/components/ProjectFavicon.tsx | 61 +-- apps/web/src/components/Sidebar.tsx | 23 +- apps/web/src/components/SidebarV2.tsx | 15 + .../components/desktopUpdate.logic.test.ts | 18 - .../web/src/components/desktopUpdate.logic.ts | 18 - .../src/components/files/FileBrowserPanel.tsx | 72 --- .../src/components/files/FilePreviewPanel.tsx | 188 ++------ .../files/projectFilesQueryState.ts | 27 -- apps/web/src/projectJump.test.ts | 60 +++ apps/web/src/projectJump.ts | 121 +++++ apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/_chat.jump.tsx | 87 ++++ apps/web/src/state/projects.ts | 13 - apps/web/src/state/queries.ts | 97 +--- packages/contracts/src/baseSchemas.ts | 25 -- packages/contracts/src/keybindings.test.ts | 77 ---- packages/contracts/src/keybindings.ts | 13 +- packages/contracts/src/project.test.ts | 42 -- packages/contracts/src/project.ts | 82 +--- packages/contracts/src/rpc.ts | 9 - packages/shared/src/projectFavicon.test.ts | 26 +- packages/shared/src/projectFavicon.ts | 17 - pnpm-workspace.yaml | 66 +-- scripts/build-desktop-artifact.test.ts | 5 - scripts/build-desktop-artifact.ts | 18 +- 53 files changed, 1947 insertions(+), 1761 deletions(-) create mode 100644 apps/desktop/src/app/DesktopApp.test.ts create mode 100644 apps/desktop/src/app/DesktopDeepLinks.test.ts create mode 100644 apps/desktop/src/app/DesktopDeepLinks.ts create mode 100644 apps/desktop/src/electron/DesktopEarlyStartup.test.ts create mode 100644 apps/desktop/src/electron/DesktopEarlyStartup.ts create mode 100644 apps/web/src/projectJump.test.ts create mode 100644 apps/web/src/projectJump.ts create mode 100644 apps/web/src/routes/_chat.jump.tsx diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index 98c1c3b2022..f3e3dcfd8ce 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -66,7 +66,7 @@ Use these client origins: - Android Emulator: `http://10.0.2.2:` - Physical device: bind the backend to `0.0.0.0` and use the host's reachable LAN origin -Enter the complete `http://` origin to make the test transport explicit. Bare IP addresses default to HTTP, while bare hostnames default to HTTPS. When testing web and mobile together, run `vp run dev --home-dir --host 127.0.0.1` instead and do not launch a second backend over the same base directory. +Always enter the complete `http://` origin; the mobile host field otherwise assumes HTTPS. When testing web and mobile together, run `vp run dev --home-dir --host 127.0.0.1` instead and do not launch a second backend over the same base directory. ## Start or reuse Metro safely diff --git a/apps/desktop/src/app/DesktopApp.test.ts b/apps/desktop/src/app/DesktopApp.test.ts new file mode 100644 index 00000000000..9e46b137b0b --- /dev/null +++ b/apps/desktop/src/app/DesktopApp.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Option from "effect/Option"; + +import { resolveDesktopBackendPortHint } from "./DesktopApp.ts"; + +describe("resolveDesktopBackendPortHint", () => { + it("keeps the renderer protocol aligned with a reused live backend", () => { + expect(resolveDesktopBackendPortHint("http://127.0.0.1:8080/", Option.some(3773))).toEqual( + Option.some(8080), + ); + }); + + it("uses the configured port when no live backend exists", () => { + expect(resolveDesktopBackendPortHint(undefined, Option.some(4949))).toEqual(Option.some(4949)); + }); + + it("ignores a malformed live backend marker", () => { + expect(resolveDesktopBackendPortHint("not a URL", Option.some(4949))).toEqual( + Option.some(4949), + ); + }); +}); diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 4101840530f..edb3f2c8f8f 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -13,6 +13,7 @@ import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; +import * as DesktopDeepLinks from "./DesktopDeepLinks.ts"; import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; @@ -23,6 +24,7 @@ import * as DesktopObservability from "./DesktopObservability.ts"; import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; +import { readLiveExistingBackend } from "../backend/DesktopExistingBackend.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; import * as DesktopState from "./DesktopState.ts"; @@ -66,6 +68,24 @@ const { logInfo: logBootstrapInfo, logWarning: logBootstrapWarning } = const { logInfo: logStartupInfo, logError: logStartupError } = DesktopObservability.makeComponentLogger("desktop-startup"); +export function resolveDesktopBackendPortHint( + existingBackendHttpBaseUrl: string | undefined, + configuredPort: Option.Option, +): Option.Option { + if (existingBackendHttpBaseUrl !== undefined) { + try { + const port = Number.parseInt(new URL(existingBackendHttpBaseUrl).port, 10); + if (Number.isSafeInteger(port) && port > 0 && port <= MAX_TCP_PORT) { + return Option.some(port); + } + } catch { + // Fall through to the configured port when the live marker is malformed. + } + } + + return configuredPort; +} + const resolveDesktopBackendPort = Effect.fn("resolveDesktopBackendPort")(function* ( configuredPort: Option.Option, ) { @@ -154,7 +174,10 @@ const bootstrap = Effect.gen(function* () { return yield* new DesktopDevelopmentBackendPortRequiredError(); } - const backendPortSelection = yield* resolveDesktopBackendPort(environment.configuredBackendPort); + const existingBackend = readLiveExistingBackend(environment.stateDir); + const backendPortSelection = yield* resolveDesktopBackendPort( + resolveDesktopBackendPortHint(existingBackend?.httpBaseUrl, environment.configuredBackendPort), + ); const backendPort = backendPortSelection.port; yield* logBootstrapInfo( backendPortSelection.selectedByScan @@ -165,6 +188,13 @@ const bootstrap = Effect.gen(function* () { ...(backendPortSelection.selectedByScan ? { startPort: DEFAULT_DESKTOP_BACKEND_PORT } : {}), }, ); + if (existingBackend) { + yield* logBootstrapInfo("reusing existing backend for shared T3 home", { + pid: existingBackend.pid, + httpBaseUrl: existingBackend.httpBaseUrl, + stateDir: existingBackend.stateDir, + }); + } const settings = yield* desktopSettings.get; if (settings.serverExposureMode !== environment.defaultDesktopSettings.serverExposureMode) { @@ -216,6 +246,12 @@ const bootstrap = Effect.gen(function* () { // slow first wsl.exe spawn. yield* Effect.forkScoped(wslBackend.reconcile); } + + // Catalog + window services are usable; flush any deep link captured from + // initial argv / open-url during single-instance setup. + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + yield* deepLinks.start; + yield* logBootstrapInfo("bootstrap deep links ready"); }).pipe(Effect.withSpan("desktop.bootstrap")); const startup = Effect.gen(function* () { diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index de945054c89..38bd6d73e06 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -54,6 +54,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => calls.setAboutPanelOptions.push(options); }), setAppUserModelId: () => Effect.void, + requestSingleInstanceLock: Effect.succeed(true), getAppMetrics: Effect.succeed([]), isDefaultProtocolClient: () => Effect.succeed(false), setAsDefaultProtocolClient: () => Effect.succeed(true), diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 0be55d633e6..385e694338d 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -45,27 +45,6 @@ const normalizeCommitHash = (value: string): Option.Option => { : Option.none(); }; -export const resolveUserDataPath = Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const legacyPath = environment.path.join( - environment.appDataDirectory, - environment.legacyUserDataDirName, - ); - const legacyPathExists = yield* fileSystem.exists(legacyPath).pipe( - Effect.mapError( - (cause) => - new DesktopUserDataPathResolutionError({ - legacyPath, - cause, - }), - ), - ); - return legacyPathExists - ? legacyPath - : environment.path.join(environment.appDataDirectory, environment.userDataDirName); -}).pipe(Effect.withSpan("desktop.appIdentity.resolveUserDataPath")); - export const make = Effect.gen(function* () { const assets = yield* DesktopAssets.DesktopAssets; const electronApp = yield* ElectronApp.ElectronApp; @@ -111,11 +90,24 @@ export const make = Effect.gen(function* () { return commitHash; }); - const userDataPath = resolveUserDataPath.pipe( - Effect.provide( - yield* Effect.context(), - ), - ); + const resolveUserDataPath = Effect.gen(function* () { + const legacyPath = environment.path.join( + environment.appDataDirectory, + environment.legacyUserDataDirName, + ); + const legacyPathExists = yield* fileSystem.exists(legacyPath).pipe( + Effect.mapError( + (cause) => + new DesktopUserDataPathResolutionError({ + legacyPath, + cause, + }), + ), + ); + return legacyPathExists + ? legacyPath + : environment.path.join(environment.appDataDirectory, environment.userDataDirName); + }).pipe(Effect.withSpan("desktop.appIdentity.resolveUserDataPath")); const configure = Effect.gen(function* () { const commitHash = yield* resolveAboutCommitHash; @@ -144,7 +136,7 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.appIdentity.configure")); return DesktopAppIdentity.of({ - resolveUserDataPath: userDataPath, + resolveUserDataPath, configure, }); }); diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 2f61ca909ae..2e1058b878a 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -2,6 +2,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; import { beforeEach, vi } from "vite-plus/test"; const { createClerkBridgeMock, storageAdapter, storageMock } = vi.hoisted(() => ({ @@ -22,38 +23,20 @@ vi.mock("@clerk/electron/storage", () => ({ storage: storageMock, })); -import * as Exit from "effect/Exit"; -import * as FileSystem from "effect/FileSystem"; import * as ElectronApp from "../electron/ElectronApp.ts"; -import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; +import * as DesktopDeepLinks from "./DesktopDeepLinks.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -const makeDesktopClerkLayer = (isDevelopment = true, events: string[] = []) => { +const makeDesktopClerkLayer = (isDevelopment = true, isPackaged = false) => { const environment = DesktopEnvironment.DesktopEnvironment.of({ stateDir: "/tmp/t3-state", isDevelopment, - appDataDirectory: "/tmp/app-data", - userDataDirName: isDevelopment ? "t3code-dev" : "t3code", - legacyUserDataDirName: isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)", - path: { join: (...parts: ReadonlyArray) => parts.join("/") }, + isPackaged, } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); - const electronApp = { - setPath: (name: string, value: string) => - Effect.sync(() => { - events.push(`setPath:${name}:${value}`); - }), - } as unknown as ElectronApp.ElectronApp["Service"]; - return DesktopClerk.layer.pipe( - Layer.provide( - Layer.mergeAll( - Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment), - Layer.succeed(ElectronApp.ElectronApp, electronApp), - FileSystem.layerNoop({ exists: () => Effect.succeed(false) }), - ), - ), + Layer.provide(Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment)), ); }; @@ -76,15 +59,11 @@ describe("DesktopClerk", () => { it.effect("acquires and releases the SDK bridge with the layer", () => { const cleanup = vi.fn(); - const events: string[] = []; storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockImplementation(() => { - events.push("createClerkBridge"); - return { cleanup, isPrimaryInstance: true }; - }); + createClerkBridgeMock.mockReturnValue({ cleanup }); return Effect.gen(function* () { - yield* Effect.scoped(Layer.build(makeDesktopClerkLayer(true, events))); + yield* Effect.scoped(Layer.build(makeDesktopClerkLayer())); assert.deepEqual(createClerkBridgeMock.mock.calls, [ [ @@ -96,10 +75,6 @@ describe("DesktopClerk", () => { ], ]); assert.equal(cleanup.mock.calls.length, 1); - // The bridge acquires Electron's single-instance lock at creation, and - // the lock both lives in and creates the userData directory — so the - // real path must be set before the bridge exists. - assert.deepEqual(events, ["setPath:userData:/tmp/app-data/t3code-dev", "createClerkBridge"]); storageMock.mockClear(); createClerkBridgeMock.mockClear(); }); @@ -153,67 +128,11 @@ describe("DesktopClerk", () => { }); }); - it.effect("registers the second-instance handler in the primary instance", () => { - storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); - const quit = vi.fn(); - const registeredEvents: string[] = []; - const electronApp = { - quit: Effect.sync(quit), - on: (eventName: string) => - Effect.sync(() => { - registeredEvents.push(eventName); - }), - } as unknown as ElectronApp.ElectronApp["Service"]; - const electronWindow = {} as ElectronWindow.ElectronWindow["Service"]; - - return Effect.gen(function* () { - const clerk = yield* DesktopClerk.DesktopClerk; - const exit = yield* Effect.exit(Effect.scoped(clerk.configure)); - - assert.isTrue(Exit.isSuccess(exit)); - assert.equal(quit.mock.calls.length, 0); - assert.deepEqual(registeredEvents, ["second-instance"]); - }).pipe( - Effect.provide(makeDesktopClerkLayer()), - Effect.provideService(ElectronApp.ElectronApp, electronApp), - Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), - ); - }); - - it.effect("quits and interrupts startup in a secondary instance", () => { - storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: false }); - const quit = vi.fn(); - const registeredEvents: string[] = []; - const electronApp = { - quit: Effect.sync(quit), - on: (eventName: string) => - Effect.sync(() => { - registeredEvents.push(eventName); - }), - } as unknown as ElectronApp.ElectronApp["Service"]; - const electronWindow = {} as ElectronWindow.ElectronWindow["Service"]; - - return Effect.gen(function* () { - const clerk = yield* DesktopClerk.DesktopClerk; - const exit = yield* Effect.exit(Effect.scoped(clerk.configure)); - - assert.isTrue(Exit.hasInterrupts(exit)); - assert.equal(quit.mock.calls.length, 1); - assert.deepEqual(registeredEvents, []); - }).pipe( - Effect.provide(makeDesktopClerkLayer()), - Effect.provideService(ElectronApp.ElectronApp, electronApp), - Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), - ); - }); - it.each([ { isDevelopment: true, scheme: "t3code-dev" }, { isDevelopment: false, scheme: "t3code" }, ])("configures the SDK with the $scheme renderer origin", ({ isDevelopment, scheme }) => { - const bridge = { cleanup: vi.fn(), isPrimaryInstance: true }; + const bridge = { cleanup: vi.fn() }; storageMock.mockReturnValue(storageAdapter); createClerkBridgeMock.mockReturnValue(bridge); @@ -231,4 +150,150 @@ describe("DesktopClerk", () => { storageMock.mockClear(); createClerkBridgeMock.mockClear(); }); + + it.effect( + "wires second-instance argv into deep links and registers the protocol when packaged", + () => { + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn() }); + + return Effect.gen(function* () { + const handledArgv = yield* Ref.make>([]); + const handledUrls = yield* Ref.make([]); + const listeners = new Map void>(); + let protocolClientRegistered = false; + + const deepLinksLayer = Layer.succeed(DesktopDeepLinks.DesktopDeepLinks, { + handleArgv: (argv) => + Ref.update(handledArgv, (items) => [...items, argv]).pipe(Effect.asVoid), + handleUrl: (url) => + Ref.update(handledUrls, (items) => [...items, url]).pipe(Effect.asVoid), + start: Effect.void, + } satisfies DesktopDeepLinks.DesktopDeepLinks["Service"]); + + const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { + metadata: Effect.die("unexpected metadata"), + name: Effect.succeed("T3 Code"), + whenReady: Effect.void, + quit: Effect.void, + exit: () => Effect.void, + relaunch: () => Effect.void, + setPath: () => Effect.void, + setName: () => Effect.void, + setAboutPanelOptions: () => Effect.void, + setAppUserModelId: () => Effect.void, + requestSingleInstanceLock: Effect.succeed(true), + isDefaultProtocolClient: () => Effect.succeed(false), + setAsDefaultProtocolClient: (protocol: string) => + Effect.sync(() => { + protocolClientRegistered = protocol === "t3code"; + return true; + }), + setDesktopName: () => Effect.void, + setDockIcon: () => Effect.void, + appendCommandLineSwitch: () => Effect.void, + on: >( + eventName: string, + listener: (...args: Args) => void, + ) => + Effect.sync(() => { + listeners.set(eventName, listener as (...args: readonly unknown[]) => void); + }).pipe(Effect.asVoid), + } as unknown as ElectronApp.ElectronApp["Service"]); + + const runtimeLayer = Layer.mergeAll( + makeDesktopClerkLayer(false, true), + deepLinksLayer, + electronAppLayer, + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const clerk = yield* DesktopClerk.DesktopClerk; + yield* clerk.configure; + + assert.isTrue(protocolClientRegistered); + assert.isTrue(listeners.has("second-instance")); + assert.isTrue(listeners.has("open-url")); + + // Initial process.argv is captured during configure. + const initialHandled = yield* Ref.get(handledArgv); + assert.isTrue(initialHandled.length >= 1); + + const secondInstance = listeners.get("second-instance"); + assert.isDefined(secondInstance); + secondInstance?.({}, [ + "t3code", + "t3code://open/thread?connection=t3vm&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + ]); + // Allow the fire-and-forget runPromise callback to settle. + yield* Effect.yieldNow; + yield* Effect.yieldNow; + + const afterSecond = yield* Ref.get(handledArgv); + assert.isTrue( + afterSecond.some((argv) => + argv.some((entry) => entry.startsWith("t3code://open/thread")), + ), + ); + + const openUrl = listeners.get("open-url"); + assert.isDefined(openUrl); + const preventDefault = vi.fn(); + openUrl?.( + { preventDefault }, + "t3code://open/thread?connection=t3vm&thread=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + assert.equal(preventDefault.mock.calls.length, 1); + const urls = yield* Ref.get(handledUrls); + assert.deepEqual(urls, [ + "t3code://open/thread?connection=t3vm&thread=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ]); + }).pipe(Effect.provide(runtimeLayer)), + ); + }); + }, + ); + + it.effect("does not register the OS protocol client in development", () => { + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn() }); + + return Effect.gen(function* () { + let protocolClientRegistered = false; + + const deepLinksLayer = Layer.succeed(DesktopDeepLinks.DesktopDeepLinks, { + handleArgv: () => Effect.void, + handleUrl: () => Effect.void, + start: Effect.void, + } satisfies DesktopDeepLinks.DesktopDeepLinks["Service"]); + + const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { + requestSingleInstanceLock: Effect.succeed(true), + setAsDefaultProtocolClient: () => + Effect.sync(() => { + protocolClientRegistered = true; + return true; + }), + on: () => Effect.void, + quit: Effect.void, + } as unknown as ElectronApp.ElectronApp["Service"]); + + const runtimeLayer = Layer.mergeAll( + makeDesktopClerkLayer(true, false), + deepLinksLayer, + electronAppLayer, + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const clerk = yield* DesktopClerk.DesktopClerk; + yield* clerk.configure; + assert.isFalse(protocolClientRegistered); + }).pipe(Effect.provide(runtimeLayer)), + ); + }); + }); }); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 11723c94714..c3e49693487 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -3,15 +3,13 @@ import { storage } from "@clerk/electron/storage"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; -import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; +import * as DesktopDeepLinks from "./DesktopDeepLinks.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; @@ -48,7 +46,7 @@ export class DesktopClerk extends Context.Service< readonly configure: Effect.Effect< void, never, - ElectronApp.ElectronApp | ElectronWindow.ElectronWindow | Scope.Scope + DesktopDeepLinks.DesktopDeepLinks | ElectronApp.ElectronApp | Scope.Scope >; } >()("@t3tools/desktop/app/DesktopClerk") {} @@ -85,18 +83,7 @@ export function createDesktopClerkBridge(stateDir: string, isDevelopment: boolea export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; - const electronApp = yield* ElectronApp.ElectronApp; - - // Electron scopes the single-instance lock to the userData directory and - // creates that directory when the lock is acquired. The SDK bridge takes - // the lock at creation, so userData must already point at the real - // directory here — under the default productName-derived path, acquiring - // the lock would create "T3 Code (Alpha)" and make the legacy-install - // detection in resolveUserDataPath match on fresh installs. - const userDataPath = yield* DesktopAppIdentity.resolveUserDataPath; - yield* electronApp.setPath("userData", userDataPath); - - const bridge = yield* Effect.acquireRelease( + yield* Effect.acquireRelease( Effect.try({ try: () => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment), catch: (cause) => @@ -121,8 +108,9 @@ export const make = Effect.gen(function* () { return DesktopClerk.of({ configure: Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; - const electronWindow = yield* ElectronWindow.ElectronWindow; - const context = yield* Effect.context(); + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + // Capture ambient services for Electron event callbacks, which cannot yield. + const context = yield* Effect.context(); const runPromise = Effect.runPromiseWith(context); // The SDK bridge holds Electron's single-instance lock (acquired at @@ -135,16 +123,27 @@ export const make = Effect.gen(function* () { return yield* Effect.interrupt; } - yield* electronApp.on("second-instance", () => { - void runPromise( - Effect.gen(function* () { - const mainWindow = yield* electronWindow.currentMainOrFirst; - if (Option.isSome(mainWindow)) { - yield* electronWindow.reveal(mainWindow.value); - } - }), - ); + // Register before readiness so cold-start and second-instance deep links + // are not dropped. Deep-link processing itself queues until start(). + yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[] = []) => { + void runPromise(deepLinks.handleArgv(argv)); + }); + + // macOS delivers custom URL scheme activations through open-url. + yield* electronApp.on("open-url", (event: { preventDefault?: () => void }, url: string) => { + event.preventDefault?.(); + void runPromise(deepLinks.handleUrl(url)); }); + + // Packaged builds own the OS protocol handler. Skip in development so a + // local electron binary does not replace the installed t3code handler. + if (environment.isPackaged && !environment.isDevelopment) { + yield* electronApp.setAsDefaultProtocolClient(DesktopDeepLinks.DESKTOP_EXTERNAL_PROTOCOL); + } + + // Initial argv may already contain a deep link (direct CLI invocation or + // protocol launch on Linux/Windows). + yield* deepLinks.handleArgv(process.argv); }).pipe(Effect.withSpan("desktop.clerk.configure")), }); }); diff --git a/apps/desktop/src/app/DesktopDeepLinks.test.ts b/apps/desktop/src/app/DesktopDeepLinks.test.ts new file mode 100644 index 00000000000..d96d3b4619c --- /dev/null +++ b/apps/desktop/src/app/DesktopDeepLinks.test.ts @@ -0,0 +1,363 @@ +import { assert, describe, it } from "@effect/vitest"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; + +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as DesktopConnectionCatalogStore from "./DesktopConnectionCatalogStore.ts"; +import * as DesktopDeepLinks from "./DesktopDeepLinks.ts"; + +const THREAD_ID = ThreadId.make("ebf3a84d-7f60-4809-a5e0-bbd574275463"); +const ENVIRONMENT_ID = EnvironmentId.make("db6d1813-ace4-42bd-9bce-e04ee27e97ff"); +const VALID_DEEP_LINK = + "t3code://open/thread?connection=t3vm&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463"; + +function catalogJson(targets: readonly { label: string; environmentId: string }[]): string { + return JSON.stringify({ + schemaVersion: 1, + targets: targets.map((target) => ({ + _tag: "BearerConnectionTarget", + environmentId: target.environmentId, + label: target.label, + connectionId: `bearer:${target.environmentId}`, + })), + profiles: [], + credentials: [], + remoteDpopTokens: [], + }); +} + +describe("DesktopDeepLinks parsing", () => { + it("parses a valid thread deep link from mixed argv", () => { + const parsed = DesktopDeepLinks.findDesktopThreadDeepLinkInArgv([ + "/usr/bin/t3code", + "--enable-features=Foo", + VALID_DEEP_LINK, + "--some-flag", + ]); + assert.isTrue(Option.isSome(parsed)); + assert.equal(parsed._tag === "Some" ? parsed.value.connectionLabel : null, "t3vm"); + assert.equal( + parsed._tag === "Some" ? parsed.value.threadId : null, + "ebf3a84d-7f60-4809-a5e0-bbd574275463", + ); + }); + + it("percent-decodes the connection label exactly once", () => { + const parsed = DesktopDeepLinks.parseDesktopThreadDeepLink( + "t3code://open/thread?connection=t3%2Fvm&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + ); + assert.isTrue(Option.isSome(parsed)); + assert.equal(parsed._tag === "Some" ? parsed.value.connectionLabel : null, "t3/vm"); + }); + + it("parses project jumps with short or full repository names", () => { + const latest = DesktopDeepLinks.parseDesktopProjectDeepLink( + "t3code://open/project?project=macs-holding%2Fscanner&action=latest", + ); + assert.deepEqual(Option.getOrNull(latest), { + project: "macs-holding/scanner", + action: "latest", + }); + const reveal = DesktopDeepLinks.parseDesktopProjectDeepLink( + "t3code://open/project?project=scanner", + ); + assert.deepEqual(Option.getOrNull(reveal), { project: "scanner", action: "reveal" }); + }); + + it("rejects invalid project jump actions and duplicate project values", () => { + assert.isTrue( + Option.isNone( + DesktopDeepLinks.parseDesktopProjectDeepLink( + "t3code://open/project?project=scanner&action=remove", + ), + ), + ); + assert.isTrue( + Option.isNone( + DesktopDeepLinks.parseDesktopProjectDeepLink( + "t3code://open/project?project=scanner&project=configurator", + ), + ), + ); + }); + + it("parses URL-like environment hosts in short and FQDN forms", () => { + for (const [raw, expectedLabel] of [ + ["t3code://t3vm?thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", "t3vm"], + ["t3code://t3vm.long.host?thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", "t3vm.long.host"], + ] as const) { + const parsed = DesktopDeepLinks.parseDesktopThreadDeepLink(raw); + assert.isTrue(Option.isSome(parsed)); + assert.equal(parsed._tag === "Some" ? parsed.value.connectionLabel : null, expectedLabel); + } + }); + + it("rejects wrong schemes, hosts, paths, missing/duplicate values, controls, and oversized values", () => { + const rejects = [ + "https://open/thread?connection=t3vm&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + "t3code://close/thread?connection=t3vm&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + "t3code://open/settings?connection=t3vm&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + "t3code://open/thread?thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + "t3code://open/thread?connection=t3vm", + "t3code://open/thread?connection=t3vm&connection=other&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + "t3code://open/thread?connection=t3vm&thread=a&thread=b", + "t3code://t3vm?connection=other&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + "t3code://t3vm/path?thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + "t3code://open/thread?connection=t3vm&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463\u0000", + `t3code://open/thread?connection=${"x".repeat(300)}&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463`, + "not-a-url", + "t3code://app/", + ]; + for (const raw of rejects) { + assert.isTrue( + Option.isNone(DesktopDeepLinks.parseDesktopThreadDeepLink(raw)), + `expected rejection for ${JSON.stringify(raw)}`, + ); + } + }); + + it("builds a hash-history navigation URL on the desktop origin", () => { + assert.equal( + DesktopDeepLinks.buildDesktopThreadNavigationUrl({ + isDevelopment: false, + environmentId: ENVIRONMENT_ID, + threadId: THREAD_ID, + }), + "t3code://app/#/db6d1813-ace4-42bd-9bce-e04ee27e97ff/ebf3a84d-7f60-4809-a5e0-bbd574275463", + ); + assert.equal( + DesktopDeepLinks.buildDesktopThreadNavigationUrl({ + isDevelopment: true, + environmentId: ENVIRONMENT_ID, + threadId: THREAD_ID, + }), + "t3code-dev://app/#/db6d1813-ace4-42bd-9bce-e04ee27e97ff/ebf3a84d-7f60-4809-a5e0-bbd574275463", + ); + }); +}); + +describe("DesktopDeepLinks catalog resolution", () => { + it("resolves a unique connection label to its environment id", () => { + const resolution = DesktopDeepLinks.resolveEnvironmentIdForConnectionLabel( + catalogJson([ + { label: "t3vm", environmentId: "db6d1813-ace4-42bd-9bce-e04ee27e97ff" }, + { label: "other", environmentId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" }, + ]), + "t3vm", + ); + assert.equal(resolution._tag, "resolved"); + if (resolution._tag === "resolved") { + assert.equal(resolution.environmentId, "db6d1813-ace4-42bd-9bce-e04ee27e97ff"); + } + }); + + it("resolves short and fully qualified host labels in either direction", () => { + const fqdnTarget = DesktopDeepLinks.resolveEnvironmentIdForConnectionLabel( + catalogJson([ + { label: "t3vm.long.host", environmentId: "db6d1813-ace4-42bd-9bce-e04ee27e97ff" }, + ]), + "t3vm", + ); + assert.equal(fqdnTarget._tag, "resolved"); + + const shortTarget = DesktopDeepLinks.resolveEnvironmentIdForConnectionLabel( + catalogJson([{ label: "t3vm", environmentId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" }]), + "t3vm.long.host", + ); + assert.equal(shortTarget._tag, "resolved"); + }); + + it("prefers an exact configured label over a short-host fallback", () => { + const resolution = DesktopDeepLinks.resolveEnvironmentIdForConnectionLabel( + catalogJson([ + { label: "t3vm.long.host", environmentId: "db6d1813-ace4-42bd-9bce-e04ee27e97ff" }, + { label: "t3vm", environmentId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" }, + ]), + "t3vm", + ); + assert.equal(resolution._tag, "resolved"); + if (resolution._tag === "resolved") { + assert.equal(resolution.environmentId, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + } + }); + + it("keeps ambiguous short matches ambiguous and does not equate distinct FQDNs", () => { + const catalog = catalogJson([ + { label: "t3vm.one.host", environmentId: "db6d1813-ace4-42bd-9bce-e04ee27e97ff" }, + { label: "t3vm.two.host", environmentId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" }, + ]); + assert.equal( + DesktopDeepLinks.resolveEnvironmentIdForConnectionLabel(catalog, "t3vm")._tag, + "ambiguous", + ); + assert.equal( + DesktopDeepLinks.resolveEnvironmentIdForConnectionLabel(catalog, "t3vm.three.host")._tag, + "missing", + ); + }); + + it("refuses missing and duplicate labels", () => { + assert.equal( + DesktopDeepLinks.resolveEnvironmentIdForConnectionLabel( + catalogJson([{ label: "other", environmentId: "db6d1813-ace4-42bd-9bce-e04ee27e97ff" }]), + "t3vm", + )._tag, + "missing", + ); + assert.equal( + DesktopDeepLinks.resolveEnvironmentIdForConnectionLabel( + catalogJson([ + { label: "t3vm", environmentId: "db6d1813-ace4-42bd-9bce-e04ee27e97ff" }, + { label: "t3vm", environmentId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" }, + ]), + "t3vm", + )._tag, + "ambiguous", + ); + }); +}); + +describe("DesktopDeepLinks service delivery", () => { + const makeHarness = Effect.gen(function* () { + const navigations = yield* Ref.make>([]); + const projectNavigations = yield* Ref.make< + Array<{ project: string; action: "reveal" | "latest" | "new" }> + >([]); + const activations = yield* Ref.make(0); + const catalogJsonRef = yield* Ref.make( + Option.some( + catalogJson([ + { + label: "t3vm", + environmentId: "db6d1813-ace4-42bd-9bce-e04ee27e97ff", + }, + ]), + ), + ); + + const windowLayer = Layer.succeed(DesktopWindow.DesktopWindow, { + createMain: Effect.die("unexpected createMain"), + ensureMain: Effect.die("unexpected ensureMain"), + revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"), + activate: Ref.update(activations, (count) => count + 1), + createMainIfBackendReady: Effect.void, + showConnectingSplash: Effect.void, + handleBackendReady: () => Effect.void, + handleBackendNotReady: Effect.void, + flushMainWindowBounds: Effect.void, + dispatchMenuAction: () => Effect.void, + syncAppearance: Effect.void, + navigateToThread: (input: { readonly environmentId: string; readonly threadId: string }) => + Ref.update(navigations, (items) => [ + ...items, + { environmentId: input.environmentId, threadId: input.threadId }, + ]), + navigateToProject: (input: { + readonly project: string; + readonly action: "reveal" | "latest" | "new"; + }) => Ref.update(projectNavigations, (items) => [...items, input]), + } as unknown as DesktopWindow.DesktopWindow["Service"]); + + const catalogLayer = Layer.succeed( + DesktopConnectionCatalogStore.DesktopConnectionCatalogStore, + { + get: Ref.get(catalogJsonRef), + set: () => Effect.succeed(true), + clear: Effect.void, + } satisfies DesktopConnectionCatalogStore.DesktopConnectionCatalogStore["Service"], + ); + + const layer = DesktopDeepLinks.layer.pipe( + Layer.provide(windowLayer), + Layer.provide(catalogLayer), + ); + + return { layer, navigations, projectNavigations, activations, catalogJsonRef } as const; + }); + + it.effect("queues initial-launch delivery until start, then opens the thread", () => + Effect.gen(function* () { + const harness = yield* makeHarness; + yield* Effect.gen(function* () { + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + yield* deepLinks.handleArgv(["t3code", VALID_DEEP_LINK]); + assert.deepEqual(yield* Ref.get(harness.navigations), []); + yield* deepLinks.start; + assert.deepEqual(yield* Ref.get(harness.navigations), [ + { + environmentId: "db6d1813-ace4-42bd-9bce-e04ee27e97ff", + threadId: "ebf3a84d-7f60-4809-a5e0-bbd574275463", + }, + ]); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("delivers running-instance deep links through handleArgv after start", () => + Effect.gen(function* () { + const harness = yield* makeHarness; + yield* Effect.gen(function* () { + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + yield* deepLinks.start; + yield* deepLinks.handleArgv([ + "t3code", + "t3code://open/thread?connection=t3vm&thread=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ]); + assert.deepEqual(yield* Ref.get(harness.navigations), [ + { + environmentId: "db6d1813-ace4-42bd-9bce-e04ee27e97ff", + threadId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + }, + ]); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("delivers a project jump without resolving an environment in Desktop", () => + Effect.gen(function* () { + const harness = yield* makeHarness; + yield* Effect.gen(function* () { + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + yield* deepLinks.start; + yield* deepLinks.handleUrl( + "t3code://open/project?project=macs-holding%2Fscanner&action=new", + ); + assert.deepEqual(yield* Ref.get(harness.projectNavigations), [ + { project: "macs-holding/scanner", action: "new" }, + ]); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("preserves reveal-only behavior for ordinary second launches", () => + Effect.gen(function* () { + const harness = yield* makeHarness; + yield* Effect.gen(function* () { + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + yield* deepLinks.start; + yield* deepLinks.handleArgv(["t3code"]); + assert.equal(yield* Ref.get(harness.activations), 1); + assert.deepEqual(yield* Ref.get(harness.navigations), []); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("reveals without navigating when the connection label is missing", () => + Effect.gen(function* () { + const harness = yield* makeHarness; + yield* Effect.gen(function* () { + const deepLinks = yield* DesktopDeepLinks.DesktopDeepLinks; + yield* deepLinks.start; + yield* deepLinks.handleArgv([ + "t3code", + "t3code://open/thread?connection=missing&thread=ebf3a84d-7f60-4809-a5e0-bbd574275463", + ]); + assert.equal(yield* Ref.get(harness.activations), 1); + assert.deepEqual(yield* Ref.get(harness.navigations), []); + }).pipe(Effect.provide(harness.layer)); + }), + ); +}); diff --git a/apps/desktop/src/app/DesktopDeepLinks.ts b/apps/desktop/src/app/DesktopDeepLinks.ts new file mode 100644 index 00000000000..924ec93c355 --- /dev/null +++ b/apps/desktop/src/app/DesktopDeepLinks.ts @@ -0,0 +1,422 @@ +import { + ConnectionCatalogDocument, + type ConnectionCatalogDocument as ConnectionCatalogDocumentType, +} from "@t3tools/client-runtime/platform"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +import { buildDesktopThreadNavigationUrl } from "../electron/ElectronProtocol.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as DesktopConnectionCatalogStore from "./DesktopConnectionCatalogStore.ts"; +import { makeComponentLogger } from "./DesktopObservability.ts"; + +export { buildDesktopThreadNavigationUrl }; + +export const DESKTOP_EXTERNAL_PROTOCOL = "t3code"; +export const DESKTOP_THREAD_DEEP_LINK_HOST = "open"; +export const DESKTOP_THREAD_DEEP_LINK_PATH = "/thread"; +export const DESKTOP_PROJECT_DEEP_LINK_PATH = "/project"; + +/** Reject oversized query values (connection labels and thread ids). */ +const MAX_DEEP_LINK_VALUE_LENGTH = 256; + +const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/; + +export type DesktopThreadDeepLink = { + readonly connectionLabel: string; + readonly threadId: ThreadId; +}; + +export type DesktopProjectDeepLink = { + readonly project: string; + readonly action: "reveal" | "latest" | "new"; +}; + +export type DesktopConnectionLabelResolution = + | { readonly _tag: "resolved"; readonly environmentId: EnvironmentId } + | { readonly _tag: "missing" } + | { readonly _tag: "ambiguous" } + | { readonly _tag: "invalidCatalog"; readonly cause: unknown }; + +type PendingDeepLinkAction = + | { readonly _tag: "reveal" } + | { readonly _tag: "openThread"; readonly deepLink: DesktopThreadDeepLink } + | { readonly _tag: "openProject"; readonly deepLink: DesktopProjectDeepLink }; + +const { logInfo: logDeepLinkInfo, logWarning: logDeepLinkWarning } = + makeComponentLogger("desktop-deep-links"); + +/** + * Parses a desktop thread deep link in either explicit or host shorthand form: + * + * - `t3code://open/thread?connection=&thread=` + * - `t3code://?thread=` + */ +export function parseDesktopThreadDeepLink(raw: string): Option.Option { + if (typeof raw !== "string" || raw.length === 0 || raw.length > 2_048) { + return Option.none(); + } + if (CONTROL_CHARACTER_PATTERN.test(raw)) { + return Option.none(); + } + + let url: URL; + try { + url = new URL(raw); + } catch { + return Option.none(); + } + + if (url.protocol !== `${DESKTOP_EXTERNAL_PROTOCOL}:`) { + return Option.none(); + } + // Normalize trailing slashes so `/thread` and `/thread/` both work. + const pathname = + url.pathname.length > 1 && url.pathname.endsWith("/") + ? url.pathname.slice(0, -1) + : url.pathname; + + const connectionValues = url.searchParams.getAll("connection"); + const threadValues = url.searchParams.getAll("thread"); + if (threadValues.length !== 1) { + return Option.none(); + } + + const isExplicitForm = + url.hostname === DESKTOP_THREAD_DEEP_LINK_HOST && pathname === DESKTOP_THREAD_DEEP_LINK_PATH; + const isHostShorthand = (pathname === "" || pathname === "/") && url.hostname.length > 0; + if ( + (!isExplicitForm && !isHostShorthand) || + (isExplicitForm && connectionValues.length !== 1) || + (isHostShorthand && connectionValues.length !== 0) + ) { + return Option.none(); + } + + // URLSearchParams percent-decodes once; reject empties, controls, and oversized values. + const connectionLabel = isExplicitForm ? (connectionValues[0] ?? "") : url.hostname; + const threadRaw = threadValues[0] ?? ""; + if ( + connectionLabel.length === 0 || + threadRaw.length === 0 || + connectionLabel.length > MAX_DEEP_LINK_VALUE_LENGTH || + threadRaw.length > MAX_DEEP_LINK_VALUE_LENGTH || + CONTROL_CHARACTER_PATTERN.test(connectionLabel) || + CONTROL_CHARACTER_PATTERN.test(threadRaw) + ) { + return Option.none(); + } + + let threadId: ThreadId; + try { + threadId = Schema.decodeUnknownSync(ThreadId)(threadRaw); + } catch { + return Option.none(); + } + + return Option.some({ + connectionLabel, + threadId, + }); +} + +export function parseDesktopProjectDeepLink(raw: string): Option.Option { + if (typeof raw !== "string" || raw.length === 0 || raw.length > 2_048) { + return Option.none(); + } + if (CONTROL_CHARACTER_PATTERN.test(raw)) { + return Option.none(); + } + + let url: URL; + try { + url = new URL(raw); + } catch { + return Option.none(); + } + + const pathname = + url.pathname.length > 1 && url.pathname.endsWith("/") + ? url.pathname.slice(0, -1) + : url.pathname; + if ( + url.protocol !== `${DESKTOP_EXTERNAL_PROTOCOL}:` || + url.hostname !== DESKTOP_THREAD_DEEP_LINK_HOST || + pathname !== DESKTOP_PROJECT_DEEP_LINK_PATH + ) { + return Option.none(); + } + + const projectValues = url.searchParams.getAll("project"); + const actionValues = url.searchParams.getAll("action"); + const project = projectValues[0]?.trim() ?? ""; + if ( + projectValues.length !== 1 || + actionValues.length > 1 || + project.length === 0 || + project.length > MAX_DEEP_LINK_VALUE_LENGTH || + CONTROL_CHARACTER_PATTERN.test(project) + ) { + return Option.none(); + } + + const action = actionValues[0] ?? "reveal"; + if (action !== "reveal" && action !== "latest" && action !== "new") { + return Option.none(); + } + return Option.some({ project, action }); +} + +/** + * Scans argv for the first valid thread deep link. Electron may inject + * platform-specific flags, so the URL is not assumed to be at a fixed index. + */ +export function findDesktopThreadDeepLinkInArgv( + argv: readonly string[], +): Option.Option { + for (const entry of argv) { + const parsed = parseDesktopThreadDeepLink(entry); + if (Option.isSome(parsed)) { + return parsed; + } + } + return Option.none(); +} + +function isShortAndFqdnMatch(left: string, right: string): boolean { + const leftIsFqdn = left.includes("."); + const rightIsFqdn = right.includes("."); + if (leftIsFqdn === rightIsFqdn) { + return false; + } + const shortLabel = leftIsFqdn ? right : left; + const fqdnLabel = leftIsFqdn ? left : right; + return fqdnLabel.slice(0, fqdnLabel.indexOf(".")) === shortLabel; +} + +/** + * Resolves a configured deep-link connection label to a unique environment id. + * + * The link producer's configured override is already represented by + * `connectionLabel`. Exact catalog labels win; only a missing exact match may + * fall back to matching a short host label with its FQDN form. + */ +export function resolveEnvironmentIdForConnectionLabel( + catalogJson: string, + connectionLabel: string, +): DesktopConnectionLabelResolution { + let document: ConnectionCatalogDocumentType; + try { + document = Schema.decodeUnknownSync(Schema.fromJsonString(ConnectionCatalogDocument))( + catalogJson, + ); + } catch (cause) { + return { _tag: "invalidCatalog", cause }; + } + + const exactMatches = document.targets.filter((target) => target.label === connectionLabel); + const matches = + exactMatches.length > 0 + ? exactMatches + : document.targets.filter((target) => isShortAndFqdnMatch(target.label, connectionLabel)); + if (matches.length === 0) { + return { _tag: "missing" }; + } + if (matches.length > 1) { + return { _tag: "ambiguous" }; + } + return { + _tag: "resolved", + environmentId: matches[0]!.environmentId, + }; +} + +export class DesktopDeepLinks extends Context.Service< + DesktopDeepLinks, + { + /** + * Handle a launch argv (initial process.argv or second-instance commandLine). + * Queues until `start` if the navigation services are not ready yet. + */ + readonly handleArgv: (argv: readonly string[]) => Effect.Effect; + /** + * Handle a macOS `open-url` deep link. + */ + readonly handleUrl: (url: string) => Effect.Effect; + /** + * Begin processing deep links. Flushes any action queued before readiness. + * Newest queued action wins. + */ + readonly start: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopDeepLinks") {} + +export const make = Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const catalogStore = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + const startedRef = yield* Ref.make(false); + const pendingActionRef = yield* Ref.make>(Option.none()); + + const revealOnly = desktopWindow.activate.pipe( + Effect.catch((error) => + logDeepLinkWarning("failed to reveal desktop window for deep link", { + message: error.message, + }), + ), + Effect.asVoid, + ); + + const resolveDeepLinkFromCatalog = ( + deepLink: DesktopThreadDeepLink, + ): Effect.Effect => + catalogStore.get.pipe( + Effect.catch((cause) => + logDeepLinkWarning("connection catalog unavailable for deep link resolution", { + reason: cause._tag, + }).pipe(Effect.as(Option.none())), + ), + Effect.map((catalog) => { + if (Option.isNone(catalog)) { + return { _tag: "missing" } as const; + } + return resolveEnvironmentIdForConnectionLabel(catalog.value, deepLink.connectionLabel); + }), + ); + + const openResolvedThread = (deepLink: DesktopThreadDeepLink): Effect.Effect => + Effect.gen(function* () { + const resolution = yield* resolveDeepLinkFromCatalog(deepLink); + switch (resolution._tag) { + case "resolved": { + yield* logDeepLinkInfo("opening thread from deep link", { + connectionLabel: deepLink.connectionLabel, + // Log only that an environment was resolved — never catalog contents. + resolved: true, + }); + yield* desktopWindow + .navigateToThread({ + environmentId: resolution.environmentId, + threadId: deepLink.threadId, + }) + .pipe( + Effect.catch((error) => + logDeepLinkWarning("failed to navigate to deep-linked thread", { + message: error.message, + }), + ), + ); + return; + } + case "missing": { + yield* logDeepLinkWarning("deep link connection label not found", { + connectionLabel: deepLink.connectionLabel, + }); + yield* revealOnly; + return; + } + case "ambiguous": { + yield* logDeepLinkWarning("deep link connection label is ambiguous", { + connectionLabel: deepLink.connectionLabel, + }); + yield* revealOnly; + return; + } + case "invalidCatalog": { + yield* logDeepLinkWarning("deep link connection catalog could not be decoded", { + reason: "invalidCatalog", + }); + yield* revealOnly; + return; + } + } + }).pipe(Effect.withSpan("desktop.deepLinks.openResolvedThread")); + + const processAction = (action: PendingDeepLinkAction): Effect.Effect => + Effect.gen(function* () { + switch (action._tag) { + case "reveal": + yield* revealOnly; + return; + case "openThread": + yield* openResolvedThread(action.deepLink); + return; + case "openProject": + yield* desktopWindow.navigateToProject(action.deepLink).pipe( + Effect.catch((error) => + logDeepLinkWarning("failed to navigate to deep-linked project", { + message: error.message, + }), + ), + ); + return; + } + }).pipe(Effect.withSpan("desktop.deepLinks.processAction")); + + const enqueueOrProcess = (action: PendingDeepLinkAction): Effect.Effect => + Effect.gen(function* () { + const started = yield* Ref.get(startedRef); + if (!started) { + // Newest action wins while startup is still settling. + yield* Ref.set(pendingActionRef, Option.some(action)); + return; + } + yield* processAction(action); + }); + + const handleArgv = (argv: readonly string[]): Effect.Effect => + Effect.gen(function* () { + for (const entry of argv) { + const projectDeepLink = parseDesktopProjectDeepLink(entry); + if (Option.isSome(projectDeepLink)) { + yield* enqueueOrProcess({ _tag: "openProject", deepLink: projectDeepLink.value }); + return; + } + } + const deepLink = findDesktopThreadDeepLinkInArgv(argv); + if (Option.isSome(deepLink)) { + yield* enqueueOrProcess({ _tag: "openThread", deepLink: deepLink.value }); + return; + } + yield* enqueueOrProcess({ _tag: "reveal" }); + }).pipe(Effect.withSpan("desktop.deepLinks.handleArgv")); + + const handleUrl = (url: string): Effect.Effect => + Effect.gen(function* () { + const projectDeepLink = parseDesktopProjectDeepLink(url); + if (Option.isSome(projectDeepLink)) { + yield* enqueueOrProcess({ _tag: "openProject", deepLink: projectDeepLink.value }); + return; + } + const deepLink = parseDesktopThreadDeepLink(url); + if (Option.isSome(deepLink)) { + yield* enqueueOrProcess({ _tag: "openThread", deepLink: deepLink.value }); + return; + } + yield* logDeepLinkWarning("ignored unsupported open-url payload"); + yield* enqueueOrProcess({ _tag: "reveal" }); + }).pipe(Effect.withSpan("desktop.deepLinks.handleUrl")); + + const start = Effect.gen(function* () { + const alreadyStarted = yield* Ref.getAndSet(startedRef, true); + if (alreadyStarted) { + return; + } + const pending = yield* Ref.getAndSet(pendingActionRef, Option.none()); + if (Option.isSome(pending)) { + yield* processAction(pending.value); + } + }).pipe(Effect.withSpan("desktop.deepLinks.start")); + + return DesktopDeepLinks.of({ + handleArgv, + handleUrl, + start, + }); +}); + +export const layer = Layer.effect(DesktopDeepLinks, make); diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index be9d7f3451f..37683b4f26c 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -29,6 +29,7 @@ describe("DesktopLifecycle", () => { setName: () => Effect.void, setAboutPanelOptions: () => Effect.void, setAppUserModelId: () => Effect.void, + requestSingleInstanceLock: Effect.succeed(true), getAppMetrics: Effect.succeed([]), isDefaultProtocolClient: () => Effect.succeed(false), setAsDefaultProtocolClient: () => Effect.succeed(true), @@ -79,6 +80,8 @@ describe("DesktopLifecycle", () => { flushMainWindowBounds: Effect.void, dispatchMenuAction: () => Effect.void, syncAppearance: Effect.void, + navigateToThread: () => Effect.void, + navigateToProject: () => Effect.void, }); const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 27c90793101..d374b291af5 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -99,6 +99,8 @@ function makePoolLayer( flushMainWindowBounds: Effect.void, dispatchMenuAction: () => Effect.die("unexpected menu action"), syncAppearance: Effect.void, + navigateToThread: () => Effect.void, + navigateToProject: () => Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]), ), ), diff --git a/apps/desktop/src/electron/DesktopEarlyStartup.test.ts b/apps/desktop/src/electron/DesktopEarlyStartup.test.ts new file mode 100644 index 00000000000..56ccc2116f0 --- /dev/null +++ b/apps/desktop/src/electron/DesktopEarlyStartup.test.ts @@ -0,0 +1,53 @@ +import { assert, describe, it } from "@effect/vitest"; +import { vi } from "vite-plus/test"; + +import { + type DesktopEarlyStartupApp, + configureDesktopEarlyStartup, +} from "./DesktopEarlyStartup.ts"; + +function makeApp() { + const exit = vi.fn(); + const getVersion = vi.fn(() => "1.2.3"); + const app: DesktopEarlyStartupApp = { + exit, + getVersion, + }; + + return { app, exit, getVersion }; +} + +describe("configureDesktopEarlyStartup", () => { + it.each(["--version", "-v"] as const)( + "prints the packaged version for %s", + (flag: "--version" | "-v") => { + const { app, exit, getVersion } = makeApp(); + const writeStdout = vi.fn(); + + configureDesktopEarlyStartup({ + app, + argv: ["t3code", flag], + writeStdout, + }); + + assert.strictEqual(getVersion.mock.calls.length, 1); + assert.deepStrictEqual(writeStdout.mock.calls, [["1.2.3\n"]]); + assert.deepStrictEqual(exit.mock.calls, [[0]]); + }, + ); + + it("does nothing for an ordinary launch", () => { + const { app, exit, getVersion } = makeApp(); + const writeStdout = vi.fn(); + + configureDesktopEarlyStartup({ + app, + argv: ["t3code"], + writeStdout, + }); + + assert.strictEqual(getVersion.mock.calls.length, 0); + assert.strictEqual(writeStdout.mock.calls.length, 0); + assert.strictEqual(exit.mock.calls.length, 0); + }); +}); diff --git a/apps/desktop/src/electron/DesktopEarlyStartup.ts b/apps/desktop/src/electron/DesktopEarlyStartup.ts new file mode 100644 index 00000000000..b56ad1a91ea --- /dev/null +++ b/apps/desktop/src/electron/DesktopEarlyStartup.ts @@ -0,0 +1,27 @@ +export interface DesktopEarlyStartupApp { + readonly exit: (exitCode?: number) => void; + readonly getVersion: () => string; +} + +export interface ConfigureDesktopEarlyStartupOptions { + readonly app: DesktopEarlyStartupApp; + readonly argv: ReadonlyArray; + readonly writeStdout: (value: string) => unknown; +} + +/** + * Applies command-line behavior that Electron must receive before `app.whenReady()`. + */ +export function configureDesktopEarlyStartup({ + app, + argv, + writeStdout, +}: ConfigureDesktopEarlyStartupOptions): void { + if (argv.includes("--version") || argv.includes("-v")) { + try { + writeStdout(`${app.getVersion()}\n`); + } finally { + app.exit(0); + } + } +} diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index e0d229497ae..783a8ace5d6 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -15,6 +15,7 @@ const { relaunchMock, removeListenerMock, removeSwitchMock, + requestSingleInstanceLockMock, setAboutPanelOptionsMock, setAppUserModelIdMock, setAsDefaultProtocolClientMock, @@ -36,6 +37,7 @@ const { relaunchMock: vi.fn(), removeListenerMock: vi.fn(), removeSwitchMock: vi.fn(), + requestSingleInstanceLockMock: vi.fn(() => true), setAboutPanelOptionsMock: vi.fn(), setAppUserModelIdMock: vi.fn(), setAsDefaultProtocolClientMock: vi.fn(() => true), @@ -68,6 +70,7 @@ vi.mock("electron", () => ({ quit: quitMock, relaunch: relaunchMock, removeListener: removeListenerMock, + requestSingleInstanceLock: requestSingleInstanceLockMock, runningUnderARM64Translation: false, setAboutPanelOptions: setAboutPanelOptionsMock, setAsDefaultProtocolClient: setAsDefaultProtocolClientMock, @@ -93,6 +96,7 @@ describe("ElectronApp", () => { relaunchMock.mockClear(); removeListenerMock.mockClear(); removeSwitchMock.mockClear(); + requestSingleInstanceLockMock.mockClear(); setPathMock.mockClear(); }); diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 6fb84c53b36..56c06fdff70 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -56,6 +56,7 @@ export class ElectronApp extends Context.Service< options: Electron.AboutPanelOptionsOptions, ) => Effect.Effect; readonly setAppUserModelId: (id: string) => Effect.Effect; + readonly requestSingleInstanceLock: Effect.Effect; readonly getAppMetrics: Effect.Effect>; readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect; readonly setAsDefaultProtocolClient: ( @@ -153,6 +154,7 @@ export const make = ElectronApp.of({ Effect.sync(() => { Electron.app.setAppUserModelId(id); }), + requestSingleInstanceLock: Effect.sync(() => Electron.app.requestSingleInstanceLock()), getAppMetrics: Effect.sync(() => Electron.app.getAppMetrics()), isDefaultProtocolClient: (protocol) => Effect.sync(() => Electron.app.isDefaultProtocolClient(protocol)), diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 11459c9ef7a..c92ff4235b3 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -24,6 +24,34 @@ export function getDesktopUrl(isDevelopment: boolean): string { return `${getDesktopOrigin(isDevelopment)}/`; } +/** + * Builds the desktop renderer URL for a canonical thread route. + * + * The Electron client uses hash history, so the path is carried after `#/`. + */ +export function buildDesktopThreadNavigationUrl(input: { + readonly isDevelopment: boolean; + readonly environmentId: string; + readonly threadId: string; +}): string { + const origin = getDesktopOrigin(input.isDevelopment); + const environmentSegment = encodeURIComponent(input.environmentId); + const threadSegment = encodeURIComponent(input.threadId); + return `${origin}/#/${environmentSegment}/${threadSegment}`; +} + +export function buildDesktopProjectNavigationUrl(input: { + readonly isDevelopment: boolean; + readonly project: string; + readonly action: "reveal" | "latest" | "new"; +}): string { + const search = new URLSearchParams({ project: input.project }); + if (input.action !== "reveal") { + search.set("action", input.action); + } + return `${getDesktopOrigin(input.isDevelopment)}/#/jump?${search.toString()}`; +} + export class ElectronProtocolRegistrationError extends Schema.TaggedErrorClass()( "ElectronProtocolRegistrationError", { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4e6a525c781..7dd2b5ff7d5 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -20,6 +20,7 @@ import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; import serverPackageJson from "../../server/package.json" with { type: "json" }; +import { configureDesktopEarlyStartup } from "./electron/DesktopEarlyStartup.ts"; import * as DesktopIpc from "./ipc/DesktopIpc.ts"; import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; @@ -36,6 +37,7 @@ import * as DesktopApp from "./app/DesktopApp.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; import * as DesktopClerk from "./app/DesktopClerk.ts"; +import * as DesktopDeepLinks from "./app/DesktopDeepLinks.ts"; import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; import * as DesktopAssets from "./app/DesktopAssets.ts"; import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; @@ -65,6 +67,12 @@ import * as DesktopWindow from "./window/DesktopWindow.ts"; import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts"; +configureDesktopEarlyStartup({ + app: Electron.app, + argv: process.argv, + writeStdout: (value) => process.stdout.write(value), +}); + const desktopEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe( @@ -158,6 +166,11 @@ const desktopWindowLayer = DesktopWindow.layer.pipe( Layer.provideMerge(desktopPreviewLayer), ); +const desktopDeepLinksLayer = DesktopDeepLinks.layer.pipe( + Layer.provideMerge(desktopWindowLayer), + Layer.provideMerge(desktopFoundationLayer), +); + // Pool layer instantiates the backend factory once for the Windows // primary instance and exposes it via pool.primary. Consumers go through // the pool now; the legacy DesktopBackendManager service is gone. The @@ -190,6 +203,7 @@ const desktopApplicationLayer = Layer.mergeAll( DesktopOpenWith.layer, desktopSshLayer, ).pipe( + Layer.provideMerge(desktopDeepLinksLayer), Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopWslBackendLayer), Layer.provideMerge(desktopLocalEnvironmentAuthLayer), diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts index 112c0ab350e..1cd2ec6327d 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -34,6 +34,7 @@ function makeElectronAppLayer( setName: () => Effect.void, setAboutPanelOptions: () => Effect.void, setAppUserModelId: () => Effect.void, + requestSingleInstanceLock: Effect.succeed(true), getAppMetrics: Effect.sync(() => { onMetricsRead(); return metrics; diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 83bd5c2e8ff..b4364338b59 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -39,6 +39,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { setName: () => Effect.void, setAboutPanelOptions: () => Effect.void, setAppUserModelId: () => Effect.void, + requestSingleInstanceLock: Effect.succeed(true), getAppMetrics: Effect.succeed([]), isDefaultProtocolClient: () => Effect.succeed(false), setAsDefaultProtocolClient: () => Effect.succeed(true), @@ -82,6 +83,8 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => flushMainWindowBounds: Effect.void, dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), syncAppearance: Effect.void, + navigateToThread: () => Effect.void, + navigateToProject: () => Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]); const makeElectronMenuLayer = ( diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 3aedd2ea6c0..ecdeb6db88b 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -32,6 +32,8 @@ vi.mock("electron", async (importOriginal) => ({ }, })); +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; + import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -1143,4 +1145,92 @@ describe("DesktopWindow", () => { }).pipe(Effect.provide(scenario.layer)); }), ); + + it.effect("queues navigation before the main window is ready and applies it on create", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.navigateToThread({ + environmentId: EnvironmentId.make("db6d1813-ace4-42bd-9bce-e04ee27e97ff"), + threadId: ThreadId.make("ebf3a84d-7f60-4809-a5e0-bbd574275463"), + }); + assert.equal(yield* Ref.get(createCount), 0); + assert.equal(fakeWindow.loadURL.mock.calls.length, 0); + + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(yield* Ref.get(createCount), 1); + assert.deepEqual(fakeWindow.loadURL.mock.calls[0], [ + "t3code-dev://app/#/db6d1813-ace4-42bd-9bce-e04ee27e97ff/ebf3a84d-7f60-4809-a5e0-bbd574275463", + ]); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("navigates an existing main window to the encoded canonical route", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + fakeWindow.loadURL.mockClear(); + + yield* desktopWindow.navigateToThread({ + environmentId: EnvironmentId.make("db6d1813-ace4-42bd-9bce-e04ee27e97ff"), + threadId: ThreadId.make("ebf3a84d-7f60-4809-a5e0-bbd574275463"), + }); + + assert.equal(yield* Ref.get(createCount), 1); + assert.deepEqual(fakeWindow.loadURL.mock.calls, [ + [ + "t3code-dev://app/#/db6d1813-ace4-42bd-9bce-e04ee27e97ff/ebf3a84d-7f60-4809-a5e0-bbd574275463", + ], + ]); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect("navigates an existing main window to a project jump route", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + fakeWindow.loadURL.mockClear(); + + yield* desktopWindow.navigateToProject({ + project: "macs-holding/scanner", + action: "latest", + }); + + assert.deepEqual(fakeWindow.loadURL.mock.calls, [ + ["t3code-dev://app/#/jump?project=macs-holding%2Fscanner&action=latest"], + ]); + }).pipe(Effect.provide(layer)); + }), + ); }); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 3bf746a8e9b..36093491ca3 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -1,3 +1,4 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -12,7 +13,11 @@ import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; -import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; +import { + buildDesktopProjectNavigationUrl, + buildDesktopThreadNavigationUrl, + getDesktopUrl, +} from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; @@ -88,6 +93,20 @@ export class DesktopWindow extends Context.Service< readonly flushMainWindowBounds: Effect.Effect; readonly dispatchMenuAction: (action: string) => Effect.Effect; readonly syncAppearance: Effect.Effect; + /** + * Navigate the main window to the canonical thread route + * (`#/$environmentId/$threadId`). Reuses or creates the main window, + * preserves backend-readiness/splash behavior, and retains one pending + * target when the renderer is not yet navigable (newest wins). + */ + readonly navigateToThread: (input: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + }) => Effect.Effect; + readonly navigateToProject: (input: { + readonly project: string; + readonly action: "reveal" | "latest" | "new"; + }) => Effect.Effect; } >()("@t3tools/desktop/window/DesktopWindow") {} @@ -262,11 +281,46 @@ export const make = Effect.gen(function* () { // The transient "Connecting to WSL" splash window, tracked separately so it // is never mistaken for the real main window. const splashWindowRef = yield* Ref.make>(Option.none()); + // Single pending deep-link route. Newest wins when several arrive during + // startup or while the main window is still loading. + const pendingNavigationUrlRef = yield* Ref.make>(Option.none()); const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); const runPromise = Effect.runPromiseWith(context); let flushMainWindowBounds: Effect.Effect = Effect.void; + const loadPendingNavigationOnWindow = (window: Electron.BrowserWindow): void => { + if (window.isDestroyed()) { + return; + } + const apply = () => { + void runPromise( + Effect.gen(function* () { + if (window.isDestroyed()) { + return; + } + const pending = yield* Ref.get(pendingNavigationUrlRef); + if (Option.isNone(pending)) { + return; + } + void window.loadURL(pending.value).catch(() => undefined); + // Clear only the route we applied so a newer pending target is kept. + yield* Ref.update(pendingNavigationUrlRef, (current) => { + if (Option.isSome(current) && current.value === pending.value) { + return Option.none(); + } + return current; + }); + }), + ); + }; + if (window.webContents.isLoadingMainFrame()) { + window.webContents.once("did-finish-load", apply); + return; + } + apply(); + }; + const dismissConnectingSplash = Effect.gen(function* () { const splash = yield* Ref.getAndSet(splashWindowRef, Option.none()); if (Option.isSome(splash) && !splash.value.isDestroyed()) { @@ -569,7 +623,27 @@ export const make = Effect.gen(function* () { if (window.isDestroyed()) { return; } - void window.loadURL(applicationUrl).catch(() => undefined); + void runPromise( + Effect.gen(function* () { + const pending = yield* Ref.get(pendingNavigationUrlRef); + const url = Option.getOrElse(pending, () => applicationUrl); + if (window.isDestroyed()) { + return; + } + void window.loadURL(url).catch(() => undefined); + if (Option.isNone(pending)) { + return; + } + // Drop only the route we just loaded. A newer deep link that arrived + // after we read pending must stay queued. + yield* Ref.update(pendingNavigationUrlRef, (current) => { + if (Option.isSome(current) && current.value === pending.value) { + return Option.none(); + } + return current; + }); + }), + ); }; const scheduleDevelopmentLoadRetry = () => { if (developmentLoadRetryFiber !== undefined || window.isDestroyed()) { @@ -842,6 +916,72 @@ export const make = Effect.gen(function* () { syncWindowAppearance(window, shouldUseDarkColors, environment.platform), ); }).pipe(Effect.withSpan("desktop.window.syncAppearance")), + navigateToThread: Effect.fn("desktop.window.navigateToThread")(function* (input) { + yield* Effect.annotateCurrentSpan({ + environmentId: input.environmentId, + threadId: input.threadId, + }); + // Newest deep link wins if several arrive before navigation applies. + yield* Ref.set( + pendingNavigationUrlRef, + Option.some( + buildDesktopThreadNavigationUrl({ + isDevelopment: environment.isDevelopment, + environmentId: input.environmentId, + threadId: input.threadId, + }), + ), + ); + + const existingWindow = yield* currentMainWindow; + if (Option.isSome(existingWindow)) { + const window = existingWindow.value; + loadPendingNavigationOnWindow(window); + yield* electronWindow.reveal(window); + yield* logWindowInfo("navigated main window to deep-linked thread"); + return; + } + + // No real main window yet. Keep the pending route so createWindow's + // initial load uses the thread URL. Create immediately when the backend + // is ready; otherwise handleBackendReady / createMainIfBackendReady will + // open the window and pick up the pending target. + yield* createMainIfBackendReady; + const createdWindow = yield* currentMainWindow; + if (Option.isSome(createdWindow)) { + // createWindow already scheduled the pending URL via loadApplication. + yield* electronWindow.reveal(createdWindow.value); + yield* logWindowInfo("created main window for deep-linked thread"); + return; + } + + yield* logWindowInfo("queued deep-linked thread until main window is navigable"); + }), + navigateToProject: Effect.fn("desktop.window.navigateToProject")(function* (input) { + yield* Ref.set( + pendingNavigationUrlRef, + Option.some( + buildDesktopProjectNavigationUrl({ + isDevelopment: environment.isDevelopment, + project: input.project, + action: input.action, + }), + ), + ); + + const existingWindow = yield* currentMainWindow; + if (Option.isSome(existingWindow)) { + loadPendingNavigationOnWindow(existingWindow.value); + yield* electronWindow.reveal(existingWindow.value); + return; + } + + yield* createMainIfBackendReady; + const createdWindow = yield* currentMainWindow; + if (Option.isSome(createdWindow)) { + yield* electronWindow.reveal(createdWindow.value); + } + }), }); }); diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 18ddbc66c0c..bc72758bc71 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -36,18 +36,6 @@ function hydratePosixPath(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): vo } } -export function hydratePosixHome( - env: NodeJS.ProcessEnv, - resolveHomeDir = () => NodeOS.userInfo().homedir, -): void { - if ((env.HOME?.trim() ?? "").length > 0) return; - - const homeDir = resolveHomeDir(); - if (homeDir.length > 0) { - env.HOME = homeDir; - } -} - export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return< void, never, @@ -75,13 +63,6 @@ export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return< if (platform !== "darwin" && platform !== "linux") return; - yield* Effect.sync(() => hydratePosixHome(env)).pipe( - Effect.catchDefect((defect) => - Effect.sync(() => { - logPathHydrationWarning("Failed to hydrate HOME from the user account.", defect); - }), - ), - ); yield* Effect.sync(() => hydratePosixPath(env, platform)).pipe( Effect.catchDefect((defect) => Effect.sync(() => { diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index d47aaaec826..a08350ed959 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -72,12 +72,7 @@ const git = (cwd: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) return result.stdout.trim(); }); -const searchWorkspaceEntries = (input: { - cwd: string; - query: string; - limit: number; - kind?: "file" | "directory"; -}) => +const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: number }) => Effect.gen(function* () { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; return yield* workspaceEntries.search(input); @@ -205,62 +200,6 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); - it.effect("applies the file filter before limiting search results", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-file-limit-" }); - yield* writeTextFile(cwd, "src/index.ts"); - yield* writeTextFile(cwd, "src/internal.ts"); - - const result = yield* searchWorkspaceEntries({ - cwd, - query: "src", - limit: 1, - kind: "file", - }); - - expect(result.entries).toEqual([{ path: "src/index.ts", kind: "file" }]); - expect(result.truncated).toBe(true); - }), - ); - - it.effect("answers an empty file-filtered query with a bounded file listing", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-empty-query-" }); - yield* writeTextFile(cwd, "src/index.ts"); - yield* writeTextFile(cwd, "README.md"); - - const result = yield* searchWorkspaceEntries({ - cwd, - query: "", - limit: 10, - kind: "file", - }); - - const paths = result.entries.map((entry) => entry.path); - expect(paths).toHaveLength(2); - expect(paths).toContain("src/index.ts"); - expect(paths).toContain("README.md"); - expect(result.entries.every((entry) => entry.kind === "file")).toBe(true); - }), - ); - - it.effect("returns only directories for the directory filter", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-directory-filter-" }); - yield* writeTextFile(cwd, "src/index.ts"); - - const result = yield* searchWorkspaceEntries({ - cwd, - query: "src", - limit: 10, - kind: "directory", - }); - - expect(result.entries).toEqual([{ path: "src", kind: "directory" }]); - expect(result.truncated).toBe(false); - }), - ); - it.effect("excludes gitignored paths for git repositories", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-gitignore-", git: true }); @@ -353,287 +292,6 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { ); }); - describe("searchContents", () => { - it.effect("returns content matches with file paths, line numbers, and ranges", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-search-" }); - yield* writeTextFile( - cwd, - "src/shapes.ts", - "export const square = 4;\nexport const Square = 16;\nexport const squareSize = 8;\n", - ); - yield* writeTextFile(cwd, "src/other.ts", "const circle = true;\n"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.searchContents({ - cwd, - query: "Square", - limit: 100, - caseSensitive: false, - wholeWord: true, - useRegex: false, - }); - - expect(result.matches.map((match) => [match.path, match.lineNumber])).toEqual([ - ["src/shapes.ts", 1], - ["src/shapes.ts", 2], - ]); - expect(result.matches[0]?.matchRanges).toEqual([{ start: 13, end: 19 }]); - expect(result.truncated).toBe(false); - }), - ); - - it.effect("honors case sensitivity and gitignore rules", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-ignore-", git: true }); - yield* writeTextFile(cwd, ".gitignore", "ignored.txt\n"); - yield* writeTextFile(cwd, "src/keep.ts", "square\nSquare\n"); - yield* writeTextFile(cwd, "ignored.txt", "Square\n"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.searchContents({ - cwd, - query: "Square", - limit: 100, - caseSensitive: true, - wholeWord: false, - useRegex: false, - }); - - expect(result.matches).toHaveLength(1); - expect(result.matches[0]).toMatchObject({ path: "src/keep.ts", lineNumber: 2 }); - }), - ); - - it.effect("filters whole-word matches by word boundaries without widening ranges", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-whole-word-" }); - yield* writeTextFile(cwd, "src/words.ts", "note notes denote\nfootnote note\n"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.searchContents({ - cwd, - query: "note", - limit: 100, - caseSensitive: true, - wholeWord: true, - useRegex: false, - }); - - // "notes", "denote", and "footnote" are word-adjacent and excluded; - // ranges cover exactly the query, never boundary characters. - expect(result.matches).toEqual([ - expect.objectContaining({ - path: "src/words.ts", - lineNumber: 1, - matchRanges: [{ start: 0, end: 4 }], - }), - expect.objectContaining({ - path: "src/words.ts", - lineNumber: 2, - matchRanges: [{ start: 9, end: 13 }], - }), - ]); - }), - ); - - it.effect("finds later whole-word matches in a file after rejected raw matches", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-late-whole-word-" }); - yield* writeTextFile(cwd, "src/words.ts", `${"afoo\n".repeat(10)}foo\n`); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.searchContents({ - cwd, - query: "foo", - limit: 1, - caseSensitive: true, - wholeWord: true, - useRegex: false, - }); - - expect(result.matches).toEqual([ - expect.objectContaining({ - path: "src/words.ts", - lineNumber: 11, - matchRanges: [{ start: 0, end: 3 }], - }), - ]); - }), - ); - - it.effect("treats astral-plane letters as whole word characters", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-astral-word-" }); - yield* writeTextFile(cwd, "src/words.ts", "𐐀foo foo foo𐐀\n"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.searchContents({ - cwd, - query: "foo", - limit: 100, - caseSensitive: true, - wholeWord: true, - useRegex: false, - }); - - expect(result.matches).toEqual([ - expect.objectContaining({ - path: "src/words.ts", - lineNumber: 1, - matchRanges: [{ start: 6, end: 9 }], - }), - ]); - }), - ); - - it.effect("matches punctuation-edged whole-word queries including adjacent occurrences", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-punctuation-" }); - yield* writeTextFile(cwd, "src/words.ts", "-foo- -foo- -foo-\n"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.searchContents({ - cwd, - query: "-foo-", - limit: 100, - caseSensitive: true, - wholeWord: true, - useRegex: false, - }); - - // Consuming-boundary regex would swallow the separating spaces and - // drop the middle occurrence; boundary post-filtering keeps all three. - expect(result.matches).toHaveLength(1); - expect(result.matches[0]).toMatchObject({ - path: "src/words.ts", - lineNumber: 1, - matchRanges: [ - { start: 0, end: 5 }, - { start: 6, end: 11 }, - { start: 12, end: 17 }, - ], - }); - }), - ); - - it.effect("matches punctuation-edged regex queries as whole words", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-regex-punctuation-" }); - yield* writeTextFile(cwd, "src/words.ts", "foo- foo-\nafoo-b\n"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.searchContents({ - cwd, - query: "foo-", - limit: 100, - caseSensitive: true, - wholeWord: true, - useRegex: true, - }); - - // wholeWord + useRegex must not silently drop non-word-edged patterns - // like "foo-", and "afoo-" is excluded because 'a'/'f' are both word - // characters at the match's left edge. - expect(result.matches).toHaveLength(1); - expect(result.matches[0]).toMatchObject({ - path: "src/words.ts", - lineNumber: 1, - matchRanges: [ - { start: 0, end: 4 }, - { start: 5, end: 9 }, - ], - }); - }), - ); - - it.effect("caps matches per file so one dense file cannot fill the page", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-per-file-cap-" }); - yield* writeTextFile(cwd, "src/dense.ts", "needle\n".repeat(300)); - yield* writeTextFile(cwd, "src/other.ts", "needle\n"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.searchContents({ - cwd, - query: "needle", - limit: 500, - caseSensitive: true, - wholeWord: false, - useRegex: false, - }); - - const byPath = new Map(); - for (const match of result.matches) { - byPath.set(match.path, (byPath.get(match.path) ?? 0) + 1); - } - expect(byPath.get("src/dense.ts")).toBe(100); - expect(byPath.get("src/other.ts")).toBe(1); - }), - ); - - it.effect("preserves regex escapes during case-insensitive searches", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-regex-" }); - yield* writeTextFile(cwd, "src/shapes.ts", "Square\nsquare\n"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.searchContents({ - cwd, - query: "\\SQUARE", - limit: 100, - caseSensitive: false, - wholeWord: false, - useRegex: true, - }); - - expect(result.matches.map((match) => match.lineNumber)).toEqual([1, 2]); - }), - ); - - it.effect("preserves invalid regex errors during case-insensitive searches", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-invalid-regex-" }); - yield* writeTextFile(cwd, "src/shapes.ts", "foobar\n"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.searchContents({ - cwd, - query: "foo)bar(", - limit: 100, - caseSensitive: false, - wholeWord: false, - useRegex: true, - }); - - expect(result.regexFallbackError).toBeDefined(); - expect(result.matches).toEqual([]); - }), - ); - - it.effect("maps multi-byte lines to string-indexed ranges", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-multibyte-" }); - yield* writeTextFile(cwd, "src/notes.ts", 'const label = "héllo wörld";\n'); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.searchContents({ - cwd, - query: "wörld", - limit: 100, - caseSensitive: true, - wholeWord: false, - useRegex: false, - }); - - expect(result.matches).toHaveLength(1); - const match = result.matches[0]!; - const range = match.matchRanges[0]!; - expect(match.lineContent.slice(range.start, range.end)).toBe("wörld"); - }), - ); - }); - describe("browse", () => { it.effect("returns matching directories and excludes files", () => Effect.gen(function* () { diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index bb2113dac37..7501cbe0eab 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -14,14 +14,11 @@ import type { FilesystemBrowseResult, ProjectListEntriesInput, ProjectListEntriesResult, - ProjectSearchContentsInput, - ProjectSearchContentsResult, ProjectSearchEntriesInput, ProjectSearchEntriesResult, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; -import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; import * as WorkspacePaths from "./WorkspacePaths.ts"; import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; @@ -96,9 +93,6 @@ export class WorkspaceEntries extends Context.Service< readonly search: ( input: ProjectSearchEntriesInput, ) => Effect.Effect; - readonly searchContents: ( - input: ProjectSearchContentsInput, - ) => Effect.Effect; readonly refresh: (cwd: string) => Effect.Effect; } >()("t3/workspace/WorkspaceEntries") {} @@ -154,37 +148,33 @@ export const make = Effect.gen(function* () { const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe( Effect.orElseSucceed(() => cwd), ); - for (const variant of WorkspaceSearchIndex.WORKSPACE_SEARCH_INDEX_VARIANTS) { - const indexKey = WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, variant); - if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, indexKey))) { - continue; - } - const recoverRefreshFailure = ( - cause: - | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed - | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut - | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed, - ) => - Effect.gen(function* () { - yield* Effect.logWarning("Failed to refresh workspace search index", { - cwd, - variant, - cause, - }); - yield* workspaceSearchIndexes.invalidate(indexKey); - }); - yield* Effect.gen(function* () { - const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; - yield* searchIndex.refresh(); - }).pipe( - Effect.provide(workspaceSearchIndexes.get(indexKey)), - Effect.catchTags({ - WorkspaceSearchIndexCreateFailed: recoverRefreshFailure, - WorkspaceSearchIndexScanTimedOut: recoverRefreshFailure, - WorkspaceSearchIndexRefreshFailed: recoverRefreshFailure, - }), - ); + if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, normalizedCwd))) { + return; } + const recoverRefreshFailure = ( + cause: + | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed + | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut + | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed, + ) => + Effect.gen(function* () { + yield* Effect.logWarning("Failed to refresh workspace search index", { + cwd, + cause, + }); + yield* workspaceSearchIndexes.invalidate(normalizedCwd); + }); + yield* Effect.gen(function* () { + const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; + yield* searchIndex.refresh(); + }).pipe( + Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), + Effect.catchTags({ + WorkspaceSearchIndexCreateFailed: recoverRefreshFailure, + WorkspaceSearchIndexScanTimedOut: recoverRefreshFailure, + WorkspaceSearchIndexRefreshFailed: recoverRefreshFailure, + }), + ); }, ); @@ -240,55 +230,28 @@ export const make = Effect.gen(function* () { const search: WorkspaceEntries["Service"]["search"] = Effect.fn("WorkspaceEntries.search")( function* (input) { const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); - const normalizedQuery = normalizeSearchQuery(input.query, { - trimLeadingPattern: /^[@./]+/, - }); + const normalizedQuery = input.query + .trim() + .toLowerCase() + .replace(/^[@./]+/, ""); return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; - return yield* searchIndex.search(normalizedQuery, input.limit, input.kind); - }).pipe( - Effect.provide( - workspaceSearchIndexes.get( - WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "paths"), - ), - ), - ); + return yield* searchIndex.search(normalizedQuery, input.limit); + }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); }, ); - const searchContents: WorkspaceEntries["Service"]["searchContents"] = Effect.fn( - "WorkspaceEntries.searchContents", - )(function* (input) { - const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); - return yield* Effect.gen(function* () { - const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; - return yield* searchIndex.searchContents(input); - }).pipe( - Effect.provide( - workspaceSearchIndexes.get( - WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "content"), - ), - ), - ); - }); - const list: WorkspaceEntries["Service"]["list"] = Effect.fn("WorkspaceEntries.list")( function* (input) { const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; return yield* searchIndex.list(); - }).pipe( - Effect.provide( - workspaceSearchIndexes.get( - WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "paths"), - ), - ), - ); + }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); }, ); - return WorkspaceEntries.of({ browse, list, refresh, search, searchContents }); + return WorkspaceEntries.of({ browse, list, refresh, search }); }); export const layer = Layer.effect(WorkspaceEntries, make).pipe( diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 15572837030..9b7ed4e2453 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -1,4 +1,4 @@ -import { FileFinder, type GrepCursor, type GrepOptions, type GrepResult } from "@ff-labs/fff-node"; +import { FileFinder } from "@ff-labs/fff-node"; import { afterEach, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; @@ -51,41 +51,6 @@ it.effect("keeps returned FileFinder creation diagnostics out of the cause chain }), ); -it.effect("waits for the full content index warmup before returning", () => - Effect.gen(function* () { - const waitForIndexReady = vi.fn(async () => ({ ok: true as const, value: true })); - const finder = { - destroy: vi.fn(), - waitForIndexReady, - } as unknown as FileFinder; - vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); - - yield* Effect.scoped(WorkspaceSearchIndex.make("/workspace/project", "content")); - - expect(waitForIndexReady).toHaveBeenCalledWith(15_000); - }), -); - -it.effect("preserves a full-index warmup timeout as a structured error", () => - Effect.gen(function* () { - const finder = { - destroy: vi.fn(), - waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: false })), - } as unknown as FileFinder; - vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); - - const error = yield* Effect.flip( - Effect.scoped(WorkspaceSearchIndex.make("/workspace/project", "content")), - ); - - expect(error).toMatchObject({ - _tag: "WorkspaceSearchIndexScanTimedOut", - cwd: "/workspace/project", - timeout: "15 seconds", - }); - }), -); - it.effect("preserves FileFinder destroy failures as structured defects", () => Effect.gen(function* () { const cause = new Error("native destroy failed"); @@ -93,7 +58,7 @@ it.effect("preserves FileFinder destroy failures as structured defects", () => destroy: vi.fn(() => { throw cause; }), - waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), + isScanning: vi.fn(() => false), } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); @@ -120,16 +85,12 @@ it.effect("preserves search and refresh failures with operation context", () => Effect.gen(function* () { const searchCause = new Error("native search failed"); const refreshCause = new Error("native scan failed"); - const contentSearchCause = new Error("native grep failed"); const finder = { destroy: vi.fn(), - waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), + isScanning: vi.fn(() => false), mixedSearch: vi.fn(() => { throw searchCause; }), - grep: vi.fn(() => { - throw contentSearchCause; - }), scanFiles: vi.fn(() => { throw refreshCause; }), @@ -139,15 +100,6 @@ it.effect("preserves search and refresh failures with operation context", () => const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); const query = "authorization: Bearer secret-token"; const searchError = yield* Effect.flip(searchIndex.search(query, 3)); - const contentSearchError = yield* Effect.flip( - searchIndex.searchContents({ - query, - limit: 3, - caseSensitive: false, - wholeWord: false, - useRegex: false, - }), - ); const refreshError = yield* Effect.flip(searchIndex.refresh()); expect(searchError).toMatchObject({ @@ -160,16 +112,6 @@ it.effect("preserves search and refresh failures with operation context", () => }); expect(searchError).not.toHaveProperty("query"); expect(searchError.message).not.toMatch(/Bearer|secret-token/); - expect(contentSearchError).toMatchObject({ - _tag: "WorkspaceSearchIndexSearchFailed", - cwd: "/workspace/project", - queryLength: query.length, - pageSize: 3, - reason: "FileFinder.grep threw unexpectedly.", - cause: contentSearchCause, - }); - expect(contentSearchError).not.toHaveProperty("query"); - expect(contentSearchError.message).not.toMatch(/Bearer|secret-token/); expect(refreshError).toMatchObject({ _tag: "WorkspaceSearchIndexRefreshFailed", cwd: "/workspace/project", @@ -185,7 +127,7 @@ it.effect("keeps returned search diagnostics out of the cause chain", () => Effect.gen(function* () { const finder = { destroy: vi.fn(), - waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), + isScanning: vi.fn(() => false), mixedSearch: vi.fn(() => ({ ok: false, error: "native query rejected" })), scanFiles: vi.fn(() => ({ ok: false, error: "native refresh rejected" })), } as unknown as FileFinder; @@ -215,80 +157,3 @@ it.effect("keeps returned search diagnostics out of the cause chain", () => }), ), ); - -it.effect("continues whole-word searches after a filtered grep page", () => - Effect.scoped( - Effect.gen(function* () { - const nextCursor = { - __brand: "GrepCursor", - _offset: 1, - } as GrepCursor; - const grepResult = ( - lineContent: string, - matchRanges: Array<[number, number]>, - cursor: GrepCursor | null, - ): GrepResult => ({ - items: [ - { - relativePath: "src/words.ts", - fileName: "words.ts", - gitStatus: "unmodified", - size: lineContent.length, - modified: 0, - isBinary: false, - totalFrecencyScore: 0, - accessFrecencyScore: 0, - modificationFrecencyScore: 0, - lineNumber: 1, - col: 0, - byteOffset: 0, - lineContent, - matchRanges, - }, - ], - totalMatched: 1, - totalFilesSearched: 1, - totalFiles: 1, - filteredFileCount: 1, - nextCursor: cursor, - }); - const grep = vi.fn((_query: string, options?: GrepOptions) => - options?.cursor - ? { ok: true as const, value: grepResult("needle", [[0, 6]], null) } - : { - ok: true as const, - value: grepResult("needleSuffix", [[0, 6]], nextCursor), - }, - ); - const finder = { - destroy: vi.fn(), - waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - grep, - } as unknown as FileFinder; - vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); - - const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project", "content"); - const result = yield* searchIndex.searchContents({ - query: "needle", - limit: 1, - caseSensitive: true, - wholeWord: true, - useRegex: false, - }); - - expect(result).toEqual({ - matches: [ - { - path: "src/words.ts", - lineNumber: 1, - lineContent: "needle", - matchRanges: [{ start: 0, end: 6 }], - }, - ], - truncated: false, - }); - expect(grep).toHaveBeenCalledTimes(2); - expect(grep.mock.calls[1]?.[1]?.cursor).toBe(nextCursor); - }), - ), -); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 8bf36b7a80a..db4d46851e7 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -1,36 +1,22 @@ -import { - type DirItem, - type DirSearchResult, - type FileItem, - FileFinder, - type GrepCursor, - type MixedItem, - type MixedSearchResult, - type Result, - type SearchResult, -} from "@ff-labs/fff-node"; +import { FileFinder, type MixedItem, type MixedSearchResult } from "@ff-labs/fff-node"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as LayerMap from "effect/LayerMap"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import type { ProjectEntry, - ProjectEntryKind, ProjectListEntriesResult, - ProjectSearchContentsInput, - ProjectSearchContentsResult, ProjectSearchEntriesResult, } from "@t3tools/contracts"; const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2; const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds"; -const WORKSPACE_INDEX_SCAN_TIMEOUT_MS = 15_000; const WORKSPACE_INDEX_IDLE_TTL = "15 minutes"; -const CONTENT_SEARCH_TIME_BUDGET_MS = 250; -const CONTENT_SEARCH_MAX_MATCHES_PER_FILE = 100; +const WORKSPACE_INDEX_SCAN_POLL_INTERVAL = "50 millis"; export class WorkspaceSearchIndexCreateFailed extends Schema.TaggedErrorClass()( "WorkspaceSearchIndexCreateFailed", @@ -110,11 +96,7 @@ export class WorkspaceSearchIndex extends Context.Service< readonly search: ( query: string, limit: number, - kind?: ProjectEntryKind, ) => Effect.Effect; - readonly searchContents: ( - input: Omit, - ) => Effect.Effect; readonly refresh: () => Effect.Effect< void, WorkspaceSearchIndexRefreshFailed | WorkspaceSearchIndexScanTimedOut @@ -147,43 +129,6 @@ function toProjectEntry(item: MixedItem): ProjectEntry | null { }; } -function toFileEntry(item: FileItem): ProjectEntry | null { - const normalizedPath = trimDirectorySeparator(toPosixPath(item.relativePath)); - return normalizedPath ? { path: normalizedPath, kind: "file" } : null; -} - -function toDirectoryEntry(item: DirItem): ProjectEntry | null { - const normalizedPath = trimDirectorySeparator(toPosixPath(item.relativePath)); - return normalizedPath ? { path: normalizedPath, kind: "directory" } : null; -} - -function mapFileSearchResult(result: SearchResult, limit: number): ProjectSearchEntriesResult { - return { - entries: result.items - .flatMap((item) => { - const entry = toFileEntry(item); - return entry ? [entry] : []; - }) - .slice(0, limit), - truncated: result.totalMatched > limit, - }; -} - -function mapDirectorySearchResult( - result: DirSearchResult, - limit: number, -): ProjectSearchEntriesResult { - const entries = result.items.flatMap((item) => { - const entry = toDirectoryEntry(item); - return entry ? [entry] : []; - }); - const rootDirectoryCount = result.items.some((item) => item.relativePath.length === 0) ? 1 : 0; - return { - entries: entries.slice(0, limit), - truncated: result.totalMatched - rootDirectoryCount > limit, - }; -} - function mapMixedSearchResult( result: MixedSearchResult, limit: number, @@ -210,74 +155,6 @@ function mapMixedSearchResult( }; } -const WORD_CHARACTER = /[\p{Letter}\p{Mark}\p{Number}_]/u; - -function codePointAt(line: string, index: number): string | undefined { - const codePoint = line.codePointAt(index); - return codePoint === undefined ? undefined : String.fromCodePoint(codePoint); -} - -function codePointBefore(line: string, index: number): string | undefined { - if (index <= 0) return undefined; - const previousCodeUnit = line.charCodeAt(index - 1); - const previousIndex = - previousCodeUnit >= 0xdc00 && previousCodeUnit <= 0xdfff ? index - 2 : index - 1; - return codePointAt(line, previousIndex); -} - -function buildContentSearchQuery(input: Omit): { - readonly searchQuery: string; - readonly regexMode: boolean; -} { - if (input.caseSensitive) { - return { searchQuery: input.query, regexMode: input.useRegex }; - } - // Plain mode relies on smart case: an all-lowercase needle matches - // case-insensitively. Regex mode needs an explicit inline flag instead. - return input.useRegex - ? { searchQuery: `(?i)${input.query}`, regexMode: true } - : { searchQuery: input.query.toLowerCase(), regexMode: false }; -} - -function mapContentMatchRanges( - line: string, - byteRanges: ReadonlyArray, -): Array<{ readonly start: number; readonly end: number }> { - const lineBytes = Buffer.from(line); - const toStringIndex = (byteOffset: number) => lineBytes.subarray(0, byteOffset).toString().length; - return byteRanges.map(([startByte, endByte]) => ({ - start: toStringIndex(startByte), - end: toStringIndex(endByte), - })); -} - -/** - * Whole-word filtering happens after the grep rather than by wrapping the - * pattern in boundary regex: consuming boundaries such as `(?:^|\W)` swallow - * the separator between adjacent matches and widen the reported ranges, and - * `\b` cannot match punctuation-edged queries at all. Matching VS Code, a - * match edge is a word boundary when it touches the line edge, the - * neighbouring character is not a word character, or the match's own edge - * character is not a word character. - */ -function isWholeWordRange( - line: string, - range: { readonly start: number; readonly end: number }, -): boolean { - if (range.end <= range.start) return false; - const isWord = (character: string | undefined) => - character !== undefined && WORD_CHARACTER.test(character); - const leftIsBoundary = - range.start === 0 || - !isWord(codePointBefore(line, range.start)) || - !isWord(codePointAt(line, range.start)); - const rightIsBoundary = - range.end >= line.length || - !isWord(codePointAt(line, range.end)) || - !isWord(codePointBefore(line, range.end)); - return leftIsBoundary && rightIsBoundary; -} - function withDirectoryAncestors(entries: ReadonlyArray): ProjectEntry[] { const entryByPath = new Map(entries.map((entry) => [entry.path, entry])); for (const entry of entries) { @@ -292,19 +169,13 @@ function withDirectoryAncestors(entries: ReadonlyArray): ProjectEn return [...entryByPath.values()]; } -const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* ( - cwd: string, - variant: WorkspaceSearchIndexVariant, -) { +const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (cwd: string) { const result = yield* Effect.try({ try: () => FileFinder.create({ basePath: cwd, disableMmapCache: true, - // Content indexing costs scan CPU and memory, so only the on-demand - // content-search index pays for it; path-only consumers (file tree, - // composer path search, file picker) keep the lightweight index. - disableContentIndexing: variant !== "content", + disableContentIndexing: true, aiMode: false, enableFsRootScanning: true, enableHomeDirScanning: true, @@ -323,65 +194,53 @@ const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* ( }); }); -const waitForIndexReady = Effect.fn("WorkspaceSearchIndex.waitForIndexReady")(function* ( - cwd: string, - finder: FileFinder, - onFailure: (input: { readonly reason: string; readonly cause?: unknown }) => E, -): Effect.fn.Return { - const result = yield* Effect.tryPromise({ - try: () => finder.waitForIndexReady(WORKSPACE_INDEX_SCAN_TIMEOUT_MS), - catch: (cause) => - onFailure({ - reason: "FileFinder.waitForIndexReady rejected unexpectedly.", - cause, - }), - }); - if (!result.ok) { - return yield* Effect.fail(onFailure({ reason: result.error })); - } - if (!result.value) { - return yield* new WorkspaceSearchIndexScanTimedOut({ - cwd, - timeout: WORKSPACE_INDEX_SCAN_TIMEOUT, - }); - } -}); +const waitForScan = (cwd: string, finder: FileFinder, onFailure: (cause: unknown) => E) => + Effect.try({ + try: () => finder.isScanning(), + catch: onFailure, + }).pipe( + Effect.repeat({ + while: (scanning) => scanning, + schedule: Schedule.spaced(WORKSPACE_INDEX_SCAN_POLL_INTERVAL), + }), + Effect.timeoutOrElse({ + duration: WORKSPACE_INDEX_SCAN_TIMEOUT, + orElse: () => + new WorkspaceSearchIndexScanTimedOut({ cwd, timeout: WORKSPACE_INDEX_SCAN_TIMEOUT }), + }), + Effect.withSpan("WorkspaceSearchIndex.waitForScan"), + ); -export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( - cwd: string, - variant: WorkspaceSearchIndexVariant = "paths", -) { - const finder = yield* Effect.acquireRelease(createFinder(cwd, variant), (finder) => +export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: string) { + const finder = yield* Effect.acquireRelease(createFinder(cwd), (finder) => Effect.try({ try: () => finder.destroy(), catch: (cause) => new WorkspaceSearchIndexDestroyFailed({ cwd, cause }), }).pipe(Effect.orDie), ); - yield* waitForIndexReady( + yield* waitForScan( cwd, finder, - ({ reason, cause }) => + (cause) => new WorkspaceSearchIndexCreateFailed({ cwd, - reason, + reason: "FileFinder.isScanning threw while creating the index.", cause, }), ); - const runSearch = Effect.fn("WorkspaceSearchIndex.runSearch")(function* ( + const runMixedSearch = Effect.fn("WorkspaceSearchIndex.runMixedSearch")(function* ( query: string, pageSize: number, - operation: "directorySearch" | "fileSearch" | "grep" | "mixedSearch", - execute: () => Result, - ): Effect.fn.Return { + ) { const result = yield* Effect.try({ - try: execute, + try: () => finder.mixedSearch(query, { pageSize }), catch: (cause) => new WorkspaceSearchIndexSearchFailed({ cwd, queryLength: query.length, pageSize, - reason: `FileFinder.${operation} threw unexpectedly.`, + reason: "FileFinder.mixedSearch threw unexpectedly.", cause, }), }); @@ -414,13 +273,13 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( reason: result.error, }); } - yield* waitForIndexReady( + yield* waitForScan( cwd, finder, - ({ reason, cause }) => + (cause) => new WorkspaceSearchIndexRefreshFailed({ cwd, - reason, + reason: "FileFinder.isScanning threw while refreshing the index.", cause, }), ); @@ -428,9 +287,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( function* () { - const result = yield* runSearch("", WORKSPACE_INDEX_PAGE_SIZE, "mixedSearch", () => - finder.mixedSearch("", { pageSize: WORKSPACE_INDEX_PAGE_SIZE }), - ); + const result = yield* runMixedSearch("", WORKSPACE_INDEX_PAGE_SIZE); const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES); const sortedEntries = withDirectoryAncestors(mapped.entries).toSorted((left, right) => left.path.localeCompare(right.path), @@ -445,112 +302,20 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( "WorkspaceSearchIndex.search", - )(function* (query, limit, kind) { - const pageSize = Math.max(1, limit + 1); - if (kind === "file") { - const result = yield* runSearch(query, pageSize, "fileSearch", () => - finder.fileSearch(query, { pageSize }), - ); - return mapFileSearchResult(result, limit); - } - if (kind === "directory") { - const result = yield* runSearch(query, pageSize, "directorySearch", () => - finder.directorySearch(query, { pageSize }), - ); - return mapDirectorySearchResult(result, limit); - } - const result = yield* runSearch(query, pageSize, "mixedSearch", () => - finder.mixedSearch(query, { pageSize }), - ); + )(function* (query, limit) { + const result = yield* runMixedSearch(query, Math.max(1, limit + 1)); return mapMixedSearchResult(result, limit); }); - const searchContents: WorkspaceSearchIndex["Service"]["searchContents"] = Effect.fn( - "WorkspaceSearchIndex.searchContents", - )(function* (input) { - const { searchQuery, regexMode } = buildContentSearchQuery(input); - const deadline = performance.now() + CONTENT_SEARCH_TIME_BUDGET_MS; - // Grep cursors advance by file, so whole-word post-filtering needs enough - // raw candidates from the current file before moving to the next one. - const rawPageSize = input.wholeWord - ? Math.max(input.limit, CONTENT_SEARCH_MAX_MATCHES_PER_FILE) - : input.limit; - const matches: Array = []; - let nextCursor: GrepCursor | null = null; - let regexFallbackError: string | undefined; - - do { - const remainingTimeBudgetMs = Math.max(1, Math.ceil(deadline - performance.now())); - const result = yield* runSearch(input.query, input.limit, "grep", () => - finder.grep(searchQuery, { - mode: regexMode ? "regex" : "plain", - smartCase: !input.caseSensitive && !regexMode, - // A single dense file must not consume the whole result page. - maxMatchesPerFile: Math.min(CONTENT_SEARCH_MAX_MATCHES_PER_FILE, rawPageSize), - pageSize: rawPageSize, - cursor: nextCursor, - timeBudgetMs: remainingTimeBudgetMs, - }), - ); - - for (const match of result.items) { - const matchRanges = mapContentMatchRanges(match.lineContent, match.matchRanges).filter( - (range) => !input.wholeWord || isWholeWordRange(match.lineContent, range), - ); - if (matchRanges.length === 0) continue; - matches.push({ - path: toPosixPath(match.relativePath), - lineNumber: match.lineNumber, - lineContent: match.lineContent, - matchRanges, - }); - } - nextCursor = result.nextCursor; - regexFallbackError ??= result.regexFallbackError; - } while (matches.length < input.limit && nextCursor !== null && performance.now() < deadline); - - return { - matches: matches.slice(0, input.limit), - truncated: matches.length > input.limit || nextCursor !== null, - ...(regexFallbackError !== undefined ? { regexFallbackError } : {}), - }; - }); - - return WorkspaceSearchIndex.of({ list, refresh, search, searchContents }); + return WorkspaceSearchIndex.of({ list, refresh, search }); }); -export const WORKSPACE_SEARCH_INDEX_VARIANTS = ["paths", "content"] as const; -export type WorkspaceSearchIndexVariant = (typeof WORKSPACE_SEARCH_INDEX_VARIANTS)[number]; - -/** - * Composite LayerMap key so the lightweight path index and the on-demand - * content-search index of the same workspace are separate resources with - * independent lifecycles. "\n" cannot appear in a filesystem path. - */ -export const workspaceSearchIndexKey = (cwd: string, variant: WorkspaceSearchIndexVariant) => - `${variant}\n${cwd}`; - -function parseWorkspaceSearchIndexKey(key: string): { - readonly cwd: string; - readonly variant: WorkspaceSearchIndexVariant; -} { - const separatorIndex = key.indexOf("\n"); - return { - variant: key.slice(0, separatorIndex) as WorkspaceSearchIndexVariant, - cwd: key.slice(separatorIndex + 1), - }; -} - /** * A layer factory is required because every index is scoped to a concrete - * workspace root and variant. WorkspaceSearchIndexMap owns memoization and - * idle cleanup; using a default cwd here would mix resources from different - * workspaces. + * workspace root. WorkspaceSearchIndexMap owns memoization and idle cleanup; + * using a default cwd here would mix resources from different workspaces. */ -export const layer = (key: string) => { - const { cwd, variant } = parseWorkspaceSearchIndexKey(key); - return Layer.effect(WorkspaceSearchIndex, make(cwd, variant)); -}; +export const layer = (cwd: string) => Layer.effect(WorkspaceSearchIndex, make(cwd)); export class WorkspaceSearchIndexMap extends LayerMap.Service()( "t3/workspace/WorkspaceSearchIndexMap", diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6a761ba497a..d691320fc9e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -43,7 +43,6 @@ import { type ProjectFileOperation, ProjectListEntriesError, ProjectReadFileError, - ProjectSearchContentsError, ProjectSearchEntriesError, ProjectWriteFileError, RelayClientInstallFailedError, @@ -1911,23 +1910,6 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "workspace" }, ), - [WS_METHODS.projectsSearchContents]: (input) => - observeRpcEffect( - WS_METHODS.projectsSearchContents, - workspaceEntries.searchContents(input).pipe( - Effect.mapError( - (cause) => - new ProjectSearchContentsError({ - cwd: input.cwd, - queryLength: input.query.length, - limit: input.limit, - ...projectEntriesFailureContext(cause), - cause, - }), - ), - ), - { "rpc.aggregate": "workspace" }, - ), [WS_METHODS.projectsListEntries]: (input) => observeRpcEffect( WS_METHODS.projectsListEntries, diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 1a0b8f889f0..a43e4905876 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -75,7 +75,7 @@ export interface CommandPaletteItem { readonly value: string; readonly searchTerms: ReadonlyArray; readonly title: ReactNode; - readonly description?: ReactNode; + readonly description?: string; readonly threadContentMatch?: CommandPaletteThreadContentMatch; readonly timestamp?: string; readonly icon: ReactNode; diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index a3a84d197f9..bcc49339cc6 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,15 +1,12 @@ import type { EnvironmentId } from "@t3tools/contracts"; -import { - getProjectFaviconCacheKey, - isProjectFaviconFallbackUrl, -} from "@t3tools/shared/projectFavicon"; +import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; import { FolderIcon } from "lucide-react"; import type { ComponentType } from "react"; import { useState } from "react"; import { useAssetUrl } from "../assets/assetUrls"; import { cn } from "~/lib/utils"; -const loadedProjectFaviconSrcs = new Map(); +const loadedProjectFaviconSrcs = new Set(); export function ProjectFavicon(input: { environmentId: EnvironmentId; @@ -27,12 +24,9 @@ export function ProjectFavicon(input: { return ; } - const cacheKey = getProjectFaviconCacheKey(input.environmentId, input.cwd, src); - return ( ; }) { - const [displayedSrc, setDisplayedSrc] = useState( - () => loadedProjectFaviconSrcs.get(cacheKey) ?? null, + const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => + loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", ); - const isLoading = displayedSrc !== src; - const handleLoadError = (failedSrc: string) => { - if (loadedProjectFaviconSrcs.get(cacheKey) === failedSrc) { - loadedProjectFaviconSrcs.delete(cacheKey); - } - setDisplayedSrc((currentSrc) => (currentSrc === failedSrc ? null : currentSrc)); - }; return ( <> - {displayedSrc === null ? ( + {status !== "loaded" ? ( ) : null} - {displayedSrc ? ( - handleLoadError(displayedSrc)} - /> - ) : null} - {isLoading ? ( - { - loadedProjectFaviconSrcs.set(cacheKey, src); - setDisplayedSrc(src); - }} - onError={() => handleLoadError(src)} - /> - ) : null} + { + loadedProjectFaviconSrcs.add(src); + setStatus("loaded"); + }} + onError={() => setStatus("error")} + /> ); } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b2b96fddb55..577c59ec7f1 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -204,6 +204,7 @@ import { } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { openCommandPalette } from "../commandPaletteBus"; +import { subscribeToProjectReveal } from "../projectJump"; import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, @@ -2690,7 +2691,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const SidebarProjectListRow = memo(function SidebarProjectListRow(props: SidebarProjectItemProps) { return ( - + ); @@ -2928,6 +2929,7 @@ function SortableProjectItem({ } ${isOver && !isDragging ? "ring-1 ring-primary/40" : ""}`} data-sidebar="menu-item" data-slot="sidebar-menu-item" + data-project-key={projectId} > {children({ attributes, listeners, setActivatorNodeRef })} @@ -5721,6 +5723,25 @@ export default function Sidebar() { }); }, []); + useEffect( + () => + subscribeToProjectReveal(({ environmentId, projectId }) => { + const physicalProjectKey = `${environmentId}:${projectId}`; + const projectKey = physicalToLogicalKey.get(physicalProjectKey) ?? physicalProjectKey; + if (!sidebarProjectByKey.has(projectKey)) return; + expandThreadListForProject(projectKey); + requestAnimationFrame(() => { + const rows = document.querySelectorAll("[data-project-key]"); + for (const row of rows) { + if (row.dataset.projectKey !== projectKey) continue; + row.scrollIntoView({ behavior: "smooth", block: "nearest" }); + break; + } + }); + }), + [expandThreadListForProject, physicalToLogicalKey, sidebarProjectByKey], + ); + return ( {prewarmedSidebarThreadRefs.map((threadRef) => ( diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 0561b67a168..921b0b3f664 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -93,6 +93,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { openCommandPalette } from "../commandPaletteBus"; +import { subscribeToProjectReveal } from "../projectJump"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; @@ -1462,6 +1463,20 @@ export default function SidebarV2() { // Project scope: one menu above the list. Scoping filters the list without // making the header width depend on the number or length of project names. const [projectScopeKey, setProjectScopeKey] = useState(null); + useEffect( + () => + subscribeToProjectReveal(({ environmentId, projectId }) => { + const projectGroup = projectGroups.find((project) => + project.memberProjectRefs.some( + (ref) => ref.environmentId === environmentId && ref.projectId === projectId, + ), + ); + if (projectGroup !== undefined) { + setProjectScopeKey(projectGroup.projectKey); + } + }), + [projectGroups], + ); const scopedProjectGroup = useMemo( () => projectScopeKey === null diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index 8d24b34a433..b07ae99c058 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -7,7 +7,6 @@ import { getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, getDesktopUpdateInstallConfirmationMessage, - getDesktopUpdateReleaseUrl, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, @@ -159,23 +158,6 @@ describe("getDesktopUpdateActionError", () => { }); describe("desktop update UI helpers", () => { - it("builds the stable release URL for a downloaded version", () => { - expect(getDesktopUpdateReleaseUrl("0.0.30")).toBe( - "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30", - ); - }); - - it("builds the nightly release URL without dropping its version suffix", () => { - expect(getDesktopUpdateReleaseUrl("0.0.30-nightly.20260728.931")).toBe( - "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30-nightly.20260728.931", - ); - }); - - it("omits the release URL when the updater does not report a version", () => { - expect(getDesktopUpdateReleaseUrl(null)).toBeNull(); - expect(getDesktopUpdateReleaseUrl(" ")).toBeNull(); - }); - it("toasts only for actionable updater errors", () => { expect( shouldToastDesktopUpdateActionResult({ diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index dc09d7ca877..11c34777a41 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -3,24 +3,6 @@ import { isWindowsPlatform } from "../lib/utils"; export type DesktopUpdateButtonAction = "download" | "install" | "none"; -const DESKTOP_RELEASE_TAG_URL = "https://github.com/pingdotgg/t3code/releases/tag"; - -/** - * The main process fills `downloadedVersion` from the updater's `update-downloaded` - * event, which is dispatched on its own fiber. A download RPC can therefore resolve - * before that write lands, so fall back to the version the download was started for. - */ -export function getDesktopUpdateDownloadedVersion(state: DesktopUpdateState): string | null { - return state.downloadedVersion ?? state.availableVersion; -} - -/** Release notes for an exact downloaded build; nightly suffixes are part of the tag. */ -export function getDesktopUpdateReleaseUrl(version: string | null): string | null { - const normalizedVersion = version?.trim(); - if (!normalizedVersion) return null; - return `${DESKTOP_RELEASE_TAG_URL}/v${encodeURIComponent(normalizedVersion)}`; -} - export function resolveDesktopUpdateButtonAction( state: DesktopUpdateState, ): DesktopUpdateButtonAction { diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index ff658693a70..307d4413751 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -26,10 +26,6 @@ interface FileBrowserPanelProps { environmentId: EnvironmentId; cwd: string; projectName: string; - /** File currently open in the preview pane; revealed and selected in the tree. */ - selectedPath: string | null; - /** Bumped when the same path should be revealed again (e.g. re-opened from search). */ - selectedPathRevealId: number; onOpenFile: (relativePath: string) => void; } @@ -102,8 +98,6 @@ export default function FileBrowserPanel({ environmentId, cwd, projectName, - selectedPath, - selectedPathRevealId, onOpenFile, }: FileBrowserPanelProps) { const { resolvedTheme } = useTheme(); @@ -117,9 +111,6 @@ export default function FileBrowserPanel({ const entryKindsRef = useRef>(entryKinds); const treePaths = useMemo(() => entries.map(treePath), [entries]); const previousTreePathsRef = useRef([]); - const syncingSelectionRef = useRef(false); - const treeSelectionPathRef = useRef(null); - const handledRevealRef = useRef<{ path: string; revealId: number } | null>(null); // The tree renders rows in shadow DOM and its anchor rect is unreliable, so // capture the right-click position ourselves; contextmenu is a composed @@ -225,12 +216,7 @@ export default function FileBrowserPanel({ initialExpansion: 1, icons: T3_PIERRE_ICONS, onSelectionChange: (selectedPaths) => { - // The drag controller's selection cache must track every change, - // including reveal-driven ones, or drags act on a stale selection. dragMention.handleSelectionChange(selectedPaths); - // Selection changes driven by the reveal sync below are echoes of an - // already-open file, not a request to open it again. - if (syncingSelectionRef.current) return; // Starting a drag selects the dragged row; that selection is a side // effect of the gesture, not a request to open the file. if (dragMention.isDragInProgress()) { @@ -238,7 +224,6 @@ export default function FileBrowserPanel({ } const selectedPath = selectedPaths.at(-1)?.replace(/\/$/, ""); if (selectedPath && entryKindsRef.current.get(selectedPath) === "file") { - treeSelectionPathRef.current = selectedPath; onOpenFile(selectedPath); } }, @@ -262,63 +247,6 @@ export default function FileBrowserPanel({ model.resetPaths(treePaths); }, [entryKinds, model, treePaths]); - useEffect(() => { - if (!selectedPath) { - handledRevealRef.current = null; - return; - } - const revealRequest = { path: selectedPath, revealId: selectedPathRevealId }; - const handledReveal = handledRevealRef.current; - // Entry refreshes rebuild treePaths while the same preview stays open. - // Replaying a handled reveal would close an active tree search and steal focus. - if ( - handledReveal?.path === revealRequest.path && - handledReveal.revealId === revealRequest.revealId - ) { - return; - } - if (entryKinds.get(selectedPath) !== "file") return; - const selectedItem = model.getItem(selectedPath); - if (!selectedItem) return; - - // A selection that originated inside the tree (clicking a row, possibly - // in an active tree search) is already visible; re-revealing it would - // close the search and clobber the user's context. Only sync external - // opens (file picker, content search, chat links). - const selectedInTree = model - .getSelectedPaths() - .some((path) => path.replace(/\/$/, "") === selectedPath); - if (selectedInTree && treeSelectionPathRef.current === selectedPath) { - treeSelectionPathRef.current = null; - handledRevealRef.current = revealRequest; - return; - } - treeSelectionPathRef.current = null; - handledRevealRef.current = revealRequest; - - syncingSelectionRef.current = true; - model.closeSearch(); - for (const path of model.getSelectedPaths()) { - model.getItem(path)?.deselect(); - } - - // Directory rows are registered with a trailing slash (see treePath), so - // ancestor lookups must use the same form to expand them. - const segments = selectedPath.split("/"); - let ancestorPath = ""; - for (const segment of segments.slice(0, -1)) { - ancestorPath = ancestorPath ? `${ancestorPath}/${segment}` : segment; - const item = model.getItem(`${ancestorPath}/`) ?? model.getItem(ancestorPath); - if (item && "expand" in item) item.expand(); - } - - selectedItem.select(); - model.scrollToPath(selectedPath, { focus: true, offset: "center" }); - queueMicrotask(() => { - syncingSelectionRef.current = false; - }); - }, [entryKinds, model, selectedPath, selectedPathRevealId, treePaths]); - // Tag tree drags with the composer mention payload. The row is read from // the composed event path (the tree's shadow root is open), so this does // not depend on running after the tree's own dragstart handler; the drag diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index a736cf96cd3..24e63a6d8ea 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -51,7 +51,6 @@ import { remapFileCommentAnnotations, } from "./fileCommentAnnotations"; import { installFileEditorDismissal } from "./fileEditorDismissal"; -import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; import { LocalCommentAnnotation } from "./LocalCommentAnnotation"; import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; import { fileBreadcrumbs } from "./filePath"; @@ -183,53 +182,25 @@ function updateFileLinkReveal(fileContainer: HTMLElement, line: number | null): ?.setAttribute(FILE_LINK_REVEAL_ATTRIBUTE, ""); } -/** - * Frames to keep retrying while the file contents or line metrics are not - * available yet (fresh mounts hydrate asynchronously). - */ -const REVEAL_MAX_ATTEMPTS = 30; -/** - * After scrolling to the target, hold it for a short window so late - * programmatic scroll resets (editable-editor focus and state restoration) - * cannot silently snap the file back to the top. Real user input cancels the - * guard immediately. - */ -const REVEAL_GUARD_FRAMES = 20; -const REVEAL_GUARD_TOLERANCE_PX = 2; - -interface FileRevealState { - frameId: number | null; - cancelGuard: (() => void) | null; - handledRequestId: number | null; - latestRequestId: number | null; -} - function useFileLineReveal( relativePath: string | null, revealLine: number | null, revealRequestId: number, ): FilePostRender { - const [revealStatesByPath] = useState(() => new Map()); + const [handledRequestIdsByPath] = useState(() => new Map()); + const [latestRequestIdsByPath] = useState(() => new Map()); + const [pendingFramesByPath] = useState(() => new Map()); return useCallback( (fileContainer, instance, phase) => { if (relativePath === null) return; - const existingState = revealStatesByPath.get(relativePath); - const state: FileRevealState = existingState ?? { - frameId: null, - cancelGuard: null, - handledRequestId: null, - latestRequestId: null, - }; - if (!existingState) revealStatesByPath.set(relativePath, state); - const cancelPendingReveal = () => { - if (state.frameId !== null) { - cancelAnimationFrame(state.frameId); - state.frameId = null; + const frameId = pendingFramesByPath.get(relativePath); + if (frameId !== undefined) { + cancelAnimationFrame(frameId); + pendingFramesByPath.delete(relativePath); } - state.cancelGuard?.(); }; if (phase === "unmount") { @@ -237,20 +208,18 @@ function useFileLineReveal( return; } - const contents = instance.file?.contents; const targetLine = - revealLine === null || contents === undefined ? null : clampFileLine(contents, revealLine); + revealLine === null ? null : clampFileLine(instance.file?.contents ?? "", revealLine); updateFileLinkReveal(fileContainer, targetLine); if (!(instance instanceof VirtualizedFile)) return; - if (state.latestRequestId !== revealRequestId) { + if (latestRequestIdsByPath.get(relativePath) !== revealRequestId) { cancelPendingReveal(); - state.latestRequestId = revealRequestId; - state.handledRequestId = null; + latestRequestIdsByPath.set(relativePath, revealRequestId); } - if (revealLine === null) { + if (targetLine === null) { fileContainer.style.minHeight = ""; return; } @@ -261,113 +230,54 @@ function useFileLineReveal( Math.max(instance.height, scrollContainer.clientHeight), )}px`; - if (state.handledRequestId === revealRequestId || state.frameId !== null) { + if ( + handledRequestIdsByPath.get(relativePath) === revealRequestId || + pendingFramesByPath.has(relativePath) + ) { return; } - const resolveScrollTarget = (line: number): number | null => { - const linePosition = instance.getLinePosition(line); - if (!linePosition) return null; + const reveal = () => { + pendingFramesByPath.delete(relativePath); + if ( + latestRequestIdsByPath.get(relativePath) !== revealRequestId || + !fileContainer.isConnected + ) { + return; + } + + const linePosition = instance.getLinePosition(targetLine); + if (!linePosition) return; - const scrollContainerRect = scrollContainer.getBoundingClientRect(); const fileTop = scrollContainer.scrollTop + fileContainer.getBoundingClientRect().top - - scrollContainerRect.top; - const root = fileContainer.shadowRoot ?? fileContainer; - const renderedLineElement = root.querySelector(`[data-line="${line}"]`); - const renderedLineRect = renderedLineElement?.getBoundingClientRect(); - - return resolveCenteredFileLineScrollTop({ - scrollTop: scrollContainer.scrollTop, - scrollHeight: scrollContainer.scrollHeight, - viewportTop: scrollContainerRect.top, - viewportHeight: scrollContainer.clientHeight, - fileTop, - estimatedLine: linePosition, - ...(renderedLineRect && renderedLineRect.height > 0 - ? { - renderedLine: { - top: renderedLineRect.top, - height: renderedLineRect.height, - }, - } - : {}), - }); - }; - - const guardScrollTarget = (line: number) => { - let framesLeft = REVEAL_GUARD_FRAMES; - let guardFrameId: number | null = null; - const cancelGuard = () => { - if (guardFrameId !== null) { - cancelAnimationFrame(guardFrameId); - guardFrameId = null; - } - scrollContainer.removeEventListener("wheel", cancelGuard); - scrollContainer.removeEventListener("touchstart", cancelGuard); - scrollContainer.removeEventListener("pointerdown", cancelGuard, true); - window.removeEventListener("keydown", cancelGuard, true); - if (state.cancelGuard === cancelGuard) state.cancelGuard = null; - }; - scrollContainer.addEventListener("wheel", cancelGuard, { passive: true }); - scrollContainer.addEventListener("touchstart", cancelGuard, { passive: true }); - // Pierre stops gutter pointer events from bubbling. Listen in capture - // so starting a comment cancels the reveal guard before the row expands. - scrollContainer.addEventListener("pointerdown", cancelGuard, { - passive: true, - capture: true, - }); - window.addEventListener("keydown", cancelGuard, true); - const holdTarget = () => { - guardFrameId = null; - framesLeft -= 1; - if (framesLeft <= 0 || !scrollContainer.isConnected) { - cancelGuard(); - return; - } - const targetTop = resolveScrollTarget(line); - if ( - targetTop !== null && - Math.abs(scrollContainer.scrollTop - targetTop) > REVEAL_GUARD_TOLERANCE_PX - ) { - scrollContainer.scrollTop = targetTop; - } - guardFrameId = requestAnimationFrame(holdTarget); - }; - guardFrameId = requestAnimationFrame(holdTarget); - state.cancelGuard = cancelGuard; - }; - - const scheduleReveal = (attempt: number) => { - state.frameId = requestAnimationFrame(() => { - state.frameId = null; - if (state.latestRequestId !== revealRequestId || !fileContainer.isConnected) { - return; - } - - // Contents and line metrics can lag the first post-render on fresh - // mounts; clamping against missing contents would scroll to line 1 - // and wrongly mark the request handled. - const currentContents = instance.file?.contents; - const line = - currentContents === undefined ? null : clampFileLine(currentContents, revealLine); - const targetTop = line === null ? null : resolveScrollTarget(line); - if (line === null || targetTop === null) { - if (attempt < REVEAL_MAX_ATTEMPTS) scheduleReveal(attempt + 1); - return; - } - updateFileLinkReveal(fileContainer, line); + scrollContainer.getBoundingClientRect().top; + const centeredTop = Math.max( + 0, + fileTop + + linePosition.top - + Math.max(0, (scrollContainer.clientHeight - linePosition.height) / 2), + ); + const maxScrollTop = Math.max( + 0, + scrollContainer.scrollHeight - scrollContainer.clientHeight, + ); - scrollContainer.scrollTop = targetTop; - state.handledRequestId = revealRequestId; - guardScrollTarget(line); - }); + scrollContainer.scrollTop = Math.min(centeredTop, maxScrollTop); + handledRequestIdsByPath.set(relativePath, revealRequestId); }; - scheduleReveal(0); + pendingFramesByPath.set(relativePath, requestAnimationFrame(reveal)); }, - [revealStatesByPath, relativePath, revealLine, revealRequestId], + [ + handledRequestIdsByPath, + latestRequestIdsByPath, + pendingFramesByPath, + relativePath, + revealLine, + revealRequestId, + ], ); } @@ -1053,8 +963,6 @@ export default function FilePreviewPanel({ environmentId={environmentId} cwd={cwd} projectName={projectName} - selectedPath={relativePath} - selectedPathRevealId={revealRequestId} onOpenFile={onOpenFile} /> diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index febffe3d5ee..06b4717dec0 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -11,7 +11,6 @@ import { useCallback } from "react"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { projectEnvironment } from "~/state/projects"; -import { useProjectPathSearch } from "~/state/queries"; import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; const EMPTY_PROJECT_FILE_PATH = ""; @@ -137,32 +136,6 @@ export function useProjectEntriesQuery( }; } -/** - * Backing query for the project file picker: a debounced, bounded, file-only - * server search. An empty query is a valid request — the index answers it - * with frecency-ordered files, so the picker's initial view is recent files - * without transferring the full workspace listing. `matchedQuery` is the - * query the returned entries were computed for, so the caller can highlight - * against results instead of half-typed input. - */ -export function useProjectFilePickerQuery( - environmentId: EnvironmentId, - cwd: string, - query: string, - limit: number, -) { - const search = useProjectPathSearch({ environmentId, cwd, query, kind: "file" }, limit, { - allowEmptyQuery: true, - }); - - return { - entries: search.isPending ? [] : search.entries, - error: search.error, - isPending: search.isPending, - matchedQuery: search.searchedQuery, - }; -} - export function useProjectFileQuery( environmentId: EnvironmentId, cwd: string, diff --git a/apps/web/src/projectJump.test.ts b/apps/web/src/projectJump.test.ts new file mode 100644 index 00000000000..0af7fc74648 --- /dev/null +++ b/apps/web/src/projectJump.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/models"; +import { parseProjectJumpAction, resolveProjectJumpTarget } from "./projectJump"; + +const environmentId = EnvironmentId.make("local"); +const project = { + id: ProjectId.make("scanner-project"), + environmentId, + title: "Scanner", + workspaceRoot: "/work/scanner", + repositoryIdentity: { + canonicalKey: "github.com/macs-holding/scanner", + locator: { source: "git-remote", remoteName: "origin", remoteUrl: "git@example/scanner.git" }, + owner: "macs-holding", + name: "scanner", + }, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", +} as EnvironmentProject; + +describe("project jumps", () => { + it("matches short names and owner/repository names", () => { + expect(resolveProjectJumpTarget("scanner", [project], [])?.project).toBe(project); + expect(resolveProjectJumpTarget("macs-holding/scanner", [project], [])?.project).toBe(project); + }); + + it("prefers the environment with the latest thread activity", () => { + const newerProject = { + ...project, + id: ProjectId.make("scanner-project-remote"), + environmentId: EnvironmentId.make("remote"), + }; + const threads = [ + { + id: ThreadId.make("latest"), + projectId: newerProject.id, + environmentId: newerProject.environmentId, + archivedAt: null, + updatedAt: "2026-04-01T00:00:00.000Z", + }, + ] as EnvironmentThreadShell[]; + + expect(resolveProjectJumpTarget("scanner", [project, newerProject], threads)?.project).toBe( + newerProject, + ); + }); + + it("defaults unknown actions to reveal", () => { + expect(parseProjectJumpAction(undefined)).toBe("reveal"); + expect(parseProjectJumpAction("latest")).toBe("latest"); + expect(parseProjectJumpAction("new")).toBe("new"); + }); +}); diff --git a/apps/web/src/projectJump.ts b/apps/web/src/projectJump.ts new file mode 100644 index 00000000000..dd7c660e4fb --- /dev/null +++ b/apps/web/src/projectJump.ts @@ -0,0 +1,121 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/models"; + +export type ProjectJumpAction = "reveal" | "latest" | "new"; + +export interface ProjectJumpTarget { + readonly project: EnvironmentProject; + readonly latestThread: EnvironmentThreadShell | null; +} + +function normalizeProjectName(value: string): string { + return decodeURIComponent(value) + .trim() + .replace(/\\/gu, "/") + .replace(/\/+$/gu, "") + .replace(/\.git$/iu, "") + .toLocaleLowerCase(); +} + +function projectNames(project: EnvironmentProject): ReadonlySet { + const identity = project.repositoryIdentity; + const names = [ + project.title, + project.workspaceRoot + .replace(/[\\/]+$/u, "") + .split(/[\\/]/u) + .at(-1), + identity?.canonicalKey, + identity?.displayName, + identity?.name, + identity?.owner && identity.name ? `${identity.owner}/${identity.name}` : undefined, + ...(identity?.remotes?.flatMap((remote) => [ + remote.canonicalKey, + remote.name, + remote.owner && remote.name ? `${remote.owner}/${remote.name}` : undefined, + ]) ?? []), + ]; + + return new Set(names.flatMap((name) => (name ? [normalizeProjectName(name)] : []))); +} + +function latestThreadForProject( + project: EnvironmentProject, + threads: readonly EnvironmentThreadShell[], +): EnvironmentThreadShell | null { + return ( + threads + .filter( + (thread) => + thread.environmentId === project.environmentId && + thread.projectId === project.id && + thread.archivedAt === null, + ) + .toSorted((left, right) => { + const timestampDifference = Date.parse(right.updatedAt) - Date.parse(left.updatedAt); + return timestampDifference !== 0 ? timestampDifference : right.id.localeCompare(left.id); + })[0] ?? null + ); +} + +export function parseProjectJumpAction(value: unknown): ProjectJumpAction { + return value === "latest" || value === "new" ? value : "reveal"; +} + +export function resolveProjectJumpTarget( + rawProjectName: string, + projects: readonly EnvironmentProject[], + threads: readonly EnvironmentThreadShell[], +): ProjectJumpTarget | null { + const projectName = normalizeProjectName(rawProjectName); + if (!projectName) return null; + + const matches = projects + .filter((project) => projectNames(project).has(projectName)) + .map((project) => ({ + project, + latestThread: latestThreadForProject(project, threads), + })); + + return ( + matches.toSorted((left, right) => { + const leftTimestamp = Date.parse(left.latestThread?.updatedAt ?? left.project.updatedAt); + const rightTimestamp = Date.parse(right.latestThread?.updatedAt ?? right.project.updatedAt); + return rightTimestamp - leftTimestamp; + })[0] ?? null + ); +} + +const PROJECT_REVEAL_EVENT = "t3code:reveal-project"; +type ProjectRevealDetail = { readonly environmentId: string; readonly projectId: string }; +let pendingProjectReveal: ProjectRevealDetail | null = null; + +export function revealProjectInSidebar(project: EnvironmentProject): void { + pendingProjectReveal = { environmentId: project.environmentId, projectId: project.id }; + window.dispatchEvent( + new CustomEvent(PROJECT_REVEAL_EVENT, { + detail: pendingProjectReveal, + }), + ); +} + +export function subscribeToProjectReveal( + listener: (detail: ProjectRevealDetail) => void, +): () => void { + const handleEvent = (event: Event) => { + if (!(event instanceof CustomEvent)) return; + const detail = event.detail as { environmentId?: unknown; projectId?: unknown }; + if (typeof detail.environmentId !== "string" || typeof detail.projectId !== "string") return; + pendingProjectReveal = null; + listener({ environmentId: detail.environmentId, projectId: detail.projectId }); + }; + window.addEventListener(PROJECT_REVEAL_EVENT, handleEvent); + if (pendingProjectReveal !== null) { + const detail = pendingProjectReveal; + pendingProjectReveal = null; + listener(detail); + } + return () => window.removeEventListener(PROJECT_REVEAL_EVENT, handleEvent); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 83252974a6d..67642bfa03d 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -24,6 +24,7 @@ import { Route as SettingsBetaRouteImport } from './routes/settings.beta' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance' import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' +import { Route as ChatJumpRouteImport } from './routes/_chat.jump' import { Route as ChatBoardRouteImport } from './routes/_chat.board' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' @@ -102,6 +103,11 @@ const ConnectCallbackRoute = ConnectCallbackRouteImport.update({ path: '/connect/callback', getParentRoute: () => rootRouteImport, } as any) +const ChatJumpRoute = ChatJumpRouteImport.update({ + id: '/jump', + path: '/jump', + getParentRoute: () => ChatRoute, +} as any) const ChatBoardRoute = ChatBoardRouteImport.update({ id: '/board', path: '/board', @@ -125,6 +131,7 @@ export interface FileRoutesByFullPath { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/board': typeof ChatBoardRoute + '/jump': typeof ChatJumpRoute '/connect/callback': typeof ConnectCallbackRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute @@ -143,6 +150,7 @@ export interface FileRoutesByTo { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/board': typeof ChatBoardRoute + '/jump': typeof ChatJumpRoute '/connect/callback': typeof ConnectCallbackRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute @@ -164,6 +172,7 @@ export interface FileRoutesById { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/_chat/board': typeof ChatBoardRoute + '/_chat/jump': typeof ChatJumpRoute '/connect_/callback': typeof ConnectCallbackRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute @@ -186,6 +195,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/board' + | '/jump' | '/connect/callback' | '/settings/appearance' | '/settings/archived' @@ -204,6 +214,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/board' + | '/jump' | '/connect/callback' | '/settings/appearance' | '/settings/archived' @@ -224,6 +235,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/_chat/board' + | '/_chat/jump' | '/connect_/callback' | '/settings/appearance' | '/settings/archived' @@ -354,6 +366,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ConnectCallbackRouteImport parentRoute: typeof rootRouteImport } + '/_chat/jump': { + id: '/_chat/jump' + path: '/jump' + fullPath: '/jump' + preLoaderRoute: typeof ChatJumpRouteImport + parentRoute: typeof ChatRoute + } '/_chat/board': { id: '/_chat/board' path: '/board' @@ -380,6 +399,7 @@ declare module '@tanstack/react-router' { interface ChatRouteChildren { ChatBoardRoute: typeof ChatBoardRoute + ChatJumpRoute: typeof ChatJumpRoute ChatIndexRoute: typeof ChatIndexRoute ChatEnvironmentIdThreadIdRoute: typeof ChatEnvironmentIdThreadIdRoute ChatDraftDraftIdRoute: typeof ChatDraftDraftIdRoute @@ -387,6 +407,7 @@ interface ChatRouteChildren { const ChatRouteChildren: ChatRouteChildren = { ChatBoardRoute: ChatBoardRoute, + ChatJumpRoute: ChatJumpRoute, ChatIndexRoute: ChatIndexRoute, ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute, ChatDraftDraftIdRoute: ChatDraftDraftIdRoute, diff --git a/apps/web/src/routes/_chat.jump.tsx b/apps/web/src/routes/_chat.jump.tsx new file mode 100644 index 00000000000..270454a8155 --- /dev/null +++ b/apps/web/src/routes/_chat.jump.tsx @@ -0,0 +1,87 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useEffect, useMemo, useRef } from "react"; + +import { Button } from "../components/ui/button"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; +import { SidebarInset } from "../components/ui/sidebar"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { + parseProjectJumpAction, + resolveProjectJumpTarget, + revealProjectInSidebar, +} from "../projectJump"; +import { + useAllEnvironmentShellsBootstrapped, + useProjects, + useThreadShells, +} from "../state/entities"; +import { buildThreadRouteParams } from "../threadRoutes"; + +function ProjectJumpRoute() { + const search = Route.useSearch(); + const projects = useProjects(); + const threads = useThreadShells(); + const bootstrapped = useAllEnvironmentShellsBootstrapped(); + const handleNewThread = useNewThreadHandler(); + const navigate = useNavigate(); + const handledKeyRef = useRef(null); + const action = parseProjectJumpAction(search.action); + const target = useMemo( + () => resolveProjectJumpTarget(search.project ?? "", projects, threads), + [projects, search.project, threads], + ); + const handledKey = `${search.project ?? ""}:${action}`; + + useEffect(() => { + if (!bootstrapped || target === null || handledKeyRef.current === handledKey) return; + handledKeyRef.current = handledKey; + + if (action === "latest" && target.latestThread !== null) { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams({ + environmentId: target.latestThread.environmentId, + threadId: target.latestThread.id, + }), + replace: true, + }); + return; + } + + if (action === "new" || action === "latest") { + void handleNewThread(scopeProjectRef(target.project.environmentId, target.project.id), { + replace: true, + }); + return; + } + + revealProjectInSidebar(target.project); + }, [action, bootstrapped, handleNewThread, handledKey, navigate, target]); + + if (!bootstrapped || target !== null) return null; + + return ( + + + + Project not found + + No project matches “{search.project || "(empty)"}” in a connected environment. + + + + + + ); +} + +export const Route = createFileRoute("/_chat/jump")({ + validateSearch: (search: Record) => ({ + project: typeof search.project === "string" ? search.project : undefined, + action: typeof search.action === "string" ? search.action : undefined, + }), + component: ProjectJumpRoute, +}); diff --git a/apps/web/src/state/projects.ts b/apps/web/src/state/projects.ts index d4e1098a364..7a879988328 100644 --- a/apps/web/src/state/projects.ts +++ b/apps/web/src/state/projects.ts @@ -1,24 +1,11 @@ import { createEnvironmentProjectAtoms } from "@t3tools/client-runtime/state/projects"; import { createProjectEnvironmentAtoms } from "@t3tools/client-runtime/state/projects"; -import { createEnvironmentRpcQueryAtomFamily } from "@t3tools/client-runtime/state/runtime"; -import { WS_METHODS } from "@t3tools/contracts"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { environmentSnapshotAtom } from "./shell"; export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime); -/** - * Web-only: project content search backs the ⇧⌘F dialog, which has no mobile - * surface, so the atom family lives here instead of the shared client-runtime - * project atoms consumed by the mobile app. - */ -export const projectContentSearch = createEnvironmentRpcQueryAtomFamily(connectionAtomRuntime, { - label: "environment-data:projects:search-contents", - tag: WS_METHODS.projectsSearchContents, - staleTimeMs: 5_000, - idleTtlMs: 60_000, -}); export const environmentProjects = createEnvironmentProjectAtoms({ catalogValueAtom: environmentCatalog.catalogValueAtom, snapshotAtom: environmentSnapshotAtom, diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 2a095b8f584..a9564c2fd64 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -12,8 +12,6 @@ import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; import type { EnvironmentId, OrchestrationThread, - ProjectContentMatch, - ProjectEntryKind, ThreadId, VcsListRefsResult, VcsRef, @@ -26,19 +24,16 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { orchestrationEnvironment } from "./orchestration"; import { isPaginatedBranchesNextPagePending } from "./paginatedBranches"; -import { projectContentSearch, projectEnvironment } from "./projects"; +import { projectEnvironment } from "./projects"; import { useEnvironmentQuery } from "./query"; import { useEnvironmentThread } from "./threads"; import { vcsEnvironment } from "./vcs"; -const PROJECT_PATH_SEARCH_DEBOUNCE_MS = 120; +const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120; const COMPOSER_PATH_SEARCH_LIMIT = 80; -const PROJECT_CONTENT_SEARCH_DEBOUNCE_MS = 120; -const PROJECT_CONTENT_SEARCH_LIMIT = 500; const THREAD_SEARCH_DEBOUNCE_MS = 200; const VCS_REF_LIST_LIMIT = 100; const EMPTY_REFS: ReadonlyArray = []; -const EMPTY_CONTENT_MATCHES: ReadonlyArray = []; const INITIAL_BRANCH_CURSORS = [undefined] as const; const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray = Object.freeze([]); const EMPTY_THREAD_SEARCH_ATOM = Atom.make({ @@ -234,50 +229,26 @@ export function usePaginatedBranches(target: VcsRefTarget) { }; } -type ProjectPathSearchTarget = ComposerPathSearchTarget & { - readonly kind?: ProjectEntryKind | undefined; -}; - -export function areProjectPathSearchTargetsEqual( - left: ProjectPathSearchTarget, - right: ProjectPathSearchTarget, -): boolean { - return ( - left.environmentId === right.environmentId && - left.cwd === right.cwd && - left.query === right.query && - left.kind === right.kind - ); -} - -export function useProjectPathSearch( - target: ProjectPathSearchTarget, - limit: number, - options?: { readonly allowEmptyQuery?: boolean }, -) { - const allowEmptyQuery = options?.allowEmptyQuery === true; +export function useComposerPathSearch(target: ComposerPathSearchTarget) { const normalizedTarget = useMemo( () => ({ environmentId: target.environmentId, cwd: target.cwd, - query: target.query == null ? null : target.query.trim(), - kind: target.kind, + query: target.query?.trim() ?? "", }), - [target.cwd, target.environmentId, target.kind, target.query], + [target.cwd, target.environmentId, target.query], ); - const debouncedTarget = useDebouncedValue(normalizedTarget, PROJECT_PATH_SEARCH_DEBOUNCE_MS); + const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS); const result = useEnvironmentQuery( debouncedTarget.environmentId !== null && debouncedTarget.cwd !== null && - debouncedTarget.query !== null && - (allowEmptyQuery || debouncedTarget.query.length > 0) + debouncedTarget.query.length > 0 ? projectEnvironment.searchEntries({ environmentId: debouncedTarget.environmentId, input: { cwd: debouncedTarget.cwd, query: debouncedTarget.query, - limit, - ...(debouncedTarget.kind ? { kind: debouncedTarget.kind } : {}), + limit: COMPOSER_PATH_SEARCH_LIMIT, }, }) : null, @@ -286,61 +257,11 @@ export function useProjectPathSearch( return { entries: result.data?.entries ?? [], error: result.error, - isPending: - !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) || result.isPending, - searchedQuery: debouncedTarget.query ?? "", + isPending: normalizedTarget.query !== debouncedTarget.query || result.isPending, refresh: result.refresh, }; } -export function useComposerPathSearch(target: ComposerPathSearchTarget) { - return useProjectPathSearch(target, COMPOSER_PATH_SEARCH_LIMIT); -} - -interface ProjectContentSearchTarget { - readonly environmentId: EnvironmentId | null; - readonly cwd: string | null; - readonly query: string; - readonly caseSensitive: boolean; - readonly wholeWord: boolean; - readonly useRegex: boolean; -} - -export function useProjectContentSearch(target: ProjectContentSearchTarget) { - // Whitespace is significant in content queries; trimming is only used to - // decide whether the input is blank. - const query = target.query; - const hasQuery = query.trim().length > 0; - const debouncedQuery = useDebouncedValue(query, PROJECT_CONTENT_SEARCH_DEBOUNCE_MS); - const result = useEnvironmentQuery( - target.environmentId !== null && - target.cwd !== null && - hasQuery && - debouncedQuery.trim().length > 0 - ? projectContentSearch({ - environmentId: target.environmentId, - input: { - cwd: target.cwd, - query: debouncedQuery, - limit: PROJECT_CONTENT_SEARCH_LIMIT, - caseSensitive: target.caseSensitive, - wholeWord: target.wholeWord, - useRegex: target.useRegex, - }, - }) - : null, - ); - - return { - matches: result.data?.matches ?? EMPTY_CONTENT_MATCHES, - error: result.error, - isPending: hasQuery && (query !== debouncedQuery || result.isPending), - hasQuery, - truncated: result.data?.truncated ?? false, - invalidRegex: target.useRegex && result.data?.regexFallbackError !== undefined, - }; -} - export function useCheckpointDiff( target: CheckpointDiffTarget, options?: { readonly enabled?: boolean }, diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index 9a63f22c9ef..a8fa565cef4 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -1,5 +1,4 @@ import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; @@ -21,30 +20,6 @@ export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximu export const IsoDateTime = Schema.String; export type IsoDateTime = typeof IsoDateTime.Type; -/** - * Wire codec for server→client arrays whose element unions grow over time - * (new literal members, new struct variants). Decoding drops elements the - * current build cannot decode instead of failing the whole payload — a client - * has to keep decoding configs sent by servers newer than itself, and - * rejecting the payload would take down the connection over data the client - * couldn't act on anyway. Encoding is the plain array encoding. - */ -export const ForwardCompatibleArray = (element: Element) => { - const decodeElement = Schema.decodeUnknownOption(element as never); - return Schema.Array(Schema.Unknown).pipe( - Schema.decodeTo( - Schema.Array(element), - SchemaTransformation.transform, ReadonlyArray>({ - decode: (values) => - values.filter((value) => Option.isSome(decodeElement(value))) as ReadonlyArray< - Element["Encoded"] - >, - encode: (values) => values, - }), - ), - ); -}; - /** * Construct a branded identifier. Enforces non-empty trimmed strings */ diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index ec8c839be95..33ecd38039f 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -20,7 +20,6 @@ const decode = ( >; const decodeResolvedRule = Schema.decodeUnknownEffect(ResolvedKeybindingRule as never); -const encodeResolvedKeybindings = Schema.encodeEffect(ResolvedKeybindingsConfig); it.effect("parses keybinding rules", () => Effect.gen(function* () { @@ -60,18 +59,6 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedCommandPalette.command, "commandPalette.toggle"); - const parsedFilePicker = yield* decode(KeybindingRule, { - key: "mod+p", - command: "filePicker.toggle", - }); - assert.strictEqual(parsedFilePicker.command, "filePicker.toggle"); - - const parsedProjectSearch = yield* decode(KeybindingRule, { - key: "mod+shift+f", - command: "projectSearch.toggle", - }); - assert.strictEqual(parsedProjectSearch.command, "projectSearch.toggle"); - const parsedLocal = yield* decode(KeybindingRule, { key: "mod+shift+n", command: "chat.newLocal", @@ -186,70 +173,6 @@ it.effect("parses resolved keybindings arrays", () => }), ); -const shortcut = { - key: "p", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, -}; - -it.effect("drops resolved rules with commands this build does not know", () => - Effect.gen(function* () { - const parsed = yield* decode(ResolvedKeybindingsConfig, [ - { command: "terminal.toggle", shortcut }, - { command: "someFuture.toggle", shortcut }, - { command: "filePicker.toggle", shortcut }, - ]); - assert.deepEqual( - parsed.map((rule) => rule.command), - ["terminal.toggle", "filePicker.toggle"], - ); - }), -); - -it.effect("drops resolved rules with unknown when-node types", () => - Effect.gen(function* () { - const parsed = yield* decode(ResolvedKeybindingsConfig, [ - { - command: "terminal.toggle", - shortcut, - whenAst: { type: "xor", left: 1, right: 2 }, - }, - { command: "terminal.split", shortcut }, - ]); - assert.deepEqual( - parsed.map((rule) => rule.command), - ["terminal.split"], - ); - }), -); - -it.effect("drops malformed resolved rule entries", () => - Effect.gen(function* () { - const parsed = yield* decode(ResolvedKeybindingsConfig, [ - "garbage", - { command: "terminal.toggle", shortcut }, - null, - ]); - assert.deepEqual( - parsed.map((rule) => rule.command), - ["terminal.toggle"], - ); - }), -); - -it.effect("encodes resolved keybindings to the plain wire shape", () => - Effect.gen(function* () { - const rules = [{ command: "terminal.toggle" as const, shortcut }]; - const encoded = yield* encodeResolvedKeybindings(rules); - assert.deepEqual(encoded, rules); - const roundTripped = yield* decode(ResolvedKeybindingsConfig, encoded); - assert.deepEqual(roundTripped, rules); - }), -); - it.effect("drops unknown fields in resolved keybinding rules", () => decodeResolvedRule({ command: "terminal.toggle", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index eba8f8ef170..f000648d236 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -1,5 +1,5 @@ import * as Schema from "effect/Schema"; -import { ForwardCompatibleArray, TrimmedString } from "./baseSchemas.ts"; +import { TrimmedString } from "./baseSchemas.ts"; export const MAX_KEYBINDING_VALUE_LENGTH = 64; export const MAX_KEYBINDING_WHEN_LENGTH = 256; @@ -63,8 +63,6 @@ const STATIC_KEYBINDING_COMMANDS = [ "preview.zoomOut", "preview.resetZoom", "commandPalette.toggle", - "filePicker.toggle", - "projectSearch.toggle", "composer.stash", "board.open", "chat.new", @@ -156,14 +154,7 @@ export const ResolvedKeybindingRule = Schema.Struct({ }).annotate({ parseOptions: { onExcessProperty: "ignore" } }); export type ResolvedKeybindingRule = typeof ResolvedKeybindingRule.Type; -/** - * The command set grows over time, so a client may receive rules it cannot - * represent (a command or `when` node added after that client shipped). - * Decoding drops those rules instead of failing the whole payload — - * rejecting the config would take down the connection over a shortcut the - * client couldn't dispatch anyway. - */ -export const ResolvedKeybindingsConfig = ForwardCompatibleArray(ResolvedKeybindingRule).check( +export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).check( Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), ); export type ResolvedKeybindingsConfig = typeof ResolvedKeybindingsConfig.Type; diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts index 8e6771cba88..ea9d5a90e7c 100644 --- a/packages/contracts/src/project.test.ts +++ b/packages/contracts/src/project.test.ts @@ -3,40 +3,10 @@ import { describe, expect, it } from "vite-plus/test"; import { ProjectReadFileError, - ProjectSearchContentsError, - ProjectSearchContentsInput, ProjectSearchEntriesError, - ProjectSearchEntriesInput, ProjectWriteFileError, } from "./project.ts"; -const decodeSearchEntriesInput = Schema.decodeUnknownSync(ProjectSearchEntriesInput); -const decodeSearchContentsInput = Schema.decodeUnknownSync(ProjectSearchContentsInput); - -describe("project search inputs", () => { - it("allows an empty entries query for bounded frecency browsing", () => { - const decoded = decodeSearchEntriesInput({ - cwd: "/workspace", - query: " ", - limit: 10, - kind: "file", - }); - expect(decoded.query).toBe(""); - }); - - it("preserves whitespace in content search queries", () => { - const decoded = decodeSearchContentsInput({ - cwd: "/workspace", - query: " foo ", - limit: 10, - caseSensitive: false, - wholeWord: false, - useRegex: false, - }); - expect(decoded.query).toBe(" foo "); - }); -}); - describe("project RPC errors", () => { it("derives stable messages from structured request context while retaining causes", () => { const cause = new Error("sensitive platform detail"); @@ -69,18 +39,6 @@ describe("project RPC errors", () => { expect(readError.message).toBe("Failed to read workspace file 'src/index.ts' in '/workspace'."); expect(readError.message).not.toContain(cause.message); expect(readError.cause).toBe(cause); - - const contentSearchError = new ProjectSearchContentsError({ - cwd: "/workspace", - queryLength: "authorization: Bearer secret-token".length, - limit: 100, - failure: "search_index_search_failed", - cause, - }); - expect(contentSearchError.message).toBe("Failed to search workspace contents in '/workspace'."); - expect(contentSearchError.message).not.toContain(cause.message); - expect(contentSearchError).not.toHaveProperty("query"); - expect(contentSearchError.cause).toBe(cause); }); it("decodes legacy message-only errors during rolling upgrades", () => { diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 252cb34b1ed..f6b54d796c3 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -1,29 +1,19 @@ import * as Schema from "effect/Schema"; -import { - NonNegativeInt, - PositiveInt, - TrimmedNonEmptyString, - TrimmedString, -} from "./baseSchemas.ts"; +import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; -const PROJECT_SEARCH_CONTENTS_MAX_LIMIT = 500; const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512; const PROJECT_READ_FILE_PATH_MAX_LENGTH = 512; -export const ProjectEntryKind = Schema.Literals(["file", "directory"]); -export type ProjectEntryKind = typeof ProjectEntryKind.Type; - export const ProjectSearchEntriesInput = Schema.Struct({ cwd: TrimmedNonEmptyString, - // An empty query is a bounded browse: the index returns frecency-ordered - // entries, which the file picker uses for its initial results. - query: TrimmedString.check(Schema.isMaxLength(256)), + query: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT)), - kind: Schema.optional(ProjectEntryKind), }); export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type; +const ProjectEntryKind = Schema.Literals(["file", "directory"]); + export const ProjectEntry = Schema.Struct({ path: TrimmedNonEmptyString, kind: ProjectEntryKind, @@ -36,39 +26,6 @@ export const ProjectSearchEntriesResult = Schema.Struct({ }); export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type; -export const ProjectSearchContentsInput = Schema.Struct({ - cwd: TrimmedNonEmptyString, - // Whitespace is significant in content queries (" foo", regex trailing - // spaces), so the query is deliberately not trimmed on the wire. - query: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(256)), - limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_CONTENTS_MAX_LIMIT)), - caseSensitive: Schema.Boolean, - wholeWord: Schema.Boolean, - useRegex: Schema.Boolean, -}); -export type ProjectSearchContentsInput = typeof ProjectSearchContentsInput.Type; - -export const ProjectContentMatchRange = Schema.Struct({ - start: NonNegativeInt, - end: NonNegativeInt, -}); -export type ProjectContentMatchRange = typeof ProjectContentMatchRange.Type; - -export const ProjectContentMatch = Schema.Struct({ - path: TrimmedNonEmptyString, - lineNumber: PositiveInt, - lineContent: Schema.String, - matchRanges: Schema.Array(ProjectContentMatchRange), -}); -export type ProjectContentMatch = typeof ProjectContentMatch.Type; - -export const ProjectSearchContentsResult = Schema.Struct({ - matches: Schema.Array(ProjectContentMatch), - truncated: Schema.Boolean, - regexFallbackError: Schema.optional(Schema.String), -}); -export type ProjectSearchContentsResult = typeof ProjectSearchContentsResult.Type; - export const ProjectListEntriesInput = Schema.Struct({ cwd: TrimmedNonEmptyString, }); @@ -137,37 +94,6 @@ export class ProjectSearchEntriesError extends Schema.TaggedErrorClass()( - "ProjectSearchContentsError", - { - cwd: Schema.optional(TrimmedNonEmptyString), - queryLength: Schema.optional(NonNegativeInt), - limit: Schema.optional(PositiveInt), - failure: Schema.optional(ProjectEntriesFailure), - normalizedCwd: Schema.optional(TrimmedNonEmptyString), - timeout: Schema.optional(TrimmedNonEmptyString), - detail: Schema.optional(TrimmedNonEmptyString), - message: TrimmedNonEmptyString, - cause: Schema.optional(Schema.Defect()), - }, -) { - // @effect-diagnostics-next-line overriddenSchemaConstructor:off - constructor( - props: ProjectEntriesFailureContext & { - readonly cwd: string; - readonly queryLength: number; - readonly limit: number; - }, - ) { - super({ - ...props, - message: - decodedProjectErrorMessage(props) ?? - `Failed to search workspace contents in '${props.cwd}'.`, - } as any); - } -} - export class ProjectListEntriesError extends Schema.TaggedErrorClass()( "ProjectListEntriesError", { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index b78069acbac..231808719af 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -90,9 +90,6 @@ import { ProjectReadFileError, ProjectReadFileInput, ProjectReadFileResult, - ProjectSearchContentsError, - ProjectSearchContentsInput, - ProjectSearchContentsResult, ProjectSearchEntriesError, ProjectSearchEntriesInput, ProjectSearchEntriesResult, @@ -477,12 +474,6 @@ export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntr error: Schema.Union([ProjectSearchEntriesError, EnvironmentAuthorizationError]), }); -export const WsProjectsSearchContentsRpc = Rpc.make(WS_METHODS.projectsSearchContents, { - payload: ProjectSearchContentsInput, - success: ProjectSearchContentsResult, - error: Schema.Union([ProjectSearchContentsError, EnvironmentAuthorizationError]), -}); - export const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, { payload: ProjectListEntriesInput, success: ProjectListEntriesResult, diff --git a/packages/shared/src/projectFavicon.test.ts b/packages/shared/src/projectFavicon.test.ts index 1df17cc7fe5..0011b2fc7c9 100644 --- a/packages/shared/src/projectFavicon.test.ts +++ b/packages/shared/src/projectFavicon.test.ts @@ -1,32 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; -import { - getProjectFaviconCacheKey, - isProjectFaviconFallbackUrl, - PROJECT_FAVICON_FALLBACK_MARKER, -} from "./projectFavicon.ts"; +import { isProjectFaviconFallbackUrl, PROJECT_FAVICON_FALLBACK_MARKER } from "./projectFavicon.ts"; describe("project favicon", () => { - it("uses the project and versioned filename as the cache identity", () => { - const firstUrl = "https://environment.example/api/assets/first-signed-token/v1-20-favicon.svg"; - const refreshedUrl = - "https://environment.example/api/assets/refreshed-signed-token/v1-20-favicon.svg"; - - expect(getProjectFaviconCacheKey("environment-1", "/workspace", firstUrl)).toBe( - getProjectFaviconCacheKey("environment-1", "/workspace", refreshedUrl), - ); - expect(getProjectFaviconCacheKey("environment-1", "/workspace", firstUrl)).not.toBe( - getProjectFaviconCacheKey( - "environment-1", - "/workspace", - "https://environment.example/api/assets/refreshed-signed-token/v2-20-favicon.svg", - ), - ); - expect(getProjectFaviconCacheKey("environment-1", "/workspace", firstUrl)).not.toBe( - getProjectFaviconCacheKey("environment-2", "/workspace", firstUrl), - ); - }); - it("identifies fallback asset URLs by their dedicated filename", () => { expect( isProjectFaviconFallbackUrl( diff --git a/packages/shared/src/projectFavicon.ts b/packages/shared/src/projectFavicon.ts index eebc1a8a1b6..2e46429b6c1 100644 --- a/packages/shared/src/projectFavicon.ts +++ b/packages/shared/src/projectFavicon.ts @@ -1,22 +1,5 @@ export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing"; -export function getProjectFaviconCacheKey( - environmentId: string, - workspaceRoot: string, - url: string, -) { - let revision = url; - - try { - const pathname = new URL(url, "https://t3.invalid").pathname; - revision = pathname.slice(pathname.lastIndexOf("/") + 1); - } catch { - // Keep the full value as a safe fallback for malformed URLs. - } - - return JSON.stringify([environmentId, workspaceRoot, revision]); -} - export function isProjectFaviconFallbackUrl(url: string | null | undefined): boolean { if (!url) return false; diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3f13f1872f7..49a3bd44351 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,28 +24,28 @@ allowBuilds: catalog: dfx: 1.0.15 - "@clerk/backend": 3.14.0 - "@clerk/clerk-js": 6.25.12 - "@clerk/electron": 0.0.24 + "@clerk/backend": 3.13.0 + "@clerk/clerk-js": 6.25.7 + "@clerk/electron": 0.0.18 "@clerk/electron-passkeys": 0.0.3 - "@clerk/expo": 4.1.2 - "@clerk/react": 6.12.9 - "@clerk/shared": 4.25.9 - "@effect/atom-react": 4.0.0-beta.103 - "@effect/openapi-generator": 4.0.0-beta.103 - "@effect/platform-bun": 4.0.0-beta.103 - "@effect/platform-node": 4.0.0-beta.103 - "@effect/platform-node-shared": 4.0.0-beta.103 - "@effect/sql-pg": 4.0.0-beta.103 - "@effect/sql-sqlite-bun": 4.0.0-beta.103 + "@clerk/expo": 4.0.2 + "@clerk/react": 6.12.7 + "@clerk/shared": 4.25.7 + "@effect/atom-react": 4.0.0-beta.102 + "@effect/openapi-generator": 4.0.0-beta.102 + "@effect/platform-bun": 4.0.0-beta.102 + "@effect/platform-node": 4.0.0-beta.102 + "@effect/platform-node-shared": 4.0.0-beta.102 + "@effect/sql-pg": 4.0.0-beta.102 + "@effect/sql-sqlite-bun": 4.0.0-beta.102 "@effect/tsgo": 0.13.2 - "@effect/vitest": 4.0.0-beta.103 + "@effect/vitest": 4.0.0-beta.102 "@noble/curves": 1.9.1 "@noble/hashes": 1.8.0 "@pierre/diffs": 1.3.0-beta.10 "@types/node": 24.12.4 "@typescript/native-preview": 7.0.0-dev.20260604.1 - effect: 4.0.0-beta.103 + effect: 4.0.0-beta.102 jose: 6.2.2 typescript: ~6.0.3 vite: npm:@voidzero-dev/vite-plus-core@0.2.2 @@ -53,28 +53,28 @@ catalog: yaml: ^2.9.0 minimumReleaseAgeExclude: - - "@clerk/backend@3.14.0" - - "@clerk/clerk-js@6.25.12" - - "@clerk/electron@0.0.24" - - "@clerk/expo@4.1.2" - - "@clerk/react@6.12.9" - - "@clerk/shared@4.25.9" + - "@clerk/backend@3.13.0" + - "@clerk/clerk-js@6.25.7" + - "@clerk/electron@0.0.18" + - "@clerk/expo@4.0.2" + - "@clerk/react@6.12.7" + - "@clerk/shared@4.25.7" - "@distilled.cloud/aws@0.30.2" - "@distilled.cloud/axiom@0.30.2" - "@distilled.cloud/cloudflare@0.30.2" - "@distilled.cloud/core@0.30.2" - "@distilled.cloud/neon@0.30.2" - "@distilled.cloud/planetscale@0.30.2" - - "@effect/atom-react@4.0.0-beta.103" - - "@effect/openapi-generator@4.0.0-beta.103" - - "@effect/platform-bun@4.0.0-beta.103" - - "@effect/platform-node-shared@4.0.0-beta.103" - - "@effect/platform-node@4.0.0-beta.103" - - "@effect/sql-pg@4.0.0-beta.103" - - "@effect/sql-sqlite-bun@4.0.0-beta.103" - - "@effect/vitest@4.0.0-beta.103" + - "@effect/atom-react@4.0.0-beta.102" + - "@effect/openapi-generator@4.0.0-beta.102" + - "@effect/platform-bun@4.0.0-beta.102" + - "@effect/platform-node-shared@4.0.0-beta.102" + - "@effect/platform-node@4.0.0-beta.102" + - "@effect/sql-pg@4.0.0-beta.102" + - "@effect/sql-sqlite-bun@4.0.0-beta.102" + - "@effect/vitest@4.0.0-beta.102" - alchemy@2.0.0-beta.65 - - effect@4.0.0-beta.103 + - effect@4.0.0-beta.102 overrides: "@clerk/backend": "catalog:" @@ -123,14 +123,16 @@ packageExtensions: vite: "catalog:" patchedDependencies: - "@effect/vitest@4.0.0-beta.103": patches/@effect__vitest@4.0.0-beta.103.patch + "@effect/platform-bun@4.0.0-beta.102": patches/@effect__platform-bun@4.0.0-beta.102.patch + "@effect/platform-node@4.0.0-beta.102": patches/@effect__platform-node@4.0.0-beta.102.patch + "@effect/vitest@4.0.0-beta.102": patches/@effect__vitest@4.0.0-beta.102.patch "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch "@legendapp/list@3.3.3": patches/@legendapp__list@3.3.3.patch "@pierre/diffs@1.3.0-beta.10": patches/@pierre%2Fdiffs@1.3.0-beta.10.patch "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch "@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch - effect@4.0.0-beta.103: patches/effect@4.0.0-beta.103.patch + effect@4.0.0-beta.102: patches/effect@4.0.0-beta.102.patch expo-modules-jsi@56.0.10: patches/expo-modules-jsi@56.0.10.patch react-native-gesture-handler@2.31.2: patches/react-native-gesture-handler@2.31.2.patch react-native-keyboard-controller@1.21.13: patches/react-native-keyboard-controller@1.21.13.patch diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 2df3a5b1adb..50b86cfa3b4 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -386,11 +386,6 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.notProperty(mac, "asarUnpack"); assert.notProperty(linux, "asarUnpack"); assert.deepStrictEqual(win.asarUnpack, WINDOWS_ASAR_UNPACK); - // Linux must register the renderer schemes so the generated .desktop - // entry advertises MimeType=x-scheme-handler/t3code; for OAuth deep links. - assert.deepStrictEqual((linux.linux as Record).protocols, [ - { name: "T3 Code", schemes: ["t3code", "t3code-dev"] }, - ]); for (const config of [mac, linux, win]) { assert.deepStrictEqual(config.electronLanguages, DESKTOP_ELECTRON_LANGUAGES); assert.deepStrictEqual(config.files, DESKTOP_FILE_EXCLUSIONS); diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 35318a4f37c..2f530eec29a 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -1621,20 +1621,20 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( executableName: "t3code", icon: "icons", category: "Development", - // electron-builder turns these into MimeType=x-scheme-handler/; - // in the .desktop entry (Exec already gets %U), so browsers can hand - // t3code:// OAuth callbacks to the app. - protocols: [ - { - name: "T3 Code", - schemes: ["t3code", "t3code-dev"], - }, - ], desktop: { entry: { StartupWMClass: "t3code", + // Register the external deep-link scheme so xdg-open can launch T3. + // electron-builder keeps %U on Exec when MimeType is present. + MimeType: "x-scheme-handler/t3code;", }, }, + protocols: [ + { + name: "T3 Code", + schemes: ["t3code"], + }, + ], }; } From 7e0c4c105c1bf095a5bbe1f7883e0051b342ce9b Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:23:40 +0200 Subject: [PATCH 2/8] fix(desktop): keep upstream Clerk catalog after reapply Restore pnpm-workspace.yaml catalog pins from fork/changes so frozen install matches the lockfile (avoid reapply-era 3.13.x clerk downgrade). --- pnpm-workspace.yaml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 49a3bd44351..a6998716f8d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,13 +24,13 @@ allowBuilds: catalog: dfx: 1.0.15 - "@clerk/backend": 3.13.0 - "@clerk/clerk-js": 6.25.7 - "@clerk/electron": 0.0.18 + "@clerk/backend": 3.14.0 + "@clerk/clerk-js": 6.25.12 + "@clerk/electron": 0.0.24 "@clerk/electron-passkeys": 0.0.3 - "@clerk/expo": 4.0.2 - "@clerk/react": 6.12.7 - "@clerk/shared": 4.25.7 + "@clerk/expo": 4.1.2 + "@clerk/react": 6.12.9 + "@clerk/shared": 4.25.9 "@effect/atom-react": 4.0.0-beta.102 "@effect/openapi-generator": 4.0.0-beta.102 "@effect/platform-bun": 4.0.0-beta.102 @@ -53,12 +53,12 @@ catalog: yaml: ^2.9.0 minimumReleaseAgeExclude: - - "@clerk/backend@3.13.0" - - "@clerk/clerk-js@6.25.7" - - "@clerk/electron@0.0.18" - - "@clerk/expo@4.0.2" - - "@clerk/react@6.12.7" - - "@clerk/shared@4.25.7" + - "@clerk/backend@3.14.0" + - "@clerk/clerk-js@6.25.12" + - "@clerk/electron@0.0.24" + - "@clerk/expo@4.1.2" + - "@clerk/react@6.12.9" + - "@clerk/shared@4.25.9" - "@distilled.cloud/aws@0.30.2" - "@distilled.cloud/axiom@0.30.2" - "@distilled.cloud/cloudflare@0.30.2" From 16d2cbf09eb136373388d5ea973fb9ccdc60e1a8 Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:26:57 +0200 Subject: [PATCH 3/8] fix(desktop): type ClerkBridge mocks with isPrimaryInstance @clerk/electron 0.0.24 requires isPrimaryInstance on ClerkBridge; the overlay test mocks still returned only cleanup, failing desktop typecheck on integration. --- apps/desktop/src/app/DesktopClerk.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 2e1058b878a..ca606630c1a 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -60,7 +60,7 @@ describe("DesktopClerk", () => { it.effect("acquires and releases the SDK bridge with the layer", () => { const cleanup = vi.fn(); storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockReturnValue({ cleanup }); + createClerkBridgeMock.mockReturnValue({ cleanup, isPrimaryInstance: true }); return Effect.gen(function* () { yield* Effect.scoped(Layer.build(makeDesktopClerkLayer())); @@ -108,6 +108,7 @@ describe("DesktopClerk", () => { cleanup: () => { throw cause; }, + isPrimaryInstance: true, }); return Effect.gen(function* () { @@ -132,7 +133,7 @@ describe("DesktopClerk", () => { { isDevelopment: true, scheme: "t3code-dev" }, { isDevelopment: false, scheme: "t3code" }, ])("configures the SDK with the $scheme renderer origin", ({ isDevelopment, scheme }) => { - const bridge = { cleanup: vi.fn() }; + const bridge = { cleanup: vi.fn(), isPrimaryInstance: true }; storageMock.mockReturnValue(storageAdapter); createClerkBridgeMock.mockReturnValue(bridge); @@ -155,7 +156,7 @@ describe("DesktopClerk", () => { "wires second-instance argv into deep links and registers the protocol when packaged", () => { storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn() }); + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); return Effect.gen(function* () { const handledArgv = yield* Ref.make>([]); @@ -259,7 +260,7 @@ describe("DesktopClerk", () => { it.effect("does not register the OS protocol client in development", () => { storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn() }); + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); return Effect.gen(function* () { let protocolClientRegistered = false; From 6c63199f65e1ae100c2e3b84ce125c94f4b8f56d Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:04:26 +0200 Subject: [PATCH 4/8] fix(desktop): include requestSingleInstanceLock in update test stubs Desktop overlay ElectronApp exposes requestSingleInstanceLock; compose fails typecheck when the shared mock omits it. --- apps/desktop/src/updates/DesktopUpdates.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index f6437fb1fff..1d0bce52864 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -130,6 +130,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { setName: () => Effect.void, setAboutPanelOptions: () => Effect.void, setAppUserModelId: () => Effect.void, + requestSingleInstanceLock: Effect.succeed(true), isDefaultProtocolClient: () => Effect.succeed(false), setAsDefaultProtocolClient: () => Effect.succeed(false), setDesktopName: () => Effect.void, From 67ea53d222ae54d1d2ad69ef4e2f9b5eaefe7ef3 Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:08:25 +0200 Subject: [PATCH 5/8] fix(desktop): stop overlay reapply from stripping project search contracts The path-filtered desktop reapply carried a stale contracts/web tree that dropped ProjectContentMatch and related search RPC surfaces. Restore those from fork/changes so compose no longer regresses web typecheck. --- .../src/workspace/WorkspaceEntries.test.ts | 344 +++++++++++++++++- apps/server/src/workspace/WorkspaceEntries.ts | 105 ++++-- .../workspace/WorkspaceSearchIndex.test.ts | 143 +++++++- .../src/workspace/WorkspaceSearchIndex.ts | 315 ++++++++++++++-- apps/server/src/ws.ts | 18 + .../src/components/CommandPalette.logic.ts | 2 +- apps/web/src/components/ProjectFavicon.tsx | 61 +++- .../components/desktopUpdate.logic.test.ts | 18 + .../web/src/components/desktopUpdate.logic.ts | 18 + .../src/components/files/FileBrowserPanel.tsx | 72 ++++ .../src/components/files/FilePreviewPanel.tsx | 188 +++++++--- .../files/projectFilesQueryState.ts | 27 ++ apps/web/src/state/projects.ts | 13 + apps/web/src/state/queries.ts | 97 ++++- packages/contracts/src/baseSchemas.ts | 25 ++ packages/contracts/src/keybindings.test.ts | 77 ++++ packages/contracts/src/keybindings.ts | 13 +- packages/contracts/src/project.test.ts | 42 +++ packages/contracts/src/project.ts | 82 ++++- packages/contracts/src/rpc.ts | 9 + packages/shared/src/projectFavicon.test.ts | 26 +- packages/shared/src/projectFavicon.ts | 17 + 22 files changed, 1548 insertions(+), 164 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index a08350ed959..d47aaaec826 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -72,7 +72,12 @@ const git = (cwd: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) return result.stdout.trim(); }); -const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: number }) => +const searchWorkspaceEntries = (input: { + cwd: string; + query: string; + limit: number; + kind?: "file" | "directory"; +}) => Effect.gen(function* () { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; return yield* workspaceEntries.search(input); @@ -200,6 +205,62 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); + it.effect("applies the file filter before limiting search results", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-file-limit-" }); + yield* writeTextFile(cwd, "src/index.ts"); + yield* writeTextFile(cwd, "src/internal.ts"); + + const result = yield* searchWorkspaceEntries({ + cwd, + query: "src", + limit: 1, + kind: "file", + }); + + expect(result.entries).toEqual([{ path: "src/index.ts", kind: "file" }]); + expect(result.truncated).toBe(true); + }), + ); + + it.effect("answers an empty file-filtered query with a bounded file listing", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-empty-query-" }); + yield* writeTextFile(cwd, "src/index.ts"); + yield* writeTextFile(cwd, "README.md"); + + const result = yield* searchWorkspaceEntries({ + cwd, + query: "", + limit: 10, + kind: "file", + }); + + const paths = result.entries.map((entry) => entry.path); + expect(paths).toHaveLength(2); + expect(paths).toContain("src/index.ts"); + expect(paths).toContain("README.md"); + expect(result.entries.every((entry) => entry.kind === "file")).toBe(true); + }), + ); + + it.effect("returns only directories for the directory filter", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-directory-filter-" }); + yield* writeTextFile(cwd, "src/index.ts"); + + const result = yield* searchWorkspaceEntries({ + cwd, + query: "src", + limit: 10, + kind: "directory", + }); + + expect(result.entries).toEqual([{ path: "src", kind: "directory" }]); + expect(result.truncated).toBe(false); + }), + ); + it.effect("excludes gitignored paths for git repositories", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-gitignore-", git: true }); @@ -292,6 +353,287 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { ); }); + describe("searchContents", () => { + it.effect("returns content matches with file paths, line numbers, and ranges", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-search-" }); + yield* writeTextFile( + cwd, + "src/shapes.ts", + "export const square = 4;\nexport const Square = 16;\nexport const squareSize = 8;\n", + ); + yield* writeTextFile(cwd, "src/other.ts", "const circle = true;\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "Square", + limit: 100, + caseSensitive: false, + wholeWord: true, + useRegex: false, + }); + + expect(result.matches.map((match) => [match.path, match.lineNumber])).toEqual([ + ["src/shapes.ts", 1], + ["src/shapes.ts", 2], + ]); + expect(result.matches[0]?.matchRanges).toEqual([{ start: 13, end: 19 }]); + expect(result.truncated).toBe(false); + }), + ); + + it.effect("honors case sensitivity and gitignore rules", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-ignore-", git: true }); + yield* writeTextFile(cwd, ".gitignore", "ignored.txt\n"); + yield* writeTextFile(cwd, "src/keep.ts", "square\nSquare\n"); + yield* writeTextFile(cwd, "ignored.txt", "Square\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "Square", + limit: 100, + caseSensitive: true, + wholeWord: false, + useRegex: false, + }); + + expect(result.matches).toHaveLength(1); + expect(result.matches[0]).toMatchObject({ path: "src/keep.ts", lineNumber: 2 }); + }), + ); + + it.effect("filters whole-word matches by word boundaries without widening ranges", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-whole-word-" }); + yield* writeTextFile(cwd, "src/words.ts", "note notes denote\nfootnote note\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "note", + limit: 100, + caseSensitive: true, + wholeWord: true, + useRegex: false, + }); + + // "notes", "denote", and "footnote" are word-adjacent and excluded; + // ranges cover exactly the query, never boundary characters. + expect(result.matches).toEqual([ + expect.objectContaining({ + path: "src/words.ts", + lineNumber: 1, + matchRanges: [{ start: 0, end: 4 }], + }), + expect.objectContaining({ + path: "src/words.ts", + lineNumber: 2, + matchRanges: [{ start: 9, end: 13 }], + }), + ]); + }), + ); + + it.effect("finds later whole-word matches in a file after rejected raw matches", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-late-whole-word-" }); + yield* writeTextFile(cwd, "src/words.ts", `${"afoo\n".repeat(10)}foo\n`); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "foo", + limit: 1, + caseSensitive: true, + wholeWord: true, + useRegex: false, + }); + + expect(result.matches).toEqual([ + expect.objectContaining({ + path: "src/words.ts", + lineNumber: 11, + matchRanges: [{ start: 0, end: 3 }], + }), + ]); + }), + ); + + it.effect("treats astral-plane letters as whole word characters", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-astral-word-" }); + yield* writeTextFile(cwd, "src/words.ts", "𐐀foo foo foo𐐀\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "foo", + limit: 100, + caseSensitive: true, + wholeWord: true, + useRegex: false, + }); + + expect(result.matches).toEqual([ + expect.objectContaining({ + path: "src/words.ts", + lineNumber: 1, + matchRanges: [{ start: 6, end: 9 }], + }), + ]); + }), + ); + + it.effect("matches punctuation-edged whole-word queries including adjacent occurrences", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-punctuation-" }); + yield* writeTextFile(cwd, "src/words.ts", "-foo- -foo- -foo-\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "-foo-", + limit: 100, + caseSensitive: true, + wholeWord: true, + useRegex: false, + }); + + // Consuming-boundary regex would swallow the separating spaces and + // drop the middle occurrence; boundary post-filtering keeps all three. + expect(result.matches).toHaveLength(1); + expect(result.matches[0]).toMatchObject({ + path: "src/words.ts", + lineNumber: 1, + matchRanges: [ + { start: 0, end: 5 }, + { start: 6, end: 11 }, + { start: 12, end: 17 }, + ], + }); + }), + ); + + it.effect("matches punctuation-edged regex queries as whole words", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-regex-punctuation-" }); + yield* writeTextFile(cwd, "src/words.ts", "foo- foo-\nafoo-b\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "foo-", + limit: 100, + caseSensitive: true, + wholeWord: true, + useRegex: true, + }); + + // wholeWord + useRegex must not silently drop non-word-edged patterns + // like "foo-", and "afoo-" is excluded because 'a'/'f' are both word + // characters at the match's left edge. + expect(result.matches).toHaveLength(1); + expect(result.matches[0]).toMatchObject({ + path: "src/words.ts", + lineNumber: 1, + matchRanges: [ + { start: 0, end: 4 }, + { start: 5, end: 9 }, + ], + }); + }), + ); + + it.effect("caps matches per file so one dense file cannot fill the page", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-per-file-cap-" }); + yield* writeTextFile(cwd, "src/dense.ts", "needle\n".repeat(300)); + yield* writeTextFile(cwd, "src/other.ts", "needle\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "needle", + limit: 500, + caseSensitive: true, + wholeWord: false, + useRegex: false, + }); + + const byPath = new Map(); + for (const match of result.matches) { + byPath.set(match.path, (byPath.get(match.path) ?? 0) + 1); + } + expect(byPath.get("src/dense.ts")).toBe(100); + expect(byPath.get("src/other.ts")).toBe(1); + }), + ); + + it.effect("preserves regex escapes during case-insensitive searches", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-regex-" }); + yield* writeTextFile(cwd, "src/shapes.ts", "Square\nsquare\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "\\SQUARE", + limit: 100, + caseSensitive: false, + wholeWord: false, + useRegex: true, + }); + + expect(result.matches.map((match) => match.lineNumber)).toEqual([1, 2]); + }), + ); + + it.effect("preserves invalid regex errors during case-insensitive searches", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-invalid-regex-" }); + yield* writeTextFile(cwd, "src/shapes.ts", "foobar\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "foo)bar(", + limit: 100, + caseSensitive: false, + wholeWord: false, + useRegex: true, + }); + + expect(result.regexFallbackError).toBeDefined(); + expect(result.matches).toEqual([]); + }), + ); + + it.effect("maps multi-byte lines to string-indexed ranges", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-multibyte-" }); + yield* writeTextFile(cwd, "src/notes.ts", 'const label = "héllo wörld";\n'); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "wörld", + limit: 100, + caseSensitive: true, + wholeWord: false, + useRegex: false, + }); + + expect(result.matches).toHaveLength(1); + const match = result.matches[0]!; + const range = match.matchRanges[0]!; + expect(match.lineContent.slice(range.start, range.end)).toBe("wörld"); + }), + ); + }); + describe("browse", () => { it.effect("returns matching directories and excludes files", () => Effect.gen(function* () { diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 7501cbe0eab..bb2113dac37 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -14,11 +14,14 @@ import type { FilesystemBrowseResult, ProjectListEntriesInput, ProjectListEntriesResult, + ProjectSearchContentsInput, + ProjectSearchContentsResult, ProjectSearchEntriesInput, ProjectSearchEntriesResult, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; +import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; import * as WorkspacePaths from "./WorkspacePaths.ts"; import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; @@ -93,6 +96,9 @@ export class WorkspaceEntries extends Context.Service< readonly search: ( input: ProjectSearchEntriesInput, ) => Effect.Effect; + readonly searchContents: ( + input: ProjectSearchContentsInput, + ) => Effect.Effect; readonly refresh: (cwd: string) => Effect.Effect; } >()("t3/workspace/WorkspaceEntries") {} @@ -148,33 +154,37 @@ export const make = Effect.gen(function* () { const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe( Effect.orElseSucceed(() => cwd), ); - if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, normalizedCwd))) { - return; - } - const recoverRefreshFailure = ( - cause: - | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed - | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut - | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed, - ) => - Effect.gen(function* () { - yield* Effect.logWarning("Failed to refresh workspace search index", { - cwd, - cause, + for (const variant of WorkspaceSearchIndex.WORKSPACE_SEARCH_INDEX_VARIANTS) { + const indexKey = WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, variant); + if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, indexKey))) { + continue; + } + const recoverRefreshFailure = ( + cause: + | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed + | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut + | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed, + ) => + Effect.gen(function* () { + yield* Effect.logWarning("Failed to refresh workspace search index", { + cwd, + variant, + cause, + }); + yield* workspaceSearchIndexes.invalidate(indexKey); }); - yield* workspaceSearchIndexes.invalidate(normalizedCwd); - }); - yield* Effect.gen(function* () { - const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; - yield* searchIndex.refresh(); - }).pipe( - Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), - Effect.catchTags({ - WorkspaceSearchIndexCreateFailed: recoverRefreshFailure, - WorkspaceSearchIndexScanTimedOut: recoverRefreshFailure, - WorkspaceSearchIndexRefreshFailed: recoverRefreshFailure, - }), - ); + yield* Effect.gen(function* () { + const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; + yield* searchIndex.refresh(); + }).pipe( + Effect.provide(workspaceSearchIndexes.get(indexKey)), + Effect.catchTags({ + WorkspaceSearchIndexCreateFailed: recoverRefreshFailure, + WorkspaceSearchIndexScanTimedOut: recoverRefreshFailure, + WorkspaceSearchIndexRefreshFailed: recoverRefreshFailure, + }), + ); + } }, ); @@ -230,28 +240,55 @@ export const make = Effect.gen(function* () { const search: WorkspaceEntries["Service"]["search"] = Effect.fn("WorkspaceEntries.search")( function* (input) { const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); - const normalizedQuery = input.query - .trim() - .toLowerCase() - .replace(/^[@./]+/, ""); + const normalizedQuery = normalizeSearchQuery(input.query, { + trimLeadingPattern: /^[@./]+/, + }); return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; - return yield* searchIndex.search(normalizedQuery, input.limit); - }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); + return yield* searchIndex.search(normalizedQuery, input.limit, input.kind); + }).pipe( + Effect.provide( + workspaceSearchIndexes.get( + WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "paths"), + ), + ), + ); }, ); + const searchContents: WorkspaceEntries["Service"]["searchContents"] = Effect.fn( + "WorkspaceEntries.searchContents", + )(function* (input) { + const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); + return yield* Effect.gen(function* () { + const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; + return yield* searchIndex.searchContents(input); + }).pipe( + Effect.provide( + workspaceSearchIndexes.get( + WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "content"), + ), + ), + ); + }); + const list: WorkspaceEntries["Service"]["list"] = Effect.fn("WorkspaceEntries.list")( function* (input) { const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; return yield* searchIndex.list(); - }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); + }).pipe( + Effect.provide( + workspaceSearchIndexes.get( + WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "paths"), + ), + ), + ); }, ); - return WorkspaceEntries.of({ browse, list, refresh, search }); + return WorkspaceEntries.of({ browse, list, refresh, search, searchContents }); }); export const layer = Layer.effect(WorkspaceEntries, make).pipe( diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 9b7ed4e2453..15572837030 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -1,4 +1,4 @@ -import { FileFinder } from "@ff-labs/fff-node"; +import { FileFinder, type GrepCursor, type GrepOptions, type GrepResult } from "@ff-labs/fff-node"; import { afterEach, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; @@ -51,6 +51,41 @@ it.effect("keeps returned FileFinder creation diagnostics out of the cause chain }), ); +it.effect("waits for the full content index warmup before returning", () => + Effect.gen(function* () { + const waitForIndexReady = vi.fn(async () => ({ ok: true as const, value: true })); + const finder = { + destroy: vi.fn(), + waitForIndexReady, + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + yield* Effect.scoped(WorkspaceSearchIndex.make("/workspace/project", "content")); + + expect(waitForIndexReady).toHaveBeenCalledWith(15_000); + }), +); + +it.effect("preserves a full-index warmup timeout as a structured error", () => + Effect.gen(function* () { + const finder = { + destroy: vi.fn(), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: false })), + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + const error = yield* Effect.flip( + Effect.scoped(WorkspaceSearchIndex.make("/workspace/project", "content")), + ); + + expect(error).toMatchObject({ + _tag: "WorkspaceSearchIndexScanTimedOut", + cwd: "/workspace/project", + timeout: "15 seconds", + }); + }), +); + it.effect("preserves FileFinder destroy failures as structured defects", () => Effect.gen(function* () { const cause = new Error("native destroy failed"); @@ -58,7 +93,7 @@ it.effect("preserves FileFinder destroy failures as structured defects", () => destroy: vi.fn(() => { throw cause; }), - isScanning: vi.fn(() => false), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); @@ -85,12 +120,16 @@ it.effect("preserves search and refresh failures with operation context", () => Effect.gen(function* () { const searchCause = new Error("native search failed"); const refreshCause = new Error("native scan failed"); + const contentSearchCause = new Error("native grep failed"); const finder = { destroy: vi.fn(), - isScanning: vi.fn(() => false), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), mixedSearch: vi.fn(() => { throw searchCause; }), + grep: vi.fn(() => { + throw contentSearchCause; + }), scanFiles: vi.fn(() => { throw refreshCause; }), @@ -100,6 +139,15 @@ it.effect("preserves search and refresh failures with operation context", () => const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); const query = "authorization: Bearer secret-token"; const searchError = yield* Effect.flip(searchIndex.search(query, 3)); + const contentSearchError = yield* Effect.flip( + searchIndex.searchContents({ + query, + limit: 3, + caseSensitive: false, + wholeWord: false, + useRegex: false, + }), + ); const refreshError = yield* Effect.flip(searchIndex.refresh()); expect(searchError).toMatchObject({ @@ -112,6 +160,16 @@ it.effect("preserves search and refresh failures with operation context", () => }); expect(searchError).not.toHaveProperty("query"); expect(searchError.message).not.toMatch(/Bearer|secret-token/); + expect(contentSearchError).toMatchObject({ + _tag: "WorkspaceSearchIndexSearchFailed", + cwd: "/workspace/project", + queryLength: query.length, + pageSize: 3, + reason: "FileFinder.grep threw unexpectedly.", + cause: contentSearchCause, + }); + expect(contentSearchError).not.toHaveProperty("query"); + expect(contentSearchError.message).not.toMatch(/Bearer|secret-token/); expect(refreshError).toMatchObject({ _tag: "WorkspaceSearchIndexRefreshFailed", cwd: "/workspace/project", @@ -127,7 +185,7 @@ it.effect("keeps returned search diagnostics out of the cause chain", () => Effect.gen(function* () { const finder = { destroy: vi.fn(), - isScanning: vi.fn(() => false), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), mixedSearch: vi.fn(() => ({ ok: false, error: "native query rejected" })), scanFiles: vi.fn(() => ({ ok: false, error: "native refresh rejected" })), } as unknown as FileFinder; @@ -157,3 +215,80 @@ it.effect("keeps returned search diagnostics out of the cause chain", () => }), ), ); + +it.effect("continues whole-word searches after a filtered grep page", () => + Effect.scoped( + Effect.gen(function* () { + const nextCursor = { + __brand: "GrepCursor", + _offset: 1, + } as GrepCursor; + const grepResult = ( + lineContent: string, + matchRanges: Array<[number, number]>, + cursor: GrepCursor | null, + ): GrepResult => ({ + items: [ + { + relativePath: "src/words.ts", + fileName: "words.ts", + gitStatus: "unmodified", + size: lineContent.length, + modified: 0, + isBinary: false, + totalFrecencyScore: 0, + accessFrecencyScore: 0, + modificationFrecencyScore: 0, + lineNumber: 1, + col: 0, + byteOffset: 0, + lineContent, + matchRanges, + }, + ], + totalMatched: 1, + totalFilesSearched: 1, + totalFiles: 1, + filteredFileCount: 1, + nextCursor: cursor, + }); + const grep = vi.fn((_query: string, options?: GrepOptions) => + options?.cursor + ? { ok: true as const, value: grepResult("needle", [[0, 6]], null) } + : { + ok: true as const, + value: grepResult("needleSuffix", [[0, 6]], nextCursor), + }, + ); + const finder = { + destroy: vi.fn(), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), + grep, + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project", "content"); + const result = yield* searchIndex.searchContents({ + query: "needle", + limit: 1, + caseSensitive: true, + wholeWord: true, + useRegex: false, + }); + + expect(result).toEqual({ + matches: [ + { + path: "src/words.ts", + lineNumber: 1, + lineContent: "needle", + matchRanges: [{ start: 0, end: 6 }], + }, + ], + truncated: false, + }); + expect(grep).toHaveBeenCalledTimes(2); + expect(grep.mock.calls[1]?.[1]?.cursor).toBe(nextCursor); + }), + ), +); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index db4d46851e7..8bf36b7a80a 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -1,22 +1,36 @@ -import { FileFinder, type MixedItem, type MixedSearchResult } from "@ff-labs/fff-node"; +import { + type DirItem, + type DirSearchResult, + type FileItem, + FileFinder, + type GrepCursor, + type MixedItem, + type MixedSearchResult, + type Result, + type SearchResult, +} from "@ff-labs/fff-node"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as LayerMap from "effect/LayerMap"; -import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import type { ProjectEntry, + ProjectEntryKind, ProjectListEntriesResult, + ProjectSearchContentsInput, + ProjectSearchContentsResult, ProjectSearchEntriesResult, } from "@t3tools/contracts"; const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2; const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds"; +const WORKSPACE_INDEX_SCAN_TIMEOUT_MS = 15_000; const WORKSPACE_INDEX_IDLE_TTL = "15 minutes"; -const WORKSPACE_INDEX_SCAN_POLL_INTERVAL = "50 millis"; +const CONTENT_SEARCH_TIME_BUDGET_MS = 250; +const CONTENT_SEARCH_MAX_MATCHES_PER_FILE = 100; export class WorkspaceSearchIndexCreateFailed extends Schema.TaggedErrorClass()( "WorkspaceSearchIndexCreateFailed", @@ -96,7 +110,11 @@ export class WorkspaceSearchIndex extends Context.Service< readonly search: ( query: string, limit: number, + kind?: ProjectEntryKind, ) => Effect.Effect; + readonly searchContents: ( + input: Omit, + ) => Effect.Effect; readonly refresh: () => Effect.Effect< void, WorkspaceSearchIndexRefreshFailed | WorkspaceSearchIndexScanTimedOut @@ -129,6 +147,43 @@ function toProjectEntry(item: MixedItem): ProjectEntry | null { }; } +function toFileEntry(item: FileItem): ProjectEntry | null { + const normalizedPath = trimDirectorySeparator(toPosixPath(item.relativePath)); + return normalizedPath ? { path: normalizedPath, kind: "file" } : null; +} + +function toDirectoryEntry(item: DirItem): ProjectEntry | null { + const normalizedPath = trimDirectorySeparator(toPosixPath(item.relativePath)); + return normalizedPath ? { path: normalizedPath, kind: "directory" } : null; +} + +function mapFileSearchResult(result: SearchResult, limit: number): ProjectSearchEntriesResult { + return { + entries: result.items + .flatMap((item) => { + const entry = toFileEntry(item); + return entry ? [entry] : []; + }) + .slice(0, limit), + truncated: result.totalMatched > limit, + }; +} + +function mapDirectorySearchResult( + result: DirSearchResult, + limit: number, +): ProjectSearchEntriesResult { + const entries = result.items.flatMap((item) => { + const entry = toDirectoryEntry(item); + return entry ? [entry] : []; + }); + const rootDirectoryCount = result.items.some((item) => item.relativePath.length === 0) ? 1 : 0; + return { + entries: entries.slice(0, limit), + truncated: result.totalMatched - rootDirectoryCount > limit, + }; +} + function mapMixedSearchResult( result: MixedSearchResult, limit: number, @@ -155,6 +210,74 @@ function mapMixedSearchResult( }; } +const WORD_CHARACTER = /[\p{Letter}\p{Mark}\p{Number}_]/u; + +function codePointAt(line: string, index: number): string | undefined { + const codePoint = line.codePointAt(index); + return codePoint === undefined ? undefined : String.fromCodePoint(codePoint); +} + +function codePointBefore(line: string, index: number): string | undefined { + if (index <= 0) return undefined; + const previousCodeUnit = line.charCodeAt(index - 1); + const previousIndex = + previousCodeUnit >= 0xdc00 && previousCodeUnit <= 0xdfff ? index - 2 : index - 1; + return codePointAt(line, previousIndex); +} + +function buildContentSearchQuery(input: Omit): { + readonly searchQuery: string; + readonly regexMode: boolean; +} { + if (input.caseSensitive) { + return { searchQuery: input.query, regexMode: input.useRegex }; + } + // Plain mode relies on smart case: an all-lowercase needle matches + // case-insensitively. Regex mode needs an explicit inline flag instead. + return input.useRegex + ? { searchQuery: `(?i)${input.query}`, regexMode: true } + : { searchQuery: input.query.toLowerCase(), regexMode: false }; +} + +function mapContentMatchRanges( + line: string, + byteRanges: ReadonlyArray, +): Array<{ readonly start: number; readonly end: number }> { + const lineBytes = Buffer.from(line); + const toStringIndex = (byteOffset: number) => lineBytes.subarray(0, byteOffset).toString().length; + return byteRanges.map(([startByte, endByte]) => ({ + start: toStringIndex(startByte), + end: toStringIndex(endByte), + })); +} + +/** + * Whole-word filtering happens after the grep rather than by wrapping the + * pattern in boundary regex: consuming boundaries such as `(?:^|\W)` swallow + * the separator between adjacent matches and widen the reported ranges, and + * `\b` cannot match punctuation-edged queries at all. Matching VS Code, a + * match edge is a word boundary when it touches the line edge, the + * neighbouring character is not a word character, or the match's own edge + * character is not a word character. + */ +function isWholeWordRange( + line: string, + range: { readonly start: number; readonly end: number }, +): boolean { + if (range.end <= range.start) return false; + const isWord = (character: string | undefined) => + character !== undefined && WORD_CHARACTER.test(character); + const leftIsBoundary = + range.start === 0 || + !isWord(codePointBefore(line, range.start)) || + !isWord(codePointAt(line, range.start)); + const rightIsBoundary = + range.end >= line.length || + !isWord(codePointAt(line, range.end)) || + !isWord(codePointBefore(line, range.end)); + return leftIsBoundary && rightIsBoundary; +} + function withDirectoryAncestors(entries: ReadonlyArray): ProjectEntry[] { const entryByPath = new Map(entries.map((entry) => [entry.path, entry])); for (const entry of entries) { @@ -169,13 +292,19 @@ function withDirectoryAncestors(entries: ReadonlyArray): ProjectEn return [...entryByPath.values()]; } -const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (cwd: string) { +const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* ( + cwd: string, + variant: WorkspaceSearchIndexVariant, +) { const result = yield* Effect.try({ try: () => FileFinder.create({ basePath: cwd, disableMmapCache: true, - disableContentIndexing: true, + // Content indexing costs scan CPU and memory, so only the on-demand + // content-search index pays for it; path-only consumers (file tree, + // composer path search, file picker) keep the lightweight index. + disableContentIndexing: variant !== "content", aiMode: false, enableFsRootScanning: true, enableHomeDirScanning: true, @@ -194,53 +323,65 @@ const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (c }); }); -const waitForScan = (cwd: string, finder: FileFinder, onFailure: (cause: unknown) => E) => - Effect.try({ - try: () => finder.isScanning(), - catch: onFailure, - }).pipe( - Effect.repeat({ - while: (scanning) => scanning, - schedule: Schedule.spaced(WORKSPACE_INDEX_SCAN_POLL_INTERVAL), - }), - Effect.timeoutOrElse({ - duration: WORKSPACE_INDEX_SCAN_TIMEOUT, - orElse: () => - new WorkspaceSearchIndexScanTimedOut({ cwd, timeout: WORKSPACE_INDEX_SCAN_TIMEOUT }), - }), - Effect.withSpan("WorkspaceSearchIndex.waitForScan"), - ); +const waitForIndexReady = Effect.fn("WorkspaceSearchIndex.waitForIndexReady")(function* ( + cwd: string, + finder: FileFinder, + onFailure: (input: { readonly reason: string; readonly cause?: unknown }) => E, +): Effect.fn.Return { + const result = yield* Effect.tryPromise({ + try: () => finder.waitForIndexReady(WORKSPACE_INDEX_SCAN_TIMEOUT_MS), + catch: (cause) => + onFailure({ + reason: "FileFinder.waitForIndexReady rejected unexpectedly.", + cause, + }), + }); + if (!result.ok) { + return yield* Effect.fail(onFailure({ reason: result.error })); + } + if (!result.value) { + return yield* new WorkspaceSearchIndexScanTimedOut({ + cwd, + timeout: WORKSPACE_INDEX_SCAN_TIMEOUT, + }); + } +}); -export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: string) { - const finder = yield* Effect.acquireRelease(createFinder(cwd), (finder) => +export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( + cwd: string, + variant: WorkspaceSearchIndexVariant = "paths", +) { + const finder = yield* Effect.acquireRelease(createFinder(cwd, variant), (finder) => Effect.try({ try: () => finder.destroy(), catch: (cause) => new WorkspaceSearchIndexDestroyFailed({ cwd, cause }), }).pipe(Effect.orDie), ); - yield* waitForScan( + yield* waitForIndexReady( cwd, finder, - (cause) => + ({ reason, cause }) => new WorkspaceSearchIndexCreateFailed({ cwd, - reason: "FileFinder.isScanning threw while creating the index.", + reason, cause, }), ); - const runMixedSearch = Effect.fn("WorkspaceSearchIndex.runMixedSearch")(function* ( + const runSearch = Effect.fn("WorkspaceSearchIndex.runSearch")(function* ( query: string, pageSize: number, - ) { + operation: "directorySearch" | "fileSearch" | "grep" | "mixedSearch", + execute: () => Result, + ): Effect.fn.Return { const result = yield* Effect.try({ - try: () => finder.mixedSearch(query, { pageSize }), + try: execute, catch: (cause) => new WorkspaceSearchIndexSearchFailed({ cwd, queryLength: query.length, pageSize, - reason: "FileFinder.mixedSearch threw unexpectedly.", + reason: `FileFinder.${operation} threw unexpectedly.`, cause, }), }); @@ -273,13 +414,13 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin reason: result.error, }); } - yield* waitForScan( + yield* waitForIndexReady( cwd, finder, - (cause) => + ({ reason, cause }) => new WorkspaceSearchIndexRefreshFailed({ cwd, - reason: "FileFinder.isScanning threw while refreshing the index.", + reason, cause, }), ); @@ -287,7 +428,9 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( function* () { - const result = yield* runMixedSearch("", WORKSPACE_INDEX_PAGE_SIZE); + const result = yield* runSearch("", WORKSPACE_INDEX_PAGE_SIZE, "mixedSearch", () => + finder.mixedSearch("", { pageSize: WORKSPACE_INDEX_PAGE_SIZE }), + ); const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES); const sortedEntries = withDirectoryAncestors(mapped.entries).toSorted((left, right) => left.path.localeCompare(right.path), @@ -302,20 +445,112 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( "WorkspaceSearchIndex.search", - )(function* (query, limit) { - const result = yield* runMixedSearch(query, Math.max(1, limit + 1)); + )(function* (query, limit, kind) { + const pageSize = Math.max(1, limit + 1); + if (kind === "file") { + const result = yield* runSearch(query, pageSize, "fileSearch", () => + finder.fileSearch(query, { pageSize }), + ); + return mapFileSearchResult(result, limit); + } + if (kind === "directory") { + const result = yield* runSearch(query, pageSize, "directorySearch", () => + finder.directorySearch(query, { pageSize }), + ); + return mapDirectorySearchResult(result, limit); + } + const result = yield* runSearch(query, pageSize, "mixedSearch", () => + finder.mixedSearch(query, { pageSize }), + ); return mapMixedSearchResult(result, limit); }); - return WorkspaceSearchIndex.of({ list, refresh, search }); + const searchContents: WorkspaceSearchIndex["Service"]["searchContents"] = Effect.fn( + "WorkspaceSearchIndex.searchContents", + )(function* (input) { + const { searchQuery, regexMode } = buildContentSearchQuery(input); + const deadline = performance.now() + CONTENT_SEARCH_TIME_BUDGET_MS; + // Grep cursors advance by file, so whole-word post-filtering needs enough + // raw candidates from the current file before moving to the next one. + const rawPageSize = input.wholeWord + ? Math.max(input.limit, CONTENT_SEARCH_MAX_MATCHES_PER_FILE) + : input.limit; + const matches: Array = []; + let nextCursor: GrepCursor | null = null; + let regexFallbackError: string | undefined; + + do { + const remainingTimeBudgetMs = Math.max(1, Math.ceil(deadline - performance.now())); + const result = yield* runSearch(input.query, input.limit, "grep", () => + finder.grep(searchQuery, { + mode: regexMode ? "regex" : "plain", + smartCase: !input.caseSensitive && !regexMode, + // A single dense file must not consume the whole result page. + maxMatchesPerFile: Math.min(CONTENT_SEARCH_MAX_MATCHES_PER_FILE, rawPageSize), + pageSize: rawPageSize, + cursor: nextCursor, + timeBudgetMs: remainingTimeBudgetMs, + }), + ); + + for (const match of result.items) { + const matchRanges = mapContentMatchRanges(match.lineContent, match.matchRanges).filter( + (range) => !input.wholeWord || isWholeWordRange(match.lineContent, range), + ); + if (matchRanges.length === 0) continue; + matches.push({ + path: toPosixPath(match.relativePath), + lineNumber: match.lineNumber, + lineContent: match.lineContent, + matchRanges, + }); + } + nextCursor = result.nextCursor; + regexFallbackError ??= result.regexFallbackError; + } while (matches.length < input.limit && nextCursor !== null && performance.now() < deadline); + + return { + matches: matches.slice(0, input.limit), + truncated: matches.length > input.limit || nextCursor !== null, + ...(regexFallbackError !== undefined ? { regexFallbackError } : {}), + }; + }); + + return WorkspaceSearchIndex.of({ list, refresh, search, searchContents }); }); +export const WORKSPACE_SEARCH_INDEX_VARIANTS = ["paths", "content"] as const; +export type WorkspaceSearchIndexVariant = (typeof WORKSPACE_SEARCH_INDEX_VARIANTS)[number]; + +/** + * Composite LayerMap key so the lightweight path index and the on-demand + * content-search index of the same workspace are separate resources with + * independent lifecycles. "\n" cannot appear in a filesystem path. + */ +export const workspaceSearchIndexKey = (cwd: string, variant: WorkspaceSearchIndexVariant) => + `${variant}\n${cwd}`; + +function parseWorkspaceSearchIndexKey(key: string): { + readonly cwd: string; + readonly variant: WorkspaceSearchIndexVariant; +} { + const separatorIndex = key.indexOf("\n"); + return { + variant: key.slice(0, separatorIndex) as WorkspaceSearchIndexVariant, + cwd: key.slice(separatorIndex + 1), + }; +} + /** * A layer factory is required because every index is scoped to a concrete - * workspace root. WorkspaceSearchIndexMap owns memoization and idle cleanup; - * using a default cwd here would mix resources from different workspaces. + * workspace root and variant. WorkspaceSearchIndexMap owns memoization and + * idle cleanup; using a default cwd here would mix resources from different + * workspaces. */ -export const layer = (cwd: string) => Layer.effect(WorkspaceSearchIndex, make(cwd)); +export const layer = (key: string) => { + const { cwd, variant } = parseWorkspaceSearchIndexKey(key); + return Layer.effect(WorkspaceSearchIndex, make(cwd, variant)); +}; export class WorkspaceSearchIndexMap extends LayerMap.Service()( "t3/workspace/WorkspaceSearchIndexMap", diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index d691320fc9e..6a761ba497a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -43,6 +43,7 @@ import { type ProjectFileOperation, ProjectListEntriesError, ProjectReadFileError, + ProjectSearchContentsError, ProjectSearchEntriesError, ProjectWriteFileError, RelayClientInstallFailedError, @@ -1910,6 +1911,23 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.projectsSearchContents]: (input) => + observeRpcEffect( + WS_METHODS.projectsSearchContents, + workspaceEntries.searchContents(input).pipe( + Effect.mapError( + (cause) => + new ProjectSearchContentsError({ + cwd: input.cwd, + queryLength: input.query.length, + limit: input.limit, + ...projectEntriesFailureContext(cause), + cause, + }), + ), + ), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.projectsListEntries]: (input) => observeRpcEffect( WS_METHODS.projectsListEntries, diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index a43e4905876..1a0b8f889f0 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -75,7 +75,7 @@ export interface CommandPaletteItem { readonly value: string; readonly searchTerms: ReadonlyArray; readonly title: ReactNode; - readonly description?: string; + readonly description?: ReactNode; readonly threadContentMatch?: CommandPaletteThreadContentMatch; readonly timestamp?: string; readonly icon: ReactNode; diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index bcc49339cc6..a3a84d197f9 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,12 +1,15 @@ import type { EnvironmentId } from "@t3tools/contracts"; -import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; +import { + getProjectFaviconCacheKey, + isProjectFaviconFallbackUrl, +} from "@t3tools/shared/projectFavicon"; import { FolderIcon } from "lucide-react"; import type { ComponentType } from "react"; import { useState } from "react"; import { useAssetUrl } from "../assets/assetUrls"; import { cn } from "~/lib/utils"; -const loadedProjectFaviconSrcs = new Set(); +const loadedProjectFaviconSrcs = new Map(); export function ProjectFavicon(input: { environmentId: EnvironmentId; @@ -24,9 +27,12 @@ export function ProjectFavicon(input: { return ; } + const cacheKey = getProjectFaviconCacheKey(input.environmentId, input.cwd, src); + return ( ; }) { - const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => - loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", + const [displayedSrc, setDisplayedSrc] = useState( + () => loadedProjectFaviconSrcs.get(cacheKey) ?? null, ); + const isLoading = displayedSrc !== src; + const handleLoadError = (failedSrc: string) => { + if (loadedProjectFaviconSrcs.get(cacheKey) === failedSrc) { + loadedProjectFaviconSrcs.delete(cacheKey); + } + setDisplayedSrc((currentSrc) => (currentSrc === failedSrc ? null : currentSrc)); + }; return ( <> - {status !== "loaded" ? ( + {displayedSrc === null ? ( ) : null} - { - loadedProjectFaviconSrcs.add(src); - setStatus("loaded"); - }} - onError={() => setStatus("error")} - /> + {displayedSrc ? ( + handleLoadError(displayedSrc)} + /> + ) : null} + {isLoading ? ( + { + loadedProjectFaviconSrcs.set(cacheKey, src); + setDisplayedSrc(src); + }} + onError={() => handleLoadError(src)} + /> + ) : null} ); } diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index b07ae99c058..8d24b34a433 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -7,6 +7,7 @@ import { getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, getDesktopUpdateInstallConfirmationMessage, + getDesktopUpdateReleaseUrl, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, @@ -158,6 +159,23 @@ describe("getDesktopUpdateActionError", () => { }); describe("desktop update UI helpers", () => { + it("builds the stable release URL for a downloaded version", () => { + expect(getDesktopUpdateReleaseUrl("0.0.30")).toBe( + "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30", + ); + }); + + it("builds the nightly release URL without dropping its version suffix", () => { + expect(getDesktopUpdateReleaseUrl("0.0.30-nightly.20260728.931")).toBe( + "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30-nightly.20260728.931", + ); + }); + + it("omits the release URL when the updater does not report a version", () => { + expect(getDesktopUpdateReleaseUrl(null)).toBeNull(); + expect(getDesktopUpdateReleaseUrl(" ")).toBeNull(); + }); + it("toasts only for actionable updater errors", () => { expect( shouldToastDesktopUpdateActionResult({ diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 11c34777a41..dc09d7ca877 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -3,6 +3,24 @@ import { isWindowsPlatform } from "../lib/utils"; export type DesktopUpdateButtonAction = "download" | "install" | "none"; +const DESKTOP_RELEASE_TAG_URL = "https://github.com/pingdotgg/t3code/releases/tag"; + +/** + * The main process fills `downloadedVersion` from the updater's `update-downloaded` + * event, which is dispatched on its own fiber. A download RPC can therefore resolve + * before that write lands, so fall back to the version the download was started for. + */ +export function getDesktopUpdateDownloadedVersion(state: DesktopUpdateState): string | null { + return state.downloadedVersion ?? state.availableVersion; +} + +/** Release notes for an exact downloaded build; nightly suffixes are part of the tag. */ +export function getDesktopUpdateReleaseUrl(version: string | null): string | null { + const normalizedVersion = version?.trim(); + if (!normalizedVersion) return null; + return `${DESKTOP_RELEASE_TAG_URL}/v${encodeURIComponent(normalizedVersion)}`; +} + export function resolveDesktopUpdateButtonAction( state: DesktopUpdateState, ): DesktopUpdateButtonAction { diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 307d4413751..ff658693a70 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -26,6 +26,10 @@ interface FileBrowserPanelProps { environmentId: EnvironmentId; cwd: string; projectName: string; + /** File currently open in the preview pane; revealed and selected in the tree. */ + selectedPath: string | null; + /** Bumped when the same path should be revealed again (e.g. re-opened from search). */ + selectedPathRevealId: number; onOpenFile: (relativePath: string) => void; } @@ -98,6 +102,8 @@ export default function FileBrowserPanel({ environmentId, cwd, projectName, + selectedPath, + selectedPathRevealId, onOpenFile, }: FileBrowserPanelProps) { const { resolvedTheme } = useTheme(); @@ -111,6 +117,9 @@ export default function FileBrowserPanel({ const entryKindsRef = useRef>(entryKinds); const treePaths = useMemo(() => entries.map(treePath), [entries]); const previousTreePathsRef = useRef([]); + const syncingSelectionRef = useRef(false); + const treeSelectionPathRef = useRef(null); + const handledRevealRef = useRef<{ path: string; revealId: number } | null>(null); // The tree renders rows in shadow DOM and its anchor rect is unreliable, so // capture the right-click position ourselves; contextmenu is a composed @@ -216,7 +225,12 @@ export default function FileBrowserPanel({ initialExpansion: 1, icons: T3_PIERRE_ICONS, onSelectionChange: (selectedPaths) => { + // The drag controller's selection cache must track every change, + // including reveal-driven ones, or drags act on a stale selection. dragMention.handleSelectionChange(selectedPaths); + // Selection changes driven by the reveal sync below are echoes of an + // already-open file, not a request to open it again. + if (syncingSelectionRef.current) return; // Starting a drag selects the dragged row; that selection is a side // effect of the gesture, not a request to open the file. if (dragMention.isDragInProgress()) { @@ -224,6 +238,7 @@ export default function FileBrowserPanel({ } const selectedPath = selectedPaths.at(-1)?.replace(/\/$/, ""); if (selectedPath && entryKindsRef.current.get(selectedPath) === "file") { + treeSelectionPathRef.current = selectedPath; onOpenFile(selectedPath); } }, @@ -247,6 +262,63 @@ export default function FileBrowserPanel({ model.resetPaths(treePaths); }, [entryKinds, model, treePaths]); + useEffect(() => { + if (!selectedPath) { + handledRevealRef.current = null; + return; + } + const revealRequest = { path: selectedPath, revealId: selectedPathRevealId }; + const handledReveal = handledRevealRef.current; + // Entry refreshes rebuild treePaths while the same preview stays open. + // Replaying a handled reveal would close an active tree search and steal focus. + if ( + handledReveal?.path === revealRequest.path && + handledReveal.revealId === revealRequest.revealId + ) { + return; + } + if (entryKinds.get(selectedPath) !== "file") return; + const selectedItem = model.getItem(selectedPath); + if (!selectedItem) return; + + // A selection that originated inside the tree (clicking a row, possibly + // in an active tree search) is already visible; re-revealing it would + // close the search and clobber the user's context. Only sync external + // opens (file picker, content search, chat links). + const selectedInTree = model + .getSelectedPaths() + .some((path) => path.replace(/\/$/, "") === selectedPath); + if (selectedInTree && treeSelectionPathRef.current === selectedPath) { + treeSelectionPathRef.current = null; + handledRevealRef.current = revealRequest; + return; + } + treeSelectionPathRef.current = null; + handledRevealRef.current = revealRequest; + + syncingSelectionRef.current = true; + model.closeSearch(); + for (const path of model.getSelectedPaths()) { + model.getItem(path)?.deselect(); + } + + // Directory rows are registered with a trailing slash (see treePath), so + // ancestor lookups must use the same form to expand them. + const segments = selectedPath.split("/"); + let ancestorPath = ""; + for (const segment of segments.slice(0, -1)) { + ancestorPath = ancestorPath ? `${ancestorPath}/${segment}` : segment; + const item = model.getItem(`${ancestorPath}/`) ?? model.getItem(ancestorPath); + if (item && "expand" in item) item.expand(); + } + + selectedItem.select(); + model.scrollToPath(selectedPath, { focus: true, offset: "center" }); + queueMicrotask(() => { + syncingSelectionRef.current = false; + }); + }, [entryKinds, model, selectedPath, selectedPathRevealId, treePaths]); + // Tag tree drags with the composer mention payload. The row is read from // the composed event path (the tree's shadow root is open), so this does // not depend on running after the tree's own dragstart handler; the drag diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 24e63a6d8ea..a736cf96cd3 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -51,6 +51,7 @@ import { remapFileCommentAnnotations, } from "./fileCommentAnnotations"; import { installFileEditorDismissal } from "./fileEditorDismissal"; +import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; import { LocalCommentAnnotation } from "./LocalCommentAnnotation"; import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; import { fileBreadcrumbs } from "./filePath"; @@ -182,25 +183,53 @@ function updateFileLinkReveal(fileContainer: HTMLElement, line: number | null): ?.setAttribute(FILE_LINK_REVEAL_ATTRIBUTE, ""); } +/** + * Frames to keep retrying while the file contents or line metrics are not + * available yet (fresh mounts hydrate asynchronously). + */ +const REVEAL_MAX_ATTEMPTS = 30; +/** + * After scrolling to the target, hold it for a short window so late + * programmatic scroll resets (editable-editor focus and state restoration) + * cannot silently snap the file back to the top. Real user input cancels the + * guard immediately. + */ +const REVEAL_GUARD_FRAMES = 20; +const REVEAL_GUARD_TOLERANCE_PX = 2; + +interface FileRevealState { + frameId: number | null; + cancelGuard: (() => void) | null; + handledRequestId: number | null; + latestRequestId: number | null; +} + function useFileLineReveal( relativePath: string | null, revealLine: number | null, revealRequestId: number, ): FilePostRender { - const [handledRequestIdsByPath] = useState(() => new Map()); - const [latestRequestIdsByPath] = useState(() => new Map()); - const [pendingFramesByPath] = useState(() => new Map()); + const [revealStatesByPath] = useState(() => new Map()); return useCallback( (fileContainer, instance, phase) => { if (relativePath === null) return; + const existingState = revealStatesByPath.get(relativePath); + const state: FileRevealState = existingState ?? { + frameId: null, + cancelGuard: null, + handledRequestId: null, + latestRequestId: null, + }; + if (!existingState) revealStatesByPath.set(relativePath, state); + const cancelPendingReveal = () => { - const frameId = pendingFramesByPath.get(relativePath); - if (frameId !== undefined) { - cancelAnimationFrame(frameId); - pendingFramesByPath.delete(relativePath); + if (state.frameId !== null) { + cancelAnimationFrame(state.frameId); + state.frameId = null; } + state.cancelGuard?.(); }; if (phase === "unmount") { @@ -208,18 +237,20 @@ function useFileLineReveal( return; } + const contents = instance.file?.contents; const targetLine = - revealLine === null ? null : clampFileLine(instance.file?.contents ?? "", revealLine); + revealLine === null || contents === undefined ? null : clampFileLine(contents, revealLine); updateFileLinkReveal(fileContainer, targetLine); if (!(instance instanceof VirtualizedFile)) return; - if (latestRequestIdsByPath.get(relativePath) !== revealRequestId) { + if (state.latestRequestId !== revealRequestId) { cancelPendingReveal(); - latestRequestIdsByPath.set(relativePath, revealRequestId); + state.latestRequestId = revealRequestId; + state.handledRequestId = null; } - if (targetLine === null) { + if (revealLine === null) { fileContainer.style.minHeight = ""; return; } @@ -230,54 +261,113 @@ function useFileLineReveal( Math.max(instance.height, scrollContainer.clientHeight), )}px`; - if ( - handledRequestIdsByPath.get(relativePath) === revealRequestId || - pendingFramesByPath.has(relativePath) - ) { + if (state.handledRequestId === revealRequestId || state.frameId !== null) { return; } - const reveal = () => { - pendingFramesByPath.delete(relativePath); - if ( - latestRequestIdsByPath.get(relativePath) !== revealRequestId || - !fileContainer.isConnected - ) { - return; - } - - const linePosition = instance.getLinePosition(targetLine); - if (!linePosition) return; + const resolveScrollTarget = (line: number): number | null => { + const linePosition = instance.getLinePosition(line); + if (!linePosition) return null; + const scrollContainerRect = scrollContainer.getBoundingClientRect(); const fileTop = scrollContainer.scrollTop + fileContainer.getBoundingClientRect().top - - scrollContainer.getBoundingClientRect().top; - const centeredTop = Math.max( - 0, - fileTop + - linePosition.top - - Math.max(0, (scrollContainer.clientHeight - linePosition.height) / 2), - ); - const maxScrollTop = Math.max( - 0, - scrollContainer.scrollHeight - scrollContainer.clientHeight, - ); + scrollContainerRect.top; + const root = fileContainer.shadowRoot ?? fileContainer; + const renderedLineElement = root.querySelector(`[data-line="${line}"]`); + const renderedLineRect = renderedLineElement?.getBoundingClientRect(); - scrollContainer.scrollTop = Math.min(centeredTop, maxScrollTop); - handledRequestIdsByPath.set(relativePath, revealRequestId); + return resolveCenteredFileLineScrollTop({ + scrollTop: scrollContainer.scrollTop, + scrollHeight: scrollContainer.scrollHeight, + viewportTop: scrollContainerRect.top, + viewportHeight: scrollContainer.clientHeight, + fileTop, + estimatedLine: linePosition, + ...(renderedLineRect && renderedLineRect.height > 0 + ? { + renderedLine: { + top: renderedLineRect.top, + height: renderedLineRect.height, + }, + } + : {}), + }); }; - pendingFramesByPath.set(relativePath, requestAnimationFrame(reveal)); + const guardScrollTarget = (line: number) => { + let framesLeft = REVEAL_GUARD_FRAMES; + let guardFrameId: number | null = null; + const cancelGuard = () => { + if (guardFrameId !== null) { + cancelAnimationFrame(guardFrameId); + guardFrameId = null; + } + scrollContainer.removeEventListener("wheel", cancelGuard); + scrollContainer.removeEventListener("touchstart", cancelGuard); + scrollContainer.removeEventListener("pointerdown", cancelGuard, true); + window.removeEventListener("keydown", cancelGuard, true); + if (state.cancelGuard === cancelGuard) state.cancelGuard = null; + }; + scrollContainer.addEventListener("wheel", cancelGuard, { passive: true }); + scrollContainer.addEventListener("touchstart", cancelGuard, { passive: true }); + // Pierre stops gutter pointer events from bubbling. Listen in capture + // so starting a comment cancels the reveal guard before the row expands. + scrollContainer.addEventListener("pointerdown", cancelGuard, { + passive: true, + capture: true, + }); + window.addEventListener("keydown", cancelGuard, true); + const holdTarget = () => { + guardFrameId = null; + framesLeft -= 1; + if (framesLeft <= 0 || !scrollContainer.isConnected) { + cancelGuard(); + return; + } + const targetTop = resolveScrollTarget(line); + if ( + targetTop !== null && + Math.abs(scrollContainer.scrollTop - targetTop) > REVEAL_GUARD_TOLERANCE_PX + ) { + scrollContainer.scrollTop = targetTop; + } + guardFrameId = requestAnimationFrame(holdTarget); + }; + guardFrameId = requestAnimationFrame(holdTarget); + state.cancelGuard = cancelGuard; + }; + + const scheduleReveal = (attempt: number) => { + state.frameId = requestAnimationFrame(() => { + state.frameId = null; + if (state.latestRequestId !== revealRequestId || !fileContainer.isConnected) { + return; + } + + // Contents and line metrics can lag the first post-render on fresh + // mounts; clamping against missing contents would scroll to line 1 + // and wrongly mark the request handled. + const currentContents = instance.file?.contents; + const line = + currentContents === undefined ? null : clampFileLine(currentContents, revealLine); + const targetTop = line === null ? null : resolveScrollTarget(line); + if (line === null || targetTop === null) { + if (attempt < REVEAL_MAX_ATTEMPTS) scheduleReveal(attempt + 1); + return; + } + updateFileLinkReveal(fileContainer, line); + + scrollContainer.scrollTop = targetTop; + state.handledRequestId = revealRequestId; + guardScrollTarget(line); + }); + }; + + scheduleReveal(0); }, - [ - handledRequestIdsByPath, - latestRequestIdsByPath, - pendingFramesByPath, - relativePath, - revealLine, - revealRequestId, - ], + [revealStatesByPath, relativePath, revealLine, revealRequestId], ); } @@ -963,6 +1053,8 @@ export default function FilePreviewPanel({ environmentId={environmentId} cwd={cwd} projectName={projectName} + selectedPath={relativePath} + selectedPathRevealId={revealRequestId} onOpenFile={onOpenFile} /> diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index 06b4717dec0..febffe3d5ee 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -11,6 +11,7 @@ import { useCallback } from "react"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { projectEnvironment } from "~/state/projects"; +import { useProjectPathSearch } from "~/state/queries"; import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; const EMPTY_PROJECT_FILE_PATH = ""; @@ -136,6 +137,32 @@ export function useProjectEntriesQuery( }; } +/** + * Backing query for the project file picker: a debounced, bounded, file-only + * server search. An empty query is a valid request — the index answers it + * with frecency-ordered files, so the picker's initial view is recent files + * without transferring the full workspace listing. `matchedQuery` is the + * query the returned entries were computed for, so the caller can highlight + * against results instead of half-typed input. + */ +export function useProjectFilePickerQuery( + environmentId: EnvironmentId, + cwd: string, + query: string, + limit: number, +) { + const search = useProjectPathSearch({ environmentId, cwd, query, kind: "file" }, limit, { + allowEmptyQuery: true, + }); + + return { + entries: search.isPending ? [] : search.entries, + error: search.error, + isPending: search.isPending, + matchedQuery: search.searchedQuery, + }; +} + export function useProjectFileQuery( environmentId: EnvironmentId, cwd: string, diff --git a/apps/web/src/state/projects.ts b/apps/web/src/state/projects.ts index 7a879988328..d4e1098a364 100644 --- a/apps/web/src/state/projects.ts +++ b/apps/web/src/state/projects.ts @@ -1,11 +1,24 @@ import { createEnvironmentProjectAtoms } from "@t3tools/client-runtime/state/projects"; import { createProjectEnvironmentAtoms } from "@t3tools/client-runtime/state/projects"; +import { createEnvironmentRpcQueryAtomFamily } from "@t3tools/client-runtime/state/runtime"; +import { WS_METHODS } from "@t3tools/contracts"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { environmentSnapshotAtom } from "./shell"; export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime); +/** + * Web-only: project content search backs the ⇧⌘F dialog, which has no mobile + * surface, so the atom family lives here instead of the shared client-runtime + * project atoms consumed by the mobile app. + */ +export const projectContentSearch = createEnvironmentRpcQueryAtomFamily(connectionAtomRuntime, { + label: "environment-data:projects:search-contents", + tag: WS_METHODS.projectsSearchContents, + staleTimeMs: 5_000, + idleTtlMs: 60_000, +}); export const environmentProjects = createEnvironmentProjectAtoms({ catalogValueAtom: environmentCatalog.catalogValueAtom, snapshotAtom: environmentSnapshotAtom, diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index a9564c2fd64..2a095b8f584 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -12,6 +12,8 @@ import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; import type { EnvironmentId, OrchestrationThread, + ProjectContentMatch, + ProjectEntryKind, ThreadId, VcsListRefsResult, VcsRef, @@ -24,16 +26,19 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { orchestrationEnvironment } from "./orchestration"; import { isPaginatedBranchesNextPagePending } from "./paginatedBranches"; -import { projectEnvironment } from "./projects"; +import { projectContentSearch, projectEnvironment } from "./projects"; import { useEnvironmentQuery } from "./query"; import { useEnvironmentThread } from "./threads"; import { vcsEnvironment } from "./vcs"; -const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120; +const PROJECT_PATH_SEARCH_DEBOUNCE_MS = 120; const COMPOSER_PATH_SEARCH_LIMIT = 80; +const PROJECT_CONTENT_SEARCH_DEBOUNCE_MS = 120; +const PROJECT_CONTENT_SEARCH_LIMIT = 500; const THREAD_SEARCH_DEBOUNCE_MS = 200; const VCS_REF_LIST_LIMIT = 100; const EMPTY_REFS: ReadonlyArray = []; +const EMPTY_CONTENT_MATCHES: ReadonlyArray = []; const INITIAL_BRANCH_CURSORS = [undefined] as const; const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray = Object.freeze([]); const EMPTY_THREAD_SEARCH_ATOM = Atom.make({ @@ -229,26 +234,50 @@ export function usePaginatedBranches(target: VcsRefTarget) { }; } -export function useComposerPathSearch(target: ComposerPathSearchTarget) { +type ProjectPathSearchTarget = ComposerPathSearchTarget & { + readonly kind?: ProjectEntryKind | undefined; +}; + +export function areProjectPathSearchTargetsEqual( + left: ProjectPathSearchTarget, + right: ProjectPathSearchTarget, +): boolean { + return ( + left.environmentId === right.environmentId && + left.cwd === right.cwd && + left.query === right.query && + left.kind === right.kind + ); +} + +export function useProjectPathSearch( + target: ProjectPathSearchTarget, + limit: number, + options?: { readonly allowEmptyQuery?: boolean }, +) { + const allowEmptyQuery = options?.allowEmptyQuery === true; const normalizedTarget = useMemo( () => ({ environmentId: target.environmentId, cwd: target.cwd, - query: target.query?.trim() ?? "", + query: target.query == null ? null : target.query.trim(), + kind: target.kind, }), - [target.cwd, target.environmentId, target.query], + [target.cwd, target.environmentId, target.kind, target.query], ); - const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS); + const debouncedTarget = useDebouncedValue(normalizedTarget, PROJECT_PATH_SEARCH_DEBOUNCE_MS); const result = useEnvironmentQuery( debouncedTarget.environmentId !== null && debouncedTarget.cwd !== null && - debouncedTarget.query.length > 0 + debouncedTarget.query !== null && + (allowEmptyQuery || debouncedTarget.query.length > 0) ? projectEnvironment.searchEntries({ environmentId: debouncedTarget.environmentId, input: { cwd: debouncedTarget.cwd, query: debouncedTarget.query, - limit: COMPOSER_PATH_SEARCH_LIMIT, + limit, + ...(debouncedTarget.kind ? { kind: debouncedTarget.kind } : {}), }, }) : null, @@ -257,11 +286,61 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { return { entries: result.data?.entries ?? [], error: result.error, - isPending: normalizedTarget.query !== debouncedTarget.query || result.isPending, + isPending: + !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) || result.isPending, + searchedQuery: debouncedTarget.query ?? "", refresh: result.refresh, }; } +export function useComposerPathSearch(target: ComposerPathSearchTarget) { + return useProjectPathSearch(target, COMPOSER_PATH_SEARCH_LIMIT); +} + +interface ProjectContentSearchTarget { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; + readonly query: string; + readonly caseSensitive: boolean; + readonly wholeWord: boolean; + readonly useRegex: boolean; +} + +export function useProjectContentSearch(target: ProjectContentSearchTarget) { + // Whitespace is significant in content queries; trimming is only used to + // decide whether the input is blank. + const query = target.query; + const hasQuery = query.trim().length > 0; + const debouncedQuery = useDebouncedValue(query, PROJECT_CONTENT_SEARCH_DEBOUNCE_MS); + const result = useEnvironmentQuery( + target.environmentId !== null && + target.cwd !== null && + hasQuery && + debouncedQuery.trim().length > 0 + ? projectContentSearch({ + environmentId: target.environmentId, + input: { + cwd: target.cwd, + query: debouncedQuery, + limit: PROJECT_CONTENT_SEARCH_LIMIT, + caseSensitive: target.caseSensitive, + wholeWord: target.wholeWord, + useRegex: target.useRegex, + }, + }) + : null, + ); + + return { + matches: result.data?.matches ?? EMPTY_CONTENT_MATCHES, + error: result.error, + isPending: hasQuery && (query !== debouncedQuery || result.isPending), + hasQuery, + truncated: result.data?.truncated ?? false, + invalidRegex: target.useRegex && result.data?.regexFallbackError !== undefined, + }; +} + export function useCheckpointDiff( target: CheckpointDiffTarget, options?: { readonly enabled?: boolean }, diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index a8fa565cef4..9a63f22c9ef 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -1,4 +1,5 @@ import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; @@ -20,6 +21,30 @@ export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximu export const IsoDateTime = Schema.String; export type IsoDateTime = typeof IsoDateTime.Type; +/** + * Wire codec for server→client arrays whose element unions grow over time + * (new literal members, new struct variants). Decoding drops elements the + * current build cannot decode instead of failing the whole payload — a client + * has to keep decoding configs sent by servers newer than itself, and + * rejecting the payload would take down the connection over data the client + * couldn't act on anyway. Encoding is the plain array encoding. + */ +export const ForwardCompatibleArray = (element: Element) => { + const decodeElement = Schema.decodeUnknownOption(element as never); + return Schema.Array(Schema.Unknown).pipe( + Schema.decodeTo( + Schema.Array(element), + SchemaTransformation.transform, ReadonlyArray>({ + decode: (values) => + values.filter((value) => Option.isSome(decodeElement(value))) as ReadonlyArray< + Element["Encoded"] + >, + encode: (values) => values, + }), + ), + ); +}; + /** * Construct a branded identifier. Enforces non-empty trimmed strings */ diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 33ecd38039f..ec8c839be95 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -20,6 +20,7 @@ const decode = ( >; const decodeResolvedRule = Schema.decodeUnknownEffect(ResolvedKeybindingRule as never); +const encodeResolvedKeybindings = Schema.encodeEffect(ResolvedKeybindingsConfig); it.effect("parses keybinding rules", () => Effect.gen(function* () { @@ -59,6 +60,18 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedCommandPalette.command, "commandPalette.toggle"); + const parsedFilePicker = yield* decode(KeybindingRule, { + key: "mod+p", + command: "filePicker.toggle", + }); + assert.strictEqual(parsedFilePicker.command, "filePicker.toggle"); + + const parsedProjectSearch = yield* decode(KeybindingRule, { + key: "mod+shift+f", + command: "projectSearch.toggle", + }); + assert.strictEqual(parsedProjectSearch.command, "projectSearch.toggle"); + const parsedLocal = yield* decode(KeybindingRule, { key: "mod+shift+n", command: "chat.newLocal", @@ -173,6 +186,70 @@ it.effect("parses resolved keybindings arrays", () => }), ); +const shortcut = { + key: "p", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, +}; + +it.effect("drops resolved rules with commands this build does not know", () => + Effect.gen(function* () { + const parsed = yield* decode(ResolvedKeybindingsConfig, [ + { command: "terminal.toggle", shortcut }, + { command: "someFuture.toggle", shortcut }, + { command: "filePicker.toggle", shortcut }, + ]); + assert.deepEqual( + parsed.map((rule) => rule.command), + ["terminal.toggle", "filePicker.toggle"], + ); + }), +); + +it.effect("drops resolved rules with unknown when-node types", () => + Effect.gen(function* () { + const parsed = yield* decode(ResolvedKeybindingsConfig, [ + { + command: "terminal.toggle", + shortcut, + whenAst: { type: "xor", left: 1, right: 2 }, + }, + { command: "terminal.split", shortcut }, + ]); + assert.deepEqual( + parsed.map((rule) => rule.command), + ["terminal.split"], + ); + }), +); + +it.effect("drops malformed resolved rule entries", () => + Effect.gen(function* () { + const parsed = yield* decode(ResolvedKeybindingsConfig, [ + "garbage", + { command: "terminal.toggle", shortcut }, + null, + ]); + assert.deepEqual( + parsed.map((rule) => rule.command), + ["terminal.toggle"], + ); + }), +); + +it.effect("encodes resolved keybindings to the plain wire shape", () => + Effect.gen(function* () { + const rules = [{ command: "terminal.toggle" as const, shortcut }]; + const encoded = yield* encodeResolvedKeybindings(rules); + assert.deepEqual(encoded, rules); + const roundTripped = yield* decode(ResolvedKeybindingsConfig, encoded); + assert.deepEqual(roundTripped, rules); + }), +); + it.effect("drops unknown fields in resolved keybinding rules", () => decodeResolvedRule({ command: "terminal.toggle", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index f000648d236..eba8f8ef170 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -1,5 +1,5 @@ import * as Schema from "effect/Schema"; -import { TrimmedString } from "./baseSchemas.ts"; +import { ForwardCompatibleArray, TrimmedString } from "./baseSchemas.ts"; export const MAX_KEYBINDING_VALUE_LENGTH = 64; export const MAX_KEYBINDING_WHEN_LENGTH = 256; @@ -63,6 +63,8 @@ const STATIC_KEYBINDING_COMMANDS = [ "preview.zoomOut", "preview.resetZoom", "commandPalette.toggle", + "filePicker.toggle", + "projectSearch.toggle", "composer.stash", "board.open", "chat.new", @@ -154,7 +156,14 @@ export const ResolvedKeybindingRule = Schema.Struct({ }).annotate({ parseOptions: { onExcessProperty: "ignore" } }); export type ResolvedKeybindingRule = typeof ResolvedKeybindingRule.Type; -export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).check( +/** + * The command set grows over time, so a client may receive rules it cannot + * represent (a command or `when` node added after that client shipped). + * Decoding drops those rules instead of failing the whole payload — + * rejecting the config would take down the connection over a shortcut the + * client couldn't dispatch anyway. + */ +export const ResolvedKeybindingsConfig = ForwardCompatibleArray(ResolvedKeybindingRule).check( Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), ); export type ResolvedKeybindingsConfig = typeof ResolvedKeybindingsConfig.Type; diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts index ea9d5a90e7c..8e6771cba88 100644 --- a/packages/contracts/src/project.test.ts +++ b/packages/contracts/src/project.test.ts @@ -3,10 +3,40 @@ import { describe, expect, it } from "vite-plus/test"; import { ProjectReadFileError, + ProjectSearchContentsError, + ProjectSearchContentsInput, ProjectSearchEntriesError, + ProjectSearchEntriesInput, ProjectWriteFileError, } from "./project.ts"; +const decodeSearchEntriesInput = Schema.decodeUnknownSync(ProjectSearchEntriesInput); +const decodeSearchContentsInput = Schema.decodeUnknownSync(ProjectSearchContentsInput); + +describe("project search inputs", () => { + it("allows an empty entries query for bounded frecency browsing", () => { + const decoded = decodeSearchEntriesInput({ + cwd: "/workspace", + query: " ", + limit: 10, + kind: "file", + }); + expect(decoded.query).toBe(""); + }); + + it("preserves whitespace in content search queries", () => { + const decoded = decodeSearchContentsInput({ + cwd: "/workspace", + query: " foo ", + limit: 10, + caseSensitive: false, + wholeWord: false, + useRegex: false, + }); + expect(decoded.query).toBe(" foo "); + }); +}); + describe("project RPC errors", () => { it("derives stable messages from structured request context while retaining causes", () => { const cause = new Error("sensitive platform detail"); @@ -39,6 +69,18 @@ describe("project RPC errors", () => { expect(readError.message).toBe("Failed to read workspace file 'src/index.ts' in '/workspace'."); expect(readError.message).not.toContain(cause.message); expect(readError.cause).toBe(cause); + + const contentSearchError = new ProjectSearchContentsError({ + cwd: "/workspace", + queryLength: "authorization: Bearer secret-token".length, + limit: 100, + failure: "search_index_search_failed", + cause, + }); + expect(contentSearchError.message).toBe("Failed to search workspace contents in '/workspace'."); + expect(contentSearchError.message).not.toContain(cause.message); + expect(contentSearchError).not.toHaveProperty("query"); + expect(contentSearchError.cause).toBe(cause); }); it("decodes legacy message-only errors during rolling upgrades", () => { diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index f6b54d796c3..252cb34b1ed 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -1,19 +1,29 @@ import * as Schema from "effect/Schema"; -import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { + NonNegativeInt, + PositiveInt, + TrimmedNonEmptyString, + TrimmedString, +} from "./baseSchemas.ts"; const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; +const PROJECT_SEARCH_CONTENTS_MAX_LIMIT = 500; const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512; const PROJECT_READ_FILE_PATH_MAX_LENGTH = 512; +export const ProjectEntryKind = Schema.Literals(["file", "directory"]); +export type ProjectEntryKind = typeof ProjectEntryKind.Type; + export const ProjectSearchEntriesInput = Schema.Struct({ cwd: TrimmedNonEmptyString, - query: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), + // An empty query is a bounded browse: the index returns frecency-ordered + // entries, which the file picker uses for its initial results. + query: TrimmedString.check(Schema.isMaxLength(256)), limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT)), + kind: Schema.optional(ProjectEntryKind), }); export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type; -const ProjectEntryKind = Schema.Literals(["file", "directory"]); - export const ProjectEntry = Schema.Struct({ path: TrimmedNonEmptyString, kind: ProjectEntryKind, @@ -26,6 +36,39 @@ export const ProjectSearchEntriesResult = Schema.Struct({ }); export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type; +export const ProjectSearchContentsInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + // Whitespace is significant in content queries (" foo", regex trailing + // spaces), so the query is deliberately not trimmed on the wire. + query: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(256)), + limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_CONTENTS_MAX_LIMIT)), + caseSensitive: Schema.Boolean, + wholeWord: Schema.Boolean, + useRegex: Schema.Boolean, +}); +export type ProjectSearchContentsInput = typeof ProjectSearchContentsInput.Type; + +export const ProjectContentMatchRange = Schema.Struct({ + start: NonNegativeInt, + end: NonNegativeInt, +}); +export type ProjectContentMatchRange = typeof ProjectContentMatchRange.Type; + +export const ProjectContentMatch = Schema.Struct({ + path: TrimmedNonEmptyString, + lineNumber: PositiveInt, + lineContent: Schema.String, + matchRanges: Schema.Array(ProjectContentMatchRange), +}); +export type ProjectContentMatch = typeof ProjectContentMatch.Type; + +export const ProjectSearchContentsResult = Schema.Struct({ + matches: Schema.Array(ProjectContentMatch), + truncated: Schema.Boolean, + regexFallbackError: Schema.optional(Schema.String), +}); +export type ProjectSearchContentsResult = typeof ProjectSearchContentsResult.Type; + export const ProjectListEntriesInput = Schema.Struct({ cwd: TrimmedNonEmptyString, }); @@ -94,6 +137,37 @@ export class ProjectSearchEntriesError extends Schema.TaggedErrorClass()( + "ProjectSearchContentsError", + { + cwd: Schema.optional(TrimmedNonEmptyString), + queryLength: Schema.optional(NonNegativeInt), + limit: Schema.optional(PositiveInt), + failure: Schema.optional(ProjectEntriesFailure), + normalizedCwd: Schema.optional(TrimmedNonEmptyString), + timeout: Schema.optional(TrimmedNonEmptyString), + detail: Schema.optional(TrimmedNonEmptyString), + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor( + props: ProjectEntriesFailureContext & { + readonly cwd: string; + readonly queryLength: number; + readonly limit: number; + }, + ) { + super({ + ...props, + message: + decodedProjectErrorMessage(props) ?? + `Failed to search workspace contents in '${props.cwd}'.`, + } as any); + } +} + export class ProjectListEntriesError extends Schema.TaggedErrorClass()( "ProjectListEntriesError", { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 231808719af..b78069acbac 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -90,6 +90,9 @@ import { ProjectReadFileError, ProjectReadFileInput, ProjectReadFileResult, + ProjectSearchContentsError, + ProjectSearchContentsInput, + ProjectSearchContentsResult, ProjectSearchEntriesError, ProjectSearchEntriesInput, ProjectSearchEntriesResult, @@ -474,6 +477,12 @@ export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntr error: Schema.Union([ProjectSearchEntriesError, EnvironmentAuthorizationError]), }); +export const WsProjectsSearchContentsRpc = Rpc.make(WS_METHODS.projectsSearchContents, { + payload: ProjectSearchContentsInput, + success: ProjectSearchContentsResult, + error: Schema.Union([ProjectSearchContentsError, EnvironmentAuthorizationError]), +}); + export const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, { payload: ProjectListEntriesInput, success: ProjectListEntriesResult, diff --git a/packages/shared/src/projectFavicon.test.ts b/packages/shared/src/projectFavicon.test.ts index 0011b2fc7c9..1df17cc7fe5 100644 --- a/packages/shared/src/projectFavicon.test.ts +++ b/packages/shared/src/projectFavicon.test.ts @@ -1,8 +1,32 @@ import { describe, expect, it } from "vite-plus/test"; -import { isProjectFaviconFallbackUrl, PROJECT_FAVICON_FALLBACK_MARKER } from "./projectFavicon.ts"; +import { + getProjectFaviconCacheKey, + isProjectFaviconFallbackUrl, + PROJECT_FAVICON_FALLBACK_MARKER, +} from "./projectFavicon.ts"; describe("project favicon", () => { + it("uses the project and versioned filename as the cache identity", () => { + const firstUrl = "https://environment.example/api/assets/first-signed-token/v1-20-favicon.svg"; + const refreshedUrl = + "https://environment.example/api/assets/refreshed-signed-token/v1-20-favicon.svg"; + + expect(getProjectFaviconCacheKey("environment-1", "/workspace", firstUrl)).toBe( + getProjectFaviconCacheKey("environment-1", "/workspace", refreshedUrl), + ); + expect(getProjectFaviconCacheKey("environment-1", "/workspace", firstUrl)).not.toBe( + getProjectFaviconCacheKey( + "environment-1", + "/workspace", + "https://environment.example/api/assets/refreshed-signed-token/v2-20-favicon.svg", + ), + ); + expect(getProjectFaviconCacheKey("environment-1", "/workspace", firstUrl)).not.toBe( + getProjectFaviconCacheKey("environment-2", "/workspace", firstUrl), + ); + }); + it("identifies fallback asset URLs by their dedicated filename", () => { expect( isProjectFaviconFallbackUrl( diff --git a/packages/shared/src/projectFavicon.ts b/packages/shared/src/projectFavicon.ts index 2e46429b6c1..eebc1a8a1b6 100644 --- a/packages/shared/src/projectFavicon.ts +++ b/packages/shared/src/projectFavicon.ts @@ -1,5 +1,22 @@ export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing"; +export function getProjectFaviconCacheKey( + environmentId: string, + workspaceRoot: string, + url: string, +) { + let revision = url; + + try { + const pathname = new URL(url, "https://t3.invalid").pathname; + revision = pathname.slice(pathname.lastIndexOf("/") + 1); + } catch { + // Keep the full value as a safe fallback for malformed URLs. + } + + return JSON.stringify([environmentId, workspaceRoot, revision]); +} + export function isProjectFaviconFallbackUrl(url: string | null | undefined): boolean { if (!url) return false; From 2ef5854b8c275ff62081193e4d67c32de4185ed8 Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:10:27 +0200 Subject: [PATCH 6/8] fix(desktop): restore hydratePosixHome after overlay reapply Desktop reapply dropped hydratePosixHome from os-jank while tests still import it; restore the fork/changes implementation. --- apps/server/src/os-jank.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index bc72758bc71..18ddbc66c0c 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -36,6 +36,18 @@ function hydratePosixPath(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): vo } } +export function hydratePosixHome( + env: NodeJS.ProcessEnv, + resolveHomeDir = () => NodeOS.userInfo().homedir, +): void { + if ((env.HOME?.trim() ?? "").length > 0) return; + + const homeDir = resolveHomeDir(); + if (homeDir.length > 0) { + env.HOME = homeDir; + } +} + export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return< void, never, @@ -63,6 +75,13 @@ export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return< if (platform !== "darwin" && platform !== "linux") return; + yield* Effect.sync(() => hydratePosixHome(env)).pipe( + Effect.catchDefect((defect) => + Effect.sync(() => { + logPathHydrationWarning("Failed to hydrate HOME from the user account.", defect); + }), + ), + ); yield* Effect.sync(() => hydratePosixPath(env, platform)).pipe( Effect.catchDefect((defect) => Effect.sync(() => { From 4556c539dc12bf95d4bc19cb4bd85f7661a4ac0b Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:14:02 +0200 Subject: [PATCH 7/8] fix(desktop): bind Clerk bridge for isPrimaryInstance check Product-merge after restack kept the primary-instance gate but dropped the acquireRelease assignment, leaving `bridge` unbound. --- apps/desktop/src/app/DesktopClerk.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index c3e49693487..38364f69918 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -83,7 +83,7 @@ export function createDesktopClerkBridge(stateDir: string, isDevelopment: boolea export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; - yield* Effect.acquireRelease( + const bridge = yield* Effect.acquireRelease( Effect.try({ try: () => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment), catch: (cause) => From a8655840a7fab7450d5f5c4aa0c62d319acc187a Mon Sep 17 00:00:00 2001 From: Patrick Roza Date: Tue, 4 Aug 2026 18:32:59 +0200 Subject: [PATCH 8/8] fix(desktop): keep Effect beta.103 catalogs after overlay reapply --- pnpm-workspace.yaml | 42 ++++++++++++++++++++---------------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a6998716f8d..3f13f1872f7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -31,21 +31,21 @@ catalog: "@clerk/expo": 4.1.2 "@clerk/react": 6.12.9 "@clerk/shared": 4.25.9 - "@effect/atom-react": 4.0.0-beta.102 - "@effect/openapi-generator": 4.0.0-beta.102 - "@effect/platform-bun": 4.0.0-beta.102 - "@effect/platform-node": 4.0.0-beta.102 - "@effect/platform-node-shared": 4.0.0-beta.102 - "@effect/sql-pg": 4.0.0-beta.102 - "@effect/sql-sqlite-bun": 4.0.0-beta.102 + "@effect/atom-react": 4.0.0-beta.103 + "@effect/openapi-generator": 4.0.0-beta.103 + "@effect/platform-bun": 4.0.0-beta.103 + "@effect/platform-node": 4.0.0-beta.103 + "@effect/platform-node-shared": 4.0.0-beta.103 + "@effect/sql-pg": 4.0.0-beta.103 + "@effect/sql-sqlite-bun": 4.0.0-beta.103 "@effect/tsgo": 0.13.2 - "@effect/vitest": 4.0.0-beta.102 + "@effect/vitest": 4.0.0-beta.103 "@noble/curves": 1.9.1 "@noble/hashes": 1.8.0 "@pierre/diffs": 1.3.0-beta.10 "@types/node": 24.12.4 "@typescript/native-preview": 7.0.0-dev.20260604.1 - effect: 4.0.0-beta.102 + effect: 4.0.0-beta.103 jose: 6.2.2 typescript: ~6.0.3 vite: npm:@voidzero-dev/vite-plus-core@0.2.2 @@ -65,16 +65,16 @@ minimumReleaseAgeExclude: - "@distilled.cloud/core@0.30.2" - "@distilled.cloud/neon@0.30.2" - "@distilled.cloud/planetscale@0.30.2" - - "@effect/atom-react@4.0.0-beta.102" - - "@effect/openapi-generator@4.0.0-beta.102" - - "@effect/platform-bun@4.0.0-beta.102" - - "@effect/platform-node-shared@4.0.0-beta.102" - - "@effect/platform-node@4.0.0-beta.102" - - "@effect/sql-pg@4.0.0-beta.102" - - "@effect/sql-sqlite-bun@4.0.0-beta.102" - - "@effect/vitest@4.0.0-beta.102" + - "@effect/atom-react@4.0.0-beta.103" + - "@effect/openapi-generator@4.0.0-beta.103" + - "@effect/platform-bun@4.0.0-beta.103" + - "@effect/platform-node-shared@4.0.0-beta.103" + - "@effect/platform-node@4.0.0-beta.103" + - "@effect/sql-pg@4.0.0-beta.103" + - "@effect/sql-sqlite-bun@4.0.0-beta.103" + - "@effect/vitest@4.0.0-beta.103" - alchemy@2.0.0-beta.65 - - effect@4.0.0-beta.102 + - effect@4.0.0-beta.103 overrides: "@clerk/backend": "catalog:" @@ -123,16 +123,14 @@ packageExtensions: vite: "catalog:" patchedDependencies: - "@effect/platform-bun@4.0.0-beta.102": patches/@effect__platform-bun@4.0.0-beta.102.patch - "@effect/platform-node@4.0.0-beta.102": patches/@effect__platform-node@4.0.0-beta.102.patch - "@effect/vitest@4.0.0-beta.102": patches/@effect__vitest@4.0.0-beta.102.patch + "@effect/vitest@4.0.0-beta.103": patches/@effect__vitest@4.0.0-beta.103.patch "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch "@legendapp/list@3.3.3": patches/@legendapp__list@3.3.3.patch "@pierre/diffs@1.3.0-beta.10": patches/@pierre%2Fdiffs@1.3.0-beta.10.patch "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch "@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch - effect@4.0.0-beta.102: patches/effect@4.0.0-beta.102.patch + effect@4.0.0-beta.103: patches/effect@4.0.0-beta.103.patch expo-modules-jsi@56.0.10: patches/expo-modules-jsi@56.0.10.patch react-native-gesture-handler@2.31.2: patches/react-native-gesture-handler@2.31.2.patch react-native-keyboard-controller@1.21.13: patches/react-native-keyboard-controller@1.21.13.patch