diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index f3e3dcfd8ce5..98c1c3b20224 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 -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. +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. ## Start or reuse Metro safely diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 07438b251c5d..9bc321dac0de 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -30,6 +30,7 @@ body: - apps/web - apps/server - apps/desktop + - apps/mobile - packages/contracts or packages/shared - Build, CI, or release tooling - Docs diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 53aab5166a56..3c9424fb322c 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -30,6 +30,7 @@ body: - apps/web - apps/server - apps/desktop + - apps/mobile - packages/contracts or packages/shared - Build, CI, or release tooling - Docs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a418a463c2b..1e51867cbe7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,10 @@ on: branches: - main +concurrency: + group: ci-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: check: name: Check @@ -14,6 +18,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 @@ -55,6 +64,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 @@ -77,18 +91,25 @@ jobs: mobile_native_static_analysis: name: Mobile Native Static Analysis - runs-on: blacksmith-12vcpu-macos-26 + runs-on: blacksmith-6vcpu-macos-26 timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=@t3tools/scripts... - name: Install mobile native static analysis tools run: brew bundle install --file apps/mobile/Brewfile @@ -103,13 +124,20 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=@t3tools/scripts... - name: Exercise release-only workflow steps run: node scripts/release-smoke.ts diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index 94d4af17e41a..f652844a54f3 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -38,13 +38,20 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=t3code-relay... - name: Deploy production relay stage id: deploy diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml index 32e45fef54e5..d53602f8f5e8 100644 --- a/.github/workflows/mobile-eas-preview.yml +++ b/.github/workflows/mobile-eas-preview.yml @@ -2,13 +2,18 @@ name: Mobile EAS Preview on: pull_request: - types: [opened, reopened, synchronize, labeled, unlabeled] + types: [opened, reopened, synchronize, labeled] jobs: preview: name: EAS Preview - if: contains(github.event.pull_request.labels.*.name, 'πŸš€ Mobile Continuous Deployment') + if: | + contains(github.event.pull_request.labels.*.name, 'πŸš€ Mobile Continuous Deployment') && + (github.event.action != 'labeled' || github.event.label.name == 'πŸš€ Mobile Continuous Deployment') runs-on: blacksmith-8vcpu-ubuntu-2404 + concurrency: + group: mobile-eas-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true permissions: contents: read pull-requests: write @@ -34,6 +39,10 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 + # No sparse-checkout here: it makes actions/checkout fetch with + # --filter=blob:none, and eas-cli archives the project via + # `git clone --depth 1 file://`, which fails (exit 128) + # when the partial clone can't serve the unfetched blobs. - name: Setup Vite+ if: steps.expo-token.outputs.present == 'true' @@ -41,7 +50,9 @@ jobs: with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=@t3tools/mobile... - name: Expose pnpm if: steps.expo-token.outputs.present == 'true' diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 685df85e57ce..2e61de6039e8 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -57,6 +57,10 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 + # No sparse-checkout here: it makes actions/checkout fetch with + # --filter=blob:none, and eas-cli archives the project via + # `git clone --depth 1 file://`, which fails (exit 128) + # when the partial clone can't serve the unfetched blobs. - name: Setup Vite+ if: steps.expo-token.outputs.present == 'true' @@ -64,7 +68,9 @@ jobs: with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=@t3tools/mobile... - name: Expose pnpm if: steps.expo-token.outputs.present == 'true' diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml index 36dfb61f73f5..0aa9f30a2ff2 100644 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -37,13 +37,21 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=@t3tools/mobile... + - --filter=@t3tools/scripts... - name: Expose pnpm run: | @@ -77,13 +85,21 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=@t3tools/mobile... + - --filter=@t3tools/scripts... - name: Expose pnpm run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc0b0622d7f6..ce47d6e6ed73 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,6 +38,10 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - id: check name: Compare HEAD to last nightly tag @@ -84,6 +88,10 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 @@ -192,6 +200,10 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 @@ -269,14 +281,19 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} - fetch-depth: 0 + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=t3... - name: Build node-pty linux-x64 prebuild shell: bash @@ -359,14 +376,21 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} - fetch-depth: 0 + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json cache: true - run-install: true + run-install: | + args: + - --filter=@t3tools/desktop... + - --filter=t3... + - --filter=@t3tools/scripts... - name: Setup Rust uses: dtolnay/rust-toolchain@stable @@ -657,6 +681,10 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 @@ -732,6 +760,10 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 @@ -853,6 +885,10 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 @@ -967,6 +1003,10 @@ jobs: fetch-depth: 0 token: ${{ steps.app_token.outputs.token }} persist-credentials: true + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - id: app_bot name: Resolve GitHub App bot identity @@ -1037,6 +1077,10 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@v1 diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 2eb540f05263..6c5385e70a6d 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -54,7 +54,6 @@ 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 385e694338dd..0be55d633e61 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -45,6 +45,27 @@ 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; @@ -90,24 +111,11 @@ export const make = Effect.gen(function* () { return commitHash; }); - 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 userDataPath = resolveUserDataPath.pipe( + Effect.provide( + yield* Effect.context(), + ), + ); const configure = Effect.gen(function* () { const commitHash = yield* resolveAboutCommitHash; @@ -136,7 +144,7 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.appIdentity.configure")); return DesktopAppIdentity.of({ - resolveUserDataPath, + resolveUserDataPath: userDataPath, configure, }); }); diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 9b5ed56d1f34..2f61ca909aef 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -22,17 +22,38 @@ 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 DesktopEnvironment from "./DesktopEnvironment.ts"; -const makeDesktopClerkLayer = (isDevelopment = true) => { +const makeDesktopClerkLayer = (isDevelopment = true, events: string[] = []) => { 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("/") }, } 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.succeed(DesktopEnvironment.DesktopEnvironment, environment)), + Layer.provide( + Layer.mergeAll( + Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment), + Layer.succeed(ElectronApp.ElectronApp, electronApp), + FileSystem.layerNoop({ exists: () => Effect.succeed(false) }), + ), + ), ); }; @@ -55,11 +76,15 @@ describe("DesktopClerk", () => { it.effect("acquires and releases the SDK bridge with the layer", () => { const cleanup = vi.fn(); + const events: string[] = []; storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockReturnValue({ cleanup }); + createClerkBridgeMock.mockImplementation(() => { + events.push("createClerkBridge"); + return { cleanup, isPrimaryInstance: true }; + }); return Effect.gen(function* () { - yield* Effect.scoped(Layer.build(makeDesktopClerkLayer())); + yield* Effect.scoped(Layer.build(makeDesktopClerkLayer(true, events))); assert.deepEqual(createClerkBridgeMock.mock.calls, [ [ @@ -71,6 +96,10 @@ 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(); }); @@ -124,11 +153,67 @@ 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() }; + const bridge = { cleanup: vi.fn(), isPrimaryInstance: true }; storageMock.mockReturnValue(storageAdapter); createClerkBridgeMock.mockReturnValue(bridge); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 0e283f8dd0c4..9611dc083d2f 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -11,6 +11,7 @@ import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/rela 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 DesktopEnvironment from "./DesktopEnvironment.ts"; declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; @@ -84,7 +85,18 @@ export function createDesktopClerkBridge(stateDir: string, isDevelopment: boolea export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; - yield* Effect.acquireRelease( + 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( Effect.try({ try: () => createDesktopClerkBridge(environment.stateDir, environment.isDevelopment), catch: (cause) => @@ -113,7 +125,12 @@ export const make = Effect.gen(function* () { const context = yield* Effect.context(); const runPromise = Effect.runPromiseWith(context); - if (!(yield* electronApp.requestSingleInstanceLock)) { + // The SDK bridge holds Electron's single-instance lock (acquired at + // bridge creation) so OAuth deep-link callbacks on Windows/Linux are + // forwarded to the running app. In a secondary instance the bridge has + // already begun quitting the app; app.quit() is asynchronous, so stop + // bootstrap here before whenReady can fire. + if (!bridge.isPrimaryInstance) { yield* electronApp.quit; return yield* Effect.interrupt; } diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index e5ce72f8e48a..978e000a7f58 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -29,7 +29,6 @@ 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), diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index 077b343959ca..ac14f56ad1a5 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -14,7 +14,6 @@ const { quitMock, relaunchMock, removeListenerMock, - requestSingleInstanceLockMock, setAboutPanelOptionsMock, setAppUserModelIdMock, setAsDefaultProtocolClientMock, @@ -35,7 +34,6 @@ const { quitMock: vi.fn(), relaunchMock: vi.fn(), removeListenerMock: vi.fn(), - requestSingleInstanceLockMock: vi.fn(() => true), setAboutPanelOptionsMock: vi.fn(), setAppUserModelIdMock: vi.fn(), setAsDefaultProtocolClientMock: vi.fn(() => true), @@ -67,7 +65,6 @@ vi.mock("electron", () => ({ quit: quitMock, relaunch: relaunchMock, removeListener: removeListenerMock, - requestSingleInstanceLock: requestSingleInstanceLockMock, runningUnderARM64Translation: false, setAboutPanelOptions: setAboutPanelOptionsMock, setAsDefaultProtocolClient: setAsDefaultProtocolClientMock, diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 5f8052f902dc..73323617195d 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -56,7 +56,6 @@ 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,7 +152,6 @@ 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/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts index 475be3da1519..36cdcb50b6ba 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -34,7 +34,6 @@ 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 34fc4447146f..f04a49f82afa 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -39,7 +39,6 @@ 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), diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 4a0c761f2c69..0147bb71a862 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -161,7 +161,7 @@ const config: ExpoConfig = { slug: "t3-code", platforms: ["ios", "android"], scheme: variant.scheme, - version: "0.1.0", + version: "1.0.1", runtimeVersion: { // Fingerprint (not appVersion) so an OTA only reaches binaries whose native // project β€” native deps, config plugins, AND patches/ β€” matches the update. @@ -275,10 +275,12 @@ const config: ExpoConfig = { "expo-camera", { cameraPermission: "Allow T3 Code to access your camera so you can scan pairing QR codes.", + microphonePermission: false, barcodeScannerEnabled: true, recordAudioAndroid: false, }, ], + ["expo-image-picker", { photosPermission: false, microphonePermission: false }], [ "expo-splash-screen", { diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 9a5e64aa46f7..c12ca979bf2f 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -11,7 +11,7 @@ "start:dev": "APP_VARIANT=development expo start", "start:preview": "APP_VARIANT=preview expo start", "start:prod": "APP_VARIANT=production expo start", - "showcase": "APP_VARIANT=development EXPO_PUBLIC_SHOWCASE=1 expo start --dev-client --scheme t3code-dev --clear", + "showcase": "APP_VARIANT=production EXPO_PUBLIC_SHOWCASE=1 expo start --dev-client --scheme t3code --clear", "screenshots": "node ../../scripts/mobile-showcase.ts", "android": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android", "android:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && REACT_NATIVE_PACKAGER_HOSTNAME=localhost expo run:android", diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 76a9399772d5..719a6a4ad592 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -15,6 +15,7 @@ import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "re import { useResolveClassNames } from "uniwind"; import { AppText as Text } from "./components/AppText"; +import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent"; @@ -392,7 +393,7 @@ export const RootStack = createNativeStackNavigator({ ...GLASS_HEADER_OPTIONS, contentStyle: { backgroundColor: "transparent" }, headerBackVisible: false, - title: "Threads", + ...getCompactBrandHeaderOptions(), }, }), Thread: createNativeStackScreen({ diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx new file mode 100644 index 000000000000..f0710e85d36d --- /dev/null +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -0,0 +1,121 @@ +import Constants from "expo-constants"; +import type { + NativeStackHeaderItem, + NativeStackNavigationOptions, +} from "@react-navigation/native-stack"; +import { Platform, View } from "react-native"; + +import { AppText as Text } from "./AppText"; +import { T3Wordmark } from "./T3Wordmark"; +import { IPAD_HOME_TITLE_OFFSET } from "../lib/layoutMetrics"; +import { resolveMobileStageLabel } from "../lib/mobileBranding"; +import { useThemeColor } from "../lib/useThemeColor"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../native/native-glass"; + +// Native leading items inherit different UIKit margins than title views. +const IOS_NATIVE_LEADING_TITLE_OFFSET = -6; +const IPAD_NATIVE_LEADING_TITLE_OFFSET = 7; + +/** + * Compact brand lockup sized for native navigation bars. + */ +export function CompactBrandTitle( + props: { + readonly nativeLeadingItem?: boolean; + } = {}, +) { + const iconColor = useThemeColor("--color-icon"); + const mutedColor = useThemeColor("--color-foreground-muted"); + const subtleColor = useThemeColor("--color-subtle"); + const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); + const titleOffset = + Platform.OS !== "ios" + ? 0 + : props.nativeLeadingItem + ? Platform.isPad + ? IPAD_NATIVE_LEADING_TITLE_OFFSET + : IOS_NATIVE_LEADING_TITLE_OFFSET + : Platform.isPad + ? IPAD_HOME_TITLE_OFFSET + : 0; + + return ( + + + + Code + + + + {stageLabel} + + + + ); +} + +export function renderCompactBrandTitle() { + return ; +} + +export function renderCompactBrandHeaderItems(): NativeStackHeaderItem[] { + return [ + { + element: , + hidesSharedBackground: true, + type: "custom", + }, + ]; +} + +export function getCompactBrandHeaderOptions( + fallbackTitleStyle?: NativeStackNavigationOptions["headerTitleStyle"], +): NativeStackNavigationOptions { + if (Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED) { + return { + headerTitle: "Threads", + headerTitleStyle: { color: "transparent", fontSize: 18, fontWeight: "800" }, + title: "Threads", + unstable_headerLeftItems: renderCompactBrandHeaderItems, + }; + } + + return { + headerTitle: renderCompactBrandTitle, + headerTitleStyle: fallbackTitleStyle, + title: "Threads", + }; +} diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 772d5e8cc14c..d52aa05b4466 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -1,14 +1,21 @@ import { SymbolView } from "./AppSymbol"; import { Image } from "expo-image"; -import { useState } from "react"; +import { useLayoutEffect, useMemo, useState } from "react"; import { View } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; -import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; +import { + getProjectFaviconCacheKey, + isProjectFaviconFallbackUrl, +} from "@t3tools/shared/projectFavicon"; import { useThemeColor } from "../lib/useThemeColor"; import { useAssetUrl } from "../state/assets"; - -/* ─── Favicon cache (matches web pattern) ────────────────────────────── */ -const loadedFaviconUrls = new Set(); +import { + beginProjectFaviconRequest, + createProjectFaviconRequest, + hasLoadedProjectFavicon, + markProjectFaviconFailed, + markProjectFaviconLoaded, +} from "./projectFaviconCache"; /* ─── Component ──────────────────────────────────────────────────────── */ export function ProjectFavicon(props: { @@ -26,10 +33,15 @@ export function ProjectFavicon(props: { : { _tag: "project-favicon", cwd: props.workspaceRoot }, ); const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; + const cacheKey = + renderableFaviconUrl && props.workspaceRoot + ? getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) + : null; return ( createProjectFaviconRequest(props.cacheKey, props.faviconUrl), + [props.cacheKey, props.faviconUrl], + ); + const [activeFaviconRequest, setActiveFaviconRequest] = useState(null); + useLayoutEffect(() => { + if (faviconRequest === null) return; + + const endRequest = beginProjectFaviconRequest(faviconRequest); + setActiveFaviconRequest(faviconRequest); + return endRequest; + }, [faviconRequest]); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => - props.faviconUrl && loadedFaviconUrls.has(props.faviconUrl) ? "loaded" : "loading", + hasLoadedProjectFavicon(props.cacheKey) ? "loaded" : "loading", ); - const showImage = props.faviconUrl !== null && status === "loaded"; + const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest; + const showImage = requestIsActive && status === "loaded"; return ( { - if (props.faviconUrl) loadedFaviconUrls.add(props.faviconUrl); + if (!markProjectFaviconLoaded(faviconRequest)) return; setStatus("loaded"); }} - onError={() => setStatus("error")} + onError={() => { + if (!markProjectFaviconFailed(faviconRequest)) return; + setStatus("error"); + }} /> ) : null} diff --git a/apps/mobile/src/components/projectFaviconCache.test.ts b/apps/mobile/src/components/projectFaviconCache.test.ts new file mode 100644 index 000000000000..d0582a8b5f5b --- /dev/null +++ b/apps/mobile/src/components/projectFaviconCache.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + beginProjectFaviconRequest, + createProjectFaviconRequest, + hasLoadedProjectFavicon, + markProjectFaviconFailed, + markProjectFaviconLoaded, +} from "./projectFaviconCache"; + +describe("project favicon cache", () => { + it("ignores callbacks from a superseded URL", () => { + const cacheKey = "environment-1:/workspace:v1-favicon.svg"; + const expiredUrl = "https://environment.example/api/assets/expired/v1-favicon.svg"; + const refreshedUrl = "https://environment.example/api/assets/refreshed/v1-favicon.svg"; + + const expiredRequest = createProjectFaviconRequest(cacheKey, expiredUrl); + const endExpiredRequest = beginProjectFaviconRequest(expiredRequest); + markProjectFaviconLoaded(expiredRequest); + const refreshedRequest = createProjectFaviconRequest(cacheKey, refreshedUrl); + const endRefreshedRequest = beginProjectFaviconRequest(refreshedRequest); + + expect(markProjectFaviconLoaded(expiredRequest)).toBe(false); + expect(markProjectFaviconFailed(expiredRequest)).toBe(false); + expect(hasLoadedProjectFavicon(cacheKey)).toBe(true); + expect(markProjectFaviconFailed(refreshedRequest)).toBe(true); + expect(hasLoadedProjectFavicon(cacheKey)).toBe(false); + + endRefreshedRequest(); + endExpiredRequest(); + }); + + it("evicts the URL that actually failed", () => { + const cacheKey = "environment-1:/workspace:v2-favicon.svg"; + const faviconUrl = "https://environment.example/api/assets/current/v2-favicon.svg"; + const request = createProjectFaviconRequest(cacheKey, faviconUrl); + const endRequest = beginProjectFaviconRequest(request); + + markProjectFaviconLoaded(request); + + expect(markProjectFaviconFailed(request)).toBe(true); + expect(hasLoadedProjectFavicon(cacheKey)).toBe(false); + + endRequest(); + }); + + it("does not supersede a request until the next request begins", () => { + const cacheKey = "environment-1:/workspace:v3-favicon.svg"; + const committedUrl = "https://environment.example/api/assets/current/v3-favicon.svg"; + const abandonedUrl = "https://environment.example/api/assets/abandoned/v3-favicon.svg"; + const committedRequest = createProjectFaviconRequest(cacheKey, committedUrl); + const endCommittedRequest = beginProjectFaviconRequest(committedRequest); + + createProjectFaviconRequest(cacheKey, abandonedUrl); + + expect(markProjectFaviconLoaded(committedRequest)).toBe(true); + expect(hasLoadedProjectFavicon(cacheKey)).toBe(true); + + endCommittedRequest(); + }); + + it("requires a cache key before creating a URL-bearing request", () => { + const firstUrl = "https://environment.example/api/assets/first/favicon.svg"; + const secondUrl = "https://environment.example/api/assets/second/favicon.svg"; + + expect(createProjectFaviconRequest(null, firstUrl)).toBeNull(); + expect(createProjectFaviconRequest(null, secondUrl)).toBeNull(); + }); + + it("restores the remaining active URL when a newer request ends", () => { + const cacheKey = "environment-1:/workspace:v4-favicon.svg"; + const firstRequest = createProjectFaviconRequest( + cacheKey, + "https://environment.example/api/assets/first/v4-favicon.svg", + ); + const secondRequest = createProjectFaviconRequest( + cacheKey, + "https://environment.example/api/assets/second/v4-favicon.svg", + ); + const endFirstRequest = beginProjectFaviconRequest(firstRequest); + const endSecondRequest = beginProjectFaviconRequest(secondRequest); + + expect(markProjectFaviconLoaded(firstRequest)).toBe(false); + endSecondRequest(); + expect(markProjectFaviconLoaded(firstRequest)).toBe(true); + endFirstRequest(); + expect(markProjectFaviconLoaded(firstRequest)).toBe(false); + }); + + it("bounds remembered loaded revisions", () => { + const firstCacheKey = "environment-1:/workspace:revision-0"; + let lastCacheKey = firstCacheKey; + + for (let revision = 0; revision < 300; revision++) { + lastCacheKey = `environment-1:/workspace:revision-${revision}`; + const request = createProjectFaviconRequest( + lastCacheKey, + `https://environment.example/api/assets/revision-${revision}/favicon.svg`, + ); + const endRequest = beginProjectFaviconRequest(request); + markProjectFaviconLoaded(request); + endRequest(); + } + + expect(hasLoadedProjectFavicon(firstCacheKey)).toBe(false); + expect(hasLoadedProjectFavicon(lastCacheKey)).toBe(true); + }); +}); diff --git a/apps/mobile/src/components/projectFaviconCache.ts b/apps/mobile/src/components/projectFaviconCache.ts new file mode 100644 index 000000000000..da77d7613f2d --- /dev/null +++ b/apps/mobile/src/components/projectFaviconCache.ts @@ -0,0 +1,94 @@ +export interface ProjectFaviconRequest { + readonly cacheKey: string; + readonly faviconUrl: string; +} + +interface ActiveFaviconRequests { + readonly urls: Map; + currentUrl: string; +} + +const MAX_LOADED_FAVICONS = 256; +const activeFaviconRequests = new Map(); +const loadedFaviconKeys = new Map(); + +export function createProjectFaviconRequest( + cacheKey: string, + faviconUrl: string, +): ProjectFaviconRequest; +export function createProjectFaviconRequest( + cacheKey: string | null, + faviconUrl: string | null, +): ProjectFaviconRequest | null; +export function createProjectFaviconRequest(cacheKey: string | null, faviconUrl: string | null) { + if (!cacheKey || !faviconUrl) return null; + return { cacheKey, faviconUrl }; +} + +export function beginProjectFaviconRequest(request: ProjectFaviconRequest) { + let activeRequests = activeFaviconRequests.get(request.cacheKey); + if (!activeRequests) { + activeRequests = { currentUrl: request.faviconUrl, urls: new Map() }; + activeFaviconRequests.set(request.cacheKey, activeRequests); + } + + const activeCount = activeRequests.urls.get(request.faviconUrl) ?? 0; + activeRequests.urls.delete(request.faviconUrl); + activeRequests.urls.set(request.faviconUrl, activeCount + 1); + activeRequests.currentUrl = request.faviconUrl; + + let ended = false; + return () => { + if (ended) return; + ended = true; + + const remainingCount = (activeRequests.urls.get(request.faviconUrl) ?? 1) - 1; + if (remainingCount > 0) { + activeRequests.urls.set(request.faviconUrl, remainingCount); + return; + } + + activeRequests.urls.delete(request.faviconUrl); + if (activeRequests.urls.size === 0) { + if (activeFaviconRequests.get(request.cacheKey) === activeRequests) { + activeFaviconRequests.delete(request.cacheKey); + } + return; + } + + if (activeRequests.currentUrl === request.faviconUrl) { + activeRequests.currentUrl = Array.from(activeRequests.urls.keys()).at(-1)!; + } + }; +} + +export function hasLoadedProjectFavicon(cacheKey: string | null) { + return cacheKey !== null && loadedFaviconKeys.has(cacheKey); +} + +function isCurrentProjectFaviconRequest(request: ProjectFaviconRequest) { + return activeFaviconRequests.get(request.cacheKey)?.currentUrl === request.faviconUrl; +} + +function rememberLoadedProjectFavicon(cacheKey: string) { + loadedFaviconKeys.delete(cacheKey); + loadedFaviconKeys.set(cacheKey, true); + + if (loadedFaviconKeys.size > MAX_LOADED_FAVICONS) { + loadedFaviconKeys.delete(loadedFaviconKeys.keys().next().value!); + } +} + +export function markProjectFaviconLoaded(request: ProjectFaviconRequest) { + if (!isCurrentProjectFaviconRequest(request)) return false; + + rememberLoadedProjectFavicon(request.cacheKey); + return true; +} + +export function markProjectFaviconFailed(request: ProjectFaviconRequest) { + if (!isCurrentProjectFaviconRequest(request)) return false; + + loadedFaviconKeys.delete(request.cacheKey); + return true; +} diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 916802e9faf5..01440007bc6f 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -29,7 +29,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { relativeTime } from "../../lib/time"; import { useThemeColor } from "../../lib/useThemeColor"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; -import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; +import { + createNativeMailSearchToolbarItem, + NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, +} from "../layout/native-mail-search-toolbar"; import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList"; export interface ArchivedThreadsHeaderEnvironment { @@ -70,7 +73,8 @@ function ArchivedThreadsHeader(props: { const searchIconColor = useThemeColor("--color-icon"); const searchTextColor = useThemeColor("--color-foreground"); const usesNativeChrome = Platform.OS === "ios"; - const usesCompactMailToolbar = Platform.OS === "ios" && width < 700; + const usesCompactMailToolbar = + Platform.OS === "ios" && width < 700 && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; const androidFilterActions = useMemo( () => [ { @@ -272,7 +276,11 @@ function ArchivedThreadsHeader(props: { ...(usesNativeChrome ? { allowToolbarIntegration: true, - placement: "integratedButton" as const, + // "integratedButton" is an iOS 26 search-bar placement; + // pre-glass iOS keeps the default pull-down placement. + ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED + ? { placement: "integratedButton" as const } + : null), } : { placement: "stacked" as const, diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index 5d619c688f1c..958827ee492b 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -134,7 +134,14 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { case "RelayEnvironmentLinkProofInvalidError": return `Relay rejected the environment link proof (${error.reason}).`; case "RelayEnvironmentConnectNotAuthorizedError": - return "Relay rejected the environment connection request."; + // "Not authorized" covers non-auth causes too; surface the reason so a + // missing link doesn't read as a credential problem. + if (error.reason === "environment_link_not_found") { + return "Relay has no active link for this environment. The environment server may not have re-established its link yet."; + } + return error.reason + ? `Relay rejected the environment connection request (${error.reason}).` + : "Relay rejected the environment connection request."; case "RelayEnvironmentEndpointUnavailableError": return `Relay could not reach the environment endpoint (${error.reason}).`; case "RelayEnvironmentEndpointTimedOutError": diff --git a/apps/mobile/src/features/connection/pairing.test.ts b/apps/mobile/src/features/connection/pairing.test.ts index 18b6c71a293a..193927684794 100644 --- a/apps/mobile/src/features/connection/pairing.test.ts +++ b/apps/mobile/src/features/connection/pairing.test.ts @@ -1,11 +1,32 @@ import { describe, expect, it } from "vite-plus/test"; import { + buildPairingUrl, extractPairingUrlFromQrPayload, PairingQrPayloadEmptyError, parsePairingUrl, } from "./pairing"; +describe("buildPairingUrl", () => { + it("uses HTTP for a schemeless IP address", () => { + expect(buildPairingUrl("192.168.1.100:3773", "pairing-token")).toBe( + "http://192.168.1.100:3773/#token=pairing-token", + ); + }); + + it("keeps HTTPS as the default for a schemeless hostname", () => { + expect(buildPairingUrl("remote.example.com", "pairing-token")).toBe( + "https://remote.example.com/#token=pairing-token", + ); + }); + + it("preserves an explicit scheme for an IP address", () => { + expect(buildPairingUrl("https://192.168.1.100:3773", "pairing-token")).toBe( + "https://192.168.1.100:3773/#token=pairing-token", + ); + }); +}); + describe("extractPairingUrlFromQrPayload", () => { it("trims raw pairing urls from qr payloads", () => { expect( diff --git a/apps/mobile/src/features/connection/pairing.ts b/apps/mobile/src/features/connection/pairing.ts index 910efa7f2565..569d00cbdd36 100644 --- a/apps/mobile/src/features/connection/pairing.ts +++ b/apps/mobile/src/features/connection/pairing.ts @@ -3,6 +3,21 @@ import * as Schema from "effect/Schema"; const MOBILE_PAIRING_URL_PARAM = "pairingUrl"; +function isIpLiteral(host: string): boolean { + try { + const hostname = new URL(`http://${host}`).hostname.replace(/^\[|\]$/g, ""); + if (hostname.includes(":")) return true; + + const octets = hostname.split("."); + return ( + octets.length === 4 && + octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255) + ); + } catch { + return false; + } +} + export class PairingQrPayloadEmptyError extends Schema.TaggedErrorClass()( "PairingQrPayloadEmptyError", {}, @@ -19,7 +34,7 @@ export function buildPairingUrl(host: string, code: string): string { if (!c) return h; try { - const url = new URL(h.includes("://") ? h : `https://${h}`); + const url = new URL(h.includes("://") ? h : `${isIpLiteral(h) ? "http" : "https"}://${h}`); url.hash = new URLSearchParams([["token", c]]).toString(); return url.toString(); } catch { diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 012f99536d25..7f5105aac177 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -29,7 +29,10 @@ import { useAdaptiveWorkspacePaneRole, useRegisterWorkspaceInspector, } from "../layout/AdaptiveWorkspaceLayout"; -import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; +import { + createNativeMailSearchToolbarItem, + NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, +} from "../layout/native-mail-search-toolbar"; import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider"; import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; @@ -354,7 +357,8 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { ); } - const usesCompactMailToolbar = Platform.OS === "ios" && !layout.usesSplitView; + const usesCompactMailToolbar = + Platform.OS === "ios" && !layout.usesSplitView && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; return ( <> diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 4265107912b8..a209dbd76239 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -1,5 +1,6 @@ import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; +import Constants from "expo-constants"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useCallback, useMemo, useRef } from "react"; import { Platform, Pressable, Text as RNText, TextInput, View } from "react-native"; @@ -9,11 +10,16 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ControlPillMenu } from "../../components/ControlPill"; import { SymbolView } from "../../components/AppSymbol"; import { T3Wordmark } from "../../components/T3Wordmark"; +import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; +import { resolveMobileStageLabel } from "../../lib/mobileBranding"; import { useThemeColor } from "../../lib/useThemeColor"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; -import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; +import { + createNativeMailSearchToolbarItem, + NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, +} from "../layout/native-mail-search-toolbar"; import type { HomeProjectSortOrder } from "./homeThreadList"; import { buildHomeListFilterMenu, @@ -61,6 +67,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) { const insets = useSafeAreaInsets(); const iconColor = useThemeColor("--color-icon"); const mutedColor = useThemeColor("--color-foreground-muted"); + const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); // Thread List v2 lays the list out in fixed creation order, so the // sort/group filter controls would be silently ignored β€” hide them and // key the "customized" icon state off the environment filter alone. @@ -192,8 +199,9 @@ function AndroidHomeHeader(props: HomeHeaderProps) { <> @@ -207,7 +215,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) { - Alpha + {stageLabel} @@ -320,9 +328,11 @@ function IosHomeHeader(props: HomeHeaderProps) { }), ] : undefined, - unstable_headerToolbarItems: - Platform.OS === "ios" - ? () => [ + // The keys below are set per-branch (not `undefined`) so a later + // reapply cannot clobber options owned by NativeHeaderToolbar. + ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED + ? { + unstable_headerToolbarItems: () => [ createNativeMailSearchToolbarItem({ composeButtonId: "home-new-task", composeSystemImageName: "square.and.pencil", @@ -336,14 +346,14 @@ function IosHomeHeader(props: HomeHeaderProps) { placeholder: "Search", searchTextChangeId: "home-search-text", }), - ] - : undefined, - headerSearchBarOptions: - Platform.OS === "ios" - ? undefined - : { + ], + } + : { + // Pre-Liquid-Glass iOS: standard pull-down search in the nav + // bar; create + sort live in the plain bottom toolbar below. + headerSearchBarOptions: { ref: searchBarRef, - allowToolbarIntegration: true, + autoCapitalize: "none" as const, hideNavigationBar: false, placeholder: "Search", onCancelButtonPress: () => { @@ -353,21 +363,11 @@ function IosHomeHeader(props: HomeHeaderProps) { props.onSearchQueryChange(event.nativeEvent.text); }, }, + }), }} /> - {Platform.OS === "ios" ? null : ( - - - - )} - - {Platform.OS === "ios" ? null : ( + {NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : ( ) : null} - - Sort projects - {PROJECT_SORT_OPTIONS.map((option) => ( - props.onProjectSortOrderChange(option.value)} - > - {option.label} - - ))} - + {threadListV2Enabled ? null : ( + + Sort projects + {PROJECT_SORT_OPTIONS.map((option) => ( + props.onProjectSortOrderChange(option.value)} + > + {option.label} + + ))} + + )} - - Sort threads - {THREAD_SORT_OPTIONS.map((option) => ( - props.onThreadSortOrderChange(option.value)} - > - {option.label} - - ))} - + {threadListV2Enabled ? null : ( + + Sort threads + {THREAD_SORT_OPTIONS.map((option) => ( + props.onThreadSortOrderChange(option.value)} + > + {option.label} + + ))} + + )} - - - + { + void checkForAppUpdateOnLaunch(); + }, []); + const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = useThreadListActions(); const pendingTasks = usePendingNewTasks(); @@ -87,7 +94,9 @@ export function HomeRouteScreen() { if (layout.usesSplitView) { return ( <> - + [] }} + /> navigation.navigate("NewTaskSheet", { screen: "NewTask" })} > <> - {/* Restore the compact title in case the split branch blanked it. */} - + {/* Restore the compact title after the split branch blanks the detail header. */} + (null); const insets = useSafeAreaInsets(); const accentColor = useThemeColor("--color-icon-muted"); + const iosBottomToolbarClearance = + Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED + ? PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT + : 0; const searchEnvironmentIds = useMemo( () => props.selectedEnvironmentId === null @@ -877,7 +882,7 @@ export function HomeScreen(props: HomeScreenProps) { @@ -1019,7 +1024,7 @@ export function HomeScreen(props: HomeScreenProps) { contentContainerStyle={{ paddingBottom: Platform.OS === "ios" - ? Math.max(insets.bottom, 24) + 96 + ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance : Math.max(insets.bottom, 16) + 88, }} /> @@ -1060,16 +1065,19 @@ export function HomeScreen(props: HomeScreenProps) { scrollEventThrottle={16} contentContainerStyle={{ // Android reserves room for the floating new-task FAB - // (56 button + 16 gap + bottom inset). + // (56 button + 16 gap + bottom inset). Pre-glass iOS shows a + // standard 44pt bottom toolbar that overlays the list and is not + // reflected in insets while contentInsetAdjustmentBehavior is + // "never". paddingBottom: Platform.OS === "ios" - ? Math.max(insets.bottom, 24) + 24 + ? Math.max(insets.bottom, 24) + 24 + iosBottomToolbarClearance : Math.max(insets.bottom, 16) + 88, }} scrollIndicatorInsets={ Platform.OS === "ios" ? { - bottom: Math.max(insets.bottom, 16) + 24, + bottom: Math.max(insets.bottom, 16) + 24 + iosBottomToolbarClearance, top: 0, } : undefined diff --git a/apps/mobile/src/features/layout/native-mail-search-toolbar.ts b/apps/mobile/src/features/layout/native-mail-search-toolbar.ts index 820e12222434..8770d96b124b 100644 --- a/apps/mobile/src/features/layout/native-mail-search-toolbar.ts +++ b/apps/mobile/src/features/layout/native-mail-search-toolbar.ts @@ -1,5 +1,16 @@ import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; + +/** + * The patched mail-style toolbar is built natively from iOS 26 Liquid Glass + * UIKit (`UIGlassEffect`) with no earlier fallback: pre-26 the native side + * silently drops the item and hides the navigation toolbar entirely. Screens + * that send it must fall back to standard search/toolbar primitives when this + * is false. + */ +export const NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED = NATIVE_LIQUID_GLASS_SUPPORTED; + type NativeMailSearchToolbarInput = Omit< HeaderBarButtonMailSearchToolbarItem, "type" | "useFallbackSearchField" diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index f354bcd29acd..49adfe75cb23 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -9,19 +9,10 @@ import { SymbolView } from "../../components/AppSymbol"; import * as Effect from "effect/Effect"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; -import { - ActivityIndicator, - Alert, - Linking, - Platform, - Pressable, - ScrollView, - View, -} from "react-native"; +import { Alert, Linking, Platform, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { - type AtomCommandResult, isAtomCommandInterrupted, reportAtomCommandResult, settleAsyncResult, @@ -47,6 +38,11 @@ import { runtime } from "../../lib/runtime"; import { useThemeColor } from "../../lib/useThemeColor"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; +import { + type AppUpdateCheckState, + registerHiddenUpdateTap, + runAppUpdateCheck, +} from "../updates/app-updates"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; @@ -578,12 +574,11 @@ function BetaSettingsSection() { ); } -type UpdateCheckState = "idle" | "checking" | "downloading" | "restarting" | "current"; - function AppSettingsSection() { const icon = useThemeColor("--color-icon"); - const [updateState, setUpdateState] = useState("idle"); + const [updateState, setUpdateState] = useState("idle"); const updateInFlight = useRef(false); + const hiddenUpdateTapCount = useRef(0); const version = Constants.expoConfig?.version ?? "0.0.0"; // Fall back to "production" to match resolveAppVariant in app.config.ts, so a @@ -591,22 +586,11 @@ function AppSettingsSection() { const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production"; const variantLabel = variant === "production" ? "" : capitalize(variant); const versionLabel = variantLabel ? `${version} Β· ${variantLabel}` : version; - // Which JS is actually running: the bundle shipped in the binary, or an OTA - // update downloaded on top of it. Surfacing this makes "am I even on the - // right build?" answerable at a glance. - const bundleLabel = Updates.isEnabled - ? Updates.isEmbeddedLaunch - ? "Embedded" - : Updates.updateId - ? `OTA ${Updates.updateId.slice(0, 7)}` - : null - : null; - const busy = updateState === "checking" || updateState === "downloading" || updateState === "restarting"; // "Up to date" is a transient acknowledgement, not a state worth persisting β€” - // drop back to the bundle label so the row keeps answering "what am I running?". + // return the version row to its normal, deliberately quiet state. useEffect(() => { if (updateState !== "current") return; const timer = setTimeout(() => setUpdateState("idle"), 3000); @@ -619,12 +603,24 @@ function AppSettingsSection() { if (updateInFlight.current) return; updateInFlight.current = true; try { - await runUpdateCheck(setUpdateState); + await runAppUpdateCheck({ + onFailure: (message) => Alert.alert("Update failed", message), + onStateChange: setUpdateState, + }); } finally { updateInFlight.current = false; } }, []); + const handleVersionPress = useCallback(() => { + if (!Updates.isEnabled || updateInFlight.current) return; + const tap = registerHiddenUpdateTap(hiddenUpdateTapCount.current); + hiddenUpdateTapCount.current = tap.nextCount; + if (tap.shouldCheck) { + void checkForUpdate(); + } + }, [checkForUpdate]); + const statusLabel = updateState === "checking" ? "Checking…" @@ -634,7 +630,7 @@ function AppSettingsSection() { ? "Restarting…" : updateState === "current" ? "Up to date" - : bundleLabel; + : null; const versionRow = ( @@ -652,21 +648,6 @@ function AppSettingsSection() { {statusLabel} ) : null} - {Updates.isEnabled ? ( - - {busy ? ( - - ) : ( - - )} - - ) : null} ); @@ -676,10 +657,10 @@ function AppSettingsSection() { {Updates.isEnabled ? ( void checkForUpdate()} + onPress={handleVersionPress} > {versionRow} @@ -690,52 +671,6 @@ function AppSettingsSection() { ); } -async function runUpdateCheck(setUpdateState: (state: UpdateCheckState) => void): Promise { - setUpdateState("checking"); - const check = await settlePromise(() => Updates.checkForUpdateAsync()); - if (check._tag === "Failure") { - reportUpdateFailure(check, "Could not check for updates."); - setUpdateState("idle"); - return; - } - // A rollback directive (`eas update:rollback`) arrives as isAvailable: false - // with isRollBackToEmbedded: true β€” there is nothing newer to install, but the - // running OTA still has to be dropped for the embedded bundle. - if (!check.value.isAvailable && !check.value.isRollBackToEmbedded) { - setUpdateState("current"); - return; - } - - setUpdateState("downloading"); - const fetched = await settlePromise(() => Updates.fetchUpdateAsync()); - if (fetched._tag === "Failure") { - reportUpdateFailure(fetched, "Could not download the update."); - setUpdateState("idle"); - return; - } - // isNew is always false for a rollback, so it can't be the sole gate here either. - if (!fetched.value.isNew && !fetched.value.isRollBackToEmbedded) { - setUpdateState("current"); - return; - } - - setUpdateState("restarting"); - // reloadAsync never resolves on success β€” the JS context is torn down β€” so - // reaching the failure branch below is the only way this returns. - const reloaded = await settlePromise(() => Updates.reloadAsync()); - if (reloaded._tag === "Failure") { - reportUpdateFailure(reloaded, "Downloaded, but could not restart the app."); - setUpdateState("idle"); - } -} - -function reportUpdateFailure(result: AtomCommandResult, fallback: string): void { - reportAtomCommandResult(result, { label: "app update check" }); - if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; - const error = squashAtomCommandFailure(result); - Alert.alert("Update failed", error instanceof Error ? error.message : fallback); -} - function capitalize(value: string): string { return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value; } diff --git a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx index f0e3f89c07dc..4be4089a54d2 100644 --- a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx +++ b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx @@ -11,6 +11,7 @@ import { import type { ReactNode } from "react"; import { Platform, useColorScheme } from "react-native"; +import { getCompactBrandHeaderOptions } from "../../components/CompactBrandTitle"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; @@ -35,10 +36,9 @@ const SIDEBAR_SCREEN_OPTIONS: SidebarScreenOptions = { headerShadowVisible: false, headerShown: true, headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? { backgroundColor: "transparent" } : undefined, - headerTitleStyle: { fontSize: 18, fontWeight: "800" }, + ...getCompactBrandHeaderOptions({ fontSize: 18, fontWeight: "800" }), headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED, scrollEdgeEffects: NATIVE_LIQUID_GLASS_SUPPORTED ? SCROLL_EDGE_EFFECTS : undefined, - title: "Threads", unstable_navigationItemStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? "editor" : undefined, }; diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 9ac4002a9b04..855713946ff8 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -15,6 +15,7 @@ import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { cn } from "../../lib/cn"; +import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; import { relativeTime } from "../../lib/time"; import { useThemeColor } from "../../lib/useThemeColor"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -33,7 +34,7 @@ import { ThreadSearchMatchExcerpt } from "./thread-search-match"; export type ThreadListVariant = "compact" | "sidebar"; /** Left inset that aligns compact secondary rows with the title column. */ -export const THREAD_LIST_COMPACT_INSET = 20; +export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET; const SIDEBAR_ROW_RADIUS = 12; function pullRequestTintColor( diff --git a/apps/mobile/src/features/updates/app-updates.test.ts b/apps/mobile/src/features/updates/app-updates.test.ts new file mode 100644 index 000000000000..474c99668cdb --- /dev/null +++ b/apps/mobile/src/features/updates/app-updates.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + createAppUpdateLaunchCheck, + registerHiddenUpdateTap, + runAppUpdateCheck, + type AppUpdateCheckState, + type AppUpdateClient, +} from "./app-updates"; + +vi.mock("expo-updates", () => ({ + isEnabled: true, + checkForUpdateAsync: vi.fn(), + fetchUpdateAsync: vi.fn(), + reloadAsync: vi.fn(), +})); + +function makeUpdateClient(overrides: Partial = {}): AppUpdateClient { + return { + isEnabled: true, + checkForUpdateAsync: vi.fn(async () => ({ + isAvailable: false, + isRollBackToEmbedded: false, + })), + fetchUpdateAsync: vi.fn(async () => ({ + isNew: true, + isRollBackToEmbedded: false, + })), + reloadAsync: vi.fn(async () => {}), + ...overrides, + }; +} + +describe("runAppUpdateCheck", () => { + it("downloads and restarts when a new update is available", async () => { + const client = makeUpdateClient({ + checkForUpdateAsync: vi.fn(async () => ({ + isAvailable: true, + isRollBackToEmbedded: false, + })), + }); + const states: AppUpdateCheckState[] = []; + + await runAppUpdateCheck({ client, onStateChange: (state) => states.push(state) }); + + expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); + expect(client.fetchUpdateAsync).toHaveBeenCalledOnce(); + expect(client.reloadAsync).toHaveBeenCalledOnce(); + expect(states).toEqual(["checking", "downloading", "restarting"]); + }); + + it("restarts into the embedded bundle for a rollback directive", async () => { + const client = makeUpdateClient({ + checkForUpdateAsync: vi.fn(async () => ({ + isAvailable: false, + isRollBackToEmbedded: true, + })), + fetchUpdateAsync: vi.fn(async () => ({ + isNew: false, + isRollBackToEmbedded: true, + })), + }); + + await runAppUpdateCheck({ client }); + + expect(client.fetchUpdateAsync).toHaveBeenCalledOnce(); + expect(client.reloadAsync).toHaveBeenCalledOnce(); + }); + + it("stops quietly when the running bundle is current", async () => { + const client = makeUpdateClient(); + const states: AppUpdateCheckState[] = []; + + await runAppUpdateCheck({ client, onStateChange: (state) => states.push(state) }); + + expect(client.fetchUpdateAsync).not.toHaveBeenCalled(); + expect(client.reloadAsync).not.toHaveBeenCalled(); + expect(states).toEqual(["checking", "current"]); + }); + + it("reports manual failures without continuing the update", async () => { + const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); + const client = makeUpdateClient({ + checkForUpdateAsync: vi.fn(async () => { + throw new Error("offline"); + }), + }); + const failures: string[] = []; + const states: AppUpdateCheckState[] = []; + + await runAppUpdateCheck({ + client, + onFailure: (message) => failures.push(message), + onStateChange: (state) => states.push(state), + }); + + expect(client.fetchUpdateAsync).not.toHaveBeenCalled(); + expect(failures).toEqual(["offline"]); + expect(states).toEqual(["checking", "idle"]); + reportError.mockRestore(); + }); + + it("coalesces overlapping launch and manual checks", async () => { + let resolveCheck!: (result: { + readonly isAvailable: boolean; + readonly isRollBackToEmbedded: boolean; + }) => void; + const checkResult = new Promise<{ + readonly isAvailable: boolean; + readonly isRollBackToEmbedded: boolean; + }>((resolve) => { + resolveCheck = resolve; + }); + const client = makeUpdateClient({ + checkForUpdateAsync: vi.fn(() => checkResult), + }); + const checkOnLaunch = createAppUpdateLaunchCheck(client); + const manualStates: AppUpdateCheckState[] = []; + + const launchCheck = checkOnLaunch(); + const manualCheck = runAppUpdateCheck({ + client, + onStateChange: (state) => manualStates.push(state), + }); + + expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); + expect(manualStates).toEqual(["checking"]); + + resolveCheck({ + isAvailable: false, + isRollBackToEmbedded: false, + }); + await Promise.all([launchCheck, manualCheck]); + + expect(manualStates).toEqual(["checking", "current"]); + + await runAppUpdateCheck({ client }); + expect(client.checkForUpdateAsync).toHaveBeenCalledTimes(2); + }); + + it("forwards failures to a manual check coalesced with the launch check", async () => { + const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); + let rejectCheck!: (error: Error) => void; + const checkResult = new Promise<{ + readonly isAvailable: boolean; + readonly isRollBackToEmbedded: boolean; + }>((_resolve, reject) => { + rejectCheck = reject; + }); + const client = makeUpdateClient({ + checkForUpdateAsync: vi.fn(() => checkResult), + }); + const checkOnLaunch = createAppUpdateLaunchCheck(client); + const failures: string[] = []; + const manualStates: AppUpdateCheckState[] = []; + + const launchCheck = checkOnLaunch(); + const manualCheck = runAppUpdateCheck({ + client, + onFailure: (message) => failures.push(message), + onStateChange: (state) => manualStates.push(state), + }); + + rejectCheck(new Error("offline")); + await Promise.all([launchCheck, manualCheck]); + + expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); + expect(failures).toEqual(["offline"]); + expect(manualStates).toEqual(["checking", "idle"]); + reportError.mockRestore(); + }); + + it("publishes the in-flight check before a state callback can re-enter", async () => { + let resolveCheck!: (result: { + readonly isAvailable: boolean; + readonly isRollBackToEmbedded: boolean; + }) => void; + const checkResult = new Promise<{ + readonly isAvailable: boolean; + readonly isRollBackToEmbedded: boolean; + }>((resolve) => { + resolveCheck = resolve; + }); + const client = makeUpdateClient({ + checkForUpdateAsync: vi.fn(() => checkResult), + }); + const reentrantStates: AppUpdateCheckState[] = []; + let reentrantCheck: Promise | undefined; + let didReenter = false; + + const initialCheck = runAppUpdateCheck({ + client, + onStateChange: (state) => { + if (state !== "checking" || didReenter) return; + didReenter = true; + reentrantCheck = runAppUpdateCheck({ + client, + onStateChange: (reentrantState) => reentrantStates.push(reentrantState), + }); + }, + }); + + expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); + expect(reentrantCheck).toBeDefined(); + expect(reentrantStates).toEqual(["checking"]); + + resolveCheck({ + isAvailable: false, + isRollBackToEmbedded: false, + }); + await Promise.all([initialCheck, reentrantCheck]); + + expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); + expect(reentrantStates).toEqual(["checking", "current"]); + }); +}); + +describe("createAppUpdateLaunchCheck", () => { + it("checks at most once for each JavaScript launch", async () => { + const client = makeUpdateClient(); + const checkOnLaunch = createAppUpdateLaunchCheck(client); + + const first = checkOnLaunch(); + const second = checkOnLaunch(); + await first; + + expect(second).toBeUndefined(); + expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); + }); + + it("does nothing when Expo updates are disabled", () => { + const client = makeUpdateClient({ isEnabled: false }); + const checkOnLaunch = createAppUpdateLaunchCheck(client); + + expect(checkOnLaunch()).toBeUndefined(); + expect(client.checkForUpdateAsync).not.toHaveBeenCalled(); + }); +}); + +describe("registerHiddenUpdateTap", () => { + it("unlocks the manual check on the fifth tap", () => { + let count = 0; + + for (let tap = 1; tap <= 5; tap += 1) { + const result = registerHiddenUpdateTap(count); + expect(result.shouldCheck).toBe(tap === 5); + count = result.nextCount; + } + + expect(count).toBe(0); + }); +}); diff --git a/apps/mobile/src/features/updates/app-updates.ts b/apps/mobile/src/features/updates/app-updates.ts new file mode 100644 index 000000000000..ab896b53c074 --- /dev/null +++ b/apps/mobile/src/features/updates/app-updates.ts @@ -0,0 +1,228 @@ +import * as Updates from "expo-updates"; + +import { + type AtomCommandResult, + isAtomCommandInterrupted, + reportAtomCommandResult, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; + +export type AppUpdateCheckState = "idle" | "checking" | "downloading" | "restarting" | "current"; + +export interface AppUpdateClient { + readonly isEnabled: boolean; + readonly checkForUpdateAsync: () => Promise<{ + readonly isAvailable: boolean; + readonly isRollBackToEmbedded: boolean; + }>; + readonly fetchUpdateAsync: () => Promise<{ + readonly isNew: boolean; + readonly isRollBackToEmbedded: boolean; + }>; + readonly reloadAsync: () => Promise; +} + +interface AppUpdateCheckOptions { + readonly client?: AppUpdateClient; + readonly onFailure?: (message: string) => void; + readonly onStateChange?: (state: AppUpdateCheckState) => void; +} + +interface AppUpdateCheckProgress { + failure: string | undefined; + state: AppUpdateCheckState | undefined; +} + +interface AppUpdateCheckInFlight { + readonly failureListeners: Set>; + readonly progress: AppUpdateCheckProgress; + readonly promise: Promise; + readonly stateListeners: Set>; +} + +interface Deferred { + readonly promise: Promise; + readonly reject: (cause: unknown) => void; + readonly resolve: () => void; +} + +const HIDDEN_UPDATE_TAP_COUNT = 5; +let appUpdateCheckInFlight: AppUpdateCheckInFlight | undefined; + +/** + * Keeps the manual update affordance discoverable only to someone deliberately + * tapping the version row five times. + */ +export function registerHiddenUpdateTap(count: number): { + readonly nextCount: number; + readonly shouldCheck: boolean; +} { + const nextCount = count + 1; + if (nextCount >= HIDDEN_UPDATE_TAP_COUNT) { + return { + nextCount: 0, + shouldCheck: true, + }; + } + return { + nextCount, + shouldCheck: false, + }; +} + +export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Promise { + const client = options.client ?? Updates; + if (!client.isEnabled) return; + + if (appUpdateCheckInFlight) { + await observeAppUpdateCheck(appUpdateCheckInFlight, options); + return; + } + + const progress: AppUpdateCheckProgress = { + failure: undefined, + state: undefined, + }; + const failureListeners = new Set>(); + const stateListeners = new Set>(); + if (options.onFailure) failureListeners.add(options.onFailure); + if (options.onStateChange) stateListeners.add(options.onStateChange); + + const deferred = createDeferred(); + const inFlight: AppUpdateCheckInFlight = { + failureListeners, + progress, + promise: deferred.promise, + stateListeners, + }; + // Publish the operation before any state listener can synchronously re-enter. + appUpdateCheckInFlight = inFlight; + + const execution = performAppUpdateCheck(client, { + onFailure: (message) => { + progress.failure = message; + notifyListeners(failureListeners, message); + }, + onStateChange: (state) => { + progress.state = state; + notifyListeners(stateListeners, state); + }, + }); + void execution.then(deferred.resolve, deferred.reject); + + try { + await deferred.promise; + } finally { + if (appUpdateCheckInFlight === inFlight) { + appUpdateCheckInFlight = undefined; + } + } +} + +function createDeferred(): Deferred { + let reject!: Deferred["reject"]; + let resolve!: Deferred["resolve"]; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = () => resolvePromise(); + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +function notifyListeners(listeners: ReadonlySet<(value: A) => void>, value: A): void { + // A listener can synchronously subscribe another caller. Snapshot so that + // caller receives only observeAppUpdateCheck's explicit current-value replay. + const snapshot = Array.from(listeners); + for (const listener of snapshot) listener(value); +} + +async function observeAppUpdateCheck( + inFlight: AppUpdateCheckInFlight, + options: AppUpdateCheckOptions, +): Promise { + const onFailure = options.onFailure; + const onStateChange = options.onStateChange; + + if (onFailure) { + inFlight.failureListeners.add(onFailure); + if (inFlight.progress.failure) onFailure(inFlight.progress.failure); + } + if (onStateChange) { + inFlight.stateListeners.add(onStateChange); + if (inFlight.progress.state) onStateChange(inFlight.progress.state); + } + + try { + await inFlight.promise; + } finally { + if (onFailure) inFlight.failureListeners.delete(onFailure); + if (onStateChange) inFlight.stateListeners.delete(onStateChange); + } +} + +async function performAppUpdateCheck( + client: AppUpdateClient, + options: AppUpdateCheckOptions, +): Promise { + const setState = options.onStateChange ?? (() => {}); + + setState("checking"); + const check = await settlePromise(() => client.checkForUpdateAsync()); + if (check._tag === "Failure") { + reportUpdateFailure(check, "Could not check for updates.", options.onFailure); + setState("idle"); + return; + } + // A rollback directive (`eas update:rollback`) arrives as isAvailable: false + // with isRollBackToEmbedded: true. The running OTA still has to be dropped. + if (!check.value.isAvailable && !check.value.isRollBackToEmbedded) { + setState("current"); + return; + } + + setState("downloading"); + const fetched = await settlePromise(() => client.fetchUpdateAsync()); + if (fetched._tag === "Failure") { + reportUpdateFailure(fetched, "Could not download the update.", options.onFailure); + setState("idle"); + return; + } + // isNew is always false for a rollback, so it cannot be the sole gate. + if (!fetched.value.isNew && !fetched.value.isRollBackToEmbedded) { + setState("current"); + return; + } + + setState("restarting"); + const reloaded = await settlePromise(() => client.reloadAsync()); + if (reloaded._tag === "Failure") { + reportUpdateFailure(reloaded, "Downloaded, but could not restart the app.", options.onFailure); + setState("idle"); + } +} + +function reportUpdateFailure( + result: AtomCommandResult, + fallback: string, + onFailure: AppUpdateCheckOptions["onFailure"], +): void { + reportAtomCommandResult(result, { label: "app update check" }); + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + onFailure?.(error instanceof Error ? error.message : fallback); +} + +export function createAppUpdateLaunchCheck( + client: AppUpdateClient = Updates, +): () => Promise | undefined { + let started = false; + + return () => { + if (started || !client.isEnabled) return undefined; + started = true; + return runAppUpdateCheck({ client }); + }; +} + +export const checkForAppUpdateOnLaunch = createAppUpdateLaunchCheck(); diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index 5c79b5b5eb8a..f559545c04ef 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -65,14 +65,6 @@ export async function pickComposerImages(input: { readonly existingCount: number }; } - const permission = await imagePicker.requestMediaLibraryPermissionsAsync(); - if (!permission.granted) { - return { - images: [], - error: "Allow photo library access to attach images.", - }; - } - const result = await imagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], allowsMultipleSelection: true, diff --git a/apps/mobile/src/lib/layoutMetrics.ts b/apps/mobile/src/lib/layoutMetrics.ts new file mode 100644 index 000000000000..139fcbb65f32 --- /dev/null +++ b/apps/mobile/src/lib/layoutMetrics.ts @@ -0,0 +1,5 @@ +/** Horizontal inset shared by the home header and compact thread list. */ +export const HOME_HORIZONTAL_INSET = 20; + +/** Compensates for the tighter native sidebar title margin on iPad. */ +export const IPAD_HOME_TITLE_OFFSET = 10; diff --git a/apps/mobile/src/lib/mobileBranding.test.ts b/apps/mobile/src/lib/mobileBranding.test.ts new file mode 100644 index 000000000000..48a84b3f9857 --- /dev/null +++ b/apps/mobile/src/lib/mobileBranding.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveMobileStageLabel } from "./mobileBranding"; + +describe("resolveMobileStageLabel", () => { + it.each([ + ["development", "Dev"], + ["preview", "Nightly"], + ["production", "Alpha"], + [undefined, "Alpha"], + ])("maps %s builds to %s", (appVariant, expected) => { + expect(resolveMobileStageLabel(appVariant)).toBe(expected); + }); +}); diff --git a/apps/mobile/src/lib/mobileBranding.ts b/apps/mobile/src/lib/mobileBranding.ts new file mode 100644 index 000000000000..9fd6020831a1 --- /dev/null +++ b/apps/mobile/src/lib/mobileBranding.ts @@ -0,0 +1,7 @@ +export type MobileStageLabel = "Alpha" | "Dev" | "Nightly"; + +export function resolveMobileStageLabel(appVariant: unknown): MobileStageLabel { + if (appVariant === "development") return "Dev"; + if (appVariant === "preview") return "Nightly"; + return "Alpha"; +} diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index 78c87119512b..a524d5152473 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -340,7 +340,8 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null { return { type: "spacing", spacing: typeof child.props.width === "number" ? child.props.width : 8, - }; + flexible: Boolean(child.props.flexible), + } as NativeStackHeaderItem; } return null; @@ -351,6 +352,11 @@ function collectToolbarItems(children: ReactNode): NativeStackHeaderItem[] { Children.forEach(children, (child) => { const item = convertToolbarChild(child); if (item) { + if (item.type === "spacing") { + // Native inserts spacing items at `index`, treating a missing index + // as 0 β€” which would move the spacer in front of earlier siblings. + (item as { index?: number }).index = items.length; + } items.push(item); } }); @@ -364,7 +370,8 @@ function NativeHeaderToolbarRoot(props: { const navigation = useNativeStackNavigation(); const items = useMemo(() => collectToolbarItems(props.children), [props.children]); - useEffect(() => { + // Swap toolbar owners before paint so split and compact headers cannot clear each other. + useLayoutEffect(() => { if (!navigation) { return; } @@ -440,6 +447,7 @@ function NativeHeaderToolbarLabel(_props: { readonly children?: ReactNode }) { NativeHeaderToolbarLabel.displayName = "NativeHeaderToolbarLabel"; function NativeHeaderToolbarSpacer(_props: { + readonly flexible?: boolean; readonly sharesBackground?: boolean; readonly width?: number; }) { diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index 84d1c22084f7..e78f90a7db91 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -1,5 +1,6 @@ import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; import { requireNativeView } from "expo"; +import { TextInputWrapper } from "expo-paste-input"; import { useCallback, useEffect, @@ -9,12 +10,13 @@ import { useState, type Ref, } from "react"; -import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from "react-native"; +import type { NativeSyntheticEvent, ViewProps } from "react-native"; import { Image, StyleSheet } from "react-native"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; import { MOBILE_TYPOGRAPHY } from "../lib/typography"; +import { useNativePaste } from "../lib/useNativePaste"; import { useFontFamily } from "../lib/useFontFamily"; import { useThemeColor } from "../lib/useThemeColor"; import { @@ -117,6 +119,7 @@ export function ComposerEditor({ const skillBorder = useThemeColor("--color-inline-skill-border"); const skillText = useThemeColor("--color-inline-skill-foreground"); const fileTint = useThemeColor("--color-icon-muted"); + const handlePaste = useNativePaste((uris) => onPasteImages?.(uris)); useImperativeHandle( ref, @@ -221,61 +224,63 @@ export function ComposerEditor({ const resolvedTextStyle = StyleSheet.flatten(textStyle) ?? {}; const regularFontFamily = useFontFamily("regular"); return ( - } - onComposerChange={(event) => { - const acknowledgedEventCount = acceptNativeEvent( - event.nativeEvent.eventCount, - event.nativeEvent.value, - event.nativeEvent.selection, - ); - if (acknowledgedEventCount === false) return; - onChangeText(event.nativeEvent.value); - onSelectionChange?.(event.nativeEvent.selection); - setMostRecentEventCount(acknowledgedEventCount); - setNativeEventSequence((sequence) => sequence + 1); - }} - onComposerSelectionChange={(event) => { - const acknowledgedEventCount = acceptNativeEvent( - event.nativeEvent.eventCount, - event.nativeEvent.value, - event.nativeEvent.selection, - ); - if (acknowledgedEventCount === false) return; - onSelectionChange?.(event.nativeEvent.selection); - setMostRecentEventCount(acknowledgedEventCount); - setNativeEventSequence((sequence) => sequence + 1); - }} - onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} - onComposerFocus={onFocus} - onComposerBlur={onBlur} - /> + + { + const acknowledgedEventCount = acceptNativeEvent( + event.nativeEvent.eventCount, + event.nativeEvent.value, + event.nativeEvent.selection, + ); + if (acknowledgedEventCount === false) return; + onChangeText(event.nativeEvent.value); + onSelectionChange?.(event.nativeEvent.selection); + setMostRecentEventCount(acknowledgedEventCount); + setNativeEventSequence((sequence) => sequence + 1); + }} + onComposerSelectionChange={(event) => { + const acknowledgedEventCount = acceptNativeEvent( + event.nativeEvent.eventCount, + event.nativeEvent.value, + event.nativeEvent.selection, + ); + if (acknowledgedEventCount === false) return; + onSelectionChange?.(event.nativeEvent.selection); + setMostRecentEventCount(acknowledgedEventCount); + setNativeEventSequence((sequence) => sequence + 1); + }} + onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} + onComposerFocus={onFocus} + onComposerBlur={onBlur} + /> + ); } diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 42fd3f900e5a..568dc3739c00 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -2,11 +2,13 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { ThreadId } from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import { describe, expect, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as TestClock from "effect/testing/TestClock"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; @@ -214,12 +216,21 @@ describe("AssetAccess", () => { prefix: "t3-asset-favicon-", }); const faviconPath = path.join(root, "favicon.svg"); - yield* fileSystem.writeFileString(faviconPath, ""); + const initialFavicon = "a"; + const updatedFavicon = "b"; + expect(updatedFavicon).toHaveLength(initialFavicon.length); + yield* fileSystem.writeFileString(faviconPath, initialFavicon); const canonicalFaviconPath = yield* fileSystem.realPath(faviconPath); const faviconResult = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, }); + expect(faviconResult.relativeUrl).toMatch(/\/v[0-9a-f]{64}-favicon\.svg$/); + expect( + yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }), + ).toEqual(faviconResult); const faviconSuffix = faviconResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); const faviconSeparatorIndex = faviconSuffix.indexOf("/"); expect( @@ -229,6 +240,14 @@ describe("AssetAccess", () => { ), ).toEqual({ kind: "file", path: canonicalFaviconPath }); + yield* fileSystem.writeFileString(faviconPath, updatedFavicon); + const updatedFaviconResult = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }); + expect( + updatedFaviconResult.relativeUrl.slice(updatedFaviconResult.relativeUrl.lastIndexOf("/")), + ).not.toBe(faviconResult.relativeUrl.slice(faviconResult.relativeUrl.lastIndexOf("/"))); + yield* fileSystem.remove(faviconPath); const fallbackResult = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, @@ -245,6 +264,31 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("buckets project favicon expiry after content hashing", () => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-expiry-", + }); + yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), ""); + + const bucketMs = 30 * 60 * 1000; + yield* TestClock.setTime(bucketMs - 1); + const crossingCrypto = Crypto.make({ + randomBytes: (size) => new Uint8Array(size), + digest: (algorithm, data) => + TestClock.adjust("2 millis").pipe(Effect.andThen(crypto.digest(algorithm, data))), + }); + const result = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }).pipe(Effect.provideService(Crypto.Crypto, crossingCrypto)); + + expect(result.expiresAt).toBe(3 * bucketMs); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("preserves structured project favicon resolution causes", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index b469e0e315b8..c00f7f1a5e3c 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -21,7 +21,9 @@ import { } from "@t3tools/shared/filePreview"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import * as Clock from "effect/Clock"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -44,6 +46,8 @@ export const ASSET_ROUTE_PREFIX = "/api/assets"; const SIGNING_SECRET_NAME = "asset-access-signing-key"; const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; +const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000; +const PROJECT_FAVICON_VERSION_PREFIX = "v"; const PREVIEW_ASSET_EXTENSIONS = new Set([ ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, @@ -169,7 +173,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; - const expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; + let expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; let claims: AssetClaims; let fileName: string; @@ -293,18 +297,18 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ), ); const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; - if ( - relativePath && - !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( - Effect.mapError( - (cause) => - new AssetProjectFaviconInspectionError({ - resource: input.resource, - cause, - }), - ), - )) - ) { + const canonicalFaviconPath = relativePath + ? yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( + Effect.mapError( + (cause) => + new AssetProjectFaviconInspectionError({ + resource: input.resource, + cause, + }), + ), + ) + : null; + if (relativePath && !canonicalFaviconPath) { return yield* new AssetProjectFaviconNotFoundError({ resource: input.resource, }); @@ -324,7 +328,31 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i relativePath, expiresAt, }; - fileName = relativePath ? path.basename(relativePath) : PROJECT_FAVICON_FALLBACK_MARKER; + if (relativePath && canonicalFaviconPath) { + const crypto = yield* Crypto.Crypto; + const faviconBytes = yield* fileSystem.readFile(canonicalFaviconPath).pipe( + Effect.mapError( + (cause) => + new AssetProjectFaviconInspectionError({ + resource: input.resource, + cause, + }), + ), + ); + const revision = yield* crypto.digest("SHA-256", faviconBytes).pipe( + Effect.map(Encoding.encodeHex), + Effect.mapError( + (cause) => + new AssetProjectFaviconInspectionError({ + resource: input.resource, + cause, + }), + ), + ); + fileName = `${PROJECT_FAVICON_VERSION_PREFIX}${revision}-${path.basename(relativePath)}`; + } else { + fileName = PROJECT_FAVICON_FALLBACK_MARKER; + } break; } } @@ -339,6 +367,13 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); + if (claims.kind === "project-favicon") { + const issuedAt = yield* Clock.currentTimeMillis; + expiresAt = + (Math.floor(issuedAt / PROJECT_FAVICON_TOKEN_BUCKET_MS) + 2) * + PROJECT_FAVICON_TOKEN_BUCKET_MS; + claims = { ...claims, expiresAt }; + } const encodedPayload = base64UrlEncode(encodeAssetClaims(claims)); const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`; return { diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 80b1cb4aa1fe..fb753b9aa4bd 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -55,6 +55,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, + [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 2eef6ac84167..252819adacf4 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -198,6 +198,8 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); + assert.equal(defaultsByCommand.get("filePicker.toggle"), "mod+p"); + assert.equal(defaultsByCommand.get("projectSearch.toggle"), "mod+shift+f"); assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 18a78fe36232..53f329e85120 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -547,19 +547,24 @@ const make = Effect.gen(function* () { }); } - const nextConfig = [...customConfig, ...missingDefaults]; - const cappedConfig = - nextConfig.length > MAX_KEYBINDINGS_COUNT - ? nextConfig.slice(-MAX_KEYBINDINGS_COUNT) - : nextConfig; - if (nextConfig.length > MAX_KEYBINDINGS_COUNT) { - yield* Effect.logWarning("truncating keybindings config to max entries", { + // Startup backfill must never evict persisted user rules: append only + // the defaults that fit and skip the rest. + const availableSlots = Math.max(0, MAX_KEYBINDINGS_COUNT - customConfig.length); + const defaultsToAppend = missingDefaults.slice(0, availableSlots); + const skippedDefaults = missingDefaults.slice(availableSlots); + if (skippedDefaults.length > 0) { + yield* Effect.logWarning("skipping default keybinding backfill at max entries", { path: keybindingsConfigPath, maxEntries: MAX_KEYBINDINGS_COUNT, + commands: skippedDefaults.map((rule) => rule.command), }); } + if (defaultsToAppend.length === 0) { + yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); + return; + } - yield* writeConfigAtomically(cappedConfig); + yield* writeConfigAtomically([...customConfig, ...defaultsToAppend]); yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); }), ); diff --git a/apps/server/src/os-jank.test.ts b/apps/server/src/os-jank.test.ts new file mode 100644 index 000000000000..a157efb665fa --- /dev/null +++ b/apps/server/src/os-jank.test.ts @@ -0,0 +1,40 @@ +import * as NodeOS from "node:os"; +import { assert, it } from "vite-plus/test"; + +import { hydratePosixHome } from "./os-jank.ts"; + +it("hydrates HOME for minimal service environments from the user account", () => { + const env: NodeJS.ProcessEnv = {}; + + hydratePosixHome(env); + + assert.equal(env.HOME, NodeOS.userInfo().homedir); +}); + +it("hydrates HOME independently of a blank process HOME", () => { + const originalHome = process.env.HOME; + const env: NodeJS.ProcessEnv = { HOME: " " }; + + try { + process.env.HOME = " "; + hydratePosixHome(env); + } finally { + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + } + + assert.equal(env.HOME, NodeOS.userInfo().homedir); +}); + +it("preserves an explicitly configured HOME", () => { + const env: NodeJS.ProcessEnv = { HOME: "/custom/home" }; + + hydratePosixHome(env, () => { + throw new Error("HOME lookup should not run"); + }); + + assert.equal(env.HOME, "/custom/home"); +}); diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index bc72758bc718..18ddbc66c0c8 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(() => { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e0d36e99bc96..853bb0b11013 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,6 +1,8 @@ import { EnvironmentHttpApi } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; @@ -535,7 +537,25 @@ export const makeServerLayer = Layer.unwrap( yield* Effect.forkScoped( Effect.sleep("250 millis").pipe( Effect.andThen(reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`)), - Effect.retry({ times: 4 }), + // On reboot this races NIC/DNS bring-up, so back off exponentially + // (capped at 30s) instead of burning all retries in a second. + // Bounded overall so a permanently broken setup still surfaces the + // warning below. Bad-request/unauthorized/conflict are + // deterministic failures (malformed origin, not linked yet, linked + // to a different cloud account) that no amount of retrying + // converges. + Effect.retry({ + while: (error) => + error._tag !== "EnvironmentHttpBadRequestError" && + error._tag !== "EnvironmentHttpUnauthorizedError" && + error._tag !== "EnvironmentHttpConflictError", + schedule: Schedule.exponential("1 second").pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed(Duration.min(duration, Duration.seconds(30))), + ), + Schedule.upTo({ duration: "10 minutes" }), + ), + }), Effect.tap(() => Effect.logInfo("T3 Connect desired link reconciled on startup")), Effect.catch((cause) => Effect.logWarning("Failed to reconcile T3 Connect desired link on startup", { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index aab69de6a128..24d53cd4846f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -153,6 +153,51 @@ it.effect("uses stable diagnostics for every parsed non-repository command", () }).pipe(Effect.provide(layer)); }); +it.effect("invalidates origin remote cache when a driver mutation adds origin", () => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + + const before = yield* driver.statusDetailsLocal(cwd); + assert.equal(before.hasOriginRemote, false); + + yield* driver.ensureRemote({ cwd, preferredName: "origin", url: remote }); + + const after = yield* driver.statusDetailsLocal(cwd); + assert.equal(after.hasOriginRemote, true); + }).pipe(Effect.provide(TestLayer)), +); + +it.effect("re-reads origin remote status after cache TTL expiry and bypassed invalidation", () => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + + // First call caches hasOriginRemote = false (5-min TTL) + assert.equal((yield* driver.statusDetailsLocal(cwd)).hasOriginRemote, false); + + // Add origin via raw git (bypasses invalidation hook) + yield* git(cwd, ["remote", "add", "origin", remote]); + + // Cache still has the stale false (TTL not yet expired) + const stillCached = yield* driver.statusDetailsLocal(cwd); + assert.equal(stillCached.hasOriginRemote, false); + + // Advance past the 5-minute TTL so the cache entry expires + yield* TestClock.adjust("6 minutes"); + + // After expiry, the next call re-executes and picks up the remote + const afterExpiry = yield* driver.statusDetailsLocal(cwd); + assert.equal(afterExpiry.hasOriginRemote, true); + }).pipe(Effect.provide(TestLayer)), +); + it.effect("coalesces concurrent ref pages into one repository snapshot", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 3aa7575d5909..e44dc0486346 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -61,6 +61,8 @@ const LIST_REFS_SNAPSHOT_CACHE_CAPACITY = 64; const LIST_REFS_SNAPSHOT_CACHE_TTL = Duration.minutes(2); const LIST_REFS_REFRESH_COALESCE_TTL = Duration.seconds(5); const LIST_REFS_REFRESH_FAILURE_COOLDOWN = Duration.seconds(30); +const STATUS_DEFAULT_BRANCH_CACHE_TTL = Duration.minutes(5); +const STATUS_ORIGIN_EXISTS_CACHE_TTL = Duration.minutes(5); const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ GCM_INTERACTIVE: "never", GIT_ASKPASS: "", @@ -1119,6 +1121,63 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return Cache.get(refresh ? repositoryPathsRefreshCache : repositoryPathsCache, cacheKey); }; + const defaultBranchCache = yield* Cache.makeWith( + (gitCommonDir: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fetchCwd = + path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; + return yield* executeGit( + "GitVcsDriver.statusDetails.defaultBranch", + fetchCwd, + ["--git-dir", gitCommonDir, "symbolic-ref", "refs/remotes/origin/HEAD"], + { allowNonZeroExit: true }, + ).pipe( + Effect.map((result) => { + if (result.exitCode !== 0) return null; + return parseDefaultBranchFromRemoteHeadRef(result.stdout, "origin"); + }), + ); + }), + { + capacity: 2_048, + timeToLive: Exit.match({ + onSuccess: () => STATUS_DEFAULT_BRANCH_CACHE_TTL, + onFailure: () => Duration.zero, + }), + }, + ); + const originExistsCache = yield* Cache.makeWith( + (gitCommonDir: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fetchCwd = + path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; + return yield* executeGit( + "GitVcsDriver.statusDetails.originExists", + fetchCwd, + ["--git-dir", gitCommonDir, "remote", "get-url", "origin"], + { allowNonZeroExit: true }, + ).pipe(Effect.map((result) => result.exitCode === 0)); + }), + { + capacity: 2_048, + timeToLive: Exit.match({ + onSuccess: () => STATUS_ORIGIN_EXISTS_CACHE_TTL, + onFailure: () => Duration.zero, + }), + }, + ); + const invalidateStatusStaticCaches = (cwd: string) => + Effect.gen(function* () { + const repositoryPaths = yield* resolveRepositoryPaths(cwd).pipe( + Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), + ); + const cacheKey = repositoryPaths?.gitCommonDir ?? normalizeRepositoryPathsCacheKey(cwd); + yield* Cache.invalidate(defaultBranchCache, cacheKey); + yield* Cache.invalidate(originExistsCache, cacheKey); + }); + const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) { const repositoryPaths = yield* resolveRepositoryPaths(cwd); if (repositoryPaths !== null) { @@ -1517,7 +1576,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); } - const [numstatStdout, defaultRefResult, hasPrimaryRemote] = yield* Effect.all( + const repositoryPaths = yield* resolveRepositoryPaths(cwd).pipe( + Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), + ); + const statusCacheKey = repositoryPaths?.gitCommonDir; + const [numstatStdout, defaultBranch, hasPrimaryRemote] = yield* Effect.all( [ executeGitWithStableDiagnostics( "GitVcsDriver.statusDetails.numstat", @@ -1574,21 +1637,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); }), ), - executeGit( - "GitVcsDriver.statusDetails.defaultRef", - cwd, - ["symbolic-ref", "refs/remotes/origin/HEAD"], - { allowNonZeroExit: true }, - ), - originRemoteExists(cwd).pipe(Effect.orElseSucceed(() => false)), + statusCacheKey + ? Cache.get(defaultBranchCache, statusCacheKey).pipe(Effect.orElseSucceed(() => null)) + : resolveDefaultBranchName(cwd, "origin").pipe(Effect.orElseSucceed(() => null)), + statusCacheKey + ? Cache.get(originExistsCache, statusCacheKey).pipe(Effect.orElseSucceed(() => false)) + : originRemoteExists(cwd).pipe(Effect.orElseSucceed(() => false)), ], { concurrency: "unbounded" }, ); const statusStdout = statusResult.stdout; - const defaultBranch = - defaultRefResult.exitCode === 0 - ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "") - : null; let refName: string | null = null; let upstreamRef: string | null = null; @@ -2809,7 +2867,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* cwd: string, effect: Effect.Effect, ): Effect.Effect => - effect.pipe(Effect.ensuring(invalidateListRefsSnapshot(cwd).pipe(Effect.ignore))); + effect.pipe( + Effect.ensuring( + Effect.all([ + invalidateListRefsSnapshot(cwd).pipe(Effect.ignore), + invalidateStatusStaticCaches(cwd).pipe(Effect.ignore), + ]), + ), + ); const initRepoWithListRefsInvalidation: GitVcsDriver.GitVcsDriver["Service"]["initRepo"] = ( input, ) => diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index a08350ed9591..d47aaaec8264 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 7501cbe0eab2..bb2113dac37d 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 9b7ed4e2453f..155728370307 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 db4d46851e7b..8bf36b7a80ac 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 935d7edec925..06888ef3f701 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -37,6 +37,7 @@ import { type ProjectFileOperation, ProjectListEntriesError, ProjectReadFileError, + ProjectSearchContentsError, ProjectSearchEntriesError, ProjectWriteFileError, RelayClientInstallFailedError, @@ -1597,6 +1598,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/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index 22ff986afbd1..a245cbc54db2 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -145,7 +145,14 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { case "RelayEnvironmentLinkProofInvalidError": return `Relay rejected the environment link proof (${error.reason}).`; case "RelayEnvironmentConnectNotAuthorizedError": - return "Relay rejected the environment connection request."; + // "Not authorized" covers non-auth causes too; surface the reason so a + // missing link doesn't read as a credential problem. + if (error.reason === "environment_link_not_found") { + return "Relay has no active link for this environment. The environment server may not have re-established its link yet."; + } + return error.reason + ? `Relay rejected the environment connection request (${error.reason}).` + : "Relay rejected the environment connection request."; case "RelayEnvironmentEndpointUnavailableError": return `Relay could not reach the environment endpoint (${error.reason}).`; case "RelayEnvironmentEndpointTimedOutError": diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index d86fe39a77f3..985e943cb39c 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,5 +1,4 @@ import { useAtomValue } from "@effect/atom-react"; -import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs"; import { CheckIcon, ChevronRightIcon, @@ -57,6 +56,8 @@ import { useOpenInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; +import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; +import { RenderErrorBoundary } from "./RenderErrorBoundary"; import { useTheme } from "../hooks/useTheme"; import { getClientSettings } from "../hooks/useSettings"; import { @@ -91,27 +92,6 @@ import { BrowserPreviewUnavailableError, } from "../browser/openFileInPreview"; -class CodeHighlightErrorBoundary extends React.Component< - { fallback: ReactNode; children: ReactNode }, - { hasError: boolean } -> { - constructor(props: { fallback: ReactNode; children: ReactNode }) { - super(props); - this.state = { hasError: false }; - } - - static getDerivedStateFromError() { - return { hasError: true }; - } - - override render() { - if (this.state.hasError) { - return this.props.fallback; - } - return this.props.children; - } -} - interface ChatMarkdownProps { text: string; cwd: string | undefined; @@ -147,7 +127,6 @@ const highlightedCodeCache = new LRUCache( MAX_HIGHLIGHT_CACHE_ENTRIES, MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, ); -const highlighterPromiseCache = new Map>(); function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null { const firstLineEnd = markdown.indexOf("\n", listItemStart); @@ -333,27 +312,6 @@ function estimateHighlightedSize(html: string, code: string): number { return Math.max(html.length * 2, code.length * 3); } -function getHighlighterPromise(language: string): Promise { - const cached = highlighterPromiseCache.get(language); - if (cached) return cached; - - const promise = getSharedHighlighter({ - themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], - langs: [language as SupportedLanguages], - preferredHighlighter: "shiki-js", - }).catch((err) => { - highlighterPromiseCache.delete(language); - if (language === "text") { - // "text" itself failed β€” Shiki cannot initialize at all, surface the error - throw err; - } - // Language not supported by Shiki β€” fall back to "text" - return getHighlighterPromise("text"); - }); - highlighterPromiseCache.set(language, promise); - return promise; -} - function readInitialWordWrapSetting(): boolean { return getClientSettings().wordWrap; } @@ -743,7 +701,7 @@ function UncachedShikiCodeBlock({ cacheKey, isStreaming, }: UncachedShikiCodeBlockProps) { - const highlighter = use(getHighlighterPromise(language)); + const highlighter = use(getSyntaxHighlighterPromise(language)); const highlightedHtml = useMemo(() => { try { return highlighter.codeToHtml(code, { lang: language, theme: themeName }); @@ -1605,7 +1563,7 @@ function ChatMarkdown({ fenceTitle={fenceTitle} theme={resolvedTheme} > - {children}}> + {children}}> {children}}> - + ); }, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ec85e4c4c183..f0a918e136fc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1883,46 +1883,75 @@ function ChatViewContent(props: ChatViewProps) { ); const systemComposerBannerItems = useMemo(() => { const items: ComposerBannerStackItem[] = []; - const resumingServerUpdate = - serverUpdateState.status === "running" && serverUpdateState.stage === "resuming"; - if (activeEnvironmentUnavailableState && !resumingServerUpdate) { - const connection = activeEnvironmentUnavailableState.connection; - const isReconnecting = - connection.phase === "connecting" || connection.phase === "reconnecting"; - items.push({ - id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, - variant: connection.phase === "error" ? "error" : "warning", - icon: , - title: `${activeEnvironmentUnavailableState.label}: ${connectionStatusTitle(connection)}`, - description: - connection.error ?? - "Reconnect this environment before sending messages or running actions.", - actions: ( - <> - - - - ), - }); + const updateRunning = serverUpdateState.status === "running"; + const unavailableConnection = activeEnvironmentUnavailableState?.connection ?? null; + const environmentReconnecting = + unavailableConnection !== null && + (unavailableConnection.phase === "connecting" || + unavailableConnection.phase === "reconnecting"); + // Reconnecting to a version-skewed server with no update in flight + // usually means the server is restarting mid-update and a refresh wiped + // the in-memory update state. Fold the reconnect and version banners + // into one calm line instead of stacking "Failed to connect" on + // "versions differ". A failed update never folds: its error and retry + // action must stay visible. + const reconnectingThroughVersionSkew = + serverUpdateState.status === "idle" && environmentReconnecting && versionMismatch !== null; + // While an update runs, transient connect blips are expected (the server + // restarts) and the update banner already shows progress. Hard failure + // phases still surface so the Reconnect action stays reachable. + const suppressUnavailableBanner = updateRunning && environmentReconnecting; + if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) { + if (reconnectingThroughVersionSkew) { + items.push({ + id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, + variant: "default", + icon: ( +