diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml new file mode 100644 index 00000000000..36dfb61f73f --- /dev/null +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -0,0 +1,134 @@ +name: Mobile Showcase Screenshots + +on: + workflow_dispatch: + inputs: + platform: + description: Device platforms to capture + required: true + default: all + type: choice + options: + - all + - ios + - android + appearance: + description: System appearances to capture + required: true + default: both + type: choice + options: + - both + - dark + - light + +permissions: + contents: read + +env: + NODE_OPTIONS: --max-old-space-size=8192 + +jobs: + ios: + name: iPhone 6.9, iPhone 6.5, and iPad 13 + if: inputs.platform == 'all' || inputs.platform == 'ios' + runs-on: blacksmith-12vcpu-macos-26 + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Expose pnpm + run: | + pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" + vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" + echo "$vp_pnpm_bin" >> "$GITHUB_PATH" + "$vp_pnpm_bin/pnpm" --version + + - name: Capture iOS showcase + run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" + + - name: Validate App Store Connect assets + run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --validate-only + + - name: Upload iOS screenshots + if: always() + uses: actions/upload-artifact@v7 + with: + name: app-store-connect-screenshots + path: artifacts/app-store/screenshots/apple/ + if-no-files-found: warn + retention-days: 14 + + android: + name: Android phone, 7-inch tablet, and 10-inch tablet + if: inputs.platform == 'all' || inputs.platform == 'android' + runs-on: blacksmith-16vcpu-ubuntu-2404 + timeout-minutes: 60 + env: + T3_SHOWCASE_ANDROID_ABI: x86_64 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Expose pnpm + run: | + pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" + vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" + echo "$vp_pnpm_bin" >> "$GITHUB_PATH" + "$vp_pnpm_bin/pnpm" --version + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + + - name: Setup Gradle cache + uses: gradle/actions/setup-gradle@v5 + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Capture Android showcase + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 36 + target: google_apis + arch: x86_64 + profile: pixel_7_pro + avd-name: Pixel_10_Pro + cores: 8 + ram-size: 4096M + disable-animations: false + script: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" + + - name: Validate Google Play assets + run: pnpm screenshots:mobile --platform android --appearance "${{ inputs.appearance }}" --validate-only + + - name: Upload Android screenshots + if: always() + uses: actions/upload-artifact@v7 + with: + name: google-play-screenshots + path: artifacts/app-store/screenshots/google-play/ + if-no-files-found: warn + retention-days: 14 diff --git a/.gitignore b/.gitignore index ef6067824f2..8e1669c8115 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ squashfs-root/ .gstack/ dist-electron/ .electron-runtime/ +.showcase/ +apps/mobile/.showcase/ +artifacts/app-store/screenshots/ node_modules/ .alchemy/ *.log diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index afbdc55ba60..09f4db07965 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -43,6 +43,15 @@ Review changed TypeScript and directly affected call sites for the conventions b - Keep implementation-specific names when an abstract port module contains one of several possible implementations, for example `makeCloudflaredRelayClient` and `layerCloudflared` in `RelayClient.ts`. - `infra/relay/src/db.ts` is an intentional exception: an inline `Layer.succeed(RelayDb, db)` is acceptable without generic `make`/`layer` exports. +## Dependency acquisition and runtime boundaries + +- Production service construction must acquire Effect service dependencies from the environment with `yield* Foo.Foo`, and its `make`/`layer` types must expose those requirements. Flag factories or constructors that accept `Foo["Service"]` (or a plain object whose methods return `Effect`) when that value is an implementation dependency owned by the service. Passing service instances explicitly is acceptable in tests and integration harnesses; passing pure configuration, immutable domain values, or deliberate callback strategies is not service injection. +- Do not hide dependencies in module globals, closures over singleton services, or `Layer.succeed` implementations that call runtime-backed or imperative APIs. Trace helpers used by a supposedly synchronous layer far enough to verify that asynchronous services are represented in the Effect environment. +- `ManagedRuntime.make`, `runPromise`, and `runPromiseExit` belong at explicit application/framework boundaries such as React, native callback, CLI, or HTTP adapters. Flag their use in domain services, repositories, persistence implementations, and service constructors. A clearly named imperative adapter may bridge an Effect service into a Promise API, but it must not become a dependency of another Effect service. +- Do not create per-feature managed runtimes or Atom runtimes to smuggle the same owned resource into multiple consumers. Compose the resource once in an application-owned layer/runtime and provide its context to integration runtimes. +- When acquisition can fail but a caller must retain fallback behavior, keep the failure typed in Effect rather than bypassing the layer through an imperative runtime. Model unavailability in service operations or with an explicit optional-service layer so downstream recovery remains visible and testable. +- During review, search touched code and affected call sites for service-instance parameters, `Layer.succeed`, `ManagedRuntime.make`, and `.runPromise`/`.runPromiseExit`. Verify that each occurrence is a legitimate test seam, pure value injection, or application boundary—not fake dependency injection or a hidden runtime. + ## Errors and predicates - Define service failures with `Schema.TaggedErrorClass` and structured attributes. Derive `message` from those attributes rather than storing an unstructured message as the only data. diff --git a/app.json b/app.json new file mode 100644 index 00000000000..306ca48315c --- /dev/null +++ b/app.json @@ -0,0 +1,3 @@ +{ + "expo": {} +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c8418e31bac..ceac5792bbb 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -31,7 +31,7 @@ "@effect/vitest": "catalog:", "@types/node": "catalog:", "cross-env": "^10.1.0", - "electron-builder": "26.8.1", + "electron-builder": "26.15.6", "tailwindcss": "^4.0.0", "vite-plus": "catalog:" }, diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 081ef903a06..929afeeabe9 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -20,7 +20,7 @@ export const APP_BUNDLE_ID = isDevelopment ? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}` : "com.t3tools.t3code"; const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"]; -const LAUNCHER_VERSION = 12; +const LAUNCHER_VERSION = 14; const defaultIconPath = NodePath.join(desktopDir, "resources", "icon.icns"); const developmentMacIconPngPath = NodePath.join( repoRoot, @@ -220,11 +220,12 @@ function ensureDevelopmentIconIcns(runtimeDir) { } } -function patchMainBundleInfoPlist(appBundlePath, iconPath) { +function patchMainBundleInfoPlist(appBundlePath, iconPath, executableName) { const infoPlistPath = NodePath.join(appBundlePath, "Contents", "Info.plist"); setPlistString(infoPlistPath, "CFBundleDisplayName", APP_DISPLAY_NAME); setPlistString(infoPlistPath, "CFBundleName", APP_DISPLAY_NAME); setPlistString(infoPlistPath, "CFBundleIdentifier", APP_BUNDLE_ID); + setPlistString(infoPlistPath, "CFBundleExecutable", executableName); setPlistString(infoPlistPath, "CFBundleIconFile", "icon.icns"); setPlistJson(infoPlistPath, "CFBundleURLTypes", [ { @@ -277,11 +278,25 @@ function readJson(path) { } } +export function resolveMacLauncherPaths(appBundlePath, displayName = APP_DISPLAY_NAME) { + const executableDir = NodePath.join(appBundlePath, "Contents", "MacOS"); + const launcherExecutableName = `${displayName} Launcher`; + return { + launcherExecutableName, + launcherBinaryPath: NodePath.join(executableDir, launcherExecutableName), + runtimeElectronBinaryPath: NodePath.join(executableDir, "Electron"), + }; +} + function buildMacLauncher(electronBinaryPath) { const sourceAppBundlePath = NodePath.resolve(NodePath.dirname(electronBinaryPath), "../.."); const runtimeDir = NodePath.join(desktopDir, ".electron-runtime"); const targetAppBundlePath = NodePath.join(runtimeDir, `${APP_DISPLAY_NAME}.app`); - const targetBinaryPath = NodePath.join(targetAppBundlePath, "Contents", "MacOS", "Electron"); + const developmentPaths = resolveMacLauncherPaths(targetAppBundlePath); + const runtimeElectronBinaryPath = developmentPaths.runtimeElectronBinaryPath; + const launcherBinaryPath = isDevelopment + ? developmentPaths.launcherBinaryPath + : runtimeElectronBinaryPath; const iconPath = isDevelopment ? ensureDevelopmentIconIcns(runtimeDir) : defaultIconPath; const metadataPath = NodePath.join(runtimeDir, "metadata.json"); @@ -298,7 +313,8 @@ function buildMacLauncher(electronBinaryPath) { const currentMetadata = readJson(metadataPath); if ( - NodeFS.existsSync(targetBinaryPath) && + NodeFS.existsSync(launcherBinaryPath) && + (!isDevelopment || NodeFS.existsSync(runtimeElectronBinaryPath)) && currentMetadata && JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata) ) { @@ -306,10 +322,10 @@ function buildMacLauncher(electronBinaryPath) { // The launcher also handles protocol activations outside the dev runner, // so refresh its fallback environment on every launch. Never let a value // captured by an older parent app override the live dev-runner environment. - writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath); + writeDevelopmentLauncherScript(launcherBinaryPath, runtimeElectronBinaryPath); } registerMacLauncherBundle(targetAppBundlePath); - return targetBinaryPath; + return launcherBinaryPath; } NodeFS.rmSync(targetAppBundlePath, { recursive: true, force: true }); @@ -321,15 +337,24 @@ function buildMacLauncher(electronBinaryPath) { recursive: true, verbatimSymlinks: true, }); - patchMainBundleInfoPlist(targetAppBundlePath, iconPath); + patchMainBundleInfoPlist( + targetAppBundlePath, + iconPath, + isDevelopment ? developmentPaths.launcherExecutableName : "Electron", + ); patchHelperBundleInfoPlists(targetAppBundlePath); if (isDevelopment) { - writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath); + // Keep Electron's native executable inside the branded bundle. Launching the + // node_modules copy makes macOS associate the process (and Dock label) with + // Electron.app even though this bundle's Info.plist has the T3 Code name. + // Its conventional executable name also keeps Electron's default-app runtime + // in development mode instead of making app.isPackaged report true. + writeDevelopmentLauncherScript(launcherBinaryPath, runtimeElectronBinaryPath); } NodeFS.writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`); registerMacLauncherBundle(targetAppBundlePath); - return targetBinaryPath; + return launcherBinaryPath; } function isLinuxSetuidSandboxConfigured(electronBinaryPath) { diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs index 36e9429e444..1c82167ea21 100644 --- a/apps/desktop/scripts/electron-launcher.test.mjs +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -1,6 +1,10 @@ import { assert, describe, it } from "vite-plus/test"; -import { makeDevelopmentLauncherScript, resolveElectronBinaryPath } from "./electron-launcher.mjs"; +import { + makeDevelopmentLauncherScript, + resolveElectronBinaryPath, + resolveMacLauncherPaths, +} from "./electron-launcher.mjs"; describe("electron development launcher", () => { it("uses captured values only as fallbacks for a live runner environment", () => { @@ -45,4 +49,33 @@ describe("electron development launcher", () => { ); assert.deepEqual(calls, ["ensure", "require:electron"]); }); + + it("keeps the native Electron executable name inside the branded macOS bundle", () => { + const paths = resolveMacLauncherPaths( + "/repo/apps/desktop/.electron-runtime/T3 Code (Dev).app", + "T3 Code (Dev)", + ); + + assert.equal(paths.launcherExecutableName, "T3 Code (Dev) Launcher"); + assert.equal( + paths.launcherBinaryPath, + "/repo/apps/desktop/.electron-runtime/T3 Code (Dev).app/Contents/MacOS/T3 Code (Dev) Launcher", + ); + assert.equal( + paths.runtimeElectronBinaryPath, + "/repo/apps/desktop/.electron-runtime/T3 Code (Dev).app/Contents/MacOS/Electron", + ); + + const script = makeDevelopmentLauncherScript({ + electronBinaryPath: paths.runtimeElectronBinaryPath, + mainEntryPath: "/repo/apps/desktop/dist-electron/main.cjs", + desktopRoot: "/repo/apps/desktop", + environment: {}, + }); + assert.include( + script, + "exec '/repo/apps/desktop/.electron-runtime/T3 Code (Dev).app/Contents/MacOS/Electron'", + ); + assert.notInclude(script, "node_modules/electron"); + }); }); diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts index 3dc218d8252..58870bbab1d 100644 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -31,6 +31,12 @@ const TestLayer = ElectronMenu.layer.pipe( Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), ); +const makeWindow = (zoomFactor = 1): Electron.BrowserWindow => + ({ + id: 7, + webContents: { getZoomFactor: () => zoomFactor }, + }) as unknown as Electron.BrowserWindow; + describe("ElectronMenu", () => { beforeEach(() => { buildFromTemplateMock.mockReset(); @@ -70,7 +76,7 @@ describe("ElectronMenu", () => { const electronMenu = yield* ElectronMenu.ElectronMenu; const selectedItemId = yield* electronMenu.showContextMenu({ - window: {} as Electron.BrowserWindow, + window: makeWindow(), items: [{ id: "copy", label: "Copy" }], position: Option.none(), }); @@ -81,20 +87,24 @@ describe("ElectronMenu", () => { it.effect("resolves with none when the menu closes without a click", () => Effect.gen(function* () { + let popupOptions: Electron.PopupOptions | undefined; buildFromTemplateMock.mockImplementation(() => ({ popup: (options: Electron.PopupOptions) => { + popupOptions = options; options.callback?.(); }, })); const electronMenu = yield* ElectronMenu.ElectronMenu; const selectedItemId = yield* electronMenu.showContextMenu({ - window: {} as Electron.BrowserWindow, + window: makeWindow(2), items: [{ id: "copy", label: "Copy" }], position: Option.some({ x: 10.8, y: 20.2 }), }); assert.isTrue(Option.isNone(selectedItemId)); + assert.equal(popupOptions?.x, 21); + assert.equal(popupOptions?.y, 40); assert.deepEqual(buildFromTemplateMock.mock.calls[0]?.[0][0], { label: "Copy", enabled: true, diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 09fb5d1807d..4d3e5a1c241 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -94,13 +94,20 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM return normalizedItems; } +// Renderer positions arrive in CSS pixels; popup() expects window points, so +// page zoom must be factored in or menus drift proportionally to their +// distance from the window origin. const normalizePosition = ( position: Option.Option, + zoomFactor: number, ): Option.Option => Option.filter( position, - ({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0, - ).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) }))); + ({ x, y }) => + Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0 && Number.isFinite(zoomFactor), + ).pipe( + Option.map(({ x, y }) => ({ x: Math.floor(x * zoomFactor), y: Math.floor(y * zoomFactor) })), + ); export const make = Effect.gen(function* () { const platform = yield* HostProcessPlatform; @@ -214,7 +221,10 @@ export const make = Effect.gen(function* () { try { const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); - const popupPosition = normalizePosition(input.position); + const popupPosition = normalizePosition( + input.position, + input.window.webContents.getZoomFactor(), + ); const popupOptions = Option.match(popupPosition, { onNone: (): Electron.PopupOptions => ({ window: input.window, diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts index 8fcc34f41c2..c2acc9ce120 100644 --- a/apps/desktop/src/electron/ElectronUpdater.test.ts +++ b/apps/desktop/src/electron/ElectronUpdater.test.ts @@ -10,6 +10,7 @@ const { autoUpdaterMock } = vi.hoisted(() => ({ autoInstallOnAppQuit: true, channel: "latest", disableDifferentialDownload: false, + fullChangelog: false, checkForUpdates: vi.fn(() => Promise.resolve(null)), downloadUpdate: vi.fn(() => Promise.resolve([])), on: vi.fn(), @@ -33,6 +34,7 @@ describe("ElectronUpdater", () => { autoUpdaterMock.autoInstallOnAppQuit = true; autoUpdaterMock.channel = "latest"; autoUpdaterMock.disableDifferentialDownload = false; + autoUpdaterMock.fullChangelog = false; autoUpdaterMock.checkForUpdates.mockClear(); autoUpdaterMock.checkForUpdates.mockImplementation(() => Promise.resolve(null)); autoUpdaterMock.downloadUpdate.mockClear(); @@ -98,6 +100,18 @@ describe("ElectronUpdater", () => { }).pipe(Effect.provide(ElectronUpdater.layer)), ); + it.effect("sets full changelog mode", () => + Effect.gen(function* () { + const updater = yield* ElectronUpdater.ElectronUpdater; + + yield* updater.setFullChangelog(true); + assert.equal(autoUpdaterMock.fullChangelog, true); + + yield* updater.setFullChangelog(false); + assert.equal(autoUpdaterMock.fullChangelog, false); + }).pipe(Effect.provide(ElectronUpdater.layer)), + ); + it.effect("preserves quit-and-install flags and the execution-time channel", () => Effect.gen(function* () { const cause = new Error("quit and install failed"); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 435fbd00228..4157d29a9df 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -66,6 +66,7 @@ export class ElectronUpdater extends Context.Service< readonly setAllowPrerelease: (value: boolean) => Effect.Effect; readonly allowDowngrade: Effect.Effect; readonly setAllowDowngrade: (value: boolean) => Effect.Effect; + readonly setFullChangelog: (value: boolean) => Effect.Effect; readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect; readonly checkForUpdates: Effect.Effect; readonly downloadUpdate: Effect.Effect; @@ -112,6 +113,11 @@ export const make = ElectronUpdater.of({ autoUpdater.allowDowngrade = value; return Effect.void; }), + setFullChangelog: (value) => + Effect.suspend(() => { + autoUpdater.fullChangelog = value; + return Effect.void; + }), setDisableDifferentialDownload: (value) => Effect.suspend(() => { autoUpdater.disableDifferentialDownload = value; diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index ec4284f5d1a..e478d0c6eff 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -35,6 +35,7 @@ import { getAppBranding, getLocalEnvironmentBootstraps, getLocalEnvironmentBearerToken, + getWindowFullscreenState, openExternal, pickFolder, setTheme, @@ -48,6 +49,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* PreviewIpc.installPreviewEventForwarding(); yield* ipc.handleSync(getAppBranding); + yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); yield* ipc.handle(getLocalEnvironmentBearerToken); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index d28c507fa7f..0d5c79cf55a 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -4,6 +4,8 @@ export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; +export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; +export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; export const UPDATE_STATE_CHANNEL = "desktop:update-state"; export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index a67b3246fb4..13e6e8d3956 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -1,10 +1,14 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import type * as Electron from "electron"; + import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; -import { getLocalEnvironmentBootstraps } from "./window.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import { getLocalEnvironmentBootstraps, getWindowFullscreenState } from "./window.ts"; const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { executablePath: "wsl.exe", @@ -126,3 +130,19 @@ describe("getLocalEnvironmentBootstraps", () => { }).pipe(Effect.provide(DesktopBackendPool.layerTest([stoppedInstance]))); }); }); + +describe("getWindowFullscreenState", () => { + it.effect("reads the current native window state", () => { + const window = { isFullScreen: () => true } as Electron.BrowserWindow; + + return Effect.gen(function* () { + assert.isTrue(yield* getWindowFullscreenState.handler()); + }).pipe( + Effect.provide( + Layer.mock(ElectronWindow.ElectronWindow)({ + currentMainOrFirst: Effect.succeed(Option.some(window)), + }), + ), + ); + }); +}); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 94357c7a161..a4e98aaabad 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -55,6 +55,16 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({ }), }); +export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({ + channel: IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.window.getWindowFullscreenState")(function* () { + const electronWindow = yield* ElectronWindow.ElectronWindow; + const window = yield* electronWindow.currentMainOrFirst; + return Option.isSome(window) && window.value.isFullScreen(); + }), +}); + export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, result: Schema.Array(DesktopEnvironmentBootstrapSchema), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 4b5c0d2f656..9f92cfcf2fa 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -116,6 +116,19 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); }; }, + getWindowFullscreenState: () => + ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true, + onWindowFullscreenStateChange: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, fullscreen: unknown) => { + if (typeof fullscreen !== "boolean") return; + listener(fullscreen); + }; + + ipcRenderer.on(IpcChannels.WINDOW_FULLSCREEN_STATE_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.WINDOW_FULLSCREEN_STATE_CHANNEL, wrappedListener); + }; + }, getUpdateState: () => ipcRenderer.invoke(IpcChannels.UPDATE_GET_STATE_CHANNEL), setUpdateChannel: (channel) => ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel), diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 696bd755506..5ae92bbee96 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -38,6 +38,7 @@ const flushCallbacks = Effect.yieldNow; function makeHarness(options: UpdatesHarnessOptions = {}) { let checkCount = 0; let allowDowngrade = false; + let fullChangelog = false; const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; const listeners = new Map void>>(); const sentStates: DesktopUpdateState[] = []; @@ -73,6 +74,10 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { Effect.sync(() => { allowDowngrade = value; }), + setFullChangelog: (value) => + Effect.sync(() => { + fullChangelog = value; + }), setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void, checkForUpdates: Effect.sync(() => { checkCount += 1; @@ -186,6 +191,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { layer, checkCount: () => checkCount, feedUrls: () => feedUrls, + fullChangelog: () => fullChangelog, listenerCount: () => Array.from(listeners.values()).reduce( (total, eventListeners) => total + eventListeners.size, @@ -287,6 +293,49 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("enables nightly full changelog release notes and broadcasts summaries", () => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + yield* updates.setChannel("nightly"); + assert.equal(harness.fullChangelog(), true); + + harness.emit("update-available", { + version: "1.2.4-nightly.20260709.766", + releaseNotes: [ + { + version: "1.2.4-nightly.20260709.766", + note: `

What's Changed

Full Changelog

`, + }, + { + version: "1.2.4-nightly.20260709.765", + note: "- [codex] Upgrade Clerk stack by @juliusmarminge in #3821", + }, + ], + }); + yield* flushCallbacks; + + const state = yield* updates.getState; + assert.equal(state.status, "available"); + assert.deepEqual(state.releaseNotes, [ + { + version: "1.2.4-nightly.20260709.766", + items: ["feat(client): persist offline environment data by @juliusmarminge in #3795"], + }, + { + version: "1.2.4-nightly.20260709.765", + items: ["[codex] Upgrade Clerk stack by @juliusmarminge in #3821"], + }, + ]); + assert.deepEqual(harness.sentStates.at(-1)?.releaseNotes, state.releaseNotes); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect("keeps raw updater event failures out of update state", () => { const harness = makeHarness(); const cause = new Error( diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index aabb0830b0f..7357907e178 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -27,6 +27,7 @@ import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; import { createInitialDesktopUpdateState, @@ -49,6 +50,10 @@ type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; const UpdateInfo = Schema.Struct({ version: Schema.String, + // Left unvalidated on purpose: a malformed release-notes payload must never + // fail the decode and block the update state transition. The shape is + // validated defensively in normalizeDesktopUpdateReleaseNotes. + releaseNotes: Schema.optional(Schema.Unknown), }); const DownloadProgressInfo = Schema.Struct({ @@ -330,10 +335,12 @@ export const make = Effect.gen(function* () { yield* electronUpdater.setChannel(channel); yield* electronUpdater.setAllowPrerelease(allowsPrerelease); yield* electronUpdater.setAllowDowngrade(allowsPrerelease); + yield* electronUpdater.setFullChangelog(allowsPrerelease); yield* logUpdaterInfo("using update channel", { channel, allowPrerelease: allowsPrerelease, allowDowngrade: allowsPrerelease, + fullChangelog: allowsPrerelease, }); }); @@ -567,11 +574,15 @@ export const make = Effect.gen(function* () { } const checkedAt = yield* currentIsoTimestamp; + const releaseNotes = normalizeDesktopUpdateReleaseNotes(info.releaseNotes, info.version); yield* setState( - reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt), + reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt, releaseNotes), ); yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); - yield* logUpdaterInfo("update available", { version: info.version }); + yield* logUpdaterInfo("update available", { + version: info.version, + releaseNoteGroups: releaseNotes.length, + }); }), ), Effect.catchCause((cause) => { diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts new file mode 100644 index 00000000000..9d6bbaea6bc --- /dev/null +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; + +describe("normalizeDesktopUpdateReleaseNotes", () => { + it("splits a plain string note into items under the fallback version", () => { + const notes = normalizeDesktopUpdateReleaseNotes( + "## What's changed\n- First fix\n- Second fix", + "1.2.3", + ); + expect(notes).toEqual([{ version: "1.2.3", items: ["First fix", "Second fix"] }]); + }); + + it("keeps per-version groups and drops empty ones", () => { + const notes = normalizeDesktopUpdateReleaseNotes( + [ + { version: "1.2.3", note: "- Newer change" }, + { version: "1.2.2", note: "Full changelog: https://example.com/compare/x...y" }, + { version: "1.2.1", note: "- Older change" }, + ], + "1.2.3", + ); + expect(notes).toEqual([ + { version: "1.2.3", items: ["Newer change"] }, + { version: "1.2.1", items: ["Older change"] }, + ]); + }); + + it("decodes valid HTML entities", () => { + const notes = normalizeDesktopUpdateReleaseNotes("- Fix & polish 😀", "1.0.0"); + expect(notes).toEqual([{ version: "1.0.0", items: ["Fix & polish 😀"] }]); + }); + + it("ignores malformed entries instead of throwing", () => { + const notes = normalizeDesktopUpdateReleaseNotes( + [ + { version: "1.2.3", note: "- Valid change" }, + { version: 42, note: "- Bad version type" }, + { version: "1.2.1", note: { html: "

object note

" } }, + "not an object", + null, + ], + "1.2.3", + ); + expect(notes).toEqual([{ version: "1.2.3", items: ["Valid change"] }]); + }); + + it("returns non-empty groups even when preceded by many boilerplate-only groups", () => { + const boilerplate = Array.from({ length: 7 }, (_, index) => ({ + version: `1.3.${9 - index}`, + note: "Full changelog: https://example.com/compare/x...y", + })); + const notes = normalizeDesktopUpdateReleaseNotes( + [...boilerplate, { version: "1.3.2", note: "- Older but real change" }], + "1.3.9", + ); + expect(notes).toEqual([{ version: "1.3.2", items: ["Older but real change"] }]); + }); + + it("does not throw on out-of-range numeric entities and keeps the literal", () => { + expect(() => + normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"), + ).not.toThrow(); + const notes = normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"); + expect(notes).toEqual([{ version: "1.0.0", items: ["Broken entity �"] }]); + }); +}); diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts new file mode 100644 index 00000000000..69857c92b3f --- /dev/null +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -0,0 +1,125 @@ +import type { DesktopUpdateReleaseNote } from "@t3tools/contracts"; + +interface ElectronReleaseNoteInfo { + readonly version: string; + readonly note: string | null | undefined; +} + +function isElectronReleaseNoteInfo(value: unknown): value is ElectronReleaseNoteInfo { + if (typeof value !== "object" || value === null) return false; + const candidate = value as { readonly version?: unknown; readonly note?: unknown }; + return ( + typeof candidate.version === "string" && + (typeof candidate.note === "string" || candidate.note === null || candidate.note === undefined) + ); +} + +const MAX_RELEASE_NOTE_GROUPS = 6; +const MAX_RELEASE_NOTE_ITEMS_PER_GROUP = 8; +const MAX_RELEASE_NOTE_ITEM_LENGTH = 220; + +const HTML_ENTITY_REPLACEMENTS: Readonly> = { + amp: "&", + apos: "'", + gt: ">", + lt: "<", + nbsp: " ", + quot: '"', +}; + +function decodeCodePoint(codePoint: number, entity: string): string { + // String.fromCodePoint throws RangeError outside the valid Unicode range, and + // Number.isFinite alone lets oversized values (e.g. �) through. + if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { + return `&${entity};`; + } + return String.fromCodePoint(codePoint); +} + +function decodeHtmlEntity(entity: string): string { + const named = HTML_ENTITY_REPLACEMENTS[entity]; + if (named) return named; + if (entity.startsWith("#x")) { + return decodeCodePoint(Number.parseInt(entity.slice(2), 16), entity); + } + if (entity.startsWith("#")) { + return decodeCodePoint(Number.parseInt(entity.slice(1), 10), entity); + } + return `&${entity};`; +} + +function decodeHtmlEntities(input: string): string { + return input.replace(/&([a-zA-Z]+|#\d+|#x[0-9a-fA-F]+);/g, (_, entity: string) => + decodeHtmlEntity(entity), + ); +} + +function stripMarkup(input: string): string { + return decodeHtmlEntities( + input + .replace(//gi, "\n") + .replace(/]*>/gi, "\n- ") + .replace(/<\/(?:p|div|li|h[1-6]|ul|ol|blockquote)>/gi, "\n") + .replace(/<[^>]*>/g, "") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1"), + ); +} + +function truncateReleaseNoteItem(item: string): string { + if (item.length <= MAX_RELEASE_NOTE_ITEM_LENGTH) return item; + return `${item.slice(0, MAX_RELEASE_NOTE_ITEM_LENGTH - 3).trimEnd()}...`; +} + +function isIgnoredReleaseNoteLine(line: string): boolean { + const normalized = line + .toLowerCase() + .replace(/[*_`#]/g, "") + .trim(); + return ( + normalized === "" || + normalized === "what's changed" || + normalized === "whats changed" || + normalized === "full changelog" || + normalized === "new contributors" || + normalized.startsWith("compare: ") || + normalized.includes("/compare/") + ); +} + +function extractReleaseNoteItems(note: string | null | undefined): ReadonlyArray { + if (!note) return []; + + const items: string[] = []; + for (const rawLine of stripMarkup(note).split("\n")) { + const item = rawLine + .trim() + .replace(/^[-*]\s+/, "") + .replace(/^\d+[.)]\s+/, "") + .replace(/\s+/g, " "); + if (isIgnoredReleaseNoteLine(item)) continue; + items.push(truncateReleaseNoteItem(item)); + if (items.length >= MAX_RELEASE_NOTE_ITEMS_PER_GROUP) break; + } + return items; +} + +export function normalizeDesktopUpdateReleaseNotes( + releaseNotes: unknown, + fallbackVersion: string, +): ReadonlyArray { + const rawNotes = + typeof releaseNotes === "string" + ? [{ version: fallbackVersion, note: releaseNotes }] + : Array.isArray(releaseNotes) + ? releaseNotes.filter(isElectronReleaseNoteInfo) + : []; + + return rawNotes + .map((entry) => ({ + version: entry.version, + items: extractReleaseNoteItems(entry.note), + })) + .filter((entry) => entry.items.length > 0) + .slice(0, MAX_RELEASE_NOTE_GROUPS); +} diff --git a/apps/desktop/src/updates/updateMachine.test.ts b/apps/desktop/src/updates/updateMachine.test.ts index 4b2a87c7ce0..040411f76f4 100644 --- a/apps/desktop/src/updates/updateMachine.test.ts +++ b/apps/desktop/src/updates/updateMachine.test.ts @@ -118,6 +118,12 @@ describe("updateMachine", () => { }); it("tracks available, download start, and progress cleanly", () => { + const releaseNotes = [ + { + version: "1.1.0", + items: ["feat: add update release notes"], + }, + ]; const available = reduceDesktopUpdateStateOnUpdateAvailable( { ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), @@ -126,15 +132,33 @@ describe("updateMachine", () => { }, "1.1.0", "2026-03-04T00:00:00.000Z", + releaseNotes, ); const downloading = reduceDesktopUpdateStateOnDownloadStart(available); const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5); expect(available.status).toBe("available"); expect(available.channel).toBe("latest"); + expect(available.releaseNotes).toBe(releaseNotes); + expect(downloading.releaseNotes).toBe(releaseNotes); expect(downloading.status).toBe("downloading"); expect(downloading.downloadPercent).toBe(0); expect(progress.downloadPercent).toBe(55.5); expect(progress.errorContext).toBeNull(); }); + + it("clears release notes when checking again", () => { + const state = reduceDesktopUpdateStateOnCheckStart( + { + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "nightly"), + enabled: true, + status: "available", + availableVersion: "1.1.0-nightly.1", + releaseNotes: [{ version: "1.1.0-nightly.1", items: ["feat: old note"] }], + }, + "2026-03-04T00:00:00.000Z", + ); + + expect(state.releaseNotes).toEqual([]); + }); }); diff --git a/apps/desktop/src/updates/updateMachine.ts b/apps/desktop/src/updates/updateMachine.ts index b5037225774..fef51bbb8ab 100644 --- a/apps/desktop/src/updates/updateMachine.ts +++ b/apps/desktop/src/updates/updateMachine.ts @@ -1,6 +1,7 @@ import type { DesktopRuntimeInfo, DesktopUpdateChannel, + DesktopUpdateReleaseNote, DesktopUpdateState, } from "@t3tools/contracts"; @@ -29,6 +30,7 @@ export function createInitialDesktopUpdateState( runningUnderArm64Translation: runtimeInfo.runningUnderArm64Translation, availableVersion: null, downloadedVersion: null, + releaseNotes: [], downloadPercent: null, checkedAt: null, message: null, @@ -45,6 +47,7 @@ export function reduceDesktopUpdateStateOnCheckStart( ...state, status: "checking", checkedAt, + releaseNotes: [], message: null, downloadPercent: null, errorContext: null, @@ -72,12 +75,14 @@ export function reduceDesktopUpdateStateOnUpdateAvailable( state: DesktopUpdateState, version: string, checkedAt: string, + releaseNotes: ReadonlyArray = [], ): DesktopUpdateState { return { ...state, status: "available", availableVersion: version, downloadedVersion: null, + releaseNotes, downloadPercent: null, checkedAt, message: null, @@ -95,6 +100,7 @@ export function reduceDesktopUpdateStateOnNoUpdate( status: "up-to-date", availableVersion: null, downloadedVersion: null, + releaseNotes: [], downloadPercent: null, checkedAt, message: null, diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 280f2109fec..492ef1ad577 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -28,7 +28,7 @@ import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts"; +import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; import * as PreviewManager from "../preview/Manager.ts"; @@ -46,6 +46,7 @@ const environmentInput = { } satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; function makeFakeBrowserWindow() { + const windowListeners = new Map void>(); const webContentsListeners = new Map void>(); const webContents = { copyImageAt: vi.fn(), @@ -66,10 +67,13 @@ function makeFakeBrowserWindow() { close: vi.fn(), focus: vi.fn(), isDestroyed: vi.fn(() => false), + isFullScreen: vi.fn(() => false), isMinimized: vi.fn(() => false), isVisible: vi.fn(() => true), loadURL: vi.fn(() => Promise.resolve()), - on: vi.fn(), + on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { + windowListeners.set(eventName, listener); + }), once: vi.fn(), restore: vi.fn(), setBackgroundColor: vi.fn(), @@ -88,6 +92,7 @@ function makeFakeBrowserWindow() { send: webContents.send, setAutoHideCursor: window.setAutoHideCursor, webContentsListeners, + windowListeners, }; } @@ -338,6 +343,37 @@ describe("DesktopWindow", () => { }), ); + it.effect("publishes native macOS fullscreen changes to the renderer", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const enterFullscreen = fakeWindow.windowListeners.get("enter-full-screen"); + const leaveFullscreen = fakeWindow.windowListeners.get("leave-full-screen"); + if (!enterFullscreen || !leaveFullscreen) { + return yield* Effect.die("fullscreen listeners were not registered"); + } + + enterFullscreen(); + leaveFullscreen(); + assert.deepEqual(fakeWindow.send.mock.calls, [ + [WINDOW_FULLSCREEN_STATE_CHANNEL, true], + [WINDOW_FULLSCREEN_STATE_CHANNEL, false], + ]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("recovers when the development renderer is temporarily unreachable", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index b1dfabe7ab4..c808c77e51c 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -15,7 +15,7 @@ import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts"; +import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; import * as PreviewManager from "../preview/Manager.ts"; const TITLEBAR_HEIGHT = 40; @@ -365,6 +365,15 @@ export const make = Effect.gen(function* () { window.setTitle(environment.displayName); }); + if (environment.platform === "darwin") { + window.on("enter-full-screen", () => { + window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, true); + }); + window.on("leave-full-screen", () => { + window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, false); + }); + } + let developmentLoadRetryIndex = 0; let developmentLoadRetryFiber: Fiber.Fiber | undefined; const clearDevelopmentLoadRetry = () => { diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index cdb3fc78286..895d246e368 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -16,6 +16,7 @@ import { formatNodePtyProbeFailureReason, formatWslShellTransportFailureReason, parseNodePath, + parseNodeVersion, parseResolvedPath, parseToolchainReport, probeWslDistros, @@ -173,6 +174,29 @@ describe("parseNodePath", () => { }); }); +describe("parseNodeVersion", () => { + it("extracts the node version from a nodeVersion: line", () => { + expect(parseNodeVersion("nodeVersion:24.10.0")).toBe("24.10.0"); + }); + + it("returns null when the version value is empty", () => { + expect(parseNodeVersion("nodeVersion:")).toBeNull(); + }); + + it("returns null when there is no nodeVersion line at all", () => { + expect(parseNodeVersion("nodePath:/usr/bin/node\nresolvedPath:/usr/bin")).toBeNull(); + }); + + it("ignores surrounding noise and trims whitespace", () => { + const stdout = [ + "some preamble noise", + " nodeVersion:22.16.0 ", + "nodePath:/usr/bin/node", + ].join("\n"); + expect(parseNodeVersion(stdout)).toBe("22.16.0"); + }); +}); + describe("parseResolvedPath", () => { it("preserves spaces and apostrophes in the resolved login-shell PATH", () => { const resolvedPath = "/home/test user/bin:/opt/test's tools/bin:/usr/bin:/bin"; diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index b38675f0e1f..c6c274d8500 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -32,7 +32,11 @@ export interface EnsureWslNodePtyOptions { } export type EnsureWslNodePtyResult = - | { readonly ok: true; readonly nodePath: string; readonly resolvedPath: string } + | { + readonly ok: true; + readonly nodePath: string; + readonly resolvedPath: string; + } | { readonly ok: false; readonly reason: string; @@ -222,6 +226,7 @@ export const formatNodePtyProbeFailureReason = (exitCode: number): string | null const NODE_PTY_PROBE_SCRIPT = ( linuxServerDir: string, ) => `printf 'nodePath:%s\\n' "$(command -v node 2>/dev/null)" +printf 'nodeVersion:%s\\n' "$(node -p 'process.versions.node' 2>/dev/null)" printf 'resolvedPath:%s\\n' "$PATH" cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1 // The server bundle externalizes its deps to node_modules, and the WSL Node @@ -318,6 +323,16 @@ export const parseNodePath = (stdout: string): string | null => { return path ?? null; }; +export const parseNodeVersion = (stdout: string): string | null => { + const version = stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("nodeVersion:")) + .map((line) => line.slice("nodeVersion:".length).trim()) + .find((value) => value.length > 0); + return version ?? null; +}; + // Captures the login-shell PATH after the shared resolver has loaded version // managers. Preserve the value byte-for-byte apart from a Windows-style CR so // paths containing spaces or apostrophes can be forwarded as one env argv. @@ -404,7 +419,11 @@ const ensureNodePtyImpl = ( const transportFailureReason = formatWslShellTransportFailureReason(probe.transportFailure); if (transportFailureReason !== null) { - return { ok: false, reason: transportFailureReason, fatal: false } as const; + return { + ok: false, + reason: transportFailureReason, + fatal: false, + } as const; } // No node at all, even after the shared resolver repaired PATH. Surface @@ -457,12 +476,31 @@ const ensureNodePtyImpl = ( } as const; } - if (probe.exitCode === 0) return { ok: true, nodePath, resolvedPath } as const; + if (probe.exitCode === 0) { + const rawVersion = parseNodeVersion(probe.stdout); + if ( + rawVersion !== null && + options.nodeEngineRange && + !satisfiesSemverRange(rawVersion, options.nodeEngineRange.trim()) + ) { + const range = options.nodeEngineRange.trim(); + return { + ok: false, + reason: `WSL Node.js ${rawVersion} does not satisfy the server's required engine range (${range}). Install a compatible version, and restart the desktop app.`, + fatal: true, + } as const; + } + return { ok: true, nodePath, resolvedPath } as const; + } if (options.allowBuild !== true) { const packagedProbeFailure = formatNodePtyProbeFailureReason(probe.exitCode); if (packagedProbeFailure !== null) { - return { ok: false, reason: packagedProbeFailure, fatal: true } as const; + return { + ok: false, + reason: packagedProbeFailure, + fatal: true, + } as const; } } diff --git a/apps/marketing/public/harnesses/cursor_light.svg b/apps/marketing/public/harnesses/cursor_light.svg index e61e0be3bfd..089d4676370 100644 --- a/apps/marketing/public/harnesses/cursor_light.svg +++ b/apps/marketing/public/harnesses/cursor_light.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/apps/marketing/public/harnesses/grok-dark.svg b/apps/marketing/public/harnesses/grok-dark.svg new file mode 100644 index 00000000000..d094fcc6f85 --- /dev/null +++ b/apps/marketing/public/harnesses/grok-dark.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/marketing/src/components/LegalPage.astro b/apps/marketing/src/components/LegalPage.astro new file mode 100644 index 00000000000..eb824039e98 --- /dev/null +++ b/apps/marketing/src/components/LegalPage.astro @@ -0,0 +1,414 @@ +--- +import Layout from "../layouts/Layout.astro"; + +interface Props { + readonly title: string; + readonly description: string; + readonly heading: string; + readonly lede: string; + readonly effectiveDate: string; + readonly lastUpdated: string; + readonly sections: ReadonlyArray; +} + +const { title, description, heading, lede, effectiveDate, lastUpdated, sections } = Astro.props; +--- + + + + + + + + diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index 5d9fc4e8f3b..9c454c4b78b 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -4,11 +4,13 @@ import { GITHUB_REPOSITORY_URL, MARKETING_STATS } from "../lib/site"; interface Props { title?: string; description?: string; + pageClass?: string; } const { title = "T3 Code", description = "T3 Code — The open-source control plane for coding agents.", + pageClass, } = Astro.props; --- @@ -30,7 +32,7 @@ const { {title} -
+ @@ -279,7 +284,7 @@ const { min-height: 100vh; display: flex; flex-direction: column; - overflow-x: hidden; + overflow-x: clip; } .nav { @@ -402,6 +407,8 @@ const { .footer-links { display: flex; + flex-wrap: wrap; + justify-content: flex-end; gap: 20px; } @@ -421,6 +428,28 @@ const { padding-left: 20px; padding-right: 20px; } + + .legal-document-page .nav { + display: none; + } + + .footer { + padding-bottom: max(32px, env(safe-area-inset-bottom)); + } + + .footer-inner { + align-items: flex-start; + flex-direction: column; + gap: 28px; + } + + .footer-links { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + width: 100%; + gap: 14px 20px; + justify-content: initial; + } } @media (max-width: 420px) { diff --git a/apps/marketing/src/lib/site.ts b/apps/marketing/src/lib/site.ts index 5ff5958c588..92e491a077a 100644 --- a/apps/marketing/src/lib/site.ts +++ b/apps/marketing/src/lib/site.ts @@ -1,6 +1,6 @@ export const GITHUB_REPOSITORY_URL = "https://github.com/pingdotgg/t3code"; export const MARKETING_STATS = { - githubStars: "12k+", + githubStars: "14k+", users: "100,000", } as const; diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index dbd9f7c4f23..841c6a622b9 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -33,6 +33,9 @@ const mobileEndorsementRows = [ + + +
@@ -41,7 +44,7 @@ const mobileEndorsementRows = [

- Orchestrate Claude Code, Codex, OpenCode and Cursor from one surface. + Orchestrate Claude Code, Codex, OpenCode, Cursor, and Grok from one surface. Bring your own subscription. Fork the whole thing.

@@ -173,7 +176,7 @@ const mobileEndorsementRows = [

Bring your own sub

T3 Code doesn't resell tokens. Plug in Claude Code, Codex, OpenCode, - or Cursor with the credentials you already have — we orchestrate + Cursor, or Grok with the credentials you already have — we orchestrate them, you keep your plan.

@@ -207,6 +210,13 @@ const mobileEndorsementRows = [
@cursor/sdk
+
+
+
+
Grok CLI
+
grok login
+
+
@@ -403,12 +413,14 @@ const mobileEndorsementRows = [ const label = document.getElementById("download-label"); const ctaBtn = document.getElementById("cta-download-btn") as HTMLAnchorElement | null; const ctaLabel = document.getElementById("cta-download-label"); + const kbd = document.getElementById("pr-button-kbd"); const platform = detectPlatform(); if (!platform) return; document.documentElement.dataset.platform = platform.os; if (label) label.textContent = platform.label; if (ctaLabel) ctaLabel.textContent = platform.label; + if (kbd && platform.os !== "mac") kbd.textContent = "Ctrl ⏎"; try { const release = await fetchLatestRelease(); @@ -613,8 +625,9 @@ const mobileEndorsementRows = [ .hero-float-mark.hf-claude { background: radial-gradient(circle at 30% 25%, rgba(217, 119, 87, 0.22), rgba(20, 20, 24, 0.92) 65%); top: 8%; left: 7%; animation-delay: 0s; transform: rotate(-8deg); } .hero-float-mark.hf-codex { background: radial-gradient(circle at 30% 25%, rgba(255, 255, 255, 0.08), rgba(20, 20, 24, 0.92) 65%); top: 6%; right: 7%; animation-delay: -2.5s; transform: rotate(6deg); } - .hero-float-mark.hf-opencode { background: radial-gradient(circle at 30% 25%, rgba(255, 255, 255, 0.06), rgba(20, 20, 24, 0.92) 65%); top: 32%; left: 5%; animation-delay: -5s; transform: rotate(4deg); } + .hero-float-mark.hf-opencode { background: radial-gradient(circle at 30% 25%, rgba(255, 255, 255, 0.06), rgba(20, 20, 24, 0.92) 65%); top: 30%; left: 5%; animation-delay: -5s; transform: rotate(4deg); } .hero-float-mark.hf-cursor { background: radial-gradient(circle at 30% 25%, rgba(255, 255, 255, 0.08), rgba(20, 20, 24, 0.92) 65%); top: 30%; right: 5%; animation-delay: -7s; transform: rotate(-5deg); } + .hero-float-mark.hf-grok { background: radial-gradient(circle at 30% 25%, rgba(255, 255, 255, 0.08), rgba(20, 20, 24, 0.92) 65%); top: 52%; left: 6%; animation-delay: -1.2s; transform: rotate(3deg); } @media (max-width: 1180px) { .hero-float-mark { width: 76px; height: 76px; border-radius: 20px; } @@ -623,6 +636,7 @@ const mobileEndorsementRows = [ .hero-float-mark.hf-codex { top: 2%; right: 3%; } .hero-float-mark.hf-opencode { top: 26%; left: 2%; } .hero-float-mark.hf-cursor { top: 24%; right: 2%; } + .hero-float-mark.hf-grok { top: 48%; left: 2%; transform: rotate(3deg); } } @media (max-width: 820px) { @@ -657,17 +671,18 @@ const mobileEndorsementRows = [ height: 44px; } - /* Two above the title, two flanking the call-to-action area. */ + /* Three stacked on the left, two on the right — keeps the center CTA clear. */ .hero-float-mark.hf-claude { top: 44px; left: 10px; transform: rotate(-10deg); } - .hero-float-mark.hf-codex { - top: 44px; - right: 10px; - transform: rotate(8deg); + .hero-float-mark.hf-grok { + top: 240px; + left: 4px; + right: auto; + transform: rotate(-4deg); } .hero-float-mark.hf-opencode { @@ -677,6 +692,12 @@ const mobileEndorsementRows = [ transform: rotate(-6deg); } + .hero-float-mark.hf-codex { + top: 44px; + right: 10px; + transform: rotate(8deg); + } + .hero-float-mark.hf-cursor { top: 474px; right: 4px; @@ -686,6 +707,14 @@ const mobileEndorsementRows = [ } @media (max-width: 340px) { + .hero-float-mark.hf-grok { + top: 220px; + left: 0; + width: 52px; + height: 52px; + border-radius: 14px; + } + .hero-float-mark.hf-opencode, .hero-float-mark.hf-cursor { top: 580px; @@ -702,6 +731,7 @@ const mobileEndorsementRows = [ right: 0; } + .hero-float-mark.hf-grok img, .hero-float-mark.hf-opencode img, .hero-float-mark.hf-cursor img { width: 30px; @@ -748,7 +778,7 @@ const mobileEndorsementRows = [ .harness-grid { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(5, 1fr); gap: 0; border: 1px solid var(--border); border-radius: var(--radius); @@ -1203,6 +1233,10 @@ const mobileEndorsementRows = [ .harness-grid { grid-template-columns: 1fr 1fr; } .harness { border-right: 0; border-bottom: 1px solid var(--border); } .harness:nth-child(odd) { border-right: 1px solid var(--border); } + .harness:nth-child(5):last-child { + grid-column: 1 / -1; + border-right: 0; + } .git-inner { grid-template-columns: 1fr; gap: 40px; } .open-grid { grid-template-columns: 1fr; } } diff --git a/apps/marketing/src/pages/legal.astro b/apps/marketing/src/pages/legal.astro new file mode 100644 index 00000000000..d75413a8da7 --- /dev/null +++ b/apps/marketing/src/pages/legal.astro @@ -0,0 +1,172 @@ +--- +import Layout from "../layouts/Layout.astro"; + +const documents = [ + { + number: "01", + title: "Privacy Policy", + description: "How T3 Code handles information, connected services, and your privacy choices.", + href: "/privacy-policy", + }, + { + number: "02", + title: "Terms of Service", + description: "The terms that govern your use of T3 Code and T3 Tools-operated services.", + href: "/terms-of-service", + }, + { + number: "03", + title: "Security Policy", + description: "Our security practices, responsible disclosure process, and shared responsibilities.", + href: "/security-policy", + }, +] as const; +--- + + + + + + diff --git a/apps/marketing/src/pages/privacy-policy.astro b/apps/marketing/src/pages/privacy-policy.astro new file mode 100644 index 00000000000..b85bc0b12b4 --- /dev/null +++ b/apps/marketing/src/pages/privacy-policy.astro @@ -0,0 +1,445 @@ +--- +import LegalPage from "../components/LegalPage.astro"; + +const sections = [ + ["introduction", "Introduction"], + ["information", "Personal information we collect"], + ["uses", "How we use your personal information"], + ["sharing", "How we share your personal information"], + ["choices", "Your choices regarding your personal information"], + ["security", "Security of your personal information"], + ["international", "International users"], + ["children", "Children"], + ["do-not-track", "Do Not Track"], + ["updates", "Updates to this Privacy Policy"], + ["contact", "Contact us"], +] as const; +--- + + +
+

01

+

Introduction

+

+ This Privacy Policy (the “Policy”) describes how T3 Tools, Inc. (the “Company,” “T3 + Tools,” “we,” or “us”) collects, uses, and shares personal information when you use the T3 + Code desktop and mobile applications, the hosted T3 Code web application located at + {" "}https://app.t3.codes, or the optional T3 Connect + service (together, the “Services”), or visit the T3 Code marketing website located at + {" "}https://t3.codes (the “Site”). This Policy applies to + personal information we collect through the Site and the Services, as well as personal + information you provide to us directly. +

+

+ By using the Site or the Services, you acknowledge the practices described in this + Policy. If you do not agree with this Policy, please do not use the Site or the Services. + This Policy does not govern third-party coding agents, artificial-intelligence providers, + identity providers, or environments that you choose to connect to T3 Code; those third + parties process information under their own terms and privacy policies. +

+
+ +
+

02

+

Personal Information We Collect

+ +

+ We collect personal information about you in different ways depending on how you use + T3 Code and which optional features you enable. +

+ +

Personal Information From Users of T3 Code

+ +

+ When you use T3 Code, we may collect the following categories of personal information: +

+
    +
  • + Account information. If you create or sign in to a T3 account, we + receive general identifiers and profile information from our authentication provider + and the identity provider you select. This may include your name, email address, + profile image, user ID, sign-in method, and authentication credentials or tokens. +
  • +
  • + Environment and device information. If you enable T3 Connect, we + process information needed to connect and secure your environments and devices, + including identifiers, labels you assign, public keys, connection endpoints, + credential hashes, connection status, app and operating-system versions, and + notification preferences. +
  • +
  • + Agent activity and notifications. If you enable notifications or Live + Activities, we process push tokens and limited activity information needed to provide + those features, such as project and thread titles, model name, status, and activity + headline. +
  • +
  • + Information you provide to us. We collect information that you choose + to provide when you request support, send feedback, or otherwise communicate with us. +
  • +
+ +

Personal Information From Users of Our Site Generally

+

+ When you visit the Site or use online portions of the Services, we and our service + providers may automatically log online identifiers and internet-activity information, + including your Internet Protocol (IP) address, device and operating-system type, browser + or app type and version, pages viewed, access times, request timestamps, connection + events, latency, error details, and diagnostic traces. The amount of information we + collect depends on the Services you use and your device and browser settings. +

+

+ We use operational and diagnostic information to provide, secure, maintain, and + troubleshoot the Services, not for targeted advertising. +

+ +

Information We Get From Others

+

+ We may receive personal information from service providers that help us operate the + Services, such as authentication and identity providers, app stores, device-platform + providers, and coding-agent providers you choose to use. We may combine this information + with information we collect through the Site and the Services. +

+ +

Cookies and Local Storage

+

+ The Site and online portions of the Services may use cookies or similar technologies to + operate essential features, maintain sessions, remember preferences, and understand + whether the Services are functioning correctly. T3 Code also stores connection settings, + credentials, preferences, drafts, and cached thread, file, branch, and server metadata on + your device. Credentials and session tokens use platform-provided protected storage where + supported. Information stored only on your device is not collected by T3 Tools unless it + is transmitted as part of a feature you use. +

+ +

Device Permissions

+
    +
  • + Camera. Used when you choose to scan a pairing QR code. Camera frames + are processed on your device. +
  • +
  • + Photos and files. Items you select are accessed only to preview them or + attach them to a request you initiate. +
  • +
  • + Notifications. Used only if you enable agent-status notifications or + Live Activities. +
  • +
  • + Local network. Used to discover or connect to T3 Code environments + accessible from your machine over the network. +
  • +
+
+ +
+

03

+

How We Use Your Personal Information

+

+ Subject to this Policy and applicable terms governing third-party applications and + services, we may use personal information for the following purposes: +

+
    +
  • To establish and verify your identity and authenticate your account;
  • +
  • To link and protect devices and environments and deliver optional notifications;
  • +
  • To process and fulfill requests you make through the Services;
  • +
  • To provide customer service and respond to support requests and feedback;
  • +
  • To communicate with you about the Services and your relationship with us;
  • +
  • To monitor reliability, prevent abuse, and investigate security incidents;
  • +
  • To comply with applicable law and enforce the terms governing the Services; and
  • +
  • + To analyze and improve T3 Code using aggregated, de-identified, or other anonymous + operational information. +
  • +
+ +

Training of AI Models

+

+ We do not use your personal information, source code, prompts, or agent responses to + train artificial-intelligence models. +

+ +

Compliance and Protection

+

We may use personal information to:

+
    +
  • Comply with applicable laws, lawful requests, and legal process;
  • +
  • Protect the rights, privacy, safety, and property of T3 Tools, our users, and others;
  • +
  • Audit our internal processes for legal, contractual, and policy compliance;
  • +
  • Enforce the terms and conditions that govern the Site and the Services; and
  • +
  • + Prevent, identify, investigate, and deter fraudulent, harmful, unauthorized, + unethical, or illegal activity, including cyberattacks and identity theft. +
  • +
+

+ We may also use personal information for other purposes consistent with this Policy or + explained to you when we collect the information. +

+
+ +
+

04

+

How We Share Your Personal Information

+

+ We may disclose the categories of personal information described above to the following + categories of third parties: +

+ +

Providers You Choose

+

+ At your direction, prompts, files, and related content are sent to coding agents and + artificial-intelligence providers configured in your environment. Because you bring and + configure your own coding-agent harness and provider, the applicable third parties vary. + Those providers process information under their own terms and privacy policies. +

+ +

Third-Party Service Providers

+

+ We use third-party service providers to help provide the Services and operate our + business. Depending on the features you use, these providers may include: +

+
    +
  • + Clerk (Clerk, Inc.).
    + We use Clerk to provide account authentication.
    + You can view Clerk's privacy policy here:{" "}https://clerk.com/legal/privacy. +
  • +
  • + Cloudflare (Cloudflare, Inc.).
    + We use Cloudflare for networking, T3 Connect infrastructure, and content delivery.
    + You can view Cloudflare's privacy policy here:{" "}https://www.cloudflare.com/policies/privacy/. +
  • +
  • + PlanetScale (PlanetScale, Inc.).
    + We use PlanetScale to host the database that supports T3 Connect.
    + You can view PlanetScale's privacy policy here:{" "}https://planetscale.com/legal/privacy. +
  • +
  • + Vercel (Vercel Inc.).
    + We use Vercel to host and deploy the T3 Code Site.
    + You can view Vercel's privacy notice here:{" "}https://vercel.com/legal/privacy-notice. +
  • +
  • + Axiom (Axiom, Inc.).
    + We use Axiom for operational diagnostics that help us maintain and troubleshoot the + Services.
    + You can view Axiom's privacy policy here:{" "}https://axiom.co/docs/legal/privacy. +
  • +
  • + Expo (650 Industries, Inc.).
    + We use Expo for application updates and related mobile-app services.
    + You can view Expo's privacy policy here:{" "}https://expo.dev/privacy. +
  • +
  • + Apple (Apple Inc.).
    + We use Apple services for app distribution, optional authentication, and platform + notifications.
    + You can view Apple's privacy policy here:{" "}https://www.apple.com/legal/privacy/. +
  • +
  • + Google (Google LLC).
    + We use Google services for app distribution and optional authentication.
    + You can view Google's privacy policy here:{" "}https://policies.google.com/privacy. +
  • +
+

+ These providers process information for us under their own contractual and privacy + obligations. +

+ +

Affiliates and Professional Advisors

+

+ We may share personal information with our affiliates for purposes consistent with this + Policy and with lawyers, accountants, bankers, and other professional advisors in the + course of the services they provide to us. +

+ +

Corporate Restructuring

+

+ We may share some or all personal information in connection with or during negotiation of + a merger, financing, acquisition, reorganization, bankruptcy, dissolution, sale of + assets, or similar transaction. If another company acquires T3 Tools or its assets, that + company may possess the personal information collected by us and assume the rights and + obligations described in this Policy. +

+ +

Legal and Safety Disclosures

+

+ We may disclose personal information if we believe in good faith that disclosure is + necessary to comply with law or legal process; protect or defend the rights, property, or + safety of T3 Tools, users of the Site or the Services, or others; investigate or prevent + fraud, abuse, or unlawful activity; or enforce this Policy and the terms governing the + Services. We may also share personal information at your direction, with your consent, or + as described to you when the information is collected. +

+ +

No Sale of Personal Information

+

+ We do not sell your personal information or share it for cross-context behavioral or + targeted advertising. +

+ +

Third-Party Websites and Services

+

+ The Site and the Services may contain links to third-party websites or services. When you + follow a third-party link or connect a third-party service, that third party may collect + personal information from you. We do not control and are not responsible for the privacy + practices or content of third parties. +

+
+ +
+

05

+

Your Choices Regarding Your Personal Information

+ +

Account and Connection Choices

+

+ You may use local or direct connections without signing in to a T3 account. You can + disconnect environments, sign out, disable optional notifications and Live Activities, + and clear local caches from Settings. You can grant or revoke camera, photo, local-network, + and notification permissions in your device settings. +

+ +

Cookies and Local Data

+

+ You can use your browser settings to stop accepting or to delete cookies, although some + portions of the Site or the Services may not function correctly as a result. Information + stored only on your device remains there until you clear it, remove the relevant + connection, or uninstall the app. The mobile app provides cache controls under + {" "}Settings → App → Client Storage. +

+ +

Retention and Deletion

+

+ We retain account, environment-link, device-registration, notification, and security + information while your account or the relevant feature is active and for as long as + reasonably necessary to provide the Services, protect them from abuse, comply with law, + and resolve disputes. Operational diagnostic traces are configured to expire after 30 + days. Some records may remain longer in backups or where retention is required for + security, fraud prevention, or legal compliance. +

+

+ To request deletion of your T3 account and associated T3 Connect data, email{" "}privacy@t3.tools{" "}from the email address associated with your account. We may need to verify your + identity. Deleting a T3 account does not delete information held by an environment or + coding-agent provider you control; contact those providers or delete that information + directly. +

+ +

Your Privacy Rights

+

+ Depending on where you live, you may have rights to request access to, correction of, + deletion of, or a portable copy of your personal information, or to restrict or object + to certain processing. You may also have the right to appeal our response or complain to + a data-protection authority. We will honor applicable rights after verifying your + request. +

+
+ +
+

06

+

Security of Your Personal Information

+

+ T3 Tools is committed to protecting the security of your personal information. We use + administrative, technical, and organizational safeguards designed to protect personal + information from unauthorized access, use, or disclosure. These safeguards include + encrypted network transport, scoped and proof-bound access tokens for supported + connection flows, protected credential storage, and redaction of authorization headers + from relay diagnostics. +

+

+ No method of transmission over the Internet or method of electronic storage is completely + secure. While we use reasonable efforts to protect personal information, we cannot + guarantee its absolute security. You are responsible for securing the environments, + coding-agent accounts, credentials, and networks that you connect to T3 Code. +

+
+ +
+

07

+

International Users

+

+ The Site and the Services are provided from the United States and are governed by United + States law. If you use the Site or the Services from outside the United States, your + personal information may be transferred to and processed in the United States and other + countries where we or our service providers operate. Where required, we use lawful + safeguards for international transfers of personal information. +

+
+ +
+

08

+

Children

+

+ The Site and the Services are not intended for children under 13 years of age, and you + must be at least 13 years old to use them. We do not knowingly collect, use, or disclose + personal information from children under 13. If you believe a child under 13 has provided + us with personal information, please contact us so that we can take appropriate action. +

+
+ +
+

09

+

Do Not Track

+

+ We currently do not respond to browser “Do Not Track” signals. T3 Code does not use + personal information for cross-context behavioral or targeted advertising. +

+
+ +
+

10

+

Updates to This Privacy Policy

+

+ We reserve the right to update this Policy from time to time. If we make material changes, + we will post the revised Policy at this URL, update the “Last Updated” and “Effective Date” + above, and provide any additional notice required by law. Except as otherwise indicated, + changes become effective when the revised Policy is posted. +

+
+ +
+

11

+

Contact Us

+

+ If you have questions about this Policy or wish to make a privacy or deletion request, + please contact us at: +

+
+ T3 Tools, Inc.
+ 2261 Market Street #5309
+ San Francisco, CA 94114
+ United States +
+ privacy@t3.tools +
+
diff --git a/apps/marketing/src/pages/security-policy.astro b/apps/marketing/src/pages/security-policy.astro new file mode 100644 index 00000000000..c9b05ec36fd --- /dev/null +++ b/apps/marketing/src/pages/security-policy.astro @@ -0,0 +1,187 @@ +--- +import LegalPage from "../components/LegalPage.astro"; + +const sections = [ + ["introduction", "Introduction"], + ["reporting", "Reporting security issues"], + ["safe-harbor", "Responsible research and safe harbor"], + ["practices", "Our security practices"], + ["responsibilities", "Your responsibilities"], + ["updates", "Updates to this policy"], + ["contact", "Contact us"], +] as const; +--- + + +
+

01

+

Introduction

+

+ This Security Policy describes the vulnerability-reporting process and security practices for + the T3 Code desktop and mobile applications, the hosted application at{" "}https://app.t3.codes, the optional T3 Connect service, and the T3 Code website at{" "}https://t3.codes. +

+

+ T3 Code connects software running on your devices and in environments you control with + coding-agent harnesses and providers you choose. We secure the software and infrastructure + operated by T3 Tools, but we do not control the security of your devices, environments, + repositories, networks, credentials, or third-party providers. Please review the shared + responsibilities below when assessing risk. +

+
+ +
+

02

+

Reporting Security Issues

+

+ If you believe you have found a security vulnerability affecting T3 Code or T3 Tools-operated + infrastructure, email{" "}security@ping.gg. Please do not + disclose the issue publicly until we have had a reasonable opportunity to investigate and + remediate it. +

+

Please include, when available:

+
    +
  • A description of the issue and its potential impact;
  • +
  • The affected application, version, URL, endpoint, or component;
  • +
  • Clear reproduction steps or a minimal proof of concept;
  • +
  • Relevant logs, screenshots, or request and response details with secrets removed; and
  • +
  • Your preferred contact information and whether you want public credit.
  • +
+

+ We aim to acknowledge complete reports within one business day, keep reporters informed of + material progress, and coordinate disclosure after a fix is available. Resolution time varies + with severity, complexity, and dependencies on third parties. +

+
+ +
+

03

+

Responsible Research and Safe Harbor

+

+ We support good-faith security research. If you make a genuine effort to comply with this + policy, avoid harm, respect privacy, and report findings promptly, we will treat your research + as authorized and will not initiate legal action against you for accidental, good-faith + violations of this policy. If a third party initiates legal action concerning compliant + research, we will make our authorization known where appropriate. +

+

To remain within this safe harbor, you must:

+
    +
  • Test only accounts, data, and systems you own or have explicit permission to test;
  • +
  • Stop and report immediately if you encounter personal, confidential, or production data;
  • +
  • Access only the minimum information needed to demonstrate the issue;
  • +
  • Avoid disrupting availability, degrading performance, or damaging or deleting data;
  • +
  • Not use social engineering, phishing, physical attacks, or denial-of-service testing;
  • +
  • Not test third-party services or infrastructure outside T3 Tools’ control; and
  • +
  • Give us reasonable time to address the issue before public disclosure.
  • +
+

+ This safe harbor does not authorize violations of law or activity outside the scope of this + policy. Vulnerability rewards are not guaranteed and, if offered, are determined by T3 Tools + in its discretion. +

+
+ +
+

04

+

Our Security Practices

+

+ We use administrative, technical, and organizational safeguards designed for the nature of T3 + Code and the information processed by T3 Tools-operated services. These practices include: +

+
    +
  • + Data minimization. T3 Code is designed so coding-session content can remain + between your clients, your environment, and the harnesses and providers you configure. We + process information through T3 Tools-operated infrastructure only when needed for a feature + you enable. +
  • +
  • + Encrypted transport. T3 Tools-operated network services use encrypted + transport. Supported connection flows use scoped credentials and security controls designed + to reduce unauthorized reuse. +
  • +
  • + Credential protection. T3 Code uses platform-provided protected storage for + credentials and session tokens where supported. We design operational logging to avoid + collecting secrets that are not needed to operate or troubleshoot the Services. +
  • +
  • + Access controls. Access to production systems and operational data is limited + according to job responsibilities and protected using authentication and authorization + controls. +
  • +
  • + Maintenance and monitoring. We monitor T3 Tools-operated services for + reliability and security events, review dependencies, and deploy updates and mitigations as + appropriate. +
  • +
  • + Established providers. We use specialized providers for services such as + authentication, hosting, and application distribution and evaluate the controls relevant to + their role. +
  • +
+

+ No system is completely secure. These practices reduce risk but do not guarantee that the Site + or Services will be free from vulnerabilities or unauthorized access. +

+
+ +
+

05

+

Your Responsibilities

+

+ Because you control much of the T3 Code execution path, you play an important role in securing + it. You are responsible for: +

+
    +
  • Keeping T3 Code, your operating systems, and connected tools up to date;
  • +
  • Securing your devices, repositories, environments, networks, and backups;
  • +
  • Protecting account, provider, repository, and environment credentials;
  • +
  • Using multi-factor authentication where your identity and coding-agent providers support it;
  • +
  • Granting providers and integrations only the permissions they need;
  • +
  • Reviewing commands, source changes, and other agent output before applying or executing it;
  • +
  • Removing lost or unused devices and revoking credentials you believe may be compromised; and
  • +
  • Following the security policies of your selected harnesses, providers, and infrastructure.
  • +
+

+ Do not send passwords, API keys, access tokens, or private keys in a vulnerability report. + Revoke any secret that may have been exposed before sharing sanitized evidence with us. +

+
+ +
+

06

+

Updates to This Policy

+

+ We may update this Security Policy as T3 Code, our infrastructure, and security practices + evolve. We will post revisions at this URL and update the dates above. Material changes to the + vulnerability-reporting process or safe-harbor terms will apply prospectively. +

+
+ +
+

07

+

Contact Us

+

+ Report security issues or ask security-related questions at{" "}security@ping.gg. +

+

+ For general legal questions, contact{" "}legal@t3.tools. + For privacy requests, contact{" "}privacy@t3.tools. +

+
+
diff --git a/apps/marketing/src/pages/terms-of-service.astro b/apps/marketing/src/pages/terms-of-service.astro new file mode 100644 index 00000000000..e6de5f2c751 --- /dev/null +++ b/apps/marketing/src/pages/terms-of-service.astro @@ -0,0 +1,396 @@ +--- +import LegalPage from "../components/LegalPage.astro"; + +const sections = [ + ["introduction", "Introduction"], + ["accounts", "Accounts"], + ["content", "Your content and providers"], + ["rights", "T3 Code and proprietary rights"], + ["acceptable-use", "Acceptable use"], + ["availability", "Availability and changes"], + ["fees", "Fees"], + ["privacy", "Privacy"], + ["termination", "Suspension and termination"], + ["beta", "Beta services"], + ["disclaimers", "Disclaimers"], + ["liability", "Limitation of liability"], + ["indemnification", "Indemnification"], + ["copyright", "Copyright complaints"], + ["third-parties", "Third-party services"], + ["feedback", "Feedback"], + ["disputes", "Dispute resolution"], + ["miscellaneous", "Miscellaneous"], + ["changes", "Changes to these Terms"], + ["contact", "Contact us"], +] as const; +--- + + +
+

01

+

Introduction

+

+ These Terms of Service (the “Terms”) are a binding agreement between you and T3 Tools, Inc. + (“T3 Tools,” “we,” “us,” or “our”). They govern your access to and use of the T3 Code desktop + and mobile applications, the hosted T3 Code application at{" "}https://app.t3.codes, and the optional T3 Connect service (together, the “Services”), as well as the T3 Code + marketing website at{" "}https://t3.codes (the “Site”). +

+

+ By accessing or using the Site or Services, you agree to these Terms. If you use the Services + for an organization, you represent that you have authority to bind that organization, and + “you” includes the organization. If you do not agree to these Terms, do not use the Site or + Services. +

+

+ You must be at least 13 years old to use the Site or Services. If the law where you live + requires a greater age to enter into these Terms, you must meet that requirement or have a + parent or legal guardian agree on your behalf. +

+
+ +
+

02

+

Accounts

+

+ You can use some T3 Code features without creating a T3 account. An account may be required + for account-backed features, including T3 Connect. You agree to provide accurate account + information, keep your credentials secure, and promptly notify us if you suspect unauthorized + access. You are responsible for activity performed through your account and connected devices, + except to the extent caused by our failure to use reasonable security measures. +

+

+ You may not share credentials in a way that compromises the Services, impersonate another + person, or create accounts through unauthorized automated means. We may require reasonable + verification before restoring access or acting on an account request. +

+
+ +
+

03

+

Your Content and Providers

+

+ T3 Code can help you send prompts, source files, images, attachments, review comments, and + instructions to coding-agent harnesses and providers you choose, and display their responses, + command output, file changes, source code, and diffs (collectively, “User Content”). You retain + all rights you have in your User Content. +

+

+ You bring and configure your own coding-agent harness and provider. Your User Content is + ordinarily processed in the environment you control and by the providers you select, not by + T3 Tools. When you enable a T3 Tools-operated feature that must transmit or process User + Content, you grant us a limited, non-exclusive license to host, transmit, reproduce, and + process that content only as reasonably necessary to provide, secure, and troubleshoot that + feature. This license ends when the content is no longer needed for those purposes, subject to + reasonable backup, security, and legal-retention requirements. +

+

+ You represent that you have the rights and permissions needed to use and submit User Content + and to direct its processing by your selected harnesses and providers. You are responsible for + reviewing those providers’ terms, privacy practices, data controls, and output before using + them with confidential, proprietary, regulated, or personal information. +

+
+ +
+

04

+

T3 Code and Proprietary Rights

+

+ T3 Tools and its licensors own the Site, the Services, and their branding, designs, hosted + infrastructure, documentation, and other materials, excluding User Content and third-party + materials. These Terms do not transfer ownership of either party’s intellectual property. +

+

+ Portions of T3 Code are available as open-source software. Your use, copying, modification, and + distribution of that source code are governed by the license included with the applicable + repository or component. These Terms govern your use of the hosted Services and do not limit + rights granted to you by an applicable open-source license. +

+

+ Subject to these Terms, we grant you a limited, non-exclusive, non-transferable, revocable right + to access and use the Services for lawful personal or internal business purposes. You may not + use our trademarks, service marks, or trade dress without written permission. +

+
+ +
+

05

+

Acceptable Use

+

You may not use the Site or Services to:

+
    +
  • Violate applicable law or another person’s rights;
  • +
  • Upload, transmit, or generate content you do not have the right to use;
  • +
  • Distribute malware, exploit code, or harmful content except in authorized security work;
  • +
  • Probe, attack, disrupt, or gain unauthorized access to the Services or another system;
  • +
  • Bypass rate limits, access controls, security measures, or usage restrictions;
  • +
  • Interfere with other users or place an unreasonable load on shared infrastructure;
  • +
  • Use the Services to facilitate fraud, harassment, abuse, or deceptive conduct; or
  • +
  • Misrepresent that output or activity from a third-party agent was produced or endorsed by us.
  • +
+

+ Authorized security research must follow our{" "}Security Policy. + We may investigate suspected violations and take proportionate action to protect users and the + Services. +

+
+ +
+

06

+

Availability and Changes

+

+ We may add, change, suspend, or discontinue features, integrations, or supported platforms. We + aim to provide reasonable notice when a change materially reduces an account-backed feature, + but urgent security, legal, or reliability changes may take effect immediately. The Services + may be unavailable because of maintenance, provider outages, network conditions, or events + outside our control. +

+

+ T3 Code depends on software, environments, networks, and coding-agent providers that you + configure or that third parties operate. We do not control their availability, compatibility, + pricing, or behavior. +

+
+ +
+

07

+

Fees

+

+ T3 Code is currently offered without a T3 Tools subscription fee unless we clearly state + otherwise for a feature. You remain responsible for charges from your coding-agent providers, + cloud infrastructure, network operators, app stores, and other third parties. If we introduce + a paid feature, we will present its price and applicable payment terms before you purchase it. +

+
+ +
+

08

+

Privacy

+

+ Our{" "}Privacy Policy explains how T3 Tools collects, uses, and + shares personal information when you use the Site and Services. Third-party environments, + harnesses, identity providers, and coding-agent providers process information under their own + terms and privacy policies. +

+
+ +
+

09

+

Suspension and Termination

+

+ You may stop using the Services at any time. You may disconnect environments and devices from + within T3 Code and may request deletion of an account as described in our Privacy Policy. +

+

+ We may suspend or terminate access to account-backed or hosted features if we reasonably + believe you materially violated these Terms, created a security or legal risk, failed to pay an + applicable fee, or used the Services in a way that could harm other users or shared + infrastructure. When practicable, we will give notice and an opportunity to cure. We may act + immediately where needed to prevent harm or comply with law. +

+

+ Provisions that by their nature should survive termination—including ownership, disclaimers, + limitations of liability, indemnification, and dispute terms—will survive. +

+
+ +
+

10

+

Beta Services

+

+ T3 Code is an early-stage product, and some or all features may be identified as alpha, beta, + preview, experimental, or pre-release (“Beta Services”). Beta Services may be incomplete, + change without notice, contain errors, or lose data. Do not rely on Beta Services as the sole + copy of important work, and review proposed commands and file changes before applying them. +

+
+ +
+

11

+

Disclaimers

+ +

+ Coding agents and artificial-intelligence systems can produce incorrect, insecure, incomplete, + or harmful output. You are responsible for reviewing output, commands, patches, and other + actions before relying on or executing them. We do not warrant that the Services or third-party + output will be uninterrupted, secure, accurate, error-free, or suitable for your purpose. +

+

+ Some jurisdictions do not allow certain warranty exclusions, so some of the foregoing may not + apply to you. +

+
+ +
+

12

+

Limitation of Liability

+ +

+ To the maximum extent permitted by law, our aggregate liability for all claims arising from or + relating to the Site, Services, or these Terms will not exceed the greater of $100 or the amount + you paid directly to T3 Tools for the Services during the 12 months before the event giving rise + to the claim. These limitations apply regardless of the legal theory and even if a remedy fails + of its essential purpose. They do not limit liability that cannot lawfully be limited. +

+
+ +
+

13

+

Indemnification

+

+ To the extent permitted by law, you will defend, indemnify, and hold harmless T3 Tools and its + affiliates, officers, employees, and agents from third-party claims, damages, losses, and + reasonable legal fees arising from your User Content, your use of the Site or Services in + violation of these Terms or law, or your infringement of another person’s rights. We will + promptly notify you of a covered claim and reasonably cooperate in its defense. You may not + settle a claim in a way that admits fault by or imposes obligations on us without our written + consent. +

+
+ + + +
+

15

+

Third-Party Services

+

+ The Services may connect to or display content from third-party coding agents, model providers, + identity providers, repositories, app stores, websites, and infrastructure. Your use of those + services is governed by their terms. We do not endorse or control third-party services and are + not responsible for their content, security, availability, output, or data practices. +

+

+ Third-party and open-source software included with T3 Code is governed by its applicable + license notices. Where those licenses conflict with these Terms for that software, the + applicable open-source license controls. +

+
+ +
+

16

+

Feedback

+

+ If you send us ideas, suggestions, or other feedback about T3 Code, you grant T3 Tools a + perpetual, irrevocable, worldwide, royalty-free right to use and commercialize that feedback + without restriction or compensation. This does not transfer ownership of your User Content. +

+
+ +
+

17

+

Dispute Resolution

+ +

Informal Resolution

+

+ Before starting arbitration or a court proceeding, the party raising a dispute must send a + written notice describing the dispute and requested relief. Notices to us must be sent to{" "}legal@t3.tools. The parties will try in good faith to resolve the dispute for at least 30 days. +

+

Binding Arbitration

+

+ Except for disputes eligible for small-claims court and requests for injunctive relief to + protect intellectual property or prevent unauthorized access, unresolved disputes will be + resolved by binding individual arbitration administered by JAMS under its applicable consumer + or comprehensive rules. The Federal Arbitration Act governs this agreement. Arbitration may + take place remotely unless the arbitrator determines that an in-person hearing is necessary. +

+

No Class Actions

+

+ Disputes must be brought only on an individual basis. Neither party may participate in a class, + collective, consolidated, or representative action or arbitration to the extent permitted by + law. +

+

Opt Out

+

+ You may opt out of this arbitration agreement by emailing{" "}legal@t3.tools within 30 days after you first accept these Terms. Include your name, the email associated + with your account if any, and a clear statement that you opt out of arbitration. +

+

+ If the arbitration agreement does not apply, exclusive jurisdiction and venue will lie in the + state and federal courts located in San Francisco County, California, and each party consents + to those courts. +

+
+ +
+

18

+

Miscellaneous

+

+ California law governs these Terms without regard to conflict-of-law principles, except that + the Federal Arbitration Act governs the arbitration provisions. These Terms and documents + incorporated by reference are the entire agreement between you and us regarding the Site and + Services. If a provision is unenforceable, it will be modified to the minimum extent necessary + or severed, and the remaining provisions will continue in effect. +

+

+ Our failure to enforce a provision is not a waiver. You may not assign these Terms without our + written consent. We may assign them in connection with a merger, acquisition, reorganization, + or sale of assets. We are not liable for delays or failures caused by events beyond our + reasonable control. Headings are for convenience only, and “including” means “including without + limitation.” +

+
+ +
+

19

+

Changes to These Terms

+

+ We may update these Terms from time to time. We will post updated Terms at this URL and update + the dates above. If a change materially affects your rights, we will provide additional notice + when reasonably practicable or as required by law. Changes apply prospectively from their + effective date. Your continued use of the Site or Services after that date means you accept the + updated Terms. +

+
+ +
+

20

+

Contact Us

+

If you have questions about these Terms, contact us at:

+
+ T3 Tools, Inc.
+ 2261 Market Street #5309
+ San Francisco, CA 94114
+ United States +
+ legal@t3.tools +
+
diff --git a/apps/mobile/README.md b/apps/mobile/README.md index ad0242c2714..0eb865cb79b 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -34,6 +34,30 @@ Build and run the local iOS dev client: vp run ios:dev ``` +If your Xcode account only has a Personal Team, use a bundle identifier you control and opt into the +reduced-capability local build. Personal Team builds omit the widget and share extensions, push +entitlement, and native Sign in with Apple entitlement; builds without this opt-in are unchanged. + +```bash +T3CODE_IOS_PERSONAL_TEAM=1 \ +T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID=com.example.t3code.dev \ +vp run ios:dev +``` + +Build and install a self-contained Release app that does not need Metro: + +```bash +vp run ios:release +``` + +The Personal Team equivalent also needs a unique bundle identifier: + +```bash +T3CODE_IOS_PERSONAL_TEAM=1 \ +T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID=com.example.t3code \ +vp run ios:release +``` + Build and run the local iOS preview app: ```bash diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 098b830aca3..781bc22a5a9 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -1,5 +1,6 @@ import type { ExpoConfig } from "expo/config"; +import { BRAND_ASSET_PATHS } from "../../scripts/lib/brand-assets.ts"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; type AppVariant = "development" | "preview" | "production"; @@ -8,47 +9,82 @@ const repoEnv = loadRepoEnv(); Object.assign(process.env, repoEnv); const APP_VARIANT = resolveAppVariant(repoEnv.APP_VARIANT); +const isIosPersonalTeamBuild = repoEnv.T3CODE_IOS_PERSONAL_TEAM === "1"; -const VARIANT_CONFIG: Record< - AppVariant, - { - readonly appName: string; - readonly scheme: string; - readonly iosIcon: string; - readonly splashIcon: string; - readonly iosBundleIdentifier: string; - readonly androidPackage: string; - readonly relyingParty?: string; - } -> = { +const personalTeamBundleIdentifier = repoEnv.T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID?.trim(); +const IOS_BUNDLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/; + +const fromRepoRoot = (relativePath: string) => `../../${relativePath}`; + +if ( + isIosPersonalTeamBuild && + (!personalTeamBundleIdentifier || + !IOS_BUNDLE_IDENTIFIER_PATTERN.test(personalTeamBundleIdentifier)) +) { + throw new Error( + "T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID must be a reverse-DNS identifier such as com.example.t3code when T3CODE_IOS_PERSONAL_TEAM=1.", + ); +} + +const DEVELOPMENT_ASSETS = { + appIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng), + iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIconComposerProject), + splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng), + androidAdaptiveForeground: fromRepoRoot(BRAND_ASSET_PATHS.developmentUniversalIconPng), + androidAdaptiveBackgroundColor: "#00639B", + androidMonochromeIcon: "./assets/android-icon-mark.png", + androidNotificationIcon: "./assets/android-notification-icon.png", + androidNotificationColor: "#00639B", +} as const; + +const PREVIEW_ASSETS = { + appIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng), + iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIconComposerProject), + splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng), + androidAdaptiveForeground: fromRepoRoot(BRAND_ASSET_PATHS.nightlyLinuxIconPng), + androidAdaptiveBackgroundColor: "#111533", + androidMonochromeIcon: "./assets/android-icon-mark.png", + androidNotificationIcon: "./assets/android-notification-icon.png", + androidNotificationColor: "#7565C7", +} as const; + +const RELEASE_ASSETS = { + appIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng), + iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIconComposerProject), + splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng), + androidAdaptiveForeground: "./assets/android-icon-mark.png", + androidAdaptiveBackgroundColor: "#000000", + androidMonochromeIcon: "./assets/android-icon-mark.png", + androidNotificationIcon: "./assets/android-notification-icon.png", + androidNotificationColor: "#FFFFFF", +} as const; + +const VARIANT_CONFIG = { development: { appName: "T3 Code Dev", scheme: "t3code-dev", - iosIcon: "./assets/icon-composer-dev.icon", - splashIcon: "./assets/splash-icon-dev.png", iosBundleIdentifier: "com.t3tools.t3code.dev", androidPackage: "com.t3tools.t3code.dev", relyingParty: "clerk.t3.codes", + assets: DEVELOPMENT_ASSETS, }, preview: { appName: "T3 Code Preview", scheme: "t3code-preview", - iosIcon: "./assets/icon-composer-prod.icon", - splashIcon: "./assets/splash-icon-prod.png", iosBundleIdentifier: "com.t3tools.t3code.preview", androidPackage: "com.t3tools.t3code.preview", relyingParty: "clerk.t3.codes", + assets: PREVIEW_ASSETS, }, production: { appName: "T3 Code", scheme: "t3code", - iosIcon: "./assets/icon-composer-prod.icon", - splashIcon: "./assets/splash-icon-prod.png", iosBundleIdentifier: "com.t3tools.t3code", androidPackage: "com.t3tools.t3code", relyingParty: "clerk.t3.codes", + assets: RELEASE_ASSETS, }, -}; +} as const; function resolveAppVariant(value: string | undefined): AppVariant { switch (value) { @@ -62,6 +98,63 @@ function resolveAppVariant(value: string | undefined): AppVariant { } const variant = VARIANT_CONFIG[APP_VARIANT]; +const iosBundleIdentifier = isIosPersonalTeamBuild + ? personalTeamBundleIdentifier! + : variant.iosBundleIdentifier; + +const dmSansFonts = { + regular: "@expo-google-fonts/dm-sans/400Regular/DMSans_400Regular.ttf", + medium: "@expo-google-fonts/dm-sans/500Medium/DMSans_500Medium.ttf", + bold: "@expo-google-fonts/dm-sans/700Bold/DMSans_700Bold.ttf", +} as const; + +const widgetsPlugin: NonNullable[number] = [ + "expo-widgets", + { + bundleIdentifier: `${iosBundleIdentifier}.widgets`, + groupIdentifier: `group.${iosBundleIdentifier}`, + enablePushNotifications: true, + // Agent activity can update many times an hour; without the + // frequent-updates entitlement iOS throttles the update budget sooner. + frequentUpdates: true, + widgets: [ + { + name: "AgentActivity", + displayName: "Agent Activity", + description: "Shows the current state of active T3 Code agents.", + supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"], + }, + ], + }, +]; + +const sharingPlugin: NonNullable[number] = [ + "expo-sharing", + { + ios: { + // Personal Teams cannot sign App Groups or extension targets. Keep the + // reduced-capability local build usable while release builds expose the + // real system share target. + enabled: !isIosPersonalTeamBuild, + extensionBundleIdentifier: `${iosBundleIdentifier}.sharing`, + appGroupId: `group.${iosBundleIdentifier}`, + activationRule: { + supportsText: true, + supportsWebUrlWithMaxCount: 1, + supportsImageWithMaxCount: 8, + }, + }, + android: { + enabled: true, + singleShareMimeTypes: ["text/plain", "image/*"], + multipleShareMimeTypes: ["image/*"], + }, + }, +]; + +// These aliases match the fonts' PostScript names on iOS. Register the same +// names on Android so React Native and the native composer use one set of +// family names without waiting for runtime font loading. const config: ExpoConfig = { name: variant.appName, @@ -77,7 +170,7 @@ const config: ExpoConfig = { policy: process.env.MOBILE_VERSION_POLICY ?? "fingerprint", }, orientation: "portrait", - icon: "./assets/icon.png", + icon: variant.assets.appIcon, userInterfaceStyle: "automatic", updates: { enabled: true, @@ -86,9 +179,9 @@ const config: ExpoConfig = { fallbackToCacheTimeout: 0, }, ios: { - icon: variant.iosIcon, + icon: variant.assets.iosIcon, supportsTablet: true, - bundleIdentifier: variant.iosBundleIdentifier, + bundleIdentifier: iosBundleIdentifier, // Pin code signing to the T3 Tools team so non-interactive `expo run:ios` // does not fall back to a personal team (which cannot sign app groups, // Sign in with Apple, or push notification entitlements). @@ -107,40 +200,94 @@ const config: ExpoConfig = { }, }, android: { - icon: "./assets/icon.png", + icon: variant.assets.appIcon, package: variant.androidPackage, adaptiveIcon: { - backgroundColor: "#E6F4FE", - foregroundImage: "./assets/android-icon-foreground.png", - backgroundImage: "./assets/android-icon-background.png", - monochromeImage: "./assets/android-icon-monochrome.png", + backgroundColor: variant.assets.androidAdaptiveBackgroundColor, + foregroundImage: variant.assets.androidAdaptiveForeground, + monochromeImage: variant.assets.androidMonochromeIcon, }, - predictiveBackGestureEnabled: false, + // Opts into OnBackInvokedCallback-based back dispatch (Android 13+). + // JS back handling survives it via react-native's Android 16 shim plus + // withAndroidPredictiveBackCompat on Android 13-15. + predictiveBackGestureEnabled: true, }, web: { - favicon: "./assets/favicon.png", + favicon: variant.assets.appIcon, }, plugins: [ - "expo-font", + "expo-asset", + [ + "expo-font", + { + ios: { + fonts: [dmSansFonts.regular, dmSansFonts.medium, dmSansFonts.bold], + }, + android: { + fonts: [ + { + fontFamily: "DMSans-Regular", + fontDefinitions: [{ path: dmSansFonts.regular, weight: 400 }], + }, + { + fontFamily: "DMSans-Medium", + fontDefinitions: [{ path: dmSansFonts.medium, weight: 500 }], + }, + { + fontFamily: "DMSans-Bold", + fontDefinitions: [{ path: dmSansFonts.bold, weight: 700 }], + }, + ], + }, + }, + ], "expo-secure-store", - ["@clerk/expo", { theme: "./clerk-theme.json" }], + "expo-sqlite", + ...(isIosPersonalTeamBuild + ? [sharingPlugin] + : ["./plugins/withShareExtensionDisplayName.cjs", sharingPlugin]), + [ + "expo-notifications", + { + icon: variant.assets.androidNotificationIcon, + color: variant.assets.androidNotificationColor, + mode: APP_VARIANT === "development" ? "development" : "production", + }, + ], + // appleSignIn must be gated here: withoutIosPersonalTeamCapabilities.cjs runs before + // plugins earlier in this array, so it cannot strip the entitlement Clerk would add. + ["@clerk/expo", { theme: "./clerk-theme.json", appleSignIn: !isIosPersonalTeamBuild }], "expo-web-browser", + [ + "expo-quick-actions", + { + // Adaptive launcher-shortcut icon; referenced by resource name from + // the shortcut items set in src/features/shortcuts. + androidIcons: { + shortcut_icon: { + foregroundImage: variant.assets.androidAdaptiveForeground, + backgroundColor: variant.assets.androidAdaptiveBackgroundColor, + }, + }, + }, + ], [ "expo-camera", { cameraPermission: "Allow T3 Code to access your camera so you can scan pairing QR codes.", barcodeScannerEnabled: true, + recordAudioAndroid: false, }, ], [ "expo-splash-screen", { - image: variant.splashIcon, + image: variant.assets.splashIcon, resizeMode: "contain", backgroundColor: "#ffffff", imageWidth: 220, dark: { - image: variant.splashIcon, + image: variant.assets.splashIcon, backgroundColor: "#0a0a0a", }, }, @@ -164,31 +311,19 @@ const config: ExpoConfig = { // expo-widgets' — its dangerous mod wipes ios/ExpoWidgetsTarget/ (which // would delete the asset catalog) and its xcodeproj mod creates the widget // target (which must exist before the compile phase can be attached). - "./plugins/withWidgetLogoAsset.cjs", - [ - "expo-widgets", - { - bundleIdentifier: `${variant.iosBundleIdentifier}.widgets`, - groupIdentifier: `group.${variant.iosBundleIdentifier}`, - enablePushNotifications: true, - // Agent activity can update many times an hour; without the - // frequent-updates entitlement iOS throttles the update budget sooner. - frequentUpdates: true, - widgets: [ - { - name: "AgentActivity", - displayName: "Agent Activity", - description: "Shows the current state of active T3 Code agents.", - supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"], - }, - ], - }, - ], + ...(!isIosPersonalTeamBuild ? ["./plugins/withWidgetLogoAsset.cjs", widgetsPlugin] : []), "./plugins/withIosSceneLifecycle.cjs", + "./plugins/withIosCrashLog.cjs", "./plugins/withAndroidCleartextTraffic.cjs", + "./plugins/withAndroidGradleHeap.cjs", + "./plugins/withAndroidModernPopupMenu.cjs", + "./plugins/withAndroidModernAlertDialog.cjs", + "./plugins/withAndroidPredictiveBackCompat.cjs", + ...(isIosPersonalTeamBuild ? ["./plugins/withoutIosPersonalTeamCapabilities.cjs"] : []), ], extra: { appVariant: APP_VARIANT, + iosPersonalTeamBuild: isIosPersonalTeamBuild, relay: { url: repoEnv.T3CODE_RELAY_URL ?? null, }, diff --git a/apps/mobile/assets/android-icon-background.png b/apps/mobile/assets/android-icon-background.png deleted file mode 100644 index b33d7978d72..00000000000 Binary files a/apps/mobile/assets/android-icon-background.png and /dev/null differ diff --git a/apps/mobile/assets/android-icon-foreground.png b/apps/mobile/assets/android-icon-foreground.png deleted file mode 100644 index b33d7978d72..00000000000 Binary files a/apps/mobile/assets/android-icon-foreground.png and /dev/null differ diff --git a/apps/mobile/assets/android-icon-mark.png b/apps/mobile/assets/android-icon-mark.png new file mode 100644 index 00000000000..3300f22fd5a Binary files /dev/null and b/apps/mobile/assets/android-icon-mark.png differ diff --git a/apps/mobile/assets/android-icon-monochrome.png b/apps/mobile/assets/android-icon-monochrome.png deleted file mode 100644 index b33d7978d72..00000000000 Binary files a/apps/mobile/assets/android-icon-monochrome.png and /dev/null differ diff --git a/apps/mobile/assets/android-notification-icon.png b/apps/mobile/assets/android-notification-icon.png new file mode 100644 index 00000000000..74de6633cbb Binary files /dev/null and b/apps/mobile/assets/android-notification-icon.png differ diff --git a/apps/mobile/assets/favicon.png b/apps/mobile/assets/favicon.png deleted file mode 100644 index e0e1b9659b8..00000000000 Binary files a/apps/mobile/assets/favicon.png and /dev/null differ diff --git a/apps/mobile/assets/icon-composer-dev.icon/Assets/Texturelabs_Paper_381XL.jpg b/apps/mobile/assets/icon-composer-dev.icon/Assets/Texturelabs_Paper_381XL.jpg deleted file mode 100644 index d98c41a4e3e..00000000000 Binary files a/apps/mobile/assets/icon-composer-dev.icon/Assets/Texturelabs_Paper_381XL.jpg and /dev/null differ diff --git a/apps/mobile/assets/icon-composer-dev.icon/Assets/gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png b/apps/mobile/assets/icon-composer-dev.icon/Assets/gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png deleted file mode 100644 index de5a82d8c49..00000000000 Binary files a/apps/mobile/assets/icon-composer-dev.icon/Assets/gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png and /dev/null differ diff --git a/apps/mobile/assets/icon-composer-prod.icon/Assets/T3.svg b/apps/mobile/assets/icon-composer-prod.icon/Assets/T3.svg deleted file mode 100644 index b12706fdfc2..00000000000 --- a/apps/mobile/assets/icon-composer-prod.icon/Assets/T3.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/mobile/assets/icon.png b/apps/mobile/assets/icon.png deleted file mode 100644 index b33d6f337b0..00000000000 Binary files a/apps/mobile/assets/icon.png and /dev/null differ diff --git a/apps/mobile/assets/splash-icon-dev.png b/apps/mobile/assets/splash-icon-dev.png deleted file mode 100644 index b33d6f337b0..00000000000 Binary files a/apps/mobile/assets/splash-icon-dev.png and /dev/null differ diff --git a/apps/mobile/assets/splash-icon-prod.png b/apps/mobile/assets/splash-icon-prod.png deleted file mode 100644 index 2ff311ce786..00000000000 Binary files a/apps/mobile/assets/splash-icon-prod.png and /dev/null differ diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index 82c707eac12..a1d315b7e14 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -8,7 +8,8 @@ "development": { "corepack": true, "env": { - "APP_VARIANT": "development" + "APP_VARIANT": "development", + "NODE_OPTIONS": "--max-old-space-size=4096" }, "channel": "development", "developmentClient": true, @@ -17,7 +18,8 @@ "preview": { "corepack": true, "env": { - "APP_VARIANT": "preview" + "APP_VARIANT": "preview", + "NODE_OPTIONS": "--max-old-space-size=4096" }, "channel": "preview", "environment": "preview", @@ -27,7 +29,8 @@ "corepack": true, "env": { "APP_VARIANT": "preview", - "MOBILE_VERSION_POLICY": "fingerprint" + "MOBILE_VERSION_POLICY": "fingerprint", + "NODE_OPTIONS": "--max-old-space-size=4096" }, "channel": "preview", "environment": "preview", @@ -40,7 +43,8 @@ "production": { "corepack": true, "env": { - "APP_VARIANT": "production" + "APP_VARIANT": "production", + "NODE_OPTIONS": "--max-old-space-size=4096" }, "channel": "production", "environment": "production", diff --git a/apps/mobile/global.css b/apps/mobile/global.css index f76300ee524..6d36bf77caf 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -12,7 +12,7 @@ /* Card / surface */ --color-card: #ffffff; --color-card-alt: #f5f5f5; - --color-card-translucent: rgba(255, 255, 255, 0.94); + --color-card-translucent: rgba(255, 255, 255, 0.8); /* Text */ --color-foreground: #262626; @@ -51,6 +51,7 @@ /* Inputs */ --color-input: #ffffff; --color-input-border: rgba(0, 0, 0, 0.1); + --color-sidebar-search: rgba(118, 118, 128, 0.12); --color-placeholder: #a3a3a3; /* Icons */ @@ -105,7 +106,7 @@ /* Card / surface */ --color-card: #171717; --color-card-alt: #1c1c1c; - --color-card-translucent: rgba(17, 17, 17, 0.94); + --color-card-translucent: rgba(17, 17, 17, 0.8); /* Text */ --color-foreground: #f5f5f5; @@ -144,6 +145,7 @@ /* Inputs */ --color-input: #141414; --color-input-border: rgba(255, 255, 255, 0.08); + --color-sidebar-search: rgba(118, 118, 128, 0.24); --color-placeholder: #8e8e93; /* Icons */ @@ -194,9 +196,10 @@ /* ─── Typography ────────────────────────────────────────────────────── */ @theme { - --font-sans: "DMSans_400Regular"; - --font-medium: "DMSans_500Medium"; - --font-bold: "DMSans_700Bold"; + /* Keep these native family names aligned with app.config.ts. */ + --font-sans: "DMSans-Regular"; + --font-medium: "DMSans-Medium"; + --font-bold: "DMSans-Bold"; /* Keep this scale aligned with src/lib/typography.ts for native style props. */ --text-3xs: 11px; @@ -221,14 +224,14 @@ /* ─── Custom utilities ──────────────────────────────────────────────── */ @utility font-t3-medium { - font-family: "DMSans_500Medium"; + font-family: var(--font-medium); } @utility font-t3-bold { - font-family: "DMSans_700Bold"; + font-family: var(--font-bold); } @utility font-t3-extrabold { - font-family: "DMSans_700Bold"; + font-family: var(--font-bold); font-weight: 800; } diff --git a/apps/mobile/index.ts b/apps/mobile/index.ts index 979dc75d5f1..fa7f923853f 100644 --- a/apps/mobile/index.ts +++ b/apps/mobile/index.ts @@ -1,5 +1,10 @@ +// Installed via a side-effect import listed first so the fatal-error handler +// is in place before the rest of the app module graph evaluates. +import "./src/lib/installCrashLog"; + import { registerRootComponent } from "expo"; import "react-native-gesture-handler"; +import { LogBox } from "react-native"; import { featureFlags } from "react-native-screens"; import App from "./src/App"; @@ -8,4 +13,8 @@ import App from "./src/App"; // native stack is rendered inside a non-fitToContents formSheet. featureFlags.experiment.synchronousScreenUpdatesEnabled = true; +if (process.env.EXPO_PUBLIC_SHOWCASE === "1") { + LogBox.ignoreAllLogs(); +} + registerRootComponent(App); diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index d314f6206c6..fe886077697 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -6,6 +6,7 @@ const { withUniwindConfig } = require("uniwind/metro"); /** @type {import("expo/metro-config").MetroConfig} */ const config = getDefaultConfig(__dirname); const workspaceRoot = path.resolve(__dirname, "../.."); +const escapedWorkspaceRoot = workspaceRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const mobileShikiRoot = path.dirname(require.resolve("shiki/package.json", { paths: [__dirname] })); const resolveShikiDependencyRoot = (packageName) => { const entryPath = require.resolve(packageName, { paths: [mobileShikiRoot] }); @@ -25,6 +26,14 @@ const resolveShikiDependencyRoot = (packageName) => { config.watchFolders = [...new Set([...(config.watchFolders ?? []), workspaceRoot])]; config.resolver = { ...config.resolver, + blockList: [ + ...(Array.isArray(config.resolver?.blockList) + ? config.resolver.blockList + : config.resolver?.blockList + ? [config.resolver.blockList] + : []), + new RegExp(`${escapedWorkspaceRoot}[/\\\\]\\.t3[/\\\\].*`), + ], extraNodeModules: { // oxlint-disable-next-line unicorn/no-useless-fallback-in-spread ...(config.resolver?.extraNodeModules ?? {}), diff --git a/apps/mobile/modules/t3-composer-editor/android/build.gradle b/apps/mobile/modules/t3-composer-editor/android/build.gradle new file mode 100644 index 00000000000..dfdb4d16a14 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/android/build.gradle @@ -0,0 +1,19 @@ +apply plugin: 'com.android.library' +apply plugin: 'org.jetbrains.kotlin.android' + +group = 'com.t3tools.composereditor' +version = '0.0.0' + +android { + namespace 'expo.modules.t3composereditor' + compileSdk rootProject.ext.compileSdkVersion + + defaultConfig { + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + } +} + +dependencies { + implementation project(':expo-modules-core') +} diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/AndroidManifest.xml b/apps/mobile/modules/t3-composer-editor/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..94cbbcfc396 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt new file mode 100644 index 00000000000..e11181e81a9 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt @@ -0,0 +1,72 @@ +package expo.modules.t3composereditor + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class T3ComposerEditorModule : Module() { + override fun definition() = ModuleDefinition { + Name("T3ComposerEditor") + + View(T3ComposerEditorView::class) { + Prop("controlledDocumentJson") { view: T3ComposerEditorView, documentJson: String -> + view.setControlledDocumentJson(documentJson) + } + Prop("themeJson") { view: T3ComposerEditorView, themeJson: String -> + view.setThemeJson(themeJson) + } + Prop("placeholder") { view: T3ComposerEditorView, placeholder: String -> + view.setPlaceholder(placeholder) + } + Prop("fontFamily") { view: T3ComposerEditorView, fontFamily: String -> + view.setFontFamily(fontFamily) + } + Prop("fontSize") { view: T3ComposerEditorView, fontSize: Double -> + view.setFontSize(fontSize.toFloat()) + } + Prop("lineHeight") { view: T3ComposerEditorView, lineHeight: Double -> + view.setLineHeight(lineHeight.toFloat()) + } + Prop("contentInsetVertical") { view: T3ComposerEditorView, contentInsetVertical: Double -> + view.setContentInsetVertical(contentInsetVertical.toInt()) + } + + Prop("singleLineCentered") { view: T3ComposerEditorView, singleLineCentered: Boolean -> + view.setSingleLineCentered(singleLineCentered) + } + Prop("editable") { view: T3ComposerEditorView, editable: Boolean -> + view.setEditable(editable) + } + Prop("scrollEnabled") { view: T3ComposerEditorView, scrollEnabled: Boolean -> + view.setScrollEnabled(scrollEnabled) + } + Prop("autoFocus") { view: T3ComposerEditorView, autoFocus: Boolean -> + view.setAutoFocus(autoFocus) + } + Prop("autoCorrect") { view: T3ComposerEditorView, autoCorrect: Boolean -> + view.setAutoCorrect(autoCorrect) + } + Prop("spellCheck") { view: T3ComposerEditorView, spellCheck: Boolean -> + view.setSpellCheck(spellCheck) + } + + Events( + "onComposerChange", + "onComposerSelectionChange", + "onComposerFocus", + "onComposerBlur", + "onComposerPasteImages", + "onComposerContentSizeChange", + ) + + AsyncFunction("focus") { view: T3ComposerEditorView -> + view.focusEditor() + } + AsyncFunction("blur") { view: T3ComposerEditorView -> + view.blurEditor() + } + AsyncFunction("setSelection") { view: T3ComposerEditorView, start: Int, end: Int -> + view.setSelection(start, end) + } + } + } +} diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt new file mode 100644 index 00000000000..e13c0a52189 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -0,0 +1,480 @@ +package expo.modules.t3composereditor + +import android.content.Context +import android.content.ClipboardManager +import android.graphics.Color +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Typeface +import android.text.Editable +import android.text.InputType +import android.text.Spanned +import android.text.TextWatcher +import android.text.style.ReplacementSpan +import android.view.Gravity +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import android.widget.EditText +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.viewevent.EventDispatcher +import expo.modules.kotlin.views.ExpoView +import org.json.JSONObject +import kotlin.math.max + +class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( + context, + appContext +) { + private val editor = SelectionAwareEditText(context) + private val onComposerChange by EventDispatcher() + private val onComposerSelectionChange by EventDispatcher() + private val onComposerFocus by EventDispatcher() + private val onComposerBlur by EventDispatcher() + private val onComposerPasteImages by EventDispatcher() + private val onComposerContentSizeChange by EventDispatcher() + private var applyingNativeValue = false + private var desiredLineHeightPx = 0 + private var lastContentHeight = 0 + private var contentInsetVertical = 0 + private var tokensJson = "[]" + private var tokens: List = emptyList() + private var chipTheme = ComposerChipTheme.default() + private var autoCorrect = true + private var spellCheck = true + private var nativeEventCount = 0 + + init { + editor.setBackgroundColor(Color.TRANSPARENT) + editor.gravity = Gravity.TOP or Gravity.START + editor.includeFontPadding = false + editor.isSingleLine = false + editor.minLines = 1 + editor.inputType = + InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_FLAG_MULTI_LINE or + InputType.TYPE_TEXT_FLAG_CAP_SENTENCES + editor.setTextColor(Color.BLACK) + editor.setHintTextColor(Color.GRAY) + editor.setPadding(0, 0, 0, 0) + editor.selectionListener = { start, end -> + if (!applyingNativeValue) { + emitSelectionChange(start, end) + } + } + editor.pasteImagesListener = { uris -> + onComposerPasteImages(mapOf("uris" to uris)) + } + editor.setOnFocusChangeListener { _, hasFocus -> + if (hasFocus) { + onComposerFocus(emptyMap()) + } else { + onComposerBlur(emptyMap()) + } + } + editor.addTextChangedListener( + object : TextWatcher { + override fun beforeTextChanged( + text: CharSequence?, + start: Int, + count: Int, + after: Int + ) = Unit + override fun onTextChanged(text: CharSequence?, start: Int, before: Int, count: Int) = Unit + + override fun afterTextChanged(editable: Editable?) { + if (applyingNativeValue) return + val nextValue = editable.toString() + val selection = currentSelectionPayload() + nativeEventCount += 1 + onComposerChange( + mapOf( + "value" to nextValue, + "selection" to selection, + "eventCount" to nativeEventCount, + ), + ) + emitContentSizeIfNeeded() + } + }, + ) + editor.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> emitContentSizeIfNeeded() } + addView( + editor, + LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT), + ) + } + + @Suppress("ReturnCount") + fun setControlledDocumentJson(documentJson: String) { + val document = try { + JSONObject(documentJson) + } catch (_: Exception) { + return + } + val mostRecentEventCount = document.optInt("mostRecentEventCount", -1) + if (mostRecentEventCount < nativeEventCount) return + + val value = document.optString("value") + if (document.optBoolean("isNativeEcho") && editor.text.toString() != value) return + + val nextTokensJson = document.optString("tokensJson", "[]") + val nextTokens = if (nextTokensJson == tokensJson) tokens else parseTokens(nextTokensJson) + val requestedSelection = document.optJSONObject("selection") + val previousSelectionStart = editor.selectionStart.coerceAtLeast(0) + val previousSelectionEnd = editor.selectionEnd.coerceAtLeast(0) + val valueChanged = editor.text.toString() != value + + applyingNativeValue = true + try { + if (valueChanged) { + editor.setText(value) + } + tokensJson = nextTokensJson + tokens = nextTokens + applyTokenSpans() + if (requestedSelection != null) { + applySelection( + requestedSelection.optInt("start", previousSelectionStart), + requestedSelection.optInt("end", previousSelectionEnd), + ) + } else if (valueChanged) { + applySelection(previousSelectionStart, previousSelectionEnd) + } + } finally { + applyingNativeValue = false + } + emitContentSizeIfNeeded() + } + + fun setThemeJson(themeJson: String) { + try { + val theme = JSONObject(themeJson) + editor.setTextColor(parseColor(theme.optString("text"), Color.BLACK)) + editor.setHintTextColor(parseColor(theme.optString("placeholder"), Color.GRAY)) + chipTheme = ComposerChipTheme( + chipBackground = parseColor(theme.optString("chipBackground"), chipTheme.chipBackground), + chipBorder = parseColor(theme.optString("chipBorder"), chipTheme.chipBorder), + chipText = parseColor(theme.optString("chipText"), chipTheme.chipText), + skillBackground = parseColor( + theme.optString("skillBackground"), + chipTheme.skillBackground, + ), + skillBorder = parseColor(theme.optString("skillBorder"), chipTheme.skillBorder), + skillText = parseColor(theme.optString("skillText"), chipTheme.skillText), + ) + applyTokenSpans() + } catch (_: Exception) { + } + } + + fun setPlaceholder(placeholder: String) { + editor.hint = placeholder + } + + fun setFontFamily(fontFamily: String) { + editor.typeface = if (fontFamily.contains("Mono", ignoreCase = true)) { + Typeface.MONOSPACE + } else { + Typeface.DEFAULT + } + } + + fun setFontSize(fontSize: Float) { + editor.textSize = fontSize + applyLineHeight() + } + + fun setLineHeight(lineHeight: Float) { + desiredLineHeightPx = (lineHeight * resources.displayMetrics.density).toInt() + applyLineHeight() + } + + fun setSingleLineCentered(centered: Boolean) { + editor.gravity = if (centered) { + Gravity.CENTER_VERTICAL or Gravity.START + } else { + Gravity.TOP or Gravity.START + } + } + + fun setContentInsetVertical(contentInsetVertical: Int) { + this.contentInsetVertical = + max(0, (contentInsetVertical * resources.displayMetrics.density).toInt()) + editor.setPadding(0, this.contentInsetVertical, 0, this.contentInsetVertical) + emitContentSizeIfNeeded() + } + + fun setEditable(editable: Boolean) { + editor.isEnabled = editable + editor.isFocusable = editable + editor.isFocusableInTouchMode = editable + editor.isCursorVisible = editable + } + + fun setScrollEnabled(scrollEnabled: Boolean) { + editor.isVerticalScrollBarEnabled = scrollEnabled + } + + fun setAutoFocus(autoFocus: Boolean) { + if (autoFocus) { + post { focusEditor() } + } + } + + fun setAutoCorrect(autoCorrect: Boolean) { + this.autoCorrect = autoCorrect + updateInputFlags() + } + + fun setSpellCheck(spellCheck: Boolean) { + this.spellCheck = spellCheck + updateInputFlags() + } + + fun focusEditor() { + editor.requestFocus() + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.showSoftInput(editor, InputMethodManager.SHOW_IMPLICIT) + } + + fun blurEditor() { + editor.clearFocus() + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.hideSoftInputFromWindow(editor.windowToken, 0) + } + + fun setSelection(start: Int, end: Int) { + applySelection(start, end) + } + + private fun applySelection(start: Int, end: Int) { + val textLength = editor.text?.length ?: 0 + val safeStart = start.coerceIn(0, textLength) + val safeEnd = end.coerceIn(0, textLength) + editor.setSelection(safeStart, safeEnd) + } + + private fun updateInputFlags() { + var flags = + InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_FLAG_MULTI_LINE or + InputType.TYPE_TEXT_FLAG_CAP_SENTENCES + flags = if (autoCorrect && spellCheck) { + flags or InputType.TYPE_TEXT_FLAG_AUTO_CORRECT + } else { + flags or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + } + editor.inputType = flags + } + + private fun applyLineHeight() { + if (desiredLineHeightPx <= 0) return + val fontHeight = editor.paint.fontMetricsInt.descent - editor.paint.fontMetricsInt.ascent + editor.setLineSpacing(max(0, desiredLineHeightPx - fontHeight).toFloat(), 1f) + } + + private fun currentSelectionPayload(): Map = + mapOf( + "start" to editor.selectionStart.coerceAtLeast(0), + "end" to editor.selectionEnd.coerceAtLeast(0), + ) + + private fun emitSelectionChange(start: Int, end: Int) { + onComposerSelectionChange( + mapOf( + "value" to editor.text.toString(), + "selection" to mapOf("start" to start, "end" to end), + "eventCount" to nativeEventCount, + ), + ) + } + + private fun emitContentSizeIfNeeded() { + val height = editor.layout?.height ?: editor.measuredHeight + val contentHeight = height + contentInsetVertical * 2 + if (contentHeight == lastContentHeight) return + lastContentHeight = contentHeight + onComposerContentSizeChange( + mapOf("height" to contentHeight / resources.displayMetrics.density), + ) + } + + private fun applyTokenSpans() { + val editable = editor.text ?: return + editable.getSpans( + 0, + editable.length, + ComposerChipSpan::class.java + ).forEach(editable::removeSpan) + tokens.forEach { token -> + if (token.start < 0 || token.end <= token.start || token.end > editable.length) return@forEach + val expectedSource = editable.substring(token.start, token.end) + if (expectedSource != token.source) return@forEach + editable.setSpan( + ComposerChipSpan( + token.label, + token.type == "skill", + chipTheme, + resources.displayMetrics.density + ), + token.start, + token.end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + editor.invalidate() + } + + private fun parseColor(value: String, fallback: Int): Int = + try { + Color.parseColor(value) + } catch (_: Exception) { + fallback + } +} + +private data class ComposerToken( + val type: String, + val source: String, + val label: String, + val start: Int, + val end: Int +) + +private data class ComposerChipTheme( + val chipBackground: Int, + val chipBorder: Int, + val chipText: Int, + val skillBackground: Int, + val skillBorder: Int, + val skillText: Int +) { + companion object { + fun default() = ComposerChipTheme( + chipBackground = Color.rgb(238, 240, 243), + chipBorder = Color.rgb(210, 214, 220), + chipText = Color.rgb(35, 39, 45), + skillBackground = Color.rgb(233, 239, 255), + skillBorder = Color.rgb(185, 200, 245), + skillText = Color.rgb(45, 72, 155), + ) + } +} + +private class ComposerChipSpan( + private val label: String, + private val skill: Boolean, + private val theme: ComposerChipTheme, + density: Float +) : ReplacementSpan() { + private val horizontalPadding = 7f * density + private val verticalPadding = 2f * density + private val cornerRadius = 6f * density + private val borderWidth = density + + override fun getSize( + paint: Paint, + text: CharSequence, + start: Int, + end: Int, + fontMetrics: Paint.FontMetricsInt? + ): Int { + fontMetrics?.let { + val extra = verticalPadding.toInt() + val base = paint.fontMetricsInt + it.top = base.top - extra + it.ascent = base.ascent - extra + it.descent = base.descent + extra + it.bottom = base.bottom + extra + } + return (paint.measureText(label) + horizontalPadding * 2).toInt() + } + + override fun draw( + canvas: Canvas, + text: CharSequence, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + val width = paint.measureText(label) + horizontalPadding * 2 + val metrics = paint.fontMetrics + val rect = RectF( + x, + y + metrics.ascent - verticalPadding, + x + width, + y + metrics.descent + verticalPadding, + ) + val originalColor = paint.color + val originalStyle = paint.style + val originalStrokeWidth = paint.strokeWidth + + paint.color = if (skill) theme.skillBackground else theme.chipBackground + paint.style = Paint.Style.FILL + canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint) + paint.color = if (skill) theme.skillBorder else theme.chipBorder + paint.style = Paint.Style.STROKE + paint.strokeWidth = borderWidth + canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint) + paint.color = if (skill) theme.skillText else theme.chipText + paint.style = Paint.Style.FILL + canvas.drawText(label, x + horizontalPadding, y.toFloat(), paint) + + paint.color = originalColor + paint.style = originalStyle + paint.strokeWidth = originalStrokeWidth + } +} + +private fun parseTokens(value: String): List = try { + val array = org.json.JSONArray(value) + List(array.length()) { index -> + val token = array.getJSONObject(index) + ComposerToken( + type = token.optString("type"), + source = token.optString("source"), + label = token.optString("label"), + start = token.optInt("start"), + end = token.optInt("end"), + ) + } +} catch (_: Exception) { + emptyList() +} + +private class SelectionAwareEditText(context: Context) : EditText(context) { + var selectionListener: ((Int, Int) -> Unit)? = null + var pasteImagesListener: ((List) -> Unit)? = null + + override fun onSelectionChanged(selStart: Int, selEnd: Int) { + super.onSelectionChanged(selStart, selEnd) + selectionListener?.invoke(selStart, selEnd) + } + + override fun onTextContextMenuItem(id: Int): Boolean { + if (id == android.R.id.paste || id == android.R.id.pasteAsPlainText) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + val clip = clipboard?.primaryClip + val imageUris = buildList { + if (clip != null) { + for (index in 0 until clip.itemCount) { + clip.getItemAt(index).uri?.let { uri -> + val mimeType = context.contentResolver.getType(uri) + if (mimeType?.startsWith("image/") == true) add(uri.toString()) + } + } + } + } + if (imageUris.isNotEmpty()) { + pasteImagesListener?.invoke(imageUris) + return true + } + } + return super.onTextContextMenuItem(id) + } +} diff --git a/apps/mobile/modules/t3-composer-editor/expo-module.config.json b/apps/mobile/modules/t3-composer-editor/expo-module.config.json index 0d6384cd91a..56fd899920c 100644 --- a/apps/mobile/modules/t3-composer-editor/expo-module.config.json +++ b/apps/mobile/modules/t3-composer-editor/expo-module.config.json @@ -1,6 +1,9 @@ { - "platforms": ["apple"], + "platforms": ["apple", "android"], "apple": { "modules": ["T3ComposerEditorModule"] + }, + "android": { + "modules": ["expo.modules.t3composereditor.T3ComposerEditorModule"] } } diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index 8515abfbae8..180da203008 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -300,7 +300,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { skillText: "#a21caf", fileTint: "#737373" ) - private var fontFamily = "DMSans_400Regular" + private var fontFamily = "DMSans-Regular" private var fontSize: CGFloat = 14 private var lineHeight: CGFloat = 20 private var contentInsetVertical: CGFloat = 0 @@ -608,7 +608,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { iconImage: UIImage?, style: ComposerChipStyle ) -> UIImage { - let font = UIFont(name: "DMSans_500Medium", size: max(12, fontSize - 2)) + let font = UIFont(name: "DMSans-Medium", size: max(12, fontSize - 2)) ?? UIFont.systemFont(ofSize: max(12, fontSize - 2), weight: .medium) let fallbackIcon = UIImage( systemName: iconName, diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h index 99417490a63..737330db1d4 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h @@ -20,12 +20,16 @@ struct T3MarkdownTextParagraphStyleRange { Float firstLineHeadIndent; Float headIndent; Float paragraphSpacing; + + bool operator==(const T3MarkdownTextParagraphStyleRange&) const = default; }; struct T3MarkdownTextAttachmentRange { size_t location; size_t length; std::string imageUri; + + bool operator==(const T3MarkdownTextAttachmentRange&) const = default; }; inline Float T3MarkdownTextAttachmentSize(const T3MarkdownTextAttachmentRange &) { @@ -52,11 +56,6 @@ T3MarkdownTextStateReal> { public: using ConcreteViewShadowNode::ConcreteViewShadowNode; - T3MarkdownTextShadowNode( - const ShadowNode& sourceShadowNode, - const ShadowNodeFragment& fragment - ); - static ShadowNodeTraits BaseTraits() { auto traits = ConcreteViewShadowNode::BaseTraits(); traits.set(ShadowNodeTraits::Trait::LeafYogaNode); @@ -71,8 +70,18 @@ T3MarkdownTextStateReal> { const LayoutConstraints& layoutConstraints) const override; private: - mutable AttributedString _attributedString; - mutable std::vector _paragraphStyleRanges; - mutable std::vector _attachmentRanges; + // Content must be derived from the current children whenever it is needed. + // Yoga can invoke layout() on a fresh clone without ever calling + // measureContent() on it (for example when both dimensions are already + // exact), so caching measure-time content in mutable members and publishing + // it from layout() lets state fall behind the children and drop text. + struct Content { + AttributedString attributedString; + std::vector paragraphStyleRanges; + std::vector attachmentRanges; + }; + + Content buildContent(const LayoutContext& layoutContext) const; + void updateStateIfNeeded(Content&& content); }; } // namespace facebook::React diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm index b9abe452fb9..7a02094a4d3 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm @@ -72,15 +72,8 @@ static void applyAttachments( } } -T3MarkdownTextShadowNode::T3MarkdownTextShadowNode( - const ShadowNode& sourceShadowNode, - const ShadowNodeFragment& fragment -) : ConcreteViewShadowNode(sourceShadowNode, fragment) { -}; - -Size T3MarkdownTextShadowNode::measureContent( - const LayoutContext& layoutContext, - const LayoutConstraints& layoutConstraints) const { +T3MarkdownTextShadowNode::Content T3MarkdownTextShadowNode::buildContent( + const LayoutContext& layoutContext) const { const auto &baseProps = getConcreteProps(); auto baseTextAttributes = TextAttributes::defaultTextAttributes(); @@ -207,14 +200,23 @@ static void applyAttachments( } } - _attributedString = baseAttributedString; - _paragraphStyleRanges = paragraphStyleRanges; - _attachmentRanges = attachmentRanges; + return Content{ + std::move(baseAttributedString), + std::move(paragraphStyleRanges), + std::move(attachmentRanges), + }; +} + +Size T3MarkdownTextShadowNode::measureContent( + const LayoutContext& layoutContext, + const LayoutConstraints& layoutConstraints) const { + const auto &baseProps = getConcreteProps(); + const auto content = buildContent(layoutContext); NSMutableAttributedString *convertedAttributedString = - [RCTNSAttributedStringFromAttributedString(baseAttributedString) mutableCopy]; - applyParagraphStyles(convertedAttributedString, paragraphStyleRanges); - applyAttachments(convertedAttributedString, attachmentRanges); + [RCTNSAttributedStringFromAttributedString(content.attributedString) mutableCopy]; + applyParagraphStyles(convertedAttributedString, content.paragraphStyleRanges); + applyAttachments(convertedAttributedString, content.attachmentRanges); const CGFloat maximumWidth = std::isfinite(layoutConstraints.maximumSize.width) ? layoutConstraints.maximumSize.width @@ -255,10 +257,21 @@ static void applyAttachments( void T3MarkdownTextShadowNode::layout(LayoutContext layoutContext) { ensureUnsealed(); + updateStateIfNeeded(buildContent(layoutContext)); +} + +void T3MarkdownTextShadowNode::updateStateIfNeeded(Content&& content) { + const auto &stateData = getStateData(); + if (stateData.attributedString == content.attributedString && + stateData.paragraphStyleRanges == content.paragraphStyleRanges && + stateData.attachmentRanges == content.attachmentRanges) { + return; + } + setStateData(T3MarkdownTextStateReal{ - _attributedString, - _paragraphStyleRanges, - _attachmentRanges, + std::move(content.attributedString), + std::move(content.paragraphStyleRanges), + std::move(content.attachmentRanges), }); } } diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx index 56321ba01ad..5c8d5bc97c2 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx @@ -34,6 +34,7 @@ export function SelectableMarkdownText({ skills = EMPTY_SKILLS, textStyle, highlightCode, + fillWidth = false, preserveSoftBreaks = false, onLinkPress, marginTop = 0, @@ -63,7 +64,15 @@ export function SelectableMarkdownText({ // shrink-to-fit containers such as user-message bubbles. Yoga then gives // the native text node an unbounded second pass and the parent only clips // the resulting single-line width instead of reflowing it. - + {chunks.map((chunk, index) => { const content = chunk.kind === "rich" ? ( diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts index 42cc3cd6fb6..c1287a3565f 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts @@ -40,6 +40,7 @@ export interface SelectableMarkdownTextProps { readonly markdown: string; readonly textStyle: NativeMarkdownTextStyle; readonly highlightCode: MarkdownCodeHighlighter; + readonly fillWidth?: boolean; readonly skills?: ReadonlyArray; readonly preserveSoftBreaks?: boolean; readonly onLinkPress?: (href: string) => void; diff --git a/apps/mobile/modules/t3-native-controls/android/build.gradle b/apps/mobile/modules/t3-native-controls/android/build.gradle new file mode 100644 index 00000000000..ba0622b7f0e --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/build.gradle @@ -0,0 +1,19 @@ +apply plugin: 'com.android.library' +apply plugin: 'org.jetbrains.kotlin.android' + +group = 'com.t3tools.nativecontrols' +version = '0.0.0' + +android { + namespace 'expo.modules.t3nativecontrols' + compileSdk rootProject.ext.compileSdkVersion + + defaultConfig { + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + } +} + +dependencies { + implementation project(':expo-modules-core') +} diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/AndroidManifest.xml b/apps/mobile/modules/t3-native-controls/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..94cbbcfc396 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3HeaderButtonView.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3HeaderButtonView.kt new file mode 100644 index 00000000000..47db92d92a4 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3HeaderButtonView.kt @@ -0,0 +1,97 @@ +package expo.modules.t3nativecontrols + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.view.View +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.viewevent.EventDispatcher +import expo.modules.kotlin.views.ExpoView + +class T3HeaderButtonView(context: Context, appContext: AppContext) : ExpoView(context, appContext) { + private val iconView = HeaderIconView(context) + private val onTriggered by EventDispatcher() + + init { + isClickable = true + isFocusable = true + setOnClickListener { + onTriggered(emptyMap()) + } + addView(iconView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + } + + fun setLabel(label: String) { + contentDescription = label + } + + fun setSystemImage(systemImage: String) { + iconView.systemImage = systemImage + } +} + +private class HeaderIconView(context: Context) : View(context) { + private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#6B7280") + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + strokeWidth = 3f * resources.displayMetrics.density + style = Paint.Style.STROKE + } + + var systemImage: String = "gearshape" + set(value) { + field = value + invalidate() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val cx = width / 2f + val cy = height / 2f + val size = minOf(width, height).toFloat() + if (systemImage == "square.and.pencil") { + drawNewTask(canvas, cx, cy, size) + } else { + drawSettings(canvas, cx, cy, size) + } + } + + private fun drawSettings(canvas: Canvas, cx: Float, cy: Float, size: Float) { + val radius = size * 0.12f + canvas.drawCircle(cx, cy, radius, paint) + for (index in 0 until 8) { + val angle = Math.PI * index / 4.0 + val inner = size * 0.19f + val outer = size * 0.27f + val sx = cx + kotlin.math.cos(angle).toFloat() * inner + val sy = cy + kotlin.math.sin(angle).toFloat() * inner + val ex = cx + kotlin.math.cos(angle).toFloat() * outer + val ey = cy + kotlin.math.sin(angle).toFloat() * outer + canvas.drawLine(sx, sy, ex, ey, paint) + } + } + + private fun drawNewTask(canvas: Canvas, cx: Float, cy: Float, size: Float) { + val left = cx - size * 0.2f + val top = cy - size * 0.16f + val right = cx + size * 0.14f + val bottom = cy + size * 0.2f + canvas.drawRoundRect(left, top, right, bottom, size * 0.04f, size * 0.04f, paint) + canvas.drawLine( + cx - size * 0.02f, + cy + size * 0.13f, + cx + size * 0.24f, + cy - size * 0.13f, + paint + ) + canvas.drawLine( + cx + size * 0.17f, + cy - size * 0.2f, + cx + size * 0.24f, + cy - size * 0.13f, + paint + ) + } +} diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt new file mode 100644 index 00000000000..b15a1cd4428 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt @@ -0,0 +1,47 @@ +package expo.modules.t3nativecontrols + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class T3NativeControlsModule : Module() { + override fun definition() = ModuleDefinition { + Name("T3NativeControls") + + Function("getShowcasePairingUrl") { + appContext.currentActivity?.intent?.getStringExtra("showcasePairingUrl") + } + + Function("getShowcaseScene") { + val storedScene = appContext.reactContext + ?.filesDir + ?.resolve("t3-showcase-scene") + ?.takeIf { it.isFile } + ?.readText() + ?.trim() + ?.takeIf { it.isNotEmpty() } + storedScene ?: appContext.currentActivity?.intent?.getStringExtra("showcaseScene") + } + + Function("prepareShowcaseCapture") { + // Android app data is cleared by the host runner before launch. + } + + Function("markShowcaseReady") { scene: String -> + appContext.reactContext + ?.filesDir + ?.resolve("t3-showcase-ready") + ?.writeText(scene) + } + + View(T3HeaderButtonView::class) { + Prop("label") { view: T3HeaderButtonView, label: String -> + view.setLabel(label) + } + Prop("systemImage") { view: T3HeaderButtonView, systemImage: String -> + view.setSystemImage(systemImage) + } + + Events("onTriggered") + } + } +} diff --git a/apps/mobile/modules/t3-native-controls/expo-module.config.json b/apps/mobile/modules/t3-native-controls/expo-module.config.json index b53b3c50a03..d9a77f14e25 100644 --- a/apps/mobile/modules/t3-native-controls/expo-module.config.json +++ b/apps/mobile/modules/t3-native-controls/expo-module.config.json @@ -1,6 +1,9 @@ { - "platforms": ["apple"], + "platforms": ["apple", "android"], "apple": { "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"] + }, + "android": { + "modules": ["expo.modules.t3nativecontrols.T3NativeControlsModule"] } } diff --git a/apps/mobile/modules/t3-native-controls/ios/T3CrashLog.swift b/apps/mobile/modules/t3-native-controls/ios/T3CrashLog.swift new file mode 100644 index 00000000000..aa6eca69707 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3CrashLog.swift @@ -0,0 +1,163 @@ +import Foundation + +/// Durable crash persistence for Documents/crash-logs. +/// +/// The Expo module uses this for JS `writeSyncText`. AppDelegate installs +/// `RCTSetFatalHandler` and calls `persistNativeFatal` so reportFatal still +/// leaves a last-crash.json when the JS ErrorUtils path never flushes. +public enum T3CrashLog { + public static let directoryName = "crash-logs" + public static let lastCrashFileName = "last-crash.json" + + private static let isoFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + /// Shared fsync write used by the Expo module and native fatal hooks. + @discardableResult + public static func writeSyncText(relativePath: String, contents: String) -> Bool { + do { + let docs = try FileManager.default.url( + for: .documentDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let url = docs.appendingPathComponent(relativePath) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = Data(contents.utf8) + // Non-atomic: under abort we prefer a partial file over losing the rename. + try data.write(to: url, options: []) + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + if #available(iOS 13.0, *) { + try handle.synchronize() + } + return true + } catch { + return false + } + } + + /// Persist a native RCTFatal / RCTFatalException payload. + public static func persistNativeFatal( + message: String, + name: String, + stack: String?, + extra: String?, + source: String + ) { + let truncatedMessage = truncate(message, max: 8_000) + let truncatedStack = stack.map { truncate($0, max: 48_000) } + let capturedAt = isoFormatter.string(from: Date()) + let millis = Int(Date().timeIntervalSince1970 * 1000) + + var record: [String: Any] = [ + "breadcrumbs": [] as [Any], + "capturedAt": capturedAt, + "handlerInvocation": 0, + "isFatal": true, + "message": truncatedMessage, + "name": name, + "source": source, + ] + if let truncatedStack, !truncatedStack.isEmpty { + record["stack"] = truncatedStack + } + if let extra, !extra.isEmpty { + record["extraData"] = truncate(extra, max: 8_000) + } + + guard let data = try? JSONSerialization.data(withJSONObject: record, options: []), + let contents = String(data: data, encoding: .utf8) + else { + let escaped = truncatedMessage + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + let fallback = + "{\"capturedAt\":\"\(capturedAt)\",\"isFatal\":true,\"message\":\"\(escaped)\",\"name\":\"\(name)\",\"source\":\"\(source)\",\"handlerInvocation\":0,\"breadcrumbs\":[]}" + _ = writeSyncText(relativePath: "\(directoryName)/\(lastCrashFileName)", contents: fallback) + _ = writeSyncText( + relativePath: "\(directoryName)/crash-native-\(millis)-0.json", + contents: fallback + ) + return + } + + _ = writeSyncText(relativePath: "\(directoryName)/\(lastCrashFileName)", contents: contents) + _ = writeSyncText( + relativePath: "\(directoryName)/crash-native-\(millis)-0.json", + contents: contents + ) + } + + public static func formatJSStack(_ value: Any?) -> String? { + guard let value else { + return nil + } + if let text = value as? String { + return text + } + if let frames = value as? [[String: Any]] { + let lines = frames.prefix(80).map { frame -> String in + let method = frame["methodName"] as? String ?? "?" + let file = frame["file"] as? String ?? "?" + let line = stringifyFrameNumber(frame["lineNumber"]) + let column = stringifyFrameNumber(frame["column"]) + return " at \(method) (\(file):\(line):\(column))" + } + return lines.joined(separator: "\n") + } + return String(describing: value) + } + + public static func formatExceptionStack(_ exception: NSException) -> String { + let addresses = exception.callStackSymbols + if addresses.isEmpty { + return exception.callStackReturnAddresses.map { String(describing: $0) }.joined(separator: "\n") + } + return addresses.prefix(80).joined(separator: "\n") + } + + public static func stringValue(_ value: Any?) -> String? { + guard let value, !(value is NSNull) else { + return nil + } + if let text = value as? String { + return text + } + if JSONSerialization.isValidJSONObject(value), + let data = try? JSONSerialization.data(withJSONObject: value, options: []), + let text = String(data: data, encoding: .utf8) { + return text + } + return String(describing: value) + } + + private static func truncate(_ text: String, max: Int) -> String { + guard text.count > max else { + return text + } + let end = text.index(text.startIndex, offsetBy: max) + return String(text[.. String { + if let number = value as? NSNumber { + return number.stringValue + } + if let intValue = value as? Int { + return String(intValue) + } + if let text = value as? String { + return text + } + return "?" + } +} diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 33e1dc9086c..7781b164a2c 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -1,9 +1,48 @@ import ExpoModulesCore +import Security public final class T3NativeControlsModule: Module { public func definition() -> ModuleDefinition { Name("T3NativeControls") + Function("getShowcasePairingUrl") { + let arguments = ProcessInfo.processInfo.arguments + guard + let flagIndex = arguments.firstIndex(of: "--showcasePairingUrl"), + arguments.indices.contains(flagIndex + 1) + else { + return nil as String? + } + return arguments[flagIndex + 1] + } + + Function("getShowcaseScene") { () -> String? in + let scenePath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseScene" + if let storedScene = try? String(contentsOfFile: scenePath, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines), !storedScene.isEmpty { + return storedScene + } + let arguments = ProcessInfo.processInfo.arguments + guard + let flagIndex = arguments.firstIndex(of: "--showcaseScene"), + arguments.indices.contains(flagIndex + 1) + else { + return nil as String? + } + return arguments[flagIndex + 1] + } + + Function("prepareShowcaseCapture") { + for itemClass in [kSecClassGenericPassword, kSecClassInternetPassword] { + SecItemDelete([kSecClass as String: itemClass] as CFDictionary) + } + } + + Function("markShowcaseReady") { (scene: String) in + let readyPath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseReadyScene" + try? scene.write(toFile: readyPath, atomically: true, encoding: .utf8) + } + View(T3HeaderButtonView.self) { Prop("label") { (view: T3HeaderButtonView, label: String) in view.setLabel(label) diff --git a/apps/mobile/modules/t3-review-diff/android/build.gradle b/apps/mobile/modules/t3-review-diff/android/build.gradle new file mode 100644 index 00000000000..22bb070b3b8 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/android/build.gradle @@ -0,0 +1,19 @@ +apply plugin: 'com.android.library' +apply plugin: 'org.jetbrains.kotlin.android' + +group = 'com.t3tools.reviewdiff' +version = '0.0.0' + +android { + namespace 'expo.modules.t3reviewdiff' + compileSdk rootProject.ext.compileSdkVersion + + defaultConfig { + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + } +} + +dependencies { + implementation project(':expo-modules-core') +} diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/AndroidManifest.xml b/apps/mobile/modules/t3-review-diff/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..94cbbcfc396 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt new file mode 100644 index 00000000000..6782e6894d9 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt @@ -0,0 +1,315 @@ +package expo.modules.t3reviewdiff + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.graphics.Shader +import android.graphics.Typeface +import kotlin.math.max +import kotlin.math.min + +internal class ReviewDiffCanvasDrawing(context: Context) { + private val density = context.resources.displayMetrics.density + var theme: DiffTheme = DiffTheme.fallback("light") + + val backgroundPaint = Paint() + val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG) + val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + hinting = Paint.HINTING_ON + isSubpixelText = false + clearShadowLayer() + } + val uiPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + hinting = Paint.HINTING_ON + isSubpixelText = false + clearShadowLayer() + } + + private val italicCodeTypeface by lazy { + Typeface.create(ReviewDiffTypefaces.regular, Typeface.ITALIC) + } + private val boldItalicCodeTypeface by lazy { + Typeface.create(ReviewDiffTypefaces.bold, Typeface.ITALIC) + } + + init { + textPaint.typeface = ReviewDiffTypefaces.regular + uiPaint.typeface = Typeface.DEFAULT_BOLD + } + + fun fileHeaderChevronRect(top: Int, bottom: Int, style: DiffStyle): RectF { + val centerY = (top + bottom) / 2f + val left = style.fileHeaderHorizontalPaddingPx + return RectF(left, centerY - 10f * density, left + 20f * density, centerY + 10f * density) + } + + fun fileHeaderIconRect(top: Int, bottom: Int, style: DiffStyle): RectF { + val chevron = fileHeaderChevronRect(top, bottom, style) + return RectF( + chevron.right + 8f * density, + chevron.top, + chevron.right + 28f * density, + chevron.bottom, + ) + } + + fun fileHeaderCheckboxRect(top: Int, bottom: Int, width: Int, style: DiffStyle): RectF { + val centerY = (top + bottom) / 2f + val right = width - style.fileHeaderHorizontalPaddingPx + return RectF( + right - 20f * density, + centerY - 10f * density, + right, + centerY + 10f * density, + ) + } + + fun drawDisclosureChevron( + canvas: Canvas, + rect: RectF, + color: Int, + collapsed: Boolean + ) { + borderPaint.style = Paint.Style.STROKE + borderPaint.color = color + borderPaint.strokeWidth = 2f * density + borderPaint.strokeCap = Paint.Cap.ROUND + borderPaint.strokeJoin = Paint.Join.ROUND + val path = Path() + if (collapsed) { + path.moveTo(rect.left + rect.width() * 0.4f, rect.top + rect.height() * 0.28f) + path.lineTo(rect.left + rect.width() * 0.6f, rect.centerY()) + path.lineTo(rect.left + rect.width() * 0.4f, rect.bottom - rect.height() * 0.28f) + } else { + path.moveTo(rect.left + rect.width() * 0.28f, rect.top + rect.height() * 0.42f) + path.lineTo(rect.centerX(), rect.top + rect.height() * 0.62f) + path.lineTo(rect.right - rect.width() * 0.28f, rect.top + rect.height() * 0.42f) + } + canvas.drawPath(path, borderPaint) + borderPaint.style = Paint.Style.FILL + } + + fun drawFileIcon(canvas: Canvas, rect: RectF, changeType: String) { + val color = when (changeType) { + "new" -> theme.addText + "deleted" -> theme.deleteText + else -> theme.hunkText + } + borderPaint.style = Paint.Style.STROKE + borderPaint.color = color + borderPaint.strokeWidth = 2f * density + canvas.drawRoundRect(rect, 6f * density, 6f * density, borderPaint) + + if (changeType == "rename-pure" || changeType == "rename-changed" || changeType == "renamed") { + drawRenameChevronIcon(canvas, rect, color) + } else { + backgroundPaint.color = color + canvas.drawCircle(rect.centerX(), rect.centerY(), 3f * density, backgroundPaint) + } + borderPaint.style = Paint.Style.FILL + } + + fun drawViewedCheckbox(canvas: Canvas, rect: RectF, checked: Boolean) { + if (checked) { + backgroundPaint.color = theme.hunkText + canvas.drawRoundRect(rect, 6f * density, 6f * density, backgroundPaint) + } + borderPaint.style = Paint.Style.STROKE + borderPaint.color = if (checked) theme.hunkText else theme.mutedText + borderPaint.strokeWidth = 1.8f * density + canvas.drawRoundRect(rect, 6f * density, 6f * density, borderPaint) + if (checked) { + borderPaint.color = theme.background + borderPaint.strokeWidth = 2f * density + borderPaint.strokeCap = Paint.Cap.ROUND + borderPaint.strokeJoin = Paint.Join.ROUND + val path = Path().apply { + moveTo(rect.left + rect.width() * 0.28f, rect.centerY()) + lineTo(rect.left + rect.width() * 0.44f, rect.bottom - rect.height() * 0.3f) + lineTo(rect.right - rect.width() * 0.25f, rect.top + rect.height() * 0.3f) + } + canvas.drawPath(path, borderPaint) + } + borderPaint.style = Paint.Style.FILL + } + + fun drawNoticeIcon(canvas: Canvas, rect: RectF, color: Int) { + borderPaint.style = Paint.Style.STROKE + borderPaint.color = color + borderPaint.strokeWidth = 1.7f * density + borderPaint.strokeCap = Paint.Cap.ROUND + canvas.drawOval(rect, borderPaint) + canvas.drawLine( + rect.centerX(), + rect.top + rect.height() * 0.3f, + rect.centerX(), + rect.top + rect.height() * 0.58f, + borderPaint, + ) + borderPaint.style = Paint.Style.FILL + canvas.drawCircle(rect.centerX(), rect.bottom - rect.height() * 0.24f, density, borderPaint) + } + + fun configureUiPaint(paint: Paint, color: Int, size: Float, weight: String) { + paint.typeface = if (isBoldFontWeight(weight)) Typeface.DEFAULT_BOLD else Typeface.DEFAULT + configureTextPaint(paint, color, size) + } + + fun configureMonospacePaint(color: Int, size: Float, weight: String) { + textPaint.typeface = ReviewDiffTypefaces.forWeight(weight) + configureTextPaint(textPaint, color, size) + } + + fun configureCodePaint(color: Int, fontStyle: Int, style: DiffStyle) { + val tokenBold = fontStyle and 2 != 0 + val italic = fontStyle and 1 != 0 + textPaint.typeface = when { + tokenBold && italic -> boldItalicCodeTypeface + tokenBold -> ReviewDiffTypefaces.bold + italic -> italicCodeTypeface + else -> ReviewDiffTypefaces.forWeight(style.codeFontWeight) + } + configureTextPaint(textPaint, color, style.codeFontSizePx) + textPaint.isUnderlineText = fontStyle and 4 != 0 + } + + fun lineNumberColor(change: String): Int = when (change) { + "add" -> theme.addText + "delete" -> theme.deleteText + else -> theme.mutedText + } + + fun drawDeleteStripes( + canvas: Canvas, + top: Int, + bottom: Int, + width: Float, + color: Int + ) { + backgroundPaint.color = color + var y = top.toFloat() + while (y < bottom) { + canvas.drawRect(0f, y, width, min(bottom.toFloat(), y + density), backgroundPaint) + y += 2f * density + } + } + + fun drawWordDiffRanges( + canvas: Canvas, + row: DiffRow, + codeX: Float, + top: Int, + bottom: Int + ) { + if (row.wordDiffRanges.isEmpty() || (row.change != "add" && row.change != "delete")) return + val color = if (row.change == "add") theme.addBar else theme.deleteBar + backgroundPaint.color = withAlpha(color, 71) + val characterWidth = textPaint.measureText("M") + val fontHeight = textPaint.fontMetrics.run { descent - ascent } + val highlightHeight = max(4f * density, min(bottom - top - 4f * density, fontHeight)) + val highlightTop = (top + bottom - highlightHeight) / 2f + row.wordDiffRanges.forEach { range -> + val left = codeX + range.start * characterWidth + val right = max(left + 2f * density, codeX + range.end * characterWidth) + canvas.drawRoundRect( + RectF(left, highlightTop, right, highlightTop + highlightHeight), + 3f * density, + 3f * density, + backgroundPaint, + ) + } + } + + fun drawFileHeaderPathScrollFades( + canvas: Canvas, + pathRect: RectF, + horizontalOffset: Int, + maxOffset: Int + ) { + if (maxOffset <= 0 || pathRect.width() <= 0f) return + val fadeWidth = min(28f * density, pathRect.width() / 3f) + if (horizontalOffset > 0) { + drawHorizontalFade( + canvas, + RectF(pathRect.left, pathRect.top, pathRect.left + fadeWidth, pathRect.bottom), + fadesToRight = false, + ) + } + if (horizontalOffset < maxOffset) { + drawHorizontalFade( + canvas, + RectF(pathRect.right - fadeWidth, pathRect.top, pathRect.right, pathRect.bottom), + fadesToRight = true, + ) + } + } + + private fun drawRenameChevronIcon(canvas: Canvas, rect: RectF, color: Int) { + borderPaint.style = Paint.Style.STROKE + borderPaint.color = color + borderPaint.strokeWidth = 1.8f * density + borderPaint.strokeCap = Paint.Cap.ROUND + borderPaint.strokeJoin = Paint.Join.ROUND + val chevronWidth = 3.6f * density + val chevronHeight = 8f * density + val gap = 2.4f * density + val startX = rect.centerX() - (chevronWidth * 2f + gap) / 2f + val path = Path() + for (x in listOf(startX, startX + chevronWidth + gap)) { + path.moveTo(x, rect.centerY() - chevronHeight / 2f) + path.lineTo(x + chevronWidth, rect.centerY()) + path.lineTo(x, rect.centerY() + chevronHeight / 2f) + } + canvas.drawPath(path, borderPaint) + } + + private fun configureTextPaint(paint: Paint, color: Int, size: Float) { + paint.textSize = size + paint.color = color + paint.style = Paint.Style.FILL + paint.textAlign = Paint.Align.LEFT + paint.isUnderlineText = false + paint.isFakeBoldText = false + paint.clearShadowLayer() + } + + private fun drawHorizontalFade(canvas: Canvas, rect: RectF, fadesToRight: Boolean) { + val opaque = theme.headerBackground + val transparent = Color.argb(0, Color.red(opaque), Color.green(opaque), Color.blue(opaque)) + backgroundPaint.shader = LinearGradient( + rect.left, + rect.centerY(), + rect.right, + rect.centerY(), + if (fadesToRight) transparent else opaque, + if (fadesToRight) opaque else transparent, + Shader.TileMode.CLAMP, + ) + canvas.drawRect(rect, backgroundPaint) + backgroundPaint.shader = null + } + + private fun isBoldFontWeight(weight: String): Boolean = when (weight.lowercase()) { + "medium", "semibold", "semi-bold", "bold", "heavy", "black" -> true + else -> false + } + + private fun withAlpha(color: Int, alpha: Int): Int = + Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)) +} + +private object ReviewDiffTypefaces { + val regular: Typeface = Typeface.create("monospace", Typeface.NORMAL) + private val medium: Typeface = Typeface.create("monospace-medium", Typeface.NORMAL) + val bold: Typeface = Typeface.create("monospace", Typeface.BOLD) + + fun forWeight(weight: String): Typeface = when (weight.lowercase()) { + "medium", "semibold", "semi-bold" -> medium + "bold", "heavy", "black" -> bold + else -> regular + } +} diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt new file mode 100644 index 00000000000..ee40275ede8 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt @@ -0,0 +1,78 @@ +package expo.modules.t3reviewdiff + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class T3ReviewDiffModule : Module() { + override fun definition() = ModuleDefinition { + Name("T3ReviewDiffSurface") + + View(T3ReviewDiffView::class) { + Prop("tokensResetKey") { view: T3ReviewDiffView, tokensResetKey: String -> + view.setTokensResetKey(tokensResetKey) + } + Prop("contentResetKey") { view: T3ReviewDiffView, contentResetKey: String -> + view.setContentResetKey(contentResetKey) + } + Prop("collapsedFileIdsJson") { view: T3ReviewDiffView, collapsedFileIdsJson: String -> + view.setCollapsedFileIdsJson(collapsedFileIdsJson) + } + Prop("viewedFileIdsJson") { view: T3ReviewDiffView, viewedFileIdsJson: String -> + view.setViewedFileIdsJson(viewedFileIdsJson) + } + Prop("selectedRowIdsJson") { view: T3ReviewDiffView, selectedRowIdsJson: String -> + view.setSelectedRowIdsJson(selectedRowIdsJson) + } + Prop("collapsedCommentIdsJson") { view: T3ReviewDiffView, collapsedCommentIdsJson: String -> + view.setCollapsedCommentIdsJson(collapsedCommentIdsJson) + } + Prop("appearanceScheme") { view: T3ReviewDiffView, appearanceScheme: String -> + view.setAppearanceScheme(appearanceScheme) + } + Prop("themeJson") { view: T3ReviewDiffView, themeJson: String -> + view.setThemeJson(themeJson) + } + Prop("styleJson") { view: T3ReviewDiffView, styleJson: String -> + view.setStyleJson(styleJson) + } + Prop("rowHeight") { view: T3ReviewDiffView, rowHeight: Double -> + view.setRowHeight(rowHeight.toFloat()) + } + Prop("contentWidth") { view: T3ReviewDiffView, contentWidth: Double -> + view.setContentWidth(contentWidth.toFloat()) + } + Prop("initialRowIndex") { view: T3ReviewDiffView, initialRowIndex: Double -> + view.setInitialRowIndex(initialRowIndex) + } + + Events( + "onDebug", + "onVisibleFileChange", + "onToggleFile", + "onToggleViewedFile", + "onPressLine", + "onToggleComment", + ) + + AsyncFunction("scrollToFile") { view: T3ReviewDiffView, fileId: String, animated: Boolean -> + view.scrollToFile(fileId, animated) + } + AsyncFunction("scrollToTop") { view: T3ReviewDiffView, animated: Boolean -> + view.scrollToTop(animated) + } + AsyncFunction("setRowsJson") { view: T3ReviewDiffView, rowsJson: String -> + view.setRowsJson(rowsJson) + } + AsyncFunction("setTokensJson") { view: T3ReviewDiffView, tokensJson: String -> + view.setTokensJson(tokensJson) + } + AsyncFunction("setTokensPatchJson") { view: T3ReviewDiffView, tokensPatchJson: String -> + view.setTokensPatchJson(tokensPatchJson) + } + + OnViewDestroys { view: T3ReviewDiffView -> + view.cleanup() + } + } + } +} diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt new file mode 100644 index 00000000000..97e9f696db9 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt @@ -0,0 +1,1429 @@ +package expo.modules.t3reviewdiff + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.VelocityTracker +import android.view.View +import android.view.ViewGroup +import android.view.ViewConfiguration +import android.widget.OverScroller +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.viewevent.EventDispatcher +import expo.modules.kotlin.views.ExpoView +import org.json.JSONArray +import org.json.JSONObject +import java.util.concurrent.Executors +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.min + +class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(context, appContext) { + private val canvasView = DiffCanvasView(context) + private val onDebug by EventDispatcher() + private val onVisibleFileChange by EventDispatcher() + private val onToggleFile by EventDispatcher() + private val onToggleViewedFile by EventDispatcher() + private val onPressLine by EventDispatcher() + private val onToggleComment by EventDispatcher() + private var rows: List = emptyList() + private var visibleRows: List = emptyList() + private var collapsedFileIds: Set = emptySet() + private var viewedFileIds: Set = emptySet() + private var selectedRowIds: Set = emptySet() + private var collapsedCommentIds: Set = emptySet() + private var initialRowIndex = 0 + private var pendingInitialScroll = false + private var lastVisibleFileId: String? = null + private var tokensResetKey = "" + private var contentResetKey = "" + private var rowsDecodeGeneration = 0 + private var tokensDecodeGeneration = 0 + private val payloadDecodeExecutor = Executors.newSingleThreadExecutor() + private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop + private val minimumFlingVelocity = ViewConfiguration.get(context).scaledMinimumFlingVelocity + private val verticalScroller = OverScroller(context) + private val horizontalScroller = OverScroller(context) + private var dragAxis: DragAxis? = null + private var horizontalDragTarget: HorizontalPanTarget? = null + private var lastTouchX = 0f + private var lastTouchY = 0f + private var velocityTracker: VelocityTracker? = null + + init { + canvasView.onRowTap = { row, gesture, target -> handleRowTap(row, gesture, target) } + canvasView.onVisibleRowsChanged = { first, last -> + onDebug( + mapOf( + "message" to "visible-range", + "firstRowIndex" to first, + "lastRowIndex" to last, + ), + ) + emitVisibleFile(first) + } + + addView( + canvasView, + LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT), + ) + } + + fun setTokensResetKey(value: String) { + if (tokensResetKey == value) return + tokensResetKey = value + canvasView.tokensByRowId = emptyMap() + } + + fun setContentResetKey(value: String) { + if (contentResetKey == value) return + contentResetKey = value + tokensDecodeGeneration += 1 + canvasView.tokensByRowId = emptyMap() + lastVisibleFileId = null + pendingInitialScroll = true + canvasView.setVerticalOffset(0) + canvasView.resetHorizontalOffsets() + applyPendingInitialScroll() + } + + fun setCollapsedFileIdsJson(value: String) { + collapsedFileIds = parseStringSet(value) + canvasView.collapsedFileIds = collapsedFileIds + rebuildVisibleRows() + } + + fun setViewedFileIdsJson(value: String) { + viewedFileIds = parseStringSet(value) + canvasView.viewedFileIds = viewedFileIds + } + + fun setSelectedRowIdsJson(value: String) { + selectedRowIds = parseStringSet(value) + canvasView.selectedRowIds = selectedRowIds + } + + fun setCollapsedCommentIdsJson(value: String) { + collapsedCommentIds = parseStringSet(value) + canvasView.collapsedCommentIds = collapsedCommentIds + rebuildVisibleRows() + } + + fun setAppearanceScheme(value: String) { + canvasView.theme = DiffTheme.fallback(value) + } + + fun setThemeJson(value: String) { + canvasView.theme = DiffTheme.fromJson(value, canvasView.theme) + } + + fun setStyleJson(value: String) { + canvasView.style = DiffStyle.fromJson(value, canvasView.style, resources.displayMetrics.density) + } + + fun setRowHeight(value: Float) { + canvasView.style = canvasView.style.copy(rowHeightPx = dp(value)) + } + + fun setContentWidth(value: Float) { + canvasView.contentWidthPx = max(width, dp(value).toInt()) + } + + fun setInitialRowIndex(value: Double) { + initialRowIndex = value.toInt().coerceAtLeast(0) + pendingInitialScroll = true + applyPendingInitialScroll() + } + + fun setRowsJson(value: String) { + rowsDecodeGeneration += 1 + val generation = rowsDecodeGeneration + payloadDecodeExecutor.execute { + val decodedRows = parseRows(value) + post { + if (generation != rowsDecodeGeneration) return@post + rows = decodedRows + lastVisibleFileId = null + rebuildVisibleRows() + } + } + } + + fun setTokensJson(value: String) { + tokensDecodeGeneration += 1 + val generation = tokensDecodeGeneration + payloadDecodeExecutor.execute { + val decodedTokens = parseTokensObject(value) + post { + if (generation != tokensDecodeGeneration) return@post + canvasView.tokensByRowId = decodedTokens + } + } + } + + fun setTokensPatchJson(value: String) { + payloadDecodeExecutor.execute { + try { + val payload = JSONObject(value) + val resetKey = payload.optString("resetKey") + val decodedTokens = parseTokensObject( + payload.optJSONObject("tokensByRowId") ?: JSONObject(), + ) + post { + if (resetKey.isNotEmpty() && resetKey != tokensResetKey) return@post + if (decodedTokens.isNotEmpty()) { + canvasView.tokensByRowId = canvasView.tokensByRowId + decodedTokens + } + } + } catch (_: Exception) { + } + } + } + + fun cleanup() { + payloadDecodeExecutor.shutdownNow() + } + + fun scrollToFile(fileId: String, animated: Boolean) { + val index = visibleRows.indexOfFirst { it.kind == "file" && it.resolvedFileId == fileId } + if (index < 0) return + scrollToY(canvasView.rowTop(index), animated) + } + + fun scrollToTop(animated: Boolean) { + scrollToY(0, animated) + } + + @Suppress("NestedBlockDepth", "ReturnCount") + override fun onInterceptTouchEvent(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + verticalScroller.forceFinished(true) + horizontalScroller.forceFinished(true) + dragAxis = null + horizontalDragTarget = canvasView.horizontalPanTarget(event.y) + lastTouchX = event.x + lastTouchY = event.y + parent?.requestDisallowInterceptTouchEvent(true) + return false + } + MotionEvent.ACTION_MOVE -> { + if (dragAxis == null) { + val deltaX = event.x - lastTouchX + val deltaY = event.y - lastTouchY + if (max(abs(deltaX), abs(deltaY)) > touchSlop) { + dragAxis = if (abs(deltaY) >= abs(deltaX)) { + DragAxis.VERTICAL + } else { + horizontalDragTarget + ?.takeIf { canvasView.maxHorizontalOffset(it) > 0 } + ?.let { DragAxis.HORIZONTAL } + } + } + } + return dragAxis != null + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> return dragAxis != null + } + return false + } + + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + velocityTracker?.recycle() + velocityTracker = VelocityTracker.obtain() + } + velocityTracker?.addMovement(event) + val handled = super.dispatchTouchEvent(event) + if ( + ( + event.actionMasked == MotionEvent.ACTION_UP || + event.actionMasked == MotionEvent.ACTION_CANCEL + ) && + dragAxis == null + ) { + velocityTracker?.recycle() + velocityTracker = null + horizontalDragTarget = null + parent?.requestDisallowInterceptTouchEvent(false) + } + return handled + } + + @Suppress("NestedBlockDepth") + override fun onTouchEvent(event: MotionEvent): Boolean { + val axis = dragAxis ?: return false + when (event.actionMasked) { + MotionEvent.ACTION_MOVE -> { + val deltaX = (lastTouchX - event.x).toInt() + val deltaY = (lastTouchY - event.y).toInt() + if (axis == DragAxis.VERTICAL) { + canvasView.scrollByVertical(deltaY) + } else { + horizontalDragTarget?.let { canvasView.scrollByHorizontal(deltaX, it) } + } + lastTouchX = event.x + lastTouchY = event.y + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + if (event.actionMasked == MotionEvent.ACTION_UP) { + velocityTracker?.computeCurrentVelocity(1000) + if (axis == DragAxis.VERTICAL) { + val velocity = -(velocityTracker?.yVelocity ?: 0f).toInt() + if (abs(velocity) >= minimumFlingVelocity) { + verticalScroller.fling( + 0, + canvasView.verticalOffset(), + 0, + velocity, + 0, + 0, + 0, + canvasView.maxVerticalOffset(), + ) + postInvalidateOnAnimation() + } + } else if (horizontalDragTarget?.kind == HorizontalPanKind.CODE) { + val velocity = -(velocityTracker?.xVelocity ?: 0f).toInt() + if (abs(velocity) >= minimumFlingVelocity) { + horizontalScroller.fling( + canvasView.horizontalOffset(), + 0, + velocity, + 0, + 0, + canvasView.maxHorizontalOffset(), + 0, + 0, + ) + postInvalidateOnAnimation() + } + } + } + dragAxis = null + horizontalDragTarget = null + velocityTracker?.recycle() + velocityTracker = null + parent?.requestDisallowInterceptTouchEvent(false) + } + } + return true + } + + override fun computeScroll() { + var animating = false + if (verticalScroller.computeScrollOffset()) { + canvasView.setVerticalOffset(verticalScroller.currY) + animating = true + } + if (horizontalScroller.computeScrollOffset()) { + canvasView.setHorizontalOffset(horizontalScroller.currX) + animating = true + } + if (animating) { + postInvalidateOnAnimation() + } + } + + private fun rebuildVisibleRows() { + val filtered = ArrayList(rows.size) + var currentFileCollapsed = false + rows.forEach { row -> + if (row.kind == "file") { + currentFileCollapsed = collapsedFileIds.contains(row.resolvedFileId) + filtered.add(row) + } else if (!currentFileCollapsed) { + filtered.add(row) + } + } + visibleRows = filtered + canvasView.rows = filtered + canvasView.viewedFileIds = viewedFileIds + canvasView.selectedRowIds = selectedRowIds + applyPendingInitialScroll() + } + + private fun handleRowTap(row: DiffRow, gesture: String, target: RowTapTarget) { + when (row.kind) { + "file" -> { + if (gesture != "tap") return + if (target == RowTapTarget.VIEWED_CHECKBOX) { + onToggleViewedFile(mapOf("fileId" to row.resolvedFileId)) + } else { + onToggleFile(mapOf("fileId" to row.resolvedFileId)) + } + } + "comment" -> onToggleComment(mapOf("commentId" to row.id)) + "line" -> { + val payload = mutableMapOf( + "rowId" to row.id, + "fileId" to row.resolvedFileId, + "gesture" to gesture, + "change" to row.change, + ) + row.oldLineNumber?.let { payload["oldLineNumber"] = it } + row.newLineNumber?.let { payload["newLineNumber"] = it } + onPressLine(payload) + } + } + } + + @Suppress("ReturnCount") + private fun emitVisibleFile(firstVisibleIndex: Int) { + if (visibleRows.isEmpty()) return + val start = firstVisibleIndex.coerceIn(0, visibleRows.lastIndex) + val fileId = (start downTo 0) + .asSequence() + .map { visibleRows[it].resolvedFileId } + .firstOrNull { it.isNotEmpty() } + ?: return + if (fileId == lastVisibleFileId) return + lastVisibleFileId = fileId + onVisibleFileChange(mapOf("fileId" to fileId)) + } + + private fun applyPendingInitialScroll() { + if (!pendingInitialScroll || visibleRows.isEmpty()) return + pendingInitialScroll = false + val index = initialRowIndex.coerceIn(0, visibleRows.lastIndex) + post { canvasView.setVerticalOffset(canvasView.rowTop(index)) } + } + + private fun scrollToY(y: Int, animated: Boolean) { + val target = y.coerceIn(0, canvasView.maxVerticalOffset()) + if (animated) { + verticalScroller.startScroll( + 0, + canvasView.verticalOffset(), + 0, + target - canvasView.verticalOffset(), + 250, + ) + postInvalidateOnAnimation() + } else { + canvasView.setVerticalOffset(target) + } + } + + private fun dp(value: Float): Float = value * resources.displayMetrics.density + + private enum class DragAxis { + VERTICAL, + HORIZONTAL + } +} + +private enum class RowTapTarget { + ROW, + VIEWED_CHECKBOX +} + +private enum class HorizontalPanKind { + CODE, + FILE_HEADER_PATH +} + +private data class HorizontalPanTarget( + val fileId: String, + val kind: HorizontalPanKind +) + +internal data class DiffRow( + val kind: String, + val id: String, + val fileId: String, + val filePath: String, + val previousPath: String?, + val changeType: String, + val additions: Int, + val deletions: Int, + val text: String, + val content: String, + val change: String, + val oldLineNumber: Int?, + val newLineNumber: Int?, + val wordDiffRanges: List, + val commentText: String, + val commentRangeLabel: String, + val commentSectionTitle: String +) { + val resolvedFileId: String get() = fileId.ifEmpty { id } +} + +internal data class DiffWordDiffRange( + val start: Int, + val end: Int +) + +private data class DiffToken( + val content: String, + val color: Int?, + val fontStyle: Int +) + +internal data class DiffTheme( + val background: Int, + val text: Int, + val mutedText: Int, + val headerBackground: Int, + val border: Int, + val hunkBackground: Int, + val hunkText: Int, + val addBackground: Int, + val deleteBackground: Int, + val addBar: Int, + val deleteBar: Int, + val addText: Int, + val deleteText: Int +) { + companion object { + fun fallback(scheme: String): DiffTheme = if (scheme == "dark") { + DiffTheme( + background = Color.rgb(20, 22, 25), + text = Color.rgb(236, 238, 240), + mutedText = Color.rgb(153, 160, 170), + headerBackground = Color.rgb(26, 29, 33), + border = Color.rgb(52, 57, 64), + hunkBackground = Color.rgb(7, 31, 40), + hunkText = Color.rgb(0, 159, 255), + addBackground = Color.rgb(13, 47, 40), + deleteBackground = Color.rgb(57, 20, 21), + addBar = Color.rgb(0, 202, 177), + deleteBar = Color.rgb(255, 46, 63), + addText = Color.rgb(94, 204, 113), + deleteText = Color.rgb(255, 103, 98), + ) + } else { + DiffTheme( + background = Color.WHITE, + text = Color.rgb(7, 7, 7), + mutedText = Color.rgb(102, 106, 115), + headerBackground = Color.WHITE, + border = Color.rgb(222, 224, 228), + hunkBackground = Color.rgb(224, 242, 255), + hunkText = Color.rgb(0, 130, 220), + addBackground = Color.rgb(229, 248, 245), + deleteBackground = Color.rgb(255, 230, 231), + addBar = Color.rgb(0, 172, 151), + deleteBar = Color.rgb(213, 44, 54), + addText = Color.rgb(25, 130, 67), + deleteText = Color.rgb(190, 38, 48), + ) + } + + fun fromJson(value: String, fallback: DiffTheme): DiffTheme = try { + val json = JSONObject(value) + DiffTheme( + background = color(json, "background", fallback.background), + text = color(json, "text", fallback.text), + mutedText = color(json, "mutedText", fallback.mutedText), + headerBackground = color(json, "headerBackground", fallback.headerBackground), + border = color(json, "border", fallback.border), + hunkBackground = color(json, "hunkBackground", fallback.hunkBackground), + hunkText = color(json, "hunkText", fallback.hunkText), + addBackground = color(json, "addBackground", fallback.addBackground), + deleteBackground = color(json, "deleteBackground", fallback.deleteBackground), + addBar = color(json, "addBar", fallback.addBar), + deleteBar = color(json, "deleteBar", fallback.deleteBar), + addText = color(json, "addText", fallback.addText), + deleteText = color(json, "deleteText", fallback.deleteText), + ) + } catch (_: Exception) { + fallback + } + + private fun color(json: JSONObject, key: String, fallback: Int): Int = + parseColor(json.optString(key), fallback) + } +} + +internal data class DiffStyle( + val rowHeightPx: Float, + val gutterWidthPx: Float, + val codePaddingPx: Float, + val changeBarWidthPx: Float, + val fileHeaderHeightPx: Float, + val fileHeaderHorizontalPaddingPx: Float, + val codeFontSizePx: Float, + val codeFontWeight: String, + val lineNumberFontSizePx: Float, + val lineNumberFontWeight: String, + val hunkFontSizePx: Float, + val hunkFontWeight: String, + val fileHeaderFontSizePx: Float, + val fileHeaderFontWeight: String, + val fileHeaderMetaFontSizePx: Float, + val fileHeaderMetaFontWeight: String, + val fileHeaderSubtextFontSizePx: Float, + val fileHeaderSubtextFontWeight: String +) { + companion object { + fun defaults(density: Float): DiffStyle = DiffStyle( + rowHeightPx = 20f * density, + gutterWidthPx = 72f * density, + codePaddingPx = 10f * density, + changeBarWidthPx = 3f * density, + fileHeaderHeightPx = 44f * density, + fileHeaderHorizontalPaddingPx = 10f * density, + codeFontSizePx = 12f * density, + codeFontWeight = "regular", + lineNumberFontSizePx = 10f * density, + lineNumberFontWeight = "regular", + hunkFontSizePx = 11f * density, + hunkFontWeight = "medium", + fileHeaderFontSizePx = 11f * density, + fileHeaderFontWeight = "semibold", + fileHeaderMetaFontSizePx = 10f * density, + fileHeaderMetaFontWeight = "semibold", + fileHeaderSubtextFontSizePx = 11f * density, + fileHeaderSubtextFontWeight = "medium", + ) + + fun fromJson(value: String, fallback: DiffStyle, density: Float): DiffStyle = try { + val json = JSONObject(value) + DiffStyle( + rowHeightPx = json.floatDp("rowHeight", fallback.rowHeightPx, density), + gutterWidthPx = json.floatDp("gutterWidth", fallback.gutterWidthPx, density), + codePaddingPx = json.floatDp("codePadding", fallback.codePaddingPx, density), + changeBarWidthPx = json.floatDp("changeBarWidth", fallback.changeBarWidthPx, density), + fileHeaderHeightPx = json.floatDp("fileHeaderHeight", fallback.fileHeaderHeightPx, density), + fileHeaderHorizontalPaddingPx = json.floatDp( + "fileHeaderHorizontalPadding", + fallback.fileHeaderHorizontalPaddingPx, + density, + ), + codeFontSizePx = json.floatSp("codeFontSize", fallback.codeFontSizePx, density), + codeFontWeight = json.optString("codeFontWeight", fallback.codeFontWeight), + lineNumberFontSizePx = json.floatSp( + "lineNumberFontSize", + fallback.lineNumberFontSizePx, + density, + ), + lineNumberFontWeight = json.optString( + "lineNumberFontWeight", + fallback.lineNumberFontWeight, + ), + hunkFontSizePx = json.floatSp("hunkFontSize", fallback.hunkFontSizePx, density), + hunkFontWeight = json.optString("hunkFontWeight", fallback.hunkFontWeight), + fileHeaderFontSizePx = json.floatSp( + "fileHeaderFontSize", + fallback.fileHeaderFontSizePx, + density, + ), + fileHeaderFontWeight = json.optString( + "fileHeaderFontWeight", + fallback.fileHeaderFontWeight, + ), + fileHeaderMetaFontSizePx = json.floatSp( + "fileHeaderMetaFontSize", + fallback.fileHeaderMetaFontSizePx, + density, + ), + fileHeaderMetaFontWeight = json.optString( + "fileHeaderMetaFontWeight", + fallback.fileHeaderMetaFontWeight, + ), + fileHeaderSubtextFontSizePx = json.floatSp( + "fileHeaderSubtextFontSize", + fallback.fileHeaderSubtextFontSizePx, + density, + ), + fileHeaderSubtextFontWeight = json.optString( + "fileHeaderSubtextFontWeight", + fallback.fileHeaderSubtextFontWeight, + ), + ) + } catch (_: Exception) { + fallback + } + } +} + +private class DiffCanvasView(context: Context) : View(context) { + private val density = resources.displayMetrics.density + private val drawing = ReviewDiffCanvasDrawing(context) + private val backgroundPaint = drawing.backgroundPaint + private val borderPaint = drawing.borderPaint + private val textPaint = drawing.textPaint + private val boldTextPaint = drawing.uiPaint + private val gestureDetector = GestureDetector( + context, + object : GestureDetector.SimpleOnGestureListener() { + override fun onDown(event: MotionEvent): Boolean = true + + override fun onSingleTapUp(event: MotionEvent): Boolean { + rowHitAt(event.y)?.let { hit -> + val target = if ( + hit.row.kind == "file" && + drawing.fileHeaderCheckboxRect( + hit.top, + hit.bottom, + width, + style + ).contains(event.x, event.y) + ) { + RowTapTarget.VIEWED_CHECKBOX + } else { + RowTapTarget.ROW + } + onRowTap?.invoke(hit.row, "tap", target) + } + return true + } + + override fun onLongPress(event: MotionEvent) { + rowHitAt(event.y)?.row + ?.takeIf { it.kind == "line" } + ?.let { onRowTap?.invoke(it, "longPress", RowTapTarget.ROW) } + } + }, + ) + private var rowOffsets = intArrayOf(0) + private var verticalOffset = 0 + private var horizontalOffset = 0 + private val headerPathOffsetsByFileId = mutableMapOf() + private var lastVisibleRange: Pair? = null + var rows: List = emptyList() + set(value) { + field = value + headerPathOffsetsByFileId.keys.retainAll( + value.asSequence().filter { it.kind == "file" }.map { it.resolvedFileId }.toSet(), + ) + rebuildOffsets() + } + var tokensByRowId: Map> = emptyMap() + set(value) { + field = value + invalidate() + } + var viewedFileIds: Set = emptySet() + set(value) { + field = value + invalidate() + } + var collapsedFileIds: Set = emptySet() + set(value) { + field = value + invalidate() + } + var collapsedCommentIds: Set = emptySet() + set(value) { + field = value + rebuildOffsets() + } + var selectedRowIds: Set = emptySet() + set(value) { + field = value + invalidate() + } + var theme: DiffTheme = DiffTheme.fallback("light") + set(value) { + field = value + drawing.theme = value + invalidate() + } + var style: DiffStyle = DiffStyle.defaults(density) + set(value) { + field = value + rebuildOffsets() + } + var contentWidthPx: Int = (1200 * density).toInt() + set(value) { + field = max(value, suggestedMinimumWidth) + setHorizontalOffset(horizontalOffset) + clampHeaderPathOffsets() + invalidate() + } + var onRowTap: ((DiffRow, String, RowTapTarget) -> Unit)? = null + var onVisibleRowsChanged: ((Int, Int) -> Unit)? = null + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + setMeasuredDimension( + MeasureSpec.getSize(widthMeasureSpec), + MeasureSpec.getSize(heightMeasureSpec), + ) + } + + override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { + super.onSizeChanged(width, height, oldWidth, oldHeight) + setVerticalOffset(verticalOffset) + setHorizontalOffset(horizontalOffset) + clampHeaderPathOffsets() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + canvas.drawColor(theme.background) + if (rows.isEmpty()) return + val first = rowIndexAt(max(0, verticalOffset + canvas.clipBounds.top)) + val last = rowIndexAt( + min(max(0, rowOffsets.last() - 1), verticalOffset + canvas.clipBounds.bottom), + ).coerceAtLeast(first) + for (index in first..last.coerceAtMost(rows.lastIndex)) { + drawRow( + canvas, + rows[index], + rowOffsets[index] - verticalOffset, + rowOffsets[index + 1] - verticalOffset, + ) + } + drawStickyFileHeader(canvas, first) + drawHorizontalScrollIndicator(canvas) + emitVisibleRange() + } + + override fun onTouchEvent(event: MotionEvent): Boolean = gestureDetector.onTouchEvent(event) + + fun rowTop(index: Int): Int = rowOffsets[index.coerceIn(0, max(0, rowOffsets.size - 2))] + + fun setVerticalOffset(value: Int) { + val maxOffset = max(0, (rowOffsets.lastOrNull() ?: 0) - height) + val nextOffset = value.coerceIn(0, maxOffset) + if (verticalOffset == nextOffset) return + verticalOffset = nextOffset + invalidate() + emitVisibleRange() + } + + fun scrollByVertical(delta: Int) { + setVerticalOffset(verticalOffset + delta) + } + + fun verticalOffset(): Int = verticalOffset + + fun maxVerticalOffset(): Int = max(0, (rowOffsets.lastOrNull() ?: 0) - height) + + fun setHorizontalOffset(value: Int) { + val nextOffset = value.coerceIn(0, maxHorizontalOffset()) + if (horizontalOffset == nextOffset) return + horizontalOffset = nextOffset + invalidate() + } + + fun scrollByHorizontal(delta: Int, target: HorizontalPanTarget) { + if (target.kind == HorizontalPanKind.FILE_HEADER_PATH) { + setHeaderPathOffset( + target.fileId, + (headerPathOffsetsByFileId[target.fileId] ?: 0) + delta, + ) + } else { + setHorizontalOffset(horizontalOffset + delta) + } + } + + fun horizontalOffset(): Int = horizontalOffset + + fun maxHorizontalOffset(): Int = max(0, contentWidthPx - width) + + fun maxHorizontalOffset(target: HorizontalPanTarget): Int = + if (target.kind == HorizontalPanKind.FILE_HEADER_PATH) { + maxHeaderPathOffset(target.fileId) + } else { + maxHorizontalOffset() + } + + fun horizontalPanTarget(y: Float): HorizontalPanTarget? { + val row = rowHitAt(y)?.row ?: return null + return HorizontalPanTarget( + fileId = row.resolvedFileId, + kind = if (row.kind == "file") HorizontalPanKind.FILE_HEADER_PATH else HorizontalPanKind.CODE, + ) + } + + fun resetHorizontalOffsets() { + setHorizontalOffset(0) + if (headerPathOffsetsByFileId.isNotEmpty()) { + headerPathOffsetsByFileId.clear() + invalidate() + } + } + + private fun rebuildOffsets() { + rowOffsets = IntArray(rows.size + 1) + rows.forEachIndexed { index, row -> + rowOffsets[index + 1] = rowOffsets[index] + rowHeight(row) + } + setVerticalOffset(verticalOffset) + clampHeaderPathOffsets() + requestLayout() + invalidate() + } + + private fun rowHeight(row: DiffRow): Int = when (row.kind) { + "file" -> style.fileHeaderHeightPx.toInt() + "notice" -> max((style.rowHeightPx * 2f).toInt(), (44 * density).toInt()) + "comment" -> if (collapsedCommentIds.contains(row.id)) { + (44 * density).toInt() + } else { + (124 * density).toInt() + } + else -> style.rowHeightPx.toInt() + }.coerceAtLeast(1) + + @Suppress("ReturnCount") + private fun rowIndexAt(y: Int): Int { + if (rows.isEmpty()) return 0 + var low = 0 + var high = rows.lastIndex + while (low <= high) { + val middle = (low + high) ushr 1 + when { + y < rowOffsets[middle] -> high = middle - 1 + y >= rowOffsets[middle + 1] -> low = middle + 1 + else -> return middle + } + } + return low.coerceIn(0, rows.lastIndex) + } + + private fun rowHitAt(y: Float): RowHit? { + stickyFileHeader(firstVisibleRow())?.let { sticky -> + if (y >= max(0, sticky.top).toFloat() && y < sticky.bottom.toFloat()) { + return rows.getOrNull(sticky.index)?.let { RowHit(it, sticky.top, sticky.bottom) } + } + } + val index = rowIndexAt(verticalOffset + y.toInt()) + return rows.getOrNull(index)?.let { + RowHit( + row = it, + top = rowOffsets[index] - verticalOffset, + bottom = rowOffsets[index + 1] - verticalOffset, + ) + } + } + + private fun firstVisibleRow(): Int = rowIndexAt(verticalOffset) + + private fun emitVisibleRange() { + if (rows.isEmpty()) return + val first = rowIndexAt(verticalOffset) + val last = rowIndexAt(verticalOffset + max(1, height)) + val range = first to last + if (range == lastVisibleRange) return + lastVisibleRange = range + onVisibleRowsChanged?.invoke(first, last) + } + + private fun drawRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { + when (row.kind) { + "file" -> drawFileRow(canvas, row, top, bottom) + "hunk" -> drawHunkRow(canvas, row, top, bottom) + "notice" -> drawNoticeRow(canvas, row, top, bottom) + "comment" -> drawCommentRow(canvas, row, top, bottom) + else -> drawLineRow(canvas, row, top, bottom) + } + } + + private fun drawFileRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { + fill(canvas, theme.headerBackground, 0f, top.toFloat(), width.toFloat(), bottom.toFloat()) + + val chevronRect = drawing.fileHeaderChevronRect(top, bottom, style) + val iconRect = drawing.fileHeaderIconRect(top, bottom, style) + val checkboxRect = drawing.fileHeaderCheckboxRect(top, bottom, width, style) + drawing.drawDisclosureChevron( + canvas, + chevronRect, + theme.mutedText, + collapsed = collapsedFileIds.contains(row.resolvedFileId), + ) + drawing.drawFileIcon(canvas, iconRect, row.changeType) + drawing.drawViewedCheckbox( + canvas, + checkboxRect, + viewedFileIds.contains(row.resolvedFileId), + ) + + drawing.configureUiPaint( + paint = boldTextPaint, + color = theme.deleteText, + size = style.fileHeaderMetaFontSizePx, + weight = style.fileHeaderMetaFontWeight, + ) + val deleteText = "-${row.deletions}" + val deleteWidth = boldTextPaint.measureText(deleteText) + val addText = "+${row.additions}" + val addWidth = boldTextPaint.measureText(addText) + val countGap = 4f * density + val countsX = checkboxRect.left - 10f * density - deleteWidth - countGap - addWidth + val baseline = centeredBaseline(top, bottom, boldTextPaint) + canvas.drawText(deleteText, countsX, baseline, boldTextPaint) + boldTextPaint.color = theme.addText + canvas.drawText(addText, countsX + deleteWidth + countGap, baseline, boldTextPaint) + + val pathLayout = fileHeaderPathLayout(row, top, bottom, iconRect, countsX) + val maxPathOffset = maxHeaderPathOffset(pathLayout) + val pathOffset = (headerPathOffsetsByFileId[row.resolvedFileId] ?: 0) + .coerceIn(0, maxPathOffset) + canvas.save() + canvas.clipRect(pathLayout.rect) + canvas.drawText( + pathLayout.displayPath, + pathLayout.rect.left - pathOffset, + centeredBaseline(top, bottom, boldTextPaint), + boldTextPaint, + ) + canvas.restore() + drawing.drawFileHeaderPathScrollFades( + canvas, + pathLayout.rect, + pathOffset, + maxPathOffset, + ) + drawBottomBorder(canvas, bottom) + } + + private fun fileHeaderPathLayout( + row: DiffRow, + top: Int, + bottom: Int, + iconRect: RectF, + countsX: Float + ): FileHeaderPathLayout { + drawing.configureUiPaint( + paint = boldTextPaint, + color = theme.text, + size = style.fileHeaderFontSizePx, + weight = style.fileHeaderFontWeight, + ) + val pathX = iconRect.right + 10f * density + val pathWidth = max(24f * density, countsX - pathX - 12f * density) + val centerY = (top + bottom) / 2f + val displayPath = if ( + !row.previousPath.isNullOrEmpty() && row.previousPath != row.filePath + ) { + "${row.previousPath} -> ${row.filePath}" + } else { + row.filePath + } + return FileHeaderPathLayout( + displayPath = displayPath, + rect = RectF(pathX, centerY - 10f * density, pathX + pathWidth, centerY + 10f * density), + ) + } + + private fun maxHeaderPathOffset(fileId: String): Int { + val row = rows.firstOrNull { it.kind == "file" && it.resolvedFileId == fileId } ?: return 0 + val top = 0 + val bottom = rowHeight(row) + val iconRect = drawing.fileHeaderIconRect(top, bottom, style) + val checkboxRect = drawing.fileHeaderCheckboxRect(top, bottom, width, style) + drawing.configureUiPaint( + paint = boldTextPaint, + color = theme.deleteText, + size = style.fileHeaderMetaFontSizePx, + weight = style.fileHeaderMetaFontWeight, + ) + val countsX = checkboxRect.left - 10f * density - + boldTextPaint.measureText("-${row.deletions}") - 4f * density - + boldTextPaint.measureText("+${row.additions}") + return maxHeaderPathOffset(fileHeaderPathLayout(row, top, bottom, iconRect, countsX)) + } + + private fun maxHeaderPathOffset(layout: FileHeaderPathLayout): Int = max( + 0, + ceil(boldTextPaint.measureText(layout.displayPath) - layout.rect.width()).toInt(), + ) + + private fun setHeaderPathOffset(fileId: String, value: Int) { + val nextOffset = value.coerceIn(0, maxHeaderPathOffset(fileId)) + if ((headerPathOffsetsByFileId[fileId] ?: 0) == nextOffset) return + headerPathOffsetsByFileId[fileId] = nextOffset + invalidate() + } + + private fun clampHeaderPathOffsets() { + headerPathOffsetsByFileId.keys.toList().forEach { fileId -> + val nextOffset = (headerPathOffsetsByFileId[fileId] ?: 0) + .coerceIn(0, maxHeaderPathOffset(fileId)) + if (nextOffset == 0) { + headerPathOffsetsByFileId.remove(fileId) + } else { + headerPathOffsetsByFileId[fileId] = nextOffset + } + } + } + + private fun drawHunkRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { + fill(canvas, theme.hunkBackground, 0f, top.toFloat(), width.toFloat(), bottom.toFloat()) + drawing.configureMonospacePaint( + color = theme.hunkText, + size = style.hunkFontSizePx, + weight = style.hunkFontWeight, + ) + drawScrollableCode(canvas, top, bottom) { codeX -> + canvas.drawText( + row.text.ifEmpty { row.content }, + codeX, + centeredBaseline(top, bottom, textPaint), + textPaint, + ) + } + } + + private fun drawNoticeRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { + fill(canvas, theme.background, 0f, top.toFloat(), width.toFloat(), bottom.toFloat()) + val iconSize = 16f * density + val iconRect = RectF( + style.fileHeaderHorizontalPaddingPx + 2f * density, + (top + bottom - iconSize) / 2f, + style.fileHeaderHorizontalPaddingPx + 2f * density + iconSize, + (top + bottom + iconSize) / 2f, + ) + drawing.drawNoticeIcon(canvas, iconRect, theme.mutedText) + drawing.configureUiPaint( + paint = textPaint, + color = theme.mutedText, + size = style.fileHeaderSubtextFontSizePx, + weight = style.fileHeaderSubtextFontWeight, + ) + val textX = iconRect.right + 10f * density + canvas.drawText( + ellipsize(row.text, textPaint, width - textX - style.fileHeaderHorizontalPaddingPx), + textX, + centeredBaseline(top, bottom, textPaint), + textPaint, + ) + drawBottomBorder(canvas, bottom) + } + + private fun drawCommentRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { + fill(canvas, theme.background, 0f, top.toFloat(), width.toFloat(), bottom.toFloat()) + val cardRect = RectF( + 8f * density, + top + 5f * density, + width - 8f * density, + bottom - 5f * density, + ) + backgroundPaint.color = theme.headerBackground + canvas.drawRoundRect(cardRect, 10f * density, 10f * density, backgroundPaint) + borderPaint.style = Paint.Style.STROKE + borderPaint.color = withAlpha(theme.border, 217) + borderPaint.strokeWidth = density + canvas.drawRoundRect(cardRect, 10f * density, 10f * density, borderPaint) + borderPaint.style = Paint.Style.FILL + + val collapsed = collapsedCommentIds.contains(row.id) + val chevronRect = RectF( + cardRect.left + 10f * density, + cardRect.top + 11f * density, + cardRect.left + 26f * density, + cardRect.top + 27f * density, + ) + drawing.drawDisclosureChevron(canvas, chevronRect, theme.mutedText, collapsed) + drawing.configureUiPaint( + paint = textPaint, + color = theme.mutedText, + size = style.fileHeaderSubtextFontSizePx, + weight = style.fileHeaderSubtextFontWeight, + ) + val title = "Comment on ${row.commentRangeLabel.ifEmpty { "line" }}" + canvas.drawText( + ellipsize(title, textPaint, cardRect.right - chevronRect.right - 20f * density), + chevronRect.right + 10f * density, + cardRect.top + 22f * density, + textPaint, + ) + if (!collapsed) { + textPaint.color = theme.text + val bodyX = cardRect.left + 18f * density + canvas.drawText( + ellipsize( + row.commentText.ifEmpty { "Comment" }, + textPaint, + cardRect.right - bodyX - 18f * density + ), + bodyX, + cardRect.top + 58f * density, + textPaint, + ) + } + } + + @Suppress("CyclomaticComplexMethod") + private fun drawLineRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { + val background = when (row.change) { + "add" -> theme.addBackground + "delete" -> theme.deleteBackground + else -> theme.background + } + fill(canvas, background, 0f, top.toFloat(), width.toFloat(), bottom.toFloat()) + if (style.changeBarWidthPx > 0) { + when (row.change) { + "add" -> fill( + canvas, + theme.addBar, + 0f, + top.toFloat(), + style.changeBarWidthPx, + bottom.toFloat() + ) + "delete" -> drawing.drawDeleteStripes( + canvas, + top, + bottom, + style.changeBarWidthPx, + theme.deleteBar, + ) + } + } + val selected = selectedRowIds.contains(row.id) + if (selected) { + fill( + canvas, + withAlpha(theme.hunkText, 56), + 0f, + top.toFloat(), + width.toFloat(), + bottom.toFloat(), + ) + fill( + canvas, + withAlpha(theme.hunkText, 242), + 0f, + top.toFloat(), + style.changeBarWidthPx, + bottom.toFloat() + ) + } + + val tokens = tokensByRowId[row.id] + drawScrollableCode(canvas, top, bottom) { codeX -> + drawing.configureCodePaint(theme.text, 0, style) + drawing.drawWordDiffRanges(canvas, row, codeX, top, bottom) + if (tokens.isNullOrEmpty()) { + canvas.drawText(row.content, codeX, centeredBaseline(top, bottom, textPaint), textPaint) + } else { + var x = codeX + tokens.forEach { token -> + drawing.configureCodePaint(token.color ?: theme.text, token.fontStyle, style) + canvas.drawText(token.content, x, centeredBaseline(top, bottom, textPaint), textPaint) + x += textPaint.measureText(token.content) + } + } + } + + drawLineNumber(canvas, row, top, bottom) + } + + private fun drawLineNumber(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { + val lineNumber = row.newLineNumber ?: row.oldLineNumber ?: return + drawing.configureMonospacePaint( + color = drawing.lineNumberColor(row.change), + size = style.lineNumberFontSizePx, + weight = style.lineNumberFontWeight, + ) + textPaint.textAlign = Paint.Align.RIGHT + canvas.drawText( + lineNumber.toString(), + style.changeBarWidthPx + style.gutterWidthPx - style.codePaddingPx, + centeredBaseline(top, bottom, textPaint), + textPaint, + ) + textPaint.textAlign = Paint.Align.LEFT + } + + private fun drawScrollableCode( + canvas: Canvas, + top: Int, + bottom: Int, + draw: (Float) -> Unit + ) { + val gutterEnd = style.changeBarWidthPx + style.gutterWidthPx + canvas.save() + canvas.clipRect(gutterEnd, top.toFloat(), width.toFloat(), bottom.toFloat()) + draw(gutterEnd + style.codePaddingPx - horizontalOffset) + canvas.restore() + } + + private fun drawStickyFileHeader(canvas: Canvas, firstVisibleIndex: Int) { + val sticky = stickyFileHeader(firstVisibleIndex) ?: return + val naturalTop = rowOffsets[sticky.index] - verticalOffset + if (naturalTop == sticky.top) return + drawFileRow(canvas, rows[sticky.index], sticky.top, sticky.bottom) + } + + @Suppress("ReturnCount") + private fun stickyFileHeader(firstVisibleIndex: Int): StickyFileHeader? { + if (rows.isEmpty()) return null + val fileIndex = (firstVisibleIndex.coerceIn(0, rows.lastIndex) downTo 0) + .firstOrNull { rows[it].kind == "file" } + ?: return null + val headerHeight = rowHeight(rows[fileIndex]) + val nextFileIndex = ((fileIndex + 1)..rows.lastIndex).firstOrNull { rows[it].kind == "file" } + val top = nextFileIndex + ?.let { min(0, rowOffsets[it] - verticalOffset - headerHeight) } + ?: 0 + return StickyFileHeader(fileIndex, top, top + headerHeight) + } + + private fun drawHorizontalScrollIndicator(canvas: Canvas) { + val maxOffset = maxHorizontalOffset() + if (maxOffset <= 0 || width <= 0) return + val trackWidth = width.toFloat() + val thumbWidth = max(24f * density, trackWidth * trackWidth / contentWidthPx) + val thumbTravel = trackWidth - thumbWidth + val left = thumbTravel * horizontalOffset / maxOffset + fill( + canvas, + withAlpha(theme.mutedText, 110), + left, + height - 2f * density, + left + thumbWidth, + height.toFloat(), + ) + } + + @Suppress("LongParameterList") + private fun fill( + canvas: Canvas, + color: Int, + left: Float, + top: Float, + right: Float, + bottom: Float + ) { + backgroundPaint.color = color + canvas.drawRect(left, top, right, bottom, backgroundPaint) + } + + private fun drawBottomBorder(canvas: Canvas, bottom: Int) { + borderPaint.color = theme.border + borderPaint.strokeWidth = density + canvas.drawLine(0f, bottom - density / 2f, width.toFloat(), bottom - density / 2f, borderPaint) + } + + private fun centeredBaseline(top: Int, bottom: Int, paint: Paint): Float { + val metrics = paint.fontMetrics + return (top + bottom) / 2f - (metrics.ascent + metrics.descent) / 2f + } + + private fun ellipsize(value: String, paint: Paint, width: Float): String { + if (paint.measureText(value) <= width) return value + val suffix = "..." + val available = max(0f, width - paint.measureText(suffix)) + var end = value.length + while (end > 0 && paint.measureText(value, 0, end) > available) end -= 1 + return value.substring(0, end) + suffix + } + + private data class StickyFileHeader( + val index: Int, + val top: Int, + val bottom: Int + ) + + private data class RowHit( + val row: DiffRow, + val top: Int, + val bottom: Int + ) + + private data class FileHeaderPathLayout( + val displayPath: String, + val rect: RectF + ) +} + +private fun withAlpha(color: Int, alpha: Int): Int = + Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)) + +private fun parseRows(value: String): List = try { + val array = JSONArray(value) + List(array.length()) { index -> + val row = array.getJSONObject(index) + DiffRow( + kind = row.optString("kind"), + id = row.optString("id"), + fileId = row.optString("fileId"), + filePath = row.optString("filePath"), + previousPath = row.optNullableString("previousPath"), + changeType = row.optString("changeType"), + additions = row.optInt("additions"), + deletions = row.optInt("deletions"), + text = row.optString("text"), + content = row.optString("content"), + change = row.optString("change", "context"), + oldLineNumber = row.optNullableInt("oldLineNumber"), + newLineNumber = row.optNullableInt("newLineNumber"), + wordDiffRanges = row.optJSONArray("wordDiffRanges")?.let(::parseWordDiffRanges).orEmpty(), + commentText = row.optString("commentText"), + commentRangeLabel = row.optString("commentRangeLabel"), + commentSectionTitle = row.optString("commentSectionTitle"), + ) + } +} catch (_: Exception) { + emptyList() +} + +private fun parseWordDiffRanges(value: JSONArray): List = buildList { + for (index in 0 until value.length()) { + val range = value.optJSONObject(index) ?: continue + val start = range.optInt("start", -1) + val end = range.optInt("end", -1) + if (start >= 0 && end > start) { + add(DiffWordDiffRange(start = start, end = end)) + } + } +} + +private fun parseTokensObject(value: String): Map> = try { + parseTokensObject(JSONObject(value)) +} catch (_: Exception) { + emptyMap() +} + +private fun parseTokensObject(value: JSONObject): Map> { + val result = LinkedHashMap>() + val keys = value.keys() + while (keys.hasNext()) { + val rowId = keys.next() + val array = value.optJSONArray(rowId) ?: continue + result[rowId] = List(array.length()) { index -> + val token = array.getJSONObject(index) + DiffToken( + content = token.optString("content"), + color = token.optNullableString("color")?.let(::parseColorOrNull), + fontStyle = token.optInt("fontStyle"), + ) + } + } + return result +} + +private fun parseStringSet(value: String): Set = try { + val array = JSONArray(value) + buildSet { + for (index in 0 until array.length()) add(array.getString(index)) + } +} catch (_: Exception) { + emptySet() +} + +private fun parseColor(value: String, fallback: Int): Int = try { + Color.parseColor(value) +} catch (_: Exception) { + fallback +} + +private fun parseColorOrNull(value: String): Int? = try { + Color.parseColor(value) +} catch (_: Exception) { + null +} + +private fun JSONObject.optNullableString(key: String): String? = + if (isNull(key)) null else optString(key).takeIf { it.isNotEmpty() } + +private fun JSONObject.optNullableInt(key: String): Int? = + if (isNull(key) || !has(key)) null else optInt(key) + +private fun JSONObject.floatDp(key: String, fallbackPx: Float, density: Float): Float = + if (has(key)) optDouble(key).toFloat() * density else fallbackPx + +private fun JSONObject.floatSp(key: String, fallbackPx: Float, density: Float): Float = + if (has(key)) optDouble(key).toFloat() * density else fallbackPx diff --git a/apps/mobile/modules/t3-review-diff/expo-module.config.json b/apps/mobile/modules/t3-review-diff/expo-module.config.json index fe6b11b649c..bf5baf4fc5c 100644 --- a/apps/mobile/modules/t3-review-diff/expo-module.config.json +++ b/apps/mobile/modules/t3-review-diff/expo-module.config.json @@ -1,7 +1,10 @@ { - "platforms": ["apple"], + "platforms": ["apple", "android"], "apple": { "modules": ["T3ReviewDiffModule"], "podspecPath": "T3ReviewDiffNative.podspec" + }, + "android": { + "modules": ["expo.modules.t3reviewdiff.T3ReviewDiffModule"] } } diff --git a/apps/mobile/modules/t3-review-diff/package.json b/apps/mobile/modules/t3-review-diff/package.json index 75ac49e5a99..4b3f91dbd86 100644 --- a/apps/mobile/modules/t3-review-diff/package.json +++ b/apps/mobile/modules/t3-review-diff/package.json @@ -7,6 +7,11 @@ "modules": [ "T3ReviewDiffModule" ] + }, + "android": { + "modules": [ + "expo.modules.t3reviewdiff.T3ReviewDiffModule" + ] } } } diff --git a/apps/mobile/modules/t3-terminal/README.md b/apps/mobile/modules/t3-terminal/README.md index 09d927f4733..768e3c0704c 100644 --- a/apps/mobile/modules/t3-terminal/README.md +++ b/apps/mobile/modules/t3-terminal/README.md @@ -18,9 +18,9 @@ uses that callback I/O model: 4. send user input back to JS with the write callback 5. emit Ghostty's measured terminal size through `onResize` -Android currently implements the same view name (`T3TerminalSurface`) and event payloads so the -React Native screen and RPC code stay platform-neutral. The renderer backend can be replaced with a -future Android Ghostty build without changing JS. +Android implements the same view contract with upstream `libghostty-vt` for terminal state, parsing, +reflow, and scrollback. An Android Canvas view renders compact snapshots produced by the JNI bridge, +so the React Native screen and RPC code stay platform-neutral. Vendored Ghostty revision and license details are in `THIRD_PARTY_NOTICES.md`. @@ -36,3 +36,15 @@ apps/mobile/modules/t3-terminal/scripts/build-libghostty-ios16.sh The script builds Ghostty with Zig 0.15.2, strips the iOS archives, and replaces only the `ios-arm64` and `ios-arm64-simulator` slices. Xcode's Metal toolchain must be installed; if `metal` fails, run `xcodebuild -downloadComponent MetalToolchain`. + +## Rebuilding libghostty-vt for Android + +The checked-in Android shared libraries and headers are pinned to the revision recorded in +`Vendor/libghostty-vt/VERSION`. Set `ANDROID_NDK_HOME` and run: + +```bash +apps/mobile/modules/t3-terminal/scripts/build-libghostty-android.sh +``` + +The script downloads Zig 0.15.2 when needed, checks out the pinned upstream Ghostty revision, and +rebuilds all four Android ABIs with 16 KB page-size support. diff --git a/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md b/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md index 0ed6e1d1487..990dd4bbe9c 100644 --- a/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md +++ b/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md @@ -14,3 +14,21 @@ iOS 16 support fork. That fork was created from VVTerm's custom-I/O Ghostty fork Ghostty's MIT license applies to the vendored framework. Keep this notice in sync when updating `Vendor/libghostty`. + +## Ghostty / libghostty-vt + +The Android terminal renderer vendors upstream `libghostty-vt` shared libraries and C headers. + +- Upstream project: https://github.com/ghostty-org/ghostty +- Vendored revision: `9f62873bf195e4d8a762d768a1405a5f2f7b1697` +- License: MIT + +Ghostty's MIT license applies to the vendored Android libraries. Keep this notice in sync when +updating `Vendor/libghostty-vt`. + +## MesloLGS NF (Android terminal font) + +- Files: `android/src/main/assets/fonts/MesloLGS-NF-{Regular,Bold}.ttf` +- Source: https://github.com/romkatv/powerlevel10k-media (Meslo LG patched with Nerd Fonts glyphs) +- Upstream: Meslo LG by André Berg (customization of Apple's Menlo), Nerd Fonts patcher +- License: Apache License 2.0 diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/LICENSE b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/LICENSE new file mode 100644 index 00000000000..0a07a66cd1a --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/VERSION b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/VERSION new file mode 100644 index 00000000000..aa5d5e7421e --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/VERSION @@ -0,0 +1 @@ +9f62873bf195e4d8a762d768a1405a5f2f7b1697 diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt.h new file mode 100644 index 00000000000..94a85033434 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt.h @@ -0,0 +1,154 @@ +/** + * @file vt.h + * + * libghostty-vt - Virtual terminal emulator library + * + * This library provides functionality for parsing and handling terminal + * escape sequences as well as maintaining terminal state such as styles, + * cursor position, screen, scrollback, and more. + * + * WARNING: This is an incomplete, work-in-progress API. It is not yet + * stable and is definitely going to change. + */ + +/** + * @mainpage libghostty-vt - Virtual Terminal Emulator Library + * + * libghostty-vt is a C library which implements a modern terminal emulator, + * extracted from the [Ghostty](https://ghostty.org) terminal emulator. + * + * libghostty-vt contains the logic for handling the core parts of a terminal + * emulator: parsing terminal escape sequences, maintaining terminal state, + * encoding input events, etc. It can handle scrollback, line wrapping, + * reflow on resize, and more. + * + * @warning This library is currently in development and the API is not yet stable. + * Breaking changes are expected in future versions. Use with caution in production code. + * + * @section groups_sec API Reference + * + * The API is organized into the following groups: + * - @ref terminal "Terminal" - Complete terminal emulator state and rendering + * - @ref render "Render State" - Incremental render state updates for custom renderers + * - @ref formatter "Formatter" - Format terminal content as plain text, VT sequences, or HTML + * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences + * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences + * - @ref paste "Paste Utilities" - Validate paste data safety + * - @ref build_info "Build Info" - Query compile-time build configuration + * - @ref allocator "Memory Management" - Memory management and custom allocators + * - @ref wasm "WebAssembly Utilities" - WebAssembly convenience functions + * + * Encoding related APIs: + * - @ref focus "Focus Encoding" - Encode focus in/out events into terminal sequences + * - @ref key "Key Encoding" - Encode key events into terminal sequences + * - @ref mouse "Mouse Encoding" - Encode mouse events into terminal sequences + * + * @section examples_sec Examples + * + * Complete working examples: + * - @ref c-vt-build-info/src/main.c - Build info query example + * - @ref c-vt/src/main.c - OSC parser example + * - @ref c-vt-encode-key/src/main.c - Key encoding example + * - @ref c-vt-encode-mouse/src/main.c - Mouse encoding example + * - @ref c-vt-paste/src/main.c - Paste safety check example + * - @ref c-vt-sgr/src/main.c - SGR parser example + * - @ref c-vt-formatter/src/main.c - Terminal formatter example + * - @ref c-vt-grid-traverse/src/main.c - Grid traversal example using grid refs + * - @ref c-vt-grid-ref-tracked/src/main.c - Tracked grid ref example + * + */ + +/** @example c-vt-build-info/src/main.c + * This example demonstrates how to query compile-time build configuration + * such as SIMD support, Kitty graphics, and tmux control mode availability. + */ + +/** @example c-vt/src/main.c + * This example demonstrates how to use the OSC parser to parse an OSC sequence, + * extract command information, and retrieve command-specific data like window titles. + */ + +/** @example c-vt-encode-key/src/main.c + * This example demonstrates how to use the key encoder to convert key events + * into terminal escape sequences using the Kitty keyboard protocol. + */ + +/** @example c-vt-encode-mouse/src/main.c + * This example demonstrates how to use the mouse encoder to convert mouse events + * into terminal escape sequences using the SGR mouse format. + */ + +/** @example c-vt-paste/src/main.c + * This example demonstrates how to use the paste utilities to check if + * paste data is safe before sending it to the terminal. + */ + +/** @example c-vt-sgr/src/main.c + * This example demonstrates how to use the SGR parser to parse terminal + * styling sequences and extract text attributes like colors and underline styles. + */ + +/** @example c-vt-formatter/src/main.c + * This example demonstrates how to use the terminal and formatter APIs to + * create a terminal, write VT-encoded content into it, and format the screen + * contents as plain text. + */ + +/** @example c-vt-grid-traverse/src/main.c + * This example demonstrates how to traverse the entire terminal grid using + * grid refs to inspect cell codepoints, row wrap state, and cell styles. + */ + +/** @example c-vt-grid-ref-tracked/src/main.c + * This example demonstrates how to track a grid ref as the terminal scrolls, + * detect when it loses its value, and move it to a new point. + */ + +/** @example c-vt-selection-gesture/src/main.c + * This example demonstrates how to use synthetic selection gesture events to + * derive drag and deep-press selection snapshots. + */ + +/** @example c-vt-kitty-graphics/src/main.c + * This example demonstrates how to use the system interface to install a + * PNG decoder callback and send a Kitty Graphics Protocol image. + */ + +#ifndef GHOSTTY_VT_H +#define GHOSTTY_VT_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/allocator.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/allocator.h new file mode 100644 index 00000000000..2e8685e8445 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/allocator.h @@ -0,0 +1,255 @@ +/** + * @file allocator.h + * + * Memory management interface for libghostty-vt. + */ + +#ifndef GHOSTTY_VT_ALLOCATOR_H +#define GHOSTTY_VT_ALLOCATOR_H + +#include +#include +#include +#include + +/** @defgroup allocator Memory Management + * + * libghostty-vt does require memory allocation for various operations, + * but is resilient to allocation failures and will gracefully handle + * out-of-memory situations by returning error codes. + * + * The exact memory management semantics are documented in the relevant + * functions and data structures. + * + * libghostty-vt uses explicit memory allocation via an allocator + * interface provided by GhosttyAllocator. The interface is based on the + * [Zig](https://ziglang.org) allocator interface, since this has been + * shown to be a flexible and powerful interface in practice and enables + * a wide variety of allocation strategies. + * + * **For the common case, you can pass NULL as the allocator for any + * function that accepts one,** and libghostty will use a default allocator. + * The default allocator will be libc malloc/free if libc is linked. + * Otherwise, a custom allocator is used (currently Zig's SMP allocator) + * that doesn't require any external dependencies. + * + * ## Basic Usage + * + * For simple use cases, you can ignore this interface entirely by passing NULL + * as the allocator parameter to functions that accept one. This will use the + * default allocator (typically libc malloc/free, if libc is linked, but + * we provide our own default allocator if libc isn't linked). + * + * To use a custom allocator: + * 1. Implement the GhosttyAllocatorVtable function pointers + * 2. Create a GhosttyAllocator struct with your vtable and context + * 3. Pass the allocator to functions that accept one + * + * ## Alloc/Free Helpers + * + * ghostty_alloc() and ghostty_free() provide a simple malloc/free-style + * interface for allocating and freeing byte buffers through the library's + * allocator. These are useful when: + * + * - You need to allocate a buffer to pass into a libghostty-vt function + * (e.g. preparing input data for ghostty_terminal_vt_write()). + * - You need to free a buffer returned by a libghostty-vt function + * (e.g. the output of ghostty_formatter_format_alloc()). + * - You are on a platform where the library's internal allocator differs + * from the consumer's C runtime (e.g. Windows, where Zig's libc and + * MSVC's CRT maintain separate heaps), so calling the standard C + * free() on library-allocated memory would be undefined behavior. + * + * Always use the same allocator (or NULL) for both the allocation and + * the corresponding free. + * + * @{ + */ + +/** + * Function table for custom memory allocator operations. + * + * This vtable defines the interface for a custom memory allocator. All + * function pointers must be valid and non-NULL. + * + * @ingroup allocator + * + * If you're not going to use a custom allocator, you can ignore all of + * this. All functions that take an allocator pointer allow NULL to use a + * default allocator. + * + * The interface is based on the Zig allocator interface. I'll say up front + * that it is easy to look at this interface and think "wow, this is really + * overcomplicated". The reason for this complexity is well thought out by + * the Zig folks, and it enables a diverse set of allocation strategies + * as shown by the Zig ecosystem. As a consolation, please note that many + * of the arguments are only needed for advanced use cases and can be + * safely ignored in simple implementations. For example, if you look at + * the Zig implementation of the libc allocator in `lib/std/heap.zig` + * (search for CAllocator), you'll see it is very simple. + * + * We chose to align with the Zig allocator interface because: + * + * 1. It is a proven interface that serves a wide variety of use cases + * in the real world via the Zig ecosystem. It's shown to work. + * + * 2. Our core implementation itself is Zig, and this lets us very + * cheaply and easily convert between C and Zig allocators. + * + * NOTE(mitchellh): In the future, we can have default implementations of + * resize/remap and allow those to be null. + */ +typedef struct { + /** + * Return a pointer to `len` bytes with specified `alignment`, or return + * `NULL` indicating the allocation failed. + * + * @param ctx The allocator context + * @param len Number of bytes to allocate + * @param alignment Required alignment for the allocation. Guaranteed to + * be a power of two between 1 and 16 inclusive. + * @param ret_addr First return address of the allocation call stack (0 if not provided) + * @return Pointer to allocated memory, or NULL if allocation failed + */ + void* (*alloc)(void *ctx, size_t len, uint8_t alignment, uintptr_t ret_addr); + + /** + * Attempt to expand or shrink memory in place. + * + * `memory_len` must equal the length requested from the most recent + * successful call to `alloc`, `resize`, or `remap`. `alignment` must + * equal the same value that was passed as the `alignment` parameter to + * the original `alloc` call. + * + * `new_len` must be greater than zero. + * + * @param ctx The allocator context + * @param memory Pointer to the memory block to resize + * @param memory_len Current size of the memory block + * @param alignment Alignment (must match original allocation) + * @param new_len New requested size + * @param ret_addr First return address of the allocation call stack (0 if not provided) + * @return true if resize was successful in-place, false if relocation would be required + */ + bool (*resize)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr); + + /** + * Attempt to expand or shrink memory, allowing relocation. + * + * `memory_len` must equal the length requested from the most recent + * successful call to `alloc`, `resize`, or `remap`. `alignment` must + * equal the same value that was passed as the `alignment` parameter to + * the original `alloc` call. + * + * A non-`NULL` return value indicates the resize was successful. The + * allocation may have same address, or may have been relocated. In either + * case, the allocation now has size of `new_len`. A `NULL` return value + * indicates that the resize would be equivalent to allocating new memory, + * copying the bytes from the old memory, and then freeing the old memory. + * In such case, it is more efficient for the caller to perform the copy. + * + * `new_len` must be greater than zero. + * + * @param ctx The allocator context + * @param memory Pointer to the memory block to remap + * @param memory_len Current size of the memory block + * @param alignment Alignment (must match original allocation) + * @param new_len New requested size + * @param ret_addr First return address of the allocation call stack (0 if not provided) + * @return Pointer to resized memory (may be relocated), or NULL if manual copy is needed + */ + void* (*remap)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr); + + /** + * Free and invalidate a region of memory. + * + * `memory_len` must equal the length requested from the most recent + * successful call to `alloc`, `resize`, or `remap`. `alignment` must + * equal the same value that was passed as the `alignment` parameter to + * the original `alloc` call. + * + * @param ctx The allocator context + * @param memory Pointer to the memory block to free + * @param memory_len Size of the memory block + * @param alignment Alignment (must match original allocation) + * @param ret_addr First return address of the allocation call stack (0 if not provided) + */ + void (*free)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, uintptr_t ret_addr); +} GhosttyAllocatorVtable; + +/** + * Custom memory allocator. + * + * For functions that take an allocator pointer, a NULL pointer indicates + * that the default allocator should be used. The default allocator will + * be libc malloc/free if we're linking to libc. If libc isn't linked, + * a custom allocator is used (currently Zig's SMP allocator). + * + * @ingroup allocator + * + * Usage example: + * @code + * GhosttyAllocator allocator = { + * .vtable = &my_allocator_vtable, + * .ctx = my_allocator_state + * }; + * @endcode + */ +typedef struct GhosttyAllocator { + /** + * Opaque context pointer passed to all vtable functions. + * This allows the allocator implementation to maintain state + * or reference external resources needed for memory management. + */ + void *ctx; + + /** + * Pointer to the allocator's vtable containing function pointers + * for memory operations (alloc, resize, remap, free). + */ + const GhosttyAllocatorVtable *vtable; +} GhosttyAllocator; + +/** + * Allocate a buffer of `len` bytes. + * + * Uses the provided allocator, or the default allocator if NULL is passed. + * The returned buffer must be freed with ghostty_free() using the same + * allocator. + * + * @param allocator Pointer to the allocator to use, or NULL for the default + * @param len Number of bytes to allocate + * @return Pointer to the allocated buffer, or NULL if allocation failed + * + * @ingroup allocator + */ +GHOSTTY_API uint8_t* ghostty_alloc(const GhosttyAllocator* allocator, size_t len); + +/** + * Free memory that was allocated by a libghostty-vt function. + * + * Use this to free buffers returned by functions such as + * ghostty_formatter_format_alloc(). Pass the same allocator that was + * used for the allocation, or NULL if the default allocator was used. + * + * On platforms where the library's internal allocator differs from the + * consumer's C runtime (e.g. Windows, where Zig's libc and MSVC's CRT + * maintain separate heaps), calling the standard C free() on memory + * allocated by the library causes undefined behavior. This function + * guarantees the correct allocator is used regardless of platform. + * + * It is safe to pass a NULL pointer; the call is a no-op in that case. + * + * @param allocator Pointer to the allocator that was used to allocate the + * memory, or NULL if the default allocator was used + * @param ptr Pointer to the memory to free (may be NULL) + * @param len Length of the allocation in bytes (must match the original + * allocation size) + * + * @ingroup allocator + */ +GHOSTTY_API void ghostty_free(const GhosttyAllocator* allocator, uint8_t* ptr, size_t len); + +/** @} */ + +#endif /* GHOSTTY_VT_ALLOCATOR_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/build_info.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/build_info.h new file mode 100644 index 00000000000..8573556f7f8 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/build_info.h @@ -0,0 +1,150 @@ +/** + * @file build_info.h + * + * Build info - query compile-time build configuration of libghostty-vt. + */ + +#ifndef GHOSTTY_VT_BUILD_INFO_H +#define GHOSTTY_VT_BUILD_INFO_H + +/** @defgroup build_info Build Info + * + * Query compile-time build configuration of libghostty-vt. + * + * These values reflect the options the library was built with and are + * constant for the lifetime of the process. + * + * ## Basic Usage + * + * Use ghostty_build_info() to query individual build options: + * + * @snippet c-vt-build-info/src/main.c build-info-query + * + * @{ + */ + +#include +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Build optimization mode. + */ +typedef enum GHOSTTY_ENUM_TYPED { + GHOSTTY_OPTIMIZE_DEBUG = 0, + GHOSTTY_OPTIMIZE_RELEASE_SAFE = 1, + GHOSTTY_OPTIMIZE_RELEASE_SMALL = 2, + GHOSTTY_OPTIMIZE_RELEASE_FAST = 3, + GHOSTTY_OPTIMIZE_MODE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyOptimizeMode; + +/** + * Build info data types that can be queried. + * + * Each variant documents the expected output pointer type. + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Invalid data type. Never results in any data extraction. */ + GHOSTTY_BUILD_INFO_INVALID = 0, + + /** + * Whether SIMD-accelerated code paths are enabled. + * + * Output type: bool * + */ + GHOSTTY_BUILD_INFO_SIMD = 1, + + /** + * Whether Kitty graphics protocol support is available. + * + * Output type: bool * + */ + GHOSTTY_BUILD_INFO_KITTY_GRAPHICS = 2, + + /** + * Whether tmux control mode support is available. + * + * Output type: bool * + */ + GHOSTTY_BUILD_INFO_TMUX_CONTROL_MODE = 3, + + /** + * The optimization mode the library was built with. + * + * Output type: GhosttyOptimizeMode * + */ + GHOSTTY_BUILD_INFO_OPTIMIZE = 4, + + /** + * The full version string (e.g. "1.2.3" or "1.2.3-dev+abcdef"). + * + * Output type: GhosttyString * + */ + GHOSTTY_BUILD_INFO_VERSION_STRING = 5, + + /** + * The major version number. + * + * Output type: size_t * + */ + GHOSTTY_BUILD_INFO_VERSION_MAJOR = 6, + + /** + * The minor version number. + * + * Output type: size_t * + */ + GHOSTTY_BUILD_INFO_VERSION_MINOR = 7, + + /** + * The patch version number. + * + * Output type: size_t * + */ + GHOSTTY_BUILD_INFO_VERSION_PATCH = 8, + + /** + * The pre metadata string (e.g. "alpha", "beta", "dev"). Has zero length if + * no pre metadata is present. + * + * Output type: GhosttyString * + */ + GHOSTTY_BUILD_INFO_VERSION_PRE = 9, + + /** + * The build metadata string (e.g. commit hash). Has zero length if + * no build metadata is present. + * + * Output type: GhosttyString * + */ + GHOSTTY_BUILD_INFO_VERSION_BUILD = 10, + GHOSTTY_BUILD_INFO_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyBuildInfo; + +/** + * Query a compile-time build configuration value. + * + * The caller must pass a pointer to the correct output type for the + * requested data (see GhosttyBuildInfo variants for types). + * + * @param data The build info field to query + * @param out Pointer to store the result (type depends on data parameter) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the + * data type is invalid + * + * @ingroup build_info + */ +GHOSTTY_API GhosttyResult ghostty_build_info(GhosttyBuildInfo data, void *out); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_BUILD_INFO_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/color.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/color.h new file mode 100644 index 00000000000..9dc21864eb9 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/color.h @@ -0,0 +1,97 @@ +/** + * @file color.h + * + * Color types and utilities. + */ + +#ifndef GHOSTTY_VT_COLOR_H +#define GHOSTTY_VT_COLOR_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * RGB color value. + * + * @ingroup sgr + */ +typedef struct { + uint8_t r; /**< Red component (0-255) */ + uint8_t g; /**< Green component (0-255) */ + uint8_t b; /**< Blue component (0-255) */ +} GhosttyColorRgb; + +/** + * Palette color index (0-255). + * + * @ingroup sgr + */ +typedef uint8_t GhosttyColorPaletteIndex; + +/** @addtogroup sgr + * @{ + */ + +/** Black color (0) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BLACK 0 +/** Red color (1) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_RED 1 +/** Green color (2) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_GREEN 2 +/** Yellow color (3) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_YELLOW 3 +/** Blue color (4) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BLUE 4 +/** Magenta color (5) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_MAGENTA 5 +/** Cyan color (6) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_CYAN 6 +/** White color (7) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_WHITE 7 +/** Bright black color (8) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BRIGHT_BLACK 8 +/** Bright red color (9) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BRIGHT_RED 9 +/** Bright green color (10) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BRIGHT_GREEN 10 +/** Bright yellow color (11) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BRIGHT_YELLOW 11 +/** Bright blue color (12) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BRIGHT_BLUE 12 +/** Bright magenta color (13) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BRIGHT_MAGENTA 13 +/** Bright cyan color (14) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BRIGHT_CYAN 14 +/** Bright white color (15) @ingroup sgr */ +#define GHOSTTY_COLOR_NAMED_BRIGHT_WHITE 15 + +/** @} */ + +/** + * Get the RGB color components. + * + * This function extracts the individual red, green, and blue components + * from a GhosttyColorRgb value. Primarily useful in WebAssembly environments + * where accessing struct fields directly is difficult. + * + * @param color The RGB color value + * @param r Pointer to store the red component (0-255) + * @param g Pointer to store the green component (0-255) + * @param b Pointer to store the blue component (0-255) + * + * @ingroup sgr + */ +GHOSTTY_API void ghostty_color_rgb_get(GhosttyColorRgb color, + uint8_t* r, + uint8_t* g, + uint8_t* b); + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_COLOR_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/device.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/device.h new file mode 100644 index 00000000000..0a1567280b8 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/device.h @@ -0,0 +1,151 @@ +/** + * @file device.h + * + * Device types used by the terminal for device status and device attribute + * queries. + */ + +#ifndef GHOSTTY_VT_DEVICE_H +#define GHOSTTY_VT_DEVICE_H + +#include +#include + +/* DA1 conformance levels (Pp parameter). */ +#define GHOSTTY_DA_CONFORMANCE_VT100 1 +#define GHOSTTY_DA_CONFORMANCE_VT101 1 +#define GHOSTTY_DA_CONFORMANCE_VT102 6 +#define GHOSTTY_DA_CONFORMANCE_VT125 12 +#define GHOSTTY_DA_CONFORMANCE_VT131 7 +#define GHOSTTY_DA_CONFORMANCE_VT132 4 +#define GHOSTTY_DA_CONFORMANCE_VT220 62 +#define GHOSTTY_DA_CONFORMANCE_VT240 62 +#define GHOSTTY_DA_CONFORMANCE_VT320 63 +#define GHOSTTY_DA_CONFORMANCE_VT340 63 +#define GHOSTTY_DA_CONFORMANCE_VT420 64 +#define GHOSTTY_DA_CONFORMANCE_VT510 65 +#define GHOSTTY_DA_CONFORMANCE_VT520 65 +#define GHOSTTY_DA_CONFORMANCE_VT525 65 +#define GHOSTTY_DA_CONFORMANCE_LEVEL_2 62 +#define GHOSTTY_DA_CONFORMANCE_LEVEL_3 63 +#define GHOSTTY_DA_CONFORMANCE_LEVEL_4 64 +#define GHOSTTY_DA_CONFORMANCE_LEVEL_5 65 + +/* DA1 feature codes (Ps parameters). */ +#define GHOSTTY_DA_FEATURE_COLUMNS_132 1 +#define GHOSTTY_DA_FEATURE_PRINTER 2 +#define GHOSTTY_DA_FEATURE_REGIS 3 +#define GHOSTTY_DA_FEATURE_SIXEL 4 +#define GHOSTTY_DA_FEATURE_SELECTIVE_ERASE 6 +#define GHOSTTY_DA_FEATURE_USER_DEFINED_KEYS 8 +#define GHOSTTY_DA_FEATURE_NATIONAL_REPLACEMENT 9 +#define GHOSTTY_DA_FEATURE_TECHNICAL_CHARACTERS 15 +#define GHOSTTY_DA_FEATURE_LOCATOR 16 +#define GHOSTTY_DA_FEATURE_TERMINAL_STATE 17 +#define GHOSTTY_DA_FEATURE_WINDOWING 18 +#define GHOSTTY_DA_FEATURE_HORIZONTAL_SCROLLING 21 +#define GHOSTTY_DA_FEATURE_ANSI_COLOR 22 +#define GHOSTTY_DA_FEATURE_RECTANGULAR_EDITING 28 +#define GHOSTTY_DA_FEATURE_ANSI_TEXT_LOCATOR 29 +#define GHOSTTY_DA_FEATURE_CLIPBOARD 52 + +/* DA2 device type identifiers (Pp parameter). */ +#define GHOSTTY_DA_DEVICE_TYPE_VT100 0 +#define GHOSTTY_DA_DEVICE_TYPE_VT220 1 +#define GHOSTTY_DA_DEVICE_TYPE_VT240 2 +#define GHOSTTY_DA_DEVICE_TYPE_VT330 18 +#define GHOSTTY_DA_DEVICE_TYPE_VT340 19 +#define GHOSTTY_DA_DEVICE_TYPE_VT320 24 +#define GHOSTTY_DA_DEVICE_TYPE_VT382 32 +#define GHOSTTY_DA_DEVICE_TYPE_VT420 41 +#define GHOSTTY_DA_DEVICE_TYPE_VT510 61 +#define GHOSTTY_DA_DEVICE_TYPE_VT520 64 +#define GHOSTTY_DA_DEVICE_TYPE_VT525 65 + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Color scheme reported in response to a CSI ? 996 n query. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + GHOSTTY_COLOR_SCHEME_LIGHT = 0, + GHOSTTY_COLOR_SCHEME_DARK = 1, + GHOSTTY_COLOR_SCHEME_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyColorScheme; + +/** + * Primary device attributes (DA1) response data. + * + * Returned as part of GhosttyDeviceAttributes in response to a CSI c query. + * The conformance_level is the Pp parameter and features contains the Ps + * feature codes. + * + * @ingroup terminal + */ +typedef struct { + /** Conformance level (Pp parameter). E.g. 62 for VT220. */ + uint16_t conformance_level; + + /** DA1 feature codes. Only the first num_features entries are valid. */ + uint16_t features[64]; + + /** Number of valid entries in the features array. */ + size_t num_features; +} GhosttyDeviceAttributesPrimary; + +/** + * Secondary device attributes (DA2) response data. + * + * Returned as part of GhosttyDeviceAttributes in response to a CSI > c query. + * Response format: CSI > Pp ; Pv ; Pc c + * + * @ingroup terminal + */ +typedef struct { + /** Terminal type identifier (Pp). E.g. 1 for VT220. */ + uint16_t device_type; + + /** Firmware/patch version number (Pv). */ + uint16_t firmware_version; + + /** ROM cartridge registration number (Pc). Always 0 for emulators. */ + uint16_t rom_cartridge; +} GhosttyDeviceAttributesSecondary; + +/** + * Tertiary device attributes (DA3) response data. + * + * Returned as part of GhosttyDeviceAttributes in response to a CSI = c query. + * Response format: DCS ! | D...D ST (DECRPTUI). + * + * @ingroup terminal + */ +typedef struct { + /** Unit ID encoded as 8 uppercase hex digits in the response. */ + uint32_t unit_id; +} GhosttyDeviceAttributesTertiary; + +/** + * Device attributes response data for all three DA levels. + * + * Filled by the device_attributes callback in response to CSI c, + * CSI > c, or CSI = c queries. The terminal uses whichever sub-struct + * matches the request type. + * + * @ingroup terminal + */ +typedef struct { + GhosttyDeviceAttributesPrimary primary; + GhosttyDeviceAttributesSecondary secondary; + GhosttyDeviceAttributesTertiary tertiary; +} GhosttyDeviceAttributes; + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_DEVICE_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/focus.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/focus.h new file mode 100644 index 00000000000..b9940f79247 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/focus.h @@ -0,0 +1,76 @@ +/** + * @file focus.h + * + * Focus encoding - encode focus in/out events into terminal escape sequences. + */ + +#ifndef GHOSTTY_VT_FOCUS_H +#define GHOSTTY_VT_FOCUS_H + +/** @defgroup focus Focus Encoding + * + * Utilities for encoding focus gained/lost events into terminal escape + * sequences (CSI I / CSI O) for focus reporting mode (mode 1004). + * + * ## Basic Usage + * + * Use ghostty_focus_encode() to encode a focus event into a caller-provided + * buffer. If the buffer is too small, the function returns + * GHOSTTY_OUT_OF_SPACE and sets the required size in the output parameter. + * + * ## Example + * + * @snippet c-vt-encode-focus/src/main.c focus-encode + * + * @{ + */ + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Focus event types for focus reporting mode (mode 1004). + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Terminal window gained focus */ + GHOSTTY_FOCUS_GAINED = 0, + /** Terminal window lost focus */ + GHOSTTY_FOCUS_LOST = 1, + GHOSTTY_FOCUS_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyFocusEvent; + +/** + * Encode a focus event into a terminal escape sequence. + * + * Encodes a focus gained (CSI I) or focus lost (CSI O) report into the + * provided buffer. + * + * If the buffer is too small, the function returns GHOSTTY_OUT_OF_SPACE + * and writes the required buffer size to @p out_written. The caller can + * then retry with a sufficiently sized buffer. + * + * @param event The focus event to encode + * @param buf Output buffer to write the encoded sequence into (may be NULL) + * @param buf_len Size of the output buffer in bytes + * @param[out] out_written On success, the number of bytes written. On + * GHOSTTY_OUT_OF_SPACE, the required buffer size. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if the buffer + * is too small + */ +GHOSTTY_API GhosttyResult ghostty_focus_encode( + GhosttyFocusEvent event, + char* buf, + size_t buf_len, + size_t* out_written); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_FOCUS_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/formatter.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/formatter.h new file mode 100644 index 00000000000..5cdcd11a3a7 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/formatter.h @@ -0,0 +1,207 @@ +/** + * @file formatter.h + * + * Format terminal content as plain text, VT sequences, or HTML. + */ + +#ifndef GHOSTTY_VT_FORMATTER_H +#define GHOSTTY_VT_FORMATTER_H + +#include +#include +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup formatter Formatter + * + * Format terminal content as plain text, VT sequences, or HTML. + * + * A formatter captures a reference to a terminal and formatting options. + * It can be used repeatedly to produce output that reflects the current + * terminal state at the time of each format call. + * + * The terminal must outlive the formatter. + * + * @{ + */ + +/** + * Extra screen state to include in styled output. + * + * @ingroup formatter + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyFormatterScreenExtra). */ + size_t size; + + /** Emit cursor position using CUP (CSI H). */ + bool cursor; + + /** Emit current SGR style state based on the cursor's active style_id. */ + bool style; + + /** Emit current hyperlink state using OSC 8 sequences. */ + bool hyperlink; + + /** Emit character protection mode using DECSCA. */ + bool protection; + + /** Emit Kitty keyboard protocol state using CSI > u and CSI = sequences. */ + bool kitty_keyboard; + + /** Emit character set designations and invocations. */ + bool charsets; +} GhosttyFormatterScreenExtra; + +/** + * Extra terminal state to include in styled output. + * + * @ingroup formatter + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyFormatterTerminalExtra). */ + size_t size; + + /** Emit the palette using OSC 4 sequences. */ + bool palette; + + /** Emit terminal modes that differ from their defaults using CSI h/l. */ + bool modes; + + /** Emit scrolling region state using DECSTBM and DECSLRM sequences. */ + bool scrolling_region; + + /** Emit tabstop positions by clearing all tabs and setting each one. */ + bool tabstops; + + /** Emit the present working directory using OSC 7. */ + bool pwd; + + /** Emit keyboard modes such as ModifyOtherKeys. */ + bool keyboard; + + /** Screen-level extras. */ + GhosttyFormatterScreenExtra screen; +} GhosttyFormatterTerminalExtra; + +/** + * Options for creating a terminal formatter. + * + * @ingroup formatter + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyFormatterTerminalOptions). */ + size_t size; + + /** Output format to emit. */ + GhosttyFormatterFormat emit; + + /** Whether to unwrap soft-wrapped lines. */ + bool unwrap; + + /** Whether to trim trailing whitespace on non-blank lines. */ + bool trim; + + /** Extra terminal state to include in styled output. */ + GhosttyFormatterTerminalExtra extra; + + /** Optional selection to restrict output to a range. + * If NULL, the entire screen is formatted. */ + const GhosttySelection *selection; +} GhosttyFormatterTerminalOptions; + +/** + * Create a formatter for a terminal's active screen. + * + * The terminal must outlive the formatter. The formatter stores a borrowed + * reference to the terminal and reads its current state on each format call. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param formatter Pointer to store the created formatter handle + * @param terminal The terminal to format (must not be NULL) + * @param options Formatting options + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup formatter + */ +GHOSTTY_API GhosttyResult ghostty_formatter_terminal_new( + const GhosttyAllocator* allocator, + GhosttyFormatter* formatter, + GhosttyTerminal terminal, + GhosttyFormatterTerminalOptions options); + +/** + * Run the formatter and produce output into the caller-provided buffer. + * + * Each call formats the current terminal state. Pass NULL for buf to + * query the required buffer size without writing any output; in that case + * out_written receives the required size and the return value is + * GHOSTTY_OUT_OF_SPACE. + * + * If the buffer is too small, returns GHOSTTY_OUT_OF_SPACE and sets + * out_written to the required size. The caller can then retry with a + * larger buffer. + * + * @param formatter The formatter handle (must not be NULL) + * @param buf Pointer to the output buffer, or NULL to query size + * @param buf_len Length of the output buffer in bytes + * @param out_written Pointer to receive the number of bytes written, + * or the required size on failure + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup formatter + */ +GHOSTTY_API GhosttyResult ghostty_formatter_format_buf(GhosttyFormatter formatter, + uint8_t* buf, + size_t buf_len, + size_t* out_written); + +/** + * Run the formatter and return an allocated buffer with the output. + * + * Each call formats the current terminal state. The buffer is allocated + * using the provided allocator (or the default allocator if NULL). + * The caller is responsible for freeing the returned buffer with + * ghostty_free(), passing the same allocator (or NULL for the default) + * that was used for the allocation. + * + * @param formatter The formatter handle (must not be NULL) + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param out_ptr Pointer to receive the allocated buffer + * @param out_len Pointer to receive the length of the output in bytes + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation + * failure + * + * @ingroup formatter + */ +GHOSTTY_API GhosttyResult ghostty_formatter_format_alloc(GhosttyFormatter formatter, + const GhosttyAllocator* allocator, + uint8_t** out_ptr, + size_t* out_len); + +/** + * Free a formatter instance. + * + * Releases all resources associated with the formatter. After this call, + * the formatter handle becomes invalid. + * + * @param formatter The formatter handle to free (may be NULL) + * + * @ingroup formatter + */ +GHOSTTY_API void ghostty_formatter_free(GhosttyFormatter formatter); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_FORMATTER_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/grid_ref.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/grid_ref.h new file mode 100644 index 00000000000..c43791dc238 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/grid_ref.h @@ -0,0 +1,212 @@ +/** + * @file grid_ref.h + * + * Terminal grid reference type for referencing a resolved position in the + * terminal grid. + */ + +#ifndef GHOSTTY_VT_GRID_REF_H +#define GHOSTTY_VT_GRID_REF_H + +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup grid_ref Grid Reference + * + * A grid reference is a reference to a specific cell position in the + * terminal. Obtain a grid reference from `ghostty_terminal_grid_ref` + * for untracked or `ghostty_terminal_grid_ref_track` for tracked. Untracked + * vs tracked is explained next. + * + * Important: The grid reference APIs are not meant to be used as the core of a render + * loop. They are not built to sustain the framerates needed for rendering large + * screens. Use the render state API for that. + * + * ## Untracked vs Tracked References + * + * ### Untracked Reference + * + * An untracked grid reference is a value type that snapshots a specific + * cell. It is only valid until the next update to the terminal instance. + * There is no guarantee that it will remain valid after any operation, + * even if a seemingly unrelated part of the grid is changed. These are meant + * to be read and have their values cached immediately after obtaining it. + * + * An untracked grid reference has a performance cost in its initial lookup, + * but doesn't affect the ongoing performance of the terminal in any way, + * since it is a one-time snapshot. + * + * ### Tracked Reference + * + * A tracked grid reference follows its cell across normal screen operations. + * For example scrolling, scrollback pruning, resize/reflow, and other + * terminal mutations update the tracked reference automatically. + * + * A tracked reference can still lose its original semantic location. This can + * happen when the underlying grid is reset, pruned, or otherwise discarded in a + * way that cannot be mapped to a meaningful new cell. In that state, + * ghostty_tracked_grid_ref_has_value() returns false and + * ghostty_tracked_grid_ref_snapshot() / ghostty_tracked_grid_ref_point() return + * GHOSTTY_NO_VALUE. The handle remains valid, and callers may move it to a new + * point with ghostty_tracked_grid_ref_set(). + * + * To read cell data from a tracked reference, first snapshot it with + * ghostty_tracked_grid_ref_snapshot(). The returned `GhosttyGridRef` is again + * an untracked reference and follows the same short lifetime rules as any other + * untracked grid reference. + * + * A tracked reference belongs to the terminal screen/page-list that was active + * when it was created or last set. Converting it to a point uses that owning + * screen/page-list, even if the terminal has since switched between primary and + * alternate screens. Calling ghostty_tracked_grid_ref_set() resolves the new + * point against the terminal's currently active screen/page-list and may move + * the tracked reference between screens. + * + * Tracked references are owned by the caller and must be freed with + * ghostty_tracked_grid_ref_free(). If the terminal that created a tracked + * reference is freed first, the handle remains valid only for tracked-grid-ref + * APIs: it reports no value and can still be freed. + * + * Each tracked reference adds bookkeeping to terminal mutations. Use them + * sparingly for long-lived anchors such as selections, search state, marks, + * or application-side bookmarks. + * + * ## Lifetime + * + * An untracked reference is a snapshot. It doesn't need to be freed. + * The safety of accessing the value is documented explicitly above: it + * is only safe to access any data until the next terminal mutating + * operation (including free). + * + * A tracked reference is allocated and must be freed when it is no + * longer needed. A tracked reference may outlive the terminal that created it; + * after terminal free, it reports no value and can still be freed. + * + * ## Examples + * + * @snippet c-vt-grid-traverse/src/main.c grid-ref-traverse + * @snippet c-vt-grid-ref-tracked/src/main.c grid-ref-tracked + * + * @{ + */ + +/** + * A resolved reference to a terminal cell position. + * + * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. + * + * @ingroup grid_ref + */ +typedef struct { + size_t size; + void *node; + uint16_t x; + uint16_t y; +} GhosttyGridRef; + +/** + * Get the cell from a grid reference. + * + * @param ref Pointer to the grid reference + * @param[out] out_cell On success, set to the cell at the ref's position (may be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the ref's + * node is NULL + * + * @ingroup grid_ref + */ +GHOSTTY_API GhosttyResult ghostty_grid_ref_cell(const GhosttyGridRef *ref, + GhosttyCell *out_cell); + +/** + * Get the row from a grid reference. + * + * @param ref Pointer to the grid reference + * @param[out] out_row On success, set to the row at the ref's position (may be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the ref's + * node is NULL + * + * @ingroup grid_ref + */ +GHOSTTY_API GhosttyResult ghostty_grid_ref_row(const GhosttyGridRef *ref, + GhosttyRow *out_row); + +/** + * Get the grapheme cluster codepoints for the cell at the grid reference's + * position. + * + * Writes the full grapheme cluster (the cell's primary codepoint followed by + * any combining codepoints) into the provided buffer. If the cell has no text, + * out_len is set to 0 and GHOSTTY_SUCCESS is returned. + * + * If the buffer is too small (or NULL), the function returns + * GHOSTTY_OUT_OF_SPACE and writes the required number of codepoints to + * out_len. The caller can then retry with a sufficiently sized buffer. + * + * @param ref Pointer to the grid reference + * @param buf Output buffer of uint32_t codepoints (may be NULL) + * @param buf_len Number of uint32_t elements in the buffer + * @param[out] out_len On success, the number of codepoints written. On + * GHOSTTY_OUT_OF_SPACE, the required buffer size in codepoints. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the ref's + * node is NULL, GHOSTTY_OUT_OF_SPACE if the buffer is too small + * + * @ingroup grid_ref + */ +GHOSTTY_API GhosttyResult ghostty_grid_ref_graphemes(const GhosttyGridRef *ref, + uint32_t *buf, + size_t buf_len, + size_t *out_len); + +/** + * Get the hyperlink URI for the cell at the grid reference's position. + * + * Writes the URI bytes into the provided buffer. If the cell has no + * hyperlink, out_len is set to 0 and GHOSTTY_SUCCESS is returned. + * + * If the buffer is too small (or NULL), the function returns + * GHOSTTY_OUT_OF_SPACE and writes the required number of bytes to + * out_len. The caller can then retry with a sufficiently sized buffer. + * + * @param ref Pointer to the grid reference + * @param buf Output buffer for the URI bytes (may be NULL) + * @param buf_len Size of the output buffer in bytes + * @param[out] out_len On success, the number of bytes written. On + * GHOSTTY_OUT_OF_SPACE, the required buffer size in bytes. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the ref's + * node is NULL, GHOSTTY_OUT_OF_SPACE if the buffer is too small + * + * @ingroup grid_ref + */ +GHOSTTY_API GhosttyResult ghostty_grid_ref_hyperlink_uri( + const GhosttyGridRef *ref, + uint8_t *buf, + size_t buf_len, + size_t *out_len); + +/** + * Get the style of the cell at the grid reference's position. + * + * @param ref Pointer to the grid reference + * @param[out] out_style On success, set to the cell's style (may be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the ref's + * node is NULL + * + * @ingroup grid_ref + */ +GHOSTTY_API GhosttyResult ghostty_grid_ref_style(const GhosttyGridRef *ref, + GhosttyStyle *out_style); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_GRID_REF_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/grid_ref_tracked.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/grid_ref_tracked.h new file mode 100644 index 00000000000..b56aefacdb6 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/grid_ref_tracked.h @@ -0,0 +1,139 @@ +/** + * @file grid_ref_tracked.h + * + * Tracked terminal grid references. + */ + +#ifndef GHOSTTY_VT_GRID_REF_TRACKED_H +#define GHOSTTY_VT_GRID_REF_TRACKED_H + +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Tracked grid references are owned grid references that move with the + * terminal. See @ref grid_ref for the full overview of tracked and untracked + * grid reference behavior. + * + * @ingroup grid_ref + */ + +/** + * Free a tracked grid reference. + * + * Passing NULL is allowed and has no effect. A tracked reference may be freed + * after the terminal that created it is freed. + * + * @param ref Tracked grid reference to free. + * + * @ingroup grid_ref + */ +GHOSTTY_API void ghostty_tracked_grid_ref_free(GhosttyTrackedGridRef ref); + +/** + * Return whether a tracked grid reference currently has a meaningful value. + * + * If the terminal that created the tracked reference has been freed, this + * returns false. + * + * @param ref Tracked grid reference. + * @return true if the reference currently has a meaningful value. + * + * @ingroup grid_ref + */ +GHOSTTY_API bool ghostty_tracked_grid_ref_has_value( + GhosttyTrackedGridRef ref); + +/** + * Convert a tracked grid reference to a point in the requested coordinate + * space. + * + * This is the tracked equivalent of ghostty_terminal_point_from_grid_ref(). + * Unlike snapshotting, this does not expose an intermediate untracked + * GhosttyGridRef. + * + * A tracked reference is resolved against the terminal screen/page-list that + * currently owns the reference. If the terminal has switched between primary + * and alternate screens since the reference was created or last set, this may + * be different from the terminal's currently active screen. + * + * If the tracked reference no longer has a meaningful value, this returns + * GHOSTTY_NO_VALUE. GHOSTTY_NO_VALUE is also returned when the reference cannot + * be represented in the requested coordinate space, including after the + * terminal that created the tracked reference has been freed. + * + * @param ref Tracked grid reference. + * @param tag Coordinate space to convert into. + * @param[out] out_point On success, receives the coordinate. May be NULL. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if ref is invalid, + * or GHOSTTY_NO_VALUE if there is no representable value. + * + * @ingroup grid_ref + */ +GHOSTTY_API GhosttyResult ghostty_tracked_grid_ref_point( + GhosttyTrackedGridRef ref, + GhosttyPointTag tag, + GhosttyPointCoordinate *out_point); + +/** + * Move an existing tracked grid reference to a new terminal point. + * + * On success, the tracked reference begins tracking the new point and any prior + * "no value" state is cleared. On GHOSTTY_OUT_OF_MEMORY, the original tracked + * reference is left unchanged. + * + * The terminal must be the same terminal that created the tracked reference. + * The point is resolved against the terminal screen/page-list that is active at + * the time this function is called. If the terminal has switched between + * primary and alternate screens, this may move the tracked reference from one + * screen/page-list to the other. + * + * @param ref Tracked grid reference. + * @param terminal Terminal instance that owns the reference. + * @param point New point to track. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if ref, terminal, + * or point is invalid, or GHOSTTY_OUT_OF_MEMORY if allocation fails. + * + * @ingroup grid_ref + */ +GHOSTTY_API GhosttyResult ghostty_tracked_grid_ref_set( + GhosttyTrackedGridRef ref, + GhosttyTerminal terminal, + GhosttyPoint point); + +/** + * Snapshot a tracked grid reference into a regular GhosttyGridRef. + * + * The returned GhosttyGridRef is an untracked snapshot and has the same + * lifetime rules as ghostty_terminal_grid_ref(): it is only valid until the + * next terminal update. Snapshot immediately before calling + * ghostty_grid_ref_cell(), ghostty_grid_ref_row(), + * ghostty_grid_ref_graphemes(), ghostty_grid_ref_hyperlink_uri(), or + * ghostty_grid_ref_style(). + * + * If the tracked reference no longer has a meaningful value, this returns + * GHOSTTY_NO_VALUE. This includes references whose owning terminal has been + * freed. + * + * @param ref Tracked grid reference. + * @param[out] out_ref On success, receives an untracked snapshot. May be NULL. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if ref is invalid, + * or GHOSTTY_NO_VALUE if the tracked location was discarded. + * + * @ingroup grid_ref + */ +GHOSTTY_API GhosttyResult ghostty_tracked_grid_ref_snapshot( + GhosttyTrackedGridRef ref, + GhosttyGridRef *out_ref); + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_GRID_REF_TRACKED_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/key.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/key.h new file mode 100644 index 00000000000..61b95475357 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/key.h @@ -0,0 +1,73 @@ +/** + * @file key.h + * + * Key encoding module - encode key events into terminal escape sequences. + */ + +#ifndef GHOSTTY_VT_KEY_H +#define GHOSTTY_VT_KEY_H + +/** @defgroup key Key Encoding + * + * Utilities for encoding key events into terminal escape sequences, + * supporting both legacy encoding as well as Kitty Keyboard Protocol. + * + * ## Basic Usage + * + * 1. Create an encoder instance with ghostty_key_encoder_new() + * 2. Configure encoder options with ghostty_key_encoder_setopt() + * or ghostty_key_encoder_setopt_from_terminal() if you have a + * GhosttyTerminal. + * 3. For each key event: + * - Create a key event with ghostty_key_event_new() + * - Set event properties (action, key, modifiers, etc.) + * - Encode with ghostty_key_encoder_encode() + * - Free the event with ghostty_key_event_free() + * - Note: You can also reuse the same key event multiple times by + * changing its properties. + * 4. Free the encoder with ghostty_key_encoder_free() when done + * + * For a complete working example, see example/c-vt-encode-key in the + * repository. + * + * ## Example + * + * @snippet c-vt-encode-key/src/main.c key-encode + * + * ## Example: Encoding with Terminal State + * + * When you have a GhosttyTerminal, you can sync its modes (cursor key + * application, Kitty flags, etc.) into the encoder automatically: + * + * @code{.c} + * // Create a terminal and feed it some VT data that changes modes + * GhosttyTerminal terminal; + * ghostty_terminal_new(NULL, &terminal, + * (GhosttyTerminalOptions){.cols = 80, .rows = 24, .max_scrollback = 0}); + * + * // Application might write data that enables Kitty keyboard protocol, etc. + * ghostty_terminal_vt_write(terminal, vt_data, vt_len); + * + * // Create an encoder and sync its options from the terminal + * GhosttyKeyEncoder encoder; + * ghostty_key_encoder_new(NULL, &encoder); + * ghostty_key_encoder_setopt_from_terminal(encoder, terminal); + * + * // Encode a key event using the terminal-derived options + * char buf[128]; + * size_t written = 0; + * ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); + * + * ghostty_key_encoder_free(encoder); + * ghostty_terminal_free(terminal); + * @endcode + * + * @{ + */ + +#include +#include + +/** @} */ + +#endif /* GHOSTTY_VT_KEY_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/key/encoder.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/key/encoder.h new file mode 100644 index 00000000000..3aeec6597b1 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/key/encoder.h @@ -0,0 +1,255 @@ +/** + * @file encoder.h + * + * Key event encoding to terminal escape sequences. + */ + +#ifndef GHOSTTY_VT_KEY_ENCODER_H +#define GHOSTTY_VT_KEY_ENCODER_H + +#include +#include +#include +#include +#include +#include + +/** + * Opaque handle to a key encoder instance. + * + * This handle represents a key encoder that converts key events into terminal + * escape sequences. + * + * @ingroup key + */ +typedef struct GhosttyKeyEncoderImpl *GhosttyKeyEncoder; + +/** + * Kitty keyboard protocol flags. + * + * Bitflags representing the various modes of the Kitty keyboard protocol. + * These can be combined using bitwise OR operations. Valid values all + * start with `GHOSTTY_KITTY_KEY_`. + * + * @ingroup key + */ +typedef uint8_t GhosttyKittyKeyFlags; + +/** Kitty keyboard protocol disabled (all flags off) */ +#define GHOSTTY_KITTY_KEY_DISABLED 0 + +/** Disambiguate escape codes */ +#define GHOSTTY_KITTY_KEY_DISAMBIGUATE (1 << 0) + +/** Report key press and release events */ +#define GHOSTTY_KITTY_KEY_REPORT_EVENTS (1 << 1) + +/** Report alternate key codes */ +#define GHOSTTY_KITTY_KEY_REPORT_ALTERNATES (1 << 2) + +/** Report all key events including those normally handled by the terminal */ +#define GHOSTTY_KITTY_KEY_REPORT_ALL (1 << 3) + +/** Report associated text with key events */ +#define GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED (1 << 4) + +/** All Kitty keyboard protocol flags enabled */ +#define GHOSTTY_KITTY_KEY_ALL (GHOSTTY_KITTY_KEY_DISAMBIGUATE | GHOSTTY_KITTY_KEY_REPORT_EVENTS | GHOSTTY_KITTY_KEY_REPORT_ALTERNATES | GHOSTTY_KITTY_KEY_REPORT_ALL | GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED) + +/** + * macOS option key behavior. + * + * Determines whether the "option" key on macOS is treated as "alt" or not. + * See the Ghostty `macos-option-as-alt` configuration option for more details. + * + * @ingroup key + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Option key is not treated as alt */ + GHOSTTY_OPTION_AS_ALT_FALSE = 0, + /** Option key is treated as alt */ + GHOSTTY_OPTION_AS_ALT_TRUE = 1, + /** Only left option key is treated as alt */ + GHOSTTY_OPTION_AS_ALT_LEFT = 2, + /** Only right option key is treated as alt */ + GHOSTTY_OPTION_AS_ALT_RIGHT = 3, + GHOSTTY_OPTION_AS_ALT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyOptionAsAlt; + +/** + * Key encoder option identifiers. + * + * These values are used with ghostty_key_encoder_setopt() to configure + * the behavior of the key encoder. + * + * @ingroup key + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Terminal DEC mode 1: cursor key application mode (value: bool) */ + GHOSTTY_KEY_ENCODER_OPT_CURSOR_KEY_APPLICATION = 0, + + /** Terminal DEC mode 66: keypad key application mode (value: bool) */ + GHOSTTY_KEY_ENCODER_OPT_KEYPAD_KEY_APPLICATION = 1, + + /** Terminal DEC mode 1035: ignore keypad with numlock (value: bool) */ + GHOSTTY_KEY_ENCODER_OPT_IGNORE_KEYPAD_WITH_NUMLOCK = 2, + + /** Terminal DEC mode 1036: alt sends escape prefix (value: bool) */ + GHOSTTY_KEY_ENCODER_OPT_ALT_ESC_PREFIX = 3, + + /** xterm modifyOtherKeys mode 2 (value: bool) */ + GHOSTTY_KEY_ENCODER_OPT_MODIFY_OTHER_KEYS_STATE_2 = 4, + + /** Kitty keyboard protocol flags (value: GhosttyKittyKeyFlags bitmask) */ + GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS = 5, + + /** macOS option-as-alt setting (value: GhosttyOptionAsAlt) */ + GHOSTTY_KEY_ENCODER_OPT_MACOS_OPTION_AS_ALT = 6, + + /** Backarrow key mode (value: bool) + * See https://vt100.net/dec/ek-vt3xx-tp-002.pdf page 170 + * If `false` (the default), `backspace` emits 0x7f + * If `true`, `backspace` emits 0x08 + */ + GHOSTTY_KEY_ENCODER_OPT_BACKARROW_KEY_MODE = 7, + + GHOSTTY_KEY_ENCODER_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyKeyEncoderOption; + +/** + * Create a new key encoder instance. + * + * Creates a new key encoder with default options. The encoder can be configured + * using ghostty_key_encoder_setopt() and must be freed using + * ghostty_key_encoder_free() when no longer needed. + * + * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator + * @param encoder Pointer to store the created encoder handle + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup key + */ +GHOSTTY_API GhosttyResult ghostty_key_encoder_new(const GhosttyAllocator *allocator, GhosttyKeyEncoder *encoder); + +/** + * Free a key encoder instance. + * + * Releases all resources associated with the key encoder. After this call, + * the encoder handle becomes invalid and must not be used. + * + * @param encoder The encoder handle to free (may be NULL) + * + * @ingroup key + */ +GHOSTTY_API void ghostty_key_encoder_free(GhosttyKeyEncoder encoder); + +/** + * Set an option on the key encoder. + * + * Configures the behavior of the key encoder. Options control various aspects + * of encoding such as terminal modes (cursor key application mode, keypad mode), + * protocol selection (Kitty keyboard protocol flags), and platform-specific + * behaviors (macOS option-as-alt). + * + * If you are using a terminal instance, you can set the key encoding + * options based on the active terminal state (e.g. legacy vs Kitty mode + * and associated flags) with ghostty_key_encoder_setopt_from_terminal(). + * + * A null pointer value does nothing. It does not reset the value to the + * default. The setopt call will do nothing. + * + * @param encoder The encoder handle, must not be NULL + * @param option The option to set + * @param value Pointer to the value to set (type depends on the option) + * + * @ingroup key + */ +GHOSTTY_API void ghostty_key_encoder_setopt(GhosttyKeyEncoder encoder, GhosttyKeyEncoderOption option, const void *value); + +/** + * Set encoder options from a terminal's current state. + * + * Reads the terminal's current modes and flags and applies them to the + * encoder's options. This sets cursor key application mode, keypad mode, + * alt escape prefix, modifyOtherKeys state, and Kitty keyboard protocol + * flags from the terminal state. + * + * Note that the `macos_option_as_alt` option cannot be determined from + * terminal state and is reset to `GHOSTTY_OPTION_AS_ALT_FALSE` by this + * call. Use ghostty_key_encoder_setopt() to set it afterward if needed. + * + * @param encoder The encoder handle, must not be NULL + * @param terminal The terminal handle, must not be NULL + * + * @ingroup key + */ +GHOSTTY_API void ghostty_key_encoder_setopt_from_terminal(GhosttyKeyEncoder encoder, GhosttyTerminal terminal); + +/** + * Encode a key event into a terminal escape sequence. + * + * Converts a key event into the appropriate terminal escape sequence based on + * the encoder's current options. The sequence is written to the provided buffer. + * + * Not all key events produce output. For example, unmodified modifier keys + * typically don't generate escape sequences. Check the out_len parameter to + * determine if any data was written. + * + * If the output buffer is too small, this function returns GHOSTTY_OUT_OF_SPACE + * and out_len will contain the required buffer size. The caller can then + * allocate a larger buffer and call the function again. + * + * @param encoder The encoder handle, must not be NULL + * @param event The key event to encode, must not be NULL + * @param out_buf Buffer to write the encoded sequence to + * @param out_buf_size Size of the output buffer in bytes + * @param out_len Pointer to store the number of bytes written (may be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if buffer too small, or other error code + * + * ## Example: Calculate required buffer size + * + * @code{.c} + * // Query the required size with a NULL buffer (always returns OUT_OF_SPACE) + * size_t required = 0; + * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, NULL, 0, &required); + * assert(result == GHOSTTY_OUT_OF_SPACE); + * + * // Allocate buffer of required size + * char *buf = malloc(required); + * + * // Encode with properly sized buffer + * size_t written = 0; + * result = ghostty_key_encoder_encode(encoder, event, buf, required, &written); + * assert(result == GHOSTTY_SUCCESS); + * + * // Use the encoded sequence... + * + * free(buf); + * @endcode + * + * ## Example: Direct encoding with static buffer + * + * @code{.c} + * // Most escape sequences are short, so a static buffer often suffices + * char buf[128]; + * size_t written = 0; + * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); + * + * if (result == GHOSTTY_SUCCESS) { + * // Write the encoded sequence to the terminal + * write(pty_fd, buf, written); + * } else if (result == GHOSTTY_OUT_OF_SPACE) { + * // Buffer too small, written contains required size + * char *dynamic_buf = malloc(written); + * result = ghostty_key_encoder_encode(encoder, event, dynamic_buf, written, &written); + * assert(result == GHOSTTY_SUCCESS); + * write(pty_fd, dynamic_buf, written); + * free(dynamic_buf); + * } + * @endcode + * + * @ingroup key + */ +GHOSTTY_API GhosttyResult ghostty_key_encoder_encode(GhosttyKeyEncoder encoder, GhosttyKeyEvent event, char *out_buf, size_t out_buf_size, size_t *out_len); + +#endif /* GHOSTTY_VT_KEY_ENCODER_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/key/event.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/key/event.h new file mode 100644 index 00000000000..eba433c6a55 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/key/event.h @@ -0,0 +1,482 @@ +/** + * @file event.h + * + * Key event representation and manipulation. + */ + +#ifndef GHOSTTY_VT_KEY_EVENT_H +#define GHOSTTY_VT_KEY_EVENT_H + +#include +#include +#include +#include +#include + +/** + * Opaque handle to a key event. + * + * This handle represents a keyboard input event containing information about + * the physical key pressed, modifiers, and generated text. + * + * @ingroup key + */ +typedef struct GhosttyKeyEventImpl *GhosttyKeyEvent; + +/** + * Keyboard input event types. + * + * @ingroup key + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Key was released */ + GHOSTTY_KEY_ACTION_RELEASE = 0, + /** Key was pressed */ + GHOSTTY_KEY_ACTION_PRESS = 1, + /** Key is being repeated (held down) */ + GHOSTTY_KEY_ACTION_REPEAT = 2, + GHOSTTY_KEY_ACTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyKeyAction; + +/** + * Keyboard modifier keys bitmask. + * + * A bitmask representing all keyboard modifiers. This tracks which modifier keys + * are pressed and, where supported by the platform, which side (left or right) + * of each modifier is active. + * + * Use the GHOSTTY_MODS_* constants to test and set individual modifiers. + * + * Modifier side bits are only meaningful when the corresponding modifier bit is set. + * Not all platforms support distinguishing between left and right modifier + * keys and Ghostty is built to expect that some platforms may not provide this + * information. + * + * @ingroup key + */ +typedef uint16_t GhosttyMods; + +/** Shift key is pressed */ +#define GHOSTTY_MODS_SHIFT (1 << 0) +/** Control key is pressed */ +#define GHOSTTY_MODS_CTRL (1 << 1) +/** Alt/Option key is pressed */ +#define GHOSTTY_MODS_ALT (1 << 2) +/** Super/Command/Windows key is pressed */ +#define GHOSTTY_MODS_SUPER (1 << 3) +/** Caps Lock is active */ +#define GHOSTTY_MODS_CAPS_LOCK (1 << 4) +/** Num Lock is active */ +#define GHOSTTY_MODS_NUM_LOCK (1 << 5) + +/** + * Right shift is pressed (0 = left, 1 = right). + * Only meaningful when GHOSTTY_MODS_SHIFT is set. + */ +#define GHOSTTY_MODS_SHIFT_SIDE (1 << 6) +/** + * Right ctrl is pressed (0 = left, 1 = right). + * Only meaningful when GHOSTTY_MODS_CTRL is set. + */ +#define GHOSTTY_MODS_CTRL_SIDE (1 << 7) +/** + * Right alt is pressed (0 = left, 1 = right). + * Only meaningful when GHOSTTY_MODS_ALT is set. + */ +#define GHOSTTY_MODS_ALT_SIDE (1 << 8) +/** + * Right super is pressed (0 = left, 1 = right). + * Only meaningful when GHOSTTY_MODS_SUPER is set. + */ +#define GHOSTTY_MODS_SUPER_SIDE (1 << 9) + +/** + * Physical key codes. + * + * The set of key codes that Ghostty is aware of. These represent physical keys + * on the keyboard and are layout-independent. For example, the "a" key on a US + * keyboard is the same as the "ф" key on a Russian keyboard, but both will + * report the same key_a value. + * + * Layout-dependent strings are provided separately as UTF-8 text and are produced + * by the platform. These values are based on the W3C UI Events KeyboardEvent code + * standard. See: https://www.w3.org/TR/uievents-code + * + * @ingroup key + */ +typedef enum GHOSTTY_ENUM_TYPED { + GHOSTTY_KEY_UNIDENTIFIED = 0, + + // Writing System Keys (W3C § 3.1.1) + GHOSTTY_KEY_BACKQUOTE, + GHOSTTY_KEY_BACKSLASH, + GHOSTTY_KEY_BRACKET_LEFT, + GHOSTTY_KEY_BRACKET_RIGHT, + GHOSTTY_KEY_COMMA, + GHOSTTY_KEY_DIGIT_0, + GHOSTTY_KEY_DIGIT_1, + GHOSTTY_KEY_DIGIT_2, + GHOSTTY_KEY_DIGIT_3, + GHOSTTY_KEY_DIGIT_4, + GHOSTTY_KEY_DIGIT_5, + GHOSTTY_KEY_DIGIT_6, + GHOSTTY_KEY_DIGIT_7, + GHOSTTY_KEY_DIGIT_8, + GHOSTTY_KEY_DIGIT_9, + GHOSTTY_KEY_EQUAL, + GHOSTTY_KEY_INTL_BACKSLASH, + GHOSTTY_KEY_INTL_RO, + GHOSTTY_KEY_INTL_YEN, + GHOSTTY_KEY_A, + GHOSTTY_KEY_B, + GHOSTTY_KEY_C, + GHOSTTY_KEY_D, + GHOSTTY_KEY_E, + GHOSTTY_KEY_F, + GHOSTTY_KEY_G, + GHOSTTY_KEY_H, + GHOSTTY_KEY_I, + GHOSTTY_KEY_J, + GHOSTTY_KEY_K, + GHOSTTY_KEY_L, + GHOSTTY_KEY_M, + GHOSTTY_KEY_N, + GHOSTTY_KEY_O, + GHOSTTY_KEY_P, + GHOSTTY_KEY_Q, + GHOSTTY_KEY_R, + GHOSTTY_KEY_S, + GHOSTTY_KEY_T, + GHOSTTY_KEY_U, + GHOSTTY_KEY_V, + GHOSTTY_KEY_W, + GHOSTTY_KEY_X, + GHOSTTY_KEY_Y, + GHOSTTY_KEY_Z, + GHOSTTY_KEY_MINUS, + GHOSTTY_KEY_PERIOD, + GHOSTTY_KEY_QUOTE, + GHOSTTY_KEY_SEMICOLON, + GHOSTTY_KEY_SLASH, + + // Functional Keys (W3C § 3.1.2) + GHOSTTY_KEY_ALT_LEFT, + GHOSTTY_KEY_ALT_RIGHT, + GHOSTTY_KEY_BACKSPACE, + GHOSTTY_KEY_CAPS_LOCK, + GHOSTTY_KEY_CONTEXT_MENU, + GHOSTTY_KEY_CONTROL_LEFT, + GHOSTTY_KEY_CONTROL_RIGHT, + GHOSTTY_KEY_ENTER, + GHOSTTY_KEY_META_LEFT, + GHOSTTY_KEY_META_RIGHT, + GHOSTTY_KEY_SHIFT_LEFT, + GHOSTTY_KEY_SHIFT_RIGHT, + GHOSTTY_KEY_SPACE, + GHOSTTY_KEY_TAB, + GHOSTTY_KEY_CONVERT, + GHOSTTY_KEY_KANA_MODE, + GHOSTTY_KEY_NON_CONVERT, + + // Control Pad Section (W3C § 3.2) + GHOSTTY_KEY_DELETE, + GHOSTTY_KEY_END, + GHOSTTY_KEY_HELP, + GHOSTTY_KEY_HOME, + GHOSTTY_KEY_INSERT, + GHOSTTY_KEY_PAGE_DOWN, + GHOSTTY_KEY_PAGE_UP, + + // Arrow Pad Section (W3C § 3.3) + GHOSTTY_KEY_ARROW_DOWN, + GHOSTTY_KEY_ARROW_LEFT, + GHOSTTY_KEY_ARROW_RIGHT, + GHOSTTY_KEY_ARROW_UP, + + // Numpad Section (W3C § 3.4) + GHOSTTY_KEY_NUM_LOCK, + GHOSTTY_KEY_NUMPAD_0, + GHOSTTY_KEY_NUMPAD_1, + GHOSTTY_KEY_NUMPAD_2, + GHOSTTY_KEY_NUMPAD_3, + GHOSTTY_KEY_NUMPAD_4, + GHOSTTY_KEY_NUMPAD_5, + GHOSTTY_KEY_NUMPAD_6, + GHOSTTY_KEY_NUMPAD_7, + GHOSTTY_KEY_NUMPAD_8, + GHOSTTY_KEY_NUMPAD_9, + GHOSTTY_KEY_NUMPAD_ADD, + GHOSTTY_KEY_NUMPAD_BACKSPACE, + GHOSTTY_KEY_NUMPAD_CLEAR, + GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY, + GHOSTTY_KEY_NUMPAD_COMMA, + GHOSTTY_KEY_NUMPAD_DECIMAL, + GHOSTTY_KEY_NUMPAD_DIVIDE, + GHOSTTY_KEY_NUMPAD_ENTER, + GHOSTTY_KEY_NUMPAD_EQUAL, + GHOSTTY_KEY_NUMPAD_MEMORY_ADD, + GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR, + GHOSTTY_KEY_NUMPAD_MEMORY_RECALL, + GHOSTTY_KEY_NUMPAD_MEMORY_STORE, + GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT, + GHOSTTY_KEY_NUMPAD_MULTIPLY, + GHOSTTY_KEY_NUMPAD_PAREN_LEFT, + GHOSTTY_KEY_NUMPAD_PAREN_RIGHT, + GHOSTTY_KEY_NUMPAD_SUBTRACT, + GHOSTTY_KEY_NUMPAD_SEPARATOR, + GHOSTTY_KEY_NUMPAD_UP, + GHOSTTY_KEY_NUMPAD_DOWN, + GHOSTTY_KEY_NUMPAD_RIGHT, + GHOSTTY_KEY_NUMPAD_LEFT, + GHOSTTY_KEY_NUMPAD_BEGIN, + GHOSTTY_KEY_NUMPAD_HOME, + GHOSTTY_KEY_NUMPAD_END, + GHOSTTY_KEY_NUMPAD_INSERT, + GHOSTTY_KEY_NUMPAD_DELETE, + GHOSTTY_KEY_NUMPAD_PAGE_UP, + GHOSTTY_KEY_NUMPAD_PAGE_DOWN, + + // Function Section (W3C § 3.5) + GHOSTTY_KEY_ESCAPE, + GHOSTTY_KEY_F1, + GHOSTTY_KEY_F2, + GHOSTTY_KEY_F3, + GHOSTTY_KEY_F4, + GHOSTTY_KEY_F5, + GHOSTTY_KEY_F6, + GHOSTTY_KEY_F7, + GHOSTTY_KEY_F8, + GHOSTTY_KEY_F9, + GHOSTTY_KEY_F10, + GHOSTTY_KEY_F11, + GHOSTTY_KEY_F12, + GHOSTTY_KEY_F13, + GHOSTTY_KEY_F14, + GHOSTTY_KEY_F15, + GHOSTTY_KEY_F16, + GHOSTTY_KEY_F17, + GHOSTTY_KEY_F18, + GHOSTTY_KEY_F19, + GHOSTTY_KEY_F20, + GHOSTTY_KEY_F21, + GHOSTTY_KEY_F22, + GHOSTTY_KEY_F23, + GHOSTTY_KEY_F24, + GHOSTTY_KEY_F25, + GHOSTTY_KEY_FN, + GHOSTTY_KEY_FN_LOCK, + GHOSTTY_KEY_PRINT_SCREEN, + GHOSTTY_KEY_SCROLL_LOCK, + GHOSTTY_KEY_PAUSE, + + // Media Keys (W3C § 3.6) + GHOSTTY_KEY_BROWSER_BACK, + GHOSTTY_KEY_BROWSER_FAVORITES, + GHOSTTY_KEY_BROWSER_FORWARD, + GHOSTTY_KEY_BROWSER_HOME, + GHOSTTY_KEY_BROWSER_REFRESH, + GHOSTTY_KEY_BROWSER_SEARCH, + GHOSTTY_KEY_BROWSER_STOP, + GHOSTTY_KEY_EJECT, + GHOSTTY_KEY_LAUNCH_APP_1, + GHOSTTY_KEY_LAUNCH_APP_2, + GHOSTTY_KEY_LAUNCH_MAIL, + GHOSTTY_KEY_MEDIA_PLAY_PAUSE, + GHOSTTY_KEY_MEDIA_SELECT, + GHOSTTY_KEY_MEDIA_STOP, + GHOSTTY_KEY_MEDIA_TRACK_NEXT, + GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS, + GHOSTTY_KEY_POWER, + GHOSTTY_KEY_SLEEP, + GHOSTTY_KEY_AUDIO_VOLUME_DOWN, + GHOSTTY_KEY_AUDIO_VOLUME_MUTE, + GHOSTTY_KEY_AUDIO_VOLUME_UP, + GHOSTTY_KEY_WAKE_UP, + + // Legacy, Non-standard, and Special Keys (W3C § 3.7) + GHOSTTY_KEY_COPY, + GHOSTTY_KEY_CUT, + GHOSTTY_KEY_PASTE, + GHOSTTY_KEY_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyKey; + +/** + * Create a new key event instance. + * + * Creates a new key event with default values. The event must be freed using + * ghostty_key_event_free() when no longer needed. + * + * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator + * @param event Pointer to store the created key event handle + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup key + */ +GHOSTTY_API GhosttyResult ghostty_key_event_new(const GhosttyAllocator *allocator, GhosttyKeyEvent *event); + +/** + * Free a key event instance. + * + * Releases all resources associated with the key event. After this call, + * the event handle becomes invalid and must not be used. + * + * @param event The key event handle to free (may be NULL) + * + * @ingroup key + */ +GHOSTTY_API void ghostty_key_event_free(GhosttyKeyEvent event); + +/** + * Set the key action (press, release, repeat). + * + * @param event The key event handle, must not be NULL + * @param action The action to set + * + * @ingroup key + */ +GHOSTTY_API void ghostty_key_event_set_action(GhosttyKeyEvent event, GhosttyKeyAction action); + +/** + * Get the key action (press, release, repeat). + * + * @param event The key event handle, must not be NULL + * @return The key action + * + * @ingroup key + */ +GHOSTTY_API GhosttyKeyAction ghostty_key_event_get_action(GhosttyKeyEvent event); + +/** + * Set the physical key code. + * + * @param event The key event handle, must not be NULL + * @param key The physical key code to set + * + * @ingroup key + */ +GHOSTTY_API void ghostty_key_event_set_key(GhosttyKeyEvent event, GhosttyKey key); + +/** + * Get the physical key code. + * + * @param event The key event handle, must not be NULL + * @return The physical key code + * + * @ingroup key + */ +GHOSTTY_API GhosttyKey ghostty_key_event_get_key(GhosttyKeyEvent event); + +/** + * Set the modifier keys bitmask. + * + * @param event The key event handle, must not be NULL + * @param mods The modifier keys bitmask to set + * + * @ingroup key + */ +GHOSTTY_API void ghostty_key_event_set_mods(GhosttyKeyEvent event, GhosttyMods mods); + +/** + * Get the modifier keys bitmask. + * + * @param event The key event handle, must not be NULL + * @return The modifier keys bitmask + * + * @ingroup key + */ +GHOSTTY_API GhosttyMods ghostty_key_event_get_mods(GhosttyKeyEvent event); + +/** + * Set the consumed modifiers bitmask. + * + * @param event The key event handle, must not be NULL + * @param consumed_mods The consumed modifiers bitmask to set + * + * @ingroup key + */ +GHOSTTY_API void ghostty_key_event_set_consumed_mods(GhosttyKeyEvent event, GhosttyMods consumed_mods); + +/** + * Get the consumed modifiers bitmask. + * + * @param event The key event handle, must not be NULL + * @return The consumed modifiers bitmask + * + * @ingroup key + */ +GHOSTTY_API GhosttyMods ghostty_key_event_get_consumed_mods(GhosttyKeyEvent event); + +/** + * Set whether the key event is part of a composition sequence. + * + * @param event The key event handle, must not be NULL + * @param composing Whether the key event is part of a composition sequence + * + * @ingroup key + */ +GHOSTTY_API void ghostty_key_event_set_composing(GhosttyKeyEvent event, bool composing); + +/** + * Get whether the key event is part of a composition sequence. + * + * @param event The key event handle, must not be NULL + * @return Whether the key event is part of a composition sequence + * + * @ingroup key + */ +GHOSTTY_API bool ghostty_key_event_get_composing(GhosttyKeyEvent event); + +/** + * Set the UTF-8 text generated by the key for the current keyboard layout. + * + * Must contain the unmodified character before any Ctrl/Meta transformations. + * The encoder derives modifier sequences from the logical key and mods + * bitmask, not from this text. Do not pass C0 control characters + * (U+0000-U+001F, U+007F) or platform function key codes (e.g. macOS PUA + * U+F700-U+F8FF); pass NULL instead and let the encoder use the logical key. + * + * The key event does NOT take ownership of the text pointer. The caller + * must ensure the string remains valid for the lifetime needed by the event. + * + * @param event The key event handle, must not be NULL + * @param utf8 The UTF-8 text to set (or NULL for empty) + * @param len Length of the UTF-8 text in bytes + * + * @ingroup key + */ +GHOSTTY_API void ghostty_key_event_set_utf8(GhosttyKeyEvent event, const char *utf8, size_t len); + +/** + * Get the UTF-8 text generated by the key event. + * + * The returned pointer is valid until the event is freed or the UTF-8 text is modified. + * + * @param event The key event handle, must not be NULL + * @param len Pointer to store the length of the UTF-8 text in bytes (may be NULL) + * @return The UTF-8 text (or NULL for empty) + * + * @ingroup key + */ +GHOSTTY_API const char *ghostty_key_event_get_utf8(GhosttyKeyEvent event, size_t *len); + +/** + * Set the unshifted Unicode codepoint. + * + * @param event The key event handle, must not be NULL + * @param codepoint The unshifted Unicode codepoint to set + * + * @ingroup key + */ +GHOSTTY_API void ghostty_key_event_set_unshifted_codepoint(GhosttyKeyEvent event, uint32_t codepoint); + +/** + * Get the unshifted Unicode codepoint. + * + * @param event The key event handle, must not be NULL + * @return The unshifted Unicode codepoint + * + * @ingroup key + */ +GHOSTTY_API uint32_t ghostty_key_event_get_unshifted_codepoint(GhosttyKeyEvent event); + +#endif /* GHOSTTY_VT_KEY_EVENT_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h new file mode 100644 index 00000000000..9bace3a3ccf --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h @@ -0,0 +1,775 @@ +/** + * @file kitty_graphics.h + * + * Kitty graphics protocol + * + * See @ref kitty_graphics for a full usage guide. + */ + +#ifndef GHOSTTY_VT_KITTY_GRAPHICS_H +#define GHOSTTY_VT_KITTY_GRAPHICS_H + +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup kitty_graphics Kitty Graphics + * + * API for inspecting images and placements stored via the + * [Kitty graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/). + * + * The central object is @ref GhosttyKittyGraphics, an opaque handle to + * the image storage associated with a terminal's active screen. From it + * you can iterate over placements and look up individual images. + * + * ## Obtaining a KittyGraphics Handle + * + * A @ref GhosttyKittyGraphics handle is obtained from a terminal via + * ghostty_terminal_get() with @ref GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS. + * The handle is borrowed from the terminal and remains valid until the + * next mutating terminal call (e.g. ghostty_terminal_vt_write() or + * ghostty_terminal_reset()). + * + * Before images can be stored, Kitty graphics must be enabled on the + * terminal by setting a non-zero storage limit with + * @ref GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_STORAGE_LIMIT, and a PNG + * decoder callback must be installed via ghostty_sys_set() with + * @ref GHOSTTY_SYS_OPT_DECODE_PNG. + * + * @snippet c-vt-kitty-graphics/src/main.c kitty-graphics-decode-png + * + * ## Iterating Placements + * + * Placements are inspected through a @ref GhosttyKittyGraphicsPlacementIterator. + * The typical workflow is: + * + * 1. Create an iterator with ghostty_kitty_graphics_placement_iterator_new(). + * 2. Populate it from the storage with ghostty_kitty_graphics_get() using + * @ref GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR. + * 3. Optionally filter by z-layer with + * ghostty_kitty_graphics_placement_iterator_set(). + * 4. Advance with ghostty_kitty_graphics_placement_next() and read + * per-placement data with ghostty_kitty_graphics_placement_get(). + * 5. For each placement, look up its image with + * ghostty_kitty_graphics_image() to access pixel data and dimensions. + * 6. Free the iterator with ghostty_kitty_graphics_placement_iterator_free(). + * + * ## Looking Up Images + * + * Given an image ID (obtained from a placement via + * @ref GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID), call + * ghostty_kitty_graphics_image() to get a @ref GhosttyKittyGraphicsImage + * handle. From this handle, ghostty_kitty_graphics_image_get() provides + * the image dimensions, pixel format, compression, and a borrowed pointer + * to the raw pixel data. + * + * ## Rendering Helpers + * + * Several functions assist with rendering a placement: + * + * - ghostty_kitty_graphics_placement_pixel_size() — rendered pixel + * dimensions accounting for source rect and aspect ratio. + * - ghostty_kitty_graphics_placement_grid_size() — number of grid + * columns and rows the placement occupies. + * - ghostty_kitty_graphics_placement_viewport_pos() — viewport-relative + * grid position (may be negative for partially scrolled placements). + * - ghostty_kitty_graphics_placement_source_rect() — resolved source + * rectangle in pixels, clamped to image bounds. + * - ghostty_kitty_graphics_placement_rect() — bounding rectangle as a + * @ref GhosttySelection. + * + * ## Lifetime and Thread Safety + * + * All handles borrowed from the terminal (GhosttyKittyGraphics, + * GhosttyKittyGraphicsImage) are invalidated by any mutating terminal + * call. The placement iterator is independently owned and must be freed + * by the caller, but the data it yields is only valid while the + * underlying terminal is not mutated. + * + * ## Example + * + * The following example creates a terminal, sends a Kitty graphics + * image, then iterates placements and prints image metadata: + * + * @snippet c-vt-kitty-graphics/src/main.c kitty-graphics-main + * + * @{ + */ + +/** + * Queryable data kinds for ghostty_kitty_graphics_get(). + * + * @ingroup kitty_graphics + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Invalid / sentinel value. */ + GHOSTTY_KITTY_GRAPHICS_DATA_INVALID = 0, + + /** + * Populate a pre-allocated placement iterator with placement data from + * the storage. Iterator data is only valid as long as the underlying + * terminal is not mutated. + * + * Output type: GhosttyKittyGraphicsPlacementIterator * + */ + GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR = 1, + GHOSTTY_KITTY_GRAPHICS_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyKittyGraphicsData; + +/** + * Queryable data kinds for ghostty_kitty_graphics_placement_get(). + * + * @ingroup kitty_graphics + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Invalid / sentinel value. */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_INVALID = 0, + + /** + * The image ID this placement belongs to. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID = 1, + + /** + * The placement ID. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_PLACEMENT_ID = 2, + + /** + * Whether this is a virtual placement (unicode placeholder). + * + * Output type: bool * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IS_VIRTUAL = 3, + + /** + * Pixel offset from the left edge of the cell. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_X_OFFSET = 4, + + /** + * Pixel offset from the top edge of the cell. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Y_OFFSET = 5, + + /** + * Source rectangle x origin in pixels. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_SOURCE_X = 6, + + /** + * Source rectangle y origin in pixels. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_SOURCE_Y = 7, + + /** + * Source rectangle width in pixels (0 = full image width). + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_SOURCE_WIDTH = 8, + + /** + * Source rectangle height in pixels (0 = full image height). + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_SOURCE_HEIGHT = 9, + + /** + * Number of columns this placement occupies. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_COLUMNS = 10, + + /** + * Number of rows this placement occupies. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_ROWS = 11, + + /** + * Z-index for this placement. + * + * Output type: int32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Z = 12, + + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyKittyGraphicsPlacementData; + +/** + * Z-layer classification for kitty graphics placements. + * + * Based on the kitty protocol z-index conventions: + * - BELOW_BG: z < INT32_MIN/2 (drawn below cell background) + * - BELOW_TEXT: INT32_MIN/2 <= z < 0 (above background, below text) + * - ABOVE_TEXT: z >= 0 (above text) + * - ALL: no filtering (current behavior) + * + * @ingroup kitty_graphics + */ +typedef enum GHOSTTY_ENUM_TYPED { + GHOSTTY_KITTY_PLACEMENT_LAYER_ALL = 0, + GHOSTTY_KITTY_PLACEMENT_LAYER_BELOW_BG = 1, + GHOSTTY_KITTY_PLACEMENT_LAYER_BELOW_TEXT = 2, + GHOSTTY_KITTY_PLACEMENT_LAYER_ABOVE_TEXT = 3, + GHOSTTY_KITTY_PLACEMENT_LAYER_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyKittyPlacementLayer; + +/** + * Settable options for ghostty_kitty_graphics_placement_iterator_set(). + * + * @ingroup kitty_graphics + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** + * Set the z-layer filter for the iterator. + * + * Input type: GhosttyKittyPlacementLayer * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_ITERATOR_OPTION_LAYER = 0, + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_ITERATOR_OPTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyKittyGraphicsPlacementIteratorOption; + +/** + * Pixel format of a Kitty graphics image. + * + * @ingroup kitty_graphics + */ +typedef enum GHOSTTY_ENUM_TYPED { + GHOSTTY_KITTY_IMAGE_FORMAT_RGB = 0, + GHOSTTY_KITTY_IMAGE_FORMAT_RGBA = 1, + GHOSTTY_KITTY_IMAGE_FORMAT_PNG = 2, + GHOSTTY_KITTY_IMAGE_FORMAT_GRAY_ALPHA = 3, + GHOSTTY_KITTY_IMAGE_FORMAT_GRAY = 4, + GHOSTTY_KITTY_IMAGE_FORMAT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyKittyImageFormat; + +/** + * Compression of a Kitty graphics image. + * + * @ingroup kitty_graphics + */ +typedef enum GHOSTTY_ENUM_TYPED { + GHOSTTY_KITTY_IMAGE_COMPRESSION_NONE = 0, + GHOSTTY_KITTY_IMAGE_COMPRESSION_ZLIB_DEFLATE = 1, + GHOSTTY_KITTY_IMAGE_COMPRESSION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyKittyImageCompression; + +/** + * Queryable data kinds for ghostty_kitty_graphics_image_get(). + * + * @ingroup kitty_graphics + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Invalid / sentinel value. */ + GHOSTTY_KITTY_IMAGE_DATA_INVALID = 0, + + /** + * The image ID. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_IMAGE_DATA_ID = 1, + + /** + * The image number. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_IMAGE_DATA_NUMBER = 2, + + /** + * Image width in pixels. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_IMAGE_DATA_WIDTH = 3, + + /** + * Image height in pixels. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_IMAGE_DATA_HEIGHT = 4, + + /** + * Pixel format of the image. + * + * Output type: GhosttyKittyImageFormat * + */ + GHOSTTY_KITTY_IMAGE_DATA_FORMAT = 5, + + /** + * Compression of the image. + * + * Output type: GhosttyKittyImageCompression * + */ + GHOSTTY_KITTY_IMAGE_DATA_COMPRESSION = 6, + + /** + * Borrowed pointer to the raw pixel data. Valid as long as the + * underlying terminal is not mutated. + * + * Output type: const uint8_t ** + */ + GHOSTTY_KITTY_IMAGE_DATA_DATA_PTR = 7, + + /** + * Length of the raw pixel data in bytes. + * + * Output type: size_t * + */ + GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN = 8, + + GHOSTTY_KITTY_IMAGE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyKittyGraphicsImageData; + +/** + * Combined rendering geometry for a placement in a single sized struct. + * + * Combines the results of ghostty_kitty_graphics_placement_pixel_size(), + * ghostty_kitty_graphics_placement_grid_size(), + * ghostty_kitty_graphics_placement_viewport_pos(), and + * ghostty_kitty_graphics_placement_source_rect() into one call. This is + * an optimization over calling those four functions individually, + * particularly useful in environments with high per-call overhead such + * as FFI or Cgo. + * + * This struct uses the sized-struct ABI pattern. Initialize with + * GHOSTTY_INIT_SIZED(GhosttyKittyGraphicsPlacementRenderInfo) before calling + * ghostty_kitty_graphics_placement_render_info(). + * + * @ingroup kitty_graphics + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyKittyGraphicsPlacementRenderInfo). */ + size_t size; + /** Rendered width in pixels. */ + uint32_t pixel_width; + /** Rendered height in pixels. */ + uint32_t pixel_height; + /** Number of grid columns the placement occupies. */ + uint32_t grid_cols; + /** Number of grid rows the placement occupies. */ + uint32_t grid_rows; + /** Viewport-relative column (may be negative for partially visible placements). */ + int32_t viewport_col; + /** Viewport-relative row (may be negative for partially visible placements). */ + int32_t viewport_row; + /** False when the placement is fully off-screen or virtual. */ + bool viewport_visible; + /** Resolved source rectangle x origin in pixels. */ + uint32_t source_x; + /** Resolved source rectangle y origin in pixels. */ + uint32_t source_y; + /** Resolved source rectangle width in pixels. */ + uint32_t source_width; + /** Resolved source rectangle height in pixels. */ + uint32_t source_height; +} GhosttyKittyGraphicsPlacementRenderInfo; + +/** + * Get data from a kitty graphics storage instance. + * + * The output pointer must be of the appropriate type for the requested + * data kind. + * + * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time. + * + * @param graphics The kitty graphics handle + * @param data The type of data to extract + * @param[out] out Pointer to store the extracted data + * @return GHOSTTY_SUCCESS on success + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_get( + GhosttyKittyGraphics graphics, + GhosttyKittyGraphicsData data, + void* out); + +/** + * Look up a Kitty graphics image by its image ID. + * + * Returns NULL if no image with the given ID exists or if Kitty graphics + * are disabled at build time. + * + * @param graphics The kitty graphics handle + * @param image_id The image ID to look up + * @return An opaque image handle, or NULL if not found + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyKittyGraphicsImage ghostty_kitty_graphics_image( + GhosttyKittyGraphics graphics, + uint32_t image_id); + +/** + * Get data from a Kitty graphics image. + * + * The output pointer must be of the appropriate type for the requested + * data kind. + * + * @param image The image handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param data The data kind to query + * @param[out] out Pointer to receive the queried value + * @return GHOSTTY_SUCCESS on success + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_image_get( + GhosttyKittyGraphicsImage image, + GhosttyKittyGraphicsImageData data, + void* out); + +/** + * Get multiple data fields from a Kitty graphics image in a single call. + * + * This is an optimization over calling ghostty_kitty_graphics_image_get() + * repeatedly, particularly useful in environments with high per-call + * overhead such as FFI or Cgo. + * + * Each element in the keys array specifies a data kind, and the + * corresponding element in the values array receives the result. + * The type of each values[i] pointer must match the output type + * documented for keys[i]. + * + * Processing stops at the first error; on success out_written + * is set to count, on error it is set to the index of the + * failing key (i.e. the number of values successfully written). + * + * @param image The image handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param count Number of key/value pairs + * @param keys Array of data kinds to query + * @param values Array of output pointers (types must match each key's + * documented output type) + * @param[out] out_written On return, receives the number of values + * successfully written (may be NULL) + * @return GHOSTTY_SUCCESS if all queries succeed + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_image_get_multi( + GhosttyKittyGraphicsImage image, + size_t count, + const GhosttyKittyGraphicsImageData* keys, + void** values, + size_t* out_written); + +/** + * Create a new placement iterator instance. + * + * All fields except the allocator are left undefined until populated + * via ghostty_kitty_graphics_get() with + * GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param[out] out_iterator On success, receives the created iterator handle + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation + * failure + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_iterator_new( + const GhosttyAllocator* allocator, + GhosttyKittyGraphicsPlacementIterator* out_iterator); + +/** + * Free a placement iterator. + * + * @param iterator The iterator handle to free (may be NULL) + * + * @ingroup kitty_graphics + */ +GHOSTTY_API void ghostty_kitty_graphics_placement_iterator_free( + GhosttyKittyGraphicsPlacementIterator iterator); + +/** + * Set an option on a placement iterator. + * + * Use GHOSTTY_KITTY_GRAPHICS_PLACEMENT_ITERATOR_OPTION_LAYER with a + * GhosttyKittyPlacementLayer value to filter placements by z-layer. + * The filter is applied during iteration: ghostty_kitty_graphics_placement_next() + * will skip placements that do not match the configured layer. + * + * The default layer is GHOSTTY_KITTY_PLACEMENT_LAYER_ALL (no filtering). + * + * @param iterator The iterator handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param option The option to set + * @param value Pointer to the value (type depends on option; NULL returns + * GHOSTTY_INVALID_VALUE) + * @return GHOSTTY_SUCCESS on success + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_iterator_set( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsPlacementIteratorOption option, + const void* value); + +/** + * Advance the placement iterator to the next placement. + * + * If a layer filter has been set via + * ghostty_kitty_graphics_placement_iterator_set(), only placements + * matching that layer are returned. + * + * @param iterator The iterator handle (may be NULL) + * @return true if advanced to the next placement, false if at the end + * + * @ingroup kitty_graphics + */ +GHOSTTY_API bool ghostty_kitty_graphics_placement_next( + GhosttyKittyGraphicsPlacementIterator iterator); + +/** + * Get data from the current placement in a placement iterator. + * + * Call ghostty_kitty_graphics_placement_next() at least once before + * calling this function. + * + * @param iterator The iterator handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param data The data kind to query + * @param[out] out Pointer to receive the queried value + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the + * iterator is NULL or not positioned on a placement + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_get( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsPlacementData data, + void* out); + +/** + * Get multiple data fields from the current placement in a single call. + * + * This is an optimization over calling ghostty_kitty_graphics_placement_get() + * repeatedly, particularly useful in environments with high per-call + * overhead such as FFI or Cgo. + * + * Each element in the keys array specifies a data kind, and the + * corresponding element in the values array receives the result. + * The type of each values[i] pointer must match the output type + * documented for keys[i]. + * + * Processing stops at the first error; on success out_written + * is set to count, on error it is set to the index of the + * failing key (i.e. the number of values successfully written). + * + * @param iterator The iterator handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param count Number of key/value pairs + * @param keys Array of data kinds to query + * @param values Array of output pointers (types must match each key's + * documented output type) + * @param[out] out_written On return, receives the number of values + * successfully written (may be NULL) + * @return GHOSTTY_SUCCESS if all queries succeed + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_get_multi( + GhosttyKittyGraphicsPlacementIterator iterator, + size_t count, + const GhosttyKittyGraphicsPlacementData* keys, + void** values, + size_t* out_written); + +/** + * Compute the grid rectangle occupied by the current placement. + * + * Uses the placement's pin, the image dimensions, and the terminal's + * cell/pixel geometry to calculate the bounding rectangle. Virtual + * placements (unicode placeholders) return GHOSTTY_NO_VALUE. + * + * @param terminal The terminal handle + * @param image The image handle for this placement's image + * @param iterator The placement iterator positioned on a placement + * @param[out] out_selection On success, receives the bounding rectangle + * as a selection with rectangle=true + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if any handle + * is NULL or the iterator is not positioned, GHOSTTY_NO_VALUE for + * virtual placements or when Kitty graphics are disabled + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_rect( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsImage image, + GhosttyTerminal terminal, + GhosttySelection* out_selection); + +/** + * Compute the rendered pixel size of the current placement. + * + * Takes into account the placement's source rectangle, specified + * columns/rows, and aspect ratio to calculate the final rendered + * pixel dimensions. + * + * @param iterator The placement iterator positioned on a placement + * @param image The image handle for this placement's image + * @param terminal The terminal handle + * @param[out] out_width On success, receives the width in pixels + * @param[out] out_height On success, receives the height in pixels + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if any handle + * is NULL or the iterator is not positioned, GHOSTTY_NO_VALUE when + * Kitty graphics are disabled + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_pixel_size( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsImage image, + GhosttyTerminal terminal, + uint32_t* out_width, + uint32_t* out_height); + +/** + * Compute the grid cell size of the current placement. + * + * Returns the number of columns and rows that the placement occupies + * in the terminal grid. If the placement specifies explicit columns + * and rows, those are returned directly; otherwise they are calculated + * from the pixel size and cell dimensions. + * + * @param iterator The placement iterator positioned on a placement + * @param image The image handle for this placement's image + * @param terminal The terminal handle + * @param[out] out_cols On success, receives the number of columns + * @param[out] out_rows On success, receives the number of rows + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if any handle + * is NULL or the iterator is not positioned, GHOSTTY_NO_VALUE when + * Kitty graphics are disabled + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_grid_size( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsImage image, + GhosttyTerminal terminal, + uint32_t* out_cols, + uint32_t* out_rows); + +/** + * Get the viewport-relative grid position of the current placement. + * + * Converts the placement's internal pin to viewport-relative column and + * row coordinates. The returned coordinates represent the top-left + * corner of the placement in the viewport's grid coordinate space. + * + * The row value can be negative when the placement's origin has + * scrolled above the top of the viewport. For example, a 4-row + * image that has scrolled up by 2 rows returns row=-2, meaning + * its top 2 rows are above the visible area but its bottom 2 rows + * are still on screen. Embedders should use these coordinates + * directly when computing the destination rectangle for rendering; + * the embedder is responsible for clipping the portion of the image + * that falls outside the viewport. + * + * Returns GHOSTTY_SUCCESS for any placement that is at least + * partially visible in the viewport. Returns GHOSTTY_NO_VALUE when + * the placement is completely outside the viewport (its bottom edge + * is above the viewport or its top edge is at or below the last + * viewport row), or when the placement is a virtual (unicode + * placeholder) placement. + * + * @param iterator The placement iterator positioned on a placement + * @param image The image handle for this placement's image + * @param terminal The terminal handle + * @param[out] out_col On success, receives the viewport-relative column + * @param[out] out_row On success, receives the viewport-relative row + * (may be negative for partially visible placements) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if fully + * off-screen or virtual, GHOSTTY_INVALID_VALUE if any handle + * is NULL or the iterator is not positioned + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_viewport_pos( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsImage image, + GhosttyTerminal terminal, + int32_t* out_col, + int32_t* out_row); + +/** + * Get the resolved source rectangle for the current placement. + * + * Applies kitty protocol semantics: a width or height of 0 in the + * placement means "use the full image dimension", and the resulting + * rectangle is clamped to the actual image bounds. The returned + * values are in pixels and are ready to use for texture sampling. + * + * @param iterator The placement iterator positioned on a placement + * @param image The image handle for this placement's image + * @param[out] out_x Source rect x origin in pixels + * @param[out] out_y Source rect y origin in pixels + * @param[out] out_width Source rect width in pixels + * @param[out] out_height Source rect height in pixels + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if any + * handle is NULL or the iterator is not positioned + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_source_rect( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsImage image, + uint32_t* out_x, + uint32_t* out_y, + uint32_t* out_width, + uint32_t* out_height); + +/** + * Get all rendering geometry for a placement in a single call. + * + * Combines pixel size, grid size, viewport position, and source + * rectangle into one struct. Initialize with + * GHOSTTY_INIT_SIZED(GhosttyKittyGraphicsPlacementRenderInfo). + * + * When viewport_visible is false, the placement is fully off-screen + * or is a virtual placement; viewport_col and viewport_row may + * contain meaningless values in that case. + * + * @param iterator The iterator positioned on a placement + * @param image The image handle for this placement's image + * @param terminal The terminal handle + * @param[out] out_info Pointer to receive the rendering geometry + * @return GHOSTTY_SUCCESS on success + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_render_info( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsImage image, + GhosttyTerminal terminal, + GhosttyKittyGraphicsPlacementRenderInfo* out_info); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_KITTY_GRAPHICS_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/modes.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/modes.h new file mode 100644 index 00000000000..8e1fd91179e --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/modes.h @@ -0,0 +1,198 @@ +/** + * @file modes.h + * + * Terminal mode utilities - pack and unpack ANSI/DEC mode identifiers. + */ + +#ifndef GHOSTTY_VT_MODES_H +#define GHOSTTY_VT_MODES_H + +/** @defgroup modes Mode Utilities + * + * Utilities for working with terminal modes. A mode is a compact + * 16-bit representation of a terminal mode identifier that encodes both + * the numeric mode value (up to 15 bits) and whether the mode is an ANSI + * mode or a DEC private mode (?-prefixed). + * + * The packed layout (least-significant bit first) is: + * - Bits 0–14: mode value (u15) + * - Bit 15: ANSI flag (0 = DEC private mode, 1 = ANSI mode) + * + * ## Example + * + * @snippet c-vt-modes/src/main.c modes-pack-unpack + * + * ## DECRPM Report Encoding + * + * Use ghostty_mode_report_encode() to encode a DECRPM response into a + * caller-provided buffer: + * + * @snippet c-vt-modes/src/main.c modes-decrpm + * + * @{ + */ + +#include +#include +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @name ANSI Modes + * Modes for standard ANSI modes. + * @{ + */ +#define GHOSTTY_MODE_KAM (ghostty_mode_new(2, true)) /**< Keyboard action (disable keyboard) */ +#define GHOSTTY_MODE_INSERT (ghostty_mode_new(4, true)) /**< Insert mode */ +#define GHOSTTY_MODE_SRM (ghostty_mode_new(12, true)) /**< Send/receive mode */ +#define GHOSTTY_MODE_LINEFEED (ghostty_mode_new(20, true)) /**< Linefeed/new line mode */ +/** @} */ + +/** @name DEC Private Modes + * Modes for DEC private modes (?-prefixed). + * @{ + */ +#define GHOSTTY_MODE_DECCKM (ghostty_mode_new(1, false)) /**< Cursor keys */ +#define GHOSTTY_MODE_132_COLUMN (ghostty_mode_new(3, false)) /**< 132/80 column mode */ +#define GHOSTTY_MODE_SLOW_SCROLL (ghostty_mode_new(4, false)) /**< Slow scroll */ +#define GHOSTTY_MODE_REVERSE_COLORS (ghostty_mode_new(5, false)) /**< Reverse video */ +#define GHOSTTY_MODE_ORIGIN (ghostty_mode_new(6, false)) /**< Origin mode */ +#define GHOSTTY_MODE_WRAPAROUND (ghostty_mode_new(7, false)) /**< Auto-wrap mode */ +#define GHOSTTY_MODE_AUTOREPEAT (ghostty_mode_new(8, false)) /**< Auto-repeat keys */ +#define GHOSTTY_MODE_X10_MOUSE (ghostty_mode_new(9, false)) /**< X10 mouse reporting */ +#define GHOSTTY_MODE_CURSOR_BLINKING (ghostty_mode_new(12, false)) /**< Cursor blink */ +#define GHOSTTY_MODE_CURSOR_VISIBLE (ghostty_mode_new(25, false)) /**< Cursor visible (DECTCEM) */ +#define GHOSTTY_MODE_ENABLE_MODE_3 (ghostty_mode_new(40, false)) /**< Allow 132 column mode */ +#define GHOSTTY_MODE_REVERSE_WRAP (ghostty_mode_new(45, false)) /**< Reverse wrap */ +#define GHOSTTY_MODE_ALT_SCREEN_LEGACY (ghostty_mode_new(47, false)) /**< Alternate screen (legacy) */ +#define GHOSTTY_MODE_KEYPAD_KEYS (ghostty_mode_new(66, false)) /**< Application keypad */ +#define GHOSTTY_MODE_BACKARROW_KEY_MODE (ghostty_mode_new(67, false)) /**< Backarrow key mode (DECBKM) */ +#define GHOSTTY_MODE_LEFT_RIGHT_MARGIN (ghostty_mode_new(69, false)) /**< Left/right margin mode */ +#define GHOSTTY_MODE_NORMAL_MOUSE (ghostty_mode_new(1000, false)) /**< Normal mouse tracking */ +#define GHOSTTY_MODE_BUTTON_MOUSE (ghostty_mode_new(1002, false)) /**< Button-event mouse tracking */ +#define GHOSTTY_MODE_ANY_MOUSE (ghostty_mode_new(1003, false)) /**< Any-event mouse tracking */ +#define GHOSTTY_MODE_FOCUS_EVENT (ghostty_mode_new(1004, false)) /**< Focus in/out events */ +#define GHOSTTY_MODE_UTF8_MOUSE (ghostty_mode_new(1005, false)) /**< UTF-8 mouse format */ +#define GHOSTTY_MODE_SGR_MOUSE (ghostty_mode_new(1006, false)) /**< SGR mouse format */ +#define GHOSTTY_MODE_ALT_SCROLL (ghostty_mode_new(1007, false)) /**< Alternate scroll mode */ +#define GHOSTTY_MODE_URXVT_MOUSE (ghostty_mode_new(1015, false)) /**< URxvt mouse format */ +#define GHOSTTY_MODE_SGR_PIXELS_MOUSE (ghostty_mode_new(1016, false)) /**< SGR-Pixels mouse format */ +#define GHOSTTY_MODE_NUMLOCK_KEYPAD (ghostty_mode_new(1035, false)) /**< Ignore keypad with NumLock */ +#define GHOSTTY_MODE_ALT_ESC_PREFIX (ghostty_mode_new(1036, false)) /**< Alt key sends ESC prefix */ +#define GHOSTTY_MODE_ALT_SENDS_ESC (ghostty_mode_new(1039, false)) /**< Alt sends escape */ +#define GHOSTTY_MODE_REVERSE_WRAP_EXT (ghostty_mode_new(1045, false)) /**< Extended reverse wrap */ +#define GHOSTTY_MODE_ALT_SCREEN (ghostty_mode_new(1047, false)) /**< Alternate screen */ +#define GHOSTTY_MODE_SAVE_CURSOR (ghostty_mode_new(1048, false)) /**< Save cursor (DECSC) */ +#define GHOSTTY_MODE_ALT_SCREEN_SAVE (ghostty_mode_new(1049, false)) /**< Alt screen + save cursor + clear */ +#define GHOSTTY_MODE_BRACKETED_PASTE (ghostty_mode_new(2004, false)) /**< Bracketed paste mode */ +#define GHOSTTY_MODE_SYNC_OUTPUT (ghostty_mode_new(2026, false)) /**< Synchronized output */ +#define GHOSTTY_MODE_GRAPHEME_CLUSTER (ghostty_mode_new(2027, false)) /**< Grapheme cluster mode */ +#define GHOSTTY_MODE_COLOR_SCHEME_REPORT (ghostty_mode_new(2031, false)) /**< Report color scheme */ +#define GHOSTTY_MODE_IN_BAND_RESIZE (ghostty_mode_new(2048, false)) /**< In-band size reports */ +/** @} */ + +/** + * A packed 16-bit terminal mode. + * + * Encodes a mode value (bits 0–14) and an ANSI flag (bit 15) into a + * single 16-bit integer. Use the inline helper functions to construct + * and inspect modes rather than manipulating bits directly. + */ +typedef uint16_t GhosttyMode; + +/** + * Create a mode from a mode value and ANSI flag. + * + * @param value The numeric mode value (0–32767) + * @param ansi true for an ANSI mode, false for a DEC private mode + * @return The packed mode + * + * @ingroup modes + */ +static inline GhosttyMode ghostty_mode_new(uint16_t value, bool ansi) { + return (GhosttyMode)((value & 0x7FFF) | ((uint16_t)ansi << 15)); +} + +/** + * Extract the numeric mode value from a mode. + * + * @param mode The mode + * @return The mode value (0–32767) + * + * @ingroup modes + */ +static inline uint16_t ghostty_mode_value(GhosttyMode mode) { + return mode & 0x7FFF; +} + +/** + * Check whether a mode represents an ANSI mode. + * + * @param mode The mode + * @return true if this is an ANSI mode, false if it is a DEC private mode + * + * @ingroup modes + */ +static inline bool ghostty_mode_ansi(GhosttyMode mode) { + return (mode >> 15) != 0; +} + +/** + * DECRPM report state values. + * + * These correspond to the Ps2 parameter in a DECRPM response + * sequence (CSI ? Ps1 ; Ps2 $ y). + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Mode is not recognized */ + GHOSTTY_MODE_REPORT_NOT_RECOGNIZED = 0, + /** Mode is set (enabled) */ + GHOSTTY_MODE_REPORT_SET = 1, + /** Mode is reset (disabled) */ + GHOSTTY_MODE_REPORT_RESET = 2, + /** Mode is permanently set */ + GHOSTTY_MODE_REPORT_PERMANENTLY_SET = 3, + /** Mode is permanently reset */ + GHOSTTY_MODE_REPORT_PERMANENTLY_RESET = 4, + GHOSTTY_MODE_REPORT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyModeReportState; + +/** + * Encode a DECRPM (DEC Private Mode Report) response sequence. + * + * Writes a mode report escape sequence into the provided buffer. + * The generated sequence has the form: + * - DEC private mode: CSI ? Ps1 ; Ps2 $ y + * - ANSI mode: CSI Ps1 ; Ps2 $ y + * + * If the buffer is too small, the function returns GHOSTTY_OUT_OF_SPACE + * and writes the required buffer size to @p out_written. The caller can + * then retry with a sufficiently sized buffer. + * + * @param mode The mode identifying the mode to report on + * @param state The report state for this mode + * @param buf Output buffer to write the encoded sequence into (may be NULL) + * @param buf_len Size of the output buffer in bytes + * @param[out] out_written On success, the number of bytes written. On + * GHOSTTY_OUT_OF_SPACE, the required buffer size. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if the buffer + * is too small + */ +GHOSTTY_API GhosttyResult ghostty_mode_report_encode( + GhosttyMode mode, + GhosttyModeReportState state, + char* buf, + size_t buf_len, + size_t* out_written); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_MODES_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/mouse.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/mouse.h new file mode 100644 index 00000000000..4ba5f52e38b --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/mouse.h @@ -0,0 +1,70 @@ +/** + * @file mouse.h + * + * Mouse encoding module - encode mouse events into terminal escape sequences. + */ + +#ifndef GHOSTTY_VT_MOUSE_H +#define GHOSTTY_VT_MOUSE_H + +/** @defgroup mouse Mouse Encoding + * + * Utilities for encoding mouse events into terminal escape sequences, + * supporting X10, UTF-8, SGR, URxvt, and SGR-Pixels mouse protocols. + * + * ## Basic Usage + * + * 1. Create an encoder instance with ghostty_mouse_encoder_new(). + * 2. Configure encoder options with ghostty_mouse_encoder_setopt() or + * ghostty_mouse_encoder_setopt_from_terminal(). + * 3. For each mouse event: + * - Create a mouse event with ghostty_mouse_event_new(). + * - Set event properties (action, button, modifiers, position). + * - Encode with ghostty_mouse_encoder_encode(). + * - Free the event with ghostty_mouse_event_free() or reuse it. + * 4. Free the encoder with ghostty_mouse_encoder_free() when done. + * + * For a complete working example, see example/c-vt-encode-mouse in the + * repository. + * + * ## Example + * + * @snippet c-vt-encode-mouse/src/main.c mouse-encode + * + * ## Example: Encoding with Terminal State + * + * When you have a GhosttyTerminal, you can sync its tracking mode and + * output format into the encoder automatically: + * + * @code{.c} + * // Create a terminal and feed it some VT data that enables mouse tracking + * GhosttyTerminal terminal; + * ghostty_terminal_new(NULL, &terminal, + * (GhosttyTerminalOptions){.cols = 80, .rows = 24, .max_scrollback = 0}); + * + * // Application might write data that enables mouse reporting, etc. + * ghostty_terminal_vt_write(terminal, vt_data, vt_len); + * + * // Create an encoder and sync its options from the terminal + * GhosttyMouseEncoder encoder; + * ghostty_mouse_encoder_new(NULL, &encoder); + * ghostty_mouse_encoder_setopt_from_terminal(encoder, terminal); + * + * // Encode a mouse event using the terminal-derived options + * char buf[128]; + * size_t written = 0; + * ghostty_mouse_encoder_encode(encoder, event, buf, sizeof(buf), &written); + * + * ghostty_mouse_encoder_free(encoder); + * ghostty_terminal_free(terminal); + * @endcode + * + * @{ + */ + +#include +#include + +/** @} */ + +#endif /* GHOSTTY_VT_MOUSE_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/mouse/encoder.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/mouse/encoder.h new file mode 100644 index 00000000000..d84d863c8d7 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/mouse/encoder.h @@ -0,0 +1,214 @@ +/** + * @file encoder.h + * + * Mouse event encoding to terminal escape sequences. + */ + +#ifndef GHOSTTY_VT_MOUSE_ENCODER_H +#define GHOSTTY_VT_MOUSE_ENCODER_H + +#include +#include +#include +#include +#include +#include +#include + +/** + * Opaque handle to a mouse encoder instance. + * + * This handle represents a mouse encoder that converts normalized + * mouse events into terminal escape sequences. + * + * @ingroup mouse + */ +typedef struct GhosttyMouseEncoderImpl *GhosttyMouseEncoder; + +/** + * Mouse tracking mode. + * + * @ingroup mouse + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Mouse reporting disabled. */ + GHOSTTY_MOUSE_TRACKING_NONE = 0, + + /** X10 mouse mode. */ + GHOSTTY_MOUSE_TRACKING_X10 = 1, + + /** Normal mouse mode (button press/release only). */ + GHOSTTY_MOUSE_TRACKING_NORMAL = 2, + + /** Button-event tracking mode. */ + GHOSTTY_MOUSE_TRACKING_BUTTON = 3, + + /** Any-event tracking mode. */ + GHOSTTY_MOUSE_TRACKING_ANY = 4, + GHOSTTY_MOUSE_TRACKING_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyMouseTrackingMode; + +/** + * Mouse output format. + * + * @ingroup mouse + */ +typedef enum GHOSTTY_ENUM_TYPED { + GHOSTTY_MOUSE_FORMAT_X10 = 0, + GHOSTTY_MOUSE_FORMAT_UTF8 = 1, + GHOSTTY_MOUSE_FORMAT_SGR = 2, + GHOSTTY_MOUSE_FORMAT_URXVT = 3, + GHOSTTY_MOUSE_FORMAT_SGR_PIXELS = 4, + GHOSTTY_MOUSE_FORMAT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyMouseFormat; + +/** + * Mouse encoder size and geometry context. + * + * This describes the rendered terminal geometry used to convert + * surface-space positions into encoded coordinates. + * + * @ingroup mouse + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyMouseEncoderSize). */ + size_t size; + + /** Full screen width in pixels. */ + uint32_t screen_width; + + /** Full screen height in pixels. */ + uint32_t screen_height; + + /** Cell width in pixels. Must be non-zero. */ + uint32_t cell_width; + + /** Cell height in pixels. Must be non-zero. */ + uint32_t cell_height; + + /** Top padding in pixels. */ + uint32_t padding_top; + + /** Bottom padding in pixels. */ + uint32_t padding_bottom; + + /** Right padding in pixels. */ + uint32_t padding_right; + + /** Left padding in pixels. */ + uint32_t padding_left; +} GhosttyMouseEncoderSize; + +/** + * Mouse encoder option identifiers. + * + * These values are used with ghostty_mouse_encoder_setopt() to configure + * the behavior of the mouse encoder. + * + * @ingroup mouse + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Mouse tracking mode (value: GhosttyMouseTrackingMode). */ + GHOSTTY_MOUSE_ENCODER_OPT_EVENT = 0, + + /** Mouse output format (value: GhosttyMouseFormat). */ + GHOSTTY_MOUSE_ENCODER_OPT_FORMAT = 1, + + /** Renderer size context (value: GhosttyMouseEncoderSize). */ + GHOSTTY_MOUSE_ENCODER_OPT_SIZE = 2, + + /** Whether any mouse button is currently pressed (value: bool). */ + GHOSTTY_MOUSE_ENCODER_OPT_ANY_BUTTON_PRESSED = 3, + + /** Whether to enable motion deduplication by last cell (value: bool). */ + GHOSTTY_MOUSE_ENCODER_OPT_TRACK_LAST_CELL = 4, + GHOSTTY_MOUSE_ENCODER_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyMouseEncoderOption; + +/** + * Create a new mouse encoder instance. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param encoder Pointer to store the created encoder handle + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup mouse + */ +GHOSTTY_API GhosttyResult ghostty_mouse_encoder_new(const GhosttyAllocator *allocator, + GhosttyMouseEncoder *encoder); + +/** + * Free a mouse encoder instance. + * + * @param encoder The encoder handle to free (may be NULL) + * + * @ingroup mouse + */ +GHOSTTY_API void ghostty_mouse_encoder_free(GhosttyMouseEncoder encoder); + +/** + * Set an option on the mouse encoder. + * + * A null pointer value does nothing. It does not reset to defaults. + * + * @param encoder The encoder handle, must not be NULL + * @param option The option to set + * @param value Pointer to option value (type depends on option) + * + * @ingroup mouse + */ +GHOSTTY_API void ghostty_mouse_encoder_setopt(GhosttyMouseEncoder encoder, + GhosttyMouseEncoderOption option, + const void *value); + +/** + * Set encoder options from a terminal's current state. + * + * This sets tracking mode and output format from terminal state. + * It does not modify size or any-button state. + * + * @param encoder The encoder handle, must not be NULL + * @param terminal The terminal handle, must not be NULL + * + * @ingroup mouse + */ +GHOSTTY_API void ghostty_mouse_encoder_setopt_from_terminal(GhosttyMouseEncoder encoder, + GhosttyTerminal terminal); + +/** + * Reset internal encoder state. + * + * This clears motion deduplication state (last tracked cell). + * + * @param encoder The encoder handle (may be NULL) + * + * @ingroup mouse + */ +GHOSTTY_API void ghostty_mouse_encoder_reset(GhosttyMouseEncoder encoder); + +/** + * Encode a mouse event into a terminal escape sequence. + * + * Not all mouse events produce output. In such cases this returns + * GHOSTTY_SUCCESS with out_len set to 0. + * + * If the output buffer is too small, this returns GHOSTTY_OUT_OF_SPACE + * and out_len contains the required size. + * + * @param encoder The encoder handle, must not be NULL + * @param event The mouse event to encode, must not be NULL + * @param out_buf Buffer to write encoded bytes to, or NULL to query required size + * @param out_buf_size Size of out_buf in bytes + * @param out_len Pointer to store bytes written (or required bytes on failure) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if buffer is too small, + * or another error code + * + * @ingroup mouse + */ +GHOSTTY_API GhosttyResult ghostty_mouse_encoder_encode(GhosttyMouseEncoder encoder, + GhosttyMouseEvent event, + char *out_buf, + size_t out_buf_size, + size_t *out_len); + +#endif /* GHOSTTY_VT_MOUSE_ENCODER_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/mouse/event.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/mouse/event.h new file mode 100644 index 00000000000..a24b0c079bb --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/mouse/event.h @@ -0,0 +1,195 @@ +/** + * @file event.h + * + * Mouse event representation and manipulation. + */ + +#ifndef GHOSTTY_VT_MOUSE_EVENT_H +#define GHOSTTY_VT_MOUSE_EVENT_H + +#include +#include +#include +#include + +/** + * Opaque handle to a mouse event. + * + * This handle represents a normalized mouse input event containing + * action, button, modifiers, and surface-space position. + * + * @ingroup mouse + */ +typedef struct GhosttyMouseEventImpl *GhosttyMouseEvent; + +/** + * Mouse event action type. + * + * @ingroup mouse + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Mouse button was pressed. */ + GHOSTTY_MOUSE_ACTION_PRESS = 0, + + /** Mouse button was released. */ + GHOSTTY_MOUSE_ACTION_RELEASE = 1, + + /** Mouse moved. */ + GHOSTTY_MOUSE_ACTION_MOTION = 2, + GHOSTTY_MOUSE_ACTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyMouseAction; + +/** + * Mouse button identity. + * + * @ingroup mouse + */ +typedef enum GHOSTTY_ENUM_TYPED { + GHOSTTY_MOUSE_BUTTON_UNKNOWN = 0, + GHOSTTY_MOUSE_BUTTON_LEFT = 1, + GHOSTTY_MOUSE_BUTTON_RIGHT = 2, + GHOSTTY_MOUSE_BUTTON_MIDDLE = 3, + GHOSTTY_MOUSE_BUTTON_FOUR = 4, + GHOSTTY_MOUSE_BUTTON_FIVE = 5, + GHOSTTY_MOUSE_BUTTON_SIX = 6, + GHOSTTY_MOUSE_BUTTON_SEVEN = 7, + GHOSTTY_MOUSE_BUTTON_EIGHT = 8, + GHOSTTY_MOUSE_BUTTON_NINE = 9, + GHOSTTY_MOUSE_BUTTON_TEN = 10, + GHOSTTY_MOUSE_BUTTON_ELEVEN = 11, + GHOSTTY_MOUSE_BUTTON_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyMouseButton; + +/** + * Mouse position in surface-space pixels. + * + * @ingroup mouse + */ +typedef struct { + float x; + float y; +} GhosttyMousePosition; + +/** + * Create a new mouse event instance. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param event Pointer to store the created event handle + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup mouse + */ +GHOSTTY_API GhosttyResult ghostty_mouse_event_new(const GhosttyAllocator *allocator, + GhosttyMouseEvent *event); + +/** + * Free a mouse event instance. + * + * @param event The mouse event handle to free (may be NULL) + * + * @ingroup mouse + */ +GHOSTTY_API void ghostty_mouse_event_free(GhosttyMouseEvent event); + +/** + * Set the event action. + * + * @param event The event handle, must not be NULL + * @param action The action to set + * + * @ingroup mouse + */ +GHOSTTY_API void ghostty_mouse_event_set_action(GhosttyMouseEvent event, + GhosttyMouseAction action); + +/** + * Get the event action. + * + * @param event The event handle, must not be NULL + * @return The event action + * + * @ingroup mouse + */ +GHOSTTY_API GhosttyMouseAction ghostty_mouse_event_get_action(GhosttyMouseEvent event); + +/** + * Set the event button. + * + * This sets a concrete button identity for the event. + * To represent "no button" (for motion events), use + * ghostty_mouse_event_clear_button(). + * + * @param event The event handle, must not be NULL + * @param button The button to set + * + * @ingroup mouse + */ +GHOSTTY_API void ghostty_mouse_event_set_button(GhosttyMouseEvent event, + GhosttyMouseButton button); + +/** + * Clear the event button. + * + * This sets the event button to "none". + * + * @param event The event handle, must not be NULL + * + * @ingroup mouse + */ +GHOSTTY_API void ghostty_mouse_event_clear_button(GhosttyMouseEvent event); + +/** + * Get the event button. + * + * @param event The event handle, must not be NULL + * @param out_button Output pointer for the button value (may be NULL) + * @return true if a button is set, false if no button is set + * + * @ingroup mouse + */ +GHOSTTY_API bool ghostty_mouse_event_get_button(GhosttyMouseEvent event, + GhosttyMouseButton *out_button); + +/** + * Set keyboard modifiers held during the event. + * + * @param event The event handle, must not be NULL + * @param mods Modifier bitmask + * + * @ingroup mouse + */ +GHOSTTY_API void ghostty_mouse_event_set_mods(GhosttyMouseEvent event, + GhosttyMods mods); + +/** + * Get keyboard modifiers held during the event. + * + * @param event The event handle, must not be NULL + * @return Modifier bitmask + * + * @ingroup mouse + */ +GHOSTTY_API GhosttyMods ghostty_mouse_event_get_mods(GhosttyMouseEvent event); + +/** + * Set the event position in surface-space pixels. + * + * @param event The event handle, must not be NULL + * @param position The position to set + * + * @ingroup mouse + */ +GHOSTTY_API void ghostty_mouse_event_set_position(GhosttyMouseEvent event, + GhosttyMousePosition position); + +/** + * Get the event position in surface-space pixels. + * + * @param event The event handle, must not be NULL + * @return The current event position + * + * @ingroup mouse + */ +GHOSTTY_API GhosttyMousePosition ghostty_mouse_event_get_position(GhosttyMouseEvent event); + +#endif /* GHOSTTY_VT_MOUSE_EVENT_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/osc.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/osc.h new file mode 100644 index 00000000000..9409ebc738f --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/osc.h @@ -0,0 +1,215 @@ +/** + * @file osc.h + * + * OSC (Operating System Command) sequence parser and command handling. + */ + +#ifndef GHOSTTY_VT_OSC_H +#define GHOSTTY_VT_OSC_H + +#include +#include +#include +#include +#include + +/** @defgroup osc OSC Parser + * + * OSC (Operating System Command) sequence parser and command handling. + * + * The parser operates in a streaming fashion, processing input byte-by-byte + * to handle OSC sequences that may arrive in fragments across multiple reads. + * This interface makes it easy to integrate into most environments and avoids + * over-allocating buffers. + * + * ## Basic Usage + * + * 1. Create a parser instance with ghostty_osc_new() + * 2. Feed bytes to the parser using ghostty_osc_next() + * 3. Finalize parsing with ghostty_osc_end() to get the command + * 4. Query command type and extract data using ghostty_osc_command_type() + * and ghostty_osc_command_data() + * 5. Free the parser with ghostty_osc_free() when done + * + * @{ + */ + +/** + * OSC command types. + * + * @ingroup osc + */ +typedef enum GHOSTTY_ENUM_TYPED { + GHOSTTY_OSC_COMMAND_INVALID = 0, + GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE = 1, + GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_ICON = 2, + GHOSTTY_OSC_COMMAND_SEMANTIC_PROMPT = 3, + GHOSTTY_OSC_COMMAND_CLIPBOARD_CONTENTS = 4, + GHOSTTY_OSC_COMMAND_REPORT_PWD = 5, + GHOSTTY_OSC_COMMAND_MOUSE_SHAPE = 6, + GHOSTTY_OSC_COMMAND_COLOR_OPERATION = 7, + GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL = 8, + GHOSTTY_OSC_COMMAND_SHOW_DESKTOP_NOTIFICATION = 9, + GHOSTTY_OSC_COMMAND_HYPERLINK_START = 10, + GHOSTTY_OSC_COMMAND_HYPERLINK_END = 11, + GHOSTTY_OSC_COMMAND_CONEMU_SLEEP = 12, + GHOSTTY_OSC_COMMAND_CONEMU_SHOW_MESSAGE_BOX = 13, + GHOSTTY_OSC_COMMAND_CONEMU_CHANGE_TAB_TITLE = 14, + GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT = 15, + GHOSTTY_OSC_COMMAND_CONEMU_WAIT_INPUT = 16, + GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 17, + GHOSTTY_OSC_COMMAND_CONEMU_RUN_PROCESS = 18, + GHOSTTY_OSC_COMMAND_CONEMU_OUTPUT_ENVIRONMENT_VARIABLE = 19, + GHOSTTY_OSC_COMMAND_CONEMU_XTERM_EMULATION = 20, + GHOSTTY_OSC_COMMAND_CONEMU_COMMENT = 21, + GHOSTTY_OSC_COMMAND_KITTY_TEXT_SIZING = 22, + GHOSTTY_OSC_COMMAND_TYPE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyOscCommandType; + +/** + * OSC command data types. + * + * These values specify what type of data to extract from an OSC command + * using `ghostty_osc_command_data`. + * + * @ingroup osc + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Invalid data type. Never results in any data extraction. */ + GHOSTTY_OSC_DATA_INVALID = 0, + + /** + * Window title string data. + * + * Valid for: GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE + * + * Output type: const char ** (pointer to null-terminated string) + * + * Lifetime: Valid until the next call to any ghostty_osc_* function with + * the same parser instance. Memory is owned by the parser. + */ + GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR = 1, + GHOSTTY_OSC_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyOscCommandData; + +/** + * Create a new OSC parser instance. + * + * Creates a new OSC (Operating System Command) parser using the provided + * allocator. The parser must be freed using ghostty_vt_osc_free() when + * no longer needed. + * + * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator + * @param parser Pointer to store the created parser handle + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup osc + */ +GHOSTTY_API GhosttyResult ghostty_osc_new(const GhosttyAllocator *allocator, GhosttyOscParser *parser); + +/** + * Free an OSC parser instance. + * + * Releases all resources associated with the OSC parser. After this call, + * the parser handle becomes invalid and must not be used. + * + * @param parser The parser handle to free (may be NULL) + * + * @ingroup osc + */ +GHOSTTY_API void ghostty_osc_free(GhosttyOscParser parser); + +/** + * Reset an OSC parser instance to its initial state. + * + * Resets the parser state, clearing any partially parsed OSC sequences + * and returning the parser to its initial state. This is useful for + * reusing a parser instance or recovering from parse errors. + * + * @param parser The parser handle to reset, must not be null. + * + * @ingroup osc + */ +GHOSTTY_API void ghostty_osc_reset(GhosttyOscParser parser); + +/** + * Parse the next byte in an OSC sequence. + * + * Processes a single byte as part of an OSC sequence. The parser maintains + * internal state to track the progress through the sequence. Call this + * function for each byte in the sequence data. + * + * When finished pumping the parser with bytes, call ghostty_osc_end + * to get the final result. + * + * @param parser The parser handle, must not be null. + * @param byte The next byte to parse + * + * @ingroup osc + */ +GHOSTTY_API void ghostty_osc_next(GhosttyOscParser parser, uint8_t byte); + +/** + * Finalize OSC parsing and retrieve the parsed command. + * + * Call this function after feeding all bytes of an OSC sequence to the parser + * using ghostty_osc_next() with the exception of the terminating character + * (ESC or ST). This function finalizes the parsing process and returns the + * parsed OSC command. + * + * The return value is never NULL. Invalid commands will return a command + * with type GHOSTTY_OSC_COMMAND_INVALID. + * + * The terminator parameter specifies the byte that terminated the OSC sequence + * (typically 0x07 for BEL or 0x5C for ST after ESC). This information is + * preserved in the parsed command so that responses can use the same terminator + * format for better compatibility with the calling program. For commands that + * do not require a response, this parameter is ignored and the resulting + * command will not retain the terminator information. + * + * The returned command handle is valid until the next call to any + * `ghostty_osc_*` function with the same parser instance with the exception + * of command introspection functions such as `ghostty_osc_command_type`. + * + * @param parser The parser handle, must not be null. + * @param terminator The terminating byte of the OSC sequence (0x07 for BEL, 0x5C for ST) + * @return Handle to the parsed OSC command + * + * @ingroup osc + */ +GHOSTTY_API GhosttyOscCommand ghostty_osc_end(GhosttyOscParser parser, uint8_t terminator); + +/** + * Get the type of an OSC command. + * + * Returns the type identifier for the given OSC command. This can be used + * to determine what kind of command was parsed and what data might be + * available from it. + * + * @param command The OSC command handle to query (may be NULL) + * @return The command type, or GHOSTTY_OSC_COMMAND_INVALID if command is NULL + * + * @ingroup osc + */ +GHOSTTY_API GhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command); + +/** + * Extract data from an OSC command. + * + * Extracts typed data from the given OSC command based on the specified + * data type. The output pointer must be of the appropriate type for the + * requested data kind. Valid command types, output types, and memory + * safety information are documented in the `GhosttyOscCommandData` enum. + * + * @param command The OSC command handle to query (may be NULL) + * @param data The type of data to extract + * @param out Pointer to store the extracted data (type depends on data parameter) + * @return true if data extraction was successful, false otherwise + * + * @ingroup osc + */ +GHOSTTY_API bool ghostty_osc_command_data(GhosttyOscCommand command, GhosttyOscCommandData data, void *out); + +/** @} */ + +#endif /* GHOSTTY_VT_OSC_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/paste.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/paste.h new file mode 100644 index 00000000000..b3df5be4e0d --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/paste.h @@ -0,0 +1,101 @@ +/** + * @file paste.h + * + * Paste utilities - validate and encode paste data for terminal input. + */ + +#ifndef GHOSTTY_VT_PASTE_H +#define GHOSTTY_VT_PASTE_H + +/** @defgroup paste Paste Utilities + * + * Utilities for validating and encoding paste data for terminal input. + * + * ## Basic Usage + * + * Use ghostty_paste_is_safe() to check if paste data contains potentially + * dangerous sequences before sending it to the terminal. + * + * Use ghostty_paste_encode() to encode paste data for writing to the pty, + * including bracketed paste wrapping and unsafe byte stripping. + * + * ## Examples + * + * ### Safety Check + * + * @snippet c-vt-paste/src/main.c paste-safety + * + * ### Encoding + * + * @snippet c-vt-paste/src/main.c paste-encode + * + * @{ + */ + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Check if paste data is safe to paste into the terminal. + * + * Data is considered unsafe if it contains: + * - Newlines (`\n`) which can inject commands + * - The bracketed paste end sequence (`\x1b[201~`) which can be used + * to exit bracketed paste mode and inject commands + * + * This check is conservative and considers data unsafe regardless of + * current terminal state. + * + * @param data The paste data to check (must not be NULL) + * @param len The length of the data in bytes + * @return true if the data is safe to paste, false otherwise + */ +GHOSTTY_API bool ghostty_paste_is_safe(const char* data, size_t len); + +/** + * Encode paste data for writing to the terminal pty. + * + * This function prepares paste data for terminal input by: + * - Stripping unsafe control bytes (NUL, ESC, DEL, etc.) by replacing + * them with spaces + * - Wrapping the data in bracketed paste sequences if @p bracketed is true + * - Replacing newlines with carriage returns if @p bracketed is false + * + * The input @p data buffer is modified in place during encoding. The + * encoded result (potentially with bracketed paste prefix/suffix) is + * written to the output buffer. + * + * If the output buffer is too small, the function returns + * GHOSTTY_OUT_OF_SPACE and sets the required size in @p out_written. + * The caller can then retry with a sufficiently sized buffer. + * + * @param data The paste data to encode (modified in place, may be NULL) + * @param data_len The length of the input data in bytes + * @param bracketed Whether bracketed paste mode is active + * @param buf Output buffer to write the encoded result into (may be NULL) + * @param buf_len Size of the output buffer in bytes + * @param[out] out_written On success, the number of bytes written. On + * GHOSTTY_OUT_OF_SPACE, the required buffer size. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if the buffer + * is too small + */ +GHOSTTY_API GhosttyResult ghostty_paste_encode( + char* data, + size_t data_len, + bool bracketed, + char* buf, + size_t buf_len, + size_t* out_written); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_PASTE_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/point.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/point.h new file mode 100644 index 00000000000..8b717f4940c --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/point.h @@ -0,0 +1,89 @@ +/** + * @file point.h + * + * Terminal point types for referencing locations in the terminal grid. + */ + +#ifndef GHOSTTY_VT_POINT_H +#define GHOSTTY_VT_POINT_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup point Point + * + * Types for referencing x/y positions in the terminal grid under + * different coordinate systems (active area, viewport, full screen, + * scrollback history). + * + * @{ + */ + +/** + * A coordinate in the terminal grid. + * + * @ingroup point + */ +typedef struct { + /** Column (0-indexed). */ + uint16_t x; + + /** Row (0-indexed). May exceed page size for screen/history tags. */ + uint32_t y; +} GhosttyPointCoordinate; + +/** + * Point reference tag. + * + * Determines which coordinate system a point uses. + * + * @ingroup point + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Active area where the cursor can move. */ + GHOSTTY_POINT_TAG_ACTIVE = 0, + + /** Visible viewport (changes when scrolled). */ + GHOSTTY_POINT_TAG_VIEWPORT = 1, + + /** Full screen including scrollback. */ + GHOSTTY_POINT_TAG_SCREEN = 2, + + /** Scrollback history only (before active area). */ + GHOSTTY_POINT_TAG_HISTORY = 3, + GHOSTTY_POINT_TAG_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, + } GhosttyPointTag; + +/** + * Point value union. + * + * @ingroup point + */ +typedef union { + /** Coordinate (used for all tag variants). */ + GhosttyPointCoordinate coordinate; + + /** Padding for ABI compatibility. Do not use. */ + uint64_t _padding[2]; +} GhosttyPointValue; + +/** + * Tagged union for a point in the terminal grid. + * + * @ingroup point + */ +typedef struct { + GhosttyPointTag tag; + GhosttyPointValue value; +} GhosttyPoint; + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_POINT_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/render.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/render.h new file mode 100644 index 00000000000..c5b1d0d4fc2 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/render.h @@ -0,0 +1,729 @@ +/** + * @file render.h + * + * Render state for creating high performance renderers. + */ + +#ifndef GHOSTTY_VT_RENDER_H +#define GHOSTTY_VT_RENDER_H + +#include +#include +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup render Render State + * + * Represents the state required to render a visible screen (a viewport) + * of a terminal instance. This is stateful and optimized for repeated + * updates from a single terminal instance and only updating dirty regions + * of the screen. + * + * The key design principle of this API is that it only needs read/write + * access to the terminal instance during the update call. This allows + * the render state to minimally impact terminal IO performance and also + * allows the renderer to be safely multi-threaded (as long as a lock is + * held during the update call to ensure exclusive access to the terminal + * instance). + * + * The basic usage of this API is: + * + * 1. Create an empty render state + * 2. Update it from a terminal instance whenever you need. + * 3. Read from the render state to get the data needed to draw your frame. + * + * ## Dirty Tracking + * + * Dirty tracking is a key feature of the render state that allows renderers + * to efficiently determine what parts of the screen have changed and only + * redraw changed regions. + * + * The render state API keeps track of dirty state at two independent layers: + * a global dirty state that indicates whether the entire frame is clean, + * partially dirty, or fully dirty, and a per-row dirty state that allows + * tracking which rows in a partially dirty frame have changed. + * + * The user of the render state API is expected to unset both of these. + * The `update` call does not unset dirty state, it only updates it. + * + * An extremely important detail: setting one dirty state doesn't unset + * the other. For example, setting the global dirty state to false does not + * reset the row-level dirty flags. So, the caller of the render state API must + * be careful to manage both layers of dirty state correctly. + * + * ## Examples + * + * ### Creating and updating render state + * @snippet c-vt-render/src/main.c render-state-update + * + * ### Checking dirty state + * @snippet c-vt-render/src/main.c render-dirty-check + * + * ### Reading colors + * @snippet c-vt-render/src/main.c render-colors + * + * ### Reading cursor state + * @snippet c-vt-render/src/main.c render-cursor + * + * ### Iterating rows and cells + * @snippet c-vt-render/src/main.c render-row-iterate + * + * ### Resetting dirty state after rendering + * @snippet c-vt-render/src/main.c render-dirty-reset + * + * @{ + */ + +/** + * Dirty state of a render state after update. + * + * @ingroup render + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Not dirty at all; rendering can be skipped. */ + GHOSTTY_RENDER_STATE_DIRTY_FALSE = 0, + + /** Some rows changed; renderer can redraw incrementally. */ + GHOSTTY_RENDER_STATE_DIRTY_PARTIAL = 1, + + /** Global state changed; renderer should redraw everything. */ + GHOSTTY_RENDER_STATE_DIRTY_FULL = 2, + GHOSTTY_RENDER_STATE_DIRTY_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyRenderStateDirty; + +/** + * Visual style of the cursor. + * + * @ingroup render + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Bar cursor (DECSCUSR 5, 6). */ + GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_BAR = 0, + + /** Block cursor (DECSCUSR 1, 2). */ + GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_BLOCK = 1, + + /** Underline cursor (DECSCUSR 3, 4). */ + GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_UNDERLINE = 2, + + /** Hollow block cursor. */ + GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_BLOCK_HOLLOW = 3, + GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyRenderStateCursorVisualStyle; + +/** + * Queryable data kinds for ghostty_render_state_get(). + * + * @ingroup render + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Invalid / sentinel value. */ + GHOSTTY_RENDER_STATE_DATA_INVALID = 0, + + /** Viewport width in cells (uint16_t). */ + GHOSTTY_RENDER_STATE_DATA_COLS = 1, + + /** Viewport height in cells (uint16_t). */ + GHOSTTY_RENDER_STATE_DATA_ROWS = 2, + + /** Current dirty state (GhosttyRenderStateDirty). */ + GHOSTTY_RENDER_STATE_DATA_DIRTY = 3, + + /** Populate a pre-allocated GhosttyRenderStateRowIterator with row data + * from the render state (GhosttyRenderStateRowIterator). Row data is + * only valid as long as the underlying render state is not updated. + * It is unsafe to use row data after updating the render state. + * */ + GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR = 4, + + /** Default/current background color (GhosttyColorRgb). */ + GHOSTTY_RENDER_STATE_DATA_COLOR_BACKGROUND = 5, + + /** Default/current foreground color (GhosttyColorRgb). */ + GHOSTTY_RENDER_STATE_DATA_COLOR_FOREGROUND = 6, + + /** Cursor color when explicitly set by terminal state (GhosttyColorRgb). + * Returns GHOSTTY_INVALID_VALUE if no explicit cursor color is set; + * use COLOR_CURSOR_HAS_VALUE to check first. */ + GHOSTTY_RENDER_STATE_DATA_COLOR_CURSOR = 7, + + /** Whether an explicit cursor color is set (bool). */ + GHOSTTY_RENDER_STATE_DATA_COLOR_CURSOR_HAS_VALUE = 8, + + /** The active 256-color palette (GhosttyColorRgb[256]). */ + GHOSTTY_RENDER_STATE_DATA_COLOR_PALETTE = 9, + + /** The visual style of the cursor (GhosttyRenderStateCursorVisualStyle). */ + GHOSTTY_RENDER_STATE_DATA_CURSOR_VISUAL_STYLE = 10, + + /** Whether the cursor is visible based on terminal modes (bool). */ + GHOSTTY_RENDER_STATE_DATA_CURSOR_VISIBLE = 11, + + /** Whether the cursor should blink based on terminal modes (bool). */ + GHOSTTY_RENDER_STATE_DATA_CURSOR_BLINKING = 12, + + /** Whether the cursor is at a password input field (bool). */ + GHOSTTY_RENDER_STATE_DATA_CURSOR_PASSWORD_INPUT = 13, + + /** Whether the cursor is visible within the viewport (bool). + * If false, the cursor viewport position values are undefined. */ + GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_HAS_VALUE = 14, + + /** Cursor viewport x position in cells (uint16_t). + * Only valid when CURSOR_VIEWPORT_HAS_VALUE is true. */ + GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_X = 15, + + /** Cursor viewport y position in cells (uint16_t). + * Only valid when CURSOR_VIEWPORT_HAS_VALUE is true. */ + GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_Y = 16, + + /** Whether the cursor is on the tail of a wide character (bool). + * Only valid when CURSOR_VIEWPORT_HAS_VALUE is true. */ + GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_WIDE_TAIL = 17, + GHOSTTY_RENDER_STATE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyRenderStateData; + +/** + * Settable options for ghostty_render_state_set(). + * + * @ingroup render + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Set dirty state (GhosttyRenderStateDirty). */ + GHOSTTY_RENDER_STATE_OPTION_DIRTY = 0, + GHOSTTY_RENDER_STATE_OPTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyRenderStateOption; + +/** + * Queryable data kinds for ghostty_render_state_row_get(). + * + * @ingroup render + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Invalid / sentinel value. */ + GHOSTTY_RENDER_STATE_ROW_DATA_INVALID = 0, + + /** Whether the current row is dirty (bool). */ + GHOSTTY_RENDER_STATE_ROW_DATA_DIRTY = 1, + + /** The raw row value (GhosttyRow). */ + GHOSTTY_RENDER_STATE_ROW_DATA_RAW = 2, + + /** Populate a pre-allocated GhosttyRenderStateRowCells with cell data for + * the current row (GhosttyRenderStateRowCells). Cell data is only + * valid as long as the underlying render state is not updated. + * It is unsafe to use cell data after updating the render state. */ + GHOSTTY_RENDER_STATE_ROW_DATA_CELLS = 3, + + /** Row-local selected cell range (GhosttyRenderStateRowSelection). */ + GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION = 4, + GHOSTTY_RENDER_STATE_ROW_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyRenderStateRowData; + +/** + * Settable options for ghostty_render_state_row_set(). + * + * @ingroup render + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Set dirty state for the current row (bool). */ + GHOSTTY_RENDER_STATE_ROW_OPTION_DIRTY = 0, + GHOSTTY_RENDER_STATE_ROW_OPTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyRenderStateRowOption; + +/** + * Row-local selection range. + * + * This struct uses the sized-struct ABI pattern. Initialize with + * GHOSTTY_INIT_SIZED(GhosttyRenderStateRowSelection) before querying + * GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION. + * + * Querying GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION returns GHOSTTY_NO_VALUE + * if the current row does not intersect the current selection. + * + * @ingroup render + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyRenderStateRowSelection). */ + size_t size; + + /** Start column of the row-local selection range, inclusive. */ + uint16_t start_x; + + /** End column of the row-local selection range, inclusive. */ + uint16_t end_x; +} GhosttyRenderStateRowSelection; + +/** + * Render-state color information. + * + * This struct uses the sized-struct ABI pattern. Initialize with + * GHOSTTY_INIT_SIZED(GhosttyRenderStateColors) before calling + * ghostty_render_state_colors_get(). + * + * Example: + * @code + * GhosttyRenderStateColors colors = GHOSTTY_INIT_SIZED(GhosttyRenderStateColors); + * GhosttyResult result = ghostty_render_state_colors_get(state, &colors); + * @endcode + * + * @ingroup render + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyRenderStateColors). */ + size_t size; + + /** The default/current background color for the render state. */ + GhosttyColorRgb background; + + /** The default/current foreground color for the render state. */ + GhosttyColorRgb foreground; + + /** The cursor color when explicitly set by terminal state. */ + GhosttyColorRgb cursor; + + /** + * True when cursor contains a valid explicit cursor color value. + * If this is false, the cursor color should be ignored; it will + * contain undefined data. + * */ + bool cursor_has_value; + + /** The active 256-color palette for this render state. */ + GhosttyColorRgb palette[256]; +} GhosttyRenderStateColors; + +/** + * Create a new render state instance. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param state Pointer to store the created render state handle + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation + * failure + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_new(const GhosttyAllocator* allocator, + GhosttyRenderState* state); + +/** + * Free a render state instance. + * + * Releases all resources associated with the render state. After this call, + * the render state handle becomes invalid. + * + * @param state The render state handle to free (may be NULL) + * + * @ingroup render + */ +GHOSTTY_API void ghostty_render_state_free(GhosttyRenderState state); + +/** + * Update a render state instance from a terminal. + * + * This consumes terminal/screen dirty state in the same way as the internal + * render state update path. + * + * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param terminal The terminal handle to read from (NULL returns GHOSTTY_INVALID_VALUE) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or + * `terminal` is NULL, GHOSTTY_OUT_OF_MEMORY if updating the state requires + * allocation and that allocation fails + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_update(GhosttyRenderState state, + GhosttyTerminal terminal); + +/** + * Get a value from a render state. + * + * The `out` pointer must point to a value of the type corresponding to the + * requested data kind (see GhosttyRenderStateData). + * + * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param data The data kind to query + * @param[out] out Pointer to receive the queried value + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` is + * NULL or `data` is not a recognized enum value + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_get(GhosttyRenderState state, + GhosttyRenderStateData data, + void* out); + +/** + * Get multiple data fields from a render state in a single call. + * + * Each element in the keys array specifies a data kind, and the + * corresponding element in the values array receives the result. + * + * Processing stops at the first error; on success out_written + * is set to count, on error it is set to the index of the + * failing key (i.e. the number of values successfully written). + * + * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param count Number of key/value pairs + * @param keys Array of data kinds to query + * @param values Array of output pointers (types must match each key's + * documented output type) + * @param[out] out_written On return, receives the number of values + * successfully written (may be NULL) + * @return GHOSTTY_SUCCESS if all queries succeed + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_get_multi( + GhosttyRenderState state, + size_t count, + const GhosttyRenderStateData* keys, + void** values, + size_t* out_written); + +/** + * Set an option on a render state. + * + * The `value` pointer must point to a value of the type corresponding to the + * requested option kind (see GhosttyRenderStateOption). + * + * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param option The option to set + * @param[in] value Pointer to the value to set (NULL returns + * GHOSTTY_INVALID_VALUE) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or + * `value` is NULL + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_set(GhosttyRenderState state, + GhosttyRenderStateOption option, + const void* value); + +/** + * Get the current color information from a render state. + * + * This writes as many fields as fit in the caller-provided sized struct. + * `out_colors->size` must be set by the caller (typically via + * GHOSTTY_INIT_SIZED(GhosttyRenderStateColors)). + * + * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param[out] out_colors Sized output struct to receive render-state colors + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or + * `out_colors` is NULL, or if `out_colors->size` is smaller than + * `sizeof(size_t)` + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_colors_get(GhosttyRenderState state, + GhosttyRenderStateColors* out_colors); + +/** + * Create a new row iterator instance. + * + * All fields except the allocator are left undefined until populated + * via ghostty_render_state_get() with + * GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param[out] out_iterator On success, receives the created iterator handle + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation + * failure + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_row_iterator_new( + const GhosttyAllocator* allocator, + GhosttyRenderStateRowIterator* out_iterator); + +/** + * Free a render-state row iterator. + * + * @param iterator The iterator handle to free (may be NULL) + * + * @ingroup render + */ +GHOSTTY_API void ghostty_render_state_row_iterator_free(GhosttyRenderStateRowIterator iterator); + +/** + * Move a render-state row iterator to the next row. + * + * Returns true if the iterator moved successfully and row data is + * available to read at the new position. + * + * @param iterator The iterator handle to advance (may be NULL) + * @return true if advanced to the next row, false if `iterator` is + * NULL or if the iterator has reached the end + * + * @ingroup render + */ +GHOSTTY_API bool ghostty_render_state_row_iterator_next(GhosttyRenderStateRowIterator iterator); + +/** + * Get a value from the current row in a render-state row iterator. + * + * The `out` pointer must point to a value of the type corresponding to the + * requested data kind (see GhosttyRenderStateRowData). + * Call ghostty_render_state_row_iterator_next() at least once before + * calling this function. + * + * @param iterator The iterator handle to query (NULL returns GHOSTTY_INVALID_VALUE) + * @param data The data kind to query + * @param[out] out Pointer to receive the queried value + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if + * `iterator` is NULL or the iterator is not positioned on a row + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_row_get( + GhosttyRenderStateRowIterator iterator, + GhosttyRenderStateRowData data, + void* out); + +/** + * Get multiple data fields from the current row in a single call. + * + * Each element in the keys array specifies a data kind, and the + * corresponding element in the values array receives the result. + * + * Processing stops at the first error; on success out_written + * is set to count, on error it is set to the index of the + * failing key (i.e. the number of values successfully written). + * + * @param iterator The iterator handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param count Number of key/value pairs + * @param keys Array of data kinds to query + * @param values Array of output pointers (types must match each key's + * documented output type) + * @param[out] out_written On return, receives the number of values + * successfully written (may be NULL) + * @return GHOSTTY_SUCCESS if all queries succeed + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_row_get_multi( + GhosttyRenderStateRowIterator iterator, + size_t count, + const GhosttyRenderStateRowData* keys, + void** values, + size_t* out_written); + +/** + * Set an option on the current row in a render-state row iterator. + * + * The `value` pointer must point to a value of the type corresponding to the + * requested option kind (see GhosttyRenderStateRowOption). + * Call ghostty_render_state_row_iterator_next() at least once before + * calling this function. + * + * @param iterator The iterator handle to update (NULL returns GHOSTTY_INVALID_VALUE) + * @param option The option to set + * @param[in] value Pointer to the value to set (NULL returns + * GHOSTTY_INVALID_VALUE) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if + * `iterator` is NULL or the iterator is not positioned on a row + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_row_set( + GhosttyRenderStateRowIterator iterator, + GhosttyRenderStateRowOption option, + const void* value); + +/** + * Create a new row cells instance. + * + * All fields except the allocator are left undefined until populated + * via ghostty_render_state_row_get() with + * GHOSTTY_RENDER_STATE_ROW_DATA_CELLS. + * + * You can reuse this value repeatedly with ghostty_render_state_row_get() to + * avoid allocating a new cells container for every row. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param[out] out_cells On success, receives the created row cells handle + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation + * failure + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_row_cells_new( + const GhosttyAllocator* allocator, + GhosttyRenderStateRowCells* out_cells); + +/** + * Queryable data kinds for ghostty_render_state_row_cells_get(). + * + * @ingroup render + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Invalid / sentinel value. */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_INVALID = 0, + + /** The raw cell value (GhosttyCell). */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW = 1, + + /** The style for the current cell (GhosttyStyle). */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_STYLE = 2, + + /** The total number of grapheme codepoints including the base codepoint + * (uint32_t). Returns 0 if the cell has no text. */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_LEN = 3, + + /** Write grapheme codepoints into a caller-provided buffer (uint32_t*). + * The buffer must be at least graphemes_len elements. The base codepoint + * is written first, followed by any extra codepoints. */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_BUF = 4, + + /** The resolved background color of the cell (GhosttyColorRgb). + * Flattens the three possible sources: content-tag bg_color_rgb, + * content-tag bg_color_palette (looked up in the palette), or the + * style's bg_color. Returns GHOSTTY_INVALID_VALUE if the cell has + * no background color, in which case the caller should use whatever + * default background color it wants (e.g. the terminal background). */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_BG_COLOR = 5, + + /** The resolved foreground color of the cell (GhosttyColorRgb). + * Resolves palette indices through the palette. Bold color handling + * is not applied; the caller should handle bold styling separately. + * Returns GHOSTTY_INVALID_VALUE if the cell has no explicit foreground + * color, in which case the caller should use whatever default foreground + * color it wants (e.g. the terminal foreground). */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_FG_COLOR = 6, + + /** Whether the cell is contained within the current selection (bool). + * This returns true when the cell's column is within the current row's + * row-local selection range, and false otherwise. Rendering policy for + * selected cells (colors, inversion, etc.) is left to the caller. + * + * Renderers that can draw cells in spans may be more efficient querying + * GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION once per row and applying that + * range directly, avoiding one C API call per cell for selection state. */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_SELECTED = 7, + + /** Whether the cell has any explicit styling (bool). + * This is equivalent to querying the raw cell's + * GHOSTTY_CELL_DATA_HAS_STYLING value, but avoids materializing the raw + * GhosttyCell for renderers that only need to know whether fetching the + * full style is necessary. */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_HAS_STYLING = 8, + + /** + * Encode the current cell's full grapheme cluster as UTF-8 into a + * caller-provided buffer (GhosttyBuffer). + * + * The base codepoint is encoded first, followed by any extra grapheme + * codepoints. Returns GHOSTTY_SUCCESS with len=0 when the cell has no text. + * + * If ptr is NULL or cap is too small for a non-empty cell, returns + * GHOSTTY_OUT_OF_SPACE without writing any bytes and sets len to the required + * buffer size in bytes. + */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_UTF8 = 9, + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyRenderStateRowCellsData; + +/** + * Move a render-state row cells iterator to the next cell. + * + * Returns true if the iterator moved successfully and cell data is + * available to read at the new position. + * + * @param cells The row cells handle to advance (may be NULL) + * @return true if advanced to the next cell, false if `cells` is + * NULL or if the iterator has reached the end + * + * @ingroup render + */ +GHOSTTY_API bool ghostty_render_state_row_cells_next(GhosttyRenderStateRowCells cells); + +/** + * Move a render-state row cells iterator to a specific column. + * + * Positions the iterator at the given x (column) index so that + * subsequent reads return data for that cell. + * + * @param cells The row cells handle to reposition (NULL returns + * GHOSTTY_INVALID_VALUE) + * @param x The zero-based column index to select + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `cells` + * is NULL or `x` is out of range + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_row_cells_select( + GhosttyRenderStateRowCells cells, uint16_t x); + +/** + * Get a value from the current cell in a render-state row cells iterator. + * + * The `out` pointer must point to a value of the type corresponding to the + * requested data kind (see GhosttyRenderStateRowCellsData). + * Call ghostty_render_state_row_cells_next() or + * ghostty_render_state_row_cells_select() at least once before + * calling this function. + * + * @param cells The row cells handle to query (NULL returns GHOSTTY_INVALID_VALUE) + * @param data The data kind to query + * @param[out] out Pointer to receive the queried value + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if + * `cells` is NULL or the iterator is not positioned on a cell + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_row_cells_get( + GhosttyRenderStateRowCells cells, + GhosttyRenderStateRowCellsData data, + void* out); + +/** + * Get multiple data fields from the current cell in a single call. + * + * Each element in the keys array specifies a data kind, and the + * corresponding element in the values array receives the result. + * + * Processing stops at the first error; on success out_written + * is set to count, on error it is set to the index of the + * failing key (i.e. the number of values successfully written). + * + * @param cells The row cells handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param count Number of key/value pairs + * @param keys Array of data kinds to query + * @param values Array of output pointers (types must match each key's + * documented output type) + * @param[out] out_written On return, receives the number of values + * successfully written (may be NULL) + * @return GHOSTTY_SUCCESS if all queries succeed + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_row_cells_get_multi( + GhosttyRenderStateRowCells cells, + size_t count, + const GhosttyRenderStateRowCellsData* keys, + void** values, + size_t* out_written); + +/** + * Free a row cells instance. + * + * @param cells The row cells handle to free (may be NULL) + * + * @ingroup render + */ +GHOSTTY_API void ghostty_render_state_row_cells_free(GhosttyRenderStateRowCells cells); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_RENDER_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/screen.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/screen.h new file mode 100644 index 00000000000..9f639b58313 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/screen.h @@ -0,0 +1,400 @@ +/** + * @file screen.h + * + * Terminal screen cell and row types. + */ + +#ifndef GHOSTTY_VT_SCREEN_H +#define GHOSTTY_VT_SCREEN_H + +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup screen Screen + * + * Terminal screen cell and row types. + * + * These types represent the contents of a terminal screen. A GhosttyCell + * is a single grid cell and a GhosttyRow is a single row. Both are opaque + * values whose fields are accessed via ghostty_cell_get() and + * ghostty_row_get() respectively. + * + * @{ + */ + +/** + * Opaque cell value. + * + * Represents a single terminal cell. The internal layout is opaque and + * must be queried via ghostty_cell_get(). Obtain cell values from + * terminal query APIs. + * + * @ingroup screen + */ +typedef uint64_t GhosttyCell; + +/** + * Opaque row value. + * + * Represents a single terminal row. The internal layout is opaque and + * must be queried via ghostty_row_get(). Obtain row values from + * terminal query APIs. + * + * @ingroup screen + */ +typedef uint64_t GhosttyRow; + +/** + * Cell content tag. + * + * Describes what kind of content a cell holds. + * + * @ingroup screen + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** A single codepoint (may be zero for empty). */ + GHOSTTY_CELL_CONTENT_CODEPOINT = 0, + + /** A codepoint that is part of a multi-codepoint grapheme cluster. */ + GHOSTTY_CELL_CONTENT_CODEPOINT_GRAPHEME = 1, + + /** No text; background color from palette. */ + GHOSTTY_CELL_CONTENT_BG_COLOR_PALETTE = 2, + + /** No text; background color as RGB. */ + GHOSTTY_CELL_CONTENT_BG_COLOR_RGB = 3, + GHOSTTY_CELL_CONTENT_TAG_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyCellContentTag; + +/** + * Cell wide property. + * + * Describes the width behavior of a cell. + * + * @ingroup screen + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Not a wide character, cell width 1. */ + GHOSTTY_CELL_WIDE_NARROW = 0, + + /** Wide character, cell width 2. */ + GHOSTTY_CELL_WIDE_WIDE = 1, + + /** Spacer after wide character. Do not render. */ + GHOSTTY_CELL_WIDE_SPACER_TAIL = 2, + + /** Spacer at end of soft-wrapped line for a wide character. */ + GHOSTTY_CELL_WIDE_SPACER_HEAD = 3, + GHOSTTY_CELL_WIDE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyCellWide; + +/** + * Semantic content type of a cell. + * + * Set by semantic prompt sequences (OSC 133) to distinguish between + * command output, user input, and shell prompt text. + * + * @ingroup screen + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Regular output content, such as command output. */ + GHOSTTY_CELL_SEMANTIC_OUTPUT = 0, + + /** Content that is part of user input. */ + GHOSTTY_CELL_SEMANTIC_INPUT = 1, + + /** Content that is part of a shell prompt. */ + GHOSTTY_CELL_SEMANTIC_PROMPT = 2, + GHOSTTY_CELL_SEMANTIC_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyCellSemanticContent; + +/** + * Cell data types. + * + * These values specify what type of data to extract from a cell + * using `ghostty_cell_get`. + * + * @ingroup screen + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Invalid data type. Never results in any data extraction. */ + GHOSTTY_CELL_DATA_INVALID = 0, + + /** + * The codepoint of the cell (0 if empty or bg-color-only). + * + * Output type: uint32_t * + */ + GHOSTTY_CELL_DATA_CODEPOINT = 1, + + /** + * The content tag describing what kind of content is in the cell. + * + * Output type: GhosttyCellContentTag * + */ + GHOSTTY_CELL_DATA_CONTENT_TAG = 2, + + /** + * The wide property of the cell. + * + * Output type: GhosttyCellWide * + */ + GHOSTTY_CELL_DATA_WIDE = 3, + + /** + * Whether the cell has text to render. + * + * Output type: bool * + */ + GHOSTTY_CELL_DATA_HAS_TEXT = 4, + + /** + * Whether the cell has non-default styling. + * + * Output type: bool * + */ + GHOSTTY_CELL_DATA_HAS_STYLING = 5, + + /** + * The style ID for the cell (for use with style lookups). + * + * Output type: uint16_t * + */ + GHOSTTY_CELL_DATA_STYLE_ID = 6, + + /** + * Whether the cell has a hyperlink. + * + * Output type: bool * + */ + GHOSTTY_CELL_DATA_HAS_HYPERLINK = 7, + + /** + * Whether the cell is protected. + * + * Output type: bool * + */ + GHOSTTY_CELL_DATA_PROTECTED = 8, + + /** + * The semantic content type of the cell (from OSC 133). + * + * Output type: GhosttyCellSemanticContent * + */ + GHOSTTY_CELL_DATA_SEMANTIC_CONTENT = 9, + + /** + * The palette index for the cell's background color. + * Only valid when content_tag is GHOSTTY_CELL_CONTENT_BG_COLOR_PALETTE. + * + * Output type: GhosttyColorPaletteIndex * + */ + GHOSTTY_CELL_DATA_COLOR_PALETTE = 10, + + /** + * The RGB value for the cell's background color. + * Only valid when content_tag is GHOSTTY_CELL_CONTENT_BG_COLOR_RGB. + * + * Output type: GhosttyColorRgb * + */ + GHOSTTY_CELL_DATA_COLOR_RGB = 11, + GHOSTTY_CELL_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyCellData; + +/** + * Row semantic prompt state. + * + * Indicates whether any cells in a row are part of a shell prompt, + * as reported by OSC 133 sequences. + * + * @ingroup screen + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** No prompt cells in this row. */ + GHOSTTY_ROW_SEMANTIC_NONE = 0, + + /** Prompt cells exist and this is a primary prompt line. */ + GHOSTTY_ROW_SEMANTIC_PROMPT = 1, + + /** Prompt cells exist and this is a continuation line. */ + GHOSTTY_ROW_SEMANTIC_PROMPT_CONTINUATION = 2, + GHOSTTY_ROW_SEMANTIC_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyRowSemanticPrompt; + +/** + * Row data types. + * + * These values specify what type of data to extract from a row + * using `ghostty_row_get`. + * + * @ingroup screen + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Invalid data type. Never results in any data extraction. */ + GHOSTTY_ROW_DATA_INVALID = 0, + + /** + * Whether this row is soft-wrapped. + * + * Output type: bool * + */ + GHOSTTY_ROW_DATA_WRAP = 1, + + /** + * Whether this row is a continuation of a soft-wrapped row. + * + * Output type: bool * + */ + GHOSTTY_ROW_DATA_WRAP_CONTINUATION = 2, + + /** + * Whether any cells in this row have grapheme clusters. + * + * Output type: bool * + */ + GHOSTTY_ROW_DATA_GRAPHEME = 3, + + /** + * Whether any cells in this row have styling (may have false positives). + * + * Output type: bool * + */ + GHOSTTY_ROW_DATA_STYLED = 4, + + /** + * Whether any cells in this row have hyperlinks (may have false positives). + * + * Output type: bool * + */ + GHOSTTY_ROW_DATA_HYPERLINK = 5, + + /** + * The semantic prompt state of this row. + * + * Output type: GhosttyRowSemanticPrompt * + */ + GHOSTTY_ROW_DATA_SEMANTIC_PROMPT = 6, + + /** + * Whether this row contains a Kitty virtual placeholder. + * + * Output type: bool * + */ + GHOSTTY_ROW_DATA_KITTY_VIRTUAL_PLACEHOLDER = 7, + + /** + * Whether this row is dirty and requires a redraw. + * + * Output type: bool * + */ + GHOSTTY_ROW_DATA_DIRTY = 8, + GHOSTTY_ROW_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyRowData; + +/** + * Get data from a cell. + * + * Extracts typed data from the given cell based on the specified + * data type. The output pointer must be of the appropriate type for the + * requested data kind. Valid data types and output types are documented + * in the `GhosttyCellData` enum. + * + * @param cell The cell value + * @param data The type of data to extract + * @param out Pointer to store the extracted data (type depends on data parameter) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the + * data type is invalid + * + * @ingroup screen + */ +GHOSTTY_API GhosttyResult ghostty_cell_get(GhosttyCell cell, + GhosttyCellData data, + void *out); + +/** + * Get multiple data fields from a cell in a single call. + * + * Each element in the keys array specifies a data kind, and the + * corresponding element in the values array receives the result. + * + * Processing stops at the first error; on success out_written + * is set to count, on error it is set to the index of the + * failing key (i.e. the number of values successfully written). + * + * @param cell The cell value + * @param count Number of key/value pairs + * @param keys Array of data kinds to query + * @param values Array of output pointers (types must match each key's + * documented output type) + * @param[out] out_written On return, receives the number of values + * successfully written (may be NULL) + * @return GHOSTTY_SUCCESS if all queries succeed + * + * @ingroup screen + */ +GHOSTTY_API GhosttyResult ghostty_cell_get_multi(GhosttyCell cell, + size_t count, + const GhosttyCellData* keys, + void** values, + size_t* out_written); + +/** + * Get data from a row. + * + * Extracts typed data from the given row based on the specified + * data type. The output pointer must be of the appropriate type for the + * requested data kind. Valid data types and output types are documented + * in the `GhosttyRowData` enum. + * + * @param row The row value + * @param data The type of data to extract + * @param out Pointer to store the extracted data (type depends on data parameter) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the + * data type is invalid + * + * @ingroup screen + */ +GHOSTTY_API GhosttyResult ghostty_row_get(GhosttyRow row, + GhosttyRowData data, + void *out); + +/** + * Get multiple data fields from a row in a single call. + * + * Each element in the keys array specifies a data kind, and the + * corresponding element in the values array receives the result. + * + * Processing stops at the first error; on success out_written + * is set to count, on error it is set to the index of the + * failing key (i.e. the number of values successfully written). + * + * @param row The row value + * @param count Number of key/value pairs + * @param keys Array of data kinds to query + * @param values Array of output pointers (types must match each key's + * documented output type) + * @param[out] out_written On return, receives the number of values + * successfully written (may be NULL) + * @return GHOSTTY_SUCCESS if all queries succeed + * + * @ingroup screen + */ +GHOSTTY_API GhosttyResult ghostty_row_get_multi(GhosttyRow row, + size_t count, + const GhosttyRowData* keys, + void** values, + size_t* out_written); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_SCREEN_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/selection.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/selection.h new file mode 100644 index 00000000000..3b926aab628 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/selection.h @@ -0,0 +1,1061 @@ +/** + * @file selection.h + * + * Selection range type for specifying a region of terminal content. + */ + +#ifndef GHOSTTY_VT_SELECTION_H +#define GHOSTTY_VT_SELECTION_H + +#include +#include +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup selection Selection + * + * A snapshot selection range defined by two grid references that identifies + * a contiguous or rectangular region of terminal content. + * + * The start and end values are GhosttyGridRef values. They are therefore + * untracked grid references and inherit the same lifetime rules: they are + * only safe to use until the next mutating operation on the terminal that + * produced them, including freeing the terminal. To keep a selection valid + * across terminal mutations, callers must maintain tracked grid references + * for the endpoints and reconstruct a GhosttySelection from fresh snapshots + * when needed. + * + * Selection gestures provide a reusable state machine for turning UI pointer + * interactions into selection snapshots. A caller creates one + * GhosttySelectionGesture per active gesture stream, reuses typed + * GhosttySelectionGestureEvent objects for synthetic press, drag, release, + * autoscroll tick, and deep-press events, and applies each event with + * ghostty_selection_gesture_event(). The returned GhosttySelection is a + * snapshot; the embedder decides whether to render it, format/copy it, or + * install it as the terminal's active selection. + * + * ## Examples + * + * @snippet c-vt-selection/src/main.c selection-main + * @snippet c-vt-selection-gesture/src/main.c selection-gesture-main + * + * @{ + */ + +/** + * Opaque handle to state for interpreting terminal selection gestures. + * + * The gesture owns only the state required to interpret pointer events. Calls + * that use a gesture are not concurrency-safe and must be serialized with + * terminal mutations. + * + * @ingroup selection + */ +typedef struct GhosttySelectionGestureImpl* GhosttySelectionGesture; + +/** + * Opaque handle to reusable input data for selection gesture operations. + * + * Event options are set with ghostty_selection_gesture_event_set(). Individual + * gesture operations document which options are required or optional. + * + * @ingroup selection + */ +typedef struct GhosttySelectionGestureEventImpl* GhosttySelectionGestureEvent; + +/** + * A snapshot selection range defined by two grid references. + * + * Both endpoints are inclusive. The endpoints preserve selection direction + * and may be reversed; callers must not assume that start is the top-left + * endpoint or that end is the bottom-right endpoint. + * + * When rectangle is false, the endpoints describe a linear selection. When + * rectangle is true, the same endpoints are interpreted as opposite corners + * of a rectangular/block selection. + * + * The start and end values are untracked GhosttyGridRef snapshots and are + * only valid until the next mutating operation on the terminal that produced + * them unless the selection is reconstructed from tracked references. + * + * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. + * + * @ingroup selection + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttySelection). */ + size_t size; + + /** + * Start of the selection range (inclusive). + * + * This may be after end in terminal order. It is an untracked + * GhosttyGridRef snapshot and follows untracked grid-ref lifetime rules. + */ + GhosttyGridRef start; + + /** + * End of the selection range (inclusive). + * + * This may be before start in terminal order. It is an untracked + * GhosttyGridRef snapshot and follows untracked grid-ref lifetime rules. + */ + GhosttyGridRef end; + + /** + * Whether the endpoints are interpreted as a rectangular/block selection + * rather than a linear selection. + */ + bool rectangle; +} GhosttySelection; + +/** + * Options for deriving a word selection from a terminal grid reference. + * + * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. + * If boundary_codepoints is NULL and boundary_codepoints_len is 0, Ghostty's + * default word-boundary codepoints are used. If boundary_codepoints_len is + * non-zero, boundary_codepoints must not be NULL. + * + * @ingroup selection + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectWordOptions). */ + size_t size; + + /** Grid reference under which to derive the word selection. */ + GhosttyGridRef ref; + + /** Optional word-boundary codepoints as uint32_t scalar values. */ + const uint32_t* boundary_codepoints; + + /** Number of entries in boundary_codepoints. */ + size_t boundary_codepoints_len; +} GhosttyTerminalSelectWordOptions; + +/** + * Options for deriving the nearest word selection between two grid references. + * + * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. + * If boundary_codepoints is NULL and boundary_codepoints_len is 0, Ghostty's + * default word-boundary codepoints are used. If boundary_codepoints_len is + * non-zero, boundary_codepoints must not be NULL. + * + * @ingroup selection + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectWordBetweenOptions). */ + size_t size; + + /** Starting grid reference for the inclusive search range. */ + GhosttyGridRef start; + + /** Ending grid reference for the inclusive search range. */ + GhosttyGridRef end; + + /** Optional word-boundary codepoints as uint32_t scalar values. */ + const uint32_t* boundary_codepoints; + + /** Number of entries in boundary_codepoints. */ + size_t boundary_codepoints_len; +} GhosttyTerminalSelectWordBetweenOptions; + +/** + * Options for deriving a line selection from a terminal grid reference. + * + * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. + * If whitespace is NULL and whitespace_len is 0, Ghostty's default line-trim + * whitespace codepoints are used. If whitespace_len is non-zero, whitespace + * must not be NULL. + * + * @ingroup selection + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectLineOptions). */ + size_t size; + + /** Grid reference under which to derive the line selection. */ + GhosttyGridRef ref; + + /** Optional codepoints to trim from the start and end of the line. */ + const uint32_t* whitespace; + + /** Number of entries in whitespace. */ + size_t whitespace_len; + + /** Whether semantic prompt state changes should bound the line selection. */ + bool semantic_prompt_boundary; +} GhosttyTerminalSelectLineOptions; + +/** + * Options for one-shot formatting of a terminal selection. + * + * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. + * + * If selection is NULL, the terminal's current active selection is used. + * If selection is non-NULL, that caller-provided snapshot selection is used. + * + * The selection is formatted from the terminal's active screen using the same + * formatting semantics as GhosttyFormatter. For copy/clipboard behavior + * matching Ghostty's Screen.selectionString(), use plain output with unwrap + * and trim both set to true. + * + * @ingroup selection + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectionFormatOptions). */ + size_t size; + + /** Output format to emit. */ + GhosttyFormatterFormat emit; + + /** Whether to unwrap soft-wrapped lines. */ + bool unwrap; + + /** Whether to trim trailing whitespace on non-blank lines. */ + bool trim; + + /** + * Optional selection to format. + * + * If NULL, the terminal's current active selection is used. If the terminal + * has no active selection, formatting returns GHOSTTY_NO_VALUE. + * + * If non-NULL, the pointed-to selection must be a valid snapshot selection + * for this terminal and must obey GhosttySelection lifetime rules. + */ + const GhosttySelection *selection; +} GhosttyTerminalSelectionFormatOptions; + +/** + * Ordering of a selection's endpoints in terminal coordinates. + * + * Mirrored orders are only produced by rectangular selections whose start + * and end endpoints are on opposite diagonal corners that are not simple + * top-left-to-bottom-right or bottom-right-to-top-left orderings. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Start is before end in top-left to bottom-right order. */ + GHOSTTY_SELECTION_ORDER_FORWARD = 0, + + /** End is before start in top-left to bottom-right order. */ + GHOSTTY_SELECTION_ORDER_REVERSE = 1, + + /** Rectangular selection from top-right to bottom-left. */ + GHOSTTY_SELECTION_ORDER_MIRRORED_FORWARD = 2, + + /** Rectangular selection from bottom-left to top-right. */ + GHOSTTY_SELECTION_ORDER_MIRRORED_REVERSE = 3, + + GHOSTTY_SELECTION_ORDER_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionOrder; + +/** + * Operation used to adjust a selection endpoint. + * + * Adjustment mutates the selection's logical end endpoint, not whichever + * endpoint is visually bottom/right. This preserves keyboard and drag + * behavior for both forward and reversed selections. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Move left to the previous non-empty cell, wrapping upward. */ + GHOSTTY_SELECTION_ADJUST_LEFT = 0, + + /** Move right to the next non-empty cell, wrapping downward. */ + GHOSTTY_SELECTION_ADJUST_RIGHT = 1, + + /** + * Move up one row at the current column, or to the beginning of the + * line if already at the top. + */ + GHOSTTY_SELECTION_ADJUST_UP = 2, + + /** + * Move down to the next non-blank row at the current column, or to the + * end of the line if none exists. + */ + GHOSTTY_SELECTION_ADJUST_DOWN = 3, + + /** Move to the top-left cell of the screen. */ + GHOSTTY_SELECTION_ADJUST_HOME = 4, + + /** Move to the right edge of the last non-blank row on the screen. */ + GHOSTTY_SELECTION_ADJUST_END = 5, + + /** + * Move up by one terminal page height, or to home if that would move + * past the top. + */ + GHOSTTY_SELECTION_ADJUST_PAGE_UP = 6, + + /** + * Move down by one terminal page height, or to end if that would move + * past the bottom. + */ + GHOSTTY_SELECTION_ADJUST_PAGE_DOWN = 7, + + /** Move to the left edge of the current line. */ + GHOSTTY_SELECTION_ADJUST_BEGINNING_OF_LINE = 8, + + /** Move to the right edge of the current line. */ + GHOSTTY_SELECTION_ADJUST_END_OF_LINE = 9, + + GHOSTTY_SELECTION_ADJUST_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionAdjust; + +/** + * Selection behavior chosen for a gesture's click sequence. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Cell-granular drag selection. */ + GHOSTTY_SELECTION_GESTURE_BEHAVIOR_CELL = 0, + + /** Word selection on press and word-granular drag selection. */ + GHOSTTY_SELECTION_GESTURE_BEHAVIOR_WORD = 1, + + /** Line selection on press and line-granular drag selection. */ + GHOSTTY_SELECTION_GESTURE_BEHAVIOR_LINE = 2, + + /** Semantic command output selection on press and drag. */ + GHOSTTY_SELECTION_GESTURE_BEHAVIOR_OUTPUT = 3, + + GHOSTTY_SELECTION_GESTURE_BEHAVIOR_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionGestureBehavior; + +/** + * Selection behaviors for single-, double-, and triple-click gestures. + * + * @ingroup selection + */ +typedef struct { + /** Behavior for single-click selection gestures. */ + GhosttySelectionGestureBehavior single_click; + + /** Behavior for double-click selection gestures. */ + GhosttySelectionGestureBehavior double_click; + + /** Behavior for triple-click selection gestures. */ + GhosttySelectionGestureBehavior triple_click; +} GhosttySelectionGestureBehaviors; + +/** + * Display geometry used to interpret selection gesture drag events. + * + * @ingroup selection + */ +typedef struct { + /** Number of columns in the rendered terminal grid. Must be non-zero. */ + uint32_t columns; + + /** Width of one terminal cell in surface pixels. Must be non-zero. */ + uint32_t cell_width; + + /** Left padding before the terminal grid begins in surface pixels. */ + uint32_t padding_left; + + /** Height of the rendered terminal surface in surface pixels. Must be non-zero. */ + uint32_t screen_height; +} GhosttySelectionGestureGeometry; + +/** + * Current autoscroll direction for an active selection drag gesture. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** No selection autoscroll is requested. */ + GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_NONE = 0, + + /** Selection dragging should autoscroll the viewport upward. */ + GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_UP = 1, + + /** Selection dragging should autoscroll the viewport downward. */ + GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_DOWN = 2, + + GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionGestureAutoscroll; + +/** + * Data fields readable from a selection gesture with + * ghostty_selection_gesture_get(). + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Current click count: uint8_t*. 0 means inactive. */ + GHOSTTY_SELECTION_GESTURE_DATA_CLICK_COUNT = 0, + + /** Whether the current/last left-click gesture has dragged: bool*. */ + GHOSTTY_SELECTION_GESTURE_DATA_DRAGGED = 1, + + /** Current autoscroll request: GhosttySelectionGestureAutoscroll*. */ + GHOSTTY_SELECTION_GESTURE_DATA_AUTOSCROLL = 2, + + /** Current gesture behavior: GhosttySelectionGestureBehavior*. */ + GHOSTTY_SELECTION_GESTURE_DATA_BEHAVIOR = 3, + + /** + * Current left-click anchor: GhosttyGridRef*. + * + * Returns GHOSTTY_NO_VALUE if there is no valid active anchor. On success, + * writes an untracked GhosttyGridRef snapshot with normal GhosttyGridRef + * lifetime rules. + */ + GHOSTTY_SELECTION_GESTURE_DATA_ANCHOR = 4, + + GHOSTTY_SELECTION_GESTURE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionGestureData; + +/** + * Selection gesture event type. + * + * The event type is fixed when the event is created. Each event type documents + * which options are valid and which options are required by gesture operations. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Press event for ghostty_selection_gesture_event(). */ + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_PRESS = 0, + + /** Release event for ghostty_selection_gesture_event(). */ + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_RELEASE = 1, + + /** Drag event for ghostty_selection_gesture_event(). */ + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DRAG = 2, + + /** Autoscroll tick event for ghostty_selection_gesture_event(). */ + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_AUTOSCROLL_TICK = 3, + + /** Deep press event for ghostty_selection_gesture_event(). */ + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DEEP_PRESS = 4, + + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionGestureEventType; + +/** + * Options stored on a reusable selection gesture event. + * + * Passing NULL as the value to ghostty_selection_gesture_event_set() clears the + * corresponding option. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** + * Grid reference under the pointer: GhosttyGridRef*. + * + * Required for PRESS and DRAG events. Optional for RELEASE events; when unset + * or cleared, release records that the pointer did not map to a valid cell. + */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF = 0, + + /** + * Surface-space pointer position: GhosttySurfacePosition*. + * + * Valid for PRESS, DRAG, and AUTOSCROLL_TICK. + */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_POSITION = 1, + + /** Maximum repeat-click distance in pixels: double*. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REPEAT_DISTANCE = 2, + + /** + * Optional monotonic event time in nanoseconds: uint64_t*. + * + * If unset, press treats the event as untimed and only single-click behavior + * is available. + */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_TIME_NS = 3, + + /** Maximum interval between repeat clicks in nanoseconds: uint64_t*. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REPEAT_INTERVAL_NS = 4, + + /** + * Word-boundary codepoints: GhosttyCodepoints*. + * + * The codepoints are copied into event-owned storage when set. If unset, + * operations that need word boundaries use Ghostty's defaults. + * + * Valid for PRESS, DRAG, AUTOSCROLL_TICK, and DEEP_PRESS. + */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_WORD_BOUNDARY_CODEPOINTS = 5, + + /** + * Selection behavior table: GhosttySelectionGestureBehaviors*. + * + * If unset, press uses the default behavior table: cell, word, line. + */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_BEHAVIORS = 6, + + /** Whether a drag or autoscroll tick should produce a rectangular selection: bool*. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_RECTANGLE = 7, + + /** Drag display geometry: GhosttySelectionGestureGeometry*. Required for DRAG and AUTOSCROLL_TICK. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_GEOMETRY = 8, + + /** Viewport coordinate for an autoscroll tick: GhosttyPointCoordinate*. Required for AUTOSCROLL_TICK. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_VIEWPORT = 9, + + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionGestureEventOption; + +/** + * Create a reusable selection gesture event object. + * + * @param allocator Allocator, or NULL for the default allocator + * @param out_event Receives the created event handle + * @param type Event type. This is fixed for the lifetime of the event. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if out_event is + * NULL or type is invalid, or GHOSTTY_OUT_OF_MEMORY if allocation fails + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_event_new( + const GhosttyAllocator* allocator, + GhosttySelectionGestureEvent* out_event, + GhosttySelectionGestureEventType type); + +/** + * Free a selection gesture event object. + * + * Passing NULL is allowed and is a no-op. + * + * @param event Selection gesture event handle to free + * + * @ingroup selection + */ +GHOSTTY_API void ghostty_selection_gesture_event_free( + GhosttySelectionGestureEvent event); + +/** + * Set or clear an option on a selection gesture event. + * + * The value type depends on option and is documented by + * GhosttySelectionGestureEventOption. Passing NULL for value clears the option. + * + * @param event Selection gesture event handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param option Event option to set or clear + * @param value Pointer to the input value for option, or NULL to clear + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY if copying + * event-owned data fails, or GHOSTTY_INVALID_VALUE if event, option, or + * value is invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_event_set( + GhosttySelectionGestureEvent event, + GhosttySelectionGestureEventOption option, + const void* value); + +/** + * Apply a selection gesture event and return the resulting selection snapshot. + * + * This dispatches to the gesture operation matching the event's fixed type. + * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_PRESS, the event must have + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF set before calling this function. + * All other press options use their initialized defaults when unset or cleared. + * + * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_RELEASE, only + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF is valid. It is optional; if unset or + * cleared, release records that the pointer did not map to a valid cell. Release + * events update gesture state but do not produce a selection, so this function + * returns GHOSTTY_NO_VALUE after applying them. + * + * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DRAG, + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF and + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_GEOMETRY are required. Position, + * rectangle, and word-boundary codepoints are optional and use initialized + * defaults when unset or cleared. + * + * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_AUTOSCROLL_TICK, + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_VIEWPORT and + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_GEOMETRY are required. Position, + * rectangle, and word-boundary codepoints are optional and use initialized + * defaults when unset or cleared. + * + * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DEEP_PRESS, only + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_WORD_BOUNDARY_CODEPOINTS is valid. It is + * optional and uses initialized defaults when unset or cleared. + * + * The returned selection is not installed as the terminal's current selection. + * It is a snapshot with the same lifetime rules as GhosttySelection. + * + * @param gesture Selection gesture handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param terminal Terminal used to interpret and update gesture state + * @param event Selection gesture event handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param[out] out_selection On success, receives the resulting selection. May + * be NULL to apply the event and discard the selection result. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the event does not + * currently produce a selection, GHOSTTY_OUT_OF_MEMORY if tracking + * gesture state fails, or GHOSTTY_INVALID_VALUE if gesture, terminal, + * event, or required event data is invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_event( + GhosttySelectionGesture gesture, + GhosttyTerminal terminal, + GhosttySelectionGestureEvent event, + GhosttySelection* out_selection); + +/** + * Create a selection gesture object. + * + * The gesture stores mutable state for terminal text selection gestures. The + * gesture is not bound to a terminal at creation time; terminal-dependent APIs + * take the terminal explicitly. + * + * @param allocator Allocator, or NULL for the default allocator + * @param out_gesture Receives the created gesture handle + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if out_gesture is + * NULL, or GHOSTTY_OUT_OF_MEMORY if allocation fails + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_new( + const GhosttyAllocator* allocator, + GhosttySelectionGesture* out_gesture); + +/** + * Free a selection gesture object. + * + * This releases any tracked terminal references owned by the gesture using the + * provided terminal, then frees the gesture object. Passing NULL for gesture is + * allowed and is a no-op. + * + * If the terminal is still alive, pass the terminal most recently used with the + * gesture so any tracked terminal references can be released correctly. If the + * terminal has already been freed, pass NULL for terminal; the terminal's page + * storage has already released the underlying tracked references, so the + * gesture wrapper can be safely discarded without touching the stale terminal + * state. + * + * @param gesture Selection gesture handle to free + * @param terminal Terminal used to release tracked gesture state, or NULL if + * the terminal has already been freed + * + * @ingroup selection + */ +GHOSTTY_API void ghostty_selection_gesture_free( + GhosttySelectionGesture gesture, + GhosttyTerminal terminal); + +/** + * Reset any active selection gesture state. + * + * This cancels the active click sequence and releases any tracked terminal + * references owned by the gesture without freeing the gesture object. + * Passing NULL is allowed and is a no-op. + * + * @param gesture Selection gesture handle to reset + * @param terminal Terminal used to release tracked gesture state + * + * @ingroup selection + */ +GHOSTTY_API void ghostty_selection_gesture_reset( + GhosttySelectionGesture gesture, + GhosttyTerminal terminal); + +/** + * Read data from a selection gesture. + * + * The type of value depends on data and is documented by + * GhosttySelectionGestureData. For GHOSTTY_SELECTION_GESTURE_DATA_ANCHOR, + * the returned GhosttyGridRef is an untracked snapshot with normal grid-ref + * lifetime rules. + * + * @param gesture Selection gesture handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param terminal Terminal used to validate terminal-backed gesture state + * @param data Data field to read + * @param value Output pointer whose type depends on data + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the requested data + * has no value, or GHOSTTY_INVALID_VALUE if gesture, terminal, data, or + * value is invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_get( + GhosttySelectionGesture gesture, + GhosttyTerminal terminal, + GhosttySelectionGestureData data, + void* value); + +/** + * Read multiple data fields from a selection gesture in a single call. + * + * This is an optimization over calling ghostty_selection_gesture_get() multiple + * times. Each entry in values must point to storage of the type documented by + * the corresponding GhosttySelectionGestureData key. + * + * If any individual read fails, the function returns that error and writes the + * index of the failing key to out_written when out_written is non-NULL. On + * success, out_written receives count when non-NULL. + * + * @param gesture Selection gesture handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param terminal Terminal used to validate terminal-backed gesture state + * @param count Number of data fields to read + * @param keys Data fields to read (must not be NULL) + * @param values Output pointers corresponding to keys (must not be NULL) + * @param out_written Optional number of fields read, or failing index on error + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if a requested data + * field has no value, or GHOSTTY_INVALID_VALUE if gesture, terminal, + * keys, values, or a value pointer is invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_get_multi( + GhosttySelectionGesture gesture, + GhosttyTerminal terminal, + size_t count, + const GhosttySelectionGestureData* keys, + void** values, + size_t* out_written); + +/** + * Derive a word selection snapshot from a terminal grid reference. + * + * The returned selection is not installed as the terminal's current + * selection. It is a snapshot with the same lifetime rules as GhosttySelection. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param options Word-selection options + * @param[out] out_selection On success, receives the derived selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the valid ref has + * no selectable word content, or GHOSTTY_INVALID_VALUE if the + * terminal, options, ref, codepoint pointer, or output pointer are + * invalid. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_select_word( + GhosttyTerminal terminal, + const GhosttyTerminalSelectWordOptions* options, + GhosttySelection* out_selection); + +/** + * Derive the nearest word selection snapshot between two terminal grid refs. + * + * Starting at options->start, this searches toward options->end (inclusive) + * and returns the first selectable word found using Ghostty's word-selection + * rules. + * + * This is useful for implementing double-click-and-drag selection in a UI. If + * a user double-clicks one word and drags across spaces or punctuation toward + * another word, selecting only the word directly under the current pointer can + * flicker or collapse when the pointer is between words. Instead, ask for the + * nearest word between the original click and the drag point, ask again in the + * reverse direction, and combine the two word bounds into the drag selection. + * + * @snippet c-vt-selection/src/main.c selection-word-between + * + * The returned selection is not installed as the terminal's current + * selection. It is a snapshot with the same lifetime rules as GhosttySelection. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param options Word-between-selection options + * @param[out] out_selection On success, receives the derived selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if there is no + * selectable word content between the valid refs, or + * GHOSTTY_INVALID_VALUE if the terminal, options, refs, codepoint + * pointer, or output pointer are invalid. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_select_word_between( + GhosttyTerminal terminal, + const GhosttyTerminalSelectWordBetweenOptions* options, + GhosttySelection* out_selection); + +/** + * Derive a line selection snapshot from a terminal grid reference. + * + * The returned selection is not installed as the terminal's current + * selection. It is a snapshot with the same lifetime rules as GhosttySelection. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param options Line-selection options + * @param[out] out_selection On success, receives the derived selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the valid ref has + * no selectable line content, or GHOSTTY_INVALID_VALUE if the + * terminal, options, ref, codepoint pointer, or output pointer are + * invalid. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_select_line( + GhosttyTerminal terminal, + const GhosttyTerminalSelectLineOptions* options, + GhosttySelection* out_selection); + +/** + * Derive a selection snapshot covering all selectable terminal content. + * + * The returned selection is not installed as the terminal's current + * selection. It is a snapshot with the same lifetime rules as GhosttySelection. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param[out] out_selection On success, receives the derived selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if there is no + * selectable content, or GHOSTTY_INVALID_VALUE if the terminal or + * output pointer is invalid. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_select_all( + GhosttyTerminal terminal, + GhosttySelection* out_selection); + +/** + * Derive a command-output selection snapshot from a terminal grid reference. + * + * The returned selection is not installed as the terminal's current + * selection. It is a snapshot with the same lifetime rules as GhosttySelection. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param ref Grid reference within command output to select + * @param[out] out_selection On success, receives the derived selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the valid ref is + * not selectable command output, or GHOSTTY_INVALID_VALUE if the + * terminal, ref, or output pointer is invalid. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_select_output( + GhosttyTerminal terminal, + GhosttyGridRef ref, + GhosttySelection* out_selection); + +/** + * Format a terminal selection into a caller-provided buffer. + * + * This is a one-shot convenience API for formatting either the terminal's + * active selection or a caller-provided GhosttySelection without explicitly + * creating a GhosttyFormatter. + * + * Pass NULL for buf to query the required output size. In that case, + * out_written receives the required size and the function returns + * GHOSTTY_OUT_OF_SPACE. + * + * If buf is too small, the function returns GHOSTTY_OUT_OF_SPACE and writes + * the required size to out_written. The caller can then retry with a larger + * buffer. + * + * If options.selection is NULL and the terminal has no active selection, the + * function returns GHOSTTY_NO_VALUE. + * + * @param terminal The terminal to read from (must not be NULL) + * @param options Selection formatting options + * @param buf Output buffer, or NULL to query required size + * @param buf_len Length of buf in bytes + * @param out_written Number of bytes written, or required size on + * GHOSTTY_OUT_OF_SPACE (must not be NULL) + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_format_buf( + GhosttyTerminal terminal, + GhosttyTerminalSelectionFormatOptions options, + uint8_t* buf, + size_t buf_len, + size_t* out_written); + +/** + * Format a terminal selection into an allocated buffer. + * + * This is a one-shot convenience API for formatting either the terminal's + * active selection or a caller-provided GhosttySelection without explicitly + * creating a GhosttyFormatter. + * + * The returned buffer is allocated using allocator, or the default allocator + * if NULL is passed. The caller owns the returned buffer and must free it with + * ghostty_free(), passing the same allocator and returned length. + * + * The returned bytes are not NUL-terminated. This supports plain text, VT, and + * HTML uniformly as byte output. + * + * If options.selection is NULL and the terminal has no active selection, the + * function returns GHOSTTY_NO_VALUE and leaves out_ptr as NULL and out_len as 0. + * + * @param terminal The terminal to read from (must not be NULL) + * @param allocator Allocator used for the returned buffer, or NULL for the default allocator + * @param options Selection formatting options + * @param out_ptr Receives the allocated output buffer (must not be NULL) + * @param out_len Receives the output length in bytes (must not be NULL) + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_format_alloc( + GhosttyTerminal terminal, + const GhosttyAllocator* allocator, + GhosttyTerminalSelectionFormatOptions options, + uint8_t** out_ptr, + size_t* out_len); + +/** + * Adjust a selection snapshot using terminal selection semantics. + * + * This mutates the caller-provided GhosttySelection in place. The logical end + * endpoint is always moved, regardless of whether the selection is forward or + * reversed visually. The input selection remains a snapshot: after adjustment, + * call ghostty_terminal_set() with GHOSTTY_TERMINAL_OPT_SELECTION to install it + * as the terminal-owned selection if desired. + * + * The selection's start and end grid refs must both be valid untracked + * snapshots for the given terminal's currently active screen. In practice, + * they must come from that terminal and screen, and no mutating terminal call + * may have occurred since the refs were produced or reconstructed from + * tracked refs. Passing refs from another terminal, another screen, or stale + * refs violates this precondition. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param selection Selection snapshot to adjust in place + * @param adjustment The adjustment operation to apply + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selection, or adjustment are invalid. Selection reference validity + * is a precondition and is not checked. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_adjust( + GhosttyTerminal terminal, + GhosttySelection* selection, + GhosttySelectionAdjust adjustment); + +/** + * Get the current endpoint ordering of a selection snapshot. + * + * The selection's start and end grid refs must both be valid untracked + * snapshots for the given terminal's currently active screen. In practice, + * they must come from that terminal and screen, and no mutating terminal call + * may have occurred since the refs were produced or reconstructed from + * tracked refs. Passing refs from another terminal, another screen, or stale + * refs violates this precondition. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param selection Selection snapshot to inspect + * @param[out] out_order On success, receives the selection order + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selection, or output pointer are invalid. Selection reference + * validity is a precondition and is not checked. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_order( + GhosttyTerminal terminal, + const GhosttySelection* selection, + GhosttySelectionOrder* out_order); + +/** + * Return a selection snapshot with endpoints ordered as requested. + * + * Use GHOSTTY_SELECTION_ORDER_FORWARD to get top-left to bottom-right bounds, + * and GHOSTTY_SELECTION_ORDER_REVERSE to get bottom-right to top-left bounds. + * Mirrored desired orders are accepted but normalized the same as forward. + * The output selection is a fresh untracked snapshot and is not installed as + * the terminal's current selection. + * + * The selection's start and end grid refs must both be valid untracked + * snapshots for the given terminal's currently active screen. In practice, + * they must come from that terminal and screen, and no mutating terminal call + * may have occurred since the refs were produced or reconstructed from + * tracked refs. Passing refs from another terminal, another screen, or stale + * refs violates this precondition. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param selection Selection snapshot to order + * @param desired Desired endpoint order + * @param[out] out_selection On success, receives the ordered selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selection, desired order, or output pointer are invalid. Selection + * reference validity is a precondition and is not checked. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_ordered( + GhosttyTerminal terminal, + const GhosttySelection* selection, + GhosttySelectionOrder desired, + GhosttySelection* out_selection); + +/** + * Test whether a terminal point is inside a selection snapshot. + * + * This uses the same selection semantics as the terminal, including + * rectangular/block selections and linear selections spanning multiple rows. + * + * The selection's start and end grid refs must both be valid untracked + * snapshots for the given terminal's currently active screen. In practice, + * they must come from that terminal and screen, and no mutating terminal call + * may have occurred since the refs were produced or reconstructed from + * tracked refs. Passing refs from another terminal, another screen, or stale + * refs violates this precondition. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param selection Selection snapshot to inspect + * @param point Point to test for containment + * @param[out] out_contains On success, receives whether point is inside selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selection, point, or output pointer are invalid. Selection reference + * validity is a precondition and is not checked. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_contains( + GhosttyTerminal terminal, + const GhosttySelection* selection, + GhosttyPoint point, + bool* out_contains); + +/** + * Test whether two selection snapshots are equal. + * + * Equality uses the terminal's internal selection semantics: both endpoint + * pins must match and both selections must have the same rectangular/block + * state. This avoids requiring callers to compare raw GhosttyGridRef internals. + * + * Both selections' start and end grid refs must be valid untracked snapshots + * for the given terminal's currently active screen. In practice, they must + * come from that terminal and screen, and no mutating terminal call may have + * occurred since the refs were produced or reconstructed from tracked refs. + * Passing refs from another terminal, another screen, or stale refs violates + * this precondition. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param a First selection snapshot to compare + * @param b Second selection snapshot to compare + * @param[out] out_equal On success, receives whether the selections are equal + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selections, or output pointer are invalid. Selection reference + * validity is a precondition and is not checked. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_equal( + GhosttyTerminal terminal, + const GhosttySelection* a, + const GhosttySelection* b, + bool* out_equal); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_SELECTION_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/sgr.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/sgr.h new file mode 100644 index 00000000000..8eec11dc970 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/sgr.h @@ -0,0 +1,350 @@ +/** + * @file sgr.h + * + * SGR (Select Graphic Rendition) attribute parsing and handling. + */ + +#ifndef GHOSTTY_VT_SGR_H +#define GHOSTTY_VT_SGR_H + +/** @defgroup sgr SGR Parser + * + * SGR (Select Graphic Rendition) attribute parser. + * + * SGR sequences are the syntax used to set styling attributes such as + * bold, italic, underline, and colors for text in terminal emulators. + * For example, you may be familiar with sequences like `ESC[1;31m`. The + * `1;31` is the SGR attribute list. + * + * The parser processes SGR parameters from CSI sequences (e.g., `ESC[1;31m`) + * and returns individual text attributes like bold, italic, colors, etc. + * It supports both semicolon (`;`) and colon (`:`) separators, possibly mixed, + * and handles various color formats including 8-color, 16-color, 256-color, + * X11 named colors, and RGB in multiple formats. + * + * ## Basic Usage + * + * 1. Create a parser instance with ghostty_sgr_new() + * 2. Set SGR parameters with ghostty_sgr_set_params() + * 3. Iterate through attributes using ghostty_sgr_next() + * 4. Free the parser with ghostty_sgr_free() when done + * + * ## Example + * + * @snippet c-vt-sgr/src/main.c sgr-basic + * + * @{ + */ + +#include +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * SGR attribute tags. + * + * These values identify the type of an SGR attribute in a tagged union. + * Use the tag to determine which field in the attribute value union to access. + * + * @ingroup sgr + */ +typedef enum GHOSTTY_ENUM_TYPED { + GHOSTTY_SGR_ATTR_UNSET = 0, + GHOSTTY_SGR_ATTR_UNKNOWN = 1, + GHOSTTY_SGR_ATTR_BOLD = 2, + GHOSTTY_SGR_ATTR_RESET_BOLD = 3, + GHOSTTY_SGR_ATTR_ITALIC = 4, + GHOSTTY_SGR_ATTR_RESET_ITALIC = 5, + GHOSTTY_SGR_ATTR_FAINT = 6, + GHOSTTY_SGR_ATTR_UNDERLINE = 7, + GHOSTTY_SGR_ATTR_UNDERLINE_COLOR = 8, + GHOSTTY_SGR_ATTR_UNDERLINE_COLOR_256 = 9, + GHOSTTY_SGR_ATTR_RESET_UNDERLINE_COLOR = 10, + GHOSTTY_SGR_ATTR_OVERLINE = 11, + GHOSTTY_SGR_ATTR_RESET_OVERLINE = 12, + GHOSTTY_SGR_ATTR_BLINK = 13, + GHOSTTY_SGR_ATTR_RESET_BLINK = 14, + GHOSTTY_SGR_ATTR_INVERSE = 15, + GHOSTTY_SGR_ATTR_RESET_INVERSE = 16, + GHOSTTY_SGR_ATTR_INVISIBLE = 17, + GHOSTTY_SGR_ATTR_RESET_INVISIBLE = 18, + GHOSTTY_SGR_ATTR_STRIKETHROUGH = 19, + GHOSTTY_SGR_ATTR_RESET_STRIKETHROUGH = 20, + GHOSTTY_SGR_ATTR_DIRECT_COLOR_FG = 21, + GHOSTTY_SGR_ATTR_DIRECT_COLOR_BG = 22, + GHOSTTY_SGR_ATTR_BG_8 = 23, + GHOSTTY_SGR_ATTR_FG_8 = 24, + GHOSTTY_SGR_ATTR_RESET_FG = 25, + GHOSTTY_SGR_ATTR_RESET_BG = 26, + GHOSTTY_SGR_ATTR_BRIGHT_BG_8 = 27, + GHOSTTY_SGR_ATTR_BRIGHT_FG_8 = 28, + GHOSTTY_SGR_ATTR_BG_256 = 29, + GHOSTTY_SGR_ATTR_FG_256 = 30, + GHOSTTY_SGR_ATTR_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySgrAttributeTag; + +/** + * Underline style types. + * + * @ingroup sgr + */ +typedef enum GHOSTTY_ENUM_TYPED { + GHOSTTY_SGR_UNDERLINE_NONE = 0, + GHOSTTY_SGR_UNDERLINE_SINGLE = 1, + GHOSTTY_SGR_UNDERLINE_DOUBLE = 2, + GHOSTTY_SGR_UNDERLINE_CURLY = 3, + GHOSTTY_SGR_UNDERLINE_DOTTED = 4, + GHOSTTY_SGR_UNDERLINE_DASHED = 5, + GHOSTTY_SGR_UNDERLINE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySgrUnderline; + +/** + * Unknown SGR attribute data. + * + * Contains the full parameter list and the partial list where parsing + * encountered an unknown or invalid sequence. + * + * @ingroup sgr + */ +typedef struct { + const uint16_t* full_ptr; + size_t full_len; + const uint16_t* partial_ptr; + size_t partial_len; +} GhosttySgrUnknown; + +/** + * SGR attribute value union. + * + * This union contains all possible attribute values. Use the tag field + * to determine which union member is active. Attributes without associated + * data (like bold, italic) don't use the union value. + * + * @ingroup sgr + */ +typedef union { + GhosttySgrUnknown unknown; + GhosttySgrUnderline underline; + GhosttyColorRgb underline_color; + GhosttyColorPaletteIndex underline_color_256; + GhosttyColorRgb direct_color_fg; + GhosttyColorRgb direct_color_bg; + GhosttyColorPaletteIndex bg_8; + GhosttyColorPaletteIndex fg_8; + GhosttyColorPaletteIndex bright_bg_8; + GhosttyColorPaletteIndex bright_fg_8; + GhosttyColorPaletteIndex bg_256; + GhosttyColorPaletteIndex fg_256; + uint64_t _padding[8]; +} GhosttySgrAttributeValue; + +/** + * SGR attribute (tagged union). + * + * A complete SGR attribute with both its type tag and associated value. + * Always check the tag field to determine which value union member is valid. + * + * Attributes without associated data (e.g., GHOSTTY_SGR_ATTR_BOLD) can be + * identified by tag alone; the value union is not used for these and + * the memory in the value field is undefined. + * + * @ingroup sgr + */ +typedef struct { + GhosttySgrAttributeTag tag; + GhosttySgrAttributeValue value; +} GhosttySgrAttribute; + +/** + * Create a new SGR parser instance. + * + * Creates a new SGR (Select Graphic Rendition) parser using the provided + * allocator. The parser must be freed using ghostty_sgr_free() when + * no longer needed. + * + * @param allocator Pointer to the allocator to use for memory management, or + * NULL to use the default allocator + * @param parser Pointer to store the created parser handle + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup sgr + */ +GHOSTTY_API GhosttyResult ghostty_sgr_new(const GhosttyAllocator* allocator, + GhosttySgrParser* parser); + +/** + * Free an SGR parser instance. + * + * Releases all resources associated with the SGR parser. After this call, + * the parser handle becomes invalid and must not be used. This includes + * any attributes previously returned by ghostty_sgr_next(). + * + * @param parser The parser handle to free (may be NULL) + * + * @ingroup sgr + */ +GHOSTTY_API void ghostty_sgr_free(GhosttySgrParser parser); + +/** + * Reset an SGR parser instance to the beginning of the parameter list. + * + * Resets the parser's iteration state without clearing the parameters. + * After calling this, ghostty_sgr_next() will start from the beginning + * of the parameter list again. + * + * @param parser The parser handle to reset, must not be NULL + * + * @ingroup sgr + */ +GHOSTTY_API void ghostty_sgr_reset(GhosttySgrParser parser); + +/** + * Set SGR parameters for parsing. + * + * Sets the SGR parameter list to parse. Parameters are the numeric values + * from a CSI SGR sequence (e.g., for `ESC[1;31m`, params would be {1, 31}). + * + * The separators array optionally specifies the separator type for each + * parameter position. Each byte should be either ';' for semicolon or ':' + * for colon. This is needed for certain color formats that use colon + * separators (e.g., `ESC[4:3m` for curly underline). Any invalid separator + * values are treated as semicolons. The separators array must have the same + * length as the params array, if it is not NULL. + * + * If separators is NULL, all parameters are assumed to be semicolon-separated. + * + * This function makes an internal copy of the parameter and separator data, + * so the caller can safely free or modify the input arrays after this call. + * + * After calling this function, the parser is automatically reset and ready + * to iterate from the beginning. + * + * @param parser The parser handle, must not be NULL + * @param params Array of SGR parameter values + * @param separators Optional array of separator characters (';' or ':'), or + * NULL + * @param len Number of parameters (and separators if provided) + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup sgr + */ +GHOSTTY_API GhosttyResult ghostty_sgr_set_params(GhosttySgrParser parser, + const uint16_t* params, + const char* separators, + size_t len); + +/** + * Get the next SGR attribute. + * + * Parses and returns the next attribute from the parameter list. + * Call this function repeatedly until it returns false to process + * all attributes in the sequence. + * + * @param parser The parser handle, must not be NULL + * @param attr Pointer to store the next attribute + * @return true if an attribute was returned, false if no more attributes + * + * @ingroup sgr + */ +GHOSTTY_API bool ghostty_sgr_next(GhosttySgrParser parser, GhosttySgrAttribute* attr); + +/** + * Get the full parameter list from an unknown SGR attribute. + * + * This function retrieves the full parameter list that was provided to the + * parser when an unknown attribute was encountered. Primarily useful in + * WebAssembly environments where accessing struct fields directly is difficult. + * + * @param unknown The unknown attribute data + * @param ptr Pointer to store the pointer to the parameter array (may be NULL) + * @return The length of the full parameter array + * + * @ingroup sgr + */ +GHOSTTY_API size_t ghostty_sgr_unknown_full(GhosttySgrUnknown unknown, + const uint16_t** ptr); + +/** + * Get the partial parameter list from an unknown SGR attribute. + * + * This function retrieves the partial parameter list where parsing stopped + * when an unknown attribute was encountered. Primarily useful in WebAssembly + * environments where accessing struct fields directly is difficult. + * + * @param unknown The unknown attribute data + * @param ptr Pointer to store the pointer to the parameter array (may be NULL) + * @return The length of the partial parameter array + * + * @ingroup sgr + */ +GHOSTTY_API size_t ghostty_sgr_unknown_partial(GhosttySgrUnknown unknown, + const uint16_t** ptr); + +/** + * Get the tag from an SGR attribute. + * + * This function extracts the tag that identifies which type of attribute + * this is. Primarily useful in WebAssembly environments where accessing + * struct fields directly is difficult. + * + * @param attr The SGR attribute + * @return The attribute tag + * + * @ingroup sgr + */ +GHOSTTY_API GhosttySgrAttributeTag ghostty_sgr_attribute_tag(GhosttySgrAttribute attr); + +/** + * Get the value from an SGR attribute. + * + * This function returns a pointer to the value union from an SGR attribute. Use + * the tag to determine which field of the union is valid. Primarily useful in + * WebAssembly environments where accessing struct fields directly is difficult. + * + * @param attr Pointer to the SGR attribute + * @return Pointer to the attribute value union + * + * @ingroup sgr + */ +GHOSTTY_API GhosttySgrAttributeValue* ghostty_sgr_attribute_value( + GhosttySgrAttribute* attr); + +#ifdef __wasm__ +/** + * Allocate memory for an SGR attribute (WebAssembly only). + * + * This is a convenience function for WebAssembly environments to allocate + * memory for an SGR attribute structure that can be passed to ghostty_sgr_next. + * + * @return Pointer to the allocated attribute structure + * + * @ingroup wasm + */ +GHOSTTY_API GhosttySgrAttribute* ghostty_wasm_alloc_sgr_attribute(void); + +/** + * Free memory for an SGR attribute (WebAssembly only). + * + * Frees memory allocated by ghostty_wasm_alloc_sgr_attribute. + * + * @param attr Pointer to the attribute structure to free + * + * @ingroup wasm + */ +GHOSTTY_API void ghostty_wasm_free_sgr_attribute(GhosttySgrAttribute* attr); +#endif + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_SGR_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/size_report.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/size_report.h new file mode 100644 index 00000000000..da33e5e5593 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/size_report.h @@ -0,0 +1,101 @@ +/** + * @file size_report.h + * + * Size report encoding - encode terminal size reports into escape sequences. + */ + +#ifndef GHOSTTY_VT_SIZE_REPORT_H +#define GHOSTTY_VT_SIZE_REPORT_H + +/** @defgroup size_report Size Report Encoding + * + * Utilities for encoding terminal size reports into escape sequences, + * supporting in-band size reports (mode 2048) and XTWINOPS responses + * (CSI 14 t, CSI 16 t, CSI 18 t). + * + * ## Basic Usage + * + * Use ghostty_size_report_encode() to encode a size report into a + * caller-provided buffer. If the buffer is too small, the function + * returns GHOSTTY_OUT_OF_SPACE and sets the required size in the + * output parameter. + * + * ## Example + * + * @snippet c-vt-size-report/src/main.c size-report-encode + * + * @{ + */ + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Size report style. + * + * Determines the output format for the terminal size report. + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** In-band size report (mode 2048): ESC [ 48 ; rows ; cols ; height ; width t */ + GHOSTTY_SIZE_REPORT_MODE_2048 = 0, + /** XTWINOPS text area size in pixels: ESC [ 4 ; height ; width t */ + GHOSTTY_SIZE_REPORT_CSI_14_T = 1, + /** XTWINOPS cell size in pixels: ESC [ 6 ; height ; width t */ + GHOSTTY_SIZE_REPORT_CSI_16_T = 2, + /** XTWINOPS text area size in characters: ESC [ 8 ; rows ; cols t */ + GHOSTTY_SIZE_REPORT_CSI_18_T = 3, + GHOSTTY_SIZE_REPORT_STYLE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySizeReportStyle; + +/** + * Terminal size information for encoding size reports. + */ +typedef struct { + /** Terminal row count in cells. */ + uint16_t rows; + /** Terminal column count in cells. */ + uint16_t columns; + /** Width of a single terminal cell in pixels. */ + uint32_t cell_width; + /** Height of a single terminal cell in pixels. */ + uint32_t cell_height; +} GhosttySizeReportSize; + +/** + * Encode a terminal size report into an escape sequence. + * + * Encodes a size report in the format specified by @p style into the + * provided buffer. + * + * If the buffer is too small, the function returns GHOSTTY_OUT_OF_SPACE + * and writes the required buffer size to @p out_written. The caller can + * then retry with a sufficiently sized buffer. + * + * @param style The size report format to encode + * @param size Terminal size information + * @param buf Output buffer to write the encoded sequence into (may be NULL) + * @param buf_len Size of the output buffer in bytes + * @param[out] out_written On success, the number of bytes written. On + * GHOSTTY_OUT_OF_SPACE, the required buffer size. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if the buffer + * is too small + */ +GHOSTTY_API GhosttyResult ghostty_size_report_encode( + GhosttySizeReportStyle style, + GhosttySizeReportSize size, + char* buf, + size_t buf_len, + size_t* out_written); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_SIZE_REPORT_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/style.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/style.h new file mode 100644 index 00000000000..b6bf860ebe7 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/style.h @@ -0,0 +1,139 @@ +/** + * @file style.h + * + * Terminal cell style types. + */ + +#ifndef GHOSTTY_VT_STYLE_H +#define GHOSTTY_VT_STYLE_H + +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup style Style + * + * Terminal cell style attributes. + * + * A style describes the visual attributes of a terminal cell, including + * foreground, background, and underline colors, as well as flags for + * bold, italic, underline, and other text decorations. + * + * @{ + */ + +/** + * Style identifier type. + * + * Used to look up the full style from a grid reference. + * Obtain this from a cell via GHOSTTY_CELL_DATA_STYLE_ID. + * + * @ingroup style + */ +typedef uint16_t GhosttyStyleId; + +/** + * Style color tags. + * + * These values identify the type of color in a style color. + * Use the tag to determine which field in the color value union to access. + * + * @ingroup style + */ +typedef enum GHOSTTY_ENUM_TYPED { + GHOSTTY_STYLE_COLOR_NONE = 0, + GHOSTTY_STYLE_COLOR_PALETTE = 1, + GHOSTTY_STYLE_COLOR_RGB = 2, + GHOSTTY_STYLE_COLOR_TAG_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, + } GhosttyStyleColorTag; + +/** + * Style color value union. + * + * Use the tag to determine which field is active. + * + * @ingroup style + */ +typedef union { + GhosttyColorPaletteIndex palette; + GhosttyColorRgb rgb; + uint64_t _padding; +} GhosttyStyleColorValue; + +/** + * Style color (tagged union). + * + * A color used in a style attribute. Can be unset (none), a palette + * index, or a direct RGB value. + * + * @ingroup style + */ +typedef struct { + GhosttyStyleColorTag tag; + GhosttyStyleColorValue value; +} GhosttyStyleColor; + +/** + * Terminal cell style. + * + * Describes the complete visual style for a terminal cell, including + * foreground, background, and underline colors, as well as text + * decoration flags. The underline field uses the same values as + * GhosttySgrUnderline. + * + * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. + * + * @ingroup style + */ +typedef struct { + size_t size; + GhosttyStyleColor fg_color; + GhosttyStyleColor bg_color; + GhosttyStyleColor underline_color; + bool bold; + bool italic; + bool faint; + bool blink; + bool inverse; + bool invisible; + bool strikethrough; + bool overline; + int underline; /**< One of GHOSTTY_SGR_UNDERLINE_* values */ +} GhosttyStyle; + +/** + * Get the default style. + * + * Initializes the style to the default values (no colors, no flags). + * + * @param style Pointer to the style to initialize + * + * @ingroup style + */ +GHOSTTY_API void ghostty_style_default(GhosttyStyle* style); + +/** + * Check if a style is the default style. + * + * Returns true if all colors are unset and all flags are off. + * + * @param style Pointer to the style to check + * @return true if the style is the default style + * + * @ingroup style + */ +GHOSTTY_API bool ghostty_style_is_default(const GhosttyStyle* style); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_STYLE_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/sys.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/sys.h new file mode 100644 index 00000000000..ae90596927d --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/sys.h @@ -0,0 +1,210 @@ +/** + * @file sys.h + * + * System interface - runtime-swappable implementations for external dependencies. + */ + +#ifndef GHOSTTY_VT_SYS_H +#define GHOSTTY_VT_SYS_H + +#include +#include +#include +#include +#include + +/** @defgroup sys System Interface + * + * Runtime-swappable function pointers for operations that depend on + * external implementations (e.g. image decoding). + * + * These are process-global settings that must be configured at startup + * before any terminal functionality that depends on them is used. + * Setting these enables various optional features of the terminal. For + * example, setting a PNG decoder enables PNG image support in the Kitty + * Graphics Protocol. + * + * Use ghostty_sys_set() with a `GhosttySysOption` to install or clear + * an implementation. Passing NULL as the value clears the implementation + * and disables the corresponding feature. + * + * ## Example + * + * ### Defining a PNG decode callback + * @snippet c-vt-kitty-graphics/src/main.c kitty-graphics-decode-png + * + * ### Installing the callback and sending a PNG image + * @snippet c-vt-kitty-graphics/src/main.c kitty-graphics-main + * + * @{ + */ + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Result of decoding an image. + * + * The `data` buffer must be allocated through the allocator provided to + * the decode callback. The library takes ownership and will free it + * with the same allocator. + */ +typedef struct { + /** Image width in pixels. */ + uint32_t width; + + /** Image height in pixels. */ + uint32_t height; + + /** Pointer to the decoded RGBA pixel data. */ + uint8_t* data; + + /** Length of the pixel data in bytes. */ + size_t data_len; +} GhosttySysImage; + +/** + * Log severity levels for the log callback. + */ +typedef enum GHOSTTY_ENUM_TYPED { + GHOSTTY_SYS_LOG_LEVEL_ERROR = 0, + GHOSTTY_SYS_LOG_LEVEL_WARNING = 1, + GHOSTTY_SYS_LOG_LEVEL_INFO = 2, + GHOSTTY_SYS_LOG_LEVEL_DEBUG = 3, + GHOSTTY_SYS_LOG_LEVEL_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySysLogLevel; + +/** + * Callback type for logging. + * + * When installed, internal library log messages are delivered through + * this callback instead of being discarded. The embedder is responsible + * for formatting and routing log output. + * + * @p scope is the log scope name as UTF-8 bytes (e.g. "osc", "kitty"). + * When the log is unscoped (default scope), @p scope_len is 0. + * + * All pointer arguments are only valid for the duration of the callback. + * The callback must be safe to call from any thread. + * + * @param userdata The userdata pointer set via GHOSTTY_SYS_OPT_USERDATA + * @param level The severity level of the log message + * @param scope Pointer to the scope name bytes + * @param scope_len Length of the scope name in bytes + * @param message Pointer to the log message bytes + * @param message_len Length of the log message in bytes + */ +typedef void (*GhosttySysLogFn)( + void* userdata, + GhosttySysLogLevel level, + const uint8_t* scope, + size_t scope_len, + const uint8_t* message, + size_t message_len); + +/** + * Callback type for PNG decoding. + * + * Decodes raw PNG data into RGBA pixels. The output pixel data must be + * allocated through the provided allocator. The library takes ownership + * of the buffer and will free it with the same allocator. + * + * @param userdata The userdata pointer set via GHOSTTY_SYS_OPT_USERDATA + * @param allocator The allocator to use for the output pixel buffer + * @param data Pointer to the raw PNG data + * @param data_len Length of the raw PNG data in bytes + * @param[out] out On success, filled with the decoded image + * @return true on success, false on failure + */ +typedef bool (*GhosttySysDecodePngFn)( + void* userdata, + const GhosttyAllocator* allocator, + const uint8_t* data, + size_t data_len, + GhosttySysImage* out); + +/** + * System option identifiers for ghostty_sys_set(). + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** + * Set the userdata pointer passed to all sys callbacks. + * + * Input type: void* (or NULL) + */ + GHOSTTY_SYS_OPT_USERDATA = 0, + + /** + * Set the PNG decode function. + * + * When set, the terminal can accept PNG images via the Kitty + * Graphics Protocol. When cleared (NULL value), PNG decoding is + * unsupported and PNG image data will be rejected. + * + * Input type: GhosttySysDecodePngFn (function pointer, or NULL) + */ + GHOSTTY_SYS_OPT_DECODE_PNG = 1, + + /** + * Set the log callback. + * + * When set, internal library log messages are delivered to this + * callback. When cleared (NULL value), log messages are silently + * discarded. + * + * Use ghostty_sys_log_stderr as a convenience callback that + * writes formatted messages to stderr. + * + * Which log levels are emitted depends on the build mode of the + * library and is not configurable at runtime. Debug builds emit + * all levels (debug and above). Release builds emit info and + * above; debug-level messages are compiled out entirely and will + * never reach the callback. + * + * Input type: GhosttySysLogFn (function pointer, or NULL) + */ + GHOSTTY_SYS_OPT_LOG = 2, + GHOSTTY_SYS_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySysOption; + +/** + * Set a system-level option. + * + * Configures a process-global implementation function. These should be + * set once at startup before using any terminal functionality that + * depends on them. + * + * @param option The option to set + * @param value Pointer to the value (type depends on the option), + * or NULL to clear it + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the + * option is not recognized + */ +GHOSTTY_API GhosttyResult ghostty_sys_set(GhosttySysOption option, + const void* value); + +/** + * Built-in log callback that writes to stderr. + * + * Formats each message as "[level](scope): message\n". + * Can be passed directly to ghostty_sys_set(): + * + * @code + * ghostty_sys_set(GHOSTTY_SYS_OPT_LOG, &ghostty_sys_log_stderr); + * @endcode + */ +GHOSTTY_API void ghostty_sys_log_stderr(void* userdata, + GhosttySysLogLevel level, + const uint8_t* scope, + size_t scope_len, + const uint8_t* message, + size_t message_len); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_SYS_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/terminal.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/terminal.h new file mode 100644 index 00000000000..b22e8aedc89 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/terminal.h @@ -0,0 +1,1322 @@ +/** + * @file terminal.h + * + * Complete terminal emulator state and rendering. + */ + +#ifndef GHOSTTY_VT_TERMINAL_H +#define GHOSTTY_VT_TERMINAL_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup terminal Terminal + * + * Complete terminal emulator state and rendering. + * + * A terminal instance manages the full emulator state including the screen, + * scrollback, cursor, styles, modes, and VT stream processing. + * + * Once a terminal session is up and running, you can configure a key encoder + * to write keyboard input via ghostty_key_encoder_setopt_from_terminal(). + * + * ### Example: VT stream processing + * @snippet c-vt-stream/src/main.c vt-stream-init + * @snippet c-vt-stream/src/main.c vt-stream-write + * + * ## Effects + * + * By default, the terminal sequence processing with ghostty_terminal_vt_write() + * only process sequences that directly affect terminal state and + * ignores sequences that have side effect behavior or require responses. + * These sequences include things like bell characters, title changes, device + * attributes queries, and more. To handle these sequences, the embedder + * must configure "effects." + * + * Effects are callbacks that the terminal invokes in response to VT + * sequences processed during ghostty_terminal_vt_write(). They let the + * embedding application react to terminal-initiated events such as bell + * characters, title changes, device status report responses, and more. + * + * Each effect is registered with ghostty_terminal_set() using the + * corresponding `GhosttyTerminalOption` identifier. A `NULL` value + * pointer clears the callback and disables the effect. + * + * A userdata pointer can be attached via `GHOSTTY_TERMINAL_OPT_USERDATA` + * and is passed to every callback, allowing callers to route events + * back to their own application state without global variables. + * You cannot specify different userdata for different callbacks. + * + * All callbacks are invoked synchronously during + * ghostty_terminal_vt_write(). Callbacks **must not** call + * ghostty_terminal_vt_write() on the same terminal (no reentrancy). + * And callbacks must be very careful to not block for too long or perform + * expensive operations, since they are blocking further IO processing. + * + * The available effects are: + * + * | Option | Callback Type | Trigger | + * |-----------------------------------------|-----------------------------------|-------------------------------------------| + * | `GHOSTTY_TERMINAL_OPT_WRITE_PTY` | `GhosttyTerminalWritePtyFn` | Query responses written back to the pty | + * | `GHOSTTY_TERMINAL_OPT_BELL` | `GhosttyTerminalBellFn` | BEL character (0x07) | + * | `GHOSTTY_TERMINAL_OPT_TITLE_CHANGED` | `GhosttyTerminalTitleChangedFn` | Title change via OSC 0 / OSC 2 | + * | `GHOSTTY_TERMINAL_OPT_PWD_CHANGED` | `GhosttyTerminalPwdChangedFn` | Pwd change via OSC 7 / OSC 9 / OSC 1337 | + * | `GHOSTTY_TERMINAL_OPT_ENQUIRY` | `GhosttyTerminalEnquiryFn` | ENQ character (0x05) | + * | `GHOSTTY_TERMINAL_OPT_XTVERSION` | `GhosttyTerminalXtversionFn` | XTVERSION query (CSI > q) | + * | `GHOSTTY_TERMINAL_OPT_SIZE` | `GhosttyTerminalSizeFn` | XTWINOPS size query (CSI 14/16/18 t) | + * | `GHOSTTY_TERMINAL_OPT_COLOR_SCHEME` | `GhosttyTerminalColorSchemeFn` | Color scheme query (CSI ? 996 n) | + * | `GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES`| `GhosttyTerminalDeviceAttributesFn`| Device attributes query (CSI c / > c / = c)| + * + * ### Defining a write_pty callback + * @snippet c-vt-effects/src/main.c effects-write-pty + * + * ### Defining a bell callback + * @snippet c-vt-effects/src/main.c effects-bell + * + * ### Defining a title_changed callback + * @snippet c-vt-effects/src/main.c effects-title-changed + * + * ### Registering effects and processing VT data + * @snippet c-vt-effects/src/main.c effects-register + * + * ## Color Theme + * + * The terminal maintains a set of colors used for rendering: a foreground + * color, a background color, a cursor color, and a 256-color palette. Each + * of these has two layers: a **default** value set by the embedder, and an + * **override** value that programs running in the terminal can set via OSC + * escape sequences (e.g. OSC 10/11/12 for foreground/background/cursor, + * OSC 4 for individual palette entries). + * + * ### Default Colors + * + * Use ghostty_terminal_set() with the color options to configure the + * default colors. These represent the theme or configuration chosen by + * the embedder. Passing `NULL` clears the default, leaving the color + * unset. + * + * | Option | Input Type | Description | + * |-----------------------------------------|-------------------------|--------------------------------------| + * | `GHOSTTY_TERMINAL_OPT_COLOR_FOREGROUND` | `GhosttyColorRgb*` | Default foreground color | + * | `GHOSTTY_TERMINAL_OPT_COLOR_BACKGROUND` | `GhosttyColorRgb*` | Default background color | + * | `GHOSTTY_TERMINAL_OPT_COLOR_CURSOR` | `GhosttyColorRgb*` | Default cursor color | + * | `GHOSTTY_TERMINAL_OPT_COLOR_PALETTE` | `GhosttyColorRgb[256]*` | Default 256-color palette | + * + * For the palette, passing `NULL` resets to the built-in default palette. + * The palette set operation preserves any per-index OSC overrides that + * programs have applied; only unmodified indices are updated. + * + * ### Reading colors + * + * Use ghostty_terminal_get() to read colors. There are two variants for + * each color: the **effective** value (which returns the OSC override if + * one is active, otherwise the default) and the **default** value (which + * ignores any OSC overrides). + * + * | Data | Output Type | Description | + * |---------------------------------------------------|-------------------------|------------------------------------------------| + * | `GHOSTTY_TERMINAL_DATA_COLOR_FOREGROUND` | `GhosttyColorRgb*` | Effective foreground (override or default) | + * | `GHOSTTY_TERMINAL_DATA_COLOR_BACKGROUND` | `GhosttyColorRgb*` | Effective background (override or default) | + * | `GHOSTTY_TERMINAL_DATA_COLOR_CURSOR` | `GhosttyColorRgb*` | Effective cursor (override or default) | + * | `GHOSTTY_TERMINAL_DATA_COLOR_PALETTE` | `GhosttyColorRgb[256]*` | Current palette (with any OSC overrides) | + * | `GHOSTTY_TERMINAL_DATA_COLOR_FOREGROUND_DEFAULT` | `GhosttyColorRgb*` | Default foreground only (ignores OSC override) | + * | `GHOSTTY_TERMINAL_DATA_COLOR_BACKGROUND_DEFAULT` | `GhosttyColorRgb*` | Default background only (ignores OSC override) | + * | `GHOSTTY_TERMINAL_DATA_COLOR_CURSOR_DEFAULT` | `GhosttyColorRgb*` | Default cursor only (ignores OSC override) | + * | `GHOSTTY_TERMINAL_DATA_COLOR_PALETTE_DEFAULT` | `GhosttyColorRgb[256]*` | Default palette only (ignores OSC overrides) | + * + * For foreground, background, and cursor colors, the getters return + * `GHOSTTY_NO_VALUE` if no color is configured (neither a default nor an + * OSC override). The palette getters always succeed since the palette + * always has a value (the built-in default if nothing else is set). + * + * ### Setting a color theme + * @snippet c-vt-colors/src/main.c colors-set-defaults + * + * ### Reading effective and default colors + * @snippet c-vt-colors/src/main.c colors-read + * + * ### Full example with OSC overrides + * @snippet c-vt-colors/src/main.c colors-main + * + * @{ + */ + +/** + * Terminal initialization options. + * + * @ingroup terminal + */ +typedef struct { + /** Terminal width in cells. Must be greater than zero. */ + uint16_t cols; + + /** Terminal height in cells. Must be greater than zero. */ + uint16_t rows; + + /** Maximum number of lines to keep in scrollback history. */ + size_t max_scrollback; + + // TODO: Consider ABI compatibility implications of this struct. + // We may want to artificially pad it significantly to support + // future options. +} GhosttyTerminalOptions; + +/** + * Scroll viewport behavior tag. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Scroll to the top of the scrollback. */ + GHOSTTY_SCROLL_VIEWPORT_TOP, + + /** Scroll to the bottom (active area). */ + GHOSTTY_SCROLL_VIEWPORT_BOTTOM, + + /** Scroll by a delta amount (up is negative). */ + GHOSTTY_SCROLL_VIEWPORT_DELTA, + GHOSTTY_SCROLL_VIEWPORT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyTerminalScrollViewportTag; + +/** + * Scroll viewport value. + * + * @ingroup terminal + */ +typedef union { + /** Scroll delta (only used with GHOSTTY_SCROLL_VIEWPORT_DELTA). Up is negative. */ + intptr_t delta; + + /** Padding for ABI compatibility. Do not use. */ + uint64_t _padding[2]; +} GhosttyTerminalScrollViewportValue; + +/** + * Tagged union for scroll viewport behavior. + * + * @ingroup terminal + */ +typedef struct { + GhosttyTerminalScrollViewportTag tag; + GhosttyTerminalScrollViewportValue value; +} GhosttyTerminalScrollViewport; + +/** + * Terminal screen identifier. + * + * Identifies which screen buffer is active in the terminal. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** The primary (normal) screen. */ + GHOSTTY_TERMINAL_SCREEN_PRIMARY = 0, + + /** The alternate screen. */ + GHOSTTY_TERMINAL_SCREEN_ALTERNATE = 1, + GHOSTTY_TERMINAL_SCREEN_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyTerminalScreen; + +/** + * Visual style of the terminal cursor. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Bar cursor (DECSCUSR 5, 6). */ + GHOSTTY_TERMINAL_CURSOR_STYLE_BAR = 0, + + /** Block cursor (DECSCUSR 1, 2). */ + GHOSTTY_TERMINAL_CURSOR_STYLE_BLOCK = 1, + + /** Underline cursor (DECSCUSR 3, 4). */ + GHOSTTY_TERMINAL_CURSOR_STYLE_UNDERLINE = 2, + + /** Hollow block cursor. */ + GHOSTTY_TERMINAL_CURSOR_STYLE_BLOCK_HOLLOW = 3, + GHOSTTY_TERMINAL_CURSOR_STYLE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyTerminalCursorStyle; + +/** + * Scrollbar state for the terminal viewport. + * + * Represents the scrollable area dimensions needed to render a scrollbar. + * + * @ingroup terminal + */ +typedef struct { + /** Total size of the scrollable area in rows. */ + uint64_t total; + + /** Offset into the total area that the viewport is at. */ + uint64_t offset; + + /** Length of the visible area in rows. */ + uint64_t len; +} GhosttyTerminalScrollbar; + +/** + * Callback function type for bell. + * + * Called when the terminal receives a BEL character (0x07). + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalBellFn)(GhosttyTerminal terminal, + void* userdata); + +/** + * Callback function type for color scheme queries (CSI ? 996 n). + * + * Called when the terminal receives a color scheme device status report + * query. Return true and fill *out_scheme with the current color scheme, + * or return false to silently ignore the query. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param[out] out_scheme Pointer to store the current color scheme + * @return true if the color scheme was filled, false to ignore the query + * + * @ingroup terminal + */ +typedef bool (*GhosttyTerminalColorSchemeFn)(GhosttyTerminal terminal, + void* userdata, + GhosttyColorScheme* out_scheme); + +/** + * Callback function type for device attributes queries (DA1/DA2/DA3). + * + * Called when the terminal receives a device attributes query (CSI c, + * CSI > c, or CSI = c). Return true and fill *out_attrs with the + * response data, or return false to silently ignore the query. + * + * The terminal uses whichever sub-struct (primary, secondary, tertiary) + * matches the request type, but all three should be filled for simplicity. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param[out] out_attrs Pointer to store the device attributes response + * @return true if attributes were filled, false to ignore the query + * + * @ingroup terminal + */ +typedef bool (*GhosttyTerminalDeviceAttributesFn)(GhosttyTerminal terminal, + void* userdata, + GhosttyDeviceAttributes* out_attrs); + +/** + * Callback function type for enquiry (ENQ, 0x05). + * + * Called when the terminal receives an ENQ character. Return the + * response bytes as a GhosttyString. The memory must remain valid + * until the callback returns. Return a zero-length string to send + * no response. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @return The response bytes to write back to the pty + * + * @ingroup terminal + */ +typedef GhosttyString (*GhosttyTerminalEnquiryFn)(GhosttyTerminal terminal, + void* userdata); + +/** + * Callback function type for size queries (XTWINOPS). + * + * Called in response to XTWINOPS size queries (CSI 14/16/18 t). + * Return true and fill *out_size with the current terminal geometry, + * or return false to silently ignore the query. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param[out] out_size Pointer to store the terminal size information + * @return true if size was filled, false to ignore the query + * + * @ingroup terminal + */ +typedef bool (*GhosttyTerminalSizeFn)(GhosttyTerminal terminal, + void* userdata, + GhosttySizeReportSize* out_size); + +/** + * Callback function type for title_changed. + * + * Called when the terminal title changes via escape sequences + * (e.g. OSC 0 or OSC 2). The new title can be queried from the + * terminal after the callback returns. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalTitleChangedFn)(GhosttyTerminal terminal, + void* userdata); + +/** + * Callback function type for pwd_changed. + * + * Called when the terminal pwd (current working directory) changes via + * escape sequences: OSC 7 (file:// URI), OSC 9 (ConEmu CurrentDir), or + * OSC 1337 CurrentDir (iTerm2). Use ghostty_terminal_get() with + * GHOSTTY_TERMINAL_DATA_PWD inside the callback to read the new value. + * + * The terminal stores whatever bytes the shell emitted, without parsing. + * That means for OSC 7 the value is the raw URI (typically file://...); + * for OSC 9/OSC 1337 it is typically a bare path. The embedder is + * responsible for decoding any URI scheme or host if it cares about them. + * + * The callback also fires when the shell clears the pwd (e.g. an empty + * OSC 7). In that case GHOSTTY_TERMINAL_DATA_PWD returns a zero-length + * string. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalPwdChangedFn)(GhosttyTerminal terminal, + void* userdata); + +/** + * Callback function type for write_pty. + * + * Called when the terminal needs to write data back to the pty, for + * example in response to a device status report or mode query. The + * data is only valid for the duration of the call; callers must copy + * it if it needs to persist. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param data Pointer to the response bytes + * @param len Length of the response in bytes + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalWritePtyFn)(GhosttyTerminal terminal, + void* userdata, + const uint8_t* data, + size_t len); + +/** + * Callback function type for XTVERSION. + * + * Called when the terminal receives an XTVERSION query (CSI > q). + * Return the version string (e.g. "myterm 1.0") as a GhosttyString. + * The memory must remain valid until the callback returns. Return a + * zero-length string to report the default "libghostty" version. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @return The version string to report + * + * @ingroup terminal + */ +typedef GhosttyString (*GhosttyTerminalXtversionFn)(GhosttyTerminal terminal, + void* userdata); + +/** + * Terminal option identifiers. + * + * These values are used with ghostty_terminal_set() to configure + * terminal callbacks and associated state. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** + * Opaque userdata pointer passed to all callbacks. + * + * Input type: void* + */ + GHOSTTY_TERMINAL_OPT_USERDATA = 0, + + /** + * Callback invoked when the terminal needs to write data back + * to the pty (e.g. in response to a DECRQM query or device + * status report). Set to NULL to ignore such sequences. + * + * Input type: GhosttyTerminalWritePtyFn + */ + GHOSTTY_TERMINAL_OPT_WRITE_PTY = 1, + + /** + * Callback invoked when the terminal receives a BEL character + * (0x07). Set to NULL to ignore bell events. + * + * Input type: GhosttyTerminalBellFn + */ + GHOSTTY_TERMINAL_OPT_BELL = 2, + + /** + * Callback invoked when the terminal receives an ENQ character + * (0x05). Set to NULL to send no response. + * + * Input type: GhosttyTerminalEnquiryFn + */ + GHOSTTY_TERMINAL_OPT_ENQUIRY = 3, + + /** + * Callback invoked when the terminal receives an XTVERSION query + * (CSI > q). Set to NULL to report the default "libghostty" string. + * + * Input type: GhosttyTerminalXtversionFn + */ + GHOSTTY_TERMINAL_OPT_XTVERSION = 4, + + /** + * Callback invoked when the terminal title changes via escape + * sequences (e.g. OSC 0 or OSC 2). Set to NULL to ignore title + * change events. + * + * Input type: GhosttyTerminalTitleChangedFn + */ + GHOSTTY_TERMINAL_OPT_TITLE_CHANGED = 5, + + /** + * Callback invoked in response to XTWINOPS size queries + * (CSI 14/16/18 t). Set to NULL to silently ignore size queries. + * + * Input type: GhosttyTerminalSizeFn + */ + GHOSTTY_TERMINAL_OPT_SIZE = 6, + + /** + * Callback invoked in response to a color scheme device status + * report query (CSI ? 996 n). Return true and fill the out pointer + * to report the current scheme, or return false to silently ignore. + * Set to NULL to ignore color scheme queries. + * + * Input type: GhosttyTerminalColorSchemeFn + */ + GHOSTTY_TERMINAL_OPT_COLOR_SCHEME = 7, + + /** + * Callback invoked in response to a device attributes query + * (CSI c, CSI > c, or CSI = c). Return true and fill the out + * pointer with response data, or return false to silently ignore. + * Set to NULL to ignore device attributes queries. + * + * Input type: GhosttyTerminalDeviceAttributesFn + */ + GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES = 8, + + /** + * Set the terminal title manually. + * + * The string data is copied into the terminal. A NULL value pointer + * clears the title (equivalent to setting an empty string). + * + * Input type: GhosttyString* + */ + GHOSTTY_TERMINAL_OPT_TITLE = 9, + + /** + * Set the terminal working directory manually. + * + * The string data is copied into the terminal. A NULL value pointer + * clears the pwd (equivalent to setting an empty string). + * + * Input type: GhosttyString* + */ + GHOSTTY_TERMINAL_OPT_PWD = 10, + + /** + * Set the default foreground color. + * + * A NULL value pointer clears the default (unset). + * + * Input type: GhosttyColorRgb* + */ + GHOSTTY_TERMINAL_OPT_COLOR_FOREGROUND = 11, + + /** + * Set the default background color. + * + * A NULL value pointer clears the default (unset). + * + * Input type: GhosttyColorRgb* + */ + GHOSTTY_TERMINAL_OPT_COLOR_BACKGROUND = 12, + + /** + * Set the default cursor color. + * + * A NULL value pointer clears the default (unset). + * + * Input type: GhosttyColorRgb* + */ + GHOSTTY_TERMINAL_OPT_COLOR_CURSOR = 13, + + /** + * Set the default 256-color palette. + * + * The value must point to an array of exactly 256 GhosttyColorRgb values. + * A NULL value pointer resets to the built-in default palette. + * + * Input type: GhosttyColorRgb[256]* + */ + GHOSTTY_TERMINAL_OPT_COLOR_PALETTE = 14, + + /** + * Set the Kitty image storage limit in bytes. + * + * Applied to all initialized screens (primary and alternate). + * A value of zero disables the Kitty graphics protocol entirely, + * deleting all stored images and placements. A NULL value pointer + * is equivalent to zero (disables). Has no effect when Kitty graphics + * are disabled at build time. + * + * Input type: uint64_t* + */ + GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_STORAGE_LIMIT = 15, + + /** + * Enable or disable Kitty image loading via the file medium. + * + * A NULL value pointer is a no-op. Has no effect when Kitty graphics + * are disabled at build time. + * + * Input type: bool* + */ + GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_FILE = 16, + + /** + * Enable or disable Kitty image loading via the temporary file medium. + * + * A NULL value pointer is a no-op. Has no effect when Kitty graphics + * are disabled at build time. + * + * Input type: bool* + */ + GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_TEMP_FILE = 17, + + /** + * Enable or disable Kitty image loading via the shared memory medium. + * + * A NULL value pointer is a no-op. Has no effect when Kitty graphics + * are disabled at build time. + * + * Input type: bool* + */ + GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_SHARED_MEM = 18, + + /** + * Set the maximum bytes the APC handler will buffer for all protocols. + * This prevents malicious input from causing unbounded memory allocation. + * A NULL value pointer removes all overrides, reverting to the built-in + * defaults. + * + * Input type: size_t* + */ + GHOSTTY_TERMINAL_OPT_APC_MAX_BYTES = 19, + + /** + * Set the maximum bytes the APC handler will buffer for Kitty graphics + * protocol data. A NULL value pointer removes the override, reverting + * to the built-in default. + * + * Input type: size_t* + */ + GHOSTTY_TERMINAL_OPT_APC_MAX_BYTES_KITTY = 20, + + /** + * Set the active screen selection. + * + * The value must point to a GhosttySelection whose grid references are + * valid for this terminal's active screen at the time of the call. The + * terminal copies the selection immediately and converts it to + * terminal-owned tracked state, so the GhosttySelection struct and its + * untracked grid references do not need to outlive this call. + * + * Passing NULL clears the active screen selection. + * + * Input type: GhosttySelection* + */ + GHOSTTY_TERMINAL_OPT_SELECTION = 21, + + /** + * Set the default cursor style used by DECSCUSR reset (CSI 0 q). + * + * A NULL value pointer resets to the built-in default block cursor. + * + * Input type: GhosttyTerminalCursorStyle* + */ + GHOSTTY_TERMINAL_OPT_DEFAULT_CURSOR_STYLE = 22, + + /** + * Set whether the default cursor should blink when reset by DECSCUSR + * (CSI 0 q). + * + * A NULL value pointer resets to the built-in default of not blinking. + * + * Input type: bool* + */ + GHOSTTY_TERMINAL_OPT_DEFAULT_CURSOR_BLINK = 23, + + /** + * Enable or disable Glyph Protocol APC handling. + * + * When disabled, Glyph Protocol APC sequences are ignored and no + * support/query/register/clear responses are emitted. Disabling also clears + * the terminal session's glyph glossary. A NULL value pointer is a no-op. + * + * Input type: bool* + */ + GHOSTTY_TERMINAL_OPT_GLYPH_PROTOCOL = 24, + + /** + * Callback invoked when the terminal pwd changes via escape + * sequences (OSC 7, OSC 9, or OSC 1337 CurrentDir). Set to NULL + * to ignore pwd change events. + * + * Input type: GhosttyTerminalPwdChangedFn + */ + GHOSTTY_TERMINAL_OPT_PWD_CHANGED = 25, + GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyTerminalOption; + +/** + * Terminal data types. + * + * These values specify what type of data to extract from a terminal + * using `ghostty_terminal_get`. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Invalid data type. Never results in any data extraction. */ + GHOSTTY_TERMINAL_DATA_INVALID = 0, + + /** + * Terminal width in cells. + * + * Output type: uint16_t * + */ + GHOSTTY_TERMINAL_DATA_COLS = 1, + + /** + * Terminal height in cells. + * + * Output type: uint16_t * + */ + GHOSTTY_TERMINAL_DATA_ROWS = 2, + + /** + * Cursor column position (0-indexed). + * + * Output type: uint16_t * + */ + GHOSTTY_TERMINAL_DATA_CURSOR_X = 3, + + /** + * Cursor row position within the active area (0-indexed). + * + * Output type: uint16_t * + */ + GHOSTTY_TERMINAL_DATA_CURSOR_Y = 4, + + /** + * Whether the cursor has a pending wrap (next print will soft-wrap). + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_CURSOR_PENDING_WRAP = 5, + + /** + * The currently active screen. + * + * Output type: GhosttyTerminalScreen * + */ + GHOSTTY_TERMINAL_DATA_ACTIVE_SCREEN = 6, + + /** + * Whether the cursor is visible (DEC mode 25). + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_CURSOR_VISIBLE = 7, + + /** + * Current Kitty keyboard protocol flags. + * + * Output type: GhosttyKittyKeyFlags * (uint8_t *) + */ + GHOSTTY_TERMINAL_DATA_KITTY_KEYBOARD_FLAGS = 8, + + /** + * Scrollbar state for the terminal viewport. + * + * This may be expensive to calculate depending on where the viewport + * is (arbitrary pins are expensive). The caller should take care to only + * call this as needed and not too frequently. + * + * Output type: GhosttyTerminalScrollbar * + */ + GHOSTTY_TERMINAL_DATA_SCROLLBAR = 9, + + /** + * The current SGR style of the cursor. + * + * This is the style that will be applied to newly printed characters. + * + * Output type: GhosttyStyle * + */ + GHOSTTY_TERMINAL_DATA_CURSOR_STYLE = 10, + + /** + * Whether any mouse tracking mode is active. + * + * Returns true if any of the mouse tracking modes (X10, normal, button, + * or any-event) are enabled. + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_MOUSE_TRACKING = 11, + + /** + * The terminal title as set by escape sequences (e.g. OSC 0/2). + * + * Returns a borrowed string. The pointer is valid until the next call + * to ghostty_terminal_vt_write() or ghostty_terminal_reset(). An empty + * string (len=0) is returned when no title has been set. + * + * Output type: GhosttyString * + */ + GHOSTTY_TERMINAL_DATA_TITLE = 12, + + /** + * The terminal's current working directory as set by escape sequences + * (e.g. OSC 7). + * + * Returns a borrowed string. The pointer is valid until the next call + * to ghostty_terminal_vt_write() or ghostty_terminal_reset(). An empty + * string (len=0) is returned when no pwd has been set. + * + * Output type: GhosttyString * + */ + GHOSTTY_TERMINAL_DATA_PWD = 13, + + /** + * The total number of rows in the active screen including scrollback. + * + * Output type: size_t * + */ + GHOSTTY_TERMINAL_DATA_TOTAL_ROWS = 14, + + /** + * The number of scrollback rows (total rows minus viewport rows). + * + * Output type: size_t * + */ + GHOSTTY_TERMINAL_DATA_SCROLLBACK_ROWS = 15, + + /** + * The total width of the terminal in pixels. + * + * This is cols * cell_width_px as set by ghostty_terminal_resize(). + * + * Output type: uint32_t * + */ + GHOSTTY_TERMINAL_DATA_WIDTH_PX = 16, + + /** + * The total height of the terminal in pixels. + * + * This is rows * cell_height_px as set by ghostty_terminal_resize(). + * + * Output type: uint32_t * + */ + GHOSTTY_TERMINAL_DATA_HEIGHT_PX = 17, + + /** + * The effective foreground color (override or default). + * + * Returns GHOSTTY_NO_VALUE if no foreground color is set. + * + * Output type: GhosttyColorRgb * + */ + GHOSTTY_TERMINAL_DATA_COLOR_FOREGROUND = 18, + + /** + * The effective background color (override or default). + * + * Returns GHOSTTY_NO_VALUE if no background color is set. + * + * Output type: GhosttyColorRgb * + */ + GHOSTTY_TERMINAL_DATA_COLOR_BACKGROUND = 19, + + /** + * The effective cursor color (override or default). + * + * Returns GHOSTTY_NO_VALUE if no cursor color is set. + * + * Output type: GhosttyColorRgb * + */ + GHOSTTY_TERMINAL_DATA_COLOR_CURSOR = 20, + + /** + * The current 256-color palette. + * + * Output type: GhosttyColorRgb[256] * + */ + GHOSTTY_TERMINAL_DATA_COLOR_PALETTE = 21, + + /** + * The default foreground color (ignoring any OSC override). + * + * Returns GHOSTTY_NO_VALUE if no default foreground color is set. + * + * Output type: GhosttyColorRgb * + */ + GHOSTTY_TERMINAL_DATA_COLOR_FOREGROUND_DEFAULT = 22, + + /** + * The default background color (ignoring any OSC override). + * + * Returns GHOSTTY_NO_VALUE if no default background color is set. + * + * Output type: GhosttyColorRgb * + */ + GHOSTTY_TERMINAL_DATA_COLOR_BACKGROUND_DEFAULT = 23, + + /** + * The default cursor color (ignoring any OSC override). + * + * Returns GHOSTTY_NO_VALUE if no default cursor color is set. + * + * Output type: GhosttyColorRgb * + */ + GHOSTTY_TERMINAL_DATA_COLOR_CURSOR_DEFAULT = 24, + + /** + * The default 256-color palette (ignoring any OSC overrides). + * + * Output type: GhosttyColorRgb[256] * + */ + GHOSTTY_TERMINAL_DATA_COLOR_PALETTE_DEFAULT = 25, + + /** + * The Kitty image storage limit in bytes for the active screen. + * + * A value of zero means the Kitty graphics protocol is disabled. + * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time. + * + * Output type: uint64_t * + */ + GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_STORAGE_LIMIT = 26, + + /** + * Whether the file medium is enabled for Kitty image loading on the + * active screen. + * + * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time. + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_FILE = 27, + + /** + * Whether the temporary file medium is enabled for Kitty image loading + * on the active screen. + * + * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time. + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_TEMP_FILE = 28, + + /** + * Whether the shared memory medium is enabled for Kitty image loading + * on the active screen. + * + * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time. + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_SHARED_MEM = 29, + + /** + * The Kitty graphics image storage for the active screen. + * + * Returns a borrowed pointer to the image storage. The pointer is valid + * until the next mutating terminal call (e.g. ghostty_terminal_vt_write() + * or ghostty_terminal_reset()). + * + * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time. + * + * Output type: GhosttyKittyGraphics * + */ + GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS = 30, + + /** + * The active screen's current selection. + * + * On success, writes an untracked snapshot of the terminal-owned selection + * to the caller-provided GhosttySelection. The GhosttySelection struct is + * caller-owned and may be kept, but the grid references inside it are + * untracked borrowed references into the active screen. They are only valid + * until the next mutating terminal call, such as ghostty_terminal_set(), + * ghostty_terminal_vt_write(), ghostty_terminal_resize(), or + * ghostty_terminal_reset(). + * + * Returns GHOSTTY_NO_VALUE when there is no active selection. + * + * Output type: GhosttySelection * + */ + GHOSTTY_TERMINAL_DATA_SELECTION = 31, + + /** + * Whether the viewport is currently pinned to the active area. + * + * This is true when the viewport is following the active terminal area, + * and false when the user has scrolled into history. + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_VIEWPORT_ACTIVE = 32, + GHOSTTY_TERMINAL_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyTerminalData; + +/** + * Create a new terminal instance. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param terminal Pointer to store the created terminal handle + * @param options Terminal initialization options + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_new(const GhosttyAllocator* allocator, + GhosttyTerminal* terminal, + GhosttyTerminalOptions options); + +/** + * Free a terminal instance. + * + * Releases all resources associated with the terminal. After this call, + * the terminal handle becomes invalid and must not be used. + * + * @param terminal The terminal handle to free (may be NULL) + * + * @ingroup terminal + */ +GHOSTTY_API void ghostty_terminal_free(GhosttyTerminal terminal); + +/** + * Perform a full reset of the terminal (RIS). + * + * Resets all terminal state back to its initial configuration, including + * modes, scrollback, scrolling region, and screen contents. The terminal + * dimensions are preserved. + * + * @param terminal The terminal handle (may be NULL, in which case this is a no-op) + * + * @ingroup terminal + */ +GHOSTTY_API void ghostty_terminal_reset(GhosttyTerminal terminal); + +/** + * Resize the terminal to the given dimensions. + * + * Changes the number of columns and rows in the terminal. The primary + * screen will reflow content if wraparound mode is enabled; the alternate + * screen does not reflow. If the dimensions are unchanged, this is a no-op. + * + * This also updates the terminal's pixel dimensions (used for image + * protocols and size reports), disables synchronized output mode (allowed + * by the spec so that resize results are shown immediately), and sends an + * in-band size report if mode 2048 is enabled. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param cols New width in cells (must be greater than zero) + * @param rows New height in cells (must be greater than zero) + * @param cell_width_px Width of a single cell in pixels + * @param cell_height_px Height of a single cell in pixels + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_resize(GhosttyTerminal terminal, + uint16_t cols, + uint16_t rows, + uint32_t cell_width_px, + uint32_t cell_height_px); + +/** + * Set an option on the terminal. + * + * Configures terminal callbacks and associated state such as the + * write_pty callback and userdata pointer. The value is passed + * directly for pointer types (callbacks, userdata) or as a pointer + * to the value for non-pointer types (e.g. GhosttyString*). + * NULL clears the option to its default. + * + * Callbacks are invoked synchronously during ghostty_terminal_vt_write(). + * Callbacks must not call ghostty_terminal_vt_write() on the same + * terminal (no reentrancy). + * + * @param terminal The terminal handle (may be NULL, in which case this is a no-op) + * @param option The option to set + * @param value Pointer to the value to set (type depends on the option), + * or NULL to clear the option + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_set(GhosttyTerminal terminal, + GhosttyTerminalOption option, + const void* value); + +/** + * Write VT-encoded data to the terminal for processing. + * + * Feeds raw bytes through the terminal's VT stream parser, updating + * terminal state accordingly. By default, sequences that require output + * (queries, device status reports) are silently ignored. Use + * ghostty_terminal_set() with GHOSTTY_TERMINAL_OPT_WRITE_PTY to install + * a callback that receives response data. + * + * This never fails. Any erroneous input or errors in processing the + * input are logged internally but do not cause this function to fail + * because this input is assumed to be untrusted and from an external + * source; so the primary goal is to keep the terminal state consistent and + * not allow malformed input to corrupt or crash. + * + * @param terminal The terminal handle + * @param data Pointer to the data to write + * @param len Length of the data in bytes + * + * @ingroup terminal + */ +GHOSTTY_API void ghostty_terminal_vt_write(GhosttyTerminal terminal, + const uint8_t* data, + size_t len); + +/** + * Scroll the terminal viewport. + * + * Scrolls the terminal's viewport according to the given behavior. + * When using GHOSTTY_SCROLL_VIEWPORT_DELTA, set the delta field in + * the value union to specify the number of rows to scroll (negative + * for up, positive for down). For other behaviors, the value is ignored. + * + * @param terminal The terminal handle (may be NULL, in which case this is a no-op) + * @param behavior The scroll behavior as a tagged union + * + * @ingroup terminal + */ +GHOSTTY_API void ghostty_terminal_scroll_viewport(GhosttyTerminal terminal, + GhosttyTerminalScrollViewport behavior); + +/** + * Get the current value of a terminal mode. + * + * Returns the value of the mode identified by the given mode. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param mode The mode identifying the mode to query + * @param[out] out_value On success, set to true if the mode is set, false + * if it is reset + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal + * is NULL or the mode does not correspond to a known mode + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_mode_get(GhosttyTerminal terminal, + GhosttyMode mode, + bool* out_value); + +/** + * Set the value of a terminal mode. + * + * Sets the mode identified by the given mode to the specified value. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param mode The mode identifying the mode to set + * @param value true to set the mode, false to reset it + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal + * is NULL or the mode does not correspond to a known mode + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_mode_set(GhosttyTerminal terminal, + GhosttyMode mode, + bool value); + +/** + * Get data from a terminal instance. + * + * Extracts typed data from the given terminal based on the specified + * data type. The output pointer must be of the appropriate type for the + * requested data kind. Valid data types and output types are documented + * in the `GhosttyTerminalData` enum. + * + * @param terminal The terminal handle (may be NULL) + * @param data The type of data to extract + * @param out Pointer to store the extracted data (type depends on data parameter) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal + * is NULL or the data type is invalid + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_get(GhosttyTerminal terminal, + GhosttyTerminalData data, + void *out); + +/** + * Get multiple data fields from a terminal in a single call. + * + * This is an optimization over calling ghostty_terminal_get() + * repeatedly, particularly useful in environments with high per-call + * overhead such as FFI or Cgo. + * + * Each element in the keys array specifies a data kind, and the + * corresponding element in the values array receives the result. + * The type of each values[i] pointer must match the output type + * documented for keys[i]. + * + * Processing stops at the first error; on success out_written + * is set to count, on error it is set to the index of the + * failing key (i.e. the number of values successfully written). + * + * @param terminal The terminal handle (may be NULL) + * @param count Number of key/value pairs + * @param keys Array of data kinds to query + * @param values Array of output pointers (types must match each key's + * documented output type) + * @param[out] out_written On return, receives the number of values + * successfully written (may be NULL) + * @return GHOSTTY_SUCCESS if all queries succeed + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_get_multi(GhosttyTerminal terminal, + size_t count, + const GhosttyTerminalData* keys, + void** values, + size_t* out_written); + +/** + * Resolve a point in the terminal grid to a grid reference. + * + * Resolves the given point (which can be in active, viewport, screen, + * or history coordinates) to a grid reference for that location. Use + * ghostty_grid_ref_cell() and ghostty_grid_ref_row() to extract the cell + * and row. + * + * Lookups using the `active` and `viewport` tags are fast. The `screen` + * and `history` tags may require traversing the full scrollback page list + * to resolve the y coordinate, so they can be expensive for large + * scrollback buffers. + * + * This function isn't meant to be used as the core of render loop. It + * isn't built to sustain the framerates needed for rendering large screens. + * Use the render state API for that. This API is instead meant for less + * strictly performance-sensitive use cases. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param point The point specifying which cell to look up + * @param[out] out_ref On success, set to the grid reference at the given point (may be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal + * is NULL or the point is out of bounds + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_grid_ref(GhosttyTerminal terminal, + GhosttyPoint point, + GhosttyGridRef *out_ref); + +/** + * Create an owned tracked grid reference for a terminal point. + * + * This is the tracked variant of ghostty_terminal_grid_ref(). The returned + * handle follows the referenced cell as the terminal's page list is modified: + * scrolling, pruning, resize/reflow, and other page-list operations update the + * tracked reference automatically. + * + * The reference is attached to the terminal screen/page-list that is active at + * creation time. + * + * If the point is outside the requested coordinate space, this returns + * GHOSTTY_INVALID_VALUE and writes NULL to out_ref. + * + * The returned handle must be freed with ghostty_tracked_grid_ref_free(). If + * the terminal is freed first, the handle remains valid only for + * tracked-grid-ref APIs: it reports no value and can still be freed. + * + * @param terminal Terminal instance. + * @param point Point to track. + * @param[out] out_ref On success, receives the tracked reference handle. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if terminal, + * point, or out_ref is invalid, or GHOSTTY_OUT_OF_MEMORY if allocation + * fails. + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_grid_ref_track( + GhosttyTerminal terminal, + GhosttyPoint point, + GhosttyTrackedGridRef *out_ref); + +/** + * Convert a grid reference back to a point in the given coordinate system. + * + * This is the inverse of ghostty_terminal_grid_ref(): given a grid reference, + * it returns the x/y coordinates in the requested coordinate system (active, + * viewport, screen, or history). + * + * The grid reference must have been obtained from the same terminal instance. + * Like all grid references, it is only valid until the next mutating terminal + * call. + * + * Not every grid reference is representable in every coordinate system. For + * example, a cell in scrollback history cannot be expressed in active + * coordinates, and a cell that has scrolled off the visible area cannot be + * expressed in viewport coordinates. In these cases, the function returns + * GHOSTTY_NO_VALUE. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param ref Pointer to the grid reference to convert + * @param tag The target coordinate system + * @param[out] out On success, set to the coordinate in the requested system (may be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal + * or ref is NULL/invalid, GHOSTTY_NO_VALUE if the ref falls outside + * the requested coordinate system + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_point_from_grid_ref( + GhosttyTerminal terminal, + const GhosttyGridRef *ref, + GhosttyPointTag tag, + GhosttyPointCoordinate *out); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_TERMINAL_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/types.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/types.h new file mode 100644 index 00000000000..214d282296c --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/types.h @@ -0,0 +1,335 @@ +/** + * @file types.h + * + * Common types, macros, and utilities for libghostty-vt. + */ + +#ifndef GHOSTTY_VT_TYPES_H +#define GHOSTTY_VT_TYPES_H + +#include +#include +#include + +// Symbol visibility for shared library builds. On Windows, functions +// are exported from the DLL when building and imported when consuming. +// On other platforms with GCC/Clang, functions are marked with default +// visibility so they remain accessible when the library is built with +// -fvisibility=hidden. For static library builds, define GHOSTTY_STATIC +// before including this header to make this a no-op. +#ifndef GHOSTTY_API +#if defined(GHOSTTY_STATIC) + #define GHOSTTY_API +#elif defined(_WIN32) || defined(_WIN64) + #ifdef GHOSTTY_BUILD_SHARED + #define GHOSTTY_API __declspec(dllexport) + #else + #define GHOSTTY_API __declspec(dllimport) + #endif +#elif defined(__GNUC__) && __GNUC__ >= 4 + #define GHOSTTY_API __attribute__((visibility("default"))) +#else + #define GHOSTTY_API +#endif +#endif + +/** + * Enum int-sizing helpers. + * + * The Zig side backs all C enums with c_int, so the C declarations + * must use int as their underlying type to maintain ABI compatibility. + * + * C23 (detected via __STDC_VERSION__ >= 202311L) supports explicit + * enum underlying types with `enum : int { ... }`. For pre-C23 + * compilers, which are free to choose any type that can represent + * all values (C11 §6.7.2.2), we add an INT_MAX sentinel as the last + * entry to force the compiler to use int. + * + * INT_MAX is used rather than a fixed constant like 0xFFFFFFFF + * because enum constants must have type int (which is signed). + * Values above INT_MAX overflow signed int and are a constraint + * violation in standard C; compilers that accept them interpret them + * as negative values via two's complement, which can collide with + * legitimate negative enum values. + * + * Usage: + * @code + * typedef enum GHOSTTY_ENUM_TYPED { + * FOO_A = 0, + * FOO_B = 1, + * FOO_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, + * } Foo; + * @endcode + */ +#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 202311L +#define GHOSTTY_ENUM_TYPED : int +#else +#define GHOSTTY_ENUM_TYPED +#endif +#define GHOSTTY_ENUM_MAX_VALUE INT_MAX + +/** + * Result codes for libghostty-vt operations. + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Operation completed successfully */ + GHOSTTY_SUCCESS = 0, + /** Operation failed due to failed allocation */ + GHOSTTY_OUT_OF_MEMORY = -1, + /** Operation failed due to invalid value */ + GHOSTTY_INVALID_VALUE = -2, + /** Operation failed because the provided buffer was too small */ + GHOSTTY_OUT_OF_SPACE = -3, + /** The requested value has no value */ + GHOSTTY_NO_VALUE = -4, + GHOSTTY_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyResult; + +/* ---- Opaque handles ---- */ + +/** + * Opaque handle to a terminal instance. + * + * @ingroup terminal + */ +typedef struct GhosttyTerminalImpl* GhosttyTerminal; + +/** + * Opaque handle to a tracked grid reference. + * + * A tracked grid reference is owned by the caller and must be freed with + * ghostty_tracked_grid_ref_free(). If the terminal that created it is freed + * first, the handle remains valid only for tracked-grid-ref APIs: it reports no + * value and can still be freed. + * + * @ingroup grid_ref + */ +typedef struct GhosttyTrackedGridRefImpl* GhosttyTrackedGridRef; + +/** + * Opaque handle to a Kitty graphics image storage. + * + * Obtained via ghostty_terminal_get() with + * GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS. The pointer is borrowed from + * the terminal and remains valid until the next mutating terminal call + * (e.g. ghostty_terminal_vt_write() or ghostty_terminal_reset()). + * + * @ingroup kitty_graphics + */ +typedef struct GhosttyKittyGraphicsImpl* GhosttyKittyGraphics; + +/** + * Opaque handle to a Kitty graphics image. + * + * Obtained via ghostty_kitty_graphics_image() with an image ID. The + * pointer is borrowed from the storage and remains valid until the next + * mutating terminal call. + * + * @ingroup kitty_graphics + */ +typedef const struct GhosttyKittyGraphicsImageImpl* GhosttyKittyGraphicsImage; + +/** + * Opaque handle to a Kitty graphics placement iterator. + * + * @ingroup kitty_graphics + */ +typedef struct GhosttyKittyGraphicsPlacementIteratorImpl* GhosttyKittyGraphicsPlacementIterator; + +/** + * Opaque handle to a render state instance. + * + * @ingroup render + */ +typedef struct GhosttyRenderStateImpl* GhosttyRenderState; + +/** + * Opaque handle to a render-state row iterator. + * + * @ingroup render + */ +typedef struct GhosttyRenderStateRowIteratorImpl* GhosttyRenderStateRowIterator; + +/** + * Opaque handle to render-state row cells. + * + * @ingroup render + */ +typedef struct GhosttyRenderStateRowCellsImpl* GhosttyRenderStateRowCells; + +/** + * Opaque handle to an SGR parser instance. + * + * This handle represents an SGR (Select Graphic Rendition) parser that can + * be used to parse SGR sequences and extract individual text attributes. + * + * @ingroup sgr + */ +typedef struct GhosttySgrParserImpl* GhosttySgrParser; + +/** + * Opaque handle to a formatter instance. + * + * @ingroup formatter + */ +typedef struct GhosttyFormatterImpl* GhosttyFormatter; + +/** + * Opaque handle to an OSC parser instance. + * + * This handle represents an OSC (Operating System Command) parser that can + * be used to parse the contents of OSC sequences. + * + * @ingroup osc + */ +typedef struct GhosttyOscParserImpl* GhosttyOscParser; + +/** + * Opaque handle to a single OSC command. + * + * This handle represents a parsed OSC (Operating System Command) command. + * The command can be queried for its type and associated data. + * + * @ingroup osc + */ +typedef struct GhosttyOscCommandImpl* GhosttyOscCommand; + +/* ---- Common value types ---- */ + +/** + * Terminal content output format. + * + * @ingroup formatter + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Plain text (no escape sequences). */ + GHOSTTY_FORMATTER_FORMAT_PLAIN, + + /** VT sequences preserving colors, styles, URLs, etc. */ + GHOSTTY_FORMATTER_FORMAT_VT, + + /** HTML with inline styles. */ + GHOSTTY_FORMATTER_FORMAT_HTML, + GHOSTTY_FORMATTER_FORMAT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyFormatterFormat; + +/** + * A borrowed byte string (pointer + length). + * + * The memory is not owned by this struct. The pointer is only valid + * for the lifetime documented by the API that produces or consumes it. + */ +typedef struct { + /** Pointer to the string bytes. */ + const uint8_t* ptr; + + /** Length of the string in bytes. */ + size_t len; +} GhosttyString; + +/** + * A caller-provided byte buffer. + * + * APIs that write to this type use `len` for the number of bytes written on + * GHOSTTY_SUCCESS and the required byte capacity on GHOSTTY_OUT_OF_SPACE. + */ +typedef struct { + /** Destination buffer for bytes. May be NULL when cap is 0 to query required size. */ + uint8_t* ptr; + + /** Capacity of ptr in bytes. */ + size_t cap; + + /** Bytes written on success, or required byte capacity on GHOSTTY_OUT_OF_SPACE. */ + size_t len; +} GhosttyBuffer; + +/** + * A surface-space position in pixels. + * + * This is not a terminal grid coordinate. It represents an x/y position in the + * rendered surface coordinate space, with (0, 0) at the top-left of the + * surface. + */ +typedef struct { + /** X position in surface pixels. */ + double x; + + /** Y position in surface pixels. */ + double y; +} GhosttySurfacePosition; + +/** + * A borrowed list of Unicode scalar values. + * + * Values are encoded as uint32_t scalar values. The memory is not owned by this + * struct. The pointer is only valid for the lifetime documented by the API that + * consumes or produces it. + * + * APIs may document special handling for NULL + len 0, such as “use defaults”. + */ +typedef struct { + /** Pointer to Unicode scalar values. */ + const uint32_t* ptr; + + /** Number of entries in ptr. */ + size_t len; +} GhosttyCodepoints; + +/** + * Initialize a sized struct to zero and set its size field. + * + * Sized structs use a `size` field as the first member for ABI + * compatibility. This macro zero-initializes the struct and sets the + * size field to `sizeof(type)`, which allows the library to detect + * which version of the struct the caller was compiled against. + * + * @param type The struct type to initialize + * @return A zero-initialized struct with the size field set + * + * Example: + * @code + * GhosttyFormatterTerminalOptions opts = GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions); + * opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN; + * opts.trim = true; + * @endcode + */ +#define GHOSTTY_INIT_SIZED(type) \ + ((type){ .size = sizeof(type) }) + +/** + * Return a pointer to a null-terminated JSON string describing the + * layout of every C API struct for the current target. + * + * This is primarily useful for language bindings that can't easily + * set C struct fields and need to do so via byte offsets. For example, + * WebAssembly modules can't share struct definitions with the host. + * + * Example (abbreviated): + * @code{.json} + * { + * "GhosttyMouseEncoderSize": { + * "size": 40, + * "align": 8, + * "fields": { + * "size": { "offset": 0, "size": 8, "type": "u64" }, + * "screen_width": { "offset": 8, "size": 4, "type": "u32" }, + * "screen_height": { "offset": 12, "size": 4, "type": "u32" }, + * "cell_width": { "offset": 16, "size": 4, "type": "u32" }, + * "cell_height": { "offset": 20, "size": 4, "type": "u32" }, + * "padding_top": { "offset": 24, "size": 4, "type": "u32" }, + * "padding_bottom": { "offset": 28, "size": 4, "type": "u32" }, + * "padding_right": { "offset": 32, "size": 4, "type": "u32" }, + * "padding_left": { "offset": 36, "size": 4, "type": "u32" } + * } + * } + * } + * @endcode + * + * The returned pointer is valid for the lifetime of the process. + * + * @return Pointer to the null-terminated JSON string. + */ +GHOSTTY_API const char *ghostty_type_json(void); + +#endif /* GHOSTTY_VT_TYPES_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/wasm.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/wasm.h new file mode 100644 index 00000000000..e2b63e2c601 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/wasm.h @@ -0,0 +1,160 @@ +/** + * @file wasm.h + * + * WebAssembly utility functions for libghostty-vt. + */ + +#ifndef GHOSTTY_VT_WASM_H +#define GHOSTTY_VT_WASM_H + +#ifdef __wasm__ + +#include +#include +#include + +/** @defgroup wasm WebAssembly Utilities + * + * Convenience functions for allocating various types in WebAssembly builds. + * **These are only available the libghostty-vt wasm module.** + * + * Ghostty relies on pointers to various types for ABI compatibility, and + * creating those pointers in Wasm can be tedious. These functions provide + * a purely additive set of utilities that simplify memory management in + * Wasm environments without changing the core C library API. + * + * @note These functions always use the default allocator. If you need + * custom allocation strategies, you should allocate types manually using + * your custom allocator. This is a very rare use case in the WebAssembly + * world so these are optimized for simplicity. + * + * ## Example Usage + * + * Here's a simple example of using the Wasm utilities with the key encoder: + * + * @code + * const { exports } = wasmInstance; + * const view = new DataView(wasmMemory.buffer); + * + * // Create key encoder + * const encoderPtr = exports.ghostty_wasm_alloc_opaque(); + * exports.ghostty_key_encoder_new(null, encoderPtr); + * const encoder = view.getUint32(encoder, true); + * + * // Configure encoder with Kitty protocol flags + * const flagsPtr = exports.ghostty_wasm_alloc_u8(); + * view.setUint8(flagsPtr, 0x1F); + * exports.ghostty_key_encoder_setopt(encoder, 5, flagsPtr); + * + * // Allocate output buffer and size pointer + * const bufferSize = 32; + * const bufPtr = exports.ghostty_wasm_alloc_u8_array(bufferSize); + * const writtenPtr = exports.ghostty_wasm_alloc_usize(); + * + * // Encode the key event + * exports.ghostty_key_encoder_encode( + * encoder, eventPtr, bufPtr, bufferSize, writtenPtr + * ); + * + * // Read encoded output + * const bytesWritten = view.getUint32(writtenPtr, true); + * const encoded = new Uint8Array(wasmMemory.buffer, bufPtr, bytesWritten); + * @endcode + * + * @remark The code above is pretty ugly! This is the lowest level interface + * to the libghostty-vt Wasm module. In practice, this should be wrapped + * in a higher-level API that abstracts away all this. + * + * @{ + */ + +/** + * Allocate an opaque pointer. This can be used for any opaque pointer + * types such as GhosttyKeyEncoder, GhosttyKeyEvent, etc. + * + * @return Pointer to allocated opaque pointer, or NULL if allocation failed + * @ingroup wasm + */ +GHOSTTY_API void** ghostty_wasm_alloc_opaque(void); + +/** + * Free an opaque pointer allocated by ghostty_wasm_alloc_opaque(). + * + * @param ptr Pointer to free, or NULL (NULL is safely ignored) + * @ingroup wasm + */ +GHOSTTY_API void ghostty_wasm_free_opaque(void **ptr); + +/** + * Allocate an array of uint8_t values. + * + * @param len Number of uint8_t elements to allocate + * @return Pointer to allocated array, or NULL if allocation failed + * @ingroup wasm + */ +GHOSTTY_API uint8_t* ghostty_wasm_alloc_u8_array(size_t len); + +/** + * Free an array allocated by ghostty_wasm_alloc_u8_array(). + * + * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored) + * @param len Length of the array (must match the length passed to alloc) + * @ingroup wasm + */ +GHOSTTY_API void ghostty_wasm_free_u8_array(uint8_t *ptr, size_t len); + +/** + * Allocate an array of uint16_t values. + * + * @param len Number of uint16_t elements to allocate + * @return Pointer to allocated array, or NULL if allocation failed + * @ingroup wasm + */ +GHOSTTY_API uint16_t* ghostty_wasm_alloc_u16_array(size_t len); + +/** + * Free an array allocated by ghostty_wasm_alloc_u16_array(). + * + * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored) + * @param len Length of the array (must match the length passed to alloc) + * @ingroup wasm + */ +GHOSTTY_API void ghostty_wasm_free_u16_array(uint16_t *ptr, size_t len); + +/** + * Allocate a single uint8_t value. + * + * @return Pointer to allocated uint8_t, or NULL if allocation failed + * @ingroup wasm + */ +GHOSTTY_API uint8_t* ghostty_wasm_alloc_u8(void); + +/** + * Free a uint8_t allocated by ghostty_wasm_alloc_u8(). + * + * @param ptr Pointer to free, or NULL (NULL is safely ignored) + * @ingroup wasm + */ +GHOSTTY_API void ghostty_wasm_free_u8(uint8_t *ptr); + +/** + * Allocate a single size_t value. + * + * @return Pointer to allocated size_t, or NULL if allocation failed + * @ingroup wasm + */ +GHOSTTY_API size_t* ghostty_wasm_alloc_usize(void); + +/** + * Free a size_t allocated by ghostty_wasm_alloc_usize(). + * + * @param ptr Pointer to free, or NULL (NULL is safely ignored) + * @ingroup wasm + */ +GHOSTTY_API void ghostty_wasm_free_usize(size_t *ptr); + +/** @} */ + +#endif /* __wasm__ */ + +#endif /* GHOSTTY_VT_WASM_H */ diff --git a/apps/mobile/modules/t3-terminal/android/.gitignore b/apps/mobile/modules/t3-terminal/android/.gitignore new file mode 100644 index 00000000000..3166b90b042 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/.gitignore @@ -0,0 +1 @@ +.cxx/ diff --git a/apps/mobile/modules/t3-terminal/android/build.gradle b/apps/mobile/modules/t3-terminal/android/build.gradle index 90c0d4fc21e..0da0777cf4f 100644 --- a/apps/mobile/modules/t3-terminal/android/build.gradle +++ b/apps/mobile/modules/t3-terminal/android/build.gradle @@ -6,11 +6,25 @@ version = '0.0.0' android { namespace 'expo.modules.t3terminal' + ndkVersion rootProject.ext.ndkVersion compileSdk rootProject.ext.compileSdkVersion defaultConfig { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion + + externalNativeBuild { + cmake { + cppFlags '-std=c++17 -Wall -Wextra -Werror' + } + } + } + + externalNativeBuild { + cmake { + path 'src/main/cpp/CMakeLists.txt' + version '3.22.1' + } } } diff --git a/apps/mobile/modules/t3-terminal/android/src/main/assets/fonts/MesloLGS-NF-Bold.ttf b/apps/mobile/modules/t3-terminal/android/src/main/assets/fonts/MesloLGS-NF-Bold.ttf new file mode 100644 index 00000000000..e0e39544b02 Binary files /dev/null and b/apps/mobile/modules/t3-terminal/android/src/main/assets/fonts/MesloLGS-NF-Bold.ttf differ diff --git a/apps/mobile/modules/t3-terminal/android/src/main/assets/fonts/MesloLGS-NF-Regular.ttf b/apps/mobile/modules/t3-terminal/android/src/main/assets/fonts/MesloLGS-NF-Regular.ttf new file mode 100644 index 00000000000..88b81490cd7 Binary files /dev/null and b/apps/mobile/modules/t3-terminal/android/src/main/assets/fonts/MesloLGS-NF-Regular.ttf differ diff --git a/apps/mobile/modules/t3-terminal/android/src/main/cpp/CMakeLists.txt b/apps/mobile/modules/t3-terminal/android/src/main/cpp/CMakeLists.txt new file mode 100644 index 00000000000..0273ff6c864 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/main/cpp/CMakeLists.txt @@ -0,0 +1,23 @@ +cmake_minimum_required(VERSION 3.22.1) + +project(t3terminal LANGUAGES CXX) + +add_library(ghostty-vt SHARED IMPORTED) +set_target_properties( + ghostty-vt + PROPERTIES IMPORTED_LOCATION + "${CMAKE_CURRENT_SOURCE_DIR}/../jniLibs/${ANDROID_ABI}/libghostty-vt.so" +) + +add_library(t3terminal SHARED t3_terminal_jni.cpp) + +target_include_directories( + t3terminal + PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/../../../../Vendor/libghostty-vt/include" +) + +target_link_options( + t3terminal PRIVATE "-Wl,-z,common-page-size=16384" "-Wl,-z,max-page-size=16384" +) + +target_link_libraries(t3terminal PRIVATE ghostty-vt log) diff --git a/apps/mobile/modules/t3-terminal/android/src/main/cpp/t3_terminal_jni.cpp b/apps/mobile/modules/t3-terminal/android/src/main/cpp/t3_terminal_jni.cpp new file mode 100644 index 00000000000..95760e8ead9 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/main/cpp/t3_terminal_jni.cpp @@ -0,0 +1,516 @@ +#include + +#include +#include +#include +#include +#include + +#include + +namespace { + +constexpr uint32_t kSnapshotMagic = 0x54563354; // "T3VT" in little endian. +constexpr uint16_t kSnapshotVersion = 1; +constexpr size_t kMaxScrollbackRows = 10000; + +enum CellFlag : uint16_t { + kBold = 1 << 0, + kItalic = 1 << 1, + kFaint = 1 << 2, + kInverse = 1 << 3, + kInvisible = 1 << 4, + kStrikethrough = 1 << 5, + kOverline = 1 << 6, + kUnderline = 1 << 7, + kSelected = 1 << 8, +}; + +struct Session { + GhosttyTerminal terminal = nullptr; + GhosttyRenderState render_state = nullptr; + GhosttyRenderStateRowIterator row_iterator = nullptr; + GhosttyRenderStateRowCells row_cells = nullptr; + std::vector responses; + std::mutex mutex; +}; + +class ByteWriter { + public: + explicit ByteWriter(size_t capacity) { bytes_.reserve(capacity); } + + void U8(uint8_t value) { bytes_.push_back(value); } + + void U16(uint16_t value) { + U8(static_cast(value)); + U8(static_cast(value >> 8)); + } + + void U32(uint32_t value) { + U16(static_cast(value)); + U16(static_cast(value >> 16)); + } + + void Bytes(const std::vector& value) { + bytes_.insert(bytes_.end(), value.begin(), value.end()); + } + + std::vector Take() { return std::move(bytes_); } + + private: + std::vector bytes_; +}; + +Session* FromHandle(jlong handle) { + return reinterpret_cast(static_cast(handle)); +} + +jbyteArray ToJavaBytes(JNIEnv* env, const std::vector& bytes) { + auto result = env->NewByteArray(static_cast(bytes.size())); + if (result != nullptr && !bytes.empty()) { + env->SetByteArrayRegion(result, 0, static_cast(bytes.size()), + reinterpret_cast(bytes.data())); + } + return result; +} + +GhosttyColorRgb RgbFromArgb(jint color) { + const auto value = static_cast(color); + return { + .r = static_cast(value >> 16), + .g = static_cast(value >> 8), + .b = static_cast(value), + }; +} + +uint32_t ArgbFromRgb(GhosttyColorRgb color) { + return 0xFF000000U | (static_cast(color.r) << 16U) | + (static_cast(color.g) << 8U) | color.b; +} + +GhosttyColorRgb Blend(GhosttyColorRgb foreground, GhosttyColorRgb background, + uint8_t foreground_weight) { + const auto blend = [foreground_weight](uint8_t front, uint8_t back) { + const uint16_t back_weight = 255 - foreground_weight; + return static_cast((front * foreground_weight + back * back_weight) / 255); + }; + return { + .r = blend(foreground.r, background.r), + .g = blend(foreground.g, background.g), + .b = blend(foreground.b, background.b), + }; +} + +void AppendUtf8(std::vector* output, uint32_t codepoint) { + if (codepoint <= 0x7F) { + output->push_back(static_cast(codepoint)); + } else if (codepoint <= 0x7FF) { + output->push_back(static_cast(0xC0 | (codepoint >> 6))); + output->push_back(static_cast(0x80 | (codepoint & 0x3F))); + } else if (codepoint <= 0xFFFF && !(codepoint >= 0xD800 && codepoint <= 0xDFFF)) { + output->push_back(static_cast(0xE0 | (codepoint >> 12))); + output->push_back(static_cast(0x80 | ((codepoint >> 6) & 0x3F))); + output->push_back(static_cast(0x80 | (codepoint & 0x3F))); + } else if (codepoint <= 0x10FFFF) { + output->push_back(static_cast(0xF0 | (codepoint >> 18))); + output->push_back(static_cast(0x80 | ((codepoint >> 12) & 0x3F))); + output->push_back(static_cast(0x80 | ((codepoint >> 6) & 0x3F))); + output->push_back(static_cast(0x80 | (codepoint & 0x3F))); + } +} + +void OnWritePty(GhosttyTerminal, void* userdata, const uint8_t* data, size_t len) { + auto* session = static_cast(userdata); + if (session == nullptr || data == nullptr || len == 0) return; + session->responses.insert(session->responses.end(), data, data + len); +} + +void ApplyTheme(Session* session, jint foreground, jint background, jint cursor, + JNIEnv* env, jintArray palette_array) { + auto foreground_rgb = RgbFromArgb(foreground); + auto background_rgb = RgbFromArgb(background); + auto cursor_rgb = RgbFromArgb(cursor); + ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_COLOR_FOREGROUND, + &foreground_rgb); + ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_COLOR_BACKGROUND, + &background_rgb); + ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_COLOR_CURSOR, &cursor_rgb); + + if (palette_array == nullptr) return; + const auto palette_length = env->GetArrayLength(palette_array); + if (palette_length <= 0) return; + + GhosttyColorRgb palette[256]; + if (ghostty_terminal_get(session->terminal, + GHOSTTY_TERMINAL_DATA_COLOR_PALETTE_DEFAULT, + palette) != GHOSTTY_SUCCESS) { + return; + } + + const auto copied_length = std::min(palette_length, 256); + std::vector colors(static_cast(copied_length)); + env->GetIntArrayRegion(palette_array, 0, copied_length, colors.data()); + for (jsize index = 0; index < copied_length; ++index) { + palette[index] = RgbFromArgb(colors[index]); + } + ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_COLOR_PALETTE, palette); +} + +void FreeSession(Session* session) { + if (session == nullptr) return; + ghostty_render_state_row_cells_free(session->row_cells); + ghostty_render_state_row_iterator_free(session->row_iterator); + ghostty_render_state_free(session->render_state); + ghostty_terminal_free(session->terminal); + delete session; +} + +std::vector DrainResponses(Session* session) { + std::vector responses; + responses.swap(session->responses); + return responses; +} + +bool ViewportGridRef(Session* session, jint x, jint y, GhosttyGridRef* out) { + *out = GhosttyGridRef{}; + out->size = sizeof(*out); + GhosttyPoint point{}; + point.tag = GHOSTTY_POINT_TAG_VIEWPORT; + point.value.coordinate.x = static_cast(std::max(x, 0)); + point.value.coordinate.y = static_cast(std::max(y, 0)); + return ghostty_terminal_grid_ref(session->terminal, point, out) == GHOSTTY_SUCCESS; +} + +uint16_t StyleFlags(const GhosttyStyle& style, bool selected) { + uint16_t flags = 0; + if (style.bold) flags |= kBold; + if (style.italic) flags |= kItalic; + if (style.faint) flags |= kFaint; + if (style.inverse) flags |= kInverse; + if (style.invisible) flags |= kInvisible; + if (style.strikethrough) flags |= kStrikethrough; + if (style.overline) flags |= kOverline; + if (style.underline != 0) flags |= kUnderline; + if (selected) flags |= kSelected; + return flags; +} + +} // namespace + +extern "C" JNIEXPORT jlong JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeCreate( + JNIEnv* env, jclass, jint cols, jint rows, jint cell_width, jint cell_height, + jint foreground, jint background, jint cursor, jintArray palette) { + auto* session = new Session(); + GhosttyTerminalOptions options = { + .cols = static_cast(std::clamp(cols, 1, 65535)), + .rows = static_cast(std::clamp(rows, 1, 65535)), + .max_scrollback = kMaxScrollbackRows, + }; + if (ghostty_terminal_new(nullptr, &session->terminal, options) != GHOSTTY_SUCCESS || + ghostty_render_state_new(nullptr, &session->render_state) != GHOSTTY_SUCCESS || + ghostty_render_state_row_iterator_new(nullptr, &session->row_iterator) != GHOSTTY_SUCCESS || + ghostty_render_state_row_cells_new(nullptr, &session->row_cells) != GHOSTTY_SUCCESS) { + FreeSession(session); + return 0; + } + + ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_USERDATA, session); + ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_WRITE_PTY, + reinterpret_cast(OnWritePty)); + ApplyTheme(session, foreground, background, cursor, env, palette); + ghostty_terminal_resize(session->terminal, options.cols, options.rows, + static_cast(std::max(cell_width, 1)), + static_cast(std::max(cell_height, 1))); + return static_cast(reinterpret_cast(session)); +} + +extern "C" JNIEXPORT void JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeDestroy(JNIEnv*, jclass, jlong handle) { + FreeSession(FromHandle(handle)); +} + +extern "C" JNIEXPORT jbyteArray JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeFeed(JNIEnv* env, jclass, jlong handle, + jbyteArray data) { + auto* session = FromHandle(handle); + if (session == nullptr || data == nullptr) return env->NewByteArray(0); + std::lock_guard lock(session->mutex); + const auto length = env->GetArrayLength(data); + std::vector bytes(static_cast(length)); + if (length > 0) { + env->GetByteArrayRegion(data, 0, length, reinterpret_cast(bytes.data())); + ghostty_terminal_vt_write(session->terminal, bytes.data(), bytes.size()); + } + return ToJavaBytes(env, DrainResponses(session)); +} + +extern "C" JNIEXPORT jbyteArray JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeResize( + JNIEnv* env, jclass, jlong handle, jint cols, jint rows, jint cell_width, jint cell_height) { + auto* session = FromHandle(handle); + if (session == nullptr) return env->NewByteArray(0); + std::lock_guard lock(session->mutex); + ghostty_terminal_resize(session->terminal, + static_cast(std::clamp(cols, 1, 65535)), + static_cast(std::clamp(rows, 1, 65535)), + static_cast(std::max(cell_width, 1)), + static_cast(std::max(cell_height, 1))); + return ToJavaBytes(env, DrainResponses(session)); +} + +extern "C" JNIEXPORT void JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeScroll(JNIEnv*, jclass, jlong handle, + jint rows) { + auto* session = FromHandle(handle); + if (session == nullptr || rows == 0) return; + std::lock_guard lock(session->mutex); + GhosttyTerminalScrollViewport scroll = { + .tag = GHOSTTY_SCROLL_VIEWPORT_DELTA, + .value = {.delta = rows}, + }; + ghostty_terminal_scroll_viewport(session->terminal, scroll); +} + +extern "C" JNIEXPORT void JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeSetTheme( + JNIEnv* env, jclass, jlong handle, jint foreground, jint background, jint cursor, + jintArray palette) { + auto* session = FromHandle(handle); + if (session == nullptr) return; + std::lock_guard lock(session->mutex); + ApplyTheme(session, foreground, background, cursor, env, palette); +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeSelectWordAt(JNIEnv*, jclass, + jlong handle, jint x, + jint y) { + auto* session = FromHandle(handle); + if (session == nullptr) return JNI_FALSE; + std::lock_guard lock(session->mutex); + GhosttyTerminalSelectWordOptions options{}; + options.size = sizeof(options); + if (!ViewportGridRef(session, x, y, &options.ref)) return JNI_FALSE; + GhosttySelection selection{}; + selection.size = sizeof(selection); + if (ghostty_terminal_select_word(session->terminal, &options, &selection) != + GHOSTTY_SUCCESS) { + return JNI_FALSE; + } + return ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_SELECTION, + &selection) == GHOSTTY_SUCCESS + ? JNI_TRUE + : JNI_FALSE; +} + +extern "C" JNIEXPORT void JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeExtendSelection( + JNIEnv*, jclass, jlong handle, jint anchor_x, jint anchor_y, jint x, jint y) { + auto* session = FromHandle(handle); + if (session == nullptr) return; + std::lock_guard lock(session->mutex); + GhosttySelection selection{}; + selection.size = sizeof(selection); + if (!ViewportGridRef(session, anchor_x, anchor_y, &selection.start)) return; + if (!ViewportGridRef(session, x, y, &selection.end)) return; + ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_SELECTION, &selection); +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeSelectAll(JNIEnv*, jclass, + jlong handle) { + auto* session = FromHandle(handle); + if (session == nullptr) return JNI_FALSE; + std::lock_guard lock(session->mutex); + GhosttySelection selection{}; + selection.size = sizeof(selection); + if (ghostty_terminal_select_all(session->terminal, &selection) != GHOSTTY_SUCCESS) { + return JNI_FALSE; + } + return ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_SELECTION, + &selection) == GHOSTTY_SUCCESS + ? JNI_TRUE + : JNI_FALSE; +} + +extern "C" JNIEXPORT void JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeClearSelection(JNIEnv*, jclass, + jlong handle) { + auto* session = FromHandle(handle); + if (session == nullptr) return; + std::lock_guard lock(session->mutex); + ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_SELECTION, nullptr); +} + +// Returns the active selection as UTF-8 bytes (soft-wrapped lines unwrapped, +// trailing whitespace trimmed), or null when there is no selection. +extern "C" JNIEXPORT jbyteArray JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeGetSelectionText(JNIEnv* env, jclass, + jlong handle) { + auto* session = FromHandle(handle); + if (session == nullptr) return nullptr; + std::lock_guard lock(session->mutex); + GhosttyTerminalSelectionFormatOptions options{}; + options.size = sizeof(options); + options.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN; + options.unwrap = true; + options.trim = true; + uint8_t* bytes = nullptr; + size_t len = 0; + if (ghostty_terminal_selection_format_alloc(session->terminal, nullptr, options, + &bytes, &len) != GHOSTTY_SUCCESS || + bytes == nullptr) { + return nullptr; + } + auto result = env->NewByteArray(static_cast(len)); + if (result != nullptr && len > 0) { + env->SetByteArrayRegion(result, 0, static_cast(len), + reinterpret_cast(bytes)); + } + ghostty_free(nullptr, bytes, len); + return result; +} + +extern "C" JNIEXPORT jbyteArray JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeSnapshot(JNIEnv* env, jclass, + jlong handle) { + auto* session = FromHandle(handle); + if (session == nullptr) return env->NewByteArray(0); + std::lock_guard lock(session->mutex); + + if (ghostty_render_state_update(session->render_state, session->terminal) != GHOSTTY_SUCCESS) { + return env->NewByteArray(0); + } + + uint16_t cols = 0; + uint16_t rows = 0; + bool cursor_visible = false; + bool cursor_in_viewport = false; + bool cursor_blinking = false; + uint16_t cursor_x = 0xFFFF; + uint16_t cursor_y = 0xFFFF; + GhosttyRenderStateCursorVisualStyle cursor_style = + GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_BLOCK; + ghostty_render_state_get(session->render_state, GHOSTTY_RENDER_STATE_DATA_COLS, &cols); + ghostty_render_state_get(session->render_state, GHOSTTY_RENDER_STATE_DATA_ROWS, &rows); + ghostty_render_state_get(session->render_state, GHOSTTY_RENDER_STATE_DATA_CURSOR_VISIBLE, + &cursor_visible); + ghostty_render_state_get(session->render_state, + GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_HAS_VALUE, + &cursor_in_viewport); + ghostty_render_state_get(session->render_state, GHOSTTY_RENDER_STATE_DATA_CURSOR_BLINKING, + &cursor_blinking); + ghostty_render_state_get(session->render_state, + GHOSTTY_RENDER_STATE_DATA_CURSOR_VISUAL_STYLE, &cursor_style); + if (cursor_in_viewport) { + ghostty_render_state_get(session->render_state, + GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_X, &cursor_x); + ghostty_render_state_get(session->render_state, + GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_Y, &cursor_y); + } + + GhosttyRenderStateColors colors{}; + colors.size = sizeof(colors); + if (ghostty_render_state_colors_get(session->render_state, &colors) != GHOSTTY_SUCCESS) { + return env->NewByteArray(0); + } + const auto cursor_color = colors.cursor_has_value ? colors.cursor : colors.foreground; + + ByteWriter writer(32 + static_cast(cols) * rows * 14); + writer.U32(kSnapshotMagic); + writer.U16(kSnapshotVersion); + writer.U16(cols); + writer.U16(rows); + writer.U16(cursor_x); + writer.U16(cursor_y); + writer.U8(cursor_visible && cursor_in_viewport ? 1 : 0); + writer.U8(static_cast(cursor_style)); + writer.U8(cursor_blinking ? 1 : 0); + writer.U8(0); + writer.U32(ArgbFromRgb(colors.foreground)); + writer.U32(ArgbFromRgb(colors.background)); + writer.U32(ArgbFromRgb(cursor_color)); + + if (ghostty_render_state_get(session->render_state, + GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR, + &session->row_iterator) != GHOSTTY_SUCCESS) { + return env->NewByteArray(0); + } + + uint16_t written_rows = 0; + while (written_rows < rows && + ghostty_render_state_row_iterator_next(session->row_iterator)) { + if (ghostty_render_state_row_get(session->row_iterator, + GHOSTTY_RENDER_STATE_ROW_DATA_CELLS, + &session->row_cells) != GHOSTTY_SUCCESS) { + break; + } + + uint16_t written_cols = 0; + while (written_cols < cols && ghostty_render_state_row_cells_next(session->row_cells)) { + GhosttyStyle style{}; + style.size = sizeof(style); + bool selected = false; + GhosttyColorRgb foreground = colors.foreground; + GhosttyColorRgb background = colors.background; + ghostty_render_state_row_cells_get(session->row_cells, + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_STYLE, + &style); + ghostty_render_state_row_cells_get(session->row_cells, + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_SELECTED, + &selected); + ghostty_render_state_row_cells_get(session->row_cells, + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_FG_COLOR, + &foreground); + ghostty_render_state_row_cells_get(session->row_cells, + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_BG_COLOR, + &background); + if (style.inverse) std::swap(foreground, background); + if (style.faint) foreground = Blend(foreground, background, 155); + + uint32_t grapheme_count = 0; + ghostty_render_state_row_cells_get( + session->row_cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_LEN, + &grapheme_count); + std::vector utf8; + if (grapheme_count > 0) { + std::vector codepoints(grapheme_count); + if (ghostty_render_state_row_cells_get( + session->row_cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_BUF, + codepoints.data()) == GHOSTTY_SUCCESS) { + utf8.reserve(grapheme_count * 4); + for (const auto codepoint : codepoints) AppendUtf8(&utf8, codepoint); + } + } + + const auto text_length = static_cast(std::min(utf8.size(), 65535)); + writer.U32(ArgbFromRgb(foreground)); + writer.U32(ArgbFromRgb(background)); + writer.U16(StyleFlags(style, selected)); + writer.U16(text_length); + if (text_length != utf8.size()) utf8.resize(text_length); + writer.Bytes(utf8); + ++written_cols; + } + + while (written_cols++ < cols) { + writer.U32(ArgbFromRgb(colors.foreground)); + writer.U32(ArgbFromRgb(colors.background)); + writer.U16(0); + writer.U16(0); + } + ++written_rows; + } + + while (written_rows++ < rows) { + for (uint16_t column = 0; column < cols; ++column) { + writer.U32(ArgbFromRgb(colors.foreground)); + writer.U32(ArgbFromRgb(colors.background)); + writer.U16(0); + writer.U16(0); + } + } + + return ToJavaBytes(env, writer.Take()); +} diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/GhosttyBridge.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/GhosttyBridge.kt new file mode 100644 index 00000000000..06c13a2824a --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/GhosttyBridge.kt @@ -0,0 +1,64 @@ +package expo.modules.t3terminal + +internal object GhosttyBridge { + init { + System.loadLibrary("ghostty-vt") + System.loadLibrary("t3terminal") + } + + @JvmStatic + @Suppress("LongParameterList") + external fun nativeCreate( + cols: Int, + rows: Int, + cellWidth: Int, + cellHeight: Int, + foreground: Int, + background: Int, + cursor: Int, + palette: IntArray + ): Long + + @JvmStatic external fun nativeDestroy(handle: Long) + + @JvmStatic external fun nativeFeed(handle: Long, data: ByteArray): ByteArray + + @JvmStatic + external fun nativeResize( + handle: Long, + cols: Int, + rows: Int, + cellWidth: Int, + cellHeight: Int + ): ByteArray + + @JvmStatic external fun nativeScroll(handle: Long, rows: Int) + + @JvmStatic + external fun nativeSetTheme( + handle: Long, + foreground: Int, + background: Int, + cursor: Int, + palette: IntArray + ) + + @JvmStatic external fun nativeSnapshot(handle: Long): ByteArray + + @JvmStatic external fun nativeSelectWordAt(handle: Long, col: Int, row: Int): Boolean + + @JvmStatic + external fun nativeExtendSelection( + handle: Long, + anchorCol: Int, + anchorRow: Int, + col: Int, + row: Int + ) + + @JvmStatic external fun nativeSelectAll(handle: Long): Boolean + + @JvmStatic external fun nativeClearSelection(handle: Long) + + @JvmStatic external fun nativeGetSelectionText(handle: Long): ByteArray? +} diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt index 20e1bab41e5..1631c7fe68a 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt @@ -30,6 +30,10 @@ class T3TerminalModule : Module() { view.focusRequest = focusRequest } + Prop("autoFocus") { view: T3TerminalView, autoFocus: Boolean -> + view.autoFocus = autoFocus + } + Prop("appearanceScheme") { view: T3TerminalView, appearanceScheme: String -> view.appearanceScheme = appearanceScheme } @@ -51,6 +55,10 @@ class T3TerminalModule : Module() { } Events("onInput", "onResize") + + OnViewDestroys { view: T3TerminalView -> + view.cleanup() + } } } } diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt index cebe86272fd..88de793a8f7 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt @@ -3,65 +3,70 @@ package expo.modules.t3terminal import android.content.Context import android.graphics.Color import android.graphics.Typeface -import android.view.View +import android.text.Editable +import android.text.InputType +import android.text.TextWatcher +import android.view.KeyEvent import android.view.ViewGroup -import android.view.inputmethod.InputMethodManager import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputMethodManager import android.widget.EditText -import android.widget.LinearLayout -import android.widget.ScrollView -import android.widget.TextView -import androidx.core.widget.doAfterTextChanged +import android.widget.FrameLayout import expo.modules.kotlin.AppContext -import expo.modules.kotlin.views.ExpoView import expo.modules.kotlin.viewevent.EventDispatcher +import expo.modules.kotlin.views.ExpoView import kotlin.math.max -import kotlin.math.min class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(context, appContext) { - private val container = LinearLayout(context) - private val scrollView = ScrollView(context) - private val textView = TextView(context) + private val container = FrameLayout(context) + private val terminalCanvas = TerminalCanvasView(context) private val inputView = EditText(context) private val onInput by EventDispatcher() private val onResize by EventDispatcher() - private var lastWidth = 0 - private var lastHeight = 0 + private var terminalHandle = 0L + private var fedBuffer = "" + private var cols = 0 + private var rows = 0 private var clearingInput = false + private var isCleanedUp = false private var backgroundColorValue = Color.parseColor("#24292E") private var foregroundColorValue = Color.parseColor("#D1D5DA") private var mutedForegroundColorValue = Color.parseColor("#959DA5") + private var cursorColorValue = Color.parseColor("#009FFF") + private var paletteColors = IntArray(0) var terminalKey: String = "" set(value) { + if (field == value) return field = value contentDescription = "t3-terminal-$value" + recreateTerminal() } var initialBuffer: String = "" set(value) { + if (field == value) return field = value - textView.text = value.ifEmpty { "$ " } - scrollView.post { - scrollView.fullScroll(View.FOCUS_DOWN) - } + feedPendingBuffer() } var fontSize: Float = 10f set(value) { field = value - textView.textSize = value + terminalCanvas.fontSizeSp = value inputView.textSize = max(value, 13f) emitResize() } var appearanceScheme: String = "dark" + + var themeConfig: String = "" set(value) { field = value + parseThemeConfig(value) + applyTheme() } - var themeConfig: String = "" - var focusRequest: Double = 0.0 set(value) { val previous = field @@ -71,6 +76,17 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex } } + var autoFocus: Boolean = true + set(value) { + field = value + if (value) { + requestKeyboardFocus() + } else { + inputView.clearFocus() + hideKeyboard() + } + } + var backgroundColorHex: String = "#24292E" set(value) { field = value @@ -89,129 +105,327 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex set(value) { field = value mutedForegroundColorValue = parseColor(value, mutedForegroundColorValue) - applyTheme() } init { + terminalCanvas.fontSizeSp = fontSize + terminalCanvas.onRequestKeyboard = { requestKeyboardFocus() } + terminalCanvas.onScrollRows = { delta -> + if (terminalHandle != 0L) { + GhosttyBridge.nativeScroll(terminalHandle, delta) + renderSnapshot() + } + } + terminalCanvas.onCellMetricsChanged = { emitResize() } + terminalCanvas.selectionDelegate = object : TerminalSelectionDelegate { + override fun selectWordAt(col: Int, row: Int): Boolean { + if (terminalHandle == 0L) return false + val selected = GhosttyBridge.nativeSelectWordAt(terminalHandle, col, row) + if (selected) renderSnapshot() + return selected + } + + override fun extendSelection(anchorCol: Int, anchorRow: Int, col: Int, row: Int) { + if (terminalHandle == 0L) return + GhosttyBridge.nativeExtendSelection(terminalHandle, anchorCol, anchorRow, col, row) + renderSnapshot() + } + + override fun selectAll(): Boolean { + if (terminalHandle == 0L) return false + val selected = GhosttyBridge.nativeSelectAll(terminalHandle) + if (selected) renderSnapshot() + return selected + } + + override fun clearSelection() { + if (terminalHandle == 0L) return + GhosttyBridge.nativeClearSelection(terminalHandle) + renderSnapshot() + } + + override fun selectionText(): String? = + if (terminalHandle == 0L) { + null + } else { + GhosttyBridge.nativeGetSelectionText(terminalHandle)?.let { String(it, Charsets.UTF_8) } + } + } + + configureInputView() + container.addView( + terminalCanvas, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ), + ) + container.addView(inputView, FrameLayout.LayoutParams(1, 1)) + addView( + container, + LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT), + ) applyTheme() - container.orientation = LinearLayout.VERTICAL - textView.typeface = Typeface.MONOSPACE - textView.textSize = fontSize - textView.setPadding(8, 8, 8, 8) - textView.text = "$ " + } + + override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { + super.onSizeChanged(width, height, oldWidth, oldHeight) + if (width != oldWidth || height != oldHeight) emitResize() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + val childWidthSpec = MeasureSpec.makeMeasureSpec(measuredWidth, MeasureSpec.EXACTLY) + val childHeightSpec = MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.EXACTLY) + container.measure(childWidthSpec, childHeightSpec) + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + container.layout(0, 0, right - left, bottom - top) + if (changed) emitResize() + } + + fun cleanup() { + if (isCleanedUp) return + isCleanedUp = true + inputView.setOnEditorActionListener(null) + terminalCanvas.onScrollRows = null + terminalCanvas.onRequestKeyboard = null + terminalCanvas.onCellMetricsChanged = null + terminalCanvas.selectionDelegate = null + destroyTerminal() + } + private fun configureInputView() { inputView.setSingleLine(true) inputView.setTextColor(Color.TRANSPARENT) inputView.setHintTextColor(Color.TRANSPARENT) inputView.setBackgroundColor(Color.TRANSPARENT) inputView.typeface = Typeface.MONOSPACE inputView.textSize = max(fontSize, 13f) - inputView.hint = "" - inputView.alpha = 0.02f - inputView.imeOptions = EditorInfo.IME_ACTION_SEND + inputView.alpha = 0.01f + inputView.isFocusableInTouchMode = true + inputView.imeOptions = EditorInfo.IME_ACTION_SEND or + EditorInfo.IME_FLAG_NO_EXTRACT_UI or + EditorInfo.IME_FLAG_NO_FULLSCREEN or + EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING + inputView.inputType = InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD or + InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS inputView.setPadding(0, 0, 0, 0) - inputView.setOnFocusChangeListener { _, hasFocus -> - if (hasFocus) { - showKeyboard() + inputView.setOnEditorActionListener { _, actionId, event -> + val isKeyUp = event?.action == KeyEvent.ACTION_UP + val isImeSend = actionId == EditorInfo.IME_ACTION_SEND && !isKeyUp + val isHardwareEnter = event?.keyCode == KeyEvent.KEYCODE_ENTER && + event.action == KeyEvent.ACTION_DOWN + val isEnter = isImeSend || isHardwareEnter + if (isEnter) { + // Enter must send CR: raw-mode TUIs treat LF as Ctrl+J (insert newline). + onInput(mapOf("data" to "\r")) + true + } else { + false } } - inputView.setOnEditorActionListener { view, actionId, _ -> - if (actionId != EditorInfo.IME_ACTION_SEND) return@setOnEditorActionListener false - onInput(mapOf("data" to "\n")) - true - } inputView.setOnKeyListener { _, keyCode, event -> - if (event.action != android.view.KeyEvent.ACTION_DOWN) return@setOnKeyListener false + if (event.action != KeyEvent.ACTION_DOWN) return@setOnKeyListener false when { - keyCode == android.view.KeyEvent.KEYCODE_DEL -> { + keyCode == KeyEvent.KEYCODE_DEL -> { onInput(mapOf("data" to "\u007F")) true } // Hardware keyboard Ctrl+A..Z -> control bytes 0x01..0x1A (Ctrl+C, Ctrl+Z, ...). - event.isCtrlPressed && - keyCode in android.view.KeyEvent.KEYCODE_A..android.view.KeyEvent.KEYCODE_Z -> { + event.isCtrlPressed && keyCode in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z -> { onInput( - mapOf("data" to (keyCode - android.view.KeyEvent.KEYCODE_A + 1).toChar().toString()), + mapOf("data" to (keyCode - KeyEvent.KEYCODE_A + 1).toChar().toString()), ) true } else -> false } } - inputView.doAfterTextChanged { editable -> - if (clearingInput) return@doAfterTextChanged - val text = editable?.toString().orEmpty() - if (text.isEmpty()) return@doAfterTextChanged - onInput(mapOf("data" to text)) - clearingInput = true - inputView.text?.clear() - clearingInput = false - } - - textView.setOnClickListener { requestKeyboardFocus() } - scrollView.setOnClickListener { requestKeyboardFocus() } - container.setOnClickListener { requestKeyboardFocus() } - isClickable = true - setOnClickListener { requestKeyboardFocus() } - - scrollView.addView( - textView, - LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT), - ) - container.addView( - scrollView, - LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - 0, - 1f, - ), - ) - container.addView( - inputView, - LinearLayout.LayoutParams(1, 1), + inputView.addTextChangedListener( + object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + if (clearingInput || s == null || count <= 0) return + val end = (start + count).coerceAtMost(s.length) + if (start >= end) return + val insertedText = s.subSequence(start, end).toString() + if (insertedText.isNotEmpty()) { + onInput(mapOf("data" to insertedText)) + } + } + + override fun afterTextChanged(editable: Editable?) { + if (clearingInput || editable.isNullOrEmpty()) return + clearingInput = true + editable.clear() + clearingInput = false + } + }, ) - addView( - container, - LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT), + } + + @Suppress("ComplexCondition") + private fun emitResize() { + if ( + width <= 0 || + height <= 0 || + terminalCanvas.width <= 0 || + terminalCanvas.height <= 0 || + isCleanedUp + ) { + return + } + val nextCols = (terminalCanvas.usableWidth() / terminalCanvas.cellWidthPx) + .toInt() + .coerceIn(2, 400) + val nextRows = (terminalCanvas.usableHeight() / terminalCanvas.cellHeightPx) + .toInt() + .coerceIn(2, 200) + if (nextCols == cols && nextRows == rows && terminalHandle != 0L) return + cols = nextCols + rows = nextRows + val response = if (terminalHandle == 0L) { + createTerminal() + ByteArray(0) + } else { + GhosttyBridge.nativeResize( + terminalHandle, + cols, + rows, + terminalCanvas.cellWidthPx.toInt(), + terminalCanvas.cellHeightPx.toInt(), + ) + } + emitResponse(response) + onResize(mapOf("cols" to cols, "rows" to rows)) + feedPendingBuffer() + renderSnapshot() + } + + @Suppress("ComplexCondition") + private fun createTerminal() { + if (terminalHandle != 0L || cols <= 0 || rows <= 0 || isCleanedUp) return + terminalHandle = GhosttyBridge.nativeCreate( + cols, + rows, + terminalCanvas.cellWidthPx.toInt(), + terminalCanvas.cellHeightPx.toInt(), + foregroundColorValue, + backgroundColorValue, + cursorColorValue, + paletteColors, ) + fedBuffer = "" + } - post { - requestKeyboardFocus() + private fun recreateTerminal() { + if (terminalHandle == 0L) return + destroyTerminal() + createTerminal() + feedPendingBuffer() + renderSnapshot() + } + + private fun destroyTerminal() { + if (terminalHandle == 0L) return + GhosttyBridge.nativeDestroy(terminalHandle) + terminalHandle = 0L + fedBuffer = "" + terminalCanvas.resetSelectionState() + } + + private fun feedPendingBuffer() { + if (terminalHandle == 0L || initialBuffer == fedBuffer) return + if (!initialBuffer.startsWith(fedBuffer)) { + recreateTerminal() + if (terminalHandle == 0L) return } + val suffix = initialBuffer.substring(fedBuffer.length) + if (suffix.isNotEmpty()) { + emitResponse(GhosttyBridge.nativeFeed(terminalHandle, suffix.toByteArray(Charsets.UTF_8))) + // New output invalidates an active selection (matches the web drawer); + // otherwise the copy toolbar drifts out of sync with the grid. + if (terminalCanvas.hasActiveSelection()) { + GhosttyBridge.nativeClearSelection(terminalHandle) + terminalCanvas.resetSelectionState() + } + } + fedBuffer = initialBuffer + renderSnapshot() } - override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { - super.onSizeChanged(width, height, oldWidth, oldHeight) - if (width == lastWidth && height == lastHeight) return - lastWidth = width - lastHeight = height - emitResize() + private fun renderSnapshot() { + if (terminalHandle == 0L) return + TerminalFrame.decode( + GhosttyBridge.nativeSnapshot(terminalHandle) + )?.let(terminalCanvas::setFrame) } - private fun emitResize() { - if (width <= 0 || height <= 0) return - val density = resources.displayMetrics.scaledDensity - val fontPx = max(fontSize * density, 1f) - val cols = max(20, min(400, (width / (fontPx * 0.62f)).toInt())) - val terminalHeight = max(height - inputView.height, 0) - val rows = max(5, min(200, (terminalHeight / (fontPx * 1.35f)).toInt())) - onResize(mapOf("cols" to cols, "rows" to rows)) + private fun emitResponse(response: ByteArray) { + if (response.isNotEmpty()) { + onInput(mapOf("data" to String(response, Charsets.UTF_8))) + } } private fun requestKeyboardFocus() { inputView.requestFocus() - showKeyboard() + val inputMethodManager = context.getSystemService( + Context.INPUT_METHOD_SERVICE + ) as? InputMethodManager + inputMethodManager?.showSoftInput(inputView, InputMethodManager.SHOW_IMPLICIT) + } + + private fun hideKeyboard() { + val inputMethodManager = context.getSystemService( + Context.INPUT_METHOD_SERVICE + ) as? InputMethodManager + inputMethodManager?.hideSoftInputFromWindow(windowToken, 0) } private fun applyTheme() { setBackgroundColor(backgroundColorValue) container.setBackgroundColor(backgroundColorValue) - scrollView.setBackgroundColor(backgroundColorValue) - textView.setTextColor(foregroundColorValue) - textView.setBackgroundColor(backgroundColorValue) - inputView.setTextColor(Color.TRANSPARENT) - inputView.setHintTextColor(mutedForegroundColorValue) - inputView.setBackgroundColor(Color.TRANSPARENT) + terminalCanvas.setBackgroundColor(backgroundColorValue) + if (terminalHandle != 0L) { + GhosttyBridge.nativeSetTheme( + terminalHandle, + foregroundColorValue, + backgroundColorValue, + cursorColorValue, + paletteColors, + ) + renderSnapshot() + } + } + + @Suppress("LoopWithTooManyJumpStatements") + private fun parseThemeConfig(config: String) { + val palette = sortedMapOf() + for (line in config.lineSequence()) { + val parts = line.split('=', limit = 2) + if (parts.size != 2) continue + val key = parts[0].trim() + val value = parts[1].trim() + when (key) { + "cursor-color" -> cursorColorValue = parseColor(value, cursorColorValue) + "palette" -> { + val paletteParts = value.split('=', limit = 2) + val index = paletteParts.firstOrNull()?.trim()?.toIntOrNull() ?: continue + val color = paletteParts.getOrNull(1)?.trim() ?: continue + if (index in 0..255) palette[index] = parseColor(color, foregroundColorValue) + } + } + } + if (palette.isNotEmpty()) { + val lastIndex = palette.lastKey() + paletteColors = IntArray(lastIndex + 1) { index -> + palette[index] ?: foregroundColorValue + } + } } private fun parseColor(value: String, fallback: Int): Int = @@ -220,9 +434,4 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex } catch (_: IllegalArgumentException) { fallback } - - private fun showKeyboard() { - val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager - imm?.showSoftInput(inputView, InputMethodManager.SHOW_IMPLICIT) - } } diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalCanvasView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalCanvasView.kt new file mode 100644 index 00000000000..f713bdb4ff0 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalCanvasView.kt @@ -0,0 +1,628 @@ +package expo.modules.t3terminal + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.Typeface +import android.util.Log +import android.view.ActionMode +import android.view.GestureDetector +import android.view.HapticFeedbackConstants +import android.view.Menu +import android.view.MenuItem +import android.view.MotionEvent +import android.view.View +import android.widget.OverScroller +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.min + +/** + * Bundled terminal font with Nerd Font glyphs (powerline, file icons). + * MesloLGS NF is the powerlevel10k-tuned Meslo Nerd Font patch. + */ +internal object TerminalTypefaces { + private var loaded = false + var regular: Typeface = Typeface.MONOSPACE + private set + var bold: Typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD) + private set + + @Suppress("TooGenericExceptionCaught") // Typeface.createFromAsset exposes RuntimeException. + fun ensureLoaded(context: Context) { + if (loaded) return + loaded = true + try { + regular = Typeface.createFromAsset(context.assets, "fonts/MesloLGS-NF-Regular.ttf") + bold = Typeface.createFromAsset(context.assets, "fonts/MesloLGS-NF-Bold.ttf") + } catch (error: RuntimeException) { + Log.w("TerminalCanvasView", "bundled terminal font unavailable, using monospace", error) + } + } +} + +/** + * Selection operations backed by the native terminal. The terminal owns the + * selection state; the canvas only drives gestures and renders the result. + */ +internal interface TerminalSelectionDelegate { + fun selectWordAt(col: Int, row: Int): Boolean + + fun extendSelection(anchorCol: Int, anchorRow: Int, col: Int, row: Int) + + fun selectAll(): Boolean + + fun clearSelection() + + fun selectionText(): String? +} + +internal class TerminalCanvasView(context: Context) : View(context) { + companion object { + const val FLAG_BOLD = 1 shl 0 + const val FLAG_ITALIC = 1 shl 1 + const val FLAG_INVISIBLE = 1 shl 4 + const val FLAG_STRIKETHROUGH = 1 shl 5 + const val FLAG_OVERLINE = 1 shl 6 + const val FLAG_UNDERLINE = 1 shl 7 + const val FLAG_SELECTED = 1 shl 8 + + private const val MENU_COPY = 1 + private const val MENU_SELECT_ALL = 2 + private const val HANDLE_COLOR = 0xFF7AA2F7.toInt() + } + + private val density = resources.displayMetrics.density + private val scaledDensity = density * resources.configuration.fontScale + private val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG) + + init { + TerminalTypefaces.ensureLoaded(context) + } + + private val regularTypeface = TerminalTypefaces.regular + private val boldTypeface = TerminalTypefaces.bold + private val italicTypeface = Typeface.create(TerminalTypefaces.regular, Typeface.ITALIC) + private val boldItalicTypeface = Typeface.create(TerminalTypefaces.bold, Typeface.ITALIC) + private val gestureDetector = GestureDetector(context, TerminalGestureListener()) + private val contentPadding = 8f * density + private var frame: TerminalFrame? = null + private var scrollRemainder = 0f + private val scroller = OverScroller(context) + private var flingLastY = 0 + private val flingRunnable = object : Runnable { + override fun run() { + if (!scroller.computeScrollOffset()) return + val currentY = scroller.currY + val deltaPx = (currentY - flingLastY).toFloat() + flingLastY = currentY + scrollRemainder += -deltaPx / cellHeightPx + val rows = scrollRemainder.toInt() + if (rows != 0) { + scrollRemainder -= rows + onScrollRows?.invoke(rows) + } + postOnAnimation(this) + } + } + private var cursorOn = true + private val cursorBlink = object : Runnable { + override fun run() { + val currentFrame = frame ?: return + if (!currentFrame.cursorBlinking || !currentFrame.cursorVisible) return + cursorOn = !cursorOn + invalidate() + postDelayed(this, 500) + } + } + + var onScrollRows: ((Int) -> Unit)? = null + var onRequestKeyboard: (() -> Unit)? = null + var onCellMetricsChanged: (() -> Unit)? = null + var selectionDelegate: TerminalSelectionDelegate? = null + + private val handlePaint = Paint(Paint.ANTI_ALIAS_FLAG) + private var selectionActive = false + private var dragSelecting = false + private var draggingHandle = false + private var anchorCol = 0 + private var anchorRow = 0 + private var extentCol = 0 + private var extentRow = 0 + + // Word-snapped span from the initial long-press; extending anchors to the + // far word edge so the word never shrinks mid-drag. + private var wordStartCol = 0 + private var wordStartRow = 0 + private var wordEndCol = 0 + private var wordEndRow = 0 + private var actionMode: ActionMode? = null + + // Actual selection endpoints in viewport cells, derived from the decoded + // frame (word-snap can extend past the pressed cell). Drive handle + // placement and hit testing. + private var selectionEndpointsValid = false + private var selectionStartCol = 0 + private var selectionStartRow = 0 + private var selectionEndCol = 0 + private var selectionEndRow = 0 + + var fontSizeSp: Float = 10f + set(value) { + if (field == value) return + field = value + updateCellMetrics() + } + + var cellWidthPx: Float = 1f + private set + var cellHeightPx: Float = 1f + private set + private var baselineOffsetPx: Float = 1f + + init { + isClickable = true + isFocusable = true + isFocusableInTouchMode = true + paint.typeface = regularTypeface + updateCellMetrics() + } + + fun setFrame(value: TerminalFrame) { + frame = value + cursorOn = true + updateSelectionEndpoints() + removeCallbacks(cursorBlink) + if (value.cursorBlinking && value.cursorVisible) postDelayed(cursorBlink, 500) + invalidate() + } + + fun resetSelectionState() { + selectionActive = false + dragSelecting = false + draggingHandle = false + selectionEndpointsValid = false + actionMode?.finish() + } + + fun hasActiveSelection(): Boolean = selectionActive + + fun usableWidth(): Float = max(width - contentPadding * 2f, 1f) + fun usableHeight(): Float = max(height - contentPadding * 2f, 1f) + + @Suppress("NestedBlockDepth", "ComplexCondition") + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val currentFrame = frame + if (currentFrame == null) { + canvas.drawColor(Color.TRANSPARENT) + return + } + canvas.drawColor(currentFrame.background) + canvas.save() + canvas.clipRect( + contentPadding, + contentPadding, + width - contentPadding, + height - contentPadding, + ) + + for (row in 0 until currentFrame.rows) { + val top = contentPadding + row * cellHeightPx + val bottom = top + cellHeightPx + for (column in 0 until currentFrame.cols) { + val index = row * currentFrame.cols + column + val left = contentPadding + column * cellWidthPx + val right = left + cellWidthPx + val background = currentFrame.cellBackgrounds[index] + val flags = currentFrame.cellFlags[index] + paint.style = Paint.Style.FILL + paint.color = if (flags and FLAG_SELECTED != 0) { + blend(currentFrame.cursorColor, background, 0.32f) + } else { + background + } + if (paint.color != currentFrame.background || flags and FLAG_SELECTED != 0) { + canvas.drawRect(left, top, right + 0.5f, bottom + 0.5f, paint) + } + + val text = currentFrame.cellText[index] + if (text.isNotEmpty() && flags and FLAG_INVISIBLE == 0) { + configureTextPaint(flags, currentFrame.cellForegrounds[index]) + canvas.drawText(text, left, top + baselineOffsetPx, paint) + if (flags and FLAG_OVERLINE != 0) { + canvas.drawRect(left, top + 1f, right, top + max(2f, density), paint) + } + } + } + } + + if (currentFrame.cursorVisible && cursorOn && + currentFrame.cursorX in 0 until currentFrame.cols && + currentFrame.cursorY in 0 until currentFrame.rows + ) { + drawCursor(canvas, currentFrame) + } + canvas.restore() + drawSelectionHandles(canvas) + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + parent?.requestDisallowInterceptTouchEvent(true) + // Touch-down always stops momentum, even when the event is consumed by + // a selection-handle grab and never reaches the gesture detector. + scroller.forceFinished(true) + removeCallbacks(flingRunnable) + } else if (event.actionMasked == MotionEvent.ACTION_UP || + event.actionMasked == MotionEvent.ACTION_CANCEL + ) { + parent?.requestDisallowInterceptTouchEvent(false) + } + return when { + dragSelecting -> { + when (event.actionMasked) { + MotionEvent.ACTION_MOVE -> extendSelectionTo(event.x, event.y) + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + dragSelecting = false + showSelectionActions() + } + } + true + } + event.actionMasked == MotionEvent.ACTION_DOWN && grabHandleAt(event.x, event.y) -> true + else -> gestureDetector.onTouchEvent(event) || super.onTouchEvent(event) + } + } + + override fun onDetachedFromWindow() { + removeCallbacks(cursorBlink) + removeCallbacks(flingRunnable) + actionMode?.finish() + super.onDetachedFromWindow() + } + + private fun updateCellMetrics() { + paint.textSize = fontSizeSp * scaledDensity + paint.typeface = regularTypeface + cellWidthPx = ceil(paint.measureText("M").toDouble()).toFloat().coerceAtLeast(1f) + val metrics = paint.fontMetrics + val glyphHeight = metrics.descent - metrics.ascent + cellHeightPx = ceil((glyphHeight * 1.12f).toDouble()).toFloat().coerceAtLeast(1f) + baselineOffsetPx = (cellHeightPx - glyphHeight) / 2f - metrics.ascent + onCellMetricsChanged?.invoke() + invalidate() + } + + private fun configureTextPaint(flags: Int, color: Int) { + val bold = flags and FLAG_BOLD != 0 + val italic = flags and FLAG_ITALIC != 0 + paint.typeface = when { + bold && italic -> boldItalicTypeface + bold -> boldTypeface + italic -> italicTypeface + else -> regularTypeface + } + paint.textSize = fontSizeSp * scaledDensity + paint.color = color + paint.style = Paint.Style.FILL + paint.isUnderlineText = flags and FLAG_UNDERLINE != 0 + paint.isStrikeThruText = flags and FLAG_STRIKETHROUGH != 0 + } + + private fun drawCursor(canvas: Canvas, currentFrame: TerminalFrame) { + val left = contentPadding + currentFrame.cursorX * cellWidthPx + val top = contentPadding + currentFrame.cursorY * cellHeightPx + val right = left + cellWidthPx + val bottom = top + cellHeightPx + paint.color = currentFrame.cursorColor + paint.isUnderlineText = false + paint.isStrikeThruText = false + when (currentFrame.cursorStyle) { + 0 -> canvas.drawRect(left, top, left + max(2f * density, 2f), bottom, paint) + 2 -> canvas.drawRect(left, bottom - max(2f * density, 2f), right, bottom, paint) + 3 -> { + paint.style = Paint.Style.STROKE + paint.strokeWidth = max(density, 1f) + canvas.drawRect(left, top, right, bottom, paint) + } + else -> { + paint.style = Paint.Style.FILL + canvas.drawRect(left, top, right, bottom, paint) + val index = currentFrame.cursorY * currentFrame.cols + currentFrame.cursorX + val text = currentFrame.cellText[index] + if (text.isNotEmpty()) { + configureTextPaint(currentFrame.cellFlags[index], currentFrame.background) + canvas.drawText(text, left, top + baselineOffsetPx, paint) + } + } + } + } + + private fun columnAt(px: Float): Int { + val cols = frame?.cols ?: return 0 + return ((px - contentPadding) / cellWidthPx).toInt().coerceIn(0, max(cols - 1, 0)) + } + + private fun rowAt(py: Float): Int { + val rows = frame?.rows ?: return 0 + return ((py - contentPadding) / cellHeightPx).toInt().coerceIn(0, max(rows - 1, 0)) + } + + private fun startWordSelection(px: Float, py: Float) { + val delegate = selectionDelegate ?: return + val col = columnAt(px) + val row = rowAt(py) + // Set before selectWordAt: the delegate re-renders synchronously and + // updateSelectionEndpoints only scans while a selection is active. + selectionActive = true + if (!delegate.selectWordAt(col, row)) { + selectionActive = false + return + } + dragSelecting = true + draggingHandle = false + if (selectionEndpointsValid) { + wordStartCol = selectionStartCol + wordStartRow = selectionStartRow + wordEndCol = selectionEndCol + wordEndRow = selectionEndRow + } else { + wordStartCol = col + wordStartRow = row + wordEndCol = col + wordEndRow = row + } + anchorCol = wordStartCol + anchorRow = wordStartRow + extentCol = col + extentRow = row + performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + } + + private fun extendSelectionTo(px: Float, py: Float) { + if (!selectionActive) return + val col = columnAt(px) + val row = rowAt(py) + if (col == extentCol && row == extentRow) return + extentCol = col + extentRow = row + if (!draggingHandle) { + val beforeWord = row < wordStartRow || (row == wordStartRow && col < wordStartCol) + if (beforeWord) { + anchorCol = wordEndCol + anchorRow = wordEndRow + } else { + anchorCol = wordStartCol + anchorRow = wordStartRow + } + } + selectionDelegate?.extendSelection(anchorCol, anchorRow, col, row) + } + + private fun clearSelection() { + if (!selectionActive) return + selectionActive = false + dragSelecting = false + draggingHandle = false + selectionEndpointsValid = false + actionMode?.finish() + selectionDelegate?.clearSelection() + } + + private fun copySelection() { + val text = selectionDelegate?.selectionText() ?: return + if (text.isEmpty()) return + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + clipboard?.setPrimaryClip(ClipData.newPlainText("Terminal", text)) + } + + private fun handleCenterX(col: Int, leadingEdge: Boolean): Float = + contentPadding + (col + if (leadingEdge) 0 else 1) * cellWidthPx + + private fun handleCenterY(row: Int): Float = + contentPadding + (row + 1) * cellHeightPx + handleRadius() + + private fun handleRadius(): Float = max(cellHeightPx * 0.45f, 12f) + + /** + * Begin dragging when the touch lands on a selection handle. The opposite + * endpoint becomes the drag anchor so the grabbed end follows the finger. + */ + private fun grabHandleAt(px: Float, py: Float): Boolean { + if (!selectionActive || !selectionEndpointsValid) return false + val slop = max(handleRadius() * 2f, 24 * density) + + fun near(cx: Float, cy: Float): Boolean { + val dx = px - cx + val dy = py - cy + return dx * dx + dy * dy <= slop * slop + } + + val startGrabbed = + near(handleCenterX(selectionStartCol, true), handleCenterY(selectionStartRow)) + val endGrabbed = + !startGrabbed && near(handleCenterX(selectionEndCol, false), handleCenterY(selectionEndRow)) + val handleGrabbed = startGrabbed || endGrabbed + if (handleGrabbed) { + if (startGrabbed) { + anchorCol = selectionEndCol + anchorRow = selectionEndRow + extentCol = selectionStartCol + extentRow = selectionStartRow + } else { + anchorCol = selectionStartCol + anchorRow = selectionStartRow + extentCol = selectionEndCol + extentRow = selectionEndRow + } + dragSelecting = true + draggingHandle = true + actionMode?.finish() + } + return handleGrabbed + } + + /** Scan the decoded frame for the first/last selected cells. */ + private fun updateSelectionEndpoints() { + selectionEndpointsValid = false + val currentFrame = frame + if (!selectionActive || currentFrame == null) return + val totalCells = currentFrame.cols * currentFrame.rows + var first = -1 + var last = -1 + for (index in 0 until totalCells) { + if (currentFrame.cellFlags[index] and FLAG_SELECTED != 0) { + if (first < 0) first = index + last = index + } + } + if (first >= 0 && currentFrame.cols > 0) { + selectionStartCol = first % currentFrame.cols + selectionStartRow = first / currentFrame.cols + selectionEndCol = last % currentFrame.cols + selectionEndRow = last / currentFrame.cols + selectionEndpointsValid = true + } + } + + // Anchor the toolbar to the actual word-snapped endpoints when known; + // gesture cells can lag behind what the terminal selected. + private fun selectionBounds(): Rect { + val startCol = if (selectionEndpointsValid) selectionStartCol else min(anchorCol, extentCol) + val endCol = if (selectionEndpointsValid) selectionEndCol else max(anchorCol, extentCol) + val startRow = if (selectionEndpointsValid) selectionStartRow else min(anchorRow, extentRow) + val endRow = if (selectionEndpointsValid) selectionEndRow else max(anchorRow, extentRow) + val left = contentPadding + min(startCol, endCol) * cellWidthPx + val right = contentPadding + (max(startCol, endCol) + 1) * cellWidthPx + val top = contentPadding + startRow * cellHeightPx + val bottom = contentPadding + (endRow + 1) * cellHeightPx + return Rect(left.toInt(), top.toInt(), right.toInt(), bottom.toInt()) + } + + private fun showSelectionActions() { + if (actionMode != null || !selectionActive) return + actionMode = startActionMode( + object : ActionMode.Callback2() { + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + menu.add(Menu.NONE, MENU_COPY, 0, android.R.string.copy) + menu.add(Menu.NONE, MENU_SELECT_ALL, 1, android.R.string.selectAll) + return true + } + + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean = false + + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean = + when (item.itemId) { + MENU_COPY -> { + copySelection() + clearSelection() + true + } + MENU_SELECT_ALL -> { + val currentFrame = frame + if (currentFrame != null && selectionDelegate?.selectAll() == true) { + anchorCol = 0 + anchorRow = 0 + extentCol = max(currentFrame.cols - 1, 0) + extentRow = max(currentFrame.rows - 1, 0) + } + true + } + else -> false + } + + override fun onDestroyActionMode(mode: ActionMode) { + actionMode = null + // Dismissing the toolbar (e.g. Back) drops the selection too — + // except mid-drag, where grabHandleAt finishes the mode on purpose. + if (selectionActive && !dragSelecting) clearSelection() + } + + override fun onGetContentRect(mode: ActionMode, view: View, outRect: Rect) { + outRect.set(selectionBounds()) + } + }, + ActionMode.TYPE_FLOATING, + ) + } + + private fun drawSelectionHandles(canvas: Canvas) { + if (!selectionActive || !selectionEndpointsValid) return + val radius = handleRadius() + handlePaint.color = HANDLE_COLOR + val stemWidth = max(radius / 4f, 2f) + + fun drawHandle(cx: Float, row: Int) { + val cornerY = contentPadding + (row + 1) * cellHeightPx + val cy = handleCenterY(row) + canvas.drawRect(cx - stemWidth / 2f, cornerY, cx + stemWidth / 2f, cy, handlePaint) + canvas.drawCircle(cx, cy, radius, handlePaint) + } + + drawHandle(handleCenterX(selectionStartCol, true), selectionStartRow) + drawHandle(handleCenterX(selectionEndCol, false), selectionEndRow) + } + + private fun blend(foreground: Int, background: Int, amount: Float): Int { + val inverseAmount = 1f - amount + return Color.rgb( + (Color.red(foreground) * amount + Color.red(background) * inverseAmount).toInt(), + (Color.green(foreground) * amount + Color.green(background) * inverseAmount).toInt(), + (Color.blue(foreground) * amount + Color.blue(background) * inverseAmount).toInt(), + ) + } + + private inner class TerminalGestureListener : GestureDetector.SimpleOnGestureListener() { + override fun onDown(event: MotionEvent): Boolean { + scroller.forceFinished(true) + removeCallbacks(flingRunnable) + onRequestKeyboard?.invoke() + return true + } + + override fun onSingleTapUp(event: MotionEvent): Boolean { + if (selectionActive) { + clearSelection() + } else { + performClick() + } + return true + } + + override fun onLongPress(event: MotionEvent) { + startWordSelection(event.x, event.y) + } + + override fun onScroll( + first: MotionEvent?, + current: MotionEvent, + distanceX: Float, + distanceY: Float + ): Boolean { + scrollRemainder += distanceY / cellHeightPx + val rows = scrollRemainder.toInt() + if (rows != 0) { + scrollRemainder -= rows + onScrollRows?.invoke(rows) + } + return true + } + + override fun onFling( + first: MotionEvent?, + current: MotionEvent, + velocityX: Float, + velocityY: Float + ): Boolean { + flingLastY = 0 + scroller.fling(0, 0, 0, velocityY.toInt(), 0, 0, Int.MIN_VALUE / 2, Int.MAX_VALUE / 2) + postOnAnimation(flingRunnable) + return true + } + } +} diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalFrame.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalFrame.kt new file mode 100644 index 00000000000..2234d628e03 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalFrame.kt @@ -0,0 +1,82 @@ +package expo.modules.t3terminal + +import java.nio.ByteBuffer +import java.nio.ByteOrder + +internal data class TerminalFrame( + val cols: Int, + val rows: Int, + val cursorX: Int, + val cursorY: Int, + val cursorVisible: Boolean, + val cursorStyle: Int, + val cursorBlinking: Boolean, + val foreground: Int, + val background: Int, + val cursorColor: Int, + val cellForegrounds: IntArray, + val cellBackgrounds: IntArray, + val cellFlags: IntArray, + val cellText: Array +) { + companion object { + private const val MAGIC = 0x54563354 + private const val VERSION = 1 + private const val HEADER_BYTES = 32 + private const val CELL_HEADER_BYTES = 12 + + @Suppress("ReturnCount") + fun decode(bytes: ByteArray): TerminalFrame? { + if (bytes.size < HEADER_BYTES) return null + val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + if (buffer.int != MAGIC || buffer.short.toInt() != VERSION) return null + + val cols = buffer.short.toInt() and 0xFFFF + val rows = buffer.short.toInt() and 0xFFFF + val cursorX = buffer.short.toInt() and 0xFFFF + val cursorY = buffer.short.toInt() and 0xFFFF + val cursorVisible = buffer.get().toInt() != 0 + val cursorStyle = buffer.get().toInt() and 0xFF + val cursorBlinking = buffer.get().toInt() != 0 + buffer.get() + val foreground = buffer.int + val background = buffer.int + val cursorColor = buffer.int + val cellCount = cols * rows + val foregrounds = IntArray(cellCount) + val backgrounds = IntArray(cellCount) + val flags = IntArray(cellCount) + val text = Array(cellCount) { "" } + + for (index in 0 until cellCount) { + if (buffer.remaining() < CELL_HEADER_BYTES) return null + foregrounds[index] = buffer.int + backgrounds[index] = buffer.int + flags[index] = buffer.short.toInt() and 0xFFFF + val textLength = buffer.short.toInt() and 0xFFFF + if (buffer.remaining() < textLength) return null + if (textLength > 0) { + text[index] = String(bytes, buffer.position(), textLength, Charsets.UTF_8) + buffer.position(buffer.position() + textLength) + } + } + + return TerminalFrame( + cols = cols, + rows = rows, + cursorX = cursorX, + cursorY = cursorY, + cursorVisible = cursorVisible, + cursorStyle = cursorStyle, + cursorBlinking = cursorBlinking, + foreground = foreground, + background = background, + cursorColor = cursorColor, + cellForegrounds = foregrounds, + cellBackgrounds = backgrounds, + cellFlags = flags, + cellText = text, + ) + } + } +} diff --git a/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/arm64-v8a/libghostty-vt.so b/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/arm64-v8a/libghostty-vt.so new file mode 100755 index 00000000000..e086ca83155 Binary files /dev/null and b/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/arm64-v8a/libghostty-vt.so differ diff --git a/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/armeabi-v7a/libghostty-vt.so b/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/armeabi-v7a/libghostty-vt.so new file mode 100755 index 00000000000..5592142ffce Binary files /dev/null and b/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/armeabi-v7a/libghostty-vt.so differ diff --git a/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/x86/libghostty-vt.so b/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/x86/libghostty-vt.so new file mode 100755 index 00000000000..bacab790833 Binary files /dev/null and b/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/x86/libghostty-vt.so differ diff --git a/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/x86_64/libghostty-vt.so b/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/x86_64/libghostty-vt.so new file mode 100755 index 00000000000..f6e0b646a2f Binary files /dev/null and b/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/x86_64/libghostty-vt.so differ diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift index 39e1874d5d7..f68cc6b4a11 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift @@ -7,7 +7,7 @@ public class T3TerminalModule: Module { // Bumped when native hardware-keyboard handling changes; surfaced in the JS debug // logs so a stale native binary is distinguishable from a broken key pipeline. Constants([ - "hardwareKeyRevision": 2, + "hardwareKeyRevision": 3, ]) View(T3TerminalView.self) { @@ -27,6 +27,10 @@ public class T3TerminalModule: Module { view.focusRequest = focusRequest } + Prop("autoFocus") { (view: T3TerminalView, autoFocus: Bool) in + view.autoFocus = autoFocus + } + Prop("appearanceScheme") { (view: T3TerminalView, appearanceScheme: String) in view.appearanceScheme = appearanceScheme } diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index 14cdb4b7802..659837fc7ab 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -119,6 +119,21 @@ private enum TerminalHardwareKeyEncoder { } } +private enum TerminalInputSequence { + /// Terminal Enter is carriage return. Sending line feed instead is Ctrl+J, + /// which raw-mode TUIs may interpret as the literal J key. + static let carriageReturn = "\r" + + static func normalizingReturn(_ input: String) -> String { + switch input { + case "\n", "\r\n": + return carriageReturn + default: + return input + } + } +} + private final class TerminalInputField: UITextField { var onDeleteBackward: (() -> Void)? var onInsert: ((String) -> Void)? @@ -233,6 +248,17 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } } + var autoFocus = true { + didSet { + guard oldValue != autoFocus else { return } + if autoFocus { + requestKeyboardFocus() + } else { + inputField.resignFirstResponder() + } + } + } + var appearanceScheme: String = TerminalAppearanceScheme.dark.rawValue { didSet { guard oldValue != appearanceScheme else { return } @@ -344,7 +370,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { public override func didMoveToWindow() { super.didMoveToWindow() - guard window != nil else { return } + guard window != nil, autoFocus else { return } DispatchQueue.main.async { [weak self] in self?.requestKeyboardFocus() } @@ -352,7 +378,9 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { if !string.isEmpty { - emitInput(string) + // Some software keyboards deliver Return through this delegate instead of + // textFieldShouldReturn, so normalize that path too. + emitInput(TerminalInputSequence.normalizingReturn(string)) return false } @@ -360,7 +388,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } public func textFieldShouldReturn(_ textField: UITextField) -> Bool { - emitInput("\n") + emitInput(TerminalInputSequence.carriageReturn) textField.text = "" return false } diff --git a/apps/mobile/modules/t3-terminal/scripts/build-libghostty-android.sh b/apps/mobile/modules/t3-terminal/scripts/build-libghostty-android.sh new file mode 100755 index 00000000000..7b9aa9b6dc6 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/scripts/build-libghostty-android.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MODULE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +VENDOR_DIR="${MODULE_DIR}/Vendor/libghostty-vt" +PATCH_DIR="${SCRIPT_DIR}/libghostty-android-patches" + +GHOSTTY_REVISION="${GHOSTTY_REVISION:-9f62873bf195e4d8a762d768a1405a5f2f7b1697}" +GHOSTTY_SOURCE_DIR="${GHOSTTY_SOURCE_DIR:-${HOME}/.cache/t3code/ghostty-${GHOSTTY_REVISION:0:8}}" +GHOSTTY_ZIG_VERSION="${GHOSTTY_ZIG_VERSION:-0.15.2}" +GHOSTTY_ZIG="${GHOSTTY_ZIG:-}" +ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-}" + +log() { + printf '[libghostty-vt-android] %s\n' "$*" +} + +die() { + printf '[libghostty-vt-android] error: %s\n' "$*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "missing required command: $1" +} + +ensure_zig() { + if [[ -n "${GHOSTTY_ZIG}" ]]; then + [[ -x "${GHOSTTY_ZIG}" ]] || die "GHOSTTY_ZIG is not executable: ${GHOSTTY_ZIG}" + return + fi + if command -v zig >/dev/null 2>&1 && [[ "$(zig version)" == "${GHOSTTY_ZIG_VERSION}" ]]; then + GHOSTTY_ZIG="$(command -v zig)" + return + fi + + local host_os host_arch cache_dir + host_os="$(uname -s | tr '[:upper:]' '[:lower:]')" + host_arch="$(uname -m)" + case "${host_os}" in + darwin) host_os="macos" ;; + linux) ;; + *) die "unsupported host OS for Zig download: ${host_os}" ;; + esac + case "${host_arch}" in + arm64) host_arch="aarch64" ;; + aarch64 | x86_64) ;; + *) die "unsupported host architecture for Zig download: ${host_arch}" ;; + esac + + cache_dir="${HOME}/.cache/t3code/zig-${GHOSTTY_ZIG_VERSION}" + GHOSTTY_ZIG="${cache_dir}/zig" + if [[ -x "${GHOSTTY_ZIG}" ]]; then + return + fi + + require_cmd curl + require_cmd tar + mkdir -p "${cache_dir}" + log "downloading Zig ${GHOSTTY_ZIG_VERSION}" + curl -fsSL \ + "https://ziglang.org/download/${GHOSTTY_ZIG_VERSION}/zig-${host_arch}-${host_os}-${GHOSTTY_ZIG_VERSION}.tar.xz" \ + | tar -xJ --strip-components=1 -C "${cache_dir}" +} + +ensure_ghostty_source() { + if [[ ! -d "${GHOSTTY_SOURCE_DIR}/.git" ]]; then + require_cmd git + log "cloning Ghostty ${GHOSTTY_REVISION}" + git clone --filter=blob:none --no-checkout https://github.com/ghostty-org/ghostty.git \ + "${GHOSTTY_SOURCE_DIR}" + git -C "${GHOSTTY_SOURCE_DIR}" fetch --depth=1 origin "${GHOSTTY_REVISION}" + git -C "${GHOSTTY_SOURCE_DIR}" checkout --detach "${GHOSTTY_REVISION}" + fi + + local actual_revision + actual_revision="$(git -C "${GHOSTTY_SOURCE_DIR}" rev-parse HEAD)" + [[ "${actual_revision}" == "${GHOSTTY_REVISION}" ]] || \ + die "expected Ghostty ${GHOSTTY_REVISION}, found ${actual_revision}" +} + +apply_ghostty_patches() { + [[ -d "${PATCH_DIR}" ]] || return + + local patch_file patch_name + for patch_file in "${PATCH_DIR}"/*.patch; do + [[ -e "${patch_file}" ]] || continue + patch_name="$(basename "${patch_file}")" + if git -C "${GHOSTTY_SOURCE_DIR}" apply --reverse --check "${patch_file}" >/dev/null 2>&1; then + log "patch already applied: ${patch_name}" + continue + fi + log "applying patch: ${patch_name}" + git -C "${GHOSTTY_SOURCE_DIR}" apply --check "${patch_file}" + git -C "${GHOSTTY_SOURCE_DIR}" apply "${patch_file}" + done +} + +if [[ -z "${ANDROID_NDK_HOME}" ]]; then + die "ANDROID_NDK_HOME must point to an installed Android NDK" +fi +[[ -d "${ANDROID_NDK_HOME}" ]] || die "Android NDK not found: ${ANDROID_NDK_HOME}" + +ensure_zig +ensure_ghostty_source +apply_ghostty_patches + +strip_tool="${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt" +strip_tool="$(find "${strip_tool}" -path '*/bin/llvm-strip' -print -quit)" +[[ -x "${strip_tool}" ]] || die "llvm-strip not found under ${ANDROID_NDK_HOME}" + +targets=( + "arm64-v8a:aarch64-linux-android" + "armeabi-v7a:arm-linux-androideabi" + "x86:x86-linux-android" + "x86_64:x86_64-linux-android" +) + +build_root="$(mktemp -d)" +trap 'rm -rf "${build_root}"' EXIT +mkdir -p "${VENDOR_DIR}/include" + +for entry in "${targets[@]}"; do + abi="${entry%%:*}" + target="${entry#*:}" + prefix="${build_root}/${abi}" + log "building ${abi} (${target})" + ( + cd "${GHOSTTY_SOURCE_DIR}" + ANDROID_NDK_HOME="${ANDROID_NDK_HOME}" "${GHOSTTY_ZIG}" build \ + -Demit-lib-vt \ + -Dtarget="${target}" \ + -Doptimize=ReleaseFast \ + -Dstrip=true \ + -Dsimd=false \ + -p "${prefix}" + ) + + mkdir -p "${MODULE_DIR}/android/src/main/jniLibs/${abi}" + cp "${prefix}/lib/libghostty-vt.so.0.1.0" \ + "${MODULE_DIR}/android/src/main/jniLibs/${abi}/libghostty-vt.so" + "${strip_tool}" --strip-unneeded \ + "${MODULE_DIR}/android/src/main/jniLibs/${abi}/libghostty-vt.so" +done + +rm -rf "${VENDOR_DIR}/include/ghostty" +cp -R "${build_root}/arm64-v8a/include/ghostty" "${VENDOR_DIR}/include/ghostty" +cp "${GHOSTTY_SOURCE_DIR}/LICENSE" "${VENDOR_DIR}/LICENSE" +printf '%s\n' "${GHOSTTY_REVISION}" > "${VENDOR_DIR}/VERSION" +log "done" diff --git a/apps/mobile/modules/t3-terminal/scripts/libghostty-android-patches/0001-link-android-libvt-with-libc.patch b/apps/mobile/modules/t3-terminal/scripts/libghostty-android-patches/0001-link-android-libvt-with-libc.patch new file mode 100644 index 00000000000..2713eb739a0 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/scripts/libghostty-android-patches/0001-link-android-libvt-with-libc.patch @@ -0,0 +1,25 @@ +diff --git a/src/build/GhosttyZig.zig b/src/build/GhosttyZig.zig +index 44c300e15..f8d4296a2 100644 +--- a/src/build/GhosttyZig.zig ++++ b/src/build/GhosttyZig.zig +@@ -123,7 +123,7 @@ fn initVt( + // no-libcxx mode (HWY_NO_LIBCXX / SIMDUTF_NO_LIBCXX) so we + // don't need libcpp. System-provided simdutf headers still + // use C++ stdlib headers, so we need libcpp in that case. +- .link_libc = if (cfg.simd) true else null, ++ .link_libc = if (cfg.simd or cfg.target.result.abi.isAndroid()) true else null, + .link_libcpp = if (cfg.simd and + b.systemIntegrationOption("simdutf", .{}) and + cfg.target.result.abi != .msvc) true else null, +diff --git a/src/terminal/c/sys.zig b/src/terminal/c/sys.zig +index c4b2b17f2..afb98ed4b 100644 +--- a/src/terminal/c/sys.zig ++++ b/src/terminal/c/sys.zig +@@ -227,6 +227,7 @@ pub fn logStderr( + message_len: usize, + ) callconv(lib.calling_conv) void { + if (comptime builtin.target.cpu.arch.isWasm()) return; ++ if (comptime builtin.target.abi.isAndroid()) return; + + const scope = scope_ptr[0..scope_len]; + const message = message_ptr[0..message_len]; diff --git a/apps/mobile/modules/t3-terminal/scripts/libghostty-android-patches/0002-align-android-libvt-to-16kb-pages.patch b/apps/mobile/modules/t3-terminal/scripts/libghostty-android-patches/0002-align-android-libvt-to-16kb-pages.patch new file mode 100644 index 00000000000..2fdffd2ccb4 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/scripts/libghostty-android-patches/0002-align-android-libvt-to-16kb-pages.patch @@ -0,0 +1,11 @@ +diff --git a/src/build/GhosttyLibVt.zig b/src/build/GhosttyLibVt.zig +--- a/src/build/GhosttyLibVt.zig ++++ b/src/build/GhosttyLibVt.zig +@@ -236,6 +236,7 @@ fn initLib( + if (lib.rootModuleTarget().abi.isAndroid()) { + // Support 16kb page sizes, required for Android 15+. ++ lib.link_z_common_page_size = 16384; // 16kb + lib.link_z_max_page_size = 16384; // 16kb + + try @import("android_ndk").addPaths(b, lib); + } diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 7455f65b348..c99351547be 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -5,13 +5,16 @@ "main": "index.ts", "scripts": { "dev": "expo start --clear", - "dev:client": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --clear", + "dev:client": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --clear --lan", + "dev:client:preview": "eas env:exec preview 'EXPO_NO_DOTENV=1 APP_VARIANT=preview expo start --dev-client --scheme t3code-preview --clear --lan'", "start": "expo start", "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", + "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 && 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", "android:preview": "APP_VARIANT=preview EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android", "android:prod": "APP_VARIANT=production EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android", "eas:android:dev": "eas build --profile development -p android", @@ -22,6 +25,7 @@ "ios:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios", "ios:preview": "APP_VARIANT=preview EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios", "ios:prod": "APP_VARIANT=production EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios", + "ios:release": "APP_VARIANT=production EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios --configuration Release --no-bundler", "eas:ios:dev": "eas build --profile development -p ios", "eas:ios:preview": "eas build --profile preview -p ios", "eas:ios:preview:dev": "eas build --profile preview:dev -p ios", @@ -63,12 +67,14 @@ "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff", "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", "@t3tools/shared": "workspace:*", + "@tabler/icons-react-native": "^3.44.0", "clsx": "^2.1.1", "diff": "8.0.3", "effect": "catalog:", "expo": "~56.0.12", "expo-asset": "~56.0.17", "expo-auth-session": "~56.0.14", + "expo-blur": "~56.0.3", "expo-build-properties": "~56.0.19", "expo-camera": "~56.0.8", "expo-clipboard": "~56.0.4", @@ -79,13 +85,17 @@ "expo-font": "~56.0.7", "expo-glass-effect": "~56.0.4", "expo-haptics": "~56.0.3", + "expo-image": "~56.0.11", "expo-image-picker": "~56.0.18", "expo-linking": "~56.0.14", "expo-network": "~56.0.5", "expo-notifications": "~56.0.18", "expo-paste-input": "^0.1.15", + "expo-quick-actions": "^6.0.2", "expo-secure-store": "~56.0.4", + "expo-sharing": "~56.0.18", "expo-splash-screen": "~56.0.10", + "expo-sqlite": "~56.0.5", "expo-symbols": "~56.0.6", "expo-updates": "~56.0.19", "expo-web-browser": "~56.0.5", diff --git a/apps/mobile/plugins/withAndroidGradleHeap.cjs b/apps/mobile/plugins/withAndroidGradleHeap.cjs new file mode 100644 index 00000000000..0ec26507d5a --- /dev/null +++ b/apps/mobile/plugins/withAndroidGradleHeap.cjs @@ -0,0 +1,22 @@ +const { withGradleProperties } = require("expo/config-plugins"); + +// The Expo template's 2GB heap is too small for D8 dex merging in this app, +// causing OutOfMemoryError in :app:mergeExtDexDebug. +const JVM_ARGS = "-Xmx4096m -XX:MaxMetaspaceSize=1024m"; + +module.exports = function withAndroidGradleHeap(config) { + return withGradleProperties(config, (nextConfig) => { + const properties = nextConfig.modResults.filter( + (item) => !(item.type === "property" && item.key === "org.gradle.jvmargs"), + ); + + properties.push({ + type: "property", + key: "org.gradle.jvmargs", + value: JVM_ARGS, + }); + + nextConfig.modResults = properties; + return nextConfig; + }); +}; diff --git a/apps/mobile/plugins/withAndroidModernAlertDialog.cjs b/apps/mobile/plugins/withAndroidModernAlertDialog.cjs new file mode 100644 index 00000000000..8c8b2b80006 --- /dev/null +++ b/apps/mobile/plugins/withAndroidModernAlertDialog.cjs @@ -0,0 +1,188 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { + AndroidConfig, + withAndroidColors, + withAndroidColorsNight, + withAndroidStyles, + withDangerousMod, +} = require("expo/config-plugins"); + +// React Native's Alert renders an AppCompat AlertDialog on Android, which +// inherits the dated framework dialog chrome (square gray panel, teal +// all-caps buttons) from the app theme. These resources restyle it with the +// app's uniwind tokens from global.css: --color-card panel, --color-foreground +// text, --color-primary buttons, DM Sans type. The @font resources referenced +// here are embedded by the expo-font plugin config in app.config.ts. + +// AppCompat's default dialog window background is an inset rounded rect, so +// the replacement keeps the same 16dp inset to preserve the dialog's margins. +const DIALOG_BACKGROUND_DRAWABLE = ` + + + + + + +`; + +const COLORS = { + light: { + background: "#FFFFFF", // --color-card + text: "#262626", // --color-foreground + secondaryText: "#525252", // --color-foreground-secondary + buttonText: "#262626", // --color-primary + }, + night: { + background: "#171717", + text: "#F5F5F5", + secondaryText: "#A3A3A3", + buttonText: "#F5F5F5", + }, +}; + +function assignStyleItem(style, name, value) { + style.item = style.item ?? []; + const existing = style.item.find((item) => item.$?.name === name); + if (existing) { + existing._ = value; + } else { + style.item.push({ _: value, $: { name } }); + } +} + +function withAlertDialogStyles(config) { + return withAndroidStyles(config, (config) => { + const resources = config.modResults.resources; + resources.style = resources.style ?? []; + + const appTheme = resources.style.find((style) => style.$?.name === "AppTheme"); + if (appTheme) { + // React Native's dialog module builds an androidx.appcompat AlertDialog, + // which resolves its theme from the AppCompat attr; the framework attr is + // set too for any native code that inflates a platform AlertDialog. + assignStyleItem(appTheme, "alertDialogTheme", "@style/AppAlertDialog"); + assignStyleItem(appTheme, "android:alertDialogTheme", "@style/AppAlertDialog"); + } + + resources.style = resources.style.filter( + (style) => + !["AppAlertDialog", "AppAlertDialog.Title", "AppAlertDialog.Button"].includes( + style.$?.name, + ), + ); + resources.style.push( + { + $: { name: "AppAlertDialog", parent: "ThemeOverlay.AppCompat.Dialog.Alert" }, + item: [ + { _: "@drawable/alert_dialog_background", $: { name: "android:windowBackground" } }, + // The message body resolves textColorPrimary in AppCompat's alert + // layout; pointing it at the secondary token dims the message + // relative to the title, which keeps full-strength text via the + // explicit color in AppAlertDialog.Title. + { _: "@color/alert_dialog_secondary_text", $: { name: "android:textColorPrimary" } }, + { _: "@color/alert_dialog_secondary_text", $: { name: "android:textColorSecondary" } }, + // Theme-level fontFamily is the lowest-priority fallback in attribute + // resolution, so it reaches every text view in the dialog that does + // not carry its own fontFamily (the message body in particular). + { _: "@font/xml_dm_sans_regular", $: { name: "android:fontFamily" } }, + // AppCompat's alert title view styles itself from the framework + // attr (?android:attr/windowTitleStyle); there is no unprefixed + // AppCompat equivalent. + { _: "@style/AppAlertDialog.Title", $: { name: "android:windowTitleStyle" } }, + { _: "@style/AppAlertDialog.Button", $: { name: "buttonBarPositiveButtonStyle" } }, + { _: "@style/AppAlertDialog.Button", $: { name: "buttonBarNegativeButtonStyle" } }, + { _: "@style/AppAlertDialog.Button", $: { name: "buttonBarNeutralButtonStyle" } }, + ], + }, + { + $: { name: "AppAlertDialog.Title", parent: "RtlOverlay.DialogWindowTitle.AppCompat" }, + item: [ + { _: "@font/dm_sans_500medium", $: { name: "android:fontFamily" } }, + { _: "18sp", $: { name: "android:textSize" } }, + { _: "@color/alert_dialog_text", $: { name: "android:textColor" } }, + ], + }, + { + $: { + name: "AppAlertDialog.Button", + parent: "Widget.AppCompat.Button.ButtonBar.AlertDialog", + }, + item: [ + // The AppCompat button appearance hardcodes sans-serif-medium, so + // the font must be set here rather than relying on the theme + // fallback. + { _: "@font/dm_sans_500medium", $: { name: "android:fontFamily" } }, + { _: "@color/alert_dialog_button_text", $: { name: "android:textColor" } }, + { _: "false", $: { name: "android:textAllCaps" } }, + { _: "0", $: { name: "android:letterSpacing" } }, + ], + }, + ); + + return config; + }); +} + +function assignColors(colorsResource, palette) { + let result = colorsResource; + result = AndroidConfig.Colors.assignColorValue(result, { + name: "alert_dialog_background", + value: palette.background, + }); + result = AndroidConfig.Colors.assignColorValue(result, { + name: "alert_dialog_text", + value: palette.text, + }); + result = AndroidConfig.Colors.assignColorValue(result, { + name: "alert_dialog_secondary_text", + value: palette.secondaryText, + }); + result = AndroidConfig.Colors.assignColorValue(result, { + name: "alert_dialog_button_text", + value: palette.buttonText, + }); + return result; +} + +function withAlertDialogColors(config) { + config = withAndroidColors(config, (config) => { + config.modResults = assignColors(config.modResults, COLORS.light); + return config; + }); + config = withAndroidColorsNight(config, (config) => { + config.modResults = assignColors(config.modResults, COLORS.night); + return config; + }); + return config; +} + +function withAlertDialogBackgroundDrawable(config) { + return withDangerousMod(config, [ + "android", + async (config) => { + const drawableDir = path.join( + config.modRequest.platformProjectRoot, + "app", + "src", + "main", + "res", + "drawable", + ); + fs.mkdirSync(drawableDir, { recursive: true }); + fs.writeFileSync( + path.join(drawableDir, "alert_dialog_background.xml"), + DIALOG_BACKGROUND_DRAWABLE, + ); + return config; + }, + ]); +} + +module.exports = function withAndroidModernAlertDialog(config) { + return withAlertDialogBackgroundDrawable(withAlertDialogColors(withAlertDialogStyles(config))); +}; diff --git a/apps/mobile/plugins/withAndroidModernPopupMenu.cjs b/apps/mobile/plugins/withAndroidModernPopupMenu.cjs new file mode 100644 index 00000000000..68409cbd0fd --- /dev/null +++ b/apps/mobile/plugins/withAndroidModernPopupMenu.cjs @@ -0,0 +1,250 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { + AndroidConfig, + withAndroidColors, + withAndroidColorsNight, + withAndroidStyles, + withDangerousMod, +} = require("expo/config-plugins"); + +// @react-native-menu/menu renders an AppCompat PopupMenu on Android, which +// inherits the dated default popup chrome from the app theme. These resources +// restyle it to match the app palette (global.css --color-card / --color-foreground) +// with rounded corners, and anchor it below the button instead of overlapping it. + +const POPUP_BACKGROUND_DRAWABLE = ` + + + + +`; + +// Checkable menu rows insert a CheckBox at the row's right edge; the theme +// swaps its square-box button for this check glyph so the selected option +// shows a plain right-aligned check. +const CHECK_DRAWABLE = ` + + + +`; + +// CheckBox button drawable: check glyph when selected, nothing otherwise. +const CHECKBOX_BUTTON_SELECTOR = ` + + + + +`; + +// Replaces the default filled-triangle submenu indicator with a stroked ">" +// chevron. +const SUBMENU_ARROW_DRAWABLE = ` + + + +`; + +const COLORS = { + light: { background: "#F7F7F7", itemText: "#262626" }, + night: { background: "#161616", itemText: "#F5F5F5" }, +}; + +function assignStyleItem(style, name, value) { + style.item = style.item ?? []; + const existing = style.item.find((item) => item.$?.name === name); + if (existing) { + existing._ = value; + } else { + style.item.push({ _: value, $: { name } }); + } +} + +function withPopupMenuStyles(config) { + return withAndroidStyles(config, (config) => { + const resources = config.modResults.resources; + resources.style = resources.style ?? []; + + const appTheme = resources.style.find((style) => style.$?.name === "AppTheme"); + if (appTheme) { + assignStyleItem(appTheme, "popupMenuStyle", "@style/AppPopupMenu"); + assignStyleItem(appTheme, "android:popupMenuStyle", "@style/AppPopupMenu"); + assignStyleItem( + appTheme, + "textAppearanceLargePopupMenu", + "@style/AppPopupMenu.TextAppearance", + ); + assignStyleItem( + appTheme, + "textAppearanceSmallPopupMenu", + "@style/AppPopupMenu.TextAppearance", + ); + // Submenu popups show their parent item as a header row that reads a + // separate theme attribute, so it needs the same themed text color. + assignStyleItem( + appTheme, + "textAppearancePopupMenuHeader", + "@style/AppPopupMenu.HeaderTextAppearance", + ); + // Menu item views resolve their submenu arrow from this style + // (android:listMenuViewStyle / android:subMenuArrow are public attrs). + assignStyleItem(appTheme, "android:listMenuViewStyle", "@style/AppPopupMenuListMenuView"); + // Checkable rows inflate a plain CheckBox at the row end; restyle its + // button so the selected option shows a right-aligned check glyph + // instead of a square box. Both framework and AppCompat attrs are set + // since the popup may inflate either CheckBox flavor. App-wide for + // native checkboxes, which the app otherwise doesn't use. + assignStyleItem(appTheme, "android:checkboxStyle", "@style/AppPopupMenuCheckBox"); + assignStyleItem(appTheme, "checkboxStyle", "@style/AppPopupMenuCheckBoxCompat"); + } + + resources.style = resources.style.filter( + (style) => + ![ + "AppPopupMenu", + "AppPopupMenu.TextAppearance", + "AppPopupMenu.HeaderTextAppearance", + "AppPopupMenuListMenuView", + "AppPopupMenuCheckBox", + "AppPopupMenuCheckBoxCompat", + ].includes(style.$?.name), + ); + resources.style.push( + { + $: { name: "AppPopupMenu", parent: "Widget.AppCompat.PopupMenu" }, + item: [ + { _: "@drawable/popup_menu_background", $: { name: "android:popupBackground" } }, + { _: "false", $: { name: "android:overlapAnchor" } }, + { _: "4dp", $: { name: "android:dropDownVerticalOffset" } }, + ], + }, + { + $: { name: "AppPopupMenu.TextAppearance", parent: "TextAppearance.AppCompat.Menu" }, + item: [ + { _: "15sp", $: { name: "android:textSize" } }, + { _: "@color/popup_menu_item_text", $: { name: "android:textColor" } }, + // DM Sans (--font-sans); embedded by the expo-font plugin config in + // app.config.ts. + { _: "@font/xml_dm_sans_regular", $: { name: "android:fontFamily" } }, + ], + }, + { + $: { + name: "AppPopupMenu.HeaderTextAppearance", + parent: "TextAppearance.AppCompat.Widget.PopupMenu.Header", + }, + item: [ + { _: "15sp", $: { name: "android:textSize" } }, + { _: "@color/popup_menu_item_text", $: { name: "android:textColor" } }, + { _: "@font/xml_dm_sans_regular", $: { name: "android:fontFamily" } }, + ], + }, + // The framework default (Widget.Material.ListMenuView) only carries + // subMenuArrow, so replacing the style wholesale is safe. + { + $: { name: "AppPopupMenuListMenuView" }, + item: [{ _: "@drawable/popup_menu_submenu_arrow", $: { name: "android:subMenuArrow" } }], + }, + { + $: { + name: "AppPopupMenuCheckBox", + parent: "android:Widget.Material.CompoundButton.CheckBox", + }, + item: [{ _: "@drawable/popup_menu_check_button", $: { name: "android:button" } }], + }, + { + $: { + name: "AppPopupMenuCheckBoxCompat", + parent: "Widget.AppCompat.CompoundButton.CheckBox", + }, + item: [ + { _: "@drawable/popup_menu_check_button", $: { name: "android:button" } }, + // buttonCompat wins over android:button in AppCompatCheckBox. + { _: "@drawable/popup_menu_check_button", $: { name: "buttonCompat" } }, + ], + }, + ); + + return config; + }); +} + +function withPopupMenuColors(config) { + config = withAndroidColors(config, (config) => { + config.modResults = AndroidConfig.Colors.assignColorValue(config.modResults, { + name: "popup_menu_background", + value: COLORS.light.background, + }); + config.modResults = AndroidConfig.Colors.assignColorValue(config.modResults, { + name: "popup_menu_item_text", + value: COLORS.light.itemText, + }); + return config; + }); + config = withAndroidColorsNight(config, (config) => { + config.modResults = AndroidConfig.Colors.assignColorValue(config.modResults, { + name: "popup_menu_background", + value: COLORS.night.background, + }); + config.modResults = AndroidConfig.Colors.assignColorValue(config.modResults, { + name: "popup_menu_item_text", + value: COLORS.night.itemText, + }); + return config; + }); + return config; +} + +function withPopupMenuBackgroundDrawable(config) { + return withDangerousMod(config, [ + "android", + async (config) => { + const drawableDir = path.join( + config.modRequest.platformProjectRoot, + "app", + "src", + "main", + "res", + "drawable", + ); + fs.mkdirSync(drawableDir, { recursive: true }); + fs.writeFileSync( + path.join(drawableDir, "popup_menu_background.xml"), + POPUP_BACKGROUND_DRAWABLE, + ); + fs.writeFileSync(path.join(drawableDir, "ic_menu_check.xml"), CHECK_DRAWABLE); + fs.writeFileSync( + path.join(drawableDir, "popup_menu_check_button.xml"), + CHECKBOX_BUTTON_SELECTOR, + ); + fs.writeFileSync( + path.join(drawableDir, "popup_menu_submenu_arrow.xml"), + SUBMENU_ARROW_DRAWABLE, + ); + return config; + }, + ]); +} + +module.exports = function withAndroidModernPopupMenu(config) { + return withPopupMenuBackgroundDrawable(withPopupMenuColors(withPopupMenuStyles(config))); +}; diff --git a/apps/mobile/plugins/withAndroidPredictiveBackCompat.cjs b/apps/mobile/plugins/withAndroidPredictiveBackCompat.cjs new file mode 100644 index 00000000000..0014ca053f3 --- /dev/null +++ b/apps/mobile/plugins/withAndroidPredictiveBackCompat.cjs @@ -0,0 +1,104 @@ +const { withMainActivity } = require("expo/config-plugins"); + +// predictiveBackGestureEnabled writes android:enableOnBackInvokedCallback="true", +// which retires the legacy KEYCODE_BACK/onBackPressed() delivery on Android 13+. +// From then on back gestures only reach the app through OnBackPressedDispatcher +// callbacks. react-native 0.85 registers its own always-enabled callback, but +// only on Android 16 with targetSdk 36 (ReactActivity's enforced-predictive-back +// workaround) — on Android 13-15 nothing is registered, the system consumes +// every back gesture itself, and JS back handling (React Navigation pops, +// BackHandler listeners) silently dies: each gesture just backgrounds the app. +// This plugin mirrors react-native's shim on API 33-35 so back keeps flowing +// to JS there. See https://github.com/software-mansion/react-native-screens/discussions/2540. + +const CALLBACK_PROPERTY = ` + // Routes predictive-back gestures to JS on Android 13-15, where react-native + // registers no OnBackPressedDispatcher callback of its own (it only does on + // Android 16+). Registered in onCreate; added by withAndroidPredictiveBackCompat. + private val predictiveBackCompatCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + isEnabled = false + onBackPressed() + isEnabled = true + } + } +`; + +const CALLBACK_REGISTRATION = ` + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Build.VERSION.SDK_INT < Build.VERSION_CODES.BAKLAVA) { + onBackPressedDispatcher.addCallback(this, predictiveBackCompatCallback) + }`; + +// Wraps the template's invokeDefaultOnBackPressed: the default action ends in +// ComponentActivity.onBackPressed(), which re-enters the dispatcher — with the +// compat callback still enabled that would bounce back into JS forever instead +// of backgrounding the app. +const INVOKE_DEFAULT_WRAPPER = `override fun invokeDefaultOnBackPressed() { + predictiveBackCompatCallback.isEnabled = false + try { + invokeDefaultOnBackPressedLegacy() + } finally { + predictiveBackCompatCallback.isEnabled = true + } + } + + private fun invokeDefaultOnBackPressedLegacy() {`; + +function insertAfter(contents, anchor, insertion, description) { + const index = contents.indexOf(anchor); + if (index === -1) { + throw new Error( + `withAndroidPredictiveBackCompat: could not find ${description} in MainActivity — the Expo template changed; update the plugin anchors.`, + ); + } + const end = index + anchor.length; + return contents.slice(0, end) + insertion + contents.slice(end); +} + +module.exports = function withAndroidPredictiveBackCompat(config) { + if (config.android?.predictiveBackGestureEnabled !== true) { + return config; + } + + return withMainActivity(config, (nextConfig) => { + let contents = nextConfig.modResults.contents; + if (nextConfig.modResults.language !== "kt") { + throw new Error("withAndroidPredictiveBackCompat: MainActivity must be Kotlin."); + } + if (contents.includes("predictiveBackCompatCallback")) { + return nextConfig; + } + + contents = insertAfter( + contents, + "import android.os.Bundle", + "\nimport androidx.activity.OnBackPressedCallback", + "the android.os.Bundle import", + ); + contents = insertAfter( + contents, + "class MainActivity : ReactActivity() {", + CALLBACK_PROPERTY, + "the MainActivity class declaration", + ); + contents = insertAfter( + contents, + "super.onCreate(null)", + CALLBACK_REGISTRATION, + "the super.onCreate call", + ); + + if (!contents.includes("override fun invokeDefaultOnBackPressed() {")) { + throw new Error( + "withAndroidPredictiveBackCompat: could not find invokeDefaultOnBackPressed in MainActivity — the Expo template changed; update the plugin anchors.", + ); + } + contents = contents.replace( + "override fun invokeDefaultOnBackPressed() {", + INVOKE_DEFAULT_WRAPPER, + ); + + nextConfig.modResults.contents = contents; + return nextConfig; + }); +}; diff --git a/apps/mobile/plugins/withIosCrashLog.cjs b/apps/mobile/plugins/withIosCrashLog.cjs new file mode 100644 index 00000000000..72afa09fd49 --- /dev/null +++ b/apps/mobile/plugins/withIosCrashLog.cjs @@ -0,0 +1,126 @@ +const { withAppDelegate } = require("expo/config-plugins"); + +const IMPORT_LINE = "internal import T3NativeControls"; +const INSTALL_CALL = "Self.installNativeFatalHandlersIfNeeded()"; +const METHOD_MARKER = "installNativeFatalHandlersIfNeeded"; +const FLAG_MARKER = "fatalHandlersInstalled"; + +const FATAL_HANDLER_METHOD = ` + private static var fatalHandlersInstalled = false + + /// Hooks RN's fatal path so reportFatal leaves last-crash.json even when the + /// JS ErrorUtils logger never flushes (the common Release Hermes failure mode). + private static func installNativeFatalHandlersIfNeeded() { + if fatalHandlersInstalled { + return + } + fatalHandlersInstalled = true + + let previousFatal = RCTGetFatalHandler() + let previousException = RCTGetFatalExceptionHandler() + + RCTSetFatalHandler { error in + if let error { + let nsError = error as NSError + T3CrashLog.persistNativeFatal( + message: nsError.localizedDescription, + name: "RCTFatal", + stack: T3CrashLog.formatJSStack(nsError.userInfo[RCTJSStackTraceKey]), + extra: T3CrashLog.stringValue(nsError.userInfo[RCTJSExtraDataKey]), + source: "rct-fatal" + ) + } + if let previousFatal { + previousFatal(error) + } else if let error { + let nsError = error as NSError + let description = nsError.localizedDescription + let name = "\\(RCTFatalExceptionName): \\(description)" + let stack = nsError.userInfo[RCTJSStackTraceKey] as? [[String: Any]] + let reason = RCTFormatError(description, stack, 175) + var userInfo = nsError.userInfo + userInfo["RCTUntruncatedMessageKey"] = RCTFormatError(description, stack, 0) + NSException(name: NSExceptionName(name), reason: reason, userInfo: userInfo).raise() + } + } + + RCTSetFatalExceptionHandler { exception in + if let exception { + T3CrashLog.persistNativeFatal( + message: exception.reason ?? exception.name.rawValue, + name: exception.name.rawValue, + stack: T3CrashLog.formatExceptionStack(exception), + extra: nil, + source: "rct-fatal-exception" + ) + } + if let previousException { + previousException(exception) + } else { + exception?.raise() + } + } + } +`; + +function ensureImport(contents) { + if (contents.includes("import T3NativeControls")) { + return contents; + } + if (contents.includes("import ReactAppDependencyProvider")) { + return contents.replace( + "import ReactAppDependencyProvider", + `import ReactAppDependencyProvider\n${IMPORT_LINE}`, + ); + } + if (contents.includes("import React\n")) { + return contents.replace("import React\n", `import React\n${IMPORT_LINE}\n`); + } + return `${IMPORT_LINE}\n${contents}`; +} + +function ensureInstallCall(contents) { + if (contents.includes(INSTALL_CALL)) { + return contents; + } + const replaced = contents.replace( + /(didFinishLaunchingWithOptions[^{]*\{\n)/, + `$1 // t3code: capture RCTFatal message/stack before JS can die.\n ${INSTALL_CALL}\n\n`, + ); + if (replaced === contents) { + throw new Error("withIosCrashLog: could not find didFinishLaunchingWithOptions body"); + } + return replaced; +} + +function ensureFatalHandlerMethod(contents) { + if (contents.includes(METHOD_MARKER) && contents.includes(FLAG_MARKER)) { + return contents; + } + if (contents.includes("// Linking API")) { + return contents.replace("// Linking API", `${FATAL_HANDLER_METHOD}\n // Linking API`); + } + // Fallback: insert before ReactNativeDelegate class. + if (contents.includes("class ReactNativeDelegate")) { + return contents.replace( + "class ReactNativeDelegate", + `${FATAL_HANDLER_METHOD}\n}\n\nclass ReactNativeDelegate`, + ); + } + throw new Error("withIosCrashLog: could not find insertion point for fatal handler method"); +} + +module.exports = function withIosCrashLog(config) { + return withAppDelegate(config, (nextConfig) => { + if (nextConfig.modResults.language !== "swift") { + throw new Error("The iOS crash log plugin requires a Swift AppDelegate."); + } + + let contents = nextConfig.modResults.contents; + contents = ensureImport(contents); + contents = ensureInstallCall(contents); + contents = ensureFatalHandlerMethod(contents); + nextConfig.modResults.contents = contents; + return nextConfig; + }); +}; diff --git a/apps/mobile/plugins/withShareExtensionDisplayName.cjs b/apps/mobile/plugins/withShareExtensionDisplayName.cjs new file mode 100644 index 00000000000..27c57925e8a --- /dev/null +++ b/apps/mobile/plugins/withShareExtensionDisplayName.cjs @@ -0,0 +1,93 @@ +"use strict"; + +// expo-sharing intentionally uses a fixed target name and also uses that +// internal target name as CFBundleDisplayName. Brand the extension while +// leaving its initially-empty signing team intact: Expo uses that state to +// select ios.appleTeamId and enable first-time profile provisioning. +// +// ORDERING: list this plugin BEFORE expo-sharing. Expo runs same-type mods in +// reverse registration order, so this executes after the extension exists. + +const fs = require("fs"); +const path = require("path"); +const { withDangerousMod, withXcodeProject } = require("expo/config-plugins"); + +const TARGET_NAME = "expo-sharing-extension"; + +function stripComments(section) { + return Object.fromEntries( + Object.entries(section ?? {}).filter(([key]) => !key.endsWith("_comment")), + ); +} + +function findByName(section, name) { + return Object.entries(stripComments(section)).find(([, value]) => value?.name === name) ?? null; +} + +function escapeXml(value) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function withDisplayNamePlist(config, displayName) { + return withDangerousMod(config, [ + "ios", + (cfg) => { + const plistPath = path.join(cfg.modRequest.platformProjectRoot, TARGET_NAME, "Info.plist"); + if (!fs.existsSync(plistPath)) { + throw new Error( + `${TARGET_NAME}/Info.plist was not generated. Register withShareExtensionDisplayName before expo-sharing.`, + ); + } + const source = fs.readFileSync(plistPath, "utf8"); + const displayNamePattern = /(CFBundleDisplayName<\/key>\s*)[^<]*(<\/string>)/; + if (!displayNamePattern.test(source)) { + throw new Error(`Could not update CFBundleDisplayName in ${plistPath}.`); + } + const next = source.replace(displayNamePattern, `$1${escapeXml(displayName)}$2`); + if (next !== source) { + fs.writeFileSync(plistPath, next); + } + return cfg; + }, + ]); +} + +function withExtensionDisplayNameBuildSetting(config, displayName) { + return withXcodeProject(config, (cfg) => { + const objects = cfg.modResults.hash.project.objects; + const targetEntry = findByName(objects.PBXNativeTarget, TARGET_NAME); + if (!targetEntry) { + throw new Error( + `${TARGET_NAME} Xcode target was not generated. Register withShareExtensionDisplayName before expo-sharing.`, + ); + } + const target = targetEntry[1]; + const configurationList = stripComments(objects.XCConfigurationList)[ + target.buildConfigurationList + ]; + if (!configurationList) { + throw new Error(`Could not find build configurations for ${TARGET_NAME}.`); + } + const buildConfigurations = stripComments(objects.XCBuildConfiguration); + for (const reference of configurationList.buildConfigurations ?? []) { + const buildConfiguration = buildConfigurations[reference.value]; + if (buildConfiguration?.buildSettings) { + buildConfiguration.buildSettings.INFOPLIST_KEY_CFBundleDisplayName = `"${displayName}"`; + } + } + return cfg; + }); +} + +module.exports = function withShareExtensionDisplayName(config) { + const displayName = config.name; + return withExtensionDisplayNameBuildSetting( + withDisplayNamePlist(config, displayName), + displayName, + ); +}; diff --git a/apps/mobile/plugins/withoutIosPersonalTeamCapabilities.cjs b/apps/mobile/plugins/withoutIosPersonalTeamCapabilities.cjs new file mode 100644 index 00000000000..035c46b34c8 --- /dev/null +++ b/apps/mobile/plugins/withoutIosPersonalTeamCapabilities.cjs @@ -0,0 +1,10 @@ +const { withEntitlementsPlist } = require("expo/config-plugins"); + +module.exports = function withoutIosPersonalTeamCapabilities(config) { + return withEntitlementsPlist(config, (modConfig) => { + delete modConfig.modResults["aps-environment"]; + delete modConfig.modResults["com.apple.developer.applesignin"]; + delete modConfig.modResults["com.apple.security.application-groups"]; + return modConfig; + }); +}; diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index d0880a39798..835f54491f6 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -1,11 +1,7 @@ -import { - DMSans_400Regular, - DMSans_500Medium, - DMSans_700Bold, - useFonts, -} from "@expo-google-fonts/dm-sans"; +import { BlurTargetView } from "expo-blur"; import * as Linking from "expo-linking"; import * as SplashScreen from "expo-splash-screen"; +import { useEffect } from "react"; import { StatusBar, useColorScheme } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; @@ -13,39 +9,50 @@ import { SafeAreaProvider } from "react-native-safe-area-context"; import { createStaticNavigation, DarkTheme, DefaultTheme } from "@react-navigation/native"; import { RegistryContext } from "@effect/atom-react"; -import { useEffect } from "react"; +import { ConfirmDialogHost } from "./components/ConfirmDialogHost"; import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider"; +import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene"; +import { IncomingShareProvider } from "./features/sharing/IncomingShareProvider"; import { AppearancePreferencesProvider } from "./features/settings/appearance/AppearancePreferencesProvider"; import { RootStack } from "./Stack"; import { appAtomRegistry } from "./state/atom-registry"; +import { OverlayPortalHost } from "./components/OverlayPortal"; +import { appBlurTargetRef } from "./lib/appBlurTarget"; import { useThemeColor } from "./lib/useThemeColor"; import "../global.css"; +if (process.env.EXPO_PUBLIC_SHOWCASE === "1") { + prepareNativeShowcaseCapture(); +} + const appLinking = { prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"], + // The Expo dev client launches the app via + // ://expo-development-client/?url= — that URL addresses + // the launcher, not app navigation. Without this filter it falls through + // to the NotFound wildcard route on every dev launch. + // expo-sharing uses a private lifecycle URL only to wake the app. The + // persisted share inbox below owns navigation once the payload is durable. + filter: (url: string) => + !url.includes("expo-development-client") && !url.includes("://expo-sharing"), }; const Navigation = createStaticNavigation(RootStack); export default function App() { - const [fontsLoaded] = useFonts({ - DMSans_400Regular, - DMSans_500Medium, - DMSans_700Bold, - }); const colorScheme = useColorScheme(); const statusBarBg = useThemeColor("--color-status-bar"); useEffect(() => { - if (fontsLoaded) SplashScreen.hide(); - }, [fontsLoaded]); + SplashScreen.hide(); + }, []); return ( - + + {/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */} + + + + + + + {/* Anchored-menu overlays render here — in-window, so the + keyboard stays up while a dropdown is open. */} + diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index d68d6f2b97b..db244d7c726 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -10,10 +10,12 @@ import { createNativeStackScreen, type NativeStackNavigationOptions, } from "@react-navigation/native-stack"; +import { useEffect, useRef } from "react"; import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "react-native"; import { useResolveClassNames } from "uniwind"; import { AppText as Text } from "./components/AppText"; +import { renderCompactBrandTitle } from "./components/CompactBrandTitle"; import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent"; @@ -41,10 +43,23 @@ import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScr import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; +import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen"; import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; +import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; import { SettingsWaitlistRouteScreen } from "./features/settings/SettingsWaitlistRouteScreen"; +import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; +import { + SettingsLegalDocumentCloseHeaderButton, + SettingsLegalDocumentExternalHeaderButton, +} from "./features/settings/components/SettingsLegalDocumentRouteScreen"; +import { useAppShortcuts } from "./features/shortcuts/useAppShortcuts"; +import { useIncomingShare } from "./features/sharing/IncomingShareProvider"; +import { + EMPTY_INCOMING_SHARE_PRESENTATION_STATE, + transitionIncomingSharePresentation, +} from "./features/sharing/incoming-share-presentation"; import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; @@ -104,6 +119,14 @@ const SHEET_SOLID_HEADER_OPTIONS: AppScreenOptions = { unstable_navigationItemStyle: undefined, }; +const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { + ...SHEET_SOLID_HEADER_OPTIONS, + headerBackVisible: false, + headerLeft: SettingsLegalDocumentCloseHeaderButton, + headerRight: () => , + presentation: "fullScreenModal", +}; + const SettingsSheetStack = createNativeStackNavigator({ initialRouteName: "Settings", screenOptions: { @@ -147,6 +170,13 @@ const SettingsSheetStack = createNativeStackNavigator({ title: "Appearance", }, }), + SettingsClientStorage: createNativeStackScreen({ + screen: SettingsClientStorageRouteScreen, + linking: "client-storage", + options: { + title: "Client Storage", + }, + }), SettingsAuth: createNativeStackScreen({ screen: SettingsAuthRouteScreen, linking: "auth", @@ -230,6 +260,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([ "GitConfirm", "GitOverview", "NewTaskSheet", + "SettingsLegal", "SettingsSheet", "ThreadReviewComment", ]); @@ -252,10 +283,30 @@ function RootStackLayout(props: { readonly children: React.ReactNode; readonly state: NavigationState; }) { + const navigation = useNavigation(); + const { pendingShare } = useIncomingShare(); + const sharePresentationRef = useRef(EMPTY_INCOMING_SHARE_PRESENTATION_STATE); useAgentNotificationNavigation(); useThreadOutboxDrain(); // Presents the T3 Connect onboarding sheet after an in-session sign-in. useConnectOnboardingNavigation(); + // Launcher app shortcuts: routes shortcut taps and tracks opened threads. + useAppShortcuts(props.state); + useEffect(() => { + const topRouteName = props.state.routes[props.state.index]?.name; + const transition = transitionIncomingSharePresentation(sharePresentationRef.current, { + isShareSheetPresented: topRouteName === "NewTaskSheet", + pendingShareId: pendingShare?.id ?? null, + }); + sharePresentationRef.current = transition.state; + if (!transition.shareIdToPresent) { + return; + } + navigation.navigate("NewTaskSheet", { + screen: "NewTask", + params: { incomingShareId: transition.shareIdToPresent }, + }); + }, [navigation, pendingShare, props.state]); // Full pathname (sheets included) for keyboard-command scoping; the // workspace layout only reacts to the underlying non-overlay route. const path = getPathFromState(props.state, navigationPathConfig); @@ -264,6 +315,7 @@ function RootStackLayout(props: { return ( + {props.children} @@ -326,7 +378,8 @@ export const RootStack = createNativeStackNavigator({ ...GLASS_HEADER_OPTIONS, contentStyle: { backgroundColor: "transparent" }, headerBackVisible: false, - title: "Threads", + headerTitle: renderCompactBrandTitle, + title: "T3 Code", }, }), Thread: createNativeStackScreen({ @@ -348,9 +401,11 @@ export const RootStack = createNativeStackNavigator({ screen: ReviewCommentComposerSheet, linking: `${THREAD_LINKING_PREFIX}/review-comment`, options: { - presentation: "formSheet", - sheetAllowedDetents: [0.55, 0.92], - sheetGrabberVisible: true, + // Android cannot host the keyboard-driven comment composer inside a + // formSheet; use a full-screen modal there instead. + presentation: Platform.OS === "android" ? "fullScreenModal" : "formSheet", + sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.55, 0.92], + sheetGrabberVisible: Platform.OS !== "android", }, }), ThreadFiles: createNativeStackScreen({ @@ -412,18 +467,32 @@ export const RootStack = createNativeStackNavigator({ options: { gestureEnabled: true, headerShown: false, - presentation: "formSheet", - sheetAllowedDetents: [0.7, 0.92], - sheetGrabberVisible: true, + // Android pushes settings as a regular full page with an in-screen + // back header; iOS keeps the detented form sheet. + ...(Platform.OS === "android" + ? { presentation: "card" as const } + : { + presentation: "formSheet" as const, + sheetAllowedDetents: [0.7, 0.92], + sheetGrabberVisible: true, + }), + }, + }), + SettingsLegal: createNativeStackScreen({ + screen: SettingsLegalRouteScreen, + linking: "settings/legal", + options: { + ...LEGAL_DOCUMENT_HEADER_OPTIONS, + title: "Legal", }, }), ConnectOnboarding: createNativeStackScreen({ screen: ConnectOnboardingRouteScreen, linking: "connect-onboarding", options: { - // Root screenOptions hide headers; formSheets that want the native - // title bar opt back in with the sheet header preset. - ...SHEET_SOLID_HEADER_OPTIONS, + // A root-level Android formSheet does not host the native stack bar; + // the route renders an embedded AndroidSheetHeader instead. + ...(Platform.OS === "android" ? { headerShown: false } : SHEET_SOLID_HEADER_OPTIONS), title: "Set up T3 Connect", gestureEnabled: true, presentation: "formSheet", @@ -436,9 +505,15 @@ export const RootStack = createNativeStackNavigator({ linking: "connections", options: { title: "Environments", - presentation: "formSheet", - sheetAllowedDetents: [0.55, 0.7], - sheetGrabberVisible: true, + // Android: full page; the screen renders its own AndroidScreenHeader, + // so the native bar stays hidden. iOS keeps the sheet. + ...(Platform.OS === "android" + ? { presentation: "card" as const, headerShown: false } + : { + presentation: "formSheet" as const, + sheetAllowedDetents: [0.55, 0.7], + sheetGrabberVisible: true, + }), }, }), ConnectionsNew: createNativeStackScreen({ @@ -460,9 +535,15 @@ export const RootStack = createNativeStackNavigator({ options: { gestureEnabled: true, headerShown: false, - presentation: "formSheet", - sheetAllowedDetents: [0.92], - sheetGrabberVisible: true, + // Android pushes the flow as a regular full page — the draft should + // read like a thread that just doesn't exist yet; iOS keeps the sheet. + ...(Platform.OS === "android" + ? { presentation: "card" as const } + : { + presentation: "formSheet" as const, + sheetAllowedDetents: [0.92], + sheetGrabberVisible: true, + }), }, }), NotFound: createNativeStackScreen({ diff --git a/apps/mobile/src/components/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/AndroidAnchoredMenu.tsx new file mode 100644 index 00000000000..c4a0045eefb --- /dev/null +++ b/apps/mobile/src/components/AndroidAnchoredMenu.tsx @@ -0,0 +1,337 @@ +import type { MenuAction, MenuComponentProps } from "@react-native-menu/menu"; +import { BlurView } from "expo-blur"; +import type { ReactNode } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { StyleProp, ViewStyle } from "react-native"; +import { BackHandler, Pressable, ScrollView, useColorScheme, View } from "react-native"; +import { useKeyboardState } from "react-native-keyboard-controller"; +import Animated, { FadeIn } from "react-native-reanimated"; + +import { appBlurTargetRef } from "../lib/appBlurTarget"; +import { useThemeColor } from "../lib/useThemeColor"; +import { cn } from "../lib/cn"; +import { type AppSymbolName, SymbolView } from "./AppSymbol"; +import { AppText as Text } from "./AppText"; +import { OverlayPortal } from "./OverlayPortal"; + +const MENU_WIDTH = 250; +const SCREEN_MARGIN = 12; +const ANCHOR_GAP = 6; + +// Anchor position is snapshotted in window coordinates when the menu opens; +// the overlay root measures itself the same way, and the menu is placed from +// the delta. Both snapshots are taken at open time so later reflows (keyboard +// show/hide, screen transitions) can't flip an opens-up menu to opens-down +// mid-presentation. +type AnchorSnapshot = { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +}; + +type OverlayFrame = { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +}; + +export type AndroidAnchoredMenuProps = { + readonly actions: readonly MenuAction[]; + readonly title?: string; + readonly onPressAction?: MenuComponentProps["onPressAction"]; + /** Applied to the anchor wrapper — call sites flex these to fill toolbars. */ + readonly className?: string; + readonly style?: StyleProp; + /** + * Plain children open the menu on tap (the wrapper owns the press). A + * render function keeps the children interactive and hands them `open` to + * call from their own gesture — e.g. a row that selects on tap and opens + * this menu on long-press. + */ + readonly children: ReactNode | ((open: () => void) => ReactNode); +}; + +/** + * Token-styled anchored dropdown for Android, drop-in for the subset of the + * MenuView contract the app uses (actions with state/subtitle/image/ + * attributes, one level of subactions). The native AppCompat PopupMenu caps + * out on theming — stock animation, item metrics, and submenu chrome — so + * ControlPillMenu renders this instead on Android while iOS keeps the native + * UIMenu. Styling follows the themed native popup (12dp radius, plain rows, + * trailing check glyph); submenus drill in under a muted parent-title header. + */ +export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { + const [anchor, setAnchor] = useState(null); + const [path, setPath] = useState([]); + // Height of the modal's root view, in the modal's own coordinate space. + // Menus that flip above their anchor are pinned by their BOTTOM edge + // (bottom = rootHeight - anchorTop), so drill-in height changes grow + // upward without any re-measurement — positioning them via `top` from the + // menu's measured height made every submenu transition settle over two + // frames and jitter. + const [rootHeight, setRootHeight] = useState(null); + // Window frame of the overlay root, measured on layout. Anchor coordinates + // are converted into this frame, so the menu lands correctly no matter + // where the portal host sits (status bar, keyboard resize, etc.). + const [overlay, setOverlay] = useState(null); + const anchorRef = useRef(null); + const overlayRef = useRef(null); + + const isDarkMode = useColorScheme() === "dark"; + const keyboardVisible = useKeyboardState((state) => state.isVisible); + const keyboardHeight = useKeyboardState((state) => state.height); + const rippleColor = useThemeColor("--color-subtle"); + const iconColor = useThemeColor("--color-icon"); + const iconSubtleColor = useThemeColor("--color-icon-subtle"); + const dangerColor = useThemeColor("--color-danger-foreground"); + + const close = useCallback(() => { + setAnchor(null); + setPath([]); + setOverlay(null); + setRootHeight(null); + }, []); + + const open = useCallback(() => { + anchorRef.current?.measureInWindow((x, y, width, height) => { + setAnchor({ x, y, width, height }); + }); + }, []); + + const measureOverlay = useCallback(() => { + overlayRef.current?.measureInWindow((x, y, width, height) => { + setOverlay({ x, y, width, height }); + setRootHeight(height); + }); + }, []); + + // The dropdown renders in-window (no Modal takes focus), so the hardware + // back gesture needs explicit handling while it is open. Back steps out of + // a drilled-in submenu one level at a time (mirroring the tappable parent + // header) before closing the menu. Under predictive back + // (enableOnBackInvokedCallback) this stays correct: back reaches JS + // through always-registered OnBackPressedDispatcher callbacks (react-native + // core on Android 16+, withAndroidPredictiveBackCompat on 13-15), which + // also keeps the system from playing a "leave app" preview while the menu + // merely closes. + const submenuDepth = path.length; + useEffect(() => { + if (anchor === null) { + return; + } + const subscription = BackHandler.addEventListener("hardwareBackPress", () => { + if (submenuDepth > 0) { + setPath((current) => current.slice(0, -1)); + } else { + close(); + } + return true; + }); + return () => subscription.remove(); + }, [anchor, close, submenuDepth]); + + const parent = path.length > 0 ? path[path.length - 1] : null; + const levelActions = (parent?.subactions ?? props.actions).filter( + (action) => !(action.attributes?.hidden ?? false), + ); + + // Anchor in overlay-local coordinates (both measured in window space). + const local = + anchor === null || overlay === null + ? null + : { + x: anchor.x - overlay.x, + y: anchor.y - overlay.y, + width: anchor.width, + height: anchor.height, + }; + const preferredLeft = + local === null || overlay === null + ? 0 + : local.x + local.width / 2 <= overlay.width / 2 + ? local.x + : local.x + local.width - MENU_WIDTH; + const left = + overlay === null + ? 0 + : Math.min( + Math.max(preferredLeft, SCREEN_MARGIN), + overlay.width - MENU_WIDTH - SCREEN_MARGIN, + ); + // The keyboard stays up while the menu is open (in-window overlay, no + // focus change), so the space it covers is not usable — without this the + // composer-pill menus "open down" into the IME and can't be tapped. + const usableBottom = + overlay === null ? 0 : overlay.height - (keyboardVisible ? keyboardHeight : 0); + const spaceBelow = + local === null || overlay === null + ? 0 + : usableBottom - (local.y + local.height) - ANCHOR_GAP - SCREEN_MARGIN; + const spaceAbove = local === null ? 0 : local.y - ANCHOR_GAP - SCREEN_MARGIN; + const opensDown = spaceBelow >= 280 || spaceBelow >= spaceAbove; + const maxHeight = Math.min(opensDown ? spaceBelow : spaceAbove, 480); + // The menu needs the overlay frame before it can be placed; it stays + // unmounted for that first frame so the fade-in plays at the final position. + const placeable = local !== null && rootHeight !== null; + + const onPressItem = useCallback( + (action: MenuAction) => { + if ((action.subactions?.length ?? 0) > 0) { + setPath((current) => [...current, action]); + return; + } + close(); + if (action.id !== undefined) { + props.onPressAction?.({ + nativeEvent: { event: action.id }, + } as Parameters>[0]); + } + }, + [close, props.onPressAction], + ); + + return ( + <> + {typeof props.children === "function" ? ( + + {props.children(open)} + + ) : ( + + {props.children} + + )} + {anchor === null ? null : ( + + + + {!placeable || local === null ? null : ( + + {/* Frosted backdrop: blur of the app content behind the menu, + washed with the translucent card tone so rows keep contrast. */} + + + {/* keyboardShouldPersistTaps: the menu often opens over an + active editor; the first item tap must act, not just + dismiss the keyboard. */} + + {parent !== null ? ( + // Muted parent title as the submenu header; tapping it + // steps back, but it reads as a label, not a button. + setPath((current) => current.slice(0, -1))} + > + + {parent.title} + + + ) : props.title ? ( + <> + + + {props.title} + + + + + ) : null} + {levelActions.map((action, index) => { + const destructive = action.attributes?.destructive ?? false; + const disabled = action.attributes?.disabled ?? false; + const hasSubmenu = (action.subactions?.length ?? 0) > 0; + return ( + onPressItem(action)} + > + + + {action.title} + + {action.subtitle ? ( + + {action.subtitle} + + ) : null} + + {hasSubmenu ? ( + + ) : action.state === "on" ? ( + + ) : action.image ? ( + + ) : null} + + ); + })} + + + )} + + + )} + + ); +} diff --git a/apps/mobile/src/components/AndroidScreenHeader.tsx b/apps/mobile/src/components/AndroidScreenHeader.tsx new file mode 100644 index 00000000000..ef5319a1b6f --- /dev/null +++ b/apps/mobile/src/components/AndroidScreenHeader.tsx @@ -0,0 +1,117 @@ +import type { ReactNode } from "react"; +import { Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { SymbolView, type AppSymbolName } from "./AppSymbol"; +import { AppText as Text } from "./AppText"; +import { cn } from "../lib/cn"; +import { useThemeColor } from "../lib/useThemeColor"; + +export interface AndroidHeaderAction { + readonly accessibilityLabel: string; + readonly icon: AppSymbolName; + readonly onPress: () => void; + readonly disabled?: boolean; +} + +export function AndroidHeaderIconButton(props: { + readonly accessibilityLabel: string; + readonly icon: AppSymbolName; + readonly onPress?: () => void; + readonly disabled?: boolean; +}) { + const foregroundColor = useThemeColor("--color-foreground"); + const disabledColor = useThemeColor("--color-icon-subtle"); + + return ( + + + + ); +} + +export function AndroidScreenHeader(props: { + readonly title: string; + readonly subtitle?: string | null; + readonly actions?: ReadonlyArray; + readonly trailing?: ReactNode; + readonly onBack?: () => void; + readonly embedded?: boolean; +}) { + const insets = useSafeAreaInsets(); + const foregroundColor = useThemeColor("--color-foreground"); + + return ( + + + {props.onBack ? ( + + + + ) : null} + + + + {props.title} + + {props.subtitle ? ( + + {props.subtitle} + + ) : null} + + + {props.actions?.map((action) => ( + + ))} + {props.trailing} + + + ); +} + +export function AndroidSheetHeader( + props: Omit[0], "embedded">, +) { + return ; +} diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx new file mode 100644 index 00000000000..ba1dd59585e --- /dev/null +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -0,0 +1,204 @@ +import { + IconAdjustmentsHorizontal, + IconAlertCircle, + IconAlertTriangle, + IconArchive, + IconArrowBackUp, + IconArrowDownCircle, + IconArrowRight, + IconArrowRightCircle, + IconArrowUp, + IconArrowUpCircle, + IconArrowUpRight, + IconArrowUpRightCircle, + IconArrowsMaximize, + IconBellRinging, + IconBolt, + IconCamera, + IconCheck, + IconChevronDown, + IconCode, + IconChevronLeft, + IconChevronRight, + IconChevronUp, + IconCircleCheck, + IconCircleXFilled, + IconCopy, + IconDeviceDesktop, + IconDots, + IconDotsCircleHorizontal, + IconEdit, + IconExternalLink, + IconEye, + IconFileText, + IconFilter, + IconFolder, + IconFolderOpen, + IconFolderPlus, + IconGitBranch, + IconHammer, + IconGitMerge, + IconGitPullRequest, + IconInfoCircle, + IconKeyboard, + IconKeyboardHide, + IconLayoutColumns, + IconLayoutSidebar, + IconLetterSpacing, + IconLink, + IconMessage, + IconMinus, + IconNetwork, + IconPalette, + IconPlayerPlay, + IconPlayerStopFilled, + IconPlus, + IconQrcode, + IconRefresh, + IconSearch, + IconServer, + IconSettings, + IconSparkles, + IconLayoutSidebarRight, + IconTerminal2, + IconTextDecrease, + IconTextIncrease, + IconTool, + IconTrash, + IconTypography, + IconUserCircle, + IconWifiOff, + IconWorld, + IconX, + type Icon, +} from "@tabler/icons-react-native"; +import { Platform } from "react-native"; +import { SymbolView as ExpoSymbolView, type SFSymbol, type SymbolViewProps } from "expo-symbols"; + +const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { + "arrow.branch": IconGitBranch, + "arrow.clockwise": IconRefresh, + "arrow.down.circle": IconArrowDownCircle, + "arrow.right": IconArrowRight, + "arrow.right.circle": IconArrowRightCircle, + "arrow.triangle.branch": IconGitBranch, + "arrow.triangle.pull": IconGitPullRequest, + "arrow.turn.left.up": IconArrowBackUp, + "arrow.up": IconArrowUp, + "arrow.up.circle": IconArrowUpCircle, + "arrow.up.left.and.arrow.down.right": IconArrowsMaximize, + "arrow.up.right": IconArrowUpRight, + "arrow.up.right.circle": IconArrowUpRightCircle, + "arrow.uturn.backward": IconArrowBackUp, + archivebox: IconArchive, + "archivebox.fill": IconArchive, + "bell.badge": IconBellRinging, + "bolt.circle": IconBolt, + "bolt.horizontal.circle": IconBolt, + camera: IconCamera, + checkmark: IconCheck, + "checkmark.circle": IconCircleCheck, + "chevron.down": IconChevronDown, + "chevron.left": IconChevronLeft, + "chevron.left.forwardslash.chevron.right": IconCode, + "chevron.right": IconChevronRight, + "chevron.up": IconChevronUp, + desktopcomputer: IconDeviceDesktop, + "doc.on.doc": IconCopy, + "doc.text": IconFileText, + ellipsis: IconDots, + "ellipsis.circle": IconDotsCircleHorizontal, + "exclamationmark.triangle": IconAlertTriangle, + eye: IconEye, + folder: IconFolder, + "folder.badge.plus": IconFolderPlus, + "folder.fill": IconFolder, + gearshape: IconSettings, + "info.circle": IconInfoCircle, + link: IconLink, + "line.3.horizontal.decrease.circle": IconFilter, + "line.3.horizontal.decrease.circle.fill": IconFilter, + magnifyingglass: IconSearch, + paintbrush: IconPalette, + "person.crop.circle": IconUserCircle, + play: IconPlayerPlay, + plus: IconPlus, + "qrcode.viewfinder": IconQrcode, + "point.3.connected.trianglepath.dotted": IconNetwork, + "point.topleft.down.curvedto.point.bottomright.up": IconGitMerge, + safari: IconExternalLink, + "server.rack": IconServer, + "sidebar.left": IconLayoutSidebar, + "sidebar.right": IconLayoutSidebarRight, + "slider.horizontal.3": IconAdjustmentsHorizontal, + "square.and.pencil": IconEdit, + "square.split.2x1": IconLayoutColumns, + "stop.fill": IconPlayerStopFilled, + terminal: IconTerminal2, + "text.bubble": IconMessage, + "text.word.spacing": IconLetterSpacing, + "textformat.size": IconTypography, + "textformat.size.larger": IconTextIncrease, + "textformat.size.smaller": IconTextDecrease, + trash: IconTrash, + "wifi.slash": IconWifiOff, + xmark: IconX, + "xmark.circle.fill": IconCircleXFilled, +}; + +// Callers can pass `{ ios, android }` names where `android` is a Material +// icon name (the raw expo-symbols contract). Resolve those here too so the +// android key keeps working through this wrapper — it wins over the SF map +// when both match (e.g. folder vs folder_open for expanded project groups). +const ANDROID_ICON_BY_MATERIAL_NAME: Record = { + auto_awesome: IconSparkles, + bolt: IconBolt, + build: IconTool, + chat_bubble: IconMessage, + check: IconCheck, + close: IconX, + construction: IconHammer, + content_copy: IconCopy, + edit: IconEdit, + error: IconAlertCircle, + folder: IconFolder, + folder_open: IconFolderOpen, + keyboard: IconKeyboard, + keyboard_arrow_down: IconChevronDown, + keyboard_arrow_up: IconChevronUp, + keyboard_hide: IconKeyboardHide, + public: IconWorld, + remove: IconMinus, + terminal: IconTerminal2, + visibility: IconEye, +}; + +export type { SFSymbol } from "expo-symbols"; +export type AppSymbolName = SymbolViewProps["name"]; + +export function SymbolView(props: SymbolViewProps) { + if (Platform.OS !== "android") { + return ; + } + + const materialName = typeof props.name === "string" ? undefined : props.name.android; + const sfSymbol = typeof props.name === "string" ? props.name : props.name.ios; + const AndroidIcon = + (materialName ? ANDROID_ICON_BY_MATERIAL_NAME[materialName] : undefined) ?? + (sfSymbol ? ANDROID_ICON_BY_SF_SYMBOL[sfSymbol] : undefined); + + if (!AndroidIcon) { + return props.fallback ?? null; + } + + return ( + + ); +} diff --git a/apps/mobile/src/components/AppText.tsx b/apps/mobile/src/components/AppText.tsx index 33bffb1f8cd..39517f0e62e 100644 --- a/apps/mobile/src/components/AppText.tsx +++ b/apps/mobile/src/components/AppText.tsx @@ -4,7 +4,6 @@ import { type TextInputProps as RNTextInputProps, type TextProps as RNTextProps, } from "react-native"; -import { useThemeColor } from "../lib/useThemeColor"; import { cn } from "../lib/cn"; @@ -18,7 +17,7 @@ export function AppText({ className, ...props }: AppTextProps) { return ; } -export type AppTextInputProps = RNTextInputProps & { +export type AppTextInputProps = Omit & { readonly className?: string; readonly ref?: React.Ref; }; @@ -27,14 +26,7 @@ export type AppTextInputProps = RNTextInputProps & { * Thin wrapper around RN TextInput with default input styling. * Uses Uniwind className — no manual style parsing. */ -export function AppTextInput({ - className, - placeholderTextColor, - ref, - ...props -}: AppTextInputProps) { - const placeholderColor = useThemeColor("--color-placeholder"); - +export function AppTextInput({ className, ref, ...props }: AppTextInputProps) { return ( ); diff --git a/apps/mobile/src/components/BrandMark.tsx b/apps/mobile/src/components/BrandMark.tsx index 78aaf6b0ce4..c8f1d438551 100644 --- a/apps/mobile/src/components/BrandMark.tsx +++ b/apps/mobile/src/components/BrandMark.tsx @@ -1,13 +1,23 @@ -import { Image, View } from "react-native"; +import Constants from "expo-constants"; +import { Image } from "expo-image"; +import { View } from "react-native"; import { AppText as Text } from "./AppText"; -const BRAND_MARK_SOURCE = require("../../../../assets/dev/blueprint-ios-1024.png"); +const appVariant = Constants.expoConfig?.extra?.appVariant; +const BRAND_MARK_SOURCE = + appVariant === "development" + ? require("../../../../assets/dev/blueprint-ios-1024.png") + : appVariant === "preview" + ? require("../../../../assets/nightly/nightly-ios-1024.png") + : require("../../../../assets/prod/black-ios-1024.png"); +const DEFAULT_STAGE_LABEL = + appVariant === "development" ? "Dev" : appVariant === "preview" ? "Preview" : "Alpha"; export function BrandMark(props: { readonly compact?: boolean; readonly stageLabel?: string }) { const compact = props.compact ?? false; const iconSize = compact ? 32 : 44; - const stageLabel = props.stageLabel ?? "Alpha"; + const stageLabel = props.stageLabel ?? DEFAULT_STAGE_LABEL; return ( @@ -22,14 +32,9 @@ export function BrandMark(props: { readonly compact?: boolean; readonly stageLab /> - - T3 Code - + T3 Code - + {stageLabel} diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx new file mode 100644 index 00000000000..2ecf34aa40c --- /dev/null +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -0,0 +1,60 @@ +import { View } from "react-native"; + +import { AppText as Text } from "./AppText"; +import { T3Wordmark } from "./T3Wordmark"; +import { useThemeColor } from "../lib/useThemeColor"; + +/** + * Compact brand lockup sized for native navigation bars. + */ +export function CompactBrandTitle() { + const iconColor = useThemeColor("--color-icon"); + const mutedColor = useThemeColor("--color-foreground-muted"); + const subtleColor = useThemeColor("--color-subtle"); + + return ( + + + + Code + + + + Alpha + + + + ); +} + +export function renderCompactBrandTitle() { + return ; +} diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index 5b4d3d626c9..0621285c03e 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -1,4 +1,4 @@ -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../components/AppSymbol"; import { Image, Pressable, ScrollView, View } from "react-native"; import { useThemeColor } from "../lib/useThemeColor"; @@ -39,14 +39,14 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { horizontal showsHorizontalScrollIndicator={false} keyboardShouldPersistTaps="always" - style={{ flexGrow: 0 }} + className="grow-0" > - + {props.attachments.map((image) => ( props.onRemove(image.id)} diff --git a/apps/mobile/src/components/ComposerToolbarTrigger.tsx b/apps/mobile/src/components/ComposerToolbarTrigger.tsx index e054a13f697..20187624964 100644 --- a/apps/mobile/src/components/ComposerToolbarTrigger.tsx +++ b/apps/mobile/src/components/ComposerToolbarTrigger.tsx @@ -1,4 +1,3 @@ -import { SymbolView } from "expo-symbols"; import type { ComponentProps, ReactNode } from "react"; import { useCallback, useMemo, useState } from "react"; import { @@ -16,6 +15,7 @@ import { import { useThemeColor } from "../lib/useThemeColor"; import { cn } from "../lib/cn"; import { AppText as Text } from "./AppText"; +import { SymbolView } from "./AppSymbol"; export const COMPOSER_TOOLBAR_CONTROL_HEIGHT = 44; export const COMPOSER_TOOLBAR_GAP = 8; @@ -31,11 +31,9 @@ export function ComposerToolbarRow(props: { }) { return ( + ; }) { const isDarkMode = useColorScheme() === "dark"; @@ -183,7 +182,11 @@ export function ComposerToolbarButton(props: { disabled={props.disabled} onPress={props.onPress} className={cn( - "h-11 flex-row items-center justify-center rounded-full active:opacity-70", + // Default width cap lives in the class chain (not the inline style) + // so callers can lift it with max-w-full — flex-filling pills in the + // thread composer stretch to the row's edge. The numeric maxWidth + // prop still wins via the inline style below. + "h-11 max-w-[172px] flex-row items-center justify-center rounded-full active:opacity-70", isCircle ? "w-11" : "gap-2 px-3.5", variant === "primary" ? props.disabled @@ -194,6 +197,7 @@ export function ComposerToolbarButton(props: { : props.active ? "bg-subtle-strong" : "bg-subtle", + props.className, )} style={({ pressed }) => [ { @@ -204,7 +208,7 @@ export function ComposerToolbarButton(props: { : defaultBorderColor : filledBorderColor, borderWidth: 1, - maxWidth: props.maxWidth ?? 172, + maxWidth: props.maxWidth, minWidth: props.minWidth, opacity: props.disabled ? 0.55 : pressed ? 0.72 : 1, shadowColor: "#000", diff --git a/apps/mobile/src/components/ConfirmDialogHost.tsx b/apps/mobile/src/components/ConfirmDialogHost.tsx new file mode 100644 index 00000000000..81daa3d6a2d --- /dev/null +++ b/apps/mobile/src/components/ConfirmDialogHost.tsx @@ -0,0 +1,111 @@ +import { useCallback, useEffect, useState } from "react"; +import { Modal, Pressable, View } from "react-native"; + +import { useThemeColor } from "../lib/useThemeColor"; +import { cn } from "../lib/cn"; +import { AppText } from "./AppText"; + +export type ConfirmDialogRequest = { + readonly title: string; + readonly message?: string; + readonly cancelText?: string; + readonly confirmText: string; + readonly destructive?: boolean; + readonly onConfirm: () => void; + readonly onCancel?: () => void; +}; + +let presentRequest: ((request: ConfirmDialogRequest) => void) | null = null; + +/** + * Imperative confirm dialog, Alert.alert-shaped. Native iOS alerts already + * match the app (and support per-button destructive red), so this is for + * Android, where the native dialog can only theme all confirm buttons at + * once. Requires ConfirmDialogHost to be mounted at the app root. + */ +export function showConfirmDialog(request: ConfirmDialogRequest): void { + presentRequest?.(request); +} + +/** + * Android-style alert dialog matching the native one themed by + * withAndroidModernAlertDialog — left-aligned text, right-aligned text + * buttons — with what the native theme can't do: a per-dialog destructive + * button color and a dimmer message than the title. + */ +export function ConfirmDialogHost() { + const [request, setRequest] = useState(null); + const pressedOverlay = useThemeColor("--color-subtle"); + + useEffect(() => { + presentRequest = setRequest; + return () => { + presentRequest = null; + }; + }, []); + + const handleCancel = useCallback(() => { + request?.onCancel?.(); + setRequest(null); + }, [request]); + + const handleConfirm = useCallback(() => { + request?.onConfirm(); + setRequest(null); + }, [request]); + + return ( + + {request === null ? null : ( + + + {request.title} + {request.message === undefined ? null : ( + + {request.message} + + )} + + + + + {request.cancelText ?? "Cancel"} + + + + + + + {request.confirmText} + + + + + + + )} + + ); +} diff --git a/apps/mobile/src/components/ControlPill.tsx b/apps/mobile/src/components/ControlPill.tsx index 670c17fbfbf..31e95cf9dbb 100644 --- a/apps/mobile/src/components/ControlPill.tsx +++ b/apps/mobile/src/components/ControlPill.tsx @@ -1,10 +1,18 @@ import { MenuView } from "@react-native-menu/menu"; -import type { ComponentProps, ReactNode } from "react"; -import { Pressable, useColorScheme, View } from "react-native"; -import { SymbolView } from "expo-symbols"; +import * as Haptics from "expo-haptics"; +import { + cloneElement, + isValidElement, + type ComponentProps, + type ReactElement, + type ReactNode, +} from "react"; +import { Platform, Pressable, useColorScheme, View } from "react-native"; import { useThemeColor } from "../lib/useThemeColor"; import { cn } from "../lib/cn"; +import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu"; +import { SymbolView } from "./AppSymbol"; import { AppText as Text } from "./AppText"; export function ControlPill(props: { @@ -74,16 +82,60 @@ export function ControlPill(props: { ); } +// iOS renders the native UIMenu (standard checkmark for `state: "on"`); +// Android renders the token-styled AndroidAnchoredMenu, since the native +// AppCompat popup can't be themed past its stock animation, metrics, and +// submenu chrome. export function ControlPillMenu( props: Omit, "children" | "themeVariant"> & { readonly children: ReactNode; + readonly className?: string; }, ) { const isDarkMode = useColorScheme() === "dark"; + if (Platform.OS === "android") { + // Long-press menus keep their child interactive: the child element gets + // an injected onLongPress (mirroring the iOS context-menu interaction) + // so its own tap handling still works. + if (props.shouldOpenOnLongPress && isValidElement(props.children)) { + const child = props.children as ReactElement<{ onLongPress?: () => void }>; + return ( + + {(open) => + cloneElement(child, { + onLongPress: () => { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + open(); + }, + }) + } + + ); + } + return ( + + {props.children} + + ); + } + + const { className: _className, ...menuProps } = props; return ( - - {props.children} + + {menuProps.children} ); } diff --git a/apps/mobile/src/components/CopyTextButton.tsx b/apps/mobile/src/components/CopyTextButton.tsx index 712b272a909..7f4e060eda0 100644 --- a/apps/mobile/src/components/CopyTextButton.tsx +++ b/apps/mobile/src/components/CopyTextButton.tsx @@ -1,4 +1,4 @@ -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../components/AppSymbol"; import { memo, useEffect, useRef, useState } from "react"; import { Pressable, type ColorValue } from "react-native"; @@ -58,7 +58,11 @@ export const CopyTextButton = memo(function CopyTextButton(props: { })} > - {leftSlot} - - {centerSlot} - - {rightSlot} + {leftSlot} + {centerSlot} + {rightSlot} diff --git a/apps/mobile/src/components/GlassSurface.tsx b/apps/mobile/src/components/GlassSurface.tsx index 03311e3b7e9..f34bd4e2836 100644 --- a/apps/mobile/src/components/GlassSurface.tsx +++ b/apps/mobile/src/components/GlassSurface.tsx @@ -10,7 +10,7 @@ import { } from "react-native"; import { useThemeColor } from "../lib/useThemeColor"; -export interface GlassSurfaceProps extends ViewProps { +export interface GlassSurfaceProps extends Omit { readonly children: ReactNode; readonly glassEffectStyle?: "clear" | "regular" | "none"; readonly tintColor?: ColorValue; diff --git a/apps/mobile/src/components/OverlayPortal.tsx b/apps/mobile/src/components/OverlayPortal.tsx new file mode 100644 index 00000000000..747595174ff --- /dev/null +++ b/apps/mobile/src/components/OverlayPortal.tsx @@ -0,0 +1,69 @@ +import { type ReactNode, useEffect, useRef, useState } from "react"; +import { View } from "react-native"; + +// Minimal in-tree portal for Android overlays. AndroidAnchoredMenu projects +// its dropdown here instead of into an RN Modal: a Modal is a separate native +// window, so presenting one moves window focus and closes the soft keyboard — +// which matters for menus anchored to the keyboard-sticky composer pills. +type Entries = ReadonlyMap; +type Listener = (entries: Entries) => void; + +let nextKey = 0; +const entries = new Map(); +const listeners = new Set(); + +function emit() { + const snapshot = new Map(entries); + for (const listener of listeners) { + listener(snapshot); + } +} + +/** Projects children into the app-root OverlayPortalHost. */ +export function OverlayPortal(props: { readonly children: ReactNode }) { + const keyRef = useRef(null); + keyRef.current ??= nextKey++; + const key = keyRef.current; + + // No dependency array: re-project after every render so the host always + // shows the current content (menus re-render while open — drill-in, theme). + useEffect(() => { + entries.set(key, props.children); + emit(); + }); + + useEffect( + () => () => { + entries.delete(key); + emit(); + }, + [key], + ); + + return null; +} + +/** Mounted once at the app root, above the navigation container. */ +export function OverlayPortalHost() { + const [current, setCurrent] = useState(() => new Map()); + + useEffect(() => { + listeners.add(setCurrent); + return () => { + listeners.delete(setCurrent); + }; + }, []); + + if (current.size === 0) { + return null; + } + return ( + + {[...current.entries()].map(([key, node]) => ( + + {node} + + ))} + + ); +} diff --git a/apps/mobile/src/components/PierreEntryIcon.tsx b/apps/mobile/src/components/PierreEntryIcon.tsx index 15ea24331b0..fa79c4f60e8 100644 --- a/apps/mobile/src/components/PierreEntryIcon.tsx +++ b/apps/mobile/src/components/PierreEntryIcon.tsx @@ -1,4 +1,4 @@ -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../components/AppSymbol"; import { Image, type ImageStyle, type StyleProp } from "react-native"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index ba306c5a9fe..772d5e8cc14 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -1,7 +1,9 @@ -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "./AppSymbol"; +import { Image } from "expo-image"; import { useState } from "react"; -import { Image, View } from "react-native"; +import { View } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; +import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; import { useThemeColor } from "../lib/useThemeColor"; import { useAssetUrl } from "../state/assets"; @@ -11,6 +13,7 @@ const loadedFaviconUrls = new Set(); /* ─── Component ──────────────────────────────────────────────────────── */ export function ProjectFavicon(props: { readonly environmentId: EnvironmentId; + readonly open?: boolean; readonly size?: number; readonly projectTitle: string; readonly workspaceRoot?: string | null; @@ -22,11 +25,13 @@ export function ProjectFavicon(props: { ? null : { _tag: "project-favicon", cwd: props.workspaceRoot }, ); + const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; return ( @@ -35,6 +40,7 @@ export function ProjectFavicon(props: { function ProjectFaviconImage(props: { readonly faviconUrl: string | null; + readonly open?: boolean; readonly projectTitle: string; readonly size: number; }) { @@ -58,7 +64,7 @@ function ProjectFaviconImage(props: { {/* Folder icon fallback (matches web's FolderIcon) */} {!showImage ? ( { if (props.faviconUrl) loadedFaviconUrls.add(props.faviconUrl); setStatus("loaded"); diff --git a/apps/mobile/src/components/T3Wordmark.tsx b/apps/mobile/src/components/T3Wordmark.tsx new file mode 100644 index 00000000000..81106557c66 --- /dev/null +++ b/apps/mobile/src/components/T3Wordmark.tsx @@ -0,0 +1,23 @@ +import type { ColorValue } from "react-native"; +import Svg, { Path } from "react-native-svg"; + +/** + * The "T3" brand mark, matching the desktop sidebar's T3Wordmark SVG + * (apps/web Sidebar.tsx). Width derives from the viewBox aspect ratio. + */ +export function T3Wordmark(props: { readonly height: number; readonly color: ColorValue }) { + const aspectRatio = 94.3941 / 56.96; + return ( + + + + ); +} diff --git a/apps/mobile/src/connection/catalog-store.ts b/apps/mobile/src/connection/catalog-store.ts index b5bda400670..6a4fcd35f6d 100644 --- a/apps/mobile/src/connection/catalog-store.ts +++ b/apps/mobile/src/connection/catalog-store.ts @@ -10,6 +10,7 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; +import * as MobileSecureStorage from "../persistence/mobile-secure-storage"; import { migrateLegacyConnectionCatalog } from "./migration"; export const CONNECTION_CATALOG_KEY = "t3code.connection-catalog.v1"; @@ -22,23 +23,22 @@ function catalogError(operation: string, cause: unknown) { }); } +const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument); +const decodeConnectionCatalogDocument = Schema.decodeEffect(ConnectionCatalogDocumentJson); +const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson); + const decodeCatalog = Effect.fn("mobile.connectionStorage.decodeCatalog")(function* (raw: string) { - const parsed = yield* Effect.try({ - try: () => JSON.parse(raw) as unknown, - catch: (cause) => catalogError("decode", cause), - }); - return yield* Effect.fromResult( - Schema.decodeUnknownResult(ConnectionCatalogDocument)(parsed), - ).pipe(Effect.mapError((cause) => catalogError("decode", cause))); + return yield* decodeConnectionCatalogDocument(raw).pipe( + Effect.mapError((cause) => catalogError("decode", cause)), + ); }); const encodeCatalog = Effect.fn("mobile.connectionStorage.encodeCatalog")(function* ( catalog: ConnectionCatalogDocumentType, ) { - const encoded = yield* Effect.fromResult( - Schema.encodeUnknownResult(ConnectionCatalogDocument)(catalog), - ).pipe(Effect.mapError((cause) => catalogError("encode", cause))); - return JSON.stringify(encoded); + return yield* encodeConnectionCatalogDocument(catalog).pipe( + Effect.mapError((cause) => catalogError("encode", cause)), + ); }); interface CatalogStore { @@ -48,20 +48,19 @@ interface CatalogStore { ) => Effect.Effect; } -export interface SecureCatalogStorage { - readonly getItem: (key: string) => Effect.Effect; - readonly setItem: (key: string, value: string) => Effect.Effect; - readonly deleteItem: (key: string) => Effect.Effect; -} - -export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogStore")(function* ( - storage: SecureCatalogStorage, -) { +export const make = Effect.fn("mobile.connectionStorage.makeCatalogStore")(function* () { + const storage = yield* MobileSecureStorage.MobileSecureStorage; + const getItem = (key: string) => + storage.getItem(key).pipe(Effect.mapError((cause) => catalogError("load", cause))); + const setItem = (key: string, value: string) => + storage.setItem(key, value).pipe(Effect.mapError((cause) => catalogError("save", cause))); + const deleteItem = (key: string) => + storage.removeItem(key).pipe(Effect.mapError((cause) => catalogError("delete", cause))); const state = yield* Ref.make>(Option.none()); const lock = yield* Semaphore.make(1); const loadLegacyCatalog = Effect.fn("mobile.connectionStorage.loadLegacyCatalog")(function* () { - const legacyRaw = yield* storage.getItem(LEGACY_CONNECTIONS_KEY); + const legacyRaw = yield* getItem(LEGACY_CONNECTIONS_KEY); const catalog = legacyRaw === null || legacyRaw.trim() === "" ? EMPTY_CONNECTION_CATALOG_DOCUMENT @@ -75,8 +74,8 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS ); if (legacyRaw !== null && legacyRaw.trim() !== "") { const encoded = yield* encodeCatalog(catalog); - yield* storage.setItem(CONNECTION_CATALOG_KEY, encoded); - yield* storage.deleteItem(LEGACY_CONNECTIONS_KEY); + yield* setItem(CONNECTION_CATALOG_KEY, encoded); + yield* deleteItem(LEGACY_CONNECTIONS_KEY); } return catalog; }); @@ -86,13 +85,13 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS if (Option.isSome(cached)) { return cached.value; } - const raw = yield* storage.getItem(CONNECTION_CATALOG_KEY); + const raw = yield* getItem(CONNECTION_CATALOG_KEY); let catalog: ConnectionCatalogDocumentType; if (raw !== null && raw.trim() !== "") { catalog = yield* decodeCatalog(raw).pipe( Effect.catch((error) => Effect.logWarning("Discarding corrupt mobile connection catalog", error).pipe( - Effect.andThen(storage.deleteItem(CONNECTION_CATALOG_KEY)), + Effect.andThen(deleteItem(CONNECTION_CATALOG_KEY)), Effect.andThen(loadLegacyCatalog()), ), ), @@ -111,7 +110,7 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS Effect.gen(function* () { const next = transform(yield* loadUnlocked()); const encoded = yield* encodeCatalog(next); - yield* storage.setItem(CONNECTION_CATALOG_KEY, encoded); + yield* setItem(CONNECTION_CATALOG_KEY, encoded); yield* Ref.set(state, Option.some(next)); }), ); diff --git a/apps/mobile/src/connection/environment-cache-store.test.ts b/apps/mobile/src/connection/environment-cache-store.test.ts new file mode 100644 index 00000000000..0caf2b41592 --- /dev/null +++ b/apps/mobile/src/connection/environment-cache-store.test.ts @@ -0,0 +1,97 @@ +import { EnvironmentId, type VcsListRefsResult } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database"; +import { make } from "./environment-cache-store"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +const REFS: VcsListRefsResult = { + refs: [ + { + name: "main", + current: true, + isDefault: true, + worktreePath: "/repo", + }, + ], + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: 1, +}; + +function cacheId(environmentId: EnvironmentId, kind: ClientCacheKind, cacheKey: string) { + return `${environmentId}:${kind}:${cacheKey}`; +} + +function makeDatabase() { + const values = new Map(); + const removed: Array = []; + const database = MobileDatabase.of({ + loadCache: (environmentId, kind, cacheKey) => + Effect.succeed(Option.fromUndefinedOr(values.get(cacheId(environmentId, kind, cacheKey)))), + saveCache: (environmentId, kind, cacheKey, _schemaVersion, payload) => + Effect.sync(() => { + values.set(cacheId(environmentId, kind, cacheKey), payload); + }), + removeCache: (environmentId, kind, cacheKey) => + Effect.sync(() => { + const id = cacheId(environmentId, kind, cacheKey); + removed.push(id); + values.delete(id); + }), + clearEnvironmentCache: (environmentId) => + Effect.sync(() => { + for (const key of values.keys()) { + if (key.startsWith(`${environmentId}:`)) values.delete(key); + } + }), + clearAllCaches: Effect.sync(() => values.clear()), + inspectCaches: Effect.succeed([]), + loadPreferencesJson: Effect.succeed(Option.none()), + savePreferencesJson: () => Effect.void, + }); + return { database, removed, values }; +} + +describe("mobile SQLite environment cache store", () => { + it.effect("round-trips schema-validated VCS refs", () => + Effect.gen(function* () { + const memory = makeDatabase(); + const store = yield* make().pipe(Effect.provideService(MobileDatabase, memory.database)); + + yield* store.saveVcsRefs(ENVIRONMENT_ID, "/repo", REFS); + + expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.some(REFS)); + }), + ); + + it.effect("deletes a corrupt cache record and treats it as a miss", () => + Effect.gen(function* () { + const memory = makeDatabase(); + const store = yield* make().pipe(Effect.provideService(MobileDatabase, memory.database)); + const id = cacheId(ENVIRONMENT_ID, "vcs-refs", "/repo"); + memory.values.set(id, "{not-json"); + + expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.none()); + expect(memory.removed).toEqual([id]); + }), + ); + + it.effect("clears one environment without touching another", () => + Effect.gen(function* () { + const memory = makeDatabase(); + const store = yield* make().pipe(Effect.provideService(MobileDatabase, memory.database)); + const otherEnvironmentId = EnvironmentId.make("environment-2"); + yield* store.saveVcsRefs(ENVIRONMENT_ID, "/repo", REFS); + yield* store.saveVcsRefs(otherEnvironmentId, "/repo", REFS); + + yield* store.clear(ENVIRONMENT_ID); + + expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.none()); + expect(yield* store.loadVcsRefs(otherEnvironmentId, "/repo")).toEqual(Option.some(REFS)); + }), + ); +}); diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts new file mode 100644 index 00000000000..a39ef5e36cb --- /dev/null +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -0,0 +1,220 @@ +import { + ConnectionPersistenceError, + EnvironmentCacheStore, + ORCHESTRATION_CACHE_SCHEMA_VERSION, + StoredOrchestrationShellSnapshot, + StoredOrchestrationThreadSnapshot, +} from "@t3tools/client-runtime/platform"; +import { type EnvironmentId, ServerConfig, VcsListRefsResult } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as MobileDatabase from "../persistence/mobile-database"; + +const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1; +const VCS_REFS_CACHE_SCHEMA_VERSION = 1; + +const StoredShellSnapshot = StoredOrchestrationShellSnapshot; +const StoredThreadSnapshot = StoredOrchestrationThreadSnapshot; +const StoredServerConfig = Schema.Struct({ + schemaVersion: Schema.Literal(SERVER_CONFIG_CACHE_SCHEMA_VERSION), + environmentId: Schema.String, + config: ServerConfig, +}); +const StoredVcsRefs = Schema.Struct({ + schemaVersion: Schema.Literal(VCS_REFS_CACHE_SCHEMA_VERSION), + environmentId: Schema.String, + cwd: Schema.String, + refs: VcsListRefsResult, +}); + +const decodeStoredShellSnapshot = Schema.decodeUnknownEffect( + Schema.fromJsonString(StoredShellSnapshot), +); +const encodeStoredShellSnapshot = Schema.encodeEffect(Schema.fromJsonString(StoredShellSnapshot)); +const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect( + Schema.fromJsonString(StoredThreadSnapshot), +); +const encodeStoredThreadSnapshot = Schema.encodeEffect(Schema.fromJsonString(StoredThreadSnapshot)); +const decodeStoredServerConfig = Schema.decodeUnknownEffect( + Schema.fromJsonString(StoredServerConfig), +); +const encodeStoredServerConfig = Schema.encodeEffect(Schema.fromJsonString(StoredServerConfig)); +const decodeStoredVcsRefs = Schema.decodeUnknownEffect(Schema.fromJsonString(StoredVcsRefs)); +const encodeStoredVcsRefs = Schema.encodeEffect(Schema.fromJsonString(StoredVcsRefs)); + +type CacheOperation = ConnectionPersistenceError["operation"]; + +function persistenceError(operation: CacheOperation, cause: unknown) { + return new ConnectionPersistenceError({ + operation, + message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`, + }); +} + +function mapDatabaseError(operation: CacheOperation) { + return (error: MobileDatabase.MobileDatabaseError) => persistenceError(operation, error); +} + +function loadDecodedCache(input: { + readonly database: MobileDatabase.MobileDatabase["Service"]; + readonly environmentId: EnvironmentId; + readonly kind: MobileDatabase.ClientCacheKind; + readonly cacheKey: string; + readonly operation: CacheOperation; + readonly decode: (raw: string) => Effect.Effect; + readonly select: (value: A) => Option.Option; +}): Effect.Effect, ConnectionPersistenceError> { + return input.database.loadCache(input.environmentId, input.kind, input.cacheKey).pipe( + Effect.mapError(mapDatabaseError(input.operation)), + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(Option.none()), + onSome: (raw) => + input.decode(raw).pipe( + Effect.map(input.select), + Effect.catch((cause) => + Effect.logWarning("Discarding corrupt mobile client cache record.", { + environmentId: input.environmentId, + kind: input.kind, + cacheKey: input.cacheKey, + cause: String(cause), + }).pipe( + Effect.andThen( + input.database + .removeCache(input.environmentId, input.kind, input.cacheKey) + .pipe(Effect.catch(() => Effect.void)), + ), + Effect.as(Option.none()), + ), + ), + ), + }), + ), + ); +} + +export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { + const database = yield* MobileDatabase.MobileDatabase; + return EnvironmentCacheStore.of({ + loadShell: Effect.fn("MobileEnvironmentCache.loadShell")((environmentId) => + loadDecodedCache({ + database, + environmentId, + kind: "shell", + cacheKey: "snapshot", + operation: "load-shell", + decode: decodeStoredShellSnapshot, + select: (stored) => + stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(), + }), + ), + saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { + const payload = yield* encodeStoredShellSnapshot({ + schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, + environmentId, + snapshot, + }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); + yield* database + .saveCache(environmentId, "shell", "snapshot", ORCHESTRATION_CACHE_SCHEMA_VERSION, payload) + .pipe(Effect.mapError(mapDatabaseError("save-shell"))); + }), + loadThread: Effect.fn("MobileEnvironmentCache.loadThread")((environmentId, threadId) => + loadDecodedCache({ + database, + environmentId, + kind: "thread", + cacheKey: threadId, + operation: "load-thread", + decode: decodeStoredThreadSnapshot, + select: (stored) => + stored.environmentId === environmentId && stored.threadId === threadId + ? Option.some(stored.snapshot) + : Option.none(), + }), + ), + saveThread: Effect.fn("MobileEnvironmentCache.saveThread")(function* (environmentId, snapshot) { + const threadId = snapshot.projection.thread.id; + const payload = yield* encodeStoredThreadSnapshot({ + schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, + environmentId, + threadId, + snapshot, + }).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause))); + yield* database + .saveCache(environmentId, "thread", threadId, ORCHESTRATION_CACHE_SCHEMA_VERSION, payload) + .pipe(Effect.mapError(mapDatabaseError("save-thread"))); + }), + removeThread: Effect.fn("MobileEnvironmentCache.removeThread")((environmentId, threadId) => + database + .removeCache(environmentId, "thread", threadId) + .pipe(Effect.mapError(mapDatabaseError("remove-thread"))), + ), + loadServerConfig: Effect.fn("MobileEnvironmentCache.loadServerConfig")((environmentId) => + loadDecodedCache({ + database, + environmentId, + kind: "server-config", + cacheKey: "config", + operation: "load-server-config", + decode: decodeStoredServerConfig, + select: (stored) => + stored.environmentId === environmentId ? Option.some(stored.config) : Option.none(), + }), + ), + saveServerConfig: Effect.fn("MobileEnvironmentCache.saveServerConfig")( + function* (environmentId, config) { + const payload = yield* encodeStoredServerConfig({ + schemaVersion: SERVER_CONFIG_CACHE_SCHEMA_VERSION, + environmentId, + config, + }).pipe(Effect.mapError((cause) => persistenceError("save-server-config", cause))); + yield* database + .saveCache( + environmentId, + "server-config", + "config", + SERVER_CONFIG_CACHE_SCHEMA_VERSION, + payload, + ) + .pipe(Effect.mapError(mapDatabaseError("save-server-config"))); + }, + ), + loadVcsRefs: Effect.fn("MobileEnvironmentCache.loadVcsRefs")((environmentId, cwd) => + loadDecodedCache({ + database, + environmentId, + kind: "vcs-refs", + cacheKey: cwd, + operation: "load-vcs-refs", + decode: decodeStoredVcsRefs, + select: (stored) => + stored.environmentId === environmentId && stored.cwd === cwd + ? Option.some(stored.refs) + : Option.none(), + }), + ), + saveVcsRefs: Effect.fn("MobileEnvironmentCache.saveVcsRefs")( + function* (environmentId, cwd, refs) { + const payload = yield* encodeStoredVcsRefs({ + schemaVersion: VCS_REFS_CACHE_SCHEMA_VERSION, + environmentId, + cwd, + refs, + }).pipe(Effect.mapError((cause) => persistenceError("save-vcs-refs", cause))); + yield* database + .saveCache(environmentId, "vcs-refs", cwd, VCS_REFS_CACHE_SCHEMA_VERSION, payload) + .pipe(Effect.mapError(mapDatabaseError("save-vcs-refs"))); + }, + ), + clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) => + database + .clearEnvironmentCache(environmentId) + .pipe(Effect.mapError(mapDatabaseError("clear-environment"))), + ), + }); +}); + +export const layer = Layer.effect(EnvironmentCacheStore, make()); diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index fd3c1530392..cc51b56ee70 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -25,7 +25,8 @@ import * as Network from "expo-network"; import { AppState } from "react-native"; import { authClientMetadata } from "../lib/authClientMetadata"; -import { loadOrCreateAgentAwarenessDeviceId } from "../lib/storage"; +import * as Runtime from "../lib/runtime"; +import * as MobileStorage from "../persistence/mobile-storage"; import { appAtomRegistry } from "../state/atom-registry"; import { clearThreadOutboxEnvironment } from "../state/thread-outbox"; import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; @@ -83,82 +84,87 @@ const wakeupsLayer = Wakeups.layer({ ), }); -const capabilitiesLayer = Layer.succeedContext( - Context.make( - CloudSession, - CloudSession.of({ - clerkToken: Effect.gen(function* () { - const session = appAtomRegistry.get(managedRelaySessionAtom); - if (session === null) { - return yield* new ConnectionBlockedError({ - reason: "authentication", - detail: "Sign in to T3 Connect to connect this environment.", - }); - } - const token = yield* session.readClerkToken().pipe( - Effect.mapError( - (error) => - new ConnectionTransientError({ - reason: "network", - detail: error.message, - }), - ), - ); - if (token === null) { - return yield* new ConnectionBlockedError({ - reason: "authentication", - detail: "The T3 Connect session is unavailable.", - }); - } - return token; - }), - }), - ).pipe( - Context.add( - PrimaryEnvironmentAuth, - PrimaryEnvironmentAuth.of({ bearerToken: Effect.succeed(Option.none()) }), - ), - Context.add( - RelayDeviceIdentity, - RelayDeviceIdentity.of({ - deviceId: Effect.tryPromise({ - try: () => loadOrCreateAgentAwarenessDeviceId(), - catch: (cause) => - new ConnectionTransientError({ - reason: "remote-unavailable", - detail: `Could not load the mobile device identity: ${String(cause)}`, - }), - }).pipe(Effect.map(Option.some)), - }), - ), - Context.add( - ClientPresentation, - ClientPresentation.of({ - metadata: authClientMetadata(), - scopes: AuthStandardClientScopes, +const capabilitiesLayer = Layer.effectContext( + Effect.gen(function* () { + const storage = yield* MobileStorage.MobileStorage; + return Context.make( + CloudSession, + CloudSession.of({ + clerkToken: Effect.gen(function* () { + const session = appAtomRegistry.get(managedRelaySessionAtom); + if (session === null) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + detail: "Sign in to T3 Connect to connect this environment.", + }); + } + const token = yield* session.readClerkToken().pipe( + Effect.mapError( + (error) => + new ConnectionTransientError({ + reason: "network", + detail: error.message, + }), + ), + ); + if (token === null) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + detail: "The T3 Connect session is unavailable.", + }); + } + return token; + }), }), - ), - Context.add( - SshEnvironmentGateway, - SshEnvironmentGateway.of({ - provision: () => - Effect.fail( - new ConnectionBlockedError({ - reason: "unsupported", - detail: "SSH environments are only available in the desktop app.", - }), - ), - prepare: () => - Effect.fail( - new ConnectionBlockedError({ - reason: "unsupported", - detail: "SSH environments are only available in the desktop app.", - }), + ).pipe( + Context.add( + PrimaryEnvironmentAuth, + PrimaryEnvironmentAuth.of({ bearerToken: Effect.succeed(Option.none()) }), + ), + Context.add( + RelayDeviceIdentity, + RelayDeviceIdentity.of({ + deviceId: storage.loadOrCreateAgentAwarenessDeviceId.pipe( + Effect.mapError( + (cause) => + new ConnectionTransientError({ + reason: "remote-unavailable", + detail: `Could not load the mobile device identity: ${String(cause)}`, + }), + ), + Effect.map(Option.some), ), - disconnect: () => Effect.void, - }), - ), - ), + }), + ), + Context.add( + ClientPresentation, + ClientPresentation.of({ + metadata: authClientMetadata(), + scopes: AuthStandardClientScopes, + }), + ), + Context.add( + SshEnvironmentGateway, + SshEnvironmentGateway.of({ + provision: () => + Effect.fail( + new ConnectionBlockedError({ + reason: "unsupported", + detail: "SSH environments are only available in the desktop app.", + }), + ), + prepare: () => + Effect.fail( + new ConnectionBlockedError({ + reason: "unsupported", + detail: "SSH environments are only available in the desktop app.", + }), + ), + disconnect: () => Effect.void, + }), + ), + ); + }), ); const platformConnectionSourceLayer = Layer.succeed( @@ -168,6 +174,13 @@ const platformConnectionSourceLayer = Layer.succeed( }), ); +const providedConnectionStorageLayer = connectionStorageLayer.pipe( + Layer.provide(Runtime.runtimeContextLayer), +); +const providedCapabilitiesLayer = capabilitiesLayer.pipe( + Layer.provide(Runtime.runtimeContextLayer), +); + const environmentOwnedDataCleanupLayer = Layer.succeed( EnvironmentOwnedDataCleanup, EnvironmentOwnedDataCleanup.of({ @@ -190,10 +203,11 @@ const environmentOwnedDataCleanupLayer = Layer.succeed( ); type ConnectionPlatformLayerSource = - | typeof connectionStorageLayer + | typeof providedConnectionStorageLayer + | typeof Runtime.runtimeContextLayer | typeof connectivityLayer | typeof wakeupsLayer - | typeof capabilitiesLayer + | typeof providedCapabilitiesLayer | typeof platformConnectionSourceLayer | typeof environmentOwnedDataCleanupLayer; @@ -202,10 +216,11 @@ export const connectionPlatformLayer: Layer.Layer< Layer.Error, Layer.Services > = Layer.mergeAll( - connectionStorageLayer, + providedConnectionStorageLayer, + Runtime.runtimeContextLayer, connectivityLayer, wakeupsLayer, - capabilitiesLayer, + providedCapabilitiesLayer, platformConnectionSourceLayer, environmentOwnedDataCleanupLayer, ); diff --git a/apps/mobile/src/connection/storage.test.ts b/apps/mobile/src/connection/storage.test.ts index 031c152e659..34ba6ed850a 100644 --- a/apps/mobile/src/connection/storage.test.ts +++ b/apps/mobile/src/connection/storage.test.ts @@ -1,28 +1,35 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import { vi } from "vite-plus/test"; -import { - CONNECTION_CATALOG_KEY, - LEGACY_CONNECTIONS_KEY, - makeCatalogStore, - type SecureCatalogStorage, -} from "./catalog-store"; +vi.mock("react-native", () => ({ + Platform: { OS: "ios" }, +})); + +vi.mock("expo-secure-store", () => ({ + deleteItemAsync: vi.fn(), + getItemAsync: vi.fn(), + setItemAsync: vi.fn(), +})); + +import { CONNECTION_CATALOG_KEY, LEGACY_CONNECTIONS_KEY, make } from "./catalog-store"; +import { MobileSecureStorage } from "../persistence/mobile-secure-storage"; function makeStorage(initial: Readonly>) { const values = new Map(Object.entries(initial)); const deleted: Array = []; - const storage: SecureCatalogStorage = { + const storage = MobileSecureStorage.of({ getItem: (key) => Effect.sync(() => values.get(key) ?? null), setItem: (key, value) => Effect.sync(() => { values.set(key, value); }), - deleteItem: (key) => + removeItem: (key) => Effect.sync(() => { deleted.push(key); values.delete(key); }), - }; + }); return { deleted, storage, values }; } @@ -32,7 +39,9 @@ describe("mobile connection catalog storage", () => { const memory = makeStorage({ [CONNECTION_CATALOG_KEY]: "{not-json", }); - const catalog = yield* makeCatalogStore(memory.storage); + const catalog = yield* make().pipe( + Effect.provideService(MobileSecureStorage, memory.storage), + ); expect((yield* catalog.read).targets).toEqual([]); expect(memory.deleted).toEqual([CONNECTION_CATALOG_KEY]); @@ -44,7 +53,9 @@ describe("mobile connection catalog storage", () => { const memory = makeStorage({ [LEGACY_CONNECTIONS_KEY]: JSON.stringify({ connections: [{ invalid: true }] }), }); - const catalog = yield* makeCatalogStore(memory.storage); + const catalog = yield* make().pipe( + Effect.provideService(MobileSecureStorage, memory.storage), + ); expect((yield* catalog.read).targets).toEqual([]); expect(memory.deleted).toEqual([LEGACY_CONNECTIONS_KEY]); @@ -71,7 +82,9 @@ describe("mobile connection catalog storage", () => { ], }), }); - const catalog = yield* makeCatalogStore(memory.storage); + const catalog = yield* make().pipe( + Effect.provideService(MobileSecureStorage, memory.storage), + ); expect((yield* catalog.read).targets).toHaveLength(1); expect(memory.deleted).toEqual([CONNECTION_CATALOG_KEY, LEGACY_CONNECTIONS_KEY]); diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts index 13882a96580..e844181673d 100644 --- a/apps/mobile/src/connection/storage.ts +++ b/apps/mobile/src/connection/storage.ts @@ -2,11 +2,6 @@ import { ConnectionPersistenceError, ConnectionRegistrationStore, ConnectionTargetStore, - EnvironmentCacheStore, - ORCHESTRATION_CACHE_SCHEMA_VERSION, - StoredOrchestrationShellSnapshot, - StoredOrchestrationThreadSnapshot, - decodeOrDiscardOrchestrationCache, registerConnectionInCatalog, removeConnectionFromCatalog, removeCatalogValue, @@ -18,88 +13,11 @@ import { CredentialStore, ProfileStore, } from "@t3tools/client-runtime/connection"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as SecureStore from "expo-secure-store"; - -import { makeCatalogStore, type SecureCatalogStorage } from "./catalog-store"; - -const SHELL_SNAPSHOT_CACHE_DIRECTORY = "connection-shell-snapshots"; -const LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots"; -const THREAD_SNAPSHOT_CACHE_DIRECTORY = "connection-thread-snapshots"; - -const StoredShellSnapshot = StoredOrchestrationShellSnapshot; -const StoredThreadSnapshot = StoredOrchestrationThreadSnapshot; -const decodeStoredShellSnapshot = Schema.decodeUnknownResult(StoredShellSnapshot); -const encodeStoredShellSnapshot = Schema.encodeUnknownResult(StoredShellSnapshot); -const decodeStoredThreadSnapshot = Schema.decodeUnknownResult(StoredThreadSnapshot); -const encodeStoredThreadSnapshot = Schema.encodeUnknownResult(StoredThreadSnapshot); - -function catalogError(operation: string, cause: unknown) { - return new ConnectionTransientError({ - reason: "remote-unavailable", - detail: `Could not ${operation} the local connection catalog: ${String(cause)}`, - }); -} - -function shellPersistenceError( - operation: - | "load-shell" - | "save-shell" - | "load-thread" - | "save-thread" - | "remove-thread" - | "clear-environment", - cause: unknown, -) { - return new ConnectionPersistenceError({ - operation, - message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`, - }); -} - -function threadSnapshotFileName(threadId: ThreadId): string { - return `${encodeURIComponent(threadId)}.json`; -} - -const threadSnapshotDirectory = Effect.fn("mobile.connectionStorage.threadSnapshotDirectory")( - function* ( - environmentId: EnvironmentId, - operation: "load-thread" | "save-thread" | "remove-thread" | "clear-environment", - ) { - return yield* Effect.tryPromise({ - try: async () => { - const { Directory, Paths } = await import("expo-file-system"); - const directory = new Directory( - Paths.document, - THREAD_SNAPSHOT_CACHE_DIRECTORY, - encodeURIComponent(environmentId), - ); - if (operation !== "clear-environment") { - directory.create({ idempotent: true, intermediates: true }); - } - return directory; - }, - catch: (cause) => shellPersistenceError(operation, cause), - }); - }, -); - -const threadSnapshotFile = Effect.fn("mobile.connectionStorage.threadSnapshotFile")(function* ( - environmentId: EnvironmentId, - threadId: ThreadId, - operation: "load-thread" | "save-thread" | "remove-thread", -) { - const { File } = yield* Effect.promise(() => import("expo-file-system")); - return new File( - yield* threadSnapshotDirectory(environmentId, operation), - threadSnapshotFileName(threadId), - ); -}); +import * as CatalogStore from "./catalog-store"; function targetPersistenceError( operation: "list-targets" | "register-connection" | "remove-connection", @@ -111,59 +29,9 @@ function targetPersistenceError( }); } -const secureCatalogStorage: SecureCatalogStorage = { - getItem: (key) => - Effect.tryPromise({ - try: () => SecureStore.getItemAsync(key), - catch: (cause) => catalogError("load", cause), - }), - setItem: (key, value) => - Effect.tryPromise({ - try: () => SecureStore.setItemAsync(key, value), - catch: (cause) => catalogError("save", cause), - }), - deleteItem: (key) => - Effect.tryPromise({ - try: () => SecureStore.deleteItemAsync(key), - catch: (cause) => catalogError("delete", cause), - }), -}; - -function shellSnapshotFileName(environmentId: EnvironmentId): string { - return `${encodeURIComponent(environmentId)}.json`; -} - -const shellSnapshotFileInDirectory = Effect.fn( - "mobile.connectionStorage.shellSnapshotFileInDirectory", -)(function* ( - environmentId: EnvironmentId, - operation: "load-shell" | "save-shell" | "clear-environment", - directoryName: string, -) { - return yield* Effect.tryPromise({ - try: async () => { - const { Directory, File, Paths } = await import("expo-file-system"); - const directory = new Directory(Paths.document, directoryName); - directory.create({ idempotent: true, intermediates: true }); - return new File(directory, shellSnapshotFileName(environmentId)); - }, - catch: (cause) => shellPersistenceError(operation, cause), - }); -}); - -const shellSnapshotFile = ( - environmentId: EnvironmentId, - operation: "load-shell" | "save-shell" | "clear-environment", -) => shellSnapshotFileInDirectory(environmentId, operation, SHELL_SNAPSHOT_CACHE_DIRECTORY); - -const legacyShellSnapshotFile = ( - environmentId: EnvironmentId, - operation: "load-shell" | "clear-environment", -) => shellSnapshotFileInDirectory(environmentId, operation, LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY); - export const connectionStorageLayer = Layer.effectContext( Effect.gen(function* () { - const catalog = yield* makeCatalogStore(secureCatalogStorage); + const catalog = yield* CatalogStore.make(); const targetStore = ConnectionTargetStore.of({ list: catalog.read.pipe( @@ -260,182 +128,11 @@ export const connectionStorageLayer = Layer.effectContext( ), })), }); - const cacheStore = EnvironmentCacheStore.of({ - loadShell: (environmentId) => - Effect.gen(function* () { - const file = yield* shellSnapshotFile(environmentId, "load-shell"); - if (file.exists) { - const raw = yield* Effect.tryPromise({ - try: () => file.text(), - catch: (cause) => shellPersistenceError("load-shell", cause), - }); - const parsed = yield* Effect.try({ - try: () => JSON.parse(raw) as unknown, - catch: (cause) => shellPersistenceError("load-shell", cause), - }); - const stored = yield* Effect.fromResult(decodeStoredShellSnapshot(parsed)).pipe( - Effect.mapError((cause) => shellPersistenceError("load-shell", cause)), - ); - return stored.environmentId === environmentId - ? Option.some(stored.snapshot) - : Option.none(); - } - - const legacyFile = yield* legacyShellSnapshotFile(environmentId, "load-shell"); - if (legacyFile.exists) { - yield* Effect.try({ - try: () => legacyFile.delete(), - catch: (cause) => shellPersistenceError("load-shell", cause), - }); - } - - return Option.none(); - }).pipe((decode) => - decodeOrDiscardOrchestrationCache( - decode, - shellSnapshotFile(environmentId, "load-shell").pipe( - Effect.flatMap((file) => - Effect.try({ - try: () => { - if (file.exists) file.delete(); - }, - catch: (cause) => shellPersistenceError("load-shell", cause), - }), - ), - Effect.catch(() => Effect.void), - ), - ), - ), - saveShell: (environmentId, snapshot) => - Effect.gen(function* () { - const file = yield* shellSnapshotFile(environmentId, "save-shell"); - const stored = { - schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, - environmentId, - snapshot, - } as const; - const encoded = yield* Effect.fromResult(encodeStoredShellSnapshot(stored)).pipe( - Effect.mapError((cause) => shellPersistenceError("save-shell", cause)), - ); - yield* Effect.try({ - try: () => { - if (!file.exists) { - file.create({ intermediates: true, overwrite: true }); - } - file.write(JSON.stringify(encoded)); - }, - catch: (cause) => shellPersistenceError("save-shell", cause), - }); - }), - loadThread: (environmentId, threadId) => - Effect.gen(function* () { - const file = yield* threadSnapshotFile(environmentId, threadId, "load-thread"); - if (!file.exists) { - return Option.none(); - } - const raw = yield* Effect.tryPromise({ - try: () => file.text(), - catch: (cause) => shellPersistenceError("load-thread", cause), - }); - const parsed = yield* Effect.try({ - try: () => JSON.parse(raw) as unknown, - catch: (cause) => shellPersistenceError("load-thread", cause), - }); - const stored = yield* Effect.fromResult(decodeStoredThreadSnapshot(parsed)).pipe( - Effect.mapError((cause) => shellPersistenceError("load-thread", cause)), - ); - return stored.environmentId === environmentId && stored.threadId === threadId - ? Option.some(stored.snapshot) - : Option.none(); - }).pipe((decode) => - decodeOrDiscardOrchestrationCache( - decode, - threadSnapshotFile(environmentId, threadId, "load-thread").pipe( - Effect.flatMap((file) => - Effect.try({ - try: () => { - if (file.exists) file.delete(); - }, - catch: (cause) => shellPersistenceError("load-thread", cause), - }), - ), - Effect.catch(() => Effect.void), - ), - ), - ), - saveThread: (environmentId, snapshot) => - Effect.gen(function* () { - const file = yield* threadSnapshotFile( - environmentId, - snapshot.projection.thread.id, - "save-thread", - ); - const encoded = yield* Effect.fromResult( - encodeStoredThreadSnapshot({ - schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, - environmentId, - threadId: snapshot.projection.thread.id, - snapshot, - }), - ).pipe(Effect.mapError((cause) => shellPersistenceError("save-thread", cause))); - yield* Effect.try({ - try: () => { - if (!file.exists) { - file.create({ intermediates: true, overwrite: true }); - } - file.write(JSON.stringify(encoded)); - }, - catch: (cause) => shellPersistenceError("save-thread", cause), - }); - }), - removeThread: (environmentId, threadId) => - Effect.gen(function* () { - const file = yield* threadSnapshotFile(environmentId, threadId, "remove-thread"); - if (file.exists) { - file.delete(); - } - }).pipe( - Effect.mapError((cause) => - cause._tag === "ConnectionPersistenceError" - ? cause - : shellPersistenceError("remove-thread", cause), - ), - ), - clear: (environmentId) => - Effect.gen(function* () { - const file = yield* shellSnapshotFile(environmentId, "clear-environment"); - if (file.exists) { - yield* Effect.try({ - try: () => file.delete(), - catch: (cause) => shellPersistenceError("clear-environment", cause), - }); - } - const legacyFile = yield* legacyShellSnapshotFile(environmentId, "clear-environment"); - if (legacyFile.exists) { - yield* Effect.try({ - try: () => legacyFile.delete(), - catch: (cause) => shellPersistenceError("clear-environment", cause), - }); - } - const threadDirectory = yield* threadSnapshotDirectory( - environmentId, - "clear-environment", - ); - if (threadDirectory.exists) { - yield* Effect.try({ - try: () => threadDirectory.delete(), - catch: (cause) => shellPersistenceError("clear-environment", cause), - }); - } - }), - }); - return Context.make(ConnectionTargetStore, targetStore).pipe( Context.add(ConnectionRegistrationStore, registrationStore), Context.add(ProfileStore.ConnectionProfileStore, profileStore), Context.add(CredentialStore.ConnectionCredentialStore, credentialStore), Context.add(TokenStore.RemoteDpopAccessTokenStore, remoteTokenStore), - Context.add(EnvironmentCacheStore, cacheStore), ); }), ); diff --git a/apps/mobile/src/features/agent-awareness/capabilities.ts b/apps/mobile/src/features/agent-awareness/capabilities.ts new file mode 100644 index 00000000000..d627f365754 --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/capabilities.ts @@ -0,0 +1,5 @@ +import Constants from "expo-constants"; + +export function supportsAgentAwarenessPush() { + return Constants.expoConfig?.extra?.iosPersonalTeamBuild !== true; +} diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts index 5de14ea76fc..0ee3e59b982 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts @@ -7,21 +7,30 @@ import * as Layer from "effect/Layer"; import { HttpClient } from "effect/unstable/http"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { savePreferencesPatch } from "../../lib/storage"; -import { linkEnvironmentToCloud } from "../cloud/linkEnvironment"; +import { MobileStorage } from "../../persistence/mobile-storage"; +import { + CloudEnvironmentLinkError, + linkEnvironmentToCloudWithPreference, +} from "../cloud/linkEnvironment"; import { setLiveActivityUpdatesEnabled } from "./liveActivityPreferences"; -import { refreshAgentAwarenessRegistration } from "./remoteRegistration"; +import { updateAgentAwarenessRegistrationPreferences } from "./remoteRegistration"; -vi.mock("../../lib/storage", () => ({ - savePreferencesPatch: vi.fn(() => Promise.resolve()), +vi.mock("expo-secure-store", () => ({ + deleteItemAsync: vi.fn(), + getItemAsync: vi.fn(), + setItemAsync: vi.fn(), +})); + +vi.mock("react-native", () => ({ + Platform: { OS: "ios" }, })); vi.mock("../cloud/linkEnvironment", () => ({ - linkEnvironmentToCloud: vi.fn(() => Effect.void), + linkEnvironmentToCloudWithPreference: vi.fn(() => Effect.void), })); vi.mock("./remoteRegistration", () => ({ - refreshAgentAwarenessRegistration: vi.fn(() => Effect.void), + updateAgentAwarenessRegistrationPreferences: vi.fn(() => Effect.void), })); const connection: SavedRemoteConnection = { @@ -40,6 +49,21 @@ const testLayer = Layer.mergeAll( HttpClient.HttpClient, HttpClient.make(() => Effect.die("unexpected HTTP request")), ), + Layer.succeed( + MobileStorage, + MobileStorage.of({ + loadSavedConnections: Effect.succeed([]), + saveConnection: () => Effect.void, + clearSavedConnection: () => Effect.void, + loadOrCreateAgentAwarenessDeviceId: Effect.succeed("device-1"), + loadAgentAwarenessDeviceId: Effect.succeed("device-1"), + loadAgentAwarenessRegistrationRecord: Effect.succeed(null), + saveAgentAwarenessRegistrationRecord: () => Effect.void, + clearAgentAwarenessRegistrationRecord: Effect.void, + loadRecentThreadShortcuts: Effect.succeed([]), + saveRecentThreadShortcuts: () => Effect.void, + }), + ), ); describe("liveActivityPreferences", () => { @@ -51,15 +75,18 @@ describe("liveActivityPreferences", () => { Effect.gen(function* () { yield* setLiveActivityUpdatesEnabled({ enabled: false, + previousEnabled: true, clerkToken: "clerk-token", connections: [connection], }); - expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); - expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); - expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ + expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ + liveActivitiesEnabled: false, + }); + expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledWith({ clerkToken: "clerk-token", connection, + liveActivitiesEnabled: false, }); }).pipe(Effect.provide(testLayer)), ); @@ -68,15 +95,18 @@ describe("liveActivityPreferences", () => { Effect.gen(function* () { yield* setLiveActivityUpdatesEnabled({ enabled: true, + previousEnabled: false, clerkToken: "clerk-token", connections: [connection], }); - expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: true }); - expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); - expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ + expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ + liveActivitiesEnabled: true, + }); + expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledWith({ clerkToken: "clerk-token", connection, + liveActivitiesEnabled: true, }); }).pipe(Effect.provide(testLayer)), ); @@ -85,13 +115,15 @@ describe("liveActivityPreferences", () => { Effect.gen(function* () { yield* setLiveActivityUpdatesEnabled({ enabled: false, + previousEnabled: true, clerkToken: null, connections: [connection], }); - expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); - expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); - expect(linkEnvironmentToCloud).not.toHaveBeenCalled(); + expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ + liveActivitiesEnabled: false, + }); + expect(linkEnvironmentToCloudWithPreference).not.toHaveBeenCalled(); }).pipe(Effect.provide(testLayer)), ); @@ -104,14 +136,51 @@ describe("liveActivityPreferences", () => { return Effect.gen(function* () { yield* setLiveActivityUpdatesEnabled({ enabled: true, + previousEnabled: false, clerkToken: "clerk-token", connections: [connection, managedConnection], }); - expect(linkEnvironmentToCloud).toHaveBeenCalledTimes(1); - expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ + expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledTimes(1); + expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledWith({ + clerkToken: "clerk-token", + connection, + liveActivitiesEnabled: true, + }); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("restores relay preferences when an environment update fails", () => { + vi.mocked(linkEnvironmentToCloudWithPreference).mockImplementationOnce(() => + Effect.fail(new CloudEnvironmentLinkError({ message: "environment update failed" })), + ); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + setLiveActivityUpdatesEnabled({ + enabled: false, + previousEnabled: true, + clerkToken: "clerk-token", + connections: [connection], + }), + ); + + expect(exit._tag).toBe("Failure"); + expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(1, { + liveActivitiesEnabled: false, + }); + expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(2, { + liveActivitiesEnabled: true, + }); + expect(linkEnvironmentToCloudWithPreference).toHaveBeenNthCalledWith(1, { + clerkToken: "clerk-token", + connection, + liveActivitiesEnabled: false, + }); + expect(linkEnvironmentToCloudWithPreference).toHaveBeenNthCalledWith(2, { clerkToken: "clerk-token", connection, + liveActivitiesEnabled: true, }); }).pipe(Effect.provide(testLayer)); }); diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts index 932376e8bce..1b30769e7bc 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts @@ -1,47 +1,73 @@ import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; -import { HttpClient } from "effect/unstable/http"; -import { ManagedRelay } from "@t3tools/client-runtime/relay"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { savePreferencesPatch } from "../../lib/storage"; -import { linkEnvironmentToCloud } from "../cloud/linkEnvironment"; -import { refreshAgentAwarenessRegistration } from "./remoteRegistration"; - -export class LiveActivityPreferenceSaveError extends Schema.TaggedErrorClass()( - "LiveActivityPreferenceSaveError", - { - enabled: Schema.Boolean, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to save the Live Activity updates setting (enabled: ${this.enabled}).`; - } -} - -export function setLiveActivityUpdatesEnabled(input: { - readonly enabled: boolean; - readonly clerkToken: string | null; - readonly connections: ReadonlyArray; -}): Effect.Effect { - return Effect.gen(function* () { - yield* Effect.tryPromise({ - try: () => savePreferencesPatch({ liveActivitiesEnabled: input.enabled }), - catch: (cause) => new LiveActivityPreferenceSaveError({ enabled: input.enabled, cause }), +import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment"; +import { updateAgentAwarenessRegistrationPreferences } from "./remoteRegistration"; + +export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEnabled")( + function* (input: { + readonly enabled: boolean; + readonly previousEnabled: boolean; + readonly clerkToken: string | null; + readonly connections: ReadonlyArray; + }) { + const linkedConnections = input.connections.filter( + (connection) => connection.bearerToken !== null, + ); + + const updateRelayPreference = Effect.fn("updateRelayPreference")(function* (enabled: boolean) { + yield* updateAgentAwarenessRegistrationPreferences({ + liveActivitiesEnabled: enabled, + }); + + const clerkToken = input.clerkToken; + if (!clerkToken) return; + + yield* Effect.forEach( + linkedConnections, + (connection) => + linkEnvironmentToCloudWithPreference({ + clerkToken, + connection, + liveActivitiesEnabled: enabled, + }), + { concurrency: "unbounded" }, + ); }); - yield* refreshAgentAwarenessRegistration(); + const restoreRelayPreference = Effect.fn("restoreRelayPreference")(function* () { + yield* updateAgentAwarenessRegistrationPreferences({ + liveActivitiesEnabled: input.previousEnabled, + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Could not restore Live Activity device preference.", cause), + ), + ); + + const clerkToken = input.clerkToken; + if (!clerkToken) return; - const clerkToken = input.clerkToken; - if (!clerkToken) { - return; - } + yield* Effect.forEach( + linkedConnections, + (connection) => + linkEnvironmentToCloudWithPreference({ + clerkToken, + connection, + liveActivitiesEnabled: input.previousEnabled, + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning( + `Could not restore Live Activity preference for environment ${connection.environmentId}.`, + cause, + ), + ), + ), + { concurrency: "unbounded" }, + ); + }); - yield* Effect.forEach( - input.connections.filter((connection) => connection.bearerToken !== null), - (connection) => linkEnvironmentToCloud({ clerkToken, connection }), - { concurrency: "unbounded" }, + yield* updateRelayPreference(input.enabled).pipe( + Effect.onError(() => restoreRelayPreference()), ); - }); -} + }, +); diff --git a/apps/mobile/src/features/agent-awareness/registrationPayload.ts b/apps/mobile/src/features/agent-awareness/registrationPayload.ts index cd2e36a403c..8279a27fee6 100644 --- a/apps/mobile/src/features/agent-awareness/registrationPayload.ts +++ b/apps/mobile/src/features/agent-awareness/registrationPayload.ts @@ -1,6 +1,7 @@ import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay"; -import type { Preferences } from "../../lib/storage"; +import type { Preferences } from "../../persistence/mobile-preferences"; +import { supportsAgentAwarenessPush } from "./capabilities"; // Development builds are Xcode-signed and receive sandbox APNs tokens; // preview and production builds are distribution-signed and use production @@ -21,7 +22,8 @@ export function makeRelayDeviceRegistrationRequest(input: { readonly notificationsEnabled: boolean; readonly preferences: Preferences; }): RelayDeviceRegistrationRequest { - const liveActivitiesEnabled = input.preferences.liveActivitiesEnabled !== false; + const pushAvailable = supportsAgentAwarenessPush(); + const liveActivitiesEnabled = pushAvailable && input.preferences.liveActivitiesEnabled !== false; return { deviceId: input.deviceId, label: input.label, @@ -34,7 +36,7 @@ export function makeRelayDeviceRegistrationRequest(input: { ...(input.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}), preferences: { liveActivitiesEnabled, - notificationsEnabled: input.notificationsEnabled, + notificationsEnabled: pushAvailable && input.notificationsEnabled, notifyOnApproval: true, notifyOnInput: true, notifyOnCompletion: true, diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index e296825c7ee..b1a48a35a0a 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -21,12 +21,13 @@ import { loadAgentAwarenessRegistrationRecord, loadOrCreateAgentAwarenessDeviceId, saveAgentAwarenessRegistrationRecord, -} from "../../lib/storage"; +} from "../../persistence/imperative"; import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; import { AgentAwarenessOperationError, __resetAgentAwarenessRemoteRegistrationForTest, getAgentAwarenessRegistrationStatus, + mergeAgentAwarenessRegistrationPreferences, refreshActiveLiveActivityRemoteRegistration, refreshAgentAwarenessRegistration, normalizeAgentAwarenessRelayBaseUrl, @@ -142,7 +143,7 @@ vi.mock("../../lib/runtime", () => ({ }, })); -vi.mock("../../lib/storage", () => ({ +vi.mock("../../persistence/imperative", () => ({ loadAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), loadPreferences: vi.fn(() => Promise.resolve({ liveActivitiesEnabled: false })), @@ -286,6 +287,26 @@ describe("makeRelayDeviceRegistrationRequest", () => { expect(resolveApsEnvironment(undefined)).toBe("production"); }); + it("disables push features in Personal Team relay registrations", () => { + Constants.expoConfig!.extra = { iosPersonalTeamBuild: true }; + + expect( + makeRelayDeviceRegistrationRequest({ + deviceId: "device-1", + label: "Julius's iPhone", + iosMajorVersion: 18, + appVersion: "1.0.0", + pushToken: "apns-token", + pushToStartToken: "push-to-start-token", + notificationsEnabled: true, + preferences: {}, + }).preferences, + ).toMatchObject({ + liveActivitiesEnabled: false, + notificationsEnabled: false, + }); + }); + it("marks notification delivery disabled when APNs permission is unavailable", () => { expect( makeRelayDeviceRegistrationRequest({ @@ -324,6 +345,15 @@ describe("makeRelayDeviceRegistrationRequest", () => { expect(normalizeAgentAwarenessRelayBaseUrl(" ")).toBeNull(); }); + it("overrides persisted preferences for an in-flight registration", () => { + expect( + mergeAgentAwarenessRegistrationPreferences( + { liveActivitiesEnabled: false, baseFontSize: 18 }, + { liveActivitiesEnabled: true }, + ), + ).toEqual({ liveActivitiesEnabled: true, baseFontSize: 18 }); + }); + it.effect("registers at most one listener while a Live Activity push token is pending", () => { registerAgentAwarenessConnection(savedConnection()); const addPushTokenListener = vi.fn(); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index e9de93c1dcb..449f90886cf 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -20,6 +20,7 @@ import { import type { SavedRemoteConnection } from "../../lib/connection"; import { runtime } from "../../lib/runtime"; +import type { Preferences } from "../../persistence/mobile-preferences"; import { clearAgentAwarenessRegistrationRecord, loadAgentAwarenessDeviceId, @@ -27,9 +28,10 @@ import { loadOrCreateAgentAwarenessDeviceId, loadPreferences, saveAgentAwarenessRegistrationRecord, -} from "../../lib/storage"; +} from "../../persistence/imperative"; import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity"; import { resolveCloudPublicConfig } from "../cloud/publicConfig"; +import { supportsAgentAwarenessPush } from "./capabilities"; import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; const REMOTE_ACTIVITY_REGISTRATION_RETRY_MS = 15_000; @@ -124,6 +126,17 @@ interface DeviceRegistrationInput { readonly observedPushToken?: string; } +interface RegisterDeviceInput extends DeviceRegistrationInput { + readonly preferencesOverride?: Partial; +} + +export function mergeAgentAwarenessRegistrationPreferences( + stored: Preferences, + override: Partial | undefined, +): Preferences { + return { ...stored, ...override }; +} + export function normalizeAgentAwarenessRelayBaseUrl( value: string | null | undefined, ): string | null { @@ -237,7 +250,7 @@ function iosMajorVersion(): number { function nativePushTokenRegistration(observedPushToken?: string) { return Effect.gen(function* () { - if (!canRegisterRemoteLiveActivities()) { + if (!canRegisterRemoteLiveActivities() || !supportsAgentAwarenessPush()) { return { notificationsEnabled: false, pushToken: null }; } if (observedPushToken) { @@ -655,7 +668,7 @@ function enqueueDeviceRegistration(input: DeviceRegistrationInput, context: stri } function registerDevice( - input: DeviceRegistrationInput = {}, + input: RegisterDeviceInput = {}, expectedGeneration = deviceRegistrationGeneration, ): Effect.Effect { return Effect.gen(function* () { @@ -665,7 +678,7 @@ function registerDevice( } logRegistrationDebug("device registration loading local state", { expectedGeneration }); - const [deviceId, preferences] = yield* Effect.all([ + const [deviceId, storedPreferences] = yield* Effect.all([ Effect.tryPromise({ try: () => loadOrCreateAgentAwarenessDeviceId(), catch: (cause) => @@ -683,6 +696,10 @@ function registerDevice( }), }), ]); + const preferences = mergeAgentAwarenessRegistrationPreferences( + storedPreferences, + input.preferencesOverride, + ); const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken); logRegistrationDebug("device registration local state ready", { expectedGeneration, @@ -820,6 +837,21 @@ export function refreshAgentAwarenessRegistration(): Effect.Effect< ); } +export function updateAgentAwarenessRegistrationPreferences( + preferencesOverride: Partial, +): Effect.Effect { + return registerDevice({ preferencesOverride }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + if (registrationStatus !== "registered") { + setRegistrationStatus("failed"); + } + logRegistrationError("device preference registration refresh failed", error); + }), + ), + ); +} + export function __resetAgentAwarenessRemoteRegistrationForTest(): void { environmentConnections.clear(); pushTokenSubscription?.remove(); diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 6ef0819abd7..916802e9faf 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -6,9 +6,11 @@ import { LegendList } from "@legendapp/list/react-native"; import type { EnvironmentId } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../components/AppSymbol"; +import { useNavigation } from "@react-navigation/native"; import { useCallback, useMemo, useRef, type ComponentProps } from "react"; import { + TextInput, ActivityIndicator, Platform, Pressable, @@ -23,6 +25,7 @@ import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; import { EmptyState } from "../../components/EmptyState"; import { ProjectFavicon } from "../../components/ProjectFavicon"; +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"; @@ -34,20 +37,6 @@ export interface ArchivedThreadsHeaderEnvironment { readonly label: string; } -const THREAD_ACTIONS: MenuAction[] = [ - { - id: "unarchive", - title: "Unarchive", - image: "arrow.uturn.backward", - }, - { - id: "delete", - title: "Delete", - image: "trash", - attributes: { destructive: true }, - }, -]; - type ArchivedThreadListItem = | { readonly kind: "project"; @@ -66,6 +55,7 @@ type ArchivedThreadListItem = function ArchivedThreadsHeader(props: { readonly environments: ReadonlyArray; + readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; readonly sortOrder: ArchivedThreadSortOrder; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; @@ -74,9 +64,140 @@ function ArchivedThreadsHeader(props: { readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void; }) { const { width } = useWindowDimensions(); + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest"; + const searchIconColor = useThemeColor("--color-icon"); + const searchTextColor = useThemeColor("--color-foreground"); const usesNativeChrome = Platform.OS === "ios"; const usesCompactMailToolbar = Platform.OS === "ios" && width < 700; + const androidFilterActions = useMemo( + () => [ + { + id: "environment", + title: "Environment", + subactions: [ + { + id: "environment:all", + title: "All environments", + state: props.selectedEnvironmentId === null ? ("on" as const) : undefined, + }, + ...props.environments.map((environment) => ({ + id: `environment:${environment.environmentId}`, + title: environment.label, + state: + props.selectedEnvironmentId === environment.environmentId + ? ("on" as const) + : undefined, + })), + ], + }, + { + id: "sort", + title: "Sort by archived date", + subactions: [ + { + id: "sort:newest", + title: "Newest first", + state: props.sortOrder === "newest" ? ("on" as const) : undefined, + }, + { + id: "sort:oldest", + title: "Oldest first", + state: props.sortOrder === "oldest" ? ("on" as const) : undefined, + }, + ], + }, + ], + [props.environments, props.selectedEnvironmentId, props.sortOrder], + ); + const handleAndroidFilterAction = useCallback( + (event: { nativeEvent: { event: string } }) => { + const action = event.nativeEvent.event; + if (action === "environment:all") { + props.onEnvironmentChange(null); + } else if (action.startsWith("environment:")) { + props.onEnvironmentChange(action.slice("environment:".length) as EnvironmentId); + } else if (action === "sort:newest") { + props.onSortOrderChange("newest"); + } else if (action === "sort:oldest") { + props.onSortOrderChange("oldest"); + } + }, + [props.onEnvironmentChange, props.onSortOrderChange], + ); + + if (Platform.OS === "android") { + // Single header row matching the app's Android chrome (AndroidScreenHeader + // palette): back chevron, inline search, filter menu. + return ( + <> + + + + navigation.goBack()} + className="size-11 items-center justify-center" + > + + + + + + + + + + + + + + + ); + } const archiveFilterMenu = { title: "Archived thread options", items: [ @@ -244,9 +365,8 @@ function ProjectGroupLabel(props: { workspaceRoot={props.project.workspaceRoot} /> {props.project.title} @@ -280,20 +400,18 @@ function ArchivedThreadRow(props: { const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string => Boolean(part), ); - const handleMenuAction = useCallback( - (event: { nativeEvent: { event: string } }) => { - if (event.nativeEvent.event === "unarchive") { - props.onUnarchive(); - } else if (event.nativeEvent.event === "delete") { - props.onDelete(); - } - }, - [props.onDelete, props.onUnarchive], - ); - return ( @@ -331,10 +445,7 @@ function ArchivedThreadRow(props: { > {props.thread.title} - + {timestamp} @@ -347,26 +458,14 @@ function ArchivedThreadRow(props: { type="monochrome" /> {subtitle.join(" · ")} ) : null} - - - - - - )} @@ -508,6 +607,7 @@ export function ArchivedThreadsScreen(props: { void }) { const { errors, fetchStatus, waitlist } = useWaitlist(); - const colors = useCloudWaitlistColors(); const [emailAddress, setEmailAddress] = useState(""); const [requestError, setRequestError] = useState(null); const isSubmitting = fetchStatus === "fetching"; @@ -34,7 +33,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } if (waitlist.id) { return ( - + You are on the waitlist @@ -47,19 +46,22 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } } return ( - + Enter your email and we will let you know when access is ready. - + Email address { setEmailAddress(value); @@ -67,16 +69,8 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } }} onSubmitEditing={() => void joinWaitlist()} placeholder="Enter your email address" - placeholderTextColor={colors.placeholder} + placeholderTextColorClassName="accent-placeholder" returnKeyType="join" - style={[ - styles.input, - { - backgroundColor: colors.input, - borderColor: - fieldError || requestError ? colors.dangerForeground : colors.inputBorder, - }, - ]} textContentType="emailAddress" value={emailAddress} /> @@ -99,15 +93,11 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } }} disabled={isSubmitting || emailAddress.trim().length === 0} onPress={() => void joinWaitlist()} - style={[ - styles.primaryButton, - { - backgroundColor: colors.primary, - opacity: isSubmitting || emailAddress.trim().length === 0 ? 0.45 : 1, - }, - ]} + className="min-h-[54px] flex-row items-center justify-center gap-2 rounded-full bg-primary px-5 py-3 disabled:opacity-[0.45]" > - {isSubmitting ? : null} + {isSubmitting ? ( + + ) : null} {isSubmitting ? "Joining" : "Join the waitlist"} @@ -120,7 +110,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } function SignInAction(props: { readonly onPress: () => void }) { return ( - + Already have access? Sign in @@ -128,48 +118,3 @@ function SignInAction(props: { readonly onPress: () => void }) { ); } - -function useCloudWaitlistColors() { - return { - dangerForeground: String(useThemeColor("--color-danger-foreground")), - input: String(useThemeColor("--color-input")), - inputBorder: String(useThemeColor("--color-input-border")), - placeholder: String(useThemeColor("--color-placeholder")), - primary: String(useThemeColor("--color-primary")), - primaryForeground: String(useThemeColor("--color-primary-foreground")), - }; -} - -const styles = StyleSheet.create({ - content: { - gap: 18, - }, - field: { - gap: 8, - }, - input: { - borderCurve: "continuous", - borderRadius: 16, - borderWidth: 1, - minHeight: 54, - paddingHorizontal: 16, - paddingVertical: 14, - }, - primaryButton: { - alignItems: "center", - borderRadius: 999, - flexDirection: "row", - gap: 8, - justifyContent: "center", - minHeight: 54, - paddingHorizontal: 20, - paddingVertical: 12, - }, - signInRow: { - alignItems: "center", - flexDirection: "row", - gap: 4, - justifyContent: "center", - paddingTop: 4, - }, -}); diff --git a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx index 657905c8b65..952d1c38841 100644 --- a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx +++ b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx @@ -2,10 +2,11 @@ import { NativeHeaderToolbar } from "../../native/StackHeader"; import { useAuth } from "@clerk/expo"; import { StackActions, useNavigation } from "@react-navigation/native"; import { useCallback, useEffect, useState } from "react"; -import { Pressable, RefreshControl, ScrollView, View } from "react-native"; +import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { reportAtomCommandResult, settlePromise } from "@t3tools/client-runtime/state/runtime"; +import { AndroidSheetHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text } from "../../components/AppText"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; @@ -81,14 +82,21 @@ function ConfiguredConnectOnboardingRouteScreen() { return ( - - - + {Platform.OS === "android" ? ( + + ) : ( + + + + )} ({ }, })); -vi.mock("../../lib/storage", () => ({ - loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), - loadPreferences: vi.fn(() => Promise.resolve({})), +vi.mock("expo-secure-store", () => ({ + deleteItemAsync: vi.fn(), + getItemAsync: vi.fn(), + setItemAsync: vi.fn(), })); +const loadPreferences = vi.fn(() => Effect.succeed({})); + const savedConnection = { environmentId: EnvironmentId.make("env-1"), environmentLabel: "Desktop", @@ -69,6 +75,29 @@ function cloudClientLayer() { const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)); return Layer.mergeAll( httpClientLayer, + Layer.succeed( + MobilePreferencesStore, + MobilePreferencesStore.of({ + load: loadPreferences(), + savePatch: (patch) => Effect.succeed(patch), + update: () => Effect.succeed({}), + }), + ), + Layer.succeed( + MobileStorage, + MobileStorage.of({ + loadSavedConnections: Effect.succeed([]), + saveConnection: () => Effect.void, + clearSavedConnection: () => Effect.void, + loadOrCreateAgentAwarenessDeviceId: Effect.succeed("device-1"), + loadAgentAwarenessDeviceId: Effect.succeed("device-1"), + loadAgentAwarenessRegistrationRecord: Effect.succeed(null), + saveAgentAwarenessRegistrationRecord: () => Effect.void, + clearAgentAwarenessRegistrationRecord: Effect.void, + loadRecentThreadShortcuts: Effect.succeed([]), + saveRecentThreadShortcuts: () => Effect.void, + }), + ), ManagedRelay.layer({ relayUrl: "https://relay.example.test", clientId: RelayMobileClientId, @@ -80,7 +109,11 @@ const withCloudServices = ( effect: Effect.Effect< A, E, - HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner + | HttpClient.HttpClient + | ManagedRelay.ManagedRelayClient + | ManagedRelay.ManagedRelayDpopSigner + | MobilePreferencesStore + | MobileStorage >, ) => effect.pipe(Effect.provide(cloudClientLayer())); @@ -146,6 +179,7 @@ describe("mobile cloud link environment client", () => { beforeEach(() => { vi.restoreAllMocks(); createProofMock.mockClear(); + loadPreferences.mockClear(); }); it("normalizes configured relay base URLs before building DPoP-bound requests", () => { @@ -705,10 +739,7 @@ describe("mobile cloud link environment client", () => { it.effect("preserves disabled Live Activity preferences when linking an environment", () => Effect.gen(function* () { - const storage = yield* Effect.promise(() => import("../../lib/storage")); - vi.mocked(storage.loadPreferences).mockResolvedValueOnce({ - liveActivitiesEnabled: false, - }); + loadPreferences.mockReturnValueOnce(Effect.succeed({ liveActivitiesEnabled: false })); const bodies: Array = []; const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => { if (init?.body) { @@ -761,6 +792,45 @@ describe("mobile cloud link environment client", () => { }), ); + it.effect("uses an explicit Live Activity preference when persisted state is unavailable", () => + Effect.gen(function* () { + loadPreferences.mockReturnValueOnce(Effect.die("persisted preferences must not be read")); + const bodies: Array> = []; + const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => { + if (init?.body) { + // @effect-diagnostics-next-line preferSchemaOverJson:off + bodies.push(JSON.parse(requestBodyText(init.body)) as Record); + } + if (String(url).endsWith("/v1/client/environment-link-challenges")) { + return Promise.resolve(Response.json(validLinkChallengeResponse())); + } + if (String(url).endsWith("/api/connect/link-proof")) { + return Promise.resolve(Response.json(validLinkProof())); + } + if (String(url).endsWith("/v1/client/environment-links")) { + return Promise.resolve(Response.json(validLinkResponse())); + } + return Promise.resolve( + Response.json({ ok: true, endpointRuntimeStatus: { status: "configured" } }), + ); + }); + vi.stubGlobal("fetch", fetchMock); + + yield* withCloudServices( + linkEnvironmentToCloudWithPreference({ + clerkToken: "clerk-token", + connection: savedConnection, + liveActivitiesEnabled: true, + }), + ); + + expect(bodies.filter((body) => "liveActivitiesEnabled" in body)).toEqual([ + expect.objectContaining({ liveActivitiesEnabled: true }), + expect.objectContaining({ liveActivitiesEnabled: true }), + ]); + }), + ); + it.effect( "does not persist cloud connect bootstrap credentials in saved connection records", () => diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index a77ca628978..5423686e048 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -30,7 +30,8 @@ import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; import { authClientMetadata } from "../../lib/authClientMetadata"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { loadOrCreateAgentAwarenessDeviceId, loadPreferences } from "../../lib/storage"; +import * as MobilePreferences from "../../persistence/mobile-preferences"; +import * as MobileStorage from "../../persistence/mobile-storage"; import { resolveCloudPublicConfig } from "./publicConfig"; const RELAY_STATUS_AND_CONNECT_SCOPES = [ @@ -256,14 +257,19 @@ function ensureConnectEndpointMatchesEnvironment(input: { return Effect.void; } -export function linkEnvironmentToCloud(input: { +interface LinkEnvironmentToCloudInput { readonly connection: SavedRemoteConnection; readonly clerkToken: string; -}): Effect.Effect< - void, - CloudEnvironmentLinkError, - HttpClient.HttpClient | ManagedRelay.ManagedRelayClient -> { +} + +type LinkEnvironmentToCloudRequirements = + | HttpClient.HttpClient + | ManagedRelay.ManagedRelayClient + | MobileStorage.MobileStorage; + +export function linkEnvironmentToCloudWithPreference( + input: LinkEnvironmentToCloudInput & { readonly liveActivitiesEnabled: boolean }, +): Effect.Effect { return Effect.gen(function* () { if (!input.connection.bearerToken) { return yield* new CloudEnvironmentLinkError({ @@ -273,15 +279,11 @@ export function linkEnvironmentToCloud(input: { const localBearerToken = input.connection.bearerToken; const relayUrl = yield* requireRelayUrl(); const relayClient = yield* ManagedRelay.ManagedRelayClient; - const deviceId = yield* Effect.tryPromise({ - try: () => loadOrCreateAgentAwarenessDeviceId(), - catch: cloudEnvironmentLinkError("Could not load the mobile device id."), - }); - const preferences = yield* Effect.tryPromise({ - try: () => loadPreferences(), - catch: cloudEnvironmentLinkError("Could not load mobile notification preferences."), - }); - const liveActivitiesEnabled = preferences.liveActivitiesEnabled !== false; + const storage = yield* MobileStorage.MobileStorage; + const deviceId = yield* storage.loadOrCreateAgentAwarenessDeviceId.pipe( + Effect.mapError(cloudEnvironmentLinkError("Could not load the mobile device id.")), + ); + const liveActivitiesEnabled = input.liveActivitiesEnabled; const challenge = yield* relayClient .createEnvironmentLinkChallenge({ clerkToken: input.clerkToken, @@ -350,6 +352,25 @@ export function linkEnvironmentToCloud(input: { }); } +export function linkEnvironmentToCloud( + input: LinkEnvironmentToCloudInput, +): Effect.Effect< + void, + CloudEnvironmentLinkError, + LinkEnvironmentToCloudRequirements | MobilePreferences.MobilePreferencesStore +> { + return MobilePreferences.MobilePreferencesStore.pipe( + Effect.flatMap((preferencesStore) => preferencesStore.load), + Effect.mapError(cloudEnvironmentLinkError("Could not load mobile notification preferences.")), + Effect.flatMap((preferences) => + linkEnvironmentToCloudWithPreference({ + ...input, + liveActivitiesEnabled: preferences.liveActivitiesEnabled !== false, + }), + ), + ); +} + export function listCloudEnvironments(input: { readonly clerkToken: string; }): Effect.Effect< @@ -460,10 +481,10 @@ export function listCloudEnvironmentsWithStatus(input: { const loadAgentAwarenessDeviceId = Effect.fn("mobile.cloud.loadAgentAwarenessDeviceId")( function* () { - return yield* Effect.tryPromise({ - try: () => loadOrCreateAgentAwarenessDeviceId(), - catch: cloudEnvironmentLinkError("Could not load the mobile device id."), - }); + const storage = yield* MobileStorage.MobileStorage; + return yield* storage.loadOrCreateAgentAwarenessDeviceId.pipe( + Effect.mapError(cloudEnvironmentLinkError("Could not load the mobile device id.")), + ); }, ); @@ -557,7 +578,10 @@ export function connectCloudEnvironment(input: { }): Effect.Effect< SavedRemoteConnection, CloudEnvironmentLinkError, - HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner + | HttpClient.HttpClient + | ManagedRelay.ManagedRelayClient + | ManagedRelay.ManagedRelayDpopSigner + | MobileStorage.MobileStorage > { return connectRelayManagedEnvironment({ clerkToken: input.clerkToken, @@ -572,7 +596,10 @@ export function refreshCloudEnvironmentConnection(input: { }): Effect.Effect< SavedRemoteConnection, CloudEnvironmentLinkError, - HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner + | HttpClient.HttpClient + | ManagedRelay.ManagedRelayClient + | ManagedRelay.ManagedRelayDpopSigner + | MobileStorage.MobileStorage > { return connectRelayManagedEnvironment({ clerkToken: input.clerkToken, diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 854ae37d4ce..608d43b2acb 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -1,5 +1,5 @@ import { useAuth } from "@clerk/expo"; -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../components/AppSymbol"; import { connectionStatusText, type EnvironmentConnectionPhase, @@ -33,6 +33,8 @@ import { type RelayEnvironmentView, useConnectionController } from "./useConnect export function CloudEnvironmentRows(props: { readonly connectedCloudEnvironments: ReadonlyArray; readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void; + readonly showcaseAvailableEnvironments?: ReadonlyArray; + readonly showcaseSignedIn?: boolean; /** * Hide the "T3 Connect" section title + refresh button for hosts that * provide their own chrome (the onboarding sheet's native header and @@ -43,7 +45,8 @@ export function CloudEnvironmentRows(props: { const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const controller = useConnectionController(); const iconColor = useThemeColor("--color-icon"); - const availableCloudEnvironments = controller.availableRelayEnvironments; + const availableCloudEnvironments = + props.showcaseAvailableEnvironments ?? controller.availableRelayEnvironments; const [expandedErrorId, setExpandedErrorId] = useState(null); const hasCloudRows = props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0; @@ -64,7 +67,7 @@ export function CloudEnvironmentRows(props: { const showHeader = props.showHeader ?? true; - if (!isSignedIn) return null; + if (!(props.showcaseSignedIn ?? isSignedIn)) return null; return ( @@ -297,9 +300,8 @@ function CloudEnvironmentRowShell(props: { {props.connectionError ? ( {measuredErrorText} @@ -321,7 +323,7 @@ function CloudEnvironmentRowShell(props: { { event.stopPropagation(); copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" }); @@ -329,7 +331,6 @@ function CloudEnvironmentRowShell(props: { onPress={(event) => { event.stopPropagation(); }} - style={{ textDecorationStyle: "dotted" }} > {errorTraceId} diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 4b2da3e24ca..03a0eb5025f 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -1,4 +1,4 @@ -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../components/AppSymbol"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId } from "@t3tools/contracts"; @@ -38,7 +38,6 @@ export function ConnectionEnvironmentRow(props: { const [url, setUrl] = useState(props.environment.displayUrl); const mutedColor = useThemeColor("--color-icon-subtle"); - const placeholderColor = useThemeColor("--color-placeholder"); const primaryFg = useThemeColor("--color-primary-foreground"); const dangerFg = useThemeColor("--color-danger-foreground"); const statusLabel = connectionStatusLabel(props.environment); @@ -98,7 +97,7 @@ export function ConnectionEnvironmentRow(props: { { event.stopPropagation(); copyTextWithHaptic(statusTraceId, { target: "connection-trace-id" }); @@ -106,7 +105,6 @@ export function ConnectionEnvironmentRow(props: { onPress={(event) => { event.stopPropagation(); }} - style={{ textDecorationStyle: "dotted" }} > {statusTraceId} @@ -140,17 +138,13 @@ export function ConnectionEnvironmentRow(props: { ) : ( <> - + Label - + URL - + Save diff --git a/apps/mobile/src/features/connection/ConnectionSheetButton.tsx b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx index 8a692d80729..d58ee338973 100644 --- a/apps/mobile/src/features/connection/ConnectionSheetButton.tsx +++ b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx @@ -1,4 +1,4 @@ -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../components/AppSymbol"; import { Platform, Pressable } from "react-native"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -37,33 +37,11 @@ export function ConnectionSheetButton(props: { }) { const tone = props.tone ?? "secondary"; - const primaryBg = useThemeColor("--color-primary"); const primaryFg = useThemeColor("--color-primary-foreground"); - const dangerBg = useThemeColor("--color-danger"); - const dangerBorderColor = useThemeColor("--color-danger-border"); const dangerFg = useThemeColor("--color-danger-foreground"); - const secondaryBg = useThemeColor("--color-secondary"); const secondaryFg = useThemeColor("--color-secondary-foreground"); - const borderColor = useThemeColor("--color-border"); - const colors = - tone === "primary" - ? { - backgroundColor: primaryBg, - borderColor: "transparent", - textColor: primaryFg, - } - : tone === "danger" - ? { - backgroundColor: dangerBg, - borderColor: dangerBorderColor, - textColor: dangerFg, - } - : { - backgroundColor: secondaryBg, - borderColor: borderColor, - textColor: secondaryFg, - }; + const textColor = tone === "primary" ? primaryFg : tone === "danger" ? dangerFg : secondaryFg; const primaryShadow = tone === "primary" @@ -84,28 +62,32 @@ export function ConnectionSheetButton(props: { props.compact ? "min-h-[42px] flex-row items-center justify-center gap-1.5 rounded-[14px] px-3.5 py-2.5" : "min-h-[48px] flex-row items-center justify-center gap-2 rounded-[16px] px-4 py-3", + "disabled:opacity-50", + tone === "primary" + ? "bg-primary" + : tone === "danger" + ? "border border-danger-border bg-danger" + : "border border-border bg-secondary", )} disabled={props.disabled} onPress={props.onPress} - style={[ - { - backgroundColor: colors.backgroundColor, - borderWidth: tone === "primary" ? 0 : 1, - borderColor: colors.borderColor, - opacity: props.disabled ? 0.5 : 1, - }, - primaryShadow, - ]} + style={primaryShadow} > {props.label} diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index af4431c6a66..de3799ac8a8 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -3,10 +3,11 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useState } from "react"; -import { Alert, ScrollView, View } from "react-native"; +import { Alert, Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { ErrorBanner } from "../../components/ErrorBanner"; import { ConnectionSheetButton } from "./ConnectionSheetButton"; @@ -38,7 +39,6 @@ export function ConnectionsNewRouteScreen({ const [scannerLocked, setScannerLocked] = useState(false); const headerIconColor = useThemeColor("--color-icon"); - const placeholderColor = useThemeColor("--color-placeholder"); const connectDisabled = isSubmitting || hostInput.trim().length === 0; @@ -137,28 +137,50 @@ export function ConnectionsNewRouteScreen({ - - { - if (showScanner) { - closeScanner(); - } else { - void openScanner(); - } - }} - separateBackground - tintColor={headerIconColor} + {Platform.OS === "android" ? ( + navigation.goBack()} + actions={[ + { + accessibilityLabel: showScanner ? "Close scanner" : "Scan QR code", + icon: showScanner ? "xmark" : "camera", + onPress: () => { + if (showScanner) { + closeScanner(); + } else { + void openScanner(); + } + }, + }, + ]} /> - + ) : ( + + { + if (showScanner) { + closeScanner(); + } else { + void openScanner(); + } + }} + separateBackground + tintColor={headerIconColor} + /> + + )} {showScanner ? ( cameraPermission?.granted ? ( - + ) : ( - + Camera permission is required to scan a QR code. @@ -200,10 +216,7 @@ export function ConnectionsNewRouteScreen({ ) : ( - + Host - + Pairing code - - navigation.navigate("ConnectionsNew")} - separateBackground + {Platform.OS === "android" ? ( + navigation.goBack()} + actions={[ + { + accessibilityLabel: "Add environment", + icon: "plus", + onPress: () => navigation.navigate("ConnectionsNew"), + }, + ]} /> - + ) : ( + + navigation.navigate("ConnectionsNew")} + separateBackground + /> + + )} , +): NativeReviewDiffRow { + return { + kind: "line", + fileId: TYPESCRIPT_FILE.id, + ...input, + }; +} + +function makeHunk(id: string): NativeReviewDiffRow { + return { + kind: "hunk", + id, + fileId: TYPESCRIPT_FILE.id, + text: "@@", + }; +} + +function highlight( + rows: ReadonlyArray, + alreadyHighlightedRowIds?: ReadonlySet, +) { + return highlightNativeReviewDiffVisibleRows({ + rows, + files: [TYPESCRIPT_FILE], + scheme: "dark", + engine: "javascript", + firstRowIndex: 0, + lastRowIndex: rows.length - 1, + overscanRows: 0, + maxRows: 100, + alreadyHighlightedRowIds, + }); +} + +describe("highlightNativeReviewDiffVisibleRows", () => { + it("does not carry grammar state across hunk boundaries", async () => { + const exportRow = makeLine({ + id: "export-row", + content: "export async function run() {}", + change: "add", + oldLineNumber: null, + newLineNumber: 100, + }); + const rows = [ + makeHunk("hunk-1"), + makeLine({ + id: "import-open", + content: "import {", + change: "context", + oldLineNumber: 1, + newLineNumber: 1, + }), + makeLine({ + id: "import-entry", + content: " Model,", + change: "context", + oldLineNumber: 2, + newLineNumber: 2, + }), + makeHunk("hunk-2"), + exportRow, + ]; + + const [highlighted, standalone] = await Promise.all([ + highlight(rows), + highlight([makeHunk("standalone-hunk"), exportRow]), + ]); + + expect(highlighted.tokensByRowId[exportRow.id]).toEqual(standalone.tokensByRowId[exportRow.id]); + }); + + it("keeps grammar state across inline comment rows", async () => { + const openingRow = makeLine({ + id: "template-open", + content: "const message = `open", + change: "add", + oldLineNumber: null, + newLineNumber: 1, + }); + const closingRow = makeLine({ + id: "template-close", + content: "closed`;", + change: "add", + oldLineNumber: null, + newLineNumber: 2, + }); + const trailingRow = makeLine({ + id: "trailing-row", + content: "export const answer = 42;", + change: "add", + oldLineNumber: null, + newLineNumber: 3, + }); + const commentRow: NativeReviewDiffRow = { + kind: "comment", + id: "comment-1", + fileId: TYPESCRIPT_FILE.id, + commentText: "Review note", + }; + + const [withComment, contiguous] = await Promise.all([ + highlight([openingRow, commentRow, closingRow, trailingRow]), + highlight([openingRow, closingRow, trailingRow]), + ]); + + expect(withComment.tokensByRowId).toEqual(contiguous.tokensByRowId); + }); + + it("does not join unhighlighted rows across cached gaps", async () => { + const trailingRow = makeLine({ + id: "trailing-row", + content: "export const answer = 42;", + change: "add", + oldLineNumber: null, + newLineNumber: 3, + }); + const rows = [ + makeLine({ + id: "template-open", + content: "const message = `open", + change: "add", + oldLineNumber: null, + newLineNumber: 1, + }), + makeLine({ + id: "template-close", + content: "closed`;", + change: "add", + oldLineNumber: null, + newLineNumber: 2, + }), + trailingRow, + ]; + + const [highlighted, standalone] = await Promise.all([ + highlight(rows, new Set(["template-close"])), + highlight([trailingRow]), + ]); + + expect(highlighted.tokensByRowId[trailingRow.id]).toEqual( + standalone.tokensByRowId[trailingRow.id], + ); + }); + + it("keeps deletion grammar state out of addition rows", async () => { + const additionRow = makeLine({ + id: "addition-row", + content: "export const answer = 42;", + change: "add", + oldLineNumber: null, + newLineNumber: 1, + }); + const rows = [ + makeLine({ + id: "deletion-row", + content: "const removed = `open", + change: "delete", + oldLineNumber: 1, + newLineNumber: null, + }), + additionRow, + ]; + + const [highlighted, standalone] = await Promise.all([ + highlight(rows), + highlight([additionRow]), + ]); + + expect(highlighted.tokensByRowId[additionRow.id]).toEqual( + standalone.tokensByRowId[additionRow.id], + ); + }); +}); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts index 6c8c957f541..14158e61c7d 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts @@ -56,6 +56,11 @@ interface NativeReviewDiffLineRow extends NativeReviewDiffRow { readonly content: string; } +interface IndexedNativeReviewDiffLineRow { + readonly row: NativeReviewDiffLineRow; + readonly rowIndex: number; +} + export interface NativeReviewDiffTokenChunk { readonly chunkIndex: number; readonly fileId: string; @@ -308,6 +313,58 @@ function isHighlightableLineRow(row: NativeReviewDiffRow): row is NativeReviewDi return row.kind === "line" && typeof row.fileId === "string" && typeof row.content === "string"; } +function hasConsecutiveLineNumbers( + previous: number | null | undefined, + next: number | null | undefined, +): boolean { + return typeof previous === "number" && typeof next === "number" && next === previous + 1; +} + +function hasOnlyCommentRowsBetween( + rows: ReadonlyArray, + previousRowIndex: number, + nextRowIndex: number, +): boolean { + for (let rowIndex = previousRowIndex + 1; rowIndex < nextRowIndex; rowIndex += 1) { + if (rows[rowIndex]?.kind !== "comment") { + return false; + } + } + return true; +} + +function canShareGrammarContext( + previous: IndexedNativeReviewDiffLineRow, + next: IndexedNativeReviewDiffLineRow, + rows: ReadonlyArray, +): boolean { + if ( + next.row.fileId !== previous.row.fileId || + !hasOnlyCommentRowsBetween(rows, previous.rowIndex, next.rowIndex) + ) { + return false; + } + + if (previous.row.change === "delete" || next.row.change === "delete") { + return ( + previous.row.change !== "add" && + next.row.change !== "add" && + hasConsecutiveLineNumbers(previous.row.oldLineNumber, next.row.oldLineNumber) + ); + } + + if (previous.row.change === "add" || next.row.change === "add") { + return hasConsecutiveLineNumbers(previous.row.newLineNumber, next.row.newLineNumber); + } + + return ( + previous.row.change === "context" && + next.row.change === "context" && + hasConsecutiveLineNumbers(previous.row.oldLineNumber, next.row.oldLineNumber) && + hasConsecutiveLineNumbers(previous.row.newLineNumber, next.row.newLineNumber) + ); +} + function groupLineRowsByFileId(rows: ReadonlyArray) { const rowsByFileId = new Map(); for (const row of rows) { @@ -360,7 +417,7 @@ export async function highlightNativeReviewDiffVisibleRows( const maxRows = input.maxRows ?? NATIVE_REVIEW_DIFF_VISIBLE_MAX_ROWS; const startIndex = clampRowIndex(input.firstRowIndex - overscanRows, input.rows); const endIndex = clampRowIndex(input.lastRowIndex + overscanRows, input.rows); - const selectedRows: NativeReviewDiffLineRow[] = []; + const selectedRows: IndexedNativeReviewDiffLineRow[] = []; for ( let rowIndex = startIndex; @@ -374,12 +431,12 @@ export async function highlightNativeReviewDiffVisibleRows( !input.alreadyHighlightedRowIds?.has(row.id) && fileMap.has(row.fileId) ) { - selectedRows.push(row); + selectedRows.push({ row, rowIndex }); } } const tokensByRowId: Record> = {}; - let segmentRows: NativeReviewDiffLineRow[] = []; + let segmentRows: IndexedNativeReviewDiffLineRow[] = []; let segmentFile: NativeReviewDiffFile | undefined; const flushSegment = () => { @@ -389,27 +446,34 @@ export async function highlightNativeReviewDiffVisibleRows( return; } - const code = segmentRows.map((row) => row.content).join("\n"); + const code = segmentRows.map(({ row }) => row.content).join("\n"); const tokenLines = highlighter.tokenize(code, { lang: segmentFile.language, theme }); - segmentRows.forEach((row, rowIndex) => { + segmentRows.forEach(({ row }, rowIndex) => { tokensByRowId[row.id] = tokenLines[rowIndex] ?? makePlainTokenFallback(row); }); segmentRows = []; segmentFile = undefined; }; - for (const row of selectedRows) { + for (const selectedRow of selectedRows) { + const { row } = selectedRow; const file = fileMap.get(row.fileId); if (!file) { continue; } - if (segmentFile && segmentFile.id !== file.id) { + const previousRow = segmentRows.at(-1); + if ( + segmentFile && + (segmentFile.id !== file.id || + (previousRow !== undefined && + !canShareGrammarContext(previousRow, selectedRow, input.rows))) + ) { flushSegment(); } segmentFile = file; - segmentRows.push(row); + segmentRows.push(selectedRow); } flushSegment(); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts index 0409655583b..438b50a27ee 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts @@ -96,5 +96,22 @@ describe("isPendingNativeViewRegistration", () => { new Error("Unable to find the 'T3ReviewDiffView' view for this native tag"), ), ).toBe(false); + expect( + isPendingNativeViewRegistration( + new Error( + "Unable to find the class expo.modules.t3reviewdiff.T3ReviewDiffView view with tag 1150", + ), + ), + ).toBe(true); + }); +}); + +describe("isNativeReviewDiffDrawEvent", () => { + it("accepts only native events emitted after diff rows draw", async () => { + const { isNativeReviewDiffDrawEvent } = await import("./nativeReviewDiffSurface"); + + expect(isNativeReviewDiffDrawEvent({ message: "draw-metrics" })).toBe(true); + expect(isNativeReviewDiffDrawEvent({ message: "visible-range" })).toBe(true); + expect(isNativeReviewDiffDrawEvent({ message: "rows-decoded" })).toBe(false); }); }); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts index 551e577b294..fda73410668 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts @@ -149,6 +149,10 @@ export interface NativeReviewDiffViewHandle { readonly scrollToTop: (animated?: boolean) => Promise; } +export function isNativeReviewDiffDrawEvent(payload: Readonly>): boolean { + return payload.message === "draw-metrics" || payload.message === "visible-range"; +} + interface NativeReviewDiffViewRef { readonly setRowsJson: (rowsJson: string) => Promise; readonly setTokensJson: (tokensJson: string) => Promise; @@ -170,9 +174,11 @@ let nativeReviewDiffViewResolutionFailed = false; type NativeReviewDiffPayloadMethod = "setRowsJson" | "setTokensJson" | "setTokensPatchJson"; export function isPendingNativeViewRegistration(error: unknown): boolean { + if (!(error instanceof Error)) return false; return ( - error instanceof Error && - error.message.includes(`Unable to find the '${NATIVE_REVIEW_DIFF_MODULE_NAME}' view`) + error.message.includes(`Unable to find the '${NATIVE_REVIEW_DIFF_MODULE_NAME}' view`) || + (error.message.includes("Unable to find the class") && + error.message.includes("T3ReviewDiffView view with tag")) ); } diff --git a/apps/mobile/src/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx index 4109ce2a999..8b5892f3a09 100644 --- a/apps/mobile/src/features/files/FileMarkdownPreview.tsx +++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx @@ -8,6 +8,7 @@ import { import { RefreshControl, ScrollView, Text as NativeText, View } from "react-native"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; +import { useFontFamily } from "../../lib/useFontFamily"; import { resolveMarkdownFontSizes, resolveNativeMarkdownTypography, @@ -45,11 +46,15 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { const codeBackground = String(useThemeColor("--color-md-code-bg")); const codeText = String(useThemeColor("--color-md-code-text")); const horizontalRule = String(useThemeColor("--color-md-hr")); + const regularFontFamily = useFontFamily("regular"); + const mediumFontFamily = useFontFamily("medium"); + const boldFontFamily = useFontFamily("bold"); return useMemo(() => { const renderers: CustomRenderers = { link: ({ href, children }) => ( { if (href) { void tryOpenExternalUrl(href, "markdown-link"); @@ -57,7 +62,6 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { }} style={{ color: link, - fontFamily: "DMSans_500Medium", textDecorationLine: "none", }} > @@ -74,11 +78,14 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { link, blockquote: blockquoteBorder, border: horizontalRule, + surface: "transparent", surfaceLight: blockquoteBackground, accent: link, tableBorder: horizontalRule, tableHeader: blockquoteBackground, tableHeaderText: strong, + tableRowOdd: blockquoteBackground, + tableRowEven: "transparent", code: codeText, codeBackground, }, @@ -86,21 +93,21 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { styles: { text: { color: body, - fontFamily: "DMSans_400Regular", + fontFamily: regularFontFamily, fontSize: markdownFontSizes.m, lineHeight: markdownFontSizes.bodyLineHeight, }, heading: { color: strong, - fontFamily: "DMSans_700Bold", + fontFamily: boldFontFamily, }, strong: { color: strong, - fontFamily: "DMSans_700Bold", + fontFamily: boldFontFamily, }, link: { color: link, - fontFamily: "DMSans_500Medium", + fontFamily: mediumFontFamily, }, blockquote: { backgroundColor: blockquoteBackground, @@ -141,9 +148,9 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { fontSize: nativeMarkdownTypography.fontSize, lineHeight: nativeMarkdownTypography.lineHeight, headingFontSizes: nativeMarkdownTypography.headingFontSizes, - fontFamily: "DMSans_400Regular", - headingFontFamily: "DMSans_700Bold", - boldFontFamily: "DMSans_700Bold", + fontFamily: regularFontFamily, + headingFontFamily: boldFontFamily, + boldFontFamily, }, }; }, [ @@ -155,8 +162,11 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { horizontalRule, link, markdownFontSizes, + mediumFontFamily, nativeMarkdownTypography, + regularFontFamily, strong, + boldFontFamily, ]); } diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index e0a9808bc3c..b29121201f5 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -1,5 +1,5 @@ import type { ProjectEntry } from "@t3tools/contracts"; -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../components/AppSymbol"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, @@ -246,7 +246,7 @@ export function FileTreeBrowser(props: { // flex-1 Views is ignored, which is why the tree rendered under the header with no blur. return ( item.node.path} contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 487d8271e1b..012f99536d2 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -1,14 +1,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - ActivityIndicator, - Platform, - Pressable, - ScrollView, - useColorScheme, - View, -} from "react-native"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { ActivityIndicator, Platform, useColorScheme, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; import { @@ -18,10 +11,11 @@ import { ThreadId, } from "@t3tools/contracts"; -import { AppText as Text } from "../../components/AppText"; +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; import { LoadingScreen } from "../../components/LoadingScreen"; -import { cn } from "../../lib/cn"; import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; @@ -48,7 +42,6 @@ import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; import { basename, - fileBreadcrumbs, isBrowserPreviewFile, isImagePreviewFile, isMarkdownPreviewFile, @@ -224,14 +217,7 @@ function FilesToolbarBottomFade() { pointerEvents="none" accessibilityElementsHidden importantForAccessibility="no-hide-descendants" - style={{ - bottom: 0, - height: 112, - left: 0, - position: "absolute", - right: 0, - zIndex: 1, - }} + className="absolute inset-x-0 bottom-0 z-[1] h-28" > @@ -254,7 +240,9 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { useAdaptiveWorkspaceLayout(); const [searchQuery, setSearchQuery] = useState(""); const colorScheme = useColorScheme(); + const isAndroid = Platform.OS === "android"; const highlightTheme = colorScheme === "dark" ? "dark" : "light"; + const iconColor = String(useThemeColor("--color-icon-muted")); const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( props.route.params, ); @@ -374,6 +362,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { Only genuinely dynamic options are set here. */} 0 ? projectName : undefined, // No refresh button: the list already supports pull-to-refresh. @@ -402,22 +391,55 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { }, }} /> - {layout.usesSplitView ? ( - - + - - ) : null} - {usesCompactMailToolbar ? null : ( - - - + + + + + + ) : ( + <> + {layout.usesSplitView ? ( + + + + ) : null} + {usesCompactMailToolbar ? null : ( + + + + )} + )} void; + readonly children: ReactNode; +}) { + if (Platform.OS !== "android") { + return <>{props.children}; + } + + return ; +} + +function AndroidHomeFab(props: { + readonly onStartNewTask: () => void; + readonly children: ReactNode; +}) { + const insets = useSafeAreaInsets(); + const primaryForegroundColor = useThemeColor("--color-primary-foreground"); + + return ( + + {props.children} + + + + + ); +} diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 652dc02fccb..82df2915147 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -3,12 +3,20 @@ import type { SidebarProjectGroupingMode, SidebarThreadSortOrder, } from "@t3tools/contracts"; -import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { useCallback, useRef } from "react"; -import { Platform } from "react-native"; +import type { MenuAction } from "@react-native-menu/menu"; +import { + NativeHeaderToolbar, + NativeStackScreenOptions, + type AppNativeStackNavigationOptions, +} from "../../native/StackHeader"; +import { useCallback, useMemo, useRef } from "react"; +import { Platform, Pressable, Text as RNText, TextInput, View } from "react-native"; import type { SearchBarCommands } from "react-native-screens"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; +import { ControlPillMenu } from "../../components/ControlPill"; +import { SymbolView } from "../../components/AppSymbol"; +import { T3Wordmark } from "../../components/T3Wordmark"; import { useThemeColor } from "../../lib/useThemeColor"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; @@ -26,10 +34,10 @@ import { } from "./home-list-options"; export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment; -const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); export function HomeHeader(props: { readonly environments: ReadonlyArray; + readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; @@ -42,7 +50,216 @@ export function HomeHeader(props: { readonly onOpenSettings: () => void; readonly onStartNewTask: () => void; }) { + if (Platform.OS === "android") { + return ; + } + + return ; +} + +type HomeHeaderProps = Parameters[0]; + +function checkedMenuState(checked: boolean) { + return checked ? ("on" as const) : undefined; +} + +function AndroidHomeHeader(props: HomeHeaderProps) { + const insets = useSafeAreaInsets(); + const iconColor = useThemeColor("--color-icon"); + const mutedColor = useThemeColor("--color-foreground-muted"); + const hasCustomListOptions = hasCustomHomeListOptions(props); + const menuActions = useMemo( + () => [ + { + id: "environment", + title: "Environment", + subactions: [ + { + id: "environment:all", + title: "All environments", + state: checkedMenuState(props.selectedEnvironmentId === null), + }, + ...props.environments.map((environment) => ({ + id: `environment:${environment.environmentId}`, + title: environment.label, + state: checkedMenuState(props.selectedEnvironmentId === environment.environmentId), + })), + ], + }, + { + id: "project-sort", + title: "Sort projects", + subactions: PROJECT_SORT_OPTIONS.map((option) => ({ + id: `project-sort:${option.value}`, + title: option.label, + state: checkedMenuState(props.projectSortOrder === option.value), + })), + }, + { + id: "thread-sort", + title: "Sort threads", + subactions: THREAD_SORT_OPTIONS.map((option) => ({ + id: `thread-sort:${option.value}`, + title: option.label, + state: checkedMenuState(props.threadSortOrder === option.value), + })), + }, + { + id: "project-grouping", + title: "Group projects", + subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({ + id: `project-grouping:${option.value}`, + title: option.label, + state: checkedMenuState(props.projectGroupingMode === option.value), + })), + }, + ], + [ + props.environments, + props.projectGroupingMode, + props.projectSortOrder, + props.selectedEnvironmentId, + props.threadSortOrder, + ], + ); + const handleMenuAction = useCallback( + (event: { nativeEvent: { event: string } }) => { + const id = event.nativeEvent.event; + if (id === "environment:all") { + props.onEnvironmentChange(null); + return; + } + + if (id.startsWith("environment:")) { + const environmentId = id.slice("environment:".length); + const environment = props.environments.find( + (candidate) => candidate.environmentId === environmentId, + ); + if (environment) { + props.onEnvironmentChange(environment.environmentId); + } + return; + } + + const projectSort = PROJECT_SORT_OPTIONS.find( + (option) => id === `project-sort:${option.value}`, + ); + if (projectSort) { + props.onProjectSortOrderChange(projectSort.value); + return; + } + + const threadSort = THREAD_SORT_OPTIONS.find((option) => id === `thread-sort:${option.value}`); + if (threadSort) { + props.onThreadSortOrderChange(threadSort.value); + return; + } + + const grouping = PROJECT_GROUPING_OPTIONS.find( + (option) => id === `project-grouping:${option.value}`, + ); + if (grouping) { + props.onProjectGroupingModeChange(grouping.value); + } + }, + [props], + ); + + return ( + <> + + + + + + {/* Mirrors the desktop SidebarBrand: T3 mark + muted "Code". */} + + + Code + + + + Alpha + + + + + + + + + + {/* Built identically to the filter button so the two circles + match exactly (ControlPill sizes via Tailwind classes and + resolves to a different box). */} + + + + + + + + + {props.searchQuery.length > 0 ? ( + props.onSearchQueryChange("")} + > + + + ) : null} + + + + + ); +} + +function IosHomeHeader(props: HomeHeaderProps) { const searchBarRef = useRef(null); + const propsRef = useRef(props); + propsRef.current = props; const iconColor = useThemeColor("--color-icon"); const hasCustomListOptions = hasCustomHomeListOptions(props); const focusSearch = useCallback(() => { @@ -50,63 +267,67 @@ export function HomeHeader(props: { return searchBarRef.current !== null; }, []); useHardwareKeyboardCommand("focusSearch", focusSearch); - const filterMenu = buildHomeListFilterMenu(props); + const screenOptions = useMemo((): AppNativeStackNavigationOptions => { + return { + headerTintColor: iconColor, + unstable_headerRightItems: + Platform.OS === "ios" + ? () => [ + withNativeGlassHeaderItem({ + accessibilityLabel: "Open settings", + icon: { name: "ellipsis", type: "sfSymbol" } as const, + identifier: "home-settings", + label: "", + onPress: () => propsRef.current.onOpenSettings(), + type: "button", + }), + ] + : undefined, + unstable_headerToolbarItems: + Platform.OS === "ios" + ? () => [ + createNativeMailSearchToolbarItem({ + composeButtonId: "home-new-task", + composeSystemImageName: "square.and.pencil", + // Build at invocation time so filter onPress handlers always + // read current callbacks via propsRef, matching compose/settings. + filterMenu: buildHomeListFilterMenu(propsRef.current), + filterButtonId: "home-filter", + filterSystemImageName: hasCustomListOptions + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease", + onComposePress: () => propsRef.current.onStartNewTask(), + onSearchTextChange: (query) => propsRef.current.onSearchQueryChange(query), + placeholder: "Search", + searchTextChangeId: "home-search-text", + }), + ] + : undefined, + headerSearchBarOptions: + Platform.OS === "ios" + ? undefined + : { + ref: searchBarRef, + allowToolbarIntegration: true, + hideNavigationBar: false, + placeholder: "Search", + onCancelButtonPress: () => propsRef.current.onSearchQueryChange(""), + onChangeText: (event) => propsRef.current.onSearchQueryChange(event.nativeEvent.text), + }, + }; + }, [ + hasCustomListOptions, + iconColor, + props.environments, + props.projectGroupingMode, + props.projectSortOrder, + props.selectedEnvironmentId, + props.threadSortOrder, + ]); return ( <> - [ - withNativeGlassHeaderItem({ - accessibilityLabel: "Open settings", - icon: { name: "ellipsis", type: "sfSymbol" } as const, - identifier: "home-settings", - label: "", - onPress: props.onOpenSettings, - type: "button", - }), - ] - : undefined, - unstable_headerToolbarItems: - Platform.OS === "ios" - ? () => [ - createNativeMailSearchToolbarItem({ - composeButtonId: "home-new-task", - composeSystemImageName: "square.and.pencil", - filterMenu, - filterButtonId: "home-filter", - filterSystemImageName: hasCustomListOptions - ? "line.3.horizontal.decrease.circle.fill" - : "line.3.horizontal.decrease", - onComposePress: props.onStartNewTask, - onSearchTextChange: props.onSearchQueryChange, - placeholder: "Search", - searchTextChangeId: "home-search-text", - }), - ] - : undefined, - headerSearchBarOptions: - Platform.OS === "ios" - ? undefined - : { - ref: searchBarRef, - allowToolbarIntegration: true, - hideNavigationBar: false, - placeholder: "Search", - onCancelButtonPress: () => { - props.onSearchQueryChange(""); - }, - onChangeText: (event) => { - props.onSearchQueryChange(event.nativeEvent.text); - }, - }, - }} - /> + {Platform.OS === "ios" ? null : ( diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index eaea597211f..d857fc7a44d 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -1,9 +1,10 @@ import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { useNavigation } from "@react-navigation/native"; -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; +import { renderCompactBrandTitle } from "../../components/CompactBrandTitle"; import { useProjects, useThreadShells } from "../../state/entities"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; @@ -11,12 +12,14 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; +import { AndroidHomeFabLayout } from "./AndroidHomeFab"; import { HomeScreen } from "./HomeScreen"; import { HomeHeader } from "./HomeHeader"; import { useHomeListOptions } from "./home-list-options"; import { usePendingTaskListActions } from "./usePendingTaskListActions"; import { useThreadListActions } from "./useThreadListActions"; +const EMPTY_HOME_TITLE_OPTIONS = { title: "", headerTitle: "" } as const; /* ─── Route screen ───────────────────────────────────────────────────── */ export function HomeRouteScreen() { @@ -56,94 +59,103 @@ export function HomeRouteScreen() { setThreadSortOrder, } = useHomeListOptions(availableEnvironmentIds); const selectedEnvironmentId = listOptions.selectedEnvironmentId; + const openSettings = useCallback(() => { + navigation.navigate("SettingsSheet", { screen: "Settings" }); + }, [navigation]); + const openNewTask = useCallback(() => { + navigation.navigate("NewTaskSheet", { screen: "NewTask" }); + }, [navigation]); // In split layouts the persistent sidebar IS the thread list — Home becomes // an empty detail pane so selecting a thread never transitions layouts. if (layout.usesSplitView) { return ( <> - + navigation.navigate("NewTaskSheet", { screen: "NewTask" })} + onPress={openNewTask} /> } /> - navigation.navigate("NewTaskSheet", { screen: "NewTask" })} - /> + ); } return ( - <> - {/* Restore the compact title in case the split branch blanked it. */} - - navigation.navigate("SettingsSheet", { screen: "Settings" })} - onProjectGroupingModeChange={setProjectGroupingMode} - onProjectSortOrderChange={setProjectSortOrder} - onSearchQueryChange={setSearchQuery} - onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} - onThreadSortOrderChange={setThreadSortOrder} - /> + + <> + {/* Restore the compact title in case the split branch blanked it. */} + + - - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }) - } - onArchiveThread={archiveThread} - onDeleteThread={confirmDeleteThread} - onEnvironmentChange={setSelectedEnvironmentId} - onOpenEnvironments={() => - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) - } - onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} - onProjectGroupingModeChange={setProjectGroupingMode} - onProjectSortOrderChange={setProjectSortOrder} - onSearchQueryChange={setSearchQuery} - onSelectThread={(thread) => { - navigation.navigate("Thread", { - environmentId: thread.environmentId, - threadId: thread.id, - }); - }} - onSelectPendingTask={openPendingTask} - onDeletePendingTask={confirmDeletePendingTask} - onNewThreadInProject={(project) => { - navigation.navigate("NewTaskSheet", { - screen: "NewTaskDraft", - params: { - environmentId: String(project.environmentId), - projectId: String(project.id), - title: project.title, - }, - }); - }} - onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} - onThreadSortOrderChange={setThreadSortOrder} - pendingTasks={pendingTasks} - projectGroupingMode={listOptions.projectGroupingMode} - projects={projects} - projectSortOrder={listOptions.projectSortOrder} - savedConnectionsById={savedConnectionsById} - searchQuery={searchQuery} - selectedEnvironmentId={selectedEnvironmentId} - threads={threads} - threadSortOrder={listOptions.threadSortOrder} - /> - + + navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }) + } + onArchiveThread={archiveThread} + onDeleteThread={confirmDeleteThread} + onEnvironmentChange={setSelectedEnvironmentId} + onOpenEnvironments={() => + navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) + } + onOpenSettings={openSettings} + onProjectGroupingModeChange={setProjectGroupingMode} + onProjectSortOrderChange={setProjectSortOrder} + onSearchQueryChange={setSearchQuery} + onSelectThread={(thread) => { + navigation.navigate("Thread", { + environmentId: thread.environmentId, + threadId: thread.id, + }); + }} + onSelectPendingTask={openPendingTask} + onDeletePendingTask={confirmDeletePendingTask} + onNewThreadInProject={(project) => { + navigation.navigate("NewTaskSheet", { + screen: "NewTaskDraft", + params: { + environmentId: String(project.environmentId), + projectId: String(project.id), + title: project.title, + }, + }); + }} + onStartNewTask={openNewTask} + onThreadSortOrderChange={setThreadSortOrder} + pendingTasks={pendingTasks} + projectGroupingMode={listOptions.projectGroupingMode} + projects={projects} + projectSortOrder={listOptions.projectSortOrder} + savedConnectionsById={savedConnectionsById} + searchQuery={searchQuery} + selectedEnvironmentId={selectedEnvironmentId} + threads={threads} + threadSortOrder={listOptions.threadSortOrder} + /> + + ); } diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 386880d76c8..28d809bb4f9 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -12,6 +12,8 @@ import type { SidebarProjectGroupingMode, SidebarThreadSortOrder, } from "@t3tools/contracts"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useMemo, useRef, useState } from "react"; import { ActivityIndicator, Platform, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; @@ -22,6 +24,7 @@ import { EmptyState } from "../../components/EmptyState"; import type { WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; import { scopedProjectKey } from "../../lib/scopedEntities"; +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { PendingTaskListRow, @@ -78,8 +81,12 @@ interface HomeScreenProps { /* ─── Layout constants ───────────────────────────────────────────────── */ const ESTIMATED_THREAD_ROW_HEIGHT = 72; -/** Height of the floating custom header on non-iOS platforms. */ -const CUSTOM_HEADER_HEIGHT = 78; +/** + * Top spacing between the list and the Android custom header. The Android + * header (AndroidHomeHeader) is rendered in-flow above this screen and + * already consumes the top safe-area inset, so the list only needs breathing + * room here. + */ function deriveEmptyState(props: { readonly catalogState: WorkspaceState; @@ -144,8 +151,8 @@ function deriveEmptyState(props: { }; } -function HomeTopContentSpacer(props: { readonly topInset: number }) { - return ; +function HomeTopContentSpacer() { + return ; } /* ─── Main screen ────────────────────────────────────────────────────── */ @@ -154,21 +161,47 @@ export function HomeScreen(props: HomeScreenProps) { const [groupDisplayStates, setGroupDisplayStates] = useState< ReadonlyMap >(() => new Map()); + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); const insets = useSafeAreaInsets(); const accentColor = useThemeColor("--color-icon-muted"); - - const updateGroupDisplay = useCallback((key: string, action: HomeGroupDisplayAction) => { - setGroupDisplayStates((previous) => { - const next = new Map(previous); - next.set( - key, - nextGroupDisplayState(previous.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action), - ); + const effectiveGroupDisplayStates = useMemo(() => { + const next = new Map(groupDisplayStates); + if (!AsyncResult.isSuccess(preferencesResult)) { return next; - }); - }, []); + } + for (const key of preferencesResult.value.collapsedProjectGroups ?? []) { + const existing = next.get(key); + next.set(key, { + ...(existing ?? DEFAULT_GROUP_DISPLAY_STATE), + collapsed: true, + }); + } + return next; + }, [groupDisplayStates, preferencesResult]); + const effectiveGroupDisplayStatesRef = useRef(effectiveGroupDisplayStates); + effectiveGroupDisplayStatesRef.current = effectiveGroupDisplayStates; + + const updateGroupDisplay = useCallback( + (key: string, action: HomeGroupDisplayAction) => { + const next = new Map(effectiveGroupDisplayStatesRef.current); + next.set(key, nextGroupDisplayState(next.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action)); + effectiveGroupDisplayStatesRef.current = next; + setGroupDisplayStates(next); + if (action === "toggle-collapsed") { + const collapsedProjectGroups: string[] = []; + for (const [groupKey, state] of next) { + if (state.collapsed) { + collapsedProjectGroups.push(groupKey); + } + } + savePreferences({ collapsedProjectGroups }); + } + }, + [savePreferences], + ); const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { if (openSwipeableRef.current !== methods) { @@ -219,10 +252,10 @@ export function HomeScreen(props: HomeScreenProps) { () => buildHomeListLayout({ groups: projectGroups, - displayStates: groupDisplayStates, + displayStates: effectiveGroupDisplayStates, showAllThreads: hasSearchQuery, }), - [projectGroups, groupDisplayStates, hasSearchQuery], + [projectGroups, effectiveGroupDisplayStates, hasSearchQuery], ); const projectCwdByKey = useMemo(() => { @@ -355,7 +388,7 @@ export function HomeScreen(props: HomeScreenProps) { className="flex-1 items-center justify-center bg-screen px-8" style={{ paddingBottom: Math.max(insets.bottom, 24), - paddingTop: Platform.OS === "ios" ? insets.top + 72 : insets.top, + paddingTop: Platform.OS === "ios" ? insets.top + 72 : 0, }} > @@ -379,10 +412,10 @@ export function HomeScreen(props: HomeScreenProps) { const listHeader = ( <> - {Platform.OS === "ios" ? null : } + {Platform.OS === "ios" ? null : } {shouldShowConnectionStatus && Platform.OS === "ios" ? ( - + { expect(groups[0]?.threads.map((thread) => thread.environmentId)).toEqual([remoteEnvironmentId]); }); + it("excludes subagent threads", () => { + const environmentId = EnvironmentId.make("environment-1"); + const project = makeProject({ + environmentId, + id: ProjectId.make("project-1"), + title: "T3 Code", + }); + const parentThreadId = ThreadId.make("thread-parent"); + const forkThreadId = ThreadId.make("thread-fork"); + const threads = [ + makeThread({ + environmentId, + id: parentThreadId, + projectId: project.id, + title: "Parent thread", + }), + makeThread({ + environmentId, + id: forkThreadId, + projectId: project.id, + title: "Forked thread", + lineage: { + rootThreadId: parentThreadId, + parentThreadId, + relationshipToParent: "fork", + }, + }), + makeThread({ + environmentId, + id: ThreadId.make("thread-subagent"), + projectId: project.id, + title: "Continue the delegated task", + lineage: { + rootThreadId: parentThreadId, + parentThreadId, + relationshipToParent: "subagent", + }, + }), + ]; + + const groups = buildGroups([project], threads); + + expect(groups).toHaveLength(1); + expect(groups[0]?.threads.map((thread) => thread.id).toSorted()).toEqual( + [parentThreadId, forkThreadId].toSorted(), + ); + }); + it("matches web repository, repository-path, and separate grouping modes", () => { const environmentId = EnvironmentId.make("environment-1"); const repositoryIdentity = { diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index cada956d8bb..46656500fe3 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -165,6 +165,9 @@ export function buildHomeThreadGroups(input: { if (thread.archivedAt !== null) { continue; } + if (thread.lineage.relationshipToParent === "subagent") { + continue; + } if (input.environmentId !== null && thread.environmentId !== input.environmentId) { continue; } diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index dd412301077..84a9f32ee5e 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -1,4 +1,4 @@ -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../components/AppSymbol"; import * as Haptics from "expo-haptics"; import { createContext, @@ -32,7 +32,8 @@ import Animated, { import { AppText as Text } from "../../components/AppText"; -const ACTION_ITEM_WIDTH = 50; +// Wide enough for the longest action label ("Unarchive"). +const ACTION_ITEM_WIDTH = 58; const ACTION_CIRCLE_SIZE = 36; const ACTION_ICON_SIZE = 15; @@ -196,7 +197,7 @@ export function ThreadSwipeable(props: { swipeableRef.current?.reset(); }, [resetKey]); const handleFullSwipeArmedChange = useCallback((armed: boolean) => { - if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") { + if (armed && !fullSwipeArmedRef.current) { void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); } fullSwipeArmedRef.current = armed; @@ -410,7 +411,9 @@ function SwipeActionButton(props: { - {props.label} + + {props.label} + diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index cc5d0dd047f..8effdc942d9 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -4,6 +4,7 @@ import * as Haptics from "expo-haptics"; import { useCallback, useRef } from "react"; import { Alert } from "react-native"; +import { showConfirmDialog } from "../../components/ConfirmDialogHost"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -21,9 +22,7 @@ function actionFailureMessage(action: ThreadListAction, cause: Cause.Cause { - Alert.alert( - "Delete thread?", - `“${thread.title}” will be permanently deleted, including its terminal history.`, - [ + const title = "Delete thread?"; + const message = `“${thread.title}” will be permanently deleted, including its terminal history.`; + if (process.env.EXPO_OS === "ios") { + Alert.alert(title, message, [ { text: "Cancel", style: "cancel" }, { text: "Delete", @@ -92,8 +91,18 @@ function useConfirmDeleteThread( void executeAction("delete", thread); }, }, - ], - ); + ]); + return; + } + showConfirmDialog({ + title, + message, + confirmText: "Delete", + destructive: true, + onConfirm: () => { + void executeAction("delete", thread); + }, + }); }, [executeAction], ); diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 88e08f1d9ac..d1327473b0d 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -476,16 +476,17 @@ export function AdaptiveWorkspaceLayout(props: { return ( - + {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? ( ) : null} - + - + {props.renderInspector?.()} diff --git a/apps/mobile/src/features/layout/workspace-pane-divider.tsx b/apps/mobile/src/features/layout/workspace-pane-divider.tsx index a892ab73655..3be3cdf602d 100644 --- a/apps/mobile/src/features/layout/workspace-pane-divider.tsx +++ b/apps/mobile/src/features/layout/workspace-pane-divider.tsx @@ -69,6 +69,7 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { return ( setHovered(true)} onHoverOut={() => setHovered(false)} - style={styles.hitTarget} > @@ -91,15 +91,6 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { } const styles = StyleSheet.create({ - hitTarget: { - alignSelf: "stretch", - cursor: "pointer", - justifyContent: "center", - marginHorizontal: -22, - position: "relative", - width: 44, - zIndex: 100, - }, line: { alignSelf: "center", backgroundColor: diff --git a/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx b/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx index ad53d75323c..d07a003361e 100644 --- a/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx +++ b/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx @@ -1,5 +1,6 @@ import { NativeHeaderToolbar } from "../../native/StackHeader"; import type { ReactNode } from "react"; +import { Platform } from "react-native"; import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; @@ -11,7 +12,7 @@ export function WorkspaceSidebarToolbar( ) { const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); - if (!layout.usesSplitView) { + if (Platform.OS === "android" || !layout.usesSplitView) { return null; } diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 0a123f995d7..d56855e6df6 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -23,7 +23,7 @@ import { } from "@t3tools/client-runtime/state/projects"; import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; import { StackActions, useNavigation } from "@react-navigation/native"; -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../components/AppSymbol"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -31,6 +31,7 @@ import * as Arr from "effect/Array"; import * as Cause from "effect/Cause"; import * as Order from "effect/Order"; import { AsyncResult } from "effect/unstable/reactivity"; +import { cn } from "../../lib/cn"; import { useProjects, useServerConfigs } from "../../state/entities"; import { filesystemEnvironment } from "../../state/filesystem"; @@ -99,10 +100,7 @@ function sourceFromParam(value: string | string[] | undefined): AddProjectRemote function SectionTitle(props: { readonly children: string }) { return ( - + {props.children} ); @@ -148,19 +146,17 @@ function ListRow(props: { readonly right?: ReactNode; readonly onPress?: () => void; }) { - const borderColor = useThemeColor("--color-border-subtle"); const chevronColor = useThemeColor("--color-chevron"); return ( ; export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProps) { + const isAndroid = Platform.OS === "android"; const navigation = useNavigation(); const insets = useSafeAreaInsets(); const { width } = useWindowDimensions(); @@ -143,15 +146,29 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp } } + const handleSubmit = useCallback(() => { + if (!target || !environmentId || !threadId || commentText.trim().length === 0) { + return; + } + + appendReviewCommentToDraft({ + environmentId, + threadId, + text: formatReviewCommentContext(target, commentText), + attachments, + }); + setAttachments([]); + dismissComposer(); + }, [attachments, commentText, dismissComposer, environmentId, target, threadId]); + return ( - - + + @@ -217,10 +234,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp style={{ height: codeSurface.rowHeight }} > - + {lineNumber ?? ""} @@ -253,8 +267,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp textAlignVertical="top" value={commentText} onChangeText={setCommentText} - className="h-full flex-1 border-0 bg-transparent px-0 py-0 font-sans text-base" - style={{ flex: 1, minHeight: 0 }} + className="h-full min-h-0 flex-1 border-0 bg-transparent px-0 py-0 font-sans text-base" /> @@ -279,7 +292,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp )} - {target ? ( + {!isAndroid && target ? ( { - if (!target || !environmentId || !threadId || commentText.trim().length === 0) { - return; - } - - appendReviewCommentToDraft({ - environmentId, - threadId, - text: formatReviewCommentContext(target, commentText), - attachments, - }); - setAttachments([]); - dismissComposer(); - }} + onPress={handleSubmit} /> ) : null} + {isAndroid && target ? ( + + + void handlePickImages()} + /> + + + + + ) : null} ; export function ReviewSheet(props: ReviewSheetProps) { + const isAndroid = Platform.OS === "android"; const { nativeReviewDiffStyle } = useAppearanceCodeSurface(); useAdaptiveWorkspacePaneRole("inspector"); const { panes, showAuxiliaryPane, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); @@ -362,7 +370,8 @@ export function ReviewSheet(props: ReviewSheetProps) { selectedThread !== null && String(selectedThread.id) === String(threadId); const selectedTheme = colorScheme === "dark" ? "dark" : "light"; // With a solid (non-overlay) header the content lays out below the header - // natively, so no manual top inset is needed. + // natively, so no manual top inset is needed. (Android renders its own + // in-flow AndroidScreenHeader, so it needs no inset either.) const topContentInset = 0; useEffect(() => { @@ -388,6 +397,7 @@ export function ReviewSheet(props: ReviewSheetProps) { }); const NativeReviewDiffView = resolveNativeReviewDiffView()!; const nativeReviewDiffViewRef = useRef(null); + const showcasedReviewDrawRef = useRef(null); // Native pull-to-refresh on the diff surface (replaces the old Refresh menu item). const [isPullRefreshing, setIsPullRefreshing] = useState(false); const handlePullToRefresh = useCallback(async () => { @@ -429,6 +439,25 @@ export function ReviewSheet(props: ReviewSheetProps) { selectedRowIds: commentSelection.selectedRowIds, canHighlight: parsedDiff.kind === "files", }); + const showcaseReviewKey = + SHOWCASE_ENABLED && parsedDiff.kind === "files" && selectedSection + ? `${reviewCache.threadKey}:${selectedSection.id}:${nativeBridge.tokensResetKey}` + : null; + const handleNativeDebug = useCallback( + (event: NativeSyntheticEvent>) => { + nativeBridge.onDebug(event); + if ( + showcaseReviewKey === null || + showcasedReviewDrawRef.current === showcaseReviewKey || + !isNativeReviewDiffDrawEvent(event.nativeEvent) + ) { + return; + } + showcasedReviewDrawRef.current = showcaseReviewKey; + markNativeShowcaseReady("review"); + }, + [nativeBridge.onDebug, showcaseReviewKey], + ); const handleSelectFile = useCallback( (fileId: string | null) => { @@ -498,6 +527,52 @@ export function ReviewSheet(props: ReviewSheetProps) { hasCachedSelectedDiff, hasAnyCachedDiff, }); + const androidSectionMenuActions = useMemo(() => { + const sectionAction = (section: ReviewSectionItem | null, title: string): MenuAction => ({ + id: section ? `section:${section.id}` : `unavailable:${title}`, + title: section?.id === selectedSection?.id ? `${title} (selected)` : title, + attributes: section ? undefined : { disabled: true }, + }); + const actions: MenuAction[] = [ + sectionAction(sectionMenu.workingTree, "Working tree"), + sectionAction(sectionMenu.branchChanges, "Branch changes"), + sectionAction(sectionMenu.latestTurn, "Latest turn"), + ]; + + if (sectionMenu.turns.length > 0) { + actions.push({ + id: "turns", + title: "Turn", + subactions: sectionMenu.turns.map((section) => ({ + id: `section:${section.id}`, + title: section.id === selectedSection?.id ? `${section.title} (selected)` : section.title, + subtitle: section.subtitle ?? undefined, + })), + }); + } + + // The Android native diff surface has no pull-to-refresh, so refresh + // stays a menu action there (iOS refreshes via pull-to-refresh instead). + actions.push({ + id: "refresh", + title: "Refresh current diff", + attributes: { + disabled: !selectedSection || selectedSection.isLoading, + }, + }); + return actions; + }, [sectionMenu, selectedSection]); + const handleAndroidSectionMenuAction = useCallback( + (event: { nativeEvent: { event: string } }) => { + const id = event.nativeEvent.event; + if (id === "refresh") { + void refreshSelectedSection(); + } else if (id.startsWith("section:")) { + selectSection(id.slice("section:".length)); + } + }, + [refreshSelectedSection, selectSection], + ); const handleRetryEnvironment = useCallback(() => { void retryEnvironment(environmentId); }, [environmentId, retryEnvironment]); @@ -511,6 +586,13 @@ export function ReviewSheet(props: ReviewSheetProps) { threadId: String(threadId), }); }, [environmentId, navigation, threadId]); + const androidHeaderSubtitle = [ + selectedSection?.title, + headerDiffSummary.additions, + headerDiffSummary.deletions, + ] + .filter((part): part is string => Boolean(part)) + .join(" · "); // The changed-files navigator lives in the workspace inspector column — // the single right-hand pane per route — instead of an in-screen panel. @@ -554,18 +636,45 @@ export function ReviewSheet(props: ReviewSheetProps) { return ( <> 0 ? headerSubtitle : undefined, - }} + options={ + isAndroid + ? // Android draws its own in-flow header (AndroidScreenHeader below). + { headerShown: false } + : { + // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS — the native + // diff scrolls internally, nothing for glass to sample). Only dynamic values + // here. + headerTintColor: headerIcon, + headerTitle: headerTitleText, + title: headerTitleText, + unstable_headerSubtitle: + Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined, + } + } /> + {isAndroid ? ( + + + + ) : null + } + /> + ) : null} + - {showSectionToolbar || panes.supportsAuxiliaryPane || gitMenuAvailable ? ( + {!isAndroid && (showSectionToolbar || panes.supportsAuxiliaryPane || gitMenuAvailable) ? ( {panes.supportsAuxiliaryPane ? ( {showConnectionNotice ? ( - + {listHeader} {!selectedSection ? ( diff --git a/apps/mobile/src/features/review/reviewDiffRendering.tsx b/apps/mobile/src/features/review/reviewDiffRendering.tsx index d7f5c5ff8a8..d00cf2be4da 100644 --- a/apps/mobile/src/features/review/reviewDiffRendering.tsx +++ b/apps/mobile/src/features/review/reviewDiffRendering.tsx @@ -48,8 +48,8 @@ export function ReviewChangeBar(props: { {Array.from({ length: Math.ceil(height / 2) }, (_, index) => ( - - + + ))} diff --git a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx index 46b7d210236..21bcad35e20 100644 --- a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx @@ -13,12 +13,10 @@ export function SettingsAppearanceRouteScreen() { diff --git a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx new file mode 100644 index 00000000000..9e18d4675fb --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx @@ -0,0 +1,215 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { SymbolView } from "expo-symbols"; +import { useMemo } from "react"; +import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AppText as Text } from "../../components/AppText"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { + clearClientCacheAtom, + clientCacheSummaryAtom, + type EnvironmentClientCacheSummary, +} from "../../state/client-cache-state"; +import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; +import { SettingsSection } from "./components/SettingsSection"; + +export function SettingsClientStorageRouteScreen() { + const insets = useSafeAreaInsets(); + const iconColor = useThemeColor("--color-icon"); + const dangerForegroundColor = useThemeColor("--color-danger-foreground"); + const summaryResult = useAtomValue(clientCacheSummaryAtom); + const clearResult = useAtomValue(clearClientCacheAtom); + const clearCache = useAtomSet(clearClientCacheAtom); + const { savedConnectionsById } = useSavedRemoteConnections(); + const isClearing = clearResult.waiting; + const summary = AsyncResult.isSuccess(summaryResult) ? summaryResult.value : null; + const environmentSummaries = useMemo( + () => + [...(summary?.environments ?? [])].sort((left, right) => { + const leftLabel = savedConnectionsById[left.environmentId]?.environmentLabel ?? ""; + const rightLabel = savedConnectionsById[right.environmentId]?.environmentLabel ?? ""; + return leftLabel.localeCompare(rightLabel); + }), + [savedConnectionsById, summary?.environments], + ); + + const confirmClearEnvironment = (environment: EnvironmentClientCacheSummary) => { + const label = + savedConnectionsById[environment.environmentId]?.environmentLabel ?? + environment.environmentId; + Alert.alert( + `Clear cache for ${label}?`, + "This removes offline threads, server metadata, and cached branches for this environment. The saved connection and credentials stay intact.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Clear Cache", + style: "destructive", + onPress: () => + clearCache({ type: "environment", environmentId: environment.environmentId }), + }, + ], + ); + }; + + const confirmClearAll = () => { + Alert.alert( + "Clear all client caches?", + "This removes offline data for every environment. Connections, credentials, account data, and app preferences stay intact.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Clear All Caches", + style: "destructive", + onPress: () => clearCache({ type: "all" }), + }, + ], + ); + }; + + return ( + + + + {AsyncResult.isFailure(summaryResult) ? ( + + + Storage unavailable + + Restart the app and try again. + + + ) : !summary ? ( + + + + Inspecting cached data… + + + ) : environmentSummaries.length > 0 ? ( + environmentSummaries.map((environment, index) => ( + confirmClearEnvironment(environment)} + /> + )) + ) : ( + + + No cached data + + Offline cache records will appear here after environments are used. + + + )} + + + + + + + + {summary ? `Clear ${formatBytes(summary.payloadBytes)}` : "Clear caches"} + + {isClearing ? : null} + + + + Clearing caches never removes environment connections, credentials, account data, or + appearance preferences. + + {AsyncResult.isFailure(summaryResult) || AsyncResult.isFailure(clearResult) ? ( + + Client storage is temporarily unavailable. Try again after restarting the app. + + ) : null} + + + + ); +} + +function CacheEnvironmentRow(props: { + readonly environment: EnvironmentClientCacheSummary; + readonly environmentLabel: string; + readonly disabled: boolean; + readonly first: boolean; + readonly onClear: () => void; +}) { + const iconColor = useThemeColor("--color-icon"); + return ( + + + + {props.environmentLabel} + + + + Clear {formatBytes(props.environment.payloadBytes)} + + + + ); +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`; +} diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index d5eecb5016f..93b806f6487 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -1,12 +1,13 @@ -import { NativeHeaderToolbar } from "../../native/StackHeader"; +import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useNavigation } from "@react-navigation/native"; -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../components/AppSymbol"; import type { EnvironmentId } from "@t3tools/contracts"; -import { useCallback, useState } from "react"; -import { ScrollView, View } from "react-native"; +import { useCallback, useEffect, useState } from "react"; +import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; @@ -14,6 +15,15 @@ import { splitEnvironmentSections } from "../connection/environmentSections"; import { cn } from "../../lib/cn"; import { useThemeColor } from "../../lib/useThemeColor"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; +import { + applyShowcaseLocalEnvironmentDisplayUrls, + resolveShowcaseEnvironmentUpdateDisplayUrl, + SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS, + SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS, +} from "../showcase/showcaseEnvironmentRows"; +import { markNativeShowcaseReady } from "../showcase/nativeShowcaseScene"; + +const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; export function SettingsEnvironmentsRouteScreen() { const { @@ -24,37 +34,95 @@ export function SettingsEnvironmentsRouteScreen() { } = useRemoteConnections(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const { localEnvironments, connectedCloudEnvironments } = splitEnvironmentSections({ + const environmentSections = splitEnvironmentSections({ connectedEnvironments, cloudEnvironments: null, }); + const localEnvironments = SHOWCASE_ENABLED + ? applyShowcaseLocalEnvironmentDisplayUrls(environmentSections.localEnvironments) + : environmentSections.localEnvironments; + const connectedCloudEnvironments = SHOWCASE_ENABLED + ? SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS + : environmentSections.connectedCloudEnvironments; const hasLocalEnvironments = localEnvironments.length > 0; const [expandedId, setExpandedId] = useState(null); const accentColor = useThemeColor("--color-icon-muted"); const headerIconColor = useThemeColor("--color-icon"); + useEffect(() => { + if (!SHOWCASE_ENABLED) return; + const timer = setTimeout(() => markNativeShowcaseReady("environments"), 500); + return () => clearTimeout(timer); + }, []); + const handleToggle = useCallback((environmentId: EnvironmentId) => { setExpandedId((prev) => (prev === environmentId ? null : environmentId)); }, []); + const handleUpdateEnvironment = useCallback( + ( + environmentId: EnvironmentId, + updates: { readonly label: string; readonly displayUrl: string }, + ) => { + if (!SHOWCASE_ENABLED) return onUpdateEnvironment(environmentId, updates); + const actualEnvironment = environmentSections.localEnvironments.find( + (environment) => environment.environmentId === environmentId, + ); + const presentedEnvironment = localEnvironments.find( + (environment) => environment.environmentId === environmentId, + ); + return onUpdateEnvironment(environmentId, { + ...updates, + displayUrl: + actualEnvironment && presentedEnvironment + ? resolveShowcaseEnvironmentUpdateDisplayUrl({ + actualDisplayUrl: actualEnvironment.displayUrl, + presentedDisplayUrl: presentedEnvironment.displayUrl, + submittedDisplayUrl: updates.displayUrl, + }) + : updates.displayUrl, + }); + }, + [environmentSections.localEnvironments, localEnvironments, onUpdateEnvironment], + ); return ( - - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" })} - separateBackground - tintColor={headerIconColor} - /> - + {Platform.OS === "android" ? ( + <> + {/* Android renders its own in-screen header instead of the native bar. */} + + navigation.goBack()} + actions={[ + { + accessibilityLabel: "Add environment", + icon: "plus", + onPress: () => + navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }), + }, + ]} + /> + + ) : ( + + + navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }) + } + separateBackground + tintColor={headerIconColor} + /> + + )} {hasLocalEnvironments ? ( @@ -63,10 +131,7 @@ export function SettingsEnvironmentsRouteScreen() { handleToggle(environment.environmentId)} onReconnect={onReconnectEnvironment} onRemove={onRemoveEnvironmentPress} - onUpdate={onUpdateEnvironment} + onUpdate={handleUpdateEnvironment} /> ))} @@ -96,10 +161,16 @@ export function SettingsEnvironmentsRouteScreen() { )} - {hasCloudPublicConfig() ? ( + {hasCloudPublicConfig() || SHOWCASE_ENABLED ? ( ) : null} diff --git a/apps/mobile/src/features/settings/SettingsLegalRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsLegalRouteScreen.tsx new file mode 100644 index 00000000000..7254186cf4c --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsLegalRouteScreen.tsx @@ -0,0 +1,6 @@ +import { SettingsLegalDocumentRouteScreen } from "./components/SettingsLegalDocumentRouteScreen"; +import { LEGAL_URL } from "./lib/legal-document-url"; + +export function SettingsLegalRouteScreen() { + return ; +} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index a4a5499eeee..6c67a4d89e8 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -1,11 +1,13 @@ import { useAuth, useUser } from "@clerk/expo"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; import Constants from "expo-constants"; import * as Notifications from "expo-notifications"; import * as Updates from "expo-updates"; import { useNavigation } from "@react-navigation/native"; import { NativeStackScreenOptions } from "../../native/StackHeader"; -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../components/AppSymbol"; import * as Effect from "effect/Effect"; +import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { Alert, Linking, Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -17,7 +19,9 @@ import { settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text } from "../../components/AppText"; +import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities"; import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences"; import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions"; import { @@ -31,8 +35,8 @@ import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/pu import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { runtime } from "../../lib/runtime"; -import { loadPreferences } from "../../lib/storage"; import { useThemeColor } from "../../lib/useThemeColor"; +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; @@ -60,23 +64,31 @@ export function SettingsRouteScreen() { return ( <> - [ - withNativeGlassHeaderItem({ - accessibilityLabel: "Close settings", - icon: { name: "xmark", type: "sfSymbol" } as const, - identifier: "settings-close", - label: "", - onPress: () => navigation.goBack(), - type: "button", - }), - ] - : undefined, - }} - /> + {Platform.OS === "android" ? ( + <> + {/* Android renders its own in-screen header instead of the native bar. */} + + navigation.goBack()} /> + + ) : ( + [ + withNativeGlassHeaderItem({ + accessibilityLabel: "Close settings", + icon: { name: "xmark", type: "sfSymbol" } as const, + identifier: "settings-close", + label: "", + onPress: () => navigation.goBack(), + type: "button", + }), + ] + : undefined, + }} + /> + )} {hasCloudPublicConfig() ? : } ); @@ -92,12 +104,10 @@ function LocalSettingsRouteScreen() { @@ -122,6 +132,9 @@ function LocalSettingsRouteScreen() { } function ConfiguredSettingsRouteScreen() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const agentAwarenessPushAvailable = supportsAgentAwarenessPush(); const insets = useSafeAreaInsets(); const navigation = useNavigation(); const { expand: expandClerkSheet } = useClerkSettingsSheetDetent(); @@ -131,6 +144,9 @@ function ConfiguredSettingsRouteScreen() { const [notificationStatus, setNotificationStatus] = useState("checking"); const [liveActivityStatus, setLiveActivityStatus] = useState("checking"); const deviceRegistered = useDeviceRegistered(); + const liveActivitiesPreferenceEnabled = AsyncResult.isSuccess(preferencesResult) + ? preferencesResult.value.liveActivitiesEnabled !== false + : true; const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]); const environmentCount = connections.length; @@ -167,16 +183,19 @@ function ConfiguredSettingsRouteScreen() { setLiveActivityStatus("signed-out"); return; } - void (async () => { - const result = await settlePromise(() => loadPreferences()); - if (result._tag === "Failure") { - reportAtomCommandResult(result, { label: "live activity preference load" }); + if (!AsyncResult.isSuccess(preferencesResult)) { + if (AsyncResult.isFailure(preferencesResult)) { + reportAtomCommandResult(preferencesResult, { label: "live activity preference load" }); setLiveActivityStatus("enabled"); - return; + } else { + setLiveActivityStatus("checking"); } - setLiveActivityStatus(result.value.liveActivitiesEnabled === false ? "disabled" : "enabled"); - })(); - }, [isLoaded, isSignedIn]); + return; + } + setLiveActivityStatus( + preferencesResult.value.liveActivitiesEnabled === false ? "disabled" : "enabled", + ); + }, [isLoaded, isSignedIn, preferencesResult]); const requestNotifications = useCallback(async () => { const result = await settleAsyncResult(() => @@ -279,6 +298,7 @@ function ConfiguredSettingsRouteScreen() { runtime.runPromiseExit( setLiveActivityUpdatesEnabled({ enabled: true, + previousEnabled: liveActivitiesPreferenceEnabled, clerkToken: tokenResult.value, connections, }), @@ -296,6 +316,7 @@ function ConfiguredSettingsRouteScreen() { return; } + savePreferences({ liveActivitiesEnabled: true }); refreshManagedRelayEnvironments(); setLiveActivityStatus("enabled"); // The environment link can succeed while this device's own registration @@ -314,7 +335,15 @@ function ConfiguredSettingsRouteScreen() { "This device could not be registered with T3 Connect, so Live Activities won't appear yet. They'll start once registration succeeds.", ); } - }, [connections, environmentCount, getToken, isSignedIn, promptSignIn]); + }, [ + connections, + environmentCount, + getToken, + isSignedIn, + liveActivitiesPreferenceEnabled, + promptSignIn, + savePreferences, + ]); const handleDeviceNotificationsChange = useCallback( (enabled: boolean) => { @@ -358,17 +387,20 @@ function ConfiguredSettingsRouteScreen() { runtime.runPromiseExit( setLiveActivityUpdatesEnabled({ enabled: false, + previousEnabled: liveActivitiesPreferenceEnabled, clerkToken: token, connections, }), ), ); if (updateResult._tag === "Failure") { + setLiveActivityStatus("enabled"); reportAtomCommandResult(updateResult, { label: "live activity disable", }); return; } + savePreferences({ liveActivitiesEnabled: false }); refreshManagedRelayEnvironments(); })(); return; @@ -381,7 +413,15 @@ function ConfiguredSettingsRouteScreen() { void linkEnvironments(); }, - [connections, getToken, isSignedIn, linkEnvironments, promptSignIn], + [ + connections, + getToken, + isSignedIn, + linkEnvironments, + liveActivitiesPreferenceEnabled, + promptSignIn, + savePreferences, + ], ); const openAccount = useCallback(() => { @@ -399,12 +439,10 @@ function ConfiguredSettingsRouteScreen() { @@ -431,22 +469,32 @@ function ConfiguredSettingsRouteScreen() { + + (() => - resolveAppearancePreferences(null), + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const preferences = useMemo( + () => + resolveAppearancePreferences( + AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : null, + ), + [preferencesResult], ); - const [isReady, setIsReady] = useState(false); - - useEffect(() => { - let cancelled = false; - - void loadPreferences() - .then((stored) => { - if (cancelled) { - return; - } - - const resolved = resolveAppearancePreferences(stored); - setPreferences(resolved); - cacheTerminalFontSize(resolveAppearance(resolved).terminalFontSize); - setIsReady(true); - }) - .catch(() => { - if (cancelled) { - return; - } - - setIsReady(true); - }); - - return () => { - cancelled = true; - }; - }, []); + const isReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting; useEffect(() => { applyTextScaleVariables(preferences.baseFontSize); - }, [preferences.baseFontSize]); - - const updatePreferences = useCallback((patch: Partial) => { - setPreferences((current) => { - const next = resolveAppearancePreferences({ ...current, ...patch }); - cacheTerminalFontSize(resolveAppearance(next).terminalFontSize); - void savePreferencesPatch({ - baseFontSize: next.baseFontSize, - terminalFontSize: next.terminalFontSize, - codeFontSize: next.codeFontSize, - codeWordBreak: next.codeWordBreak, - }).catch(() => undefined); - return next; - }); - }, []); + cacheTerminalFontSize(resolveAppearance(preferences).terminalFontSize); + }, [preferences]); + + const updatePreferences = useCallback( + (patch: Partial) => { + savePreferences(patch); + }, + [savePreferences], + ); const setBaseFontSize = useCallback( (value: number) => { @@ -150,7 +118,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN } export function useAppearancePreferences(): AppearancePreferencesContextValue { - const context = useContext(AppearancePreferencesContext); + const context = use(AppearancePreferencesContext); if (!context) { throw new Error("useAppearancePreferences must be used within AppearancePreferencesProvider"); } diff --git a/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx index 5166ed11db7..c0e46d248e0 100644 --- a/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx +++ b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx @@ -1,5 +1,5 @@ import * as Haptics from "expo-haptics"; -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../../../components/AppSymbol"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { View, type AccessibilityActionEvent } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; @@ -136,7 +136,7 @@ export function FontSizeSliderRow(props: { }; return ( - + + + + navigation.goBack()} + className="p-2 active:opacity-60" + > + + + ); +} + +export function SettingsLegalDocumentExternalHeaderButton({ + externalUrl = LEGAL_URL, +}: { + readonly externalUrl?: string; +}) { + const iconColor = useThemeColor("--color-icon"); + const safeExternalUrl = isLegalDocumentUrl(externalUrl) ? externalUrl : LEGAL_URL; + + return ( + void Linking.openURL(safeExternalUrl).catch(() => undefined)} + className="p-2 active:opacity-60" + > + + + ); +} + +interface SettingsLegalDocumentRouteScreenProps { + readonly documentName: string; + readonly documentUrl: string; +} + +export function SettingsLegalDocumentRouteScreen({ + documentName, + documentUrl, +}: SettingsLegalDocumentRouteScreenProps) { + const navigation = useNavigation>(); + const iconColor = useThemeColor("--color-icon"); + const [reloadKey, setReloadKey] = useState(0); + const [loadProgress, setLoadProgress] = useState(0); + const [loadError, setLoadError] = useState(null); + const [externalUrl, setExternalUrl] = useState(documentUrl); + const renderExternalHeaderButton = useCallback( + () => , + [externalUrl], + ); + + useLayoutEffect(() => { + navigation.setOptions({ + headerRight: renderExternalHeaderButton, + }); + }, [navigation, renderExternalHeaderButton]); + + const openExternalUrl = useCallback((url: string) => { + void Linking.openURL(url).catch(() => undefined); + }, []); + if (loadError) { + return ( + + + + + Couldn't load the {documentName.toLowerCase()} + + + {loadError} + + + + { + setLoadError(null); + setReloadKey((value) => value + 1); + }} + className="items-center rounded-xl bg-foreground px-4 py-3 active:opacity-80" + > + Try Again + + openExternalUrl(documentUrl)} + className="items-center rounded-xl px-4 py-3 active:bg-foreground/5" + > + Open in Browser + + + + ); + } + + return ( + + {loadProgress > 0 && loadProgress < 1 ? : null} + { + if (isLegalDocumentUrl(request.url)) return true; + + openExternalUrl(request.url); + return false; + }} + onLoadProgress={(event) => { + setLoadProgress(event.nativeEvent.progress); + }} + onLoadStart={() => { + setLoadProgress(0.05); + setLoadError(null); + }} + onLoadEnd={(event) => { + if (isLegalDocumentUrl(event.nativeEvent.url)) { + setExternalUrl(event.nativeEvent.url); + } + setLoadProgress(0); + }} + onError={(event) => { + setLoadProgress(0); + setLoadError(event.nativeEvent.description || "The page could not be loaded."); + }} + onHttpError={(event) => { + if (!isLegalDocumentUrl(event.nativeEvent.url)) return; + setLoadProgress(0); + setLoadError(`The server returned status ${event.nativeEvent.statusCode}.`); + }} + renderLoading={() => ( + + + + )} + style={{ flex: 1, backgroundColor: "transparent" }} + /> + + ); +} diff --git a/apps/mobile/src/features/settings/components/SettingsRow.tsx b/apps/mobile/src/features/settings/components/SettingsRow.tsx index 23089c33950..2f435c3a47f 100644 --- a/apps/mobile/src/features/settings/components/SettingsRow.tsx +++ b/apps/mobile/src/features/settings/components/SettingsRow.tsx @@ -1,11 +1,12 @@ import { useNavigation } from "@react-navigation/native"; -import { SymbolView } from "expo-symbols"; import type { ComponentProps } from "react"; import { Pressable, View } from "react-native"; +import { SymbolView } from "../../../components/AppSymbol"; + import { AppText as Text } from "../../../components/AppText"; import { useThemeColor } from "../../../lib/useThemeColor"; -import type { SettingsSheetTarget } from "./settings-sheet-targets"; +import type { SettingsLegalDocumentTarget, SettingsSheetTarget } from "./settings-sheet-targets"; type SymbolName = ComponentProps["name"]; @@ -15,6 +16,7 @@ export function SettingsRow(props: { readonly label: string; readonly value?: string; readonly target?: SettingsSheetTarget; + readonly fullScreenTarget?: SettingsLegalDocumentTarget; readonly onPress?: () => void; }) { const navigation = useNavigation(); @@ -22,8 +24,11 @@ export function SettingsRow(props: { const chevron = useThemeColor("--color-chevron"); const content = ( @@ -68,6 +73,20 @@ export function SettingsRow(props: { ); } + const fullScreenTarget = props.fullScreenTarget; + if (fullScreenTarget) { + return ( + navigation.navigate(fullScreenTarget)} + > + {content} + + ); + } + return ( {content} diff --git a/apps/mobile/src/features/settings/components/SettingsSection.tsx b/apps/mobile/src/features/settings/components/SettingsSection.tsx index 7b4f0379161..9c87561a8ee 100644 --- a/apps/mobile/src/features/settings/components/SettingsSection.tsx +++ b/apps/mobile/src/features/settings/components/SettingsSection.tsx @@ -3,13 +3,21 @@ import { View } from "react-native"; import { AppText as Text } from "../../../components/AppText"; -export function SettingsSection(props: { readonly title: string; readonly children: ReactNode }) { +export function SettingsSection(props: { + readonly title: string; + readonly children: ReactNode; + /** Force the grouped card background; Android otherwise lists options flat. */ + readonly card?: boolean; +}) { return ( {props.title} {props.children} diff --git a/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx b/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx index 8620166eff2..38d707e231d 100644 --- a/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx +++ b/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx @@ -1,7 +1,7 @@ -import { SymbolView } from "expo-symbols"; import type { ComponentProps } from "react"; import { Switch, View } from "react-native"; +import { SymbolView } from "../../../components/AppSymbol"; import { AppText as Text } from "../../../components/AppText"; import { useThemeColor } from "../../../lib/useThemeColor"; @@ -20,8 +20,11 @@ export function SettingsSwitchRow(props: { return ( {props.label} diff --git a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts index 7de54bceee5..71c059bedb4 100644 --- a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts +++ b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts @@ -1 +1,7 @@ -export type SettingsSheetTarget = "SettingsEnvironments" | "SettingsArchive" | "SettingsAppearance"; +export type SettingsSheetTarget = + | "SettingsEnvironments" + | "SettingsArchive" + | "SettingsAppearance" + | "SettingsClientStorage"; + +export type SettingsLegalDocumentTarget = "SettingsLegal"; diff --git a/apps/mobile/src/features/settings/lib/legal-document-url.test.ts b/apps/mobile/src/features/settings/lib/legal-document-url.test.ts new file mode 100644 index 00000000000..10ce4ca4879 --- /dev/null +++ b/apps/mobile/src/features/settings/lib/legal-document-url.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isLegalDocumentUrl } from "./legal-document-url"; + +describe("isLegalDocumentUrl", () => { + it.each([ + "https://t3.codes/legal", + "https://t3.codes/legal/", + "https://t3.codes/privacy-policy?source=app", + "https://t3.codes/terms-of-service#updates", + "https://t3.codes/security-policy", + ])("allows a configured legal document: %s", (url) => { + expect(isLegalDocumentUrl(url)).toBe(true); + }); + + it.each([ + "https://t3.codes/download", + "https://example.com/legal", + "javascript:alert(1)", + "not-a-url", + ])("rejects a URL outside the legal-document allowlist: %s", (url) => { + expect(isLegalDocumentUrl(url)).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/settings/lib/legal-document-url.ts b/apps/mobile/src/features/settings/lib/legal-document-url.ts new file mode 100644 index 00000000000..4556375615d --- /dev/null +++ b/apps/mobile/src/features/settings/lib/legal-document-url.ts @@ -0,0 +1,58 @@ +const DEFAULT_MARKETING_SITE_URL = "https://t3.codes"; + +function resolveMarketingSiteUrl(override: string | undefined): URL { + try { + const url = new URL(override?.trim() || DEFAULT_MARKETING_SITE_URL); + if (url.protocol !== "https:" && url.protocol !== "http:") { + return new URL(DEFAULT_MARKETING_SITE_URL); + } + + url.search = ""; + url.hash = ""; + url.pathname = `${url.pathname.replace(/\/+$/, "")}/`; + return url; + } catch { + return new URL(DEFAULT_MARKETING_SITE_URL); + } +} + +const MARKETING_SITE_URL = resolveMarketingSiteUrl(process.env.EXPO_PUBLIC_MARKETING_SITE_URL); + +function marketingSiteDocumentUrl(path: string): string { + return new URL(path, MARKETING_SITE_URL).toString(); +} + +export const PRIVACY_POLICY_URL = marketingSiteDocumentUrl("privacy-policy"); +export const SECURITY_POLICY_URL = marketingSiteDocumentUrl("security-policy"); +export const TERMS_OF_SERVICE_URL = marketingSiteDocumentUrl("terms-of-service"); +export const LEGAL_URL = marketingSiteDocumentUrl("legal"); + +export const ALLOWED_LEGAL_DOCUMENT_URLS = [ + LEGAL_URL, + PRIVACY_POLICY_URL, + TERMS_OF_SERVICE_URL, + SECURITY_POLICY_URL, +] as const; + +function webDocumentIdentity(value: string): string | null { + try { + const url = new URL(value); + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + + const pathname = url.pathname.replace(/\/+$/, "") || "/"; + return `${url.origin}${pathname}`; + } catch { + return null; + } +} + +const ALLOWED_LEGAL_DOCUMENT_IDENTITIES = new Set( + ALLOWED_LEGAL_DOCUMENT_URLS.map(webDocumentIdentity).filter( + (value): value is string => value !== null, + ), +); + +export function isLegalDocumentUrl(value: string): boolean { + const identity = webDocumentIdentity(value); + return identity !== null && ALLOWED_LEGAL_DOCUMENT_IDENTITIES.has(identity); +} diff --git a/apps/mobile/src/features/sharing/IncomingShareProvider.tsx b/apps/mobile/src/features/sharing/IncomingShareProvider.tsx new file mode 100644 index 00000000000..04d371e5d2f --- /dev/null +++ b/apps/mobile/src/features/sharing/IncomingShareProvider.tsx @@ -0,0 +1,310 @@ +import Constants from "expo-constants"; +import * as Crypto from "expo-crypto"; +import { + clearSharedPayloads, + getResolvedSharedPayloadsAsync, + getSharedPayloads, + type ResolvedSharePayload, + type SharePayload, +} from "expo-sharing"; +import React, { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; +import { Alert, AppState, Platform } from "react-native"; + +import { + buildIncomingShareDraft, + type IncomingShareDestination, + type IncomingShareDraft, +} from "./incoming-share-model"; +import { IncomingShareInbox } from "./incoming-share-inbox"; +import { + loadIncomingShareDrafts, + removeIncomingShareDraft, + writeIncomingShareDraft, +} from "./incoming-share-storage"; + +type IncomingShareContextValue = { + readonly pendingShare: IncomingShareDraft | null; + readonly isLoading: boolean; + readonly error: Error | null; + readonly getShare: (shareId: string) => IncomingShareDraft | null; + readonly reserveShare: (shareId: string, destination: IncomingShareDestination) => Promise; + readonly releaseShareReservation: ( + shareId: string, + expectedDestination: IncomingShareDestination, + ) => Promise; + readonly consumeShare: (shareId: string) => Promise; + readonly refresh: () => Promise; +}; + +const IncomingShareContext = React.createContext(null); + +function receiveSharingEnabled(): boolean { + if (Platform.OS === "android") { + return true; + } + if (Platform.OS !== "ios") { + return false; + } + return Constants.expoConfig?.extra?.iosPersonalTeamBuild !== true; +} + +async function resolvedPayloadsForImages(): Promise> { + try { + return await getResolvedSharedPayloadsAsync(); + } catch (error) { + // iOS already gives the containing app a copied file:// URL, so raw + // payloads remain usable. Android normally resolves content:// into a + // private cache file; its modern File API can still read the raw URI when + // resolution fails. + console.warn("[incoming-share] could not resolve shared file metadata", error); + return []; + } +} + +async function incomingShareIdForPayloads(payloads: ReadonlyArray): Promise { + const fingerprint = JSON.stringify( + payloads.map((payload) => ({ + shareType: payload.shareType, + mimeType: payload.mimeType ?? null, + value: payload.value, + })), + ); + const digest = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, fingerprint); + return `share-${digest}`; +} + +async function readBase64(uri: string): Promise { + const { File } = await import("expo-file-system"); + return new File(uri).base64(); +} + +async function removeOwnedFile(uri: string): Promise { + if (!uri.startsWith("file:")) { + return; + } + try { + const { File } = await import("expo-file-system"); + const file = new File(uri); + if (file.exists) { + file.delete(); + } + } catch (error) { + console.warn("[incoming-share] could not remove temporary shared file", error); + } +} + +async function removeReplayedImagePayloadFiles( + payloads: ReadonlyArray, +): Promise { + const uris = new Set(); + for (const payload of payloads) { + if (payload.shareType === "image") { + uris.add(payload.value); + } + } + if (uris.size === 0) { + return; + } + const resolvedPayloads = await resolvedPayloadsForImages(); + for (const payload of resolvedPayloads) { + if (payload.shareType === "image" && payload.contentUri) { + uris.add(payload.contentUri); + } + } + await Promise.all([...uris].map(removeOwnedFile)); +} + +// Keep one operation queue across provider remounts (including development +// Strict Mode remounts) so two app lifecycle notifications cannot ingest the +// same native handoff independently. +const incomingShareInbox = new IncomingShareInbox({ + loadDrafts: loadIncomingShareDrafts, + writeDraft: writeIncomingShareDraft, + removeDraft: removeIncomingShareDraft, + getPayloads: getSharedPayloads, + clearPayloads: clearSharedPayloads, + buildDraft: async ({ payloads, id, createdAt }) => { + const cleanupUris = new Set(); + const resolvedPayloads = payloads.some((payload) => payload.shareType === "image") + ? await resolvedPayloadsForImages() + : []; + const draft = await buildIncomingShareDraft({ + payloads, + resolvedPayloads, + fileReader: { + readBase64, + removeOwnedFile: (uri) => { + cleanupUris.add(uri); + }, + }, + id, + createdAt, + }); + return { + draft, + cleanup: async () => { + await Promise.all([...cleanupUris].map(removeOwnedFile)); + }, + }; + }, + cleanupReplayedPayloads: removeReplayedImagePayloadFiles, + idForPayloads: incomingShareIdForPayloads, + now: () => new Date().toISOString(), + onClearError: (error) => { + console.warn("[incoming-share] could not acknowledge native payload", error); + }, + onCleanupError: (error) => { + console.warn("[incoming-share] could not remove temporary shared file", error); + }, +}); + +export function IncomingShareProvider(props: React.PropsWithChildren) { + const enabled = receiveSharingEnabled(); + const [drafts, setDrafts] = useState>([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const refreshPromiseRef = useRef | null>(null); + const mountedRef = useRef(false); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const refresh = useCallback(async () => { + if (refreshPromiseRef.current) { + return refreshPromiseRef.current; + } + + const operation = (async () => { + try { + const snapshot = await incomingShareInbox.refresh({ ingestNative: enabled }); + if (mountedRef.current) { + setDrafts(snapshot); + setError(null); + } + } catch (cause) { + const persisted = await incomingShareInbox + .refresh({ ingestNative: false }) + .catch(() => null); + if (mountedRef.current) { + if (persisted) { + setDrafts(persisted); + } + setError(cause instanceof Error ? cause : new Error("Could not import shared content.")); + } + } + })().finally(() => { + refreshPromiseRef.current = null; + }); + + refreshPromiseRef.current = operation; + return operation; + }, [enabled]); + + useEffect(() => { + void refresh().finally(() => { + if (mountedRef.current) { + setIsLoading(false); + } + }); + }, [refresh]); + + const refreshOnAppActive = useEffectEvent(() => { + void refresh(); + }); + + useEffect(() => { + if (!enabled) { + return; + } + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") { + refreshOnAppActive(); + } + }); + return () => subscription.remove(); + }, [enabled]); + + useEffect(() => { + if (!error) { + return; + } + Alert.alert("Could not import shared content", error.message, [ + { text: "Dismiss", style: "cancel", onPress: () => setError(null) }, + { + text: "Retry", + onPress: () => { + setError(null); + void refresh(); + }, + }, + ]); + }, [error, refresh]); + + const consumeShare = useCallback(async (shareId: string) => { + const snapshot = await incomingShareInbox.consume(shareId); + if (mountedRef.current) { + setDrafts(snapshot); + } + }, []); + const reserveShare = useCallback( + async (shareId: string, destination: IncomingShareDestination) => { + const snapshot = await incomingShareInbox.reserve(shareId, destination); + if (mountedRef.current) { + setDrafts(snapshot); + } + }, + [], + ); + const releaseShareReservation = useCallback( + async (shareId: string, expectedDestination: IncomingShareDestination) => { + const snapshot = await incomingShareInbox.releaseReservation(shareId, expectedDestination); + if (mountedRef.current) { + setDrafts(snapshot); + } + }, + [], + ); + const getShare = useCallback( + (shareId: string) => drafts.find((draft) => draft.id === shareId) ?? null, + [drafts], + ); + + const value = useMemo( + () => ({ + pendingShare: drafts[0] ?? null, + isLoading, + error, + getShare, + releaseShareReservation, + reserveShare, + consumeShare, + refresh, + }), + [ + consumeShare, + drafts, + error, + getShare, + isLoading, + refresh, + releaseShareReservation, + reserveShare, + ], + ); + + return ( + {props.children} + ); +} + +export function useIncomingShare(): IncomingShareContextValue { + const value = React.use(IncomingShareContext); + if (value === null) { + throw new Error("useIncomingShare must be used within IncomingShareProvider."); + } + return value; +} diff --git a/apps/mobile/src/features/sharing/incoming-share-inbox.test.ts b/apps/mobile/src/features/sharing/incoming-share-inbox.test.ts new file mode 100644 index 00000000000..ff50c6a917a --- /dev/null +++ b/apps/mobile/src/features/sharing/incoming-share-inbox.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it, vi } from "@effect/vitest"; +import type { SharePayload } from "expo-sharing"; + +import type { IncomingShareDraft } from "./incoming-share-model"; +import { IncomingShareInbox, type IncomingShareInboxDependencies } from "./incoming-share-inbox"; + +const PAYLOAD: SharePayload = { + shareType: "text", + mimeType: "text/plain", + value: "Fix this", +}; + +function draft(id: string, createdAt = "2026-07-16T08:00:00.000Z"): IncomingShareDraft { + return { + schemaVersion: 1, + id, + createdAt, + text: PAYLOAD.value, + attachments: [], + warnings: [], + }; +} + +function deferred() { + let resolve!: () => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +function createHarness(overrides: Partial = {}) { + const persisted = new Map(); + let payloads: ReadonlyArray = [PAYLOAD]; + const dependencies: IncomingShareInboxDependencies = { + loadDrafts: async () => [...persisted.values()], + writeDraft: async (value) => { + persisted.set(value.id, value); + }, + removeDraft: async (shareId) => { + persisted.delete(shareId); + }, + getPayloads: () => payloads, + clearPayloads: () => { + payloads = []; + }, + buildDraft: async ({ id, createdAt }) => ({ + draft: draft(id, createdAt), + cleanup: async () => undefined, + }), + idForPayloads: async () => "share-stable", + now: () => "2026-07-16T08:00:00.000Z", + ...overrides, + }; + return { inbox: new IncomingShareInbox(dependencies), persisted }; +} + +describe("IncomingShareInbox", () => { + it("coalesces a replay of an already-persisted native handoff", async () => { + const buildDraft = vi.fn(async ({ id, createdAt }) => ({ + draft: draft(id, createdAt), + cleanup: async () => undefined, + })); + const cleanupReplayedPayloads = vi.fn(async () => undefined); + const { inbox, persisted } = createHarness({ buildDraft, cleanupReplayedPayloads }); + persisted.set("share-stable", draft("share-stable")); + + await expect(inbox.refresh({ ingestNative: true })).resolves.toEqual([draft("share-stable")]); + expect(buildDraft).not.toHaveBeenCalled(); + expect(cleanupReplayedPayloads).toHaveBeenCalledWith([PAYLOAD]); + }); + + it("serializes concurrent refreshes so one native payload creates one inbox item", async () => { + const building = deferred(); + const buildDraft = vi.fn(async ({ id, createdAt }) => { + await building.promise; + return { draft: draft(id, createdAt), cleanup: async () => undefined }; + }); + const { inbox } = createHarness({ buildDraft }); + + const first = inbox.refresh({ ingestNative: true }); + const second = inbox.refresh({ ingestNative: true }); + building.resolve(); + + await expect(Promise.all([first, second])).resolves.toEqual([ + [draft("share-stable")], + [draft("share-stable")], + ]); + expect(buildDraft).toHaveBeenCalledTimes(1); + }); + + it("orders consumption after an in-flight refresh without restoring stale state", async () => { + const building = deferred(); + const { inbox, persisted } = createHarness({ + buildDraft: async ({ id, createdAt }) => { + await building.promise; + return { draft: draft(id, createdAt), cleanup: async () => undefined }; + }, + }); + + const refresh = inbox.refresh({ ingestNative: true }); + const consume = inbox.consume("share-stable"); + building.resolve(); + + await expect(refresh).resolves.toEqual([draft("share-stable")]); + await expect(consume).resolves.toEqual([]); + expect([...persisted.values()]).toEqual([]); + }); + + it("consumes only the addressed share when another share has identical content", async () => { + const { inbox, persisted } = createHarness(); + persisted.set("share-first", draft("share-first", "2026-07-16T07:59:00.000Z")); + persisted.set("share-second", draft("share-second")); + + await expect(inbox.refresh({ ingestNative: false })).resolves.toEqual([ + draft("share-second"), + draft("share-first", "2026-07-16T07:59:00.000Z"), + ]); + await expect(inbox.consume("share-second")).resolves.toEqual([ + draft("share-first", "2026-07-16T07:59:00.000Z"), + ]); + expect([...persisted.values()]).toEqual([draft("share-first", "2026-07-16T07:59:00.000Z")]); + }); + + it("keeps content-identical shares addressable by their own ids", async () => { + const { inbox, persisted } = createHarness(); + persisted.set("share-open-flow", draft("share-open-flow", "2026-07-16T07:59:00.000Z")); + persisted.set("share-newer", draft("share-newer")); + + await expect(inbox.refresh({ ingestNative: false })).resolves.toEqual([ + draft("share-newer"), + draft("share-open-flow", "2026-07-16T07:59:00.000Z"), + ]); + }); + + it("does not acknowledge a supported payload when its durable write fails", async () => { + const clearPayloads = vi.fn(); + const cleanup = vi.fn(async () => undefined); + const { inbox } = createHarness({ + clearPayloads, + buildDraft: async ({ id, createdAt }) => ({ + draft: draft(id, createdAt), + cleanup, + }), + writeDraft: async () => { + throw new Error("disk full"); + }, + }); + + await expect(inbox.refresh({ ingestNative: true })).rejects.toThrow("disk full"); + expect(clearPayloads).not.toHaveBeenCalled(); + expect(cleanup).not.toHaveBeenCalled(); + }); + + it("durably reserves a share for one project before draft import", async () => { + const { inbox, persisted } = createHarness(); + persisted.set("share-stable", draft("share-stable")); + const destination = { environmentId: "environment-1", projectId: "project-1" }; + + await expect(inbox.reserve("share-stable", destination)).resolves.toEqual([ + { ...draft("share-stable"), destination }, + ]); + expect(persisted.get("share-stable")?.destination).toEqual(destination); + await expect(inbox.reserve("share-stable", destination)).resolves.toEqual([ + { ...draft("share-stable"), destination }, + ]); + }); + + it("rejects moving a reserved share to another project", async () => { + const { inbox, persisted } = createHarness(); + persisted.set("share-stable", { + ...draft("share-stable"), + destination: { environmentId: "environment-1", projectId: "project-1" }, + }); + + await expect( + inbox.reserve("share-stable", { + environmentId: "environment-1", + projectId: "project-2", + }), + ).rejects.toThrow("already reserved"); + expect(persisted.get("share-stable")?.destination?.projectId).toBe("project-1"); + }); + + it("conditionally releases a reservation when its project is unavailable", async () => { + const { inbox, persisted } = createHarness(); + const destination = { environmentId: "environment-1", projectId: "project-1" }; + persisted.set("share-stable", { ...draft("share-stable"), destination }); + + await expect(inbox.releaseReservation("share-stable", destination)).resolves.toEqual([ + draft("share-stable"), + ]); + expect(persisted.get("share-stable")?.destination).toBeUndefined(); + + await expect( + inbox.reserve("share-stable", { + environmentId: "environment-2", + projectId: "project-2", + }), + ).resolves.toEqual([ + { + ...draft("share-stable"), + destination: { environmentId: "environment-2", projectId: "project-2" }, + }, + ]); + }); + + it("does not release a reservation that changed concurrently", async () => { + const { inbox, persisted } = createHarness(); + persisted.set("share-stable", { + ...draft("share-stable"), + destination: { environmentId: "environment-2", projectId: "project-2" }, + }); + + await expect( + inbox.releaseReservation("share-stable", { + environmentId: "environment-1", + projectId: "project-1", + }), + ).rejects.toThrow("reservation changed"); + expect(persisted.get("share-stable")?.destination?.projectId).toBe("project-2"); + }); + + it("treats an already-removed share as an already-released reservation", async () => { + const { inbox, persisted } = createHarness(); + persisted.set("share-other", draft("share-other")); + + await expect( + inbox.releaseReservation("share-stable", { + environmentId: "environment-1", + projectId: "project-1", + }), + ).resolves.toEqual([draft("share-other")]); + expect([...persisted.values()]).toEqual([draft("share-other")]); + }); +}); diff --git a/apps/mobile/src/features/sharing/incoming-share-inbox.ts b/apps/mobile/src/features/sharing/incoming-share-inbox.ts new file mode 100644 index 00000000000..1f61ea710bb --- /dev/null +++ b/apps/mobile/src/features/sharing/incoming-share-inbox.ts @@ -0,0 +1,193 @@ +import type { SharePayload } from "expo-sharing"; + +import { SerializedAsyncQueue } from "../../lib/serialized-async-queue"; +import { + hasIncomingShareContent, + type IncomingShareDestination, + type IncomingShareDraft, +} from "./incoming-share-model"; + +export interface IncomingShareInboxDependencies { + readonly loadDrafts: () => Promise>; + readonly writeDraft: (draft: IncomingShareDraft) => Promise; + readonly removeDraft: (shareId: string) => Promise; + readonly getPayloads: () => ReadonlyArray; + readonly clearPayloads: () => void; + readonly buildDraft: (input: { + readonly payloads: ReadonlyArray; + readonly id: string; + readonly createdAt: string; + }) => Promise<{ + readonly draft: IncomingShareDraft; + readonly cleanup: () => Promise; + }>; + readonly cleanupReplayedPayloads?: (payloads: ReadonlyArray) => Promise; + readonly idForPayloads: (payloads: ReadonlyArray) => Promise; + readonly now: () => string; + readonly onClearError?: (error: unknown) => void; + readonly onCleanupError?: (error: unknown) => void; +} + +export function sortAndDedupeIncomingShares( + drafts: ReadonlyArray, +): ReadonlyArray { + const ids = new Set(); + return [...drafts] + .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) + .filter((draft) => { + if (ids.has(draft.id)) { + return false; + } + ids.add(draft.id); + return true; + }); +} + +/** + * Serializes every durable inbox mutation. This prevents a stale storage load + * or a foreground refresh from restoring an item after it has been consumed. + */ +export class IncomingShareInbox { + private readonly operations = new SerializedAsyncQueue(); + + constructor(private readonly dependencies: IncomingShareInboxDependencies) {} + + private runExclusive(operation: () => Promise): Promise { + return this.operations.run(operation); + } + + private clearNativePayloads(): void { + try { + this.dependencies.clearPayloads(); + } catch (error) { + this.dependencies.onClearError?.(error); + } + } + + private async cleanup(operation: () => Promise): Promise { + try { + await operation(); + } catch (error) { + this.dependencies.onCleanupError?.(error); + } + } + + refresh(options: { readonly ingestNative: boolean }): Promise> { + return this.runExclusive(async () => { + const loaded = await this.dependencies.loadDrafts(); + const persisted = sortAndDedupeIncomingShares(loaded); + if (!options.ingestNative) { + return persisted; + } + + const payloads = this.dependencies.getPayloads(); + if (payloads.length === 0) { + return persisted; + } + + // A share extension payload remains available until the containing app + // acknowledges it. Use a content-derived id so a crash after the durable + // write but before acknowledgement reuses the same inbox item. + const shareId = await this.dependencies.idForPayloads(payloads); + if (loaded.some((draft) => draft.id === shareId)) { + if (this.dependencies.cleanupReplayedPayloads) { + await this.cleanup(() => this.dependencies.cleanupReplayedPayloads!(payloads)); + } + this.clearNativePayloads(); + return persisted; + } + + const built = await this.dependencies.buildDraft({ + payloads, + id: shareId, + createdAt: this.dependencies.now(), + }); + const { draft } = built; + if (!hasIncomingShareContent(draft)) { + // Unsupported native payloads cannot become actionable on retry and + // would otherwise reopen the project picker on every foreground. + await this.cleanup(built.cleanup); + this.clearNativePayloads(); + throw new Error( + draft.warnings[0] ?? "The shared content is not supported by the composer.", + ); + } + + // The durable inbox write is the transaction boundary. Never clear the + // native handoff first: a process termination must leave one recoverable + // copy on one side of the boundary. + await this.dependencies.writeDraft(draft); + await this.cleanup(built.cleanup); + this.clearNativePayloads(); + return sortAndDedupeIncomingShares([draft, ...persisted]); + }); + } + + consume(shareId: string): Promise> { + return this.runExclusive(async () => { + // The stable payload-derived id already coalesces retries of the same + // native handoff. Payload equality cannot identify duplicate handoffs: + // users may intentionally share identical content more than once. + await this.dependencies.removeDraft(shareId); + return sortAndDedupeIncomingShares(await this.dependencies.loadDrafts()); + }); + } + + reserve( + shareId: string, + destination: IncomingShareDestination, + ): Promise> { + return this.runExclusive(async () => { + const persisted = await this.dependencies.loadDrafts(); + const target = persisted.find((draft) => draft.id === shareId); + if (!target) { + throw new Error("The shared content is no longer available."); + } + if (target.destination) { + if ( + target.destination.environmentId !== destination.environmentId || + target.destination.projectId !== destination.projectId + ) { + throw new Error("The shared content is already reserved for another project draft."); + } + return sortAndDedupeIncomingShares(persisted); + } + + const reserved = { ...target, destination }; + await this.dependencies.writeDraft(reserved); + return sortAndDedupeIncomingShares( + persisted.map((draft) => (draft.id === shareId ? reserved : draft)), + ); + }); + } + + releaseReservation( + shareId: string, + expectedDestination: IncomingShareDestination, + ): Promise> { + return this.runExclusive(async () => { + const persisted = await this.dependencies.loadDrafts(); + const target = persisted.find((draft) => draft.id === shareId); + if (!target) { + // Conditional release is idempotent: if another operation already + // consumed the share, no reservation remains to clean up. + return sortAndDedupeIncomingShares(persisted); + } + if (!target.destination) { + return sortAndDedupeIncomingShares(persisted); + } + if ( + target.destination.environmentId !== expectedDestination.environmentId || + target.destination.projectId !== expectedDestination.projectId + ) { + throw new Error("The shared content reservation changed before it could be released."); + } + + const { destination: _destination, ...unreserved } = target; + await this.dependencies.writeDraft(unreserved); + return sortAndDedupeIncomingShares( + persisted.map((draft) => (draft.id === shareId ? unreserved : draft)), + ); + }); + } +} diff --git a/apps/mobile/src/features/sharing/incoming-share-model.test.ts b/apps/mobile/src/features/sharing/incoming-share-model.test.ts new file mode 100644 index 00000000000..07ede18b8ef --- /dev/null +++ b/apps/mobile/src/features/sharing/incoming-share-model.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it, vi } from "@effect/vitest"; +import { + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, +} from "@t3tools/contracts"; +import type { ResolvedSharePayload, SharePayload } from "expo-sharing"; + +import { buildIncomingShareDraft, hasIncomingShareContent } from "./incoming-share-model"; + +describe("incoming native shares", () => { + it("converts shared text, URLs, and images into a durable composer draft", async () => { + const image: SharePayload = { + shareType: "image", + value: "file:///shared/Screenshot.png", + mimeType: "image/png", + }; + const payloads: SharePayload[] = [ + { shareType: "text", value: "Please explain this error" }, + { shareType: "url", value: "https://example.com/issue/1" }, + { shareType: "text", value: "Please explain this error" }, + image, + ]; + const resolvedImage: ResolvedSharePayload = { + ...image, + contentUri: image.value, + contentType: "image", + contentMimeType: "image/png", + contentSize: 3, + originalName: "Screenshot.png", + }; + const removeOwnedFile = vi.fn(() => Promise.resolve()); + + const result = await buildIncomingShareDraft({ + id: "share-1", + createdAt: "2026-07-15T10:00:00.000Z", + payloads, + resolvedPayloads: [resolvedImage], + fileReader: { + readBase64: async () => "YWJj", + removeOwnedFile, + }, + }); + + expect(result).toEqual({ + schemaVersion: 1, + id: "share-1", + createdAt: "2026-07-15T10:00:00.000Z", + text: "Please explain this error\n\nhttps://example.com/issue/1", + attachments: [ + { + id: "share-1:image:3", + type: "image", + name: "Screenshot.png", + mimeType: "image/png", + sizeBytes: 3, + dataUrl: "data:image/png;base64,YWJj", + previewUri: "data:image/png;base64,YWJj", + }, + ], + warnings: [], + }); + expect(removeOwnedFile).toHaveBeenCalledWith(image.value); + expect(hasIncomingShareContent(result)).toBe(true); + }); + + it("skips oversized images and releases the temporary native file", async () => { + const image: SharePayload = { + shareType: "image", + value: "file:///shared/huge.png", + mimeType: "image/png", + }; + const readBase64 = vi.fn(async () => "unused"); + const removeOwnedFile = vi.fn(() => Promise.resolve()); + + const result = await buildIncomingShareDraft({ + id: "share-2", + createdAt: "2026-07-15T10:00:00.000Z", + payloads: [image], + resolvedPayloads: [ + { + ...image, + contentUri: image.value, + contentType: "image", + contentMimeType: "image/png", + contentSize: PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1, + originalName: "huge.png", + }, + ], + fileReader: { readBase64, removeOwnedFile }, + }); + + expect(result.attachments).toEqual([]); + expect(result.warnings).toEqual(["'huge.png' exceeds the 10 MB attachment limit."]); + expect(readBase64).not.toHaveBeenCalled(); + expect(removeOwnedFile).toHaveBeenCalledWith(image.value); + expect(hasIncomingShareContent(result)).toBe(false); + }); + + it("releases every temporary file when a share exceeds the attachment limit", async () => { + const payloads = Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS + 1 }, (_, index) => ({ + shareType: "image" as const, + value: `file:///shared/${index}.png`, + mimeType: "image/png", + })); + const removeOwnedFile = vi.fn(() => Promise.resolve()); + const readBase64 = vi.fn(async () => "YWJj"); + + const result = await buildIncomingShareDraft({ + id: "share-3", + createdAt: "2026-07-15T10:00:00.000Z", + payloads, + resolvedPayloads: [], + fileReader: { readBase64, removeOwnedFile }, + }); + + expect(result.attachments).toHaveLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS); + expect(result.warnings).toEqual([ + `Only the first ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} shared images were attached.`, + ]); + expect(readBase64).toHaveBeenCalledTimes(PROVIDER_SEND_TURN_MAX_ATTACHMENTS); + expect(removeOwnedFile).toHaveBeenCalledTimes(payloads.length); + }); + + it("maps duplicate image payloads to distinct resolved files", async () => { + const duplicate: SharePayload = { + shareType: "image", + value: "content://shared/screenshot", + mimeType: "image/png", + }; + const resolvedPayloads: ResolvedSharePayload[] = [ + { + ...duplicate, + contentUri: "file:///cache/first.png", + contentType: "image", + contentMimeType: "image/png", + contentSize: 3, + originalName: "first.png", + }, + { + ...duplicate, + contentUri: "file:///cache/second.png", + contentType: "image", + contentMimeType: "image/png", + contentSize: 3, + originalName: "second.png", + }, + ]; + const readBase64 = vi.fn(async (uri: string) => + uri.includes("first") ? "Zmlyc3Q=" : "c2Vjb25k", + ); + const removeOwnedFile = vi.fn(async () => undefined); + + const result = await buildIncomingShareDraft({ + id: "share-duplicates", + createdAt: "2026-07-16T08:00:00.000Z", + payloads: [duplicate, duplicate], + resolvedPayloads, + fileReader: { readBase64, removeOwnedFile }, + }); + + expect(readBase64.mock.calls.map(([uri]) => uri)).toEqual([ + "file:///cache/first.png", + "file:///cache/second.png", + ]); + expect(result.attachments.map((attachment) => attachment.name)).toEqual([ + "first.png", + "second.png", + ]); + expect(removeOwnedFile).toHaveBeenCalledWith("file:///cache/first.png"); + expect(removeOwnedFile).toHaveBeenCalledWith("file:///cache/second.png"); + }); + + it("keeps imported content when temporary-file cleanup fails", async () => { + const image: SharePayload = { + shareType: "image", + value: "file:///shared/screenshot.png", + mimeType: "image/png", + }; + + const result = await buildIncomingShareDraft({ + id: "share-cleanup-failure", + createdAt: "2026-07-16T08:00:00.000Z", + payloads: [image], + resolvedPayloads: [], + fileReader: { + readBase64: async () => "YWJj", + removeOwnedFile: async () => { + throw new Error("file is busy"); + }, + }, + }); + + expect(result.attachments).toHaveLength(1); + expect(result.warnings).toEqual([]); + }); +}); diff --git a/apps/mobile/src/features/sharing/incoming-share-model.ts b/apps/mobile/src/features/sharing/incoming-share-model.ts new file mode 100644 index 00000000000..873574209fc --- /dev/null +++ b/apps/mobile/src/features/sharing/incoming-share-model.ts @@ -0,0 +1,217 @@ +import { + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import type { ResolvedSharePayload, SharePayload } from "expo-sharing"; + +import { DraftComposerImageAttachmentSchema } from "../../lib/composer-image-schema"; +import type { DraftComposerImageAttachment } from "../../lib/composerImages"; +import { estimateBase64ByteSize } from "../../lib/base64"; + +export interface IncomingShareDraft { + readonly schemaVersion: 1; + readonly id: string; + readonly createdAt: string; + readonly destination?: IncomingShareDestination; + readonly text: string; + readonly attachments: ReadonlyArray; + readonly warnings: ReadonlyArray; +} + +export interface IncomingShareDestination { + readonly environmentId: string; + readonly projectId: string; +} + +const IncomingShareDestinationSchema = Schema.Struct({ + environmentId: Schema.String, + projectId: Schema.String, +}); + +export const IncomingShareDraftSchema = Schema.Struct({ + schemaVersion: Schema.Literal(1), + id: Schema.String, + createdAt: Schema.String, + destination: Schema.optional(IncomingShareDestinationSchema), + text: Schema.String, + attachments: Schema.Array(DraftComposerImageAttachmentSchema), + warnings: Schema.Array(Schema.String), +}); + +const decodeIncomingShareDraftSync = Schema.decodeUnknownSync(IncomingShareDraftSchema); + +export function decodeIncomingShareDraft(value: unknown): IncomingShareDraft { + return decodeIncomingShareDraftSync(value); +} + +export interface IncomingShareFileReader { + readonly readBase64: (uri: string) => Promise; + readonly removeOwnedFile: (uri: string) => Promise | void; +} + +function sharedText(payloads: ReadonlyArray): string { + const seen = new Set(); + const values: string[] = []; + for (const payload of payloads) { + if (payload.shareType !== "text" && payload.shareType !== "url") { + continue; + } + const value = payload.value.trim(); + if (!value || seen.has(value)) { + continue; + } + seen.add(value); + values.push(value); + } + return values.join("\n\n"); +} + +function resolvedImageFor( + payload: SharePayload, + index: number, + resolvedPayloads: ReadonlyArray, + consumedIndexes: Set, +): ResolvedSharePayload | undefined { + const sameIndex = resolvedPayloads[index]; + if ( + !consumedIndexes.has(index) && + sameIndex?.shareType === payload.shareType && + sameIndex.value === payload.value + ) { + consumedIndexes.add(index); + return sameIndex; + } + const matchingIndex = resolvedPayloads.findIndex( + (candidate, candidateIndex) => + !consumedIndexes.has(candidateIndex) && + candidate.shareType === payload.shareType && + candidate.value === payload.value, + ); + if (matchingIndex < 0) { + return undefined; + } + consumedIndexes.add(matchingIndex); + return resolvedPayloads[matchingIndex]; +} + +async function releaseOwnedFiles( + fileReader: IncomingShareFileReader, + uris: ReadonlyArray, +): Promise { + for (const uri of new Set(uris.filter((candidate): candidate is string => Boolean(candidate)))) { + try { + await fileReader.removeOwnedFile(uri); + } catch { + // Temporary-file cleanup is best-effort and must never discard content + // that was successfully converted into a durable composer attachment. + } + } +} + +function fallbackName(uri: string, index: number, mimeType: string): string { + try { + const pathName = new URL(uri).pathname.split("/").findLast((segment) => segment.length > 0); + if (pathName) { + return decodeURIComponent(pathName); + } + } catch { + // Fall through to a deterministic attachment name. + } + const extension = mimeType.split("/")[1]?.replace(/[^a-z0-9.+-]/gi, "") || "png"; + return `shared-image-${index + 1}.${extension}`; +} + +export async function buildIncomingShareDraft(input: { + readonly payloads: ReadonlyArray; + readonly resolvedPayloads: ReadonlyArray; + readonly fileReader: IncomingShareFileReader; + readonly id: string; + readonly createdAt: string; +}): Promise { + const attachments: DraftComposerImageAttachment[] = []; + const warnings: string[] = []; + const consumedResolvedPayloadIndexes = new Set(); + let warnedAttachmentLimit = false; + + for (const [index, payload] of input.payloads.entries()) { + if (payload.shareType !== "image") { + continue; + } + const resolved = resolvedImageFor( + payload, + index, + input.resolvedPayloads, + consumedResolvedPayloadIndexes, + ); + const uri = resolved?.contentUri ?? payload.value; + if (attachments.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { + if (!warnedAttachmentLimit) { + warnings.push( + `Only the first ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} shared images were attached.`, + ); + warnedAttachmentLimit = true; + } + await releaseOwnedFiles(input.fileReader, [uri, payload.value]); + continue; + } + + const mimeType = (resolved?.contentMimeType ?? payload.mimeType ?? "image/png").toLowerCase(); + if (!uri || !mimeType.startsWith("image/")) { + warnings.push("One shared item was not a supported image."); + await releaseOwnedFiles(input.fileReader, [uri, payload.value]); + continue; + } + if ( + resolved?.contentSize !== null && + resolved?.contentSize !== undefined && + resolved.contentSize > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + ) { + warnings.push( + `'${resolved.originalName ?? fallbackName(uri, index, mimeType)}' exceeds the 10 MB attachment limit.`, + ); + await releaseOwnedFiles(input.fileReader, [uri, payload.value]); + continue; + } + + try { + const base64 = await input.fileReader.readBase64(uri); + const sizeBytes = resolved?.contentSize ?? estimateBase64ByteSize(base64); + if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { + warnings.push( + `'${resolved?.originalName ?? fallbackName(uri, index, mimeType)}' exceeds the 10 MB attachment limit.`, + ); + continue; + } + const dataUrl = `data:${mimeType};base64,${base64}`; + attachments.push({ + id: `${input.id}:image:${index}`, + type: "image", + name: resolved?.originalName ?? fallbackName(uri, index, mimeType), + mimeType, + sizeBytes, + dataUrl, + // The share provider's file is temporary. A data-backed preview keeps + // the composer valid after its source file and App Group entry are gone. + previewUri: dataUrl, + }); + } catch { + warnings.push(`Could not read '${fallbackName(uri, index, mimeType)}'.`); + } finally { + await releaseOwnedFiles(input.fileReader, [uri, payload.value]); + } + } + + return { + schemaVersion: 1, + id: input.id, + createdAt: input.createdAt, + text: sharedText(input.payloads), + attachments, + warnings, + }; +} + +export function hasIncomingShareContent(draft: IncomingShareDraft): boolean { + return draft.text.trim().length > 0 || draft.attachments.length > 0; +} diff --git a/apps/mobile/src/features/sharing/incoming-share-presentation.test.ts b/apps/mobile/src/features/sharing/incoming-share-presentation.test.ts new file mode 100644 index 00000000000..c46ee6eb300 --- /dev/null +++ b/apps/mobile/src/features/sharing/incoming-share-presentation.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + EMPTY_INCOMING_SHARE_PRESENTATION_STATE, + transitionIncomingSharePresentation, +} from "./incoming-share-presentation"; + +describe("incoming share presentation", () => { + it("does not reopen a dismissed share when refresh returns a new object for the same id", () => { + const presented = transitionIncomingSharePresentation(EMPTY_INCOMING_SHARE_PRESENTATION_STATE, { + isShareSheetPresented: false, + pendingShareId: "share-1", + }); + expect(presented.shareIdToPresent).toBe("share-1"); + + const whilePresented = transitionIncomingSharePresentation(presented.state, { + isShareSheetPresented: true, + pendingShareId: "share-1", + }); + expect(whilePresented).toEqual({ state: presented.state, shareIdToPresent: null }); + + const dismissed = transitionIncomingSharePresentation(whilePresented.state, { + isShareSheetPresented: false, + pendingShareId: "share-1", + }); + expect(dismissed.state.dismissedShareId).toBe("share-1"); + + expect( + transitionIncomingSharePresentation(dismissed.state, { + isShareSheetPresented: false, + pendingShareId: "share-1", + }), + ).toEqual({ state: dismissed.state, shareIdToPresent: null }); + }); + + it("presents the next queued share immediately after the previous sheet closes", () => { + const first = transitionIncomingSharePresentation(EMPTY_INCOMING_SHARE_PRESENTATION_STATE, { + isShareSheetPresented: false, + pendingShareId: "share-1", + }); + + const next = transitionIncomingSharePresentation(first.state, { + isShareSheetPresented: false, + pendingShareId: "share-2", + }); + expect(next.shareIdToPresent).toBe("share-2"); + expect(next.state).toEqual({ presentedShareId: "share-2", dismissedShareId: null }); + }); + + it("forgets dismissal after consumption so a later handoff may reuse the id", () => { + const dismissed = { + presentedShareId: null, + dismissedShareId: "share-1", + }; + const consumed = transitionIncomingSharePresentation(dismissed, { + isShareSheetPresented: false, + pendingShareId: null, + }); + expect(consumed.state).toEqual(EMPTY_INCOMING_SHARE_PRESENTATION_STATE); + + expect( + transitionIncomingSharePresentation(consumed.state, { + isShareSheetPresented: false, + pendingShareId: "share-1", + }).shareIdToPresent, + ).toBe("share-1"); + }); + + it("forgets a consumed presentation while its sheet is still mounted", () => { + const presented = { + presentedShareId: "share-1", + dismissedShareId: null, + }; + const consumed = transitionIncomingSharePresentation(presented, { + isShareSheetPresented: true, + pendingShareId: null, + }); + expect(consumed.state).toEqual(EMPTY_INCOMING_SHARE_PRESENTATION_STATE); + + const replacementWhileOpen = transitionIncomingSharePresentation(consumed.state, { + isShareSheetPresented: true, + pendingShareId: "share-1", + }); + expect(replacementWhileOpen.shareIdToPresent).toBeNull(); + expect( + transitionIncomingSharePresentation(replacementWhileOpen.state, { + isShareSheetPresented: false, + pendingShareId: "share-1", + }).shareIdToPresent, + ).toBe("share-1"); + }); +}); diff --git a/apps/mobile/src/features/sharing/incoming-share-presentation.ts b/apps/mobile/src/features/sharing/incoming-share-presentation.ts new file mode 100644 index 00000000000..663d9dc17de --- /dev/null +++ b/apps/mobile/src/features/sharing/incoming-share-presentation.ts @@ -0,0 +1,71 @@ +export interface IncomingSharePresentationState { + readonly presentedShareId: string | null; + readonly dismissedShareId: string | null; +} + +export interface IncomingSharePresentationTransition { + readonly state: IncomingSharePresentationState; + readonly shareIdToPresent: string | null; +} + +export const EMPTY_INCOMING_SHARE_PRESENTATION_STATE: IncomingSharePresentationState = { + presentedShareId: null, + dismissedShareId: null, +}; + +/** + * Tracks presentation by durable share id rather than object identity. A user + * dismissal suppresses only that inbox item until it is consumed or replaced. + */ +export function transitionIncomingSharePresentation( + state: IncomingSharePresentationState, + input: { + readonly isShareSheetPresented: boolean; + readonly pendingShareId: string | null; + }, +): IncomingSharePresentationTransition { + if (input.isShareSheetPresented) { + if (state.presentedShareId !== null && input.pendingShareId !== state.presentedShareId) { + // Consumption may happen while the sheet remains mounted. Forget the + // old presentation immediately so a later handoff may reuse its id. + return { + state: EMPTY_INCOMING_SHARE_PRESENTATION_STATE, + shareIdToPresent: null, + }; + } + return { state, shareIdToPresent: null }; + } + + let nextState = state; + if (state.presentedShareId !== null) { + if (input.pendingShareId === state.presentedShareId) { + return { + state: { + presentedShareId: null, + dismissedShareId: state.presentedShareId, + }, + shareIdToPresent: null, + }; + } + nextState = { ...state, presentedShareId: null }; + } + + if (input.pendingShareId === null) { + return { + state: EMPTY_INCOMING_SHARE_PRESENTATION_STATE, + shareIdToPresent: null, + }; + } + + if (nextState.dismissedShareId === input.pendingShareId) { + return { state: nextState, shareIdToPresent: null }; + } + + return { + state: { + presentedShareId: input.pendingShareId, + dismissedShareId: null, + }, + shareIdToPresent: input.pendingShareId, + }; +} diff --git a/apps/mobile/src/features/sharing/incoming-share-storage.ts b/apps/mobile/src/features/sharing/incoming-share-storage.ts new file mode 100644 index 00000000000..8364b4c98a4 --- /dev/null +++ b/apps/mobile/src/features/sharing/incoming-share-storage.ts @@ -0,0 +1,80 @@ +import * as Schema from "effect/Schema"; + +import { decodeIncomingShareDraft, type IncomingShareDraft } from "./incoming-share-model"; + +const INCOMING_SHARE_DIRECTORY = "incoming-shares"; + +export class IncomingShareStorageError extends Schema.TaggedErrorClass()( + "IncomingShareStorageError", + { + operation: Schema.Literals(["load", "write", "remove"]), + shareId: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Incoming share storage operation ${this.operation} failed for ${this.shareId ?? "unknown"}.`; + } +} + +function fileName(shareId: string): string { + return `${encodeURIComponent(shareId)}.json`; +} + +async function getDirectory() { + const { Directory, Paths } = await import("expo-file-system"); + const directory = new Directory(Paths.document, INCOMING_SHARE_DIRECTORY); + directory.create({ idempotent: true, intermediates: true }); + return directory; +} + +async function getFile(shareId: string) { + const { File } = await import("expo-file-system"); + return new File(await getDirectory(), fileName(shareId)); +} + +export async function loadIncomingShareDrafts(): Promise> { + try { + const { File } = await import("expo-file-system"); + const drafts: IncomingShareDraft[] = []; + for (const entry of (await getDirectory()).list()) { + if (!(entry instanceof File) || !entry.name.endsWith(".json")) { + continue; + } + try { + drafts.push(decodeIncomingShareDraft(JSON.parse(await entry.text()) as unknown)); + } catch (cause) { + console.warn( + "[incoming-share] ignored invalid persisted share", + new IncomingShareStorageError({ operation: "load", shareId: null, cause }), + ); + } + } + return drafts.sort((left, right) => right.createdAt.localeCompare(left.createdAt)); + } catch (cause) { + throw new IncomingShareStorageError({ operation: "load", shareId: null, cause }); + } +} + +export async function writeIncomingShareDraft(draft: IncomingShareDraft): Promise { + try { + const file = await getFile(draft.id); + if (!file.exists) { + file.create({ intermediates: true, overwrite: true }); + } + file.write(JSON.stringify(draft)); + } catch (cause) { + throw new IncomingShareStorageError({ operation: "write", shareId: draft.id, cause }); + } +} + +export async function removeIncomingShareDraft(shareId: string): Promise { + try { + const file = await getFile(shareId); + if (file.exists) { + file.delete(); + } + } catch (cause) { + throw new IncomingShareStorageError({ operation: "remove", shareId, cause }); + } +} diff --git a/apps/mobile/src/features/shortcuts/appShortcuts.test.ts b/apps/mobile/src/features/shortcuts/appShortcuts.test.ts new file mode 100644 index 00000000000..17b63bb78fd --- /dev/null +++ b/apps/mobile/src/features/shortcuts/appShortcuts.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { NavigationState } from "@react-navigation/native"; + +import type { RecentThreadShortcut } from "../../persistence/imperative"; +import { + activeThreadRef, + buildShortcutActions, + MAX_RECENT_THREAD_SHORTCUTS, + NEW_TASK_SHORTCUT_ID, + shortcutHref, + withRecentThreadShortcut, +} from "./appShortcuts"; + +function navState(route: { name: string; params?: unknown }): NavigationState { + return { index: 0, routes: [route] } as unknown as NavigationState; +} + +function thread(suffix: string, title = `Thread ${suffix}`): RecentThreadShortcut { + return { environmentId: `env-${suffix}`, threadId: `thread-${suffix}`, title }; +} + +describe("withRecentThreadShortcut", () => { + it("prepends a newly opened thread", () => { + const next = withRecentThreadShortcut([thread("a")], thread("b")); + expect(next.map((entry) => entry.threadId)).toEqual(["thread-b", "thread-a"]); + }); + + it("moves a reopened thread to the front without duplicating it", () => { + const next = withRecentThreadShortcut([thread("a"), thread("b")], thread("b")); + expect(next.map((entry) => entry.threadId)).toEqual(["thread-b", "thread-a"]); + }); + + it("caps the list at the shortcut budget", () => { + const current = [thread("a"), thread("b"), thread("c")]; + const next = withRecentThreadShortcut(current, thread("d")); + expect(next).toHaveLength(MAX_RECENT_THREAD_SHORTCUTS); + expect(next[0]?.threadId).toBe("thread-d"); + expect(next.map((entry) => entry.threadId)).not.toContain("thread-c"); + }); + + it("returns the same array when the thread already leads with the same title", () => { + const current = [thread("a"), thread("b")]; + expect(withRecentThreadShortcut(current, thread("a"))).toBe(current); + }); + + it("keeps the known title when a reopen records an empty one", () => { + const current = [thread("a", "Fix the build")]; + const next = withRecentThreadShortcut(current, thread("a", "")); + expect(next).toBe(current); + }); + + it("updates the title once the shell provides one", () => { + const current = [thread("a", "")]; + const next = withRecentThreadShortcut(current, thread("a", "Fix the build")); + expect(next[0]?.title).toBe("Fix the build"); + expect(next).toHaveLength(1); + }); +}); + +describe("buildShortcutActions", () => { + it("leads with the static new-task action", () => { + const actions = buildShortcutActions([thread("a")]); + expect(actions[0]?.id).toBe(NEW_TASK_SHORTCUT_ID); + expect(actions[0]?.params?.href).toBe("/new"); + expect(actions).toHaveLength(2); + }); + + it("deep-links threads with encoded route params", () => { + const actions = buildShortcutActions([ + { environmentId: "env 1", threadId: "thread/2", title: "Spaced out" }, + ]); + expect(actions[1]?.params?.href).toBe("/threads/env%201/thread%2F2"); + expect(actions[1]?.title).toBe("Spaced out"); + }); + + it("falls back to a generic label for missing titles", () => { + const actions = buildShortcutActions([thread("a", " ")]); + expect(actions[1]?.title).toBe("Thread"); + }); +}); + +describe("shortcutHref", () => { + it("accepts exactly the destinations shortcuts can produce", () => { + expect(shortcutHref({ id: "x", title: "x", params: { href: "/new" } })).toBe("/new"); + expect(shortcutHref({ id: "x", title: "x", params: { href: "/threads/env-1/thread-2" } })).toBe( + "/threads/env-1/thread-2", + ); + expect( + shortcutHref({ id: "x", title: "x", params: { href: "/threads/env%201/thread%2F2" } }), + ).toBe("/threads/env%201/thread%2F2"); + }); + + it("rejects everything else", () => { + for (const href of [ + "https://evil.example", + "//evil.example", + "/settings", + "/threads/only-one-segment", + "/threads/a/b/c", + "/threads//x", + "/threads/a/b?x=1", + "/threads/a/b#frag", + "/new/extra", + ]) { + expect(shortcutHref({ id: "x", title: "x", params: { href } })).toBe(null); + } + expect(shortcutHref({ id: "x", title: "x", params: { href: 3 } })).toBe(null); + expect(shortcutHref({ id: "x", title: "x" })).toBe(null); + }); +}); + +describe("activeThreadRef", () => { + it("resolves the active Thread route's params", () => { + const ref = activeThreadRef( + navState({ name: "Thread", params: { environmentId: "env-1", threadId: "thread-2" } }), + ); + expect(ref).toEqual({ environmentId: "env-1", threadId: "thread-2" }); + }); + + it("takes the first value of array params", () => { + const ref = activeThreadRef( + navState({ name: "Thread", params: { environmentId: ["env-1"], threadId: ["thread-2"] } }), + ); + expect(ref).toEqual({ environmentId: "env-1", threadId: "thread-2" }); + }); + + it("returns null for non-thread routes", () => { + expect(activeThreadRef(navState({ name: "Home" }))).toBe(null); + }); + + it("returns null instead of throwing for malformed params", () => { + const malformed: unknown[] = [ + undefined, + {}, + { environmentId: "env-1" }, + { environmentId: "", threadId: "thread-2" }, + { environmentId: " ", threadId: "thread-2" }, + { environmentId: "env-1", threadId: " " }, + { environmentId: 42, threadId: "thread-2" }, + { environmentId: { nested: true }, threadId: "thread-2" }, + { environmentId: [], threadId: [] }, + ]; + for (const params of malformed) { + expect(activeThreadRef(navState({ name: "Thread", params }))).toBe(null); + } + }); +}); + +describe("launcher shortcut ids", () => { + it("cannot collide across different env/thread pairs", () => { + const a = buildShortcutActions([{ environmentId: "a-b", threadId: "c", title: "x" }]); + const b = buildShortcutActions([{ environmentId: "a", threadId: "b-c", title: "x" }]); + expect(a[1]?.id).not.toBe(b[1]?.id); + }); +}); diff --git a/apps/mobile/src/features/shortcuts/appShortcuts.ts b/apps/mobile/src/features/shortcuts/appShortcuts.ts new file mode 100644 index 00000000000..49e9d0999af --- /dev/null +++ b/apps/mobile/src/features/shortcuts/appShortcuts.ts @@ -0,0 +1,137 @@ +import type { Action } from "expo-quick-actions"; +import type { NavigationState } from "@react-navigation/native"; +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; + +import type { RecentThreadShortcut } from "../../persistence/imperative"; + +// Launchers cap visible shortcuts around 4; one slot is the static +// "New task" entry, the rest rotate through recently opened threads. +export const MAX_RECENT_THREAD_SHORTCUTS = 3; + +export const NEW_TASK_SHORTCUT_ID = "new-task"; +const NEW_TASK_SHORTCUT_HREF = "/new"; + +// Adaptive icon resource generated by the expo-quick-actions config plugin +// (androidIcons key in app.config.ts). +const SHORTCUT_ICON = "shortcut_icon"; + +// Matches only the thread deep-link shape shortcuts are allowed to carry: +// exactly two non-empty segments, no nested paths, queries, or fragments. +const THREAD_SHORTCUT_HREF_PATTERN = /^\/threads\/[^/?#]+\/[^/?#]+$/; + +function threadShortcutHref(thread: RecentThreadShortcut): string { + return `/threads/${encodeURIComponent(thread.environmentId)}/${encodeURIComponent(thread.threadId)}`; +} + +function firstRouteParam(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) { + return value[0] ?? null; + } + + return value ?? null; +} + +/** + * Thread ref of the navigation state's ACTIVE route, or null. Never throws: + * route params can arrive malformed from external deep links or restored + * state, the branded id constructors throw on values failing their schema, + * and this runs during render of the root stack layout — an uncaught throw + * here would take down the whole navigation tree. + */ +export function activeThreadRef(state: NavigationState): ScopedThreadRef | null { + const route = state.routes[state.index]; + if (route?.name !== "Thread") { + return null; + } + + try { + const params = route.params as + | { + readonly environmentId?: string | string[]; + readonly threadId?: string | string[]; + } + | undefined; + const environmentId = firstRouteParam(params?.environmentId)?.trim(); + const threadId = firstRouteParam(params?.threadId)?.trim(); + if (!environmentId || !threadId) { + return null; + } + + return { + environmentId: EnvironmentId.make(environmentId), + threadId: ThreadId.make(threadId), + }; + } catch { + return null; + } +} + +function threadShortcutLabel(thread: RecentThreadShortcut): string { + const title = thread.title.trim(); + return title.length > 0 ? title : "Thread"; +} + +/** + * In-app navigation path carried by a shortcut, or null for anything + * malformed — shortcuts persist in the launcher across app updates, so this + * allowlists the exact destinations shortcuts can produce rather than + * trusting any path-shaped string (`//host`, arbitrary routes, stale + * persisted junk all navigate nowhere). + */ +export function shortcutHref(action: Action): string | null { + const href = action.params?.href; + if (typeof href !== "string") { + return null; + } + + return href === NEW_TASK_SHORTCUT_HREF || THREAD_SHORTCUT_HREF_PATTERN.test(href) ? href : null; +} + +/** + * Records an opened thread as the most recent shortcut entry. Returns the + * SAME array when nothing changed so effect chains keyed on identity skip + * redundant launcher/storage writes. An empty title never clobbers a known + * one — shells load after navigation, so the first record of a thread often + * predates its title. + */ +export function withRecentThreadShortcut( + current: ReadonlyArray, + opened: RecentThreadShortcut, +): ReadonlyArray { + const existing = current.find( + (thread) => + thread.environmentId === opened.environmentId && thread.threadId === opened.threadId, + ); + const title = opened.title.trim().length > 0 ? opened.title : (existing?.title ?? opened.title); + if (current[0] === existing && existing !== undefined && existing.title === title) { + return current; + } + + return [ + { environmentId: opened.environmentId, threadId: opened.threadId, title }, + ...current.filter((thread) => thread !== existing), + ].slice(0, MAX_RECENT_THREAD_SHORTCUTS); +} + +/** Full launcher shortcut list: static "New task" first, then recents. */ +export function buildShortcutActions(recents: ReadonlyArray): Action[] { + return [ + { + id: NEW_TASK_SHORTCUT_ID, + title: "New task", + icon: SHORTCUT_ICON, + params: { href: NEW_TASK_SHORTCUT_HREF }, + }, + ...recents.slice(0, MAX_RECENT_THREAD_SHORTCUTS).map( + (thread): Action => ({ + // The encoded href doubles as the launcher id: URI-encoding makes the + // env/thread join unambiguous (a plain `-` join lets different pairs + // collide and overwrite each other's launcher slots). + id: `thread:${threadShortcutHref(thread)}`, + title: threadShortcutLabel(thread), + icon: SHORTCUT_ICON, + params: { href: threadShortcutHref(thread) }, + }), + ), + ]; +} diff --git a/apps/mobile/src/features/shortcuts/useAppShortcuts.ts b/apps/mobile/src/features/shortcuts/useAppShortcuts.ts new file mode 100644 index 00000000000..3070a1d54e0 --- /dev/null +++ b/apps/mobile/src/features/shortcuts/useAppShortcuts.ts @@ -0,0 +1,136 @@ +import * as QuickActions from "expo-quick-actions"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Platform } from "react-native"; +import { useLinkTo, type NavigationState } from "@react-navigation/native"; + +import { + loadRecentThreadShortcuts, + saveRecentThreadShortcuts, + type RecentThreadShortcut, +} from "../../persistence/imperative"; +import { useThreadShell } from "../../state/entities"; +import { + activeThreadRef, + buildShortcutActions, + shortcutHref, + withRecentThreadShortcut, +} from "./appShortcuts"; + +/** + * Owns the launcher app shortcuts (Android long-press menu): keeps the + * static "New task" entry plus the recently opened threads in sync, and + * routes shortcut taps — cold start included — to their in-app screens. + * Mounted once in the root stack layout. + */ +export function useAppShortcuts(state: NavigationState): void { + useShortcutNavigation(); + useRecentThreadShortcutSync(state); +} + +function useShortcutNavigation(): void { + const linkTo = useLinkTo(); + const handledInitialAction = useRef(false); + + useEffect(() => { + // Cold start: the tapped shortcut arrives as the launch action, before + // any listener can fire. Navigating from here pushes the target over the + // initial Home route, so back returns home instead of exiting the app. + if (!handledInitialAction.current) { + handledInitialAction.current = true; + const initialHref = QuickActions.initial ? shortcutHref(QuickActions.initial) : null; + if (initialHref !== null) { + linkTo(initialHref); + } + } + + const subscription = QuickActions.addListener((action) => { + const href = shortcutHref(action); + if (href !== null) { + linkTo(href); + } + }); + return () => subscription.remove(); + }, [linkTo]); +} + +function useRecentThreadShortcutSync(state: NavigationState): void { + const threadRef = useMemo(() => activeThreadRef(state), [state]); + const threadShell = useThreadShell(threadRef); + // null until the persisted list loads; recording waits on it so the first + // thread opened after a cold start cannot clobber older entries. + const [recents, setRecents] = useState | null>(null); + // Gates storage writes: a failed load falls back to an empty in-memory + // list (so the launcher still gets the "New task" item), but persisting + // that fallback would erase valid history over a transient read error. + // Real thread opens flip this on — by then the list is the new truth. + const persistableRef = useRef(false); + // Saves are fire-and-forget; chaining them keeps an older list from + // finishing after (and overwriting) a newer one. + const saveQueueRef = useRef>(Promise.resolve()); + + useEffect(() => { + if (Platform.OS !== "android") { + return; + } + + let cancelled = false; + void loadRecentThreadShortcuts() + .then((threads) => { + if (!cancelled) { + persistableRef.current = true; + setRecents(threads); + } + }) + .catch((error) => { + console.warn("[app-shortcuts] failed to load recent threads", error); + if (!cancelled) { + setRecents([]); + } + }); + return () => { + cancelled = true; + }; + }, []); + + const loaded = recents !== null; + const environmentId = threadRef?.environmentId ?? null; + const threadId = threadRef?.threadId ?? null; + const title = threadShell?.title ?? ""; + useEffect(() => { + if (!loaded || environmentId === null || threadId === null) { + return; + } + + // withRecentThreadShortcut returns the same array when nothing changed, + // so React bails out and the persist effect below does not re-fire. + setRecents((current) => { + if (current === null) { + return current; + } + const next = withRecentThreadShortcut(current, { environmentId, threadId, title }); + if (next !== current) { + persistableRef.current = true; + } + return next; + }); + }, [loaded, environmentId, threadId, title]); + + useEffect(() => { + if (recents === null) { + return; + } + + if (persistableRef.current) { + saveQueueRef.current = saveQueueRef.current.then( + () => + saveRecentThreadShortcuts(recents).catch((error) => { + console.warn("[app-shortcuts] failed to persist recent threads", error); + }), + () => undefined, + ); + } + void QuickActions.setItems(buildShortcutActions(recents)).catch((error) => { + console.warn("[app-shortcuts] failed to update launcher shortcuts", error); + }); + }, [recents]); +} diff --git a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx new file mode 100644 index 00000000000..9a4272824ba --- /dev/null +++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx @@ -0,0 +1,224 @@ +import { useEffect, useRef, useState } from "react"; +import { Keyboard, View } from "react-native"; +import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; +import { AsyncResult } from "effect/unstable/reactivity"; + +import { useConnectionController } from "../connection/useConnectionController"; +import { useProjects, useThreadShells } from "../../state/entities"; +import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; +import { holdEditingQueuedMessage } from "../../state/use-thread-outbox"; +import { useWorkspaceState } from "../../state/workspace"; +import { + getNativeShowcasePairingUrls, + getNativeShowcaseScene, + markNativeShowcaseReady, + type ShowcaseScene, +} from "./nativeShowcaseScene"; +import { + buildShowcasePendingTasks, + SHOWCASE_PENDING_TASK_DEFINITIONS, +} from "./showcasePendingTasks"; +import { retryShowcaseOperation } from "./showcaseRetry"; + +const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; +const SHOWCASE_THREAD_ID = "remote-command-center"; + +function sceneFromPathname(pathname: string): ShowcaseScene | null { + const routePath = pathname.split(/[?#]/u, 1)[0] ?? pathname; + if (routePath === "/settings" || routePath.endsWith("/settings/environments")) { + return "environments"; + } + if (routePath.endsWith("/terminal")) return "terminal"; + if (routePath.endsWith("/review")) return "review"; + if (routePath.startsWith("/threads/")) return "thread"; + if (routePath === "/") return "threads"; + return null; +} + +export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) { + const navigation = useNavigation(); + const { connectPairingUrl } = useConnectionController(); + const workspace = useWorkspaceState(); + const projects = useProjects(); + const threads = useThreadShells(); + const attemptedPairingRef = useRef(new Set()); + const seededPendingTaskIdsRef = useRef(new Set()); + const [pairingUrls, setPairingUrls] = useState>([]); + const [pendingTasksReady, setPendingTasksReady] = useState(false); + const [requestedScene, setRequestedScene] = useState(null); + const [readyScene, setReadyScene] = useState(null); + + useEffect(() => { + if (!SHOWCASE_ENABLED || pairingUrls.length > 0) return; + + const readPairingUrls = () => { + const values = getNativeShowcasePairingUrls(); + if (values.length > 0) setPairingUrls(values); + }; + readPairingUrls(); + const interval = setInterval(readPairingUrls, 250); + return () => clearInterval(interval); + }, [pairingUrls.length]); + + useEffect(() => { + if (!SHOWCASE_ENABLED) return; + + const readRequestedScene = () => { + const value = getNativeShowcaseScene(); + if (value) setRequestedScene(value); + }; + readRequestedScene(); + const interval = setInterval(readRequestedScene, 250); + return () => clearInterval(interval); + }, []); + + useEffect(() => { + if (!SHOWCASE_ENABLED || pairingUrls.length === 0) return; + let cancelled = false; + void (async () => { + await Promise.all( + pairingUrls.map(async (pairingUrl) => { + if (cancelled || attemptedPairingRef.current.has(pairingUrl)) return; + const paired = await retryShowcaseOperation( + async () => AsyncResult.isSuccess(await connectPairingUrl(pairingUrl)), + { isCancelled: () => cancelled }, + ); + if (paired) attemptedPairingRef.current.add(pairingUrl); + }), + ); + })(); + return () => { + cancelled = true; + }; + }, [connectPairingUrl, pairingUrls]); + + const scene = sceneFromPathname(props.pathname); + const hasServerFixture = + workspace.state.hasReadyEnvironment && + workspace.environments.length >= 3 && + projects.length >= 3 && + threads.some((thread) => String(thread.id) === SHOWCASE_THREAD_ID); + const hasFixture = hasServerFixture && pendingTasksReady; + const showcaseThread = threads.find((thread) => String(thread.id) === SHOWCASE_THREAD_ID); + + useEffect(() => { + if (!SHOWCASE_ENABLED || !hasServerFixture || pendingTasksReady) return; + + const pendingTasks = buildShowcasePendingTasks(projects, Date.now()); + if (pendingTasks.length !== SHOWCASE_PENDING_TASK_DEFINITIONS.length) return; + + let cancelled = false; + for (const task of pendingTasks) holdEditingQueuedMessage(task.messageId); + void (async () => { + const results = await Promise.all( + pendingTasks.map(async (task) => { + const messageId = String(task.messageId); + if (seededPendingTaskIdsRef.current.has(messageId)) return true; + const seeded = await retryShowcaseOperation( + async () => { + await enqueueThreadOutboxMessage(task); + return true; + }, + { isCancelled: () => cancelled }, + ); + if (seeded) seededPendingTaskIdsRef.current.add(messageId); + return seeded; + }), + ); + if (!cancelled && results.every(Boolean)) setPendingTasksReady(true); + })(); + return () => { + cancelled = true; + }; + }, [hasServerFixture, pendingTasksReady, projects]); + + useEffect(() => { + if (!SHOWCASE_ENABLED || requestedScene === null || !hasFixture || !showcaseThread) return; + if (scene === requestedScene) return; + + const params = { + environmentId: String(showcaseThread.environmentId), + threadId: SHOWCASE_THREAD_ID, + }; + if (requestedScene === "threads") { + navigation.dispatch(StackActions.popToTop()); + return; + } + const routes: Array<{ + name: string; + params?: Record; + state?: { index: number; routes: Array<{ name: string }> }; + }> = [{ name: "Home" }]; + if (requestedScene === "environments") { + routes.push({ + name: "SettingsSheet", + state: { + index: 1, + routes: [{ name: "Settings" }, { name: "SettingsEnvironments" }], + }, + }); + } else { + routes.push({ name: "Thread", params }); + if (requestedScene === "terminal") { + routes.push({ + name: "ThreadTerminal", + params: { ...params, terminalId: "term-1" }, + }); + } else if (requestedScene === "review") { + routes.push({ name: "ThreadReview", params }); + } + } + navigation.dispatch( + CommonActions.reset({ + index: routes.length - 1, + routes, + }), + ); + }, [hasFixture, navigation, requestedScene, scene, showcaseThread]); + + useEffect(() => { + if ( + !SHOWCASE_ENABLED || + scene === null || + requestedScene === null || + scene !== requestedScene || + !hasFixture + ) { + setReadyScene(null); + return; + } + // Review owns its readiness marker because route activation happens before + // the VCS request is parsed and the native diff surface is mounted. + if (scene === "review") { + setReadyScene(null); + return; + } + if (scene === "terminal") Keyboard.dismiss(); + + let renderFrame: number | null = null; + let readyFrame: number | null = null; + const settleTimer = setTimeout(() => { + renderFrame = requestAnimationFrame(() => { + readyFrame = requestAnimationFrame(() => { + markNativeShowcaseReady(scene); + setReadyScene(scene); + }); + }); + }, 500); + return () => { + clearTimeout(settleTimer); + if (renderFrame !== null) cancelAnimationFrame(renderFrame); + if (readyFrame !== null) cancelAnimationFrame(readyFrame); + }; + }, [hasFixture, requestedScene, scene]); + + if (!SHOWCASE_ENABLED || readyScene === null) return null; + + return ( + + ); +} diff --git a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts new file mode 100644 index 00000000000..1f2e263ebf1 --- /dev/null +++ b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts @@ -0,0 +1,68 @@ +import { requireOptionalNativeModule } from "expo"; + +export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const; +export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; + +interface NativeShowcaseControls { + readonly getShowcasePairingUrl?: () => string | null; + readonly getShowcaseScene?: () => string | null; + readonly prepareShowcaseCapture?: () => void; + readonly markShowcaseReady?: (scene: ShowcaseScene) => void; +} + +function nativeShowcaseControls(): NativeShowcaseControls | null { + return requireOptionalNativeModule("T3NativeControls"); +} + +export function getNativeShowcasePairingUrls(): ReadonlyArray { + try { + let raw = nativeShowcaseControls()?.getShowcasePairingUrl?.()?.trim(); + if (!raw) return []; + if (raw.startsWith("json-uri:")) { + try { + raw = decodeURIComponent(raw.slice("json-uri:".length)); + } catch { + return []; + } + } + try { + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.filter( + (candidate): candidate is string => + typeof candidate === "string" && candidate.trim().length > 0, + ); + } + } catch { + // Older runners pass a single URL rather than a JSON array. + } + return [raw]; + } catch { + return []; + } +} + +export function getNativeShowcaseScene(): ShowcaseScene | null { + try { + const scene = nativeShowcaseControls()?.getShowcaseScene?.()?.trim(); + return SHOWCASE_SCENES.find((candidate) => candidate === scene) ?? null; + } catch { + return null; + } +} + +export function prepareNativeShowcaseCapture(): void { + try { + nativeShowcaseControls()?.prepareShowcaseCapture?.(); + } catch { + // The harness still works when a development build predates this helper. + } +} + +export function markNativeShowcaseReady(scene: ShowcaseScene): void { + try { + nativeShowcaseControls()?.markShowcaseReady?.(scene); + } catch { + // The readiness marker is capture-runner metadata, never app functionality. + } +} diff --git a/apps/mobile/src/features/showcase/showcaseEnvironmentRows.test.ts b/apps/mobile/src/features/showcase/showcaseEnvironmentRows.test.ts new file mode 100644 index 00000000000..a459cbe1db3 --- /dev/null +++ b/apps/mobile/src/features/showcase/showcaseEnvironmentRows.test.ts @@ -0,0 +1,70 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; + +import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; +import { + applyShowcaseLocalEnvironmentDisplayUrls, + resolveShowcaseEnvironmentUpdateDisplayUrl, +} from "./showcaseEnvironmentRows"; + +function environment( + environmentId: string, + environmentLabel: string, + displayUrl = "http://127.0.0.1:3773/", +): ConnectedEnvironmentSummary { + return { + environmentId: EnvironmentId.make(environmentId), + environmentLabel, + displayUrl, + isRelayManaged: false, + connectionState: "connected", + connectionError: null, + connectionErrorTraceId: null, + }; +} + +it("presents showcase transports as remote endpoints", () => { + const environments = applyShowcaseLocalEnvironmentDisplayUrls([ + environment("runtime-id-1", "Moonbase Terminal"), + environment("runtime-id-2", "Suspense Station"), + environment("runtime-id-3", "Kernel Cabin"), + ]); + + assert.deepStrictEqual( + environments.map(({ displayUrl }) => displayUrl), + [ + "https://moonbase.tail9f3a.ts.net/", + "https://suspense-vps.hel1.t3.sh/", + "http://100.82.16.5:3773/", + ], + ); +}); + +it("leaves environments outside the showcase fixture unchanged", () => { + const original = environment( + "runtime-id-4", + "My Workstation", + "https://workstation.example.test/", + ); + + assert.deepStrictEqual(applyShowcaseLocalEnvironmentDisplayUrls([original]), [original]); +}); + +it("does not persist a cosmetic showcase URL when only the label is saved", () => { + assert.equal( + resolveShowcaseEnvironmentUpdateDisplayUrl({ + actualDisplayUrl: "http://127.0.0.1:3773/", + presentedDisplayUrl: "https://moonbase.tail9f3a.ts.net/", + submittedDisplayUrl: "https://moonbase.tail9f3a.ts.net/", + }), + "http://127.0.0.1:3773/", + ); + assert.equal( + resolveShowcaseEnvironmentUpdateDisplayUrl({ + actualDisplayUrl: "http://127.0.0.1:3773/", + presentedDisplayUrl: "https://moonbase.tail9f3a.ts.net/", + submittedDisplayUrl: "https://new-host.example.com/", + }), + "https://new-host.example.com/", + ); +}); diff --git a/apps/mobile/src/features/showcase/showcaseEnvironmentRows.ts b/apps/mobile/src/features/showcase/showcaseEnvironmentRows.ts new file mode 100644 index 00000000000..66ec3046c67 --- /dev/null +++ b/apps/mobile/src/features/showcase/showcaseEnvironmentRows.ts @@ -0,0 +1,70 @@ +import { EnvironmentId } from "@t3tools/contracts"; + +import type { RelayEnvironmentView } from "../connection/useConnectionController"; +import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; + +const SHOWCASE_LOCAL_ENVIRONMENT_DISPLAY_URLS: Readonly> = { + "Moonbase Terminal": "https://moonbase.tail9f3a.ts.net/", + "Suspense Station": "https://suspense-vps.hel1.t3.sh/", + "Kernel Cabin": "http://100.82.16.5:3773/", +}; + +export function applyShowcaseLocalEnvironmentDisplayUrls( + environments: ReadonlyArray, +): ReadonlyArray { + return environments.map((environment) => ({ + ...environment, + displayUrl: + SHOWCASE_LOCAL_ENVIRONMENT_DISPLAY_URLS[environment.environmentLabel] ?? + environment.displayUrl, + })); +} + +export function resolveShowcaseEnvironmentUpdateDisplayUrl(input: { + readonly actualDisplayUrl: string; + readonly presentedDisplayUrl: string; + readonly submittedDisplayUrl: string; +}): string { + return input.submittedDisplayUrl === input.presentedDisplayUrl + ? input.actualDisplayUrl + : input.submittedDisplayUrl; +} + +const pocketPiId = EnvironmentId.make("showcase-pocket-pi"); +const pocketPiEndpoint = { + httpBaseUrl: "https://pocket-pi.t3.sh", + wsBaseUrl: "wss://pocket-pi.t3.sh", + providerKind: "t3_relay" as const, +}; + +export const SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS: ReadonlyArray = [ + { + environmentId: EnvironmentId.make("showcase-aurora-gpu"), + environmentLabel: "Aurora GPU Pod", + displayUrl: "https://aurora-gpu.t3.sh", + isRelayManaged: true, + connectionState: "connected", + connectionError: null, + connectionErrorTraceId: null, + }, +]; + +export const SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS: ReadonlyArray = [ + { + environment: { + environmentId: pocketPiId, + label: "Pocket Pi", + endpoint: pocketPiEndpoint, + linkedAt: "2026-07-16T08:00:00.000Z", + }, + availability: "online", + status: { + environmentId: pocketPiId, + endpoint: pocketPiEndpoint, + status: "online", + checkedAt: "2026-07-16T08:41:00.000Z", + }, + error: null, + traceId: null, + }, +]; diff --git a/apps/mobile/src/features/showcase/showcasePendingTasks.test.ts b/apps/mobile/src/features/showcase/showcasePendingTasks.test.ts new file mode 100644 index 00000000000..8ab043d7cd8 --- /dev/null +++ b/apps/mobile/src/features/showcase/showcasePendingTasks.test.ts @@ -0,0 +1,72 @@ +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ProjectId, ProviderInstanceId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; + +import { + buildShowcasePendingTasks, + SHOWCASE_PENDING_TASK_DEFINITIONS, +} from "./showcasePendingTasks"; + +const projects: ReadonlyArray = [ + { + environmentId: EnvironmentId.make("moonbase-terminal"), + id: ProjectId.make("t3code"), + title: "T3 Code", + workspaceRoot: "/workspace/t3code", + repositoryIdentity: null, + defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + scripts: [], + createdAt: "2026-07-16T08:00:00.000Z", + updatedAt: "2026-07-16T08:00:00.000Z", + }, + { + environmentId: EnvironmentId.make("suspense-station"), + id: ProjectId.make("react"), + title: "React", + workspaceRoot: "/workspace/react", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-07-16T08:00:00.000Z", + updatedAt: "2026-07-16T08:00:00.000Z", + }, +]; + +it("builds sendable-looking pending tasks against real showcase projects", () => { + const tasks = buildShowcasePendingTasks(projects, Date.parse("2026-07-16T09:00:00.000Z")); + + assert.equal(tasks.length, SHOWCASE_PENDING_TASK_DEFINITIONS.length); + assert.deepStrictEqual( + tasks.map((task) => ({ + environmentId: String(task.environmentId), + projectId: task.creation ? String(task.creation.projectId) : undefined, + title: task.creation?.projectTitle, + branch: task.creation?.branch, + createdAt: task.createdAt, + })), + [ + { + environmentId: "moonbase-terminal", + projectId: "t3code", + title: "T3 Code", + branch: "feat/offline-launchpad", + createdAt: "2026-07-16T08:52:00.000Z", + }, + { + environmentId: "suspense-station", + projectId: "react", + title: "React", + branch: "perf/tunnel-handoff", + createdAt: "2026-07-16T08:33:00.000Z", + }, + ], + ); + assert.equal( + tasks.every((task) => task.modelSelection !== undefined), + true, + ); +}); + +it("waits until every referenced project has hydrated", () => { + assert.equal(buildShowcasePendingTasks(projects.slice(0, 1), Date.now()).length, 1); +}); diff --git a/apps/mobile/src/features/showcase/showcasePendingTasks.ts b/apps/mobile/src/features/showcase/showcasePendingTasks.ts new file mode 100644 index 00000000000..1b0b5ed22f4 --- /dev/null +++ b/apps/mobile/src/features/showcase/showcasePendingTasks.ts @@ -0,0 +1,66 @@ +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + MessageId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; + +import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; + +export const SHOWCASE_PENDING_TASK_DEFINITIONS = [ + { + projectId: "t3code", + id: "offline-launch-checklist", + text: "Ship the offline launch checklist before touchdown ✈️", + branch: "feat/offline-launchpad", + minutesAgo: 8, + }, + { + projectId: "react", + id: "train-tunnel-suspense", + text: "Polish the Suspense handoff for the train tunnel 🚇", + branch: "perf/tunnel-handoff", + minutesAgo: 27, + }, +] as const; + +const FALLBACK_MODEL_SELECTION = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", +} as const; + +export function buildShowcasePendingTasks( + projects: ReadonlyArray, + now: number, +): ReadonlyArray { + return SHOWCASE_PENDING_TASK_DEFINITIONS.flatMap((definition) => { + const project = projects.find((candidate) => String(candidate.id) === definition.projectId); + if (!project) return []; + + return [ + { + environmentId: project.environmentId, + threadId: ThreadId.make(`showcase-pending-${definition.id}`), + messageId: MessageId.make(`showcase-pending-message-${definition.id}`), + commandId: CommandId.make(`showcase-pending-command-${definition.id}`), + text: definition.text, + attachments: [], + modelSelection: project.defaultModelSelection ?? FALLBACK_MODEL_SELECTION, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + creation: { + projectId: project.id, + projectTitle: project.title, + projectCwd: project.workspaceRoot, + workspaceMode: "local" as const, + branch: definition.branch, + worktreePath: project.workspaceRoot, + }, + createdAt: new Date(now - definition.minutesAgo * 60_000).toISOString(), + }, + ]; + }); +} diff --git a/apps/mobile/src/features/showcase/showcaseRetry.test.ts b/apps/mobile/src/features/showcase/showcaseRetry.test.ts new file mode 100644 index 00000000000..86c2890366e --- /dev/null +++ b/apps/mobile/src/features/showcase/showcaseRetry.test.ts @@ -0,0 +1,44 @@ +import { assert, it } from "@effect/vitest"; + +import { retryShowcaseOperation } from "./showcaseRetry"; + +it("retries a failed showcase operation until it succeeds", async () => { + let attempts = 0; + const succeeded = await retryShowcaseOperation( + async () => { + attempts += 1; + return attempts === 3; + }, + { isCancelled: () => false, retryDelayMs: 0 }, + ); + + assert.equal(succeeded, true); + assert.equal(attempts, 3); +}); + +it("recovers when a showcase operation attempt hangs", async () => { + let attempts = 0; + const succeeded = await retryShowcaseOperation( + () => { + attempts += 1; + return attempts === 1 ? new Promise(() => undefined) : Promise.resolve(true); + }, + { isCancelled: () => false, attemptTimeoutMs: 1, retryDelayMs: 0 }, + ); + + assert.equal(succeeded, true); + assert.equal(attempts, 2); +}); + +it("stops retrying when the owning showcase effect is cancelled", async () => { + let cancelled = false; + const succeeded = await retryShowcaseOperation( + async () => { + cancelled = true; + return false; + }, + { isCancelled: () => cancelled, retryDelayMs: 0 }, + ); + + assert.equal(succeeded, false); +}); diff --git a/apps/mobile/src/features/showcase/showcaseRetry.ts b/apps/mobile/src/features/showcase/showcaseRetry.ts new file mode 100644 index 00000000000..0c7aeb12019 --- /dev/null +++ b/apps/mobile/src/features/showcase/showcaseRetry.ts @@ -0,0 +1,46 @@ +export interface ShowcaseRetryOptions { + readonly isCancelled: () => boolean; + readonly attemptTimeoutMs?: number; + readonly retryDelayMs?: number; +} + +const DEFAULT_ATTEMPT_TIMEOUT_MS = 10_000; +const DEFAULT_RETRY_DELAY_MS = 500; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function runAttemptWithTimeout( + operation: () => Promise, + timeoutMs: number, +): Promise { + let timeout: ReturnType | null = null; + try { + return await Promise.race([ + operation(), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), timeoutMs); + }), + ]); + } catch { + return false; + } finally { + if (timeout !== null) clearTimeout(timeout); + } +} + +/** Retry transient showcase setup work until it succeeds or the owning effect unmounts. */ +export async function retryShowcaseOperation( + operation: () => Promise, + options: ShowcaseRetryOptions, +): Promise { + const attemptTimeoutMs = options.attemptTimeoutMs ?? DEFAULT_ATTEMPT_TIMEOUT_MS; + const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS; + + while (!options.isCancelled()) { + if (await runAttemptWithTimeout(operation, attemptTimeoutMs)) return true; + if (!options.isCancelled()) await delay(retryDelayMs); + } + return false; +} diff --git a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx index ad174a2502d..b205b4df72c 100644 --- a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx +++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx @@ -37,6 +37,7 @@ interface TerminalSurfaceProps extends ViewProps { readonly buffer: string; readonly fontSize?: number; readonly isRunning: boolean; + readonly autoFocus?: boolean; readonly keyboardFocusRequest?: number; readonly theme?: TerminalTheme; readonly onInput: (data: string) => void; @@ -82,9 +83,9 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter return ( - + {statusLabel} { const text = event.nativeEvent.text; if (text.length > 0) { - props.onInput(`${text}\n`); + // Terminal Enter is CR. LF is Ctrl+J and raw-mode TUIs can treat it as J. + props.onInput(`${text}\r`); } }} /> @@ -165,13 +162,7 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter })} onPress={() => props.onInput("\u0003")} > - + Ctrl-C @@ -225,6 +216,7 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf ({ @@ -63,6 +65,94 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( const terminalKey = `${props.environmentId}:${props.threadId}:${terminalId}`; const isRunning = terminal.status === "running" || terminal.status === "starting"; + // Close the session and dismiss the panel when the process ends while + // attached (e.g. typing `exit`), mirroring the web drawer's + // onSessionExited flow. + const runningTerminalKeyRef = useRef(null); + const reopenedStaleTerminalKeyRef = useRef(null); + + // Attach subscriptions are cached with an idle TTL; reopening the panel + // after its session ended reuses the stale stream without a new attach + // RPC. Issue an explicit open so the server respawns the session and its + // snapshot flows into the live subscription. + useEffect(() => { + if (isRunning) { + reopenedStaleTerminalKeyRef.current = null; + return; + } + if ( + attachInput === null || + (terminal.status !== "closed" && terminal.status !== "exited") || + terminal.version === 0 || + runningTerminalKeyRef.current === terminalKey || + reopenedStaleTerminalKeyRef.current === terminalKey + ) { + return; + } + reopenedStaleTerminalKeyRef.current = terminalKey; + void openTerminal({ + environmentId: props.environmentId, + input: { + threadId: props.threadId, + terminalId, + cwd: props.cwd, + worktreePath: props.worktreePath, + cols: lastGridSizeRef.current.cols, + rows: lastGridSizeRef.current.rows, + }, + }).then((result) => { + // Release the guard on failure so a later render can retry the respawn. + if (result._tag === "Failure" && reopenedStaleTerminalKeyRef.current === terminalKey) { + reopenedStaleTerminalKeyRef.current = null; + } + }); + }, [ + attachInput, + isRunning, + openTerminal, + props.cwd, + props.environmentId, + props.threadId, + props.worktreePath, + terminal.status, + terminal.version, + terminalId, + terminalKey, + ]); + + useEffect(() => { + // Forget both markers while hidden: if the process ends while the panel + // is unobserved (or was just auto-closed), the next show must take the + // stale-reopen path instead of treating it as a live exit or skipping + // the respawn. + if (attachInput === null) { + runningTerminalKeyRef.current = null; + reopenedStaleTerminalKeyRef.current = null; + return; + } + if (isRunning) { + runningTerminalKeyRef.current = terminalKey; + return; + } + // The web drawer treats both exited and closed as session end. + const sessionEnded = terminal.status === "exited" || terminal.status === "closed"; + if (!sessionEnded || runningTerminalKeyRef.current !== terminalKey) { + return; + } + runningTerminalKeyRef.current = null; + // Mark this key handled so the stale-attach effect doesn't respawn the + // session the user just ended. + reopenedStaleTerminalKeyRef.current = terminalKey; + void closeTerminal({ + environmentId: props.environmentId, + input: { + threadId: props.threadId, + terminalId, + }, + }); + props.onClose(); + }, [attachInput, closeTerminal, isRunning, props, terminal.status, terminalId, terminalKey]); + const sendResize = useCallback( (size: TerminalGridSize) => { void resizeTerminal({ diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index 827411d5dc2..072aec2c04b 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -1,6 +1,7 @@ import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; -import { SymbolView } from "expo-symbols"; +import type { MenuAction } from "@react-native-menu/menu"; +import { SymbolView } from "../../components/AppSymbol"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -12,11 +13,13 @@ import { useKeyboardState, } from "react-native-keyboard-controller"; +import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { ComposerToolbarButton, ComposerToolbarRow, ComposerToolbarScroller, } from "../../components/ComposerToolbarTrigger"; +import { ControlPillMenu } from "../../components/ControlPill"; import { EmptyState } from "../../components/EmptyState"; import { GlassSurface } from "../../components/GlassSurface"; import { LoadingScreen } from "../../components/LoadingScreen"; @@ -58,6 +61,7 @@ import { buildTerminalMenuSessions, getTerminalStatusLabel, nextOpenTerminalId, + previousLiveTerminalId, resolveTerminalSessionLabel, type TerminalMenuSession, } from "./terminalMenu"; @@ -66,6 +70,7 @@ import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiSt const DEFAULT_TERMINAL_COLS = 80; const DEFAULT_TERMINAL_ROWS = 24; const TERMINAL_ACCESSORY_HEIGHT = 52; +const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; type PendingModifier = "ctrl" | "meta"; type HostPlatform = "mac" | "linux" | "windows" | "unknown"; @@ -158,6 +163,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); const clearTerminal = useAtomCommand(terminalEnvironment.clear, "terminal clear"); + const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close"); + const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; const { state: workspaceState } = useWorkspaceState(); @@ -348,6 +355,64 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) }); const isRunning = terminal.status === "running" || terminal.status === "starting"; + // When the process ends while this screen is attached (e.g. typing `exit`), + // close the session and leave the screen, mirroring the web drawer's + // onSessionExited flow. Only react to a running -> exited transition + // observed on this screen so already-exited sessions can still be opened + // (they restart on attach). + const runningTerminalKeyRef = useRef(null); + const reopenedStaleTerminalKeyRef = useRef(null); + const pendingExitNavigationRef = useRef(null); + + // Attach subscriptions are cached with an idle TTL, so revisiting a + // terminal whose session ended while unobserved reuses the stale stream + // without a new attach RPC — the server never respawns anything. Detect + // that (dead status with processed events, never seen running here) and + // issue an explicit open; its snapshot flows into the live subscription. + useEffect(() => { + if (isRunning) { + reopenedStaleTerminalKeyRef.current = null; + return; + } + if ( + terminalAttachInput === null || + !selectedThread || + (terminal.status !== "closed" && terminal.status !== "exited") || + terminal.version === 0 || + runningTerminalKeyRef.current === terminalKey || + reopenedStaleTerminalKeyRef.current === terminalKey + ) { + return; + } + reopenedStaleTerminalKeyRef.current = terminalKey; + void openTerminal({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + terminalId, + cwd: terminalAttachInput.cwd, + worktreePath: terminalAttachInput.worktreePath, + cols: terminalAttachInput.cols, + rows: terminalAttachInput.rows, + ...(terminalAttachInput.env ? { env: terminalAttachInput.env } : {}), + }, + }).then((result) => { + // Release the guard on failure so a later render can retry the respawn. + if (result._tag === "Failure" && reopenedStaleTerminalKeyRef.current === terminalKey) { + reopenedStaleTerminalKeyRef.current = null; + } + }); + }, [ + isRunning, + openTerminal, + selectedThread, + terminal.status, + terminal.version, + terminalAttachInput, + terminalId, + terminalKey, + ]); + useEffect(() => { terminalDebugLog("surface:props", { terminalKey, @@ -739,6 +804,105 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) [navigation, selectedThread, terminalId], ); + const navigateAwayAfterExit = useCallback(() => { + // With other shells still live, fall through to the previous one instead + // of dropping the user back on the thread. + const fallbackTerminalId = previousLiveTerminalId({ + sessions: terminalMenuSessions, + exitedTerminalId: terminalId, + }); + if (fallbackTerminalId !== null && selectedThread) { + navigation.dispatch( + StackActions.replace("ThreadTerminal", { + environmentId: String(selectedThread.environmentId), + threadId: String(selectedThread.id), + terminalId: fallbackTerminalId, + }), + ); + return; + } + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + // Deep-linked/root mounts have nothing to pop; land on the thread + // instead of stranding the user on a dead terminal. + if (selectedThread) { + navigation.dispatch( + StackActions.replace("Thread", { + environmentId: String(selectedThread.environmentId), + threadId: String(selectedThread.id), + }), + ); + } + }, [navigation, selectedThread, terminalId, terminalMenuSessions]); + + useEffect(() => { + // Detached (hidden surface or environment drop): forget the running + // marker so a reattach takes the stale-reopen path instead of misreading + // the dead snapshot as an exit observed on this screen. A pending exit + // navigation stays armed — it only clears once the session runs again — + // so refocusing a dead screen still leaves it. + if (terminalAttachInput === null) { + runningTerminalKeyRef.current = null; + return; + } + if (isRunning) { + runningTerminalKeyRef.current = terminalKey; + // The session came back (e.g. respawned elsewhere) before the user + // returned; a stale pending exit must not eject a live terminal. + pendingExitNavigationRef.current = null; + return; + } + // The web drawer treats both exited and closed as session end. + const sessionEnded = terminal.status === "exited" || terminal.status === "closed"; + if (!sessionEnded || runningTerminalKeyRef.current !== terminalKey) { + return; + } + runningTerminalKeyRef.current = null; + // Mark this key handled so the stale-attach effect doesn't respawn the + // session the user just ended. + reopenedStaleTerminalKeyRef.current = terminalKey; + if (selectedThread) { + void closeTerminal({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + terminalId, + }, + }); + } + if (navigation.isFocused()) { + navigateAwayAfterExit(); + return; + } + // An unfocused screen can't navigate; leave when the user returns so + // they never land on the dead session. + pendingExitNavigationRef.current = terminalKey; + }, [ + closeTerminal, + isRunning, + navigateAwayAfterExit, + navigation, + selectedThread, + terminal.status, + terminalAttachInput, + terminalId, + terminalKey, + ]); + + useEffect( + () => + navigation.addListener("focus", () => { + if (pendingExitNavigationRef.current !== terminalKey) { + return; + } + pendingExitNavigationRef.current = null; + navigateAwayAfterExit(); + }), + [navigateAwayAfterExit, navigation, terminalKey], + ); + const handleOpenNewTerminal = useCallback(() => { if (!selectedThread) { return; @@ -764,6 +928,69 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) setTerminalFontSize(stepTerminalFontSize(fontSize, 1)); }, [fontSize, setTerminalFontSize]); + // Android mirror of the iOS NativeHeaderToolbar terminal menu below: text + // size, session switching, and "Open new terminal", rendered through the + // token-styled anchored menu (the native header items are iOS-only). + const androidTerminalMenuActions = useMemo( + () => [ + { + id: "text-size", + title: "Text size", + subactions: [ + { + id: "font-decrease", + title: `A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`, + attributes: fontSize <= MIN_TERMINAL_FONT_SIZE ? { disabled: true } : undefined, + }, + { + id: "font-increase", + title: `A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`, + attributes: fontSize >= MAX_TERMINAL_FONT_SIZE ? { disabled: true } : undefined, + }, + ], + }, + ...terminalMenuSessions.map( + (session): MenuAction => ({ + id: `terminal-session:${session.terminalId}`, + title: session.displayLabel, + subtitle: [getTerminalStatusLabel({ status: session.status }), basename(session.cwd)] + .filter(Boolean) + .join(" · "), + state: session.terminalId === terminalId ? ("on" as const) : undefined, + }), + ), + { + id: "terminal-new", + title: "Open new terminal", + image: "plus", + subtitle: `Start another shell in ${basename(selectedThreadProject?.workspaceRoot ?? null) ?? "this workspace"}`, + }, + ], + [fontSize, selectedThreadProject?.workspaceRoot, terminalId, terminalMenuSessions], + ); + + const handleAndroidTerminalMenuAction = useCallback( + (event: { nativeEvent: { event: string } }) => { + const id = event.nativeEvent.event; + if (id === "font-decrease") { + handleDecreaseFontSize(); + return; + } + if (id === "font-increase") { + handleIncreaseFontSize(); + return; + } + if (id === "terminal-new") { + handleOpenNewTerminal(); + return; + } + if (id.startsWith("terminal-session:")) { + handleSelectTerminal(id.slice("terminal-session:".length)); + } + }, + [handleDecreaseFontSize, handleIncreaseFontSize, handleOpenNewTerminal, handleSelectTerminal], + ); + const handleClearTerminal = useCallback(() => { if (!selectedThread) { return; @@ -860,12 +1087,53 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS — the pty // scrolls internally, nothing for glass to sample). Default title/subtitle // styling, like every other page. + // Android draws its own in-flow header (AndroidScreenHeader below); + // the native stack header stays iOS-only. + headerShown: Platform.OS !== "android", title: "Terminal", unstable_headerSubtitle: usesNativeHeaderGlass && headerSubtitle.length > 0 ? headerSubtitle : undefined, }} /> + {Platform.OS === "android" ? ( + navigation.goBack() : undefined} + trailing={ + <> + {layout.usesSplitView ? ( + + ) : null} + {isEnvironmentReady ? ( + + + + ) : null} + + } + /> + ) : null} + {layout.usesSplitView ? ( ) : null} - + {!isEnvironmentReady ? ( ) : ( <> - + diff --git a/apps/mobile/src/features/terminal/nativeTerminalModule.ts b/apps/mobile/src/features/terminal/nativeTerminalModule.ts index 120b7962f2c..f6a74595c80 100644 --- a/apps/mobile/src/features/terminal/nativeTerminalModule.ts +++ b/apps/mobile/src/features/terminal/nativeTerminalModule.ts @@ -23,6 +23,7 @@ interface TerminalResizeEvent { export interface NativeTerminalSurfaceProps extends ViewProps { readonly appearanceScheme?: "light" | "dark"; + readonly autoFocus?: boolean; readonly focusRequest?: number; readonly themeConfig?: string; readonly backgroundColor?: string; diff --git a/apps/mobile/src/features/terminal/terminalMenu.test.ts b/apps/mobile/src/features/terminal/terminalMenu.test.ts index 48c87e18dd4..1f176263ca5 100644 --- a/apps/mobile/src/features/terminal/terminalMenu.test.ts +++ b/apps/mobile/src/features/terminal/terminalMenu.test.ts @@ -9,9 +9,25 @@ import { buildTerminalMenuSessions, nextOpenTerminalId, nextTerminalId, + previousLiveTerminalId, resolveProjectScriptTerminalId, + type TerminalMenuSession, } from "./terminalMenu"; +function makeMenuSession(input: { + readonly terminalId: string; + readonly status: TerminalMenuSession["status"]; +}): TerminalMenuSession { + return { + terminalId: input.terminalId, + cwd: null, + status: input.status, + hasRunningSubprocess: false, + displayLabel: getTerminalLabel(input.terminalId), + updatedAt: null, + }; +} + function makeKnownSession(input: { readonly terminalId: string; readonly status: KnownTerminalSession["state"]["status"]; @@ -144,6 +160,60 @@ describe("nextOpenTerminalId", () => { }); }); +describe("previousLiveTerminalId", () => { + it("returns null when no other live session remains", () => { + expect( + previousLiveTerminalId({ + sessions: [ + makeMenuSession({ terminalId: "term-2", status: "exited" }), + makeMenuSession({ terminalId: "term-3", status: "closed" }), + ], + exitedTerminalId: "term-2", + }), + ).toBe(null); + }); + + it("prefers the nearest live session below the exited id", () => { + expect( + previousLiveTerminalId({ + sessions: [ + makeMenuSession({ terminalId: DEFAULT_TERMINAL_ID, status: "running" }), + makeMenuSession({ terminalId: "term-2", status: "running" }), + makeMenuSession({ terminalId: "term-3", status: "exited" }), + makeMenuSession({ terminalId: "term-4", status: "running" }), + ], + exitedTerminalId: "term-3", + }), + ).toBe("term-2"); + }); + + it("falls back to the nearest live session above when the exited id was lowest", () => { + expect( + previousLiveTerminalId({ + sessions: [ + makeMenuSession({ terminalId: DEFAULT_TERMINAL_ID, status: "exited" }), + makeMenuSession({ terminalId: "term-2", status: "starting" }), + makeMenuSession({ terminalId: "term-4", status: "running" }), + ], + exitedTerminalId: DEFAULT_TERMINAL_ID, + }), + ).toBe("term-2"); + }); + + it("ignores dead sessions when picking the fallback", () => { + expect( + previousLiveTerminalId({ + sessions: [ + makeMenuSession({ terminalId: DEFAULT_TERMINAL_ID, status: "running" }), + makeMenuSession({ terminalId: "term-2", status: "exited" }), + makeMenuSession({ terminalId: "term-3", status: "exited" }), + ], + exitedTerminalId: "term-3", + }), + ).toBe(DEFAULT_TERMINAL_ID); + }); +}); + describe("resolveProjectScriptTerminalId", () => { it("reuses the default shell when no terminal is running", () => { expect( diff --git a/apps/mobile/src/features/terminal/terminalMenu.ts b/apps/mobile/src/features/terminal/terminalMenu.ts index 29374bdda6d..06cb74e9467 100644 --- a/apps/mobile/src/features/terminal/terminalMenu.ts +++ b/apps/mobile/src/features/terminal/terminalMenu.ts @@ -114,6 +114,35 @@ export function buildTerminalMenuSessions(input: { return Arr.sort(sessionsById.values(), terminalMenuSessionOrder); } +/** + * Picks the session to show after a terminal exits: the nearest live session + * below the exited id (terminal n-1), falling back to the nearest one above. + * Returns null when no other live session remains and the terminal UI should + * be dismissed instead. + */ +export function previousLiveTerminalId(input: { + readonly sessions: ReadonlyArray; + readonly exitedTerminalId: string; +}): string | null { + const live = Arr.sort( + input.sessions.filter( + (session) => + session.terminalId !== input.exitedTerminalId && + (session.status === "running" || session.status === "starting"), + ), + terminalMenuSessionOrder, + ); + if (live.length === 0) { + return null; + } + + const below = live.filter( + (session) => + session.terminalId.localeCompare(input.exitedTerminalId, undefined, { numeric: true }) < 0, + ); + return (below[below.length - 1] ?? live[0])?.terminalId ?? null; +} + export function resolveProjectScriptTerminalId(input: { readonly existingTerminalIds: ReadonlyArray; readonly hasRunningTerminal: boolean; diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index 87ac2a6f951..ccf6a307122 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -1,7 +1,7 @@ import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger"; import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts"; -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../components/AppSymbol"; import { memo } from "react"; import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "react-native"; @@ -154,23 +154,11 @@ const CommandRow = memo(function CommandRow(props: { ) : iconName ? ( ) : null} - + {props.item.label} {props.item.description ? ( - + {props.item.description} ) : null} @@ -187,18 +175,15 @@ export const ComposerCommandPopover = memo(function ComposerCommandPopover( return ( {label ? ( - - + + {label} ) : null} {props.items.length > 0 ? ( @@ -212,7 +197,7 @@ export const ComposerCommandPopover = memo(function ComposerCommandPopover( ))} ) : ( - + {emptyText(props.triggerKind, props.isLoading)} diff --git a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx index 93d929e5961..4c99c2c866c 100644 --- a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx +++ b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx @@ -1,5 +1,5 @@ import * as Haptics from "expo-haptics"; -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../components/AppSymbol"; import { useCallback, useEffect, useRef } from "react"; import { ActivityIndicator, Pressable, View } from "react-native"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; @@ -47,7 +47,8 @@ export function GitActionProgressOverlay(props: { diff --git a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx index a9c508f81d2..8e6819378a6 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx @@ -9,6 +9,7 @@ type NewTaskDraftRouteParams = { readonly projectId?: string | string[]; readonly title?: string | string[]; readonly pendingTaskId?: string | string[]; + readonly incomingShareId?: string | string[]; }; export function NewTaskDraftRouteScreen({ route }: StaticScreenProps) { @@ -36,6 +37,9 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps state.isVisible); @@ -78,10 +97,95 @@ export function NewTaskDraftScreen(props: { )?.connectionState === "connected"; const promptInputRef = useRef(null); const loadedBranchesProjectKeyRef = useRef(null); + const [isComposerFocused, setIsComposerFocused] = useState(false); + const [importingShareKey, setImportingShareKey] = useState(null); + const [isCancellingShareImport, setIsCancellingShareImport] = useState(false); + const [cancelledIncomingShareId, setCancelledIncomingShareId] = useState(null); + const [isReturningToProjectPicker, setIsReturningToProjectPicker] = useState(false); + const [shareImportAttempt, setShareImportAttempt] = useState(0); + const startedShareImportKeyRef = useRef(null); + const cancellingShareImportKeyRef = useRef(null); + const shareImportDraftBackupRef = useRef(new Map()); + const activeShareImportTokenRef = useRef(null); + const shareImportMountedRef = useRef(true); + const latestDraftKeyRef = useRef(flow.draftKey); + const latestIncomingShareIdRef = useRef(props.incomingShareId); + latestDraftKeyRef.current = flow.draftKey; + latestIncomingShareIdRef.current = props.incomingShareId; + const isImportingShare = importingShareKey !== null; + const alertedUnavailableIncomingShareIdRef = useRef(null); + const incomingShare = props.incomingShareId ? getShare(props.incomingShareId) : null; + const requestedInitialProjectAvailable = Boolean( + props.initialProjectRef?.environmentId && + props.initialProjectRef.projectId && + projects.some( + (project) => + project.environmentId === props.initialProjectRef?.environmentId && + project.id === props.initialProjectRef?.projectId, + ), + ); + const isProjectPickerReturnActive = + isReturningToProjectPicker && !requestedInitialProjectAvailable; + const isIncomingShareTransferPending = Boolean( + incomingShare && cancelledIncomingShareId !== props.incomingShareId, + ); + usePreventRemove( + (isIncomingShareTransferPending && !isProjectPickerReturnActive) || isCancellingShareImport, + () => undefined, + ); + const hasImportedIncomingShare = Boolean( + props.incomingShareId && + flow.draftKey && + getComposerDraftSnapshot(flow.draftKey).importedShareIds?.includes(props.incomingShareId), + ); + const isIncomingShareUnavailable = Boolean( + props.incomingShareId && + !isIncomingShareInboxLoading && + !incomingShare && + !hasImportedIncomingShare, + ); + const isIncomingShareReady = + !props.incomingShareId || + (hasImportedIncomingShare && !incomingShare) || + isIncomingShareUnavailable; const appliedInitialProjectKeyRef = useRef(null); useEffect(() => { + if (cancelledIncomingShareId === props.incomingShareId) { + navigation.goBack(); + } + }, [cancelledIncomingShareId, navigation, props.incomingShareId]); + useEffect(() => { + if (!isReturningToProjectPicker) { + return; + } + if (requestedInitialProjectAvailable) { + setIsReturningToProjectPicker(false); + return; + } + // Let usePreventRemove commit its disabled state before replacing this + // route, otherwise the transfer guard can swallow the fallback action. + const frame = requestAnimationFrame(() => { + navigation.dispatch( + StackActions.replace("NewTask", { incomingShareId: props.incomingShareId }), + ); + }); + return () => cancelAnimationFrame(frame); + }, [ + isReturningToProjectPicker, + navigation, + props.incomingShareId, + requestedInitialProjectAvailable, + ]); + useEffect(() => { + if (!shareImportMountedRef.current) { + startedShareImportKeyRef.current = null; + } + shareImportMountedRef.current = true; return () => { appliedInitialProjectKeyRef.current = null; + shareImportMountedRef.current = false; + activeShareImportTokenRef.current = null; + cancellingShareImportKeyRef.current = null; }; }, []); @@ -112,7 +216,9 @@ export function NewTaskDraftScreen(props: { }; }, [props.pendingTaskId, cancelEditingPendingTask]); - const borderColor = useThemeColor("--color-border"); + const foregroundColor = useThemeColor("--color-foreground"); + const regularFontFamily = useFontFamily("regular"); + const bodyText = useScaledTextRole("body"); const headlineText = useScaledTextRole("headline"); const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)"; @@ -157,6 +263,14 @@ export function NewTaskDraftScreen(props: { setProject(directProject); return; } + + if (projects.length > 0) { + // Never fall through to the flow provider's temporary first-project + // default. Return to the picker with the share id intact so the user + // can choose an available destination. + setIsReturningToProjectPicker(true); + } + return; } if (selectedProject) { @@ -173,6 +287,7 @@ export function NewTaskDraftScreen(props: { logicalProjects, projects, props.initialProjectRef, + props.incomingShareId, props.pendingTaskId, navigation, selectedProject, @@ -193,7 +308,208 @@ export function NewTaskDraftScreen(props: { }, [flow.loadBranches, selectedProject]); useEffect(() => { - if (!selectedProject) { + const shareId = props.incomingShareId; + const draftKey = flow.draftKey; + const destinationProject = selectedProject; + const initialEnvironmentId = props.initialProjectRef?.environmentId; + const initialProjectId = props.initialProjectRef?.projectId; + const selectedProjectMatchesRoute = + !initialEnvironmentId || + !initialProjectId || + (destinationProject?.environmentId === initialEnvironmentId && + destinationProject.id === initialProjectId); + if ( + !shareId || + !draftKey || + !destinationProject || + !selectedProjectMatchesRoute || + cancelledIncomingShareId === shareId + ) { + return; + } + const importKey = `${shareId}:${draftKey}`; + if ( + startedShareImportKeyRef.current === importKey || + cancellingShareImportKeyRef.current === importKey + ) { + return; + } + + if (!incomingShare) { + if (isIncomingShareUnavailable && alertedUnavailableIncomingShareIdRef.current !== shareId) { + alertedUnavailableIncomingShareIdRef.current = shareId; + Alert.alert( + "Shared content unavailable", + "The shared content is no longer in the inbox. You can continue editing this task draft.", + ); + } + return; + } + + if (alertedUnavailableIncomingShareIdRef.current === shareId) { + alertedUnavailableIncomingShareIdRef.current = null; + } + startedShareImportKeyRef.current = importKey; + const draftBackup = + shareImportDraftBackupRef.current.get(importKey) ?? getComposerDraftSnapshot(draftKey); + shareImportDraftBackupRef.current.set(importKey, draftBackup); + const importToken = Symbol(importKey); + let didReserveShare = false; + let needsDraftRestore = false; + activeShareImportTokenRef.current = importToken; + setImportingShareKey(importKey); + void (async () => { + await reserveShare(shareId, { + environmentId: String(destinationProject.environmentId), + projectId: String(destinationProject.id), + }); + didReserveShare = true; + if ( + !shareImportMountedRef.current || + activeShareImportTokenRef.current !== importToken || + latestDraftKeyRef.current !== draftKey || + latestIncomingShareIdRef.current !== shareId + ) { + return; + } + needsDraftRestore = true; + const { skippedAttachmentCount } = await mergeComposerDraftContent(draftKey, { + text: incomingShare.text, + attachments: incomingShare.attachments, + sourceShareId: shareId, + }); + if ( + !shareImportMountedRef.current || + activeShareImportTokenRef.current !== importToken || + latestDraftKeyRef.current !== draftKey || + latestIncomingShareIdRef.current !== shareId + ) { + // The durable reservation makes an interrupted transfer resume only + // in this project instead of copying into a second project draft. + return; + } + await consumeShare(shareId); + if (!shareImportMountedRef.current || activeShareImportTokenRef.current !== importToken) { + return; + } + const warnings = [...incomingShare.warnings]; + if (skippedAttachmentCount > 0) { + warnings.push( + `${skippedAttachmentCount} shared image${skippedAttachmentCount === 1 ? " was" : "s were"} skipped because this draft reached the attachment limit.`, + ); + } + if (warnings.length > 0) { + Alert.alert("Some shared content was skipped", warnings.join("\n")); + } + shareImportDraftBackupRef.current.delete(importKey); + })() + .catch((error) => { + if (!shareImportMountedRef.current || activeShareImportTokenRef.current !== importToken) { + return; + } + Alert.alert( + "Could not import shared content", + error instanceof Error ? error.message : "The shared content could not be saved.", + [ + { + text: "Cancel import", + style: "cancel", + onPress: () => { + const cancelImport = async (): Promise => { + if (!shareImportMountedRef.current) { + return; + } + // Latch synchronously before restoring the draft. The + // restore publishes atom state and can re-run the import + // effect before React commits the cancelling state update. + cancellingShareImportKeyRef.current = importKey; + setIsCancellingShareImport(true); + try { + if (needsDraftRestore) { + await restoreComposerDraftSnapshot(draftKey, draftBackup); + needsDraftRestore = false; + } + if (didReserveShare) { + await releaseShareReservation(shareId, { + environmentId: String(destinationProject.environmentId), + projectId: String(destinationProject.id), + }); + } + shareImportDraftBackupRef.current.delete(importKey); + if (shareImportMountedRef.current) { + setIsCancellingShareImport(false); + setCancelledIncomingShareId(shareId); + } + } catch (cancelError) { + if (!shareImportMountedRef.current) { + return; + } + Alert.alert( + "Could not cancel import", + cancelError instanceof Error + ? cancelError.message + : "The shared content could not be restored safely.", + [ + { + text: "Retry import", + onPress: () => { + cancellingShareImportKeyRef.current = null; + setIsCancellingShareImport(false); + setShareImportAttempt((attempt) => attempt + 1); + }, + }, + { + text: "Retry cancel", + onPress: () => void cancelImport(), + }, + ], + { cancelable: false }, + ); + } + }; + void cancelImport(); + }, + }, + { + text: "Retry", + onPress: () => setShareImportAttempt((attempt) => attempt + 1), + }, + ], + { cancelable: false }, + ); + }) + .finally(() => { + if (startedShareImportKeyRef.current === importKey) { + // Every terminal path, including an invalidated operation, must + // release the synchronous start latch so this transfer can retry. + startedShareImportKeyRef.current = null; + } + if (shareImportMountedRef.current && activeShareImportTokenRef.current === importToken) { + activeShareImportTokenRef.current = null; + setImportingShareKey(null); + } + }); + }, [ + consumeShare, + cancelledIncomingShareId, + flow.draftKey, + hasImportedIncomingShare, + incomingShare, + isIncomingShareInboxLoading, + isIncomingShareUnavailable, + props.incomingShareId, + props.initialProjectRef?.environmentId, + props.initialProjectRef?.projectId, + releaseShareReservation, + reserveShare, + selectedProject, + shareImportAttempt, + ]); + + useEffect(() => { + // Android starts with the collapsed composer pill (like an open thread) + // and only expands/focuses when tapped. + if (!selectedProject || Platform.OS === "android") { return; } @@ -215,10 +531,11 @@ export function NewTaskDraftScreen(props: { flow.environments.map((environment) => ({ id: `environment:${environment.environmentId}`, title: environment.environmentLabel, + attributes: isIncomingShareTransferPending ? { disabled: true } : undefined, state: flow.selectedEnvironmentId === environment.environmentId ? ("on" as const) : undefined, })), - [flow.environments, flow.selectedEnvironmentId], + [flow.environments, flow.selectedEnvironmentId, isIncomingShareTransferPending], ); const modelMenuActions = useMemo( @@ -383,20 +700,23 @@ export function NewTaskDraftScreen(props: { [currentBranchName, flow.selectedBranchName, flow.workspaceMode], ); function handleModelMenuAction(event: string) { - if (!event.startsWith("model:")) { + if (isIncomingShareTransferPending || !event.startsWith("model:")) { return; } flow.setSelectedModelKey(event.slice("model:".length)); } function handleEnvironmentMenuAction(event: string) { - if (!event.startsWith("environment:")) { + if (isIncomingShareTransferPending || !event.startsWith("environment:")) { return; } flow.selectEnvironment(EnvironmentId.make(event.slice("environment:".length))); } function handleOptionsMenuAction(event: string) { + if (isIncomingShareTransferPending) { + return; + } const providerOptions = applyProviderOptionMenuEvent(providerOptionDescriptors, event); if (providerOptions) { flow.setSelectedModelOptions(providerOptions); @@ -416,6 +736,9 @@ export function NewTaskDraftScreen(props: { } function handleWorkspaceMenuAction(event: string) { + if (isIncomingShareTransferPending) { + return; + } if (event.startsWith("workspace:mode:")) { flow.setWorkspaceMode( event.slice("workspace:mode:".length) as Parameters[0], @@ -436,6 +759,9 @@ export function NewTaskDraftScreen(props: { } async function handlePickImages(): Promise { + if (isIncomingShareTransferPending) { + return; + } const result = await pickComposerImages({ existingCount: flow.attachments.length }); if (result.images.length > 0) { flow.appendAttachments(result.images); @@ -518,8 +844,7 @@ export function NewTaskDraftScreen(props: { if (editingPendingTask) { flow.finishEditingPendingTask(); } else { - flow.setPrompt(""); - flow.clearAttachments(); + clearComposerDraftContent(draftKey); } navigation.getParent()?.goBack(); return; @@ -577,8 +902,7 @@ export function NewTaskDraftScreen(props: { } flow.finishEditingPendingTask(); } else { - flow.setPrompt(""); - flow.clearAttachments(); + clearComposerDraftContent(draftKey); } navigation.dispatch( StackActions.replace("Thread", { @@ -591,7 +915,208 @@ export function NewTaskDraftScreen(props: { if (!selectedProject) { return ( - + {Platform.OS === "android" ? ( + <> + + navigation.goBack()} /> + + ) : ( + + )} + + ); + } + + const isAndroid = Platform.OS === "android"; + const isDarkMode = colorScheme === "dark"; + // Android expansion follows native editor focus so relayout cannot race + // the touch gesture that opens the keyboard. + const isExpanded = !isAndroid || isComposerFocused; + const canStart = + Boolean(flow.selectedProject) && + Boolean(flow.selectedModel) && + flow.prompt.trim().length > 0 && + isIncomingShareReady && + !isImportingShare && + !flow.submitting && + !(flow.workspaceMode === "worktree" && !flow.selectedBranchName); + const promptEditor = ( + setIsComposerFocused(true)} + onBlur={() => setIsComposerFocused(false)} + onPasteImages={(uris) => void handleNativePasteImages(uris)} + placeholder={`Describe a coding task in ${selectedProject.title}`} + // Same collapsed centering as ThreadComposer: native vertical gravity + // in a pill-height box. + singleLineCentered={!isExpanded} + contentInsetVertical={isAndroid ? 0 : undefined} + style={ + isAndroid + ? isExpanded + ? { minHeight: 80, maxHeight: 160, paddingHorizontal: 4, paddingVertical: 4 } + : { height: 36 } + : { flex: 1, minHeight: 0 } + } + textStyle={ + isAndroid + ? { ...bodyText, color: foregroundColor, fontFamily: regularFontFamily } + : headlineText + } + /> + ); + + const toolbarPills = ( + <> + void handlePickImages()} + showChevron={false} + disabled={isIncomingShareTransferPending} + /> + handleModelMenuAction(nativeEvent.event)} + > + } + label={flow.selectedModelOption?.label ?? "Model"} + /> + + handleOptionsMenuAction(nativeEvent.event)} + > + + + handleEnvironmentMenuAction(nativeEvent.event)} + > + + + handleWorkspaceMenuAction(nativeEvent.event)} + > + + + + ); + + const startButton = ( + void handleStart()} + variant="primary" + showChevron={false} + disabled={!canStart} + /> + ); + + if (isAndroid) { + // The draft is a thread that doesn't exist yet, so it mirrors the thread + // page: in-screen header, empty feed canvas above, and the same floating + // composer chrome as ThreadComposer (collapsed pill → expanded card). + return ( + + + navigation.goBack()} /> + + + + + + + {isExpanded && flow.attachments.length > 0 ? ( + + undefined : flow.removeAttachment + } + /> + + ) : null} + {promptEditor} + {!isExpanded ? ( + void handleStart()} + /> + ) : null} + + + {isExpanded ? ( + + + {toolbarPills} + + {startButton} + + ) : null} + + ); } @@ -600,35 +1125,15 @@ export function NewTaskDraftScreen(props: { - - - void handleNativePasteImages(uris)} - placeholder={`Describe a coding task in ${selectedProject.title}`} - style={{ flex: 1, minHeight: 0 }} - textStyle={headlineText} - /> - + + {promptEditor} - + {flow.attachments.length > 0 ? ( - + undefined : flow.removeAttachment} imageSize={88} imageBorderRadius={20} /> @@ -639,74 +1144,9 @@ export function NewTaskDraftScreen(props: { fadeOpaque={sheetFadeOpaque} fadeTransparent={sheetFadeTransparent} > - void handlePickImages()} - showChevron={false} - /> - handleModelMenuAction(nativeEvent.event)} - > - - } - label={flow.selectedModelOption?.label ?? "Model"} - /> - - handleOptionsMenuAction(nativeEvent.event)} - > - - - handleEnvironmentMenuAction(nativeEvent.event)} - > - - - handleWorkspaceMenuAction(nativeEvent.event)} - > - - + {toolbarPills} - void handleStart()} - variant="primary" - showChevron={false} - disabled={ - !flow.selectedProject || - !flow.selectedModel || - flow.prompt.trim().length === 0 || - flow.submitting || - (flow.workspaceMode === "worktree" && !flow.selectedBranchName) - } - /> + {startButton} diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 896a43d636b..99985385ea7 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -1,12 +1,14 @@ -import { NativeHeaderToolbar } from "../../native/StackHeader"; -import { useNavigation } from "@react-navigation/native"; -import { SymbolView } from "expo-symbols"; +import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; +import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { SymbolView } from "../../components/AppSymbol"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; -import { useMemo } from "react"; -import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; +import { useEffect, useMemo, useRef } from "react"; +import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; +import { cn } from "../../lib/cn"; +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text } from "../../components/AppText"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { useProjects, useThreadShells } from "../../state/entities"; @@ -14,6 +16,11 @@ import type { WorkspaceState } from "../../state/workspaceModel"; import { useWorkspaceState } from "../../state/workspace"; import { groupProjectsByRepository } from "../../lib/repositoryGroups"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; +import { useIncomingShare } from "../sharing/IncomingShareProvider"; + +type NewTaskRouteParams = { + readonly incomingShareId?: string | string[]; +}; function deriveProjectEmptyState(catalogState: WorkspaceState): { readonly title: string; @@ -70,16 +77,29 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): { }; } -export function NewTaskRouteScreen() { +export function NewTaskRouteScreen({ route }: StaticScreenProps) { const projects = useProjects(); const threads = useThreadShells(); const { state: catalogState } = useWorkspaceState(); const navigation = useNavigation(); + const isFocused = useIsFocused(); const { layout } = useAdaptiveWorkspaceLayout(); const insets = useSafeAreaInsets(); const chevronColor = useThemeColor("--color-chevron"); const accentColor = useThemeColor("--color-icon-muted"); - const borderSubtleColor = useThemeColor("--color-border-subtle"); + const { getShare, releaseShareReservation } = useIncomingShare(); + const routeShareId = Array.isArray(route.params?.incomingShareId) + ? route.params.incomingShareId[0] + : route.params?.incomingShareId; + const incomingShare = routeShareId ? getShare(routeShareId) : null; + const incomingShareSubtitle = incomingShare + ? incomingShare.attachments.length === 0 + ? "Choose a project for what you shared" + : incomingShare.attachments.length === 1 + ? "Choose a project for the image you shared" + : `Choose a project for the ${incomingShare.attachments.length} images you shared` + : null; + const screenTitle = incomingShare ? "Start a task" : "Choose project"; const repositoryGroups = useMemo( () => groupProjectsByRepository({ projects, threads }), [projects, threads], @@ -108,31 +128,123 @@ export function NewTaskRouteScreen() { return nextItems; }, [repositoryGroups]); const projectEmptyState = deriveProjectEmptyState(catalogState); + const resumedDestinationKeyRef = useRef(null); + const reservedDestinationProject = incomingShare?.destination + ? (projects.find( + (project) => + project.environmentId === incomingShare.destination?.environmentId && + project.id === incomingShare.destination?.projectId, + ) ?? null) + : null; + + async function selectProject(item: (typeof items)[number]): Promise { + if (incomingShare?.destination && !reservedDestinationProject) { + try { + await releaseShareReservation(incomingShare.id, incomingShare.destination); + } catch (error) { + Alert.alert( + "Could not change project", + error instanceof Error + ? error.message + : "The shared content reservation could not be updated.", + ); + return; + } + } + navigation.navigate("NewTaskSheet", { + screen: "NewTaskDraft", + params: { + environmentId: item.environmentId, + projectId: item.id, + title: item.title, + incomingShareId: incomingShare?.id, + }, + }); + } + + useEffect(() => { + const destination = incomingShare?.destination; + if (!destination) { + resumedDestinationKeyRef.current = null; + return; + } + if (!isFocused) { + // Returning from the reserved draft is a fresh resume attempt. Keeping + // this latch set would leave every project row disabled with no route. + resumedDestinationKeyRef.current = null; + return; + } + const destinationKey = `${incomingShare.id}:${destination.environmentId}:${destination.projectId}`; + if (resumedDestinationKeyRef.current === destinationKey) { + return; + } + if (!reservedDestinationProject) { + return; + } + resumedDestinationKeyRef.current = destinationKey; + navigation.navigate("NewTaskSheet", { + screen: "NewTaskDraft", + params: { + environmentId: reservedDestinationProject.environmentId, + projectId: reservedDestinationProject.id, + title: reservedDestinationProject.title, + incomingShareId: incomingShare.id, + }, + }); + }, [incomingShare, isFocused, navigation, reservedDestinationProject]); return ( - - {layout.usesSplitView ? ( - navigation.goBack()} - separateBackground + {Platform.OS === "android" ? ( + <> + {/* Android renders its own in-screen header instead of the native bar. */} + + navigation.goBack() : undefined} + actions={[ + { + accessibilityLabel: "Add project", + icon: "plus", + onPress: () => navigation.navigate("NewTaskSheet", { screen: "AddProject" }), + }, + ]} + /> + + ) : ( + <> + - ) : null} - navigation.navigate("NewTaskSheet", { screen: "AddProject" })} - separateBackground - /> - + + {layout.usesSplitView ? ( + navigation.goBack()} + separateBackground + /> + ) : null} + navigation.navigate("NewTaskSheet", { screen: "AddProject" })} + separateBackground + /> + + + )} - navigation.navigate("NewTaskSheet", { - screen: "NewTaskDraft", - params: { - environmentId: item.environmentId, - projectId: item.id, - title: item.title, - }, - }) - } - style={{ - paddingHorizontal: 16, - paddingVertical: 14, - borderTopWidth: isFirst ? 0 : 1, - borderTopColor: borderSubtleColor, - borderTopLeftRadius: isFirst ? 24 : 0, - borderTopRightRadius: isFirst ? 24 : 0, - borderBottomLeftRadius: isLast ? 24 : 0, - borderBottomRightRadius: isLast ? 24 : 0, - }} + disabled={reservedDestinationProject !== null} + onPress={() => void selectProject(item)} + className={cn( + "bg-card px-4 py-3.5", + !isFirst && "border-t border-border-subtle", + isFirst && "rounded-t-[24px]", + isLast && "rounded-b-[24px]", + )} > diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index eb3db00b026..b8fa86623a4 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -22,6 +22,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject import { ActivityIndicator, Image, + Platform, Pressable, useColorScheme, View, @@ -116,12 +117,18 @@ export interface ThreadComposerProps { /** * The pill / card container — renders as LiquidGlassView on supported * iOS 26+ devices (progressive blur, native morph), opaque View otherwise. + * Exported so NewTaskDraftScreen can render the same composer chrome. */ // One timing for every piece of the expanded↔compact morph so the surface, // toolbar, and siblings move together instead of popping between layouts. -const COMPOSER_LAYOUT_TRANSITION = LinearTransition.duration(220); - -function ComposerSurface(props: { +// Android gets NO layout transition: the composer rides the keyboard via +// KeyboardStickyView (frame-synced to the IME), and a time-based morph +// running alongside that translate reads as jitter. Snapping the layout and +// letting the keyboard-synced slide be the only motion looks native there. +const COMPOSER_LAYOUT_TRANSITION = + Platform.OS === "android" ? undefined : LinearTransition.duration(220); + +export function ComposerSurface(props: { readonly children: ReactNode; readonly style: ViewStyle; readonly isDarkMode: boolean; @@ -689,9 +696,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer return ( {composerTrigger && composerMenuItems.length > 0 ? ( - + 0 ? "pb-2.5" : undefined} entering={FadeIn.duration(160)} exiting={FadeOut.duration(120)} - style={{ paddingBottom: props.draftAttachments.length > 0 ? 10 : 0 }} > ) : null} - + {!isExpanded && props.draftAttachments.length > 0 ? ( - + {props.draftAttachments.slice(0, 3).map((image) => ( onPressImage(image.previewUri)}> ))} {props.draftAttachments.length > 3 ? ( - + +{props.draftAttachments.length - 3} @@ -852,8 +838,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} - {/* Toolbar row — matches draft page layout (expanded only) */} {isExpanded ? ( + // Toolbar row — matches draft page layout (expanded only) void props.onPickDraftImages()} showChevron={false} @@ -889,6 +876,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer {showStopAction ? ( 0 ? ( - + {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send automatically. diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 0b9aa2fdfbc..f0a3b4d88d1 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -17,7 +17,7 @@ import type { import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; import * as Haptics from "expo-haptics"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { Platform, View, type GestureResponderEvent } from "react-native"; +import { Platform, View, type GestureResponderEvent, type LayoutChangeEvent } from "react-native"; import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller"; import Animated, { FadeInDown, FadeOut, LinearTransition } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -178,6 +178,10 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray @@ -251,6 +254,24 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const composerOverlapHeight = composerChrome + composerBottomInset; const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0; const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight; + const [composerOverlayHeight, setComposerOverlayHeight] = useState(estimatedOverlayHeight); + const estimatedOverlayHeightRef = useRef(estimatedOverlayHeight); + useEffect(() => { + estimatedOverlayHeightRef.current = estimatedOverlayHeight; + }, [estimatedOverlayHeight]); + useEffect(() => { + // A measured overlay can include approval or input cards that are not part + // of this baseline estimate. Keep that larger measurement until layout + // reports a new height, while ensuring chrome changes never under-estimate + // the composer inset. + setComposerOverlayHeight((current) => Math.max(current, estimatedOverlayHeight)); + }, [estimatedOverlayHeight]); + useEffect(() => { + // Layout callbacks from the previous thread can arrive after navigation. + // Reset their larger measurement to this thread's known baseline; its own + // overlay layout will immediately replace it if it contains extra cards. + setComposerOverlayHeight(estimatedOverlayHeightRef.current); + }, [selectedThreadKey]); // The overlay's measured height includes the home-indicator inset (the // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes // UIKit add the safe-area bottom to the content inset AGAIN — leaving a @@ -260,11 +281,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // its end-scroll math matches the real resting position. const nativeInsetOvercount = props.usesAutomaticContentInsets === true && Platform.OS === "ios" ? insets.bottom : 0; + // heightAdjustment is added to measured composer height. Keep the safe-area + // under-report (UIKit automatic insets) and add breathing room above chrome. + const composerEndHeightAdjustment = THREAD_FEED_COMPOSER_GAP - nativeInsetOvercount; const { contentInsetEndAdjustment, onComposerLayout } = useKeyboardChatComposerInset( listRef, composerOverlayRef, - Math.max(0, estimatedOverlayHeight - nativeInsetOvercount), - -nativeInsetOvercount, + Math.max(0, estimatedOverlayHeight + composerEndHeightAdjustment), + composerEndHeightAdjustment, + ); + const handleComposerLayout = useCallback( + (event: LayoutChangeEvent) => { + onComposerLayout(event); + const height = event.nativeEvent.layout.height; + setComposerOverlayHeight((current) => (Math.abs(current - height) > 1 ? height : current)); + }, + [onComposerLayout], ); const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); const showContent = props.showContent ?? true; @@ -392,7 +424,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {showContent ? ( ) : ( - + )} {/* Floating composer — sticks to keyboard via KeyboardStickyView */} @@ -440,10 +473,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {/* No paddingTop here: the overlay's measured height becomes the list's bottom inset, so any padding above the pill/composer pushes the resting content floor up by the same amount. */} - + {props.activeWorkStartedAt ? ( @@ -456,10 +490,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {props.activePendingApproval || props.activePendingUserInput ? ( {props.activePendingApproval ? ( ; readonly anchorMessageId: MessageId | null; readonly contentInsetEndAdjustment: SharedValue; + /** Effective resting end inset: measured composer overlay plus feed gap. */ + readonly contentInsetEndEstimate: number; readonly contentTopInset?: number; readonly contentBottomInset?: number; readonly topAccessory?: ReactNode; @@ -239,18 +243,26 @@ function MessageAttachmentImage(props: { attachmentId: props.attachmentId, }); - if (uri === null) { - return ( - - - - ); - } - return ( - props.onPressImage(uri)}> - - + + {uri === null ? ( + + ) : ( + props.onPressImage(uri)} + style={({ pressed }) => ({ height: "100%", opacity: pressed ? 0.7 : 1, width: "100%" })} + > + + + )} + ); } @@ -291,6 +303,12 @@ const MARKDOWN_COLORS = { }, } as const; +const MARKDOWN_MONO_FONT = Platform.select({ + ios: "ui-monospace", + android: "monospace", + default: "monospace", +}); + interface MarkdownStyleSets { readonly user: MarkdownStyleSet; readonly assistant: MarkdownStyleSet; @@ -323,10 +341,6 @@ const markdownLinkStyles = StyleSheet.create({ favicon: { borderRadius: 3, }, - file: { - fontFamily: "DMSans_700Bold", - fontWeight: "700", - }, }); const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { @@ -339,12 +353,12 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { return ( { void Linking.openURL(props.href); }} style={{ color: props.color, - fontFamily: "DMSans_400Regular", textDecorationLine: "none", }} > @@ -367,6 +381,122 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { ); }); +function MarkdownCodeBlock(props: { + readonly backgroundColor: string; + readonly borderColor: string; + readonly content: string; + readonly copyTintColor: ColorValue; + readonly headerTextColor: string; + readonly fontSize: number; + readonly highlightCode: boolean; + readonly language?: string | null; + readonly lineHeight: number; + readonly textColor: string; + readonly theme: ReviewDiffTheme; +}) { + const content = props.content.replace(/\n$/, ""); + const languageLabel = props.language?.trim() || "text"; + const highlighted = useMarkdownCodeHighlight({ + code: content, + enabled: props.highlightCode && Boolean(props.language?.trim()), + language: props.language, + theme: props.theme, + }); + let tokenOffset = 0; + + return ( + + + + {languageLabel} + + + + + + {highlighted + ? highlighted.map((line, lineIndex) => { + const lineStartOffset = tokenOffset; + const lineText = line.map((token) => token.content).join(""); + const renderedLine = ( + + {line.map((token) => { + const startOffset = tokenOffset; + tokenOffset += token.content.length; + const fontStyle = + token.fontStyle !== null && (token.fontStyle & 1) === 1 + ? ("italic" as const) + : ("normal" as const); + const fontWeight = + token.fontStyle !== null && (token.fontStyle & 2) === 2 + ? ("700" as const) + : ("400" as const); + + return ( + + {token.content} + + ); + })} + {lineIndex + 1 < highlighted.length ? "\n" : ""} + + ); + if (lineIndex + 1 < highlighted.length) { + tokenOffset += 1; + } + return renderedLine; + }) + : content} + + + + ); +} + function useReviewCommentColors(): ReviewCommentColors { const colorScheme = useColorScheme(); const isDark = colorScheme === "dark"; @@ -401,8 +531,13 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe () => resolveNativeMarkdownTypography(appearance.baseFontSize), [appearance.baseFontSize], ); - const colors = MARKDOWN_COLORS[colorScheme === "dark" ? "dark" : "light"]; + const themeMode = colorScheme === "dark" ? "dark" : "light"; + const colors = MARKDOWN_COLORS[themeMode]; + const iconSubtleColor = String(useThemeColor("--color-icon-subtle")); const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground")); + const userBubbleForegroundMuted = String(useThemeColor("--color-user-bubble-foreground-muted")); + const regularFontFamily = useFontFamily("regular"); + const boldFontFamily = useFontFamily("bold"); return useMemo(() => { const markdownBodyColor = colors.body; @@ -428,6 +563,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe link: markdownLinkColor, blockquote: markdownBlockquoteBorder, border: markdownHrColor, + surface: "transparent", surfaceLight: markdownBlockquoteBg, accent: markdownLinkColor, tableBorder: markdownHrColor, @@ -454,9 +590,9 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe h6: markdownFontSizes.h6, }, fontFamilies: { - regular: "DMSans_400Regular", - heading: "DMSans_700Bold", - mono: "ui-monospace", + regular: regularFontFamily, + heading: boldFontFamily, + mono: MARKDOWN_MONO_FONT, }, headingWeight: "700", borderRadius: { @@ -477,7 +613,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe bold: { fontWeight: "700", color: markdownStrongColor, - fontFamily: "DMSans_700Bold", + fontFamily: boldFontFamily, }, italic: { fontStyle: "italic" }, link: { @@ -493,7 +629,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe marginVertical: 10, }, heading: { - fontFamily: "DMSans_700Bold", + fontFamily: boldFontFamily, color: markdownStrongColor, marginTop: 18, marginBottom: 8, @@ -510,15 +646,18 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe inlineCodeTextColor: string, blockBackgroundColor: string, blockTextColor: string, + copyTintColor: ColorValue, preserveSoftBreaks: boolean, + highlightCode: boolean, ): CustomRenderers => ({ link: ({ children, href = "" }) => { const presentation = resolveMarkdownLinkPresentation(href); if (presentation.kind === "file") { return ( onLinkPress(href)} - style={[markdownLinkStyles.file, { color: inlineTextColor }]} + style={{ color: inlineTextColor }} > void): MarkdownStyleSe const linkHref = presentation.href; return ( { @@ -549,17 +689,14 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe } : undefined } - style={{ - color: markdownLinkColor, - textDecorationLine: "underline", - }} + style={{ color: markdownLinkColor }} > {children} ); }, list: ({ node, Renderer, ordered = false, start = 1 }) => ( - + {node.children?.map((child, index) => { const childKey = `${child.type}:${child.beg ?? "unknown"}:${child.end ?? "unknown"}`; if (child.type === "task_list_item") { @@ -568,20 +705,13 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe ); } return ( - + void): MarkdownStyleSe > {ordered ? `${start + index}.` : "•"} - + @@ -601,9 +731,9 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe const value = content ?? ""; return ( void): MarkdownStyleSe soft_break: () => {"\n"}, } : {}), - code_block: ({ content, language }) => ( - - {language ? ( - - - {language} - - - ) : null} - - - {content} - - - + code_block: ({ content = "", language }) => ( + ), }); @@ -690,7 +782,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe bold: { fontWeight: "700", color: markdownUserBodyColor, - fontFamily: "DMSans_700Bold", + fontFamily: boldFontFamily, }, heading: { ...baseStyles.heading, @@ -726,7 +818,9 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe markdownUserInlineCodeText, markdownUserFenceBg, markdownUserFenceText, + userBubbleForegroundMuted, true, + false, ), nativeTextStyle: { color: markdownUserBodyColor, @@ -744,9 +838,9 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe fontSize: nativeMarkdownTypography.fontSize, lineHeight: nativeMarkdownTypography.lineHeight, headingFontSizes: nativeMarkdownTypography.headingFontSizes, - fontFamily: "DMSans_400Regular", - headingFontFamily: "DMSans_700Bold", - boldFontFamily: "DMSans_700Bold", + fontFamily: regularFontFamily, + headingFontFamily: boldFontFamily, + boldFontFamily, }, }, assistant: { @@ -757,7 +851,9 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe markdownInlineCodeText, markdownCodeBg, markdownCodeText, + iconSubtleColor, false, + true, ), nativeTextStyle: { color: markdownBodyColor, @@ -775,13 +871,24 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe fontSize: nativeMarkdownTypography.fontSize, lineHeight: nativeMarkdownTypography.lineHeight, headingFontSizes: nativeMarkdownTypography.headingFontSizes, - fontFamily: "DMSans_400Regular", - headingFontFamily: "DMSans_700Bold", - boldFontFamily: "DMSans_700Bold", + fontFamily: regularFontFamily, + headingFontFamily: boldFontFamily, + boldFontFamily, }, }, }; - }, [colors, inlineSkillForeground, markdownFontSizes, nativeMarkdownTypography, onLinkPress]); + }, [ + boldFontFamily, + colors, + iconSubtleColor, + inlineSkillForeground, + markdownFontSizes, + nativeMarkdownTypography, + onLinkPress, + regularFontFamily, + themeMode, + userBubbleForegroundMuted, + ]); } function renderFeedEntry( @@ -924,6 +1031,7 @@ function renderFeedEntry( hasNativeSelectableMarkdownText() ? ( + {props.loading ? : null} {props.title} {props.detail} @@ -1263,6 +1372,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const copyFeedbackTimeoutRef = useRef | null>(null); const foldSettleFrameRef = useRef(null); const foldSettleSecondFrameRef = useRef(null); + const initialEndCorrectionFrameRef = useRef(null); + const underflowCorrectionFrameRef = useRef(null); + const initialEndCorrectionKeyRef = useRef(null); + const previousContentUnderflowsViewportRef = useRef(false); const previousLatestTurnRef = useRef(props.latestRun); const disclosureAnchorKeyRef = useRef(null); const headerMaterialVisibleRef = useRef(false); @@ -1271,6 +1384,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { props.layoutVariant === "split" ? 0 : windowWidth, ); const [viewportHeight, setViewportHeight] = useState(0); + const [contentHeight, setContentHeight] = useState(0); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); const [interactionState, setInteractionState] = useState<{ readonly copiedRowId: string | null; @@ -1395,6 +1509,51 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { setViewportHeight((current) => (Math.abs(current - nextHeight) > 1 ? nextHeight : current)); }, []); + const handleContentSizeChange = useCallback((_width: number, height: number) => { + setContentHeight((current) => (Math.abs(current - height) > 1 ? height : current)); + }, []); + // UIKit's automatic top inset reduces the visible list area without adding + // to contentHeight. Non-automatic layouts render their header spacer inside + // the list, so their contentHeight already accounts for it. + const usableViewportHeight = viewportHeight - (usesNativeAutomaticInsets ? anchorTopInset : 0); + const contentUnderflowsViewport = + contentHeight > 0 && + viewportHeight > 0 && + contentHeight + props.contentInsetEndEstimate < usableViewportHeight - CONTENT_FIT_EPSILON; + + useEffect(() => { + if (underflowCorrectionFrameRef.current !== null) { + cancelAnimationFrame(underflowCorrectionFrameRef.current); + underflowCorrectionFrameRef.current = null; + } + + const previouslyUnderflowed = previousContentUnderflowsViewportRef.current; + previousContentUnderflowsViewportRef.current = contentUnderflowsViewport; + if (contentUnderflowsViewport) { + underflowCorrectionFrameRef.current = requestAnimationFrame(() => { + underflowCorrectionFrameRef.current = null; + void props.listRef.current?.scrollToOffset({ + animated: false, + offset: -anchorTopInset, + }); + }); + return; + } + + // A disclosure transition owns its visible-content anchor, so consuming + // the underflow transition without an end scroll is intentional there. + if (previouslyUnderflowed && !disclosureToggleSettling) { + void props.listRef.current?.scrollToEnd({ animated: true }); + } + }, [ + anchorTopInset, + contentHeight, + contentUnderflowsViewport, + disclosureToggleSettling, + props.listRef, + viewportHeight, + ]); + useEffect(() => { reportHeaderMaterialVisibility(false); }, [props.threadId, reportHeaderMaterialVisibility]); @@ -1404,14 +1563,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { [expandedTurnIds, props.feed, props.latestRun], ); - // The empty↔filled key below remounts the list, which resets its imperative - // content-inset override — and useKeyboardChatComposerInset (mounted above + // A hydrating thread reserves the filled identity so detail arrival does not + // replace the native list during the draft-to-thread transition. An already + // open empty thread still remounts when its first message arrives. That + // remount resets its imperative content-inset override — and + // useKeyboardChatComposerInset (mounted above // the remount boundary) deduplicates by height, so it never re-reports the // composer inset to the fresh instance. Without this, the remounted list's // initial scroll-to-end computes with a zero end inset and rests one // composer-height short of the end. Layout effect: it must land before the // list's first positioning tick or the one-shot initial scroll misses it. - const listMountKey = `${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`; + const listMountState = + props.contentPresentation.kind === "loading" + ? "filled" + : props.feed.length === 0 + ? "empty" + : "filled"; + const listMountKey = `${props.threadId}:${listMountState}`; useLayoutEffect(() => { const bottom = props.contentInsetEndAdjustment.value; if (bottom > 0) { @@ -1419,6 +1587,41 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } }, [listMountKey, props.contentInsetEndAdjustment, props.listRef]); + useEffect(() => { + if (initialEndCorrectionFrameRef.current !== null) { + cancelAnimationFrame(initialEndCorrectionFrameRef.current); + initialEndCorrectionFrameRef.current = null; + } + + if ( + initialEndCorrectionKeyRef.current === listMountKey || + props.contentPresentation.kind !== "ready" || + contentHeight <= 0 || + viewportHeight <= 0 + ) { + return; + } + + initialEndCorrectionKeyRef.current = listMountKey; + if (contentUnderflowsViewport) { + return; + } + + initialEndCorrectionFrameRef.current = requestAnimationFrame(() => { + initialEndCorrectionFrameRef.current = requestAnimationFrame(() => { + initialEndCorrectionFrameRef.current = null; + void props.listRef.current?.scrollToEnd({ animated: false }); + }); + }); + }, [ + contentHeight, + contentUnderflowsViewport, + listMountKey, + props.contentPresentation.kind, + props.listRef, + viewportHeight, + ]); + const anchoredEndSpace = useMemo( () => resolveChatListAnchoredEndSpace( @@ -1481,6 +1684,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { if (foldSettleSecondFrameRef.current !== null) { cancelAnimationFrame(foldSettleSecondFrameRef.current); } + if (underflowCorrectionFrameRef.current !== null) { + cancelAnimationFrame(underflowCorrectionFrameRef.current); + } + if (initialEndCorrectionFrameRef.current !== null) { + cancelAnimationFrame(initialEndCorrectionFrameRef.current); + } }; }, []); @@ -1646,8 +1855,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { return ( <> - - + + ) : null} + {props.contentPresentation.kind === "loading" ? ( + + + + ) : null} setExpandedImage(null)} + presentationStyle="overFullScreen" swipeToCloseEnabled doubleTapToZoomEnabled /> diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index c639bd60f53..6e0e243aad6 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -5,7 +5,6 @@ import type { } from "@t3tools/client-runtime/state/shell"; import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; -import { SymbolView } from "expo-symbols"; import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native"; import { Platform, StyleSheet, TextInput, View, useColorScheme } from "react-native"; @@ -17,6 +16,7 @@ import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; +import { SymbolView } from "../../components/AppSymbol"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -97,8 +97,6 @@ function SidebarHeaderButtonGroup(props: { const SIDEBAR_STICKY_HEADER_HEIGHT = 106; const SIDEBAR_STICKY_HEADER_FADE_HEIGHT = 44; -const IOS_SEARCH_FILL_DARK = "rgba(118, 118, 128, 0.24)"; -const IOS_SEARCH_FILL_LIGHT = "rgba(118, 118, 128, 0.12)"; const SIDEBAR_HEADER_WASH_OPACITY = { dark: [0.22, 0.14, 0.04], light: [0.46, 0.3, 0.08], @@ -140,15 +138,13 @@ function NativeSidebarContainer(props: ThreadNavigationSidebarProps) { return ( @@ -338,11 +334,8 @@ function ThreadNavigationSidebarPane( const backgroundColor = useThemeColor("--color-drawer"); const borderColor = useThemeColor("--color-border"); - const foregroundColor = useThemeColor("--color-foreground"); const mutedColor = useThemeColor("--color-foreground-muted"); const placeholderColor = useThemeColor("--color-placeholder"); - const searchBackgroundColor = - colorScheme === "dark" ? IOS_SEARCH_FILL_DARK : IOS_SEARCH_FILL_LIGHT; const headerFadeColor = String(backgroundColor); const headerWashOpacity = SIDEBAR_HEADER_WASH_OPACITY[colorScheme]; const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState(null); @@ -532,7 +525,7 @@ function ThreadNavigationSidebarPane( [filterIcon, filterMenu, props.onOpenSettings], ); const listEmpty = ( - + {catalogState.isLoadingConnections ? "Loading threads…" : props.searchQuery.trim().length > 0 @@ -566,7 +559,7 @@ function ThreadNavigationSidebarPane( unstable_headerRightItems: () => nativeHeaderItems, }} /> - + + - + @@ -704,12 +687,8 @@ function ThreadNavigationSidebarPane( - - + + Threads @@ -724,14 +703,7 @@ function ThreadNavigationSidebarPane( - + {showsConnectionStatus ? ( - + - immediateThreadRelationships(graph, props.threadId).toSorted( + copySorted( + immediateThreadRelationships(graph, props.threadId), (left, right) => Number(right.threadId === mergeTargetThreadId) - Number(left.threadId === mergeTargetThreadId), diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index b45f350aafe..dbdf1560343 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -10,18 +10,21 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro import * as Option from "effect/Option"; import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; -import { Platform, Pressable, ScrollView, Text as RNText, View } from "react-native"; +import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useWorkspaceState } from "../../state/workspace"; -import { useThemeColor } from "../../lib/useThemeColor"; import { useEnvironmentQuery } from "../../state/query"; import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state"; import { vcsEnvironment } from "../../state/vcs"; import { EmptyState } from "../../components/EmptyState"; +import { + AndroidScreenHeader, + type AndroidHeaderAction, +} from "../../components/AndroidScreenHeader"; import { LoadingScreen } from "../../components/LoadingScreen"; +import { addBreadcrumb } from "../../lib/breadcrumbs"; import { scopedThreadKey } from "../../lib/scopedEntities"; -import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { connectionTone } from "../connection/connectionTone"; import { @@ -30,7 +33,7 @@ import { useRemoteEnvironmentRuntime, } from "../../state/use-remote-environment-registry"; import { useKnownTerminalSessions } from "../../state/use-terminal-session"; -import { useSelectedThreadDetailState } from "../../state/use-thread-detail"; +import { useSelectedThreadDetailQuery } from "../../state/use-thread-detail"; import { useThreadSelection } from "../../state/use-thread-selection"; import { GitActionProgressOverlay } from "./GitActionProgressOverlay"; import { @@ -69,7 +72,6 @@ import { ThreadInspectorContentStack, type ThreadInspectorMode, } from "./thread-inspector-content-stack"; -import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; interface ThreadInspectorSelection { readonly routeThreadIdentity: string | null; @@ -78,6 +80,11 @@ interface ThreadInspectorSelection { type NativeHeaderItems = ReadonlyArray>; +const THREAD_DETAIL_STALL_RETRY_DELAYS_MS = [8_000, 12_000] as const; +const THREAD_DETAIL_STALL_ERROR_DELAY_MS = 20_000; +const THREAD_DETAIL_STALL_ERROR = + "The conversation did not finish loading. Close and reopen the thread to retry."; + function InspectorPaneRoleActivation() { useAdaptiveWorkspacePaneRole("inspector"); return null; @@ -144,7 +151,91 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { selectedThread === null ? null : scopedThreadKey(selectedThread.environmentId, selectedThread.id); - const selectedThreadDetailState = useSelectedThreadDetailState(); + const selectedThreadDetailQuery = useSelectedThreadDetailQuery(); + const selectedThreadDetailState = selectedThreadDetailQuery.state; + const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data); + const detailRefreshAttemptsRef = useRef(new Map()); + const refreshSelectedThreadDetailRef = useRef(selectedThreadDetailQuery.refresh); + const [stalledDetailKey, setStalledDetailKey] = useState(null); + + useEffect(() => { + refreshSelectedThreadDetailRef.current = selectedThreadDetailQuery.refresh; + }, [selectedThreadDetailQuery.refresh]); + + useEffect( + () => () => { + if (routeThreadKey !== null) { + detailRefreshAttemptsRef.current.delete(routeThreadKey); + } + }, + [routeThreadKey], + ); + + useEffect(() => { + if (routeThreadKey === null) { + return; + } + + if (selectedThreadKey !== routeThreadKey) { + return; + } + + if (selectedThreadDetail !== null || selectedThreadDetailState.status === "deleted") { + detailRefreshAttemptsRef.current.delete(routeThreadKey); + setStalledDetailKey((current) => (current === routeThreadKey ? null : current)); + return; + } + + if (routeConnectionState !== "connected") { + detailRefreshAttemptsRef.current.delete(routeThreadKey); + setStalledDetailKey((current) => (current === routeThreadKey ? null : current)); + return; + } + + if (stalledDetailKey === routeThreadKey) { + return; + } + + let cancelled = false; + let timer: ReturnType | null = null; + const scheduleRetry = () => { + const attempts = detailRefreshAttemptsRef.current.get(routeThreadKey) ?? 0; + const delayMs = + THREAD_DETAIL_STALL_RETRY_DELAYS_MS[attempts] ?? THREAD_DETAIL_STALL_ERROR_DELAY_MS; + timer = setTimeout(() => { + if (cancelled) { + return; + } + const currentAttempts = detailRefreshAttemptsRef.current.get(routeThreadKey) ?? 0; + if (currentAttempts !== attempts) { + return; + } + if (attempts >= THREAD_DETAIL_STALL_RETRY_DELAYS_MS.length) { + setStalledDetailKey(routeThreadKey); + return; + } + detailRefreshAttemptsRef.current.set(routeThreadKey, attempts + 1); + refreshSelectedThreadDetailRef.current(); + scheduleRetry(); + }, delayMs); + }; + + scheduleRetry(); + + return () => { + cancelled = true; + if (timer !== null) { + clearTimeout(timer); + } + }; + }, [ + routeThreadKey, + routeConnectionState, + selectedThreadKey, + selectedThreadDetail, + selectedThreadDetailState.status, + stalledDetailKey, + ]); if (environmentId === null || threadIdRaw === null) { return ; @@ -155,7 +246,13 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { // loading placeholder while messages fetch, and the composer's connection // pill reports connecting/reconnecting/syncing status. if (selectedThread !== null && selectedThreadKey === routeThreadKey) { - return ; + return ( + + ); } const stillHydrating = @@ -172,7 +269,8 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { function ThreadRouteContent( props: ThreadRouteScreenProps & { - readonly selectedThreadDetailState: ReturnType; + readonly detailLoadError: string | null; + readonly selectedThreadDetailState: ReturnType["state"]; }, ) { const { @@ -278,7 +376,6 @@ function ThreadRouteContent( ); /* ─── Native header theming ──────────────────────────────────────── */ - const foregroundColor = String(useThemeColor("--color-foreground")); const usesNativeHeaderGlass = Platform.OS === "ios"; const headerSubtitle = [ selectedThreadProject?.title ?? null, @@ -326,11 +423,28 @@ function ThreadRouteContent( const gitActionProgress = useGitActionProgress(gitActionProgressTarget); const handleOpenGitInspector = useCallback(() => { + if (!fileInspector.supported) { + if (selectedThread === null) { + return; + } + navigation.navigate("GitOverview", { + environmentId: String(selectedThread.environmentId), + threadId: String(selectedThread.id), + }); + return; + } setInspectorSelection({ routeThreadIdentity, mode: "git" }); showAuxiliaryPane("inspector"); - }, [routeThreadIdentity, showAuxiliaryPane]); + }, [fileInspector.supported, navigation, routeThreadIdentity, selectedThread, showAuxiliaryPane]); const handleOpenFilesInspector = useCallback(() => { - if (!fileInspector.supported || selectedThread === null || selectedThreadCwd === null) { + if (selectedThread === null || selectedThreadCwd === null) { + return; + } + if (!fileInspector.supported) { + navigation.navigate("ThreadFiles", { + environmentId: String(selectedThread.environmentId), + threadId: String(selectedThread.id), + }); return; } setInspectorSelection({ @@ -340,6 +454,7 @@ function ThreadRouteContent( showAuxiliaryPane("inspector"); }, [ fileInspector.supported, + navigation, props.renderInspector, routeThreadIdentity, selectedThread, @@ -447,6 +562,11 @@ function ThreadRouteContent( if (!selectedThread || !runtime || !threadRuntimeIsActive(runtime)) { return; } + addBreadcrumb("thread.stop", { + environmentId: selectedThread.environmentId, + threadId: selectedThread.id, + runId: runtime.activeRunId ?? null, + }); return interruptThreadTurn({ environmentId: selectedThread.environmentId, input: { @@ -637,6 +757,55 @@ function ThreadRouteContent( ], [panes.primarySidebarVisible, props.onReturnToThread, navigation, togglePrimarySidebar], ); + const androidHeaderActions = useMemo>(() => { + if (Platform.OS !== "android") return []; + + const actions: AndroidHeaderAction[] = []; + if (props.onReturnToThread) { + actions.push({ + accessibilityLabel: "Return to chat", + icon: "chevron.left", + onPress: props.onReturnToThread, + }); + } + if (selectedThreadCwd !== null) { + actions.push({ + accessibilityLabel: "Open files", + icon: "folder", + onPress: handleOpenFilesInspector, + }); + } + if (selectedThreadProject?.workspaceRoot) { + actions.push({ + accessibilityLabel: "Open terminal", + icon: "terminal", + onPress: () => handleOpenTerminal(null), + }); + } + actions.push({ + accessibilityLabel: "Open git controls", + icon: "point.topleft.down.curvedto.point.bottomright.up", + onPress: handleOpenGitInspector, + }); + if (fileInspector.supported && selectedThreadCwd !== null) { + actions.push({ + accessibilityLabel: "Toggle inspector", + icon: "sidebar.right", + onPress: handleToggleInspector, + }); + } + return actions; + }, [ + fileInspector.supported, + handleOpenFilesInspector, + handleOpenTerminal, + handleOpenGitInspector, + handleToggleInspector, + props.onReturnToThread, + selectedThreadCwd, + selectedThreadProject?.workspaceRoot, + ]); + // Deep links / cold starts land with Thread as the ONLY route, where the // native back button does not render. Provide an explicit Home escape for // that case; when history exists the native back button is used instead. @@ -654,6 +823,56 @@ function ThreadRouteContent( [navigation], ); + // Keep hooks above early returns so hook order stays stable if route params + // or selectedThread go null on a re-render of this mounted instance. + const selectedThreadTitle = selectedThread?.title ?? ""; + const threadScreenOptions = useMemo( + () => ({ + // Android draws its own in-flow header (AndroidScreenHeader below); + // the native stack header stays iOS-only. + headerShown: Platform.OS !== "android", + headerTitle: selectedThreadTitle, + headerTitleStyle: usesNativeHeaderGlass + ? ({ + fontSize: 17, + fontWeight: "800" as const, + } as const) + : undefined, + title: selectedThreadTitle, + headerBackVisible: !layout.usesSplitView, + // Compact uses the NATIVE back button when a previous route exists; + // deep links / cold starts get an explicit Home button instead. + // Split view always uses its custom left items. + unstable_headerLeftItems: + Platform.OS === "ios" + ? layout.usesSplitView + ? () => splitLeftHeaderItems + : canGoBack + ? undefined + : () => compactHomeHeaderItems + : undefined, + // Search lives in the persistent sidebar, so the split header keeps + // the git controls on the RIGHT (no center items — center space is + // reserved for future breadcrumbs/status). + unstable_headerRightItems: + Platform.OS === "ios" + ? () => (layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems) + : undefined, + unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined, + }), + [ + canGoBack, + compactHomeHeaderItems, + compactRightHeaderItems, + headerSubtitle, + layout.usesSplitView, + selectedThreadTitle, + splitLeftHeaderItems, + threadCenterHeaderItems, + usesNativeHeaderGlass, + ], + ); + if (!environmentId || !threadId) { return ; } @@ -662,10 +881,9 @@ function ThreadRouteContent( return ; } - const selectedThreadKey = scopedThreadKey(selectedThread.environmentId, selectedThread.id); const contentPresentation = projectThreadContentPresentation({ hasDetail: selectedThreadDetail !== null, - detailError: Option.getOrNull(selectedThreadDetailState.error), + detailError: Option.getOrNull(selectedThreadDetailState.error) ?? props.detailLoadError, detailDeleted: selectedThreadDetailState.status === "deleted", connectionState: routeConnectionState, }); @@ -726,40 +944,22 @@ function ThreadRouteContent( return ( <> {activeInspectorRenderer ? : null} - splitLeftHeaderItems - : canGoBack - ? undefined - : () => compactHomeHeaderItems - : undefined, - // Search lives in the persistent sidebar, so the split header keeps - // the git controls on the RIGHT (no center items — center space is - // reserved for future breadcrumbs/status). - unstable_headerRightItems: - Platform.OS === "ios" - ? () => (layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems) - : undefined, - unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined, - }} - /> + + + {Platform.OS === "android" ? ( + navigation.goBack()} + actions={androidHeaderActions} + /> + ) : null} - {renderThreadRouteBody(!layout.usesSplitView && !usesNativeHeaderGlass)} + {/* Android surfaces the git/files/inspector actions in its in-flow + header above, so the fallback action toolbar stays iOS-only. */} + {renderThreadRouteBody( + Platform.OS !== "android" && !layout.usesSplitView && !usesNativeHeaderGlass, + )} ); } diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index 2acf641753d..c66fc788762 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -1,11 +1,12 @@ import { sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useState } from "react"; -import { Pressable, ScrollView, View } from "react-native"; +import { Platform, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useThemeColor } from "../../../lib/useThemeColor"; +import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; +import { cn } from "../../../lib/cn"; import { useEnvironmentQuery } from "../../../state/query"; import { useThreadSelection } from "../../../state/use-thread-selection"; import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; @@ -27,12 +28,6 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); - const borderColor = useThemeColor("--color-border"); - const inputBorderColor = useThemeColor("--color-input-border"); - const inputBg = useThemeColor("--color-input"); - const foregroundColor = useThemeColor("--color-foreground"); - const subtleStrongColor = useThemeColor("--color-subtle-strong"); - const gitStatus = useEnvironmentQuery( selectedThread !== null && selectedThreadCwd !== null ? vcsEnvironment.status({ @@ -63,147 +58,125 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { const disabledExistingBranches = new Set(disabledExistingBranchNames); return ( - - - - New branch - - - { - const branch = sanitizeFeatureBranchName(newBranchName.trim()); - if (branch.length === 0) return; - void gitActions.onCreateSelectedThreadBranch(branch).then(() => { - setNewBranchName(""); - navigation.goBack(); - }); - }} - /> - + + {Platform.OS === "android" ? ( + navigation.goBack()} /> + ) : null} + + + + New branch + + + { + const branch = sanitizeFeatureBranchName(newBranchName.trim()); + if (branch.length === 0) return; + void gitActions.onCreateSelectedThreadBranch(branch).then(() => { + setNewBranchName(""); + navigation.goBack(); + }); + }} + /> + - - - New worktree - - - - { - const baseBranch = worktreeBaseBranch.trim(); - const newBranch = worktreeBranchName.trim(); - if (baseBranch.length === 0 || newBranch.length === 0) return; - void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => { - setWorktreeBranchName(""); - navigation.goBack(); - }); - }} - /> - + + + New worktree + + + + { + const baseBranch = worktreeBaseBranch.trim(); + const newBranch = worktreeBranchName.trim(); + if (baseBranch.length === 0 || newBranch.length === 0) return; + void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => { + setWorktreeBranchName(""); + navigation.goBack(); + }); + }} + /> + - - - Existing branches - - {branchesLoading ? ( - Loading branches... - ) : null} - {!branchesLoading && availableBranches.length === 0 ? ( - - No local branches found. + + + Existing branches - ) : null} - {availableBranches.map((branch) => { - const disabled = disabledExistingBranches.has(branch.name); - const subtitle = branch.worktreePath - ? branch.worktreePath === currentWorktreePath - ? "Checked out in this thread" - : "Checked out in another worktree" - : branch.isDefault - ? "Default branch" - : "Local branch"; + {branchesLoading ? ( + + Loading branches... + + ) : null} + {!branchesLoading && availableBranches.length === 0 ? ( + + No local branches found. + + ) : null} + {availableBranches.map((branch) => { + const disabled = disabledExistingBranches.has(branch.name); + const subtitle = branch.worktreePath + ? branch.worktreePath === currentWorktreePath + ? "Checked out in this thread" + : "Checked out in another worktree" + : branch.isDefault + ? "Default branch" + : "Local branch"; - return ( - { - void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { - navigation.goBack(); - }); - }} - > - - {branch.name} - {subtitle} - - ); - })} - - + return ( + { + void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { + navigation.goBack(); + }); + }} + > + + {branch.name} + {subtitle} + + ); + })} + + + ); } diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index bf2d187af0d..cdc7f1a64a9 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -1,10 +1,11 @@ import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useCallback, useState } from "react"; -import { Pressable, ScrollView, View, useColorScheme } from "react-native"; +import { Platform, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useThemeColor } from "../../../lib/useThemeColor"; +import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; +import { cn } from "../../../lib/cn"; import { useEnvironmentQuery } from "../../../state/query"; import { useThreadSelection } from "../../../state/use-thread-selection"; import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; @@ -21,18 +22,11 @@ type GitCommitSheetProps = StaticScreenProps<{ export function GitCommitSheet(_props: GitCommitSheetProps) { const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const isDarkMode = useColorScheme() === "dark"; const { selectedThread } = useThreadSelection(); const { selectedThreadCwd } = useSelectedThreadWorktree(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); - const borderColor = useThemeColor("--color-border"); - const borderSubtleColor = useThemeColor("--color-border-subtle"); - const inputBorderColor = useThemeColor("--color-input-border"); - const inputBg = useThemeColor("--color-input"); - const foregroundColor = useThemeColor("--color-foreground"); - const gitStatus = useEnvironmentQuery( selectedThread !== null && selectedThreadCwd !== null ? vcsEnvironment.status({ @@ -72,180 +66,168 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { ); return ( - - - - Branch - - {gitStatus.data?.refName ?? "(detached HEAD)"} - - - {isDefaultRef ? ( - - Warning: this is the default branch. - - ) : null} - - - - - - Files - - {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions} + + {Platform.OS === "android" ? ( + navigation.goBack()} /> + ) : null} + + + + Branch + + {gitStatus.data?.refName ?? "(detached HEAD)"} - - {!allSelected && isEditingFiles ? ( + {isDefaultRef ? ( + + Warning: this is the default branch. + + ) : null} + + + + + + Files + + {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions} + + + + {!allSelected && isEditingFiles ? ( + setExcludedFiles(new Set())} + > + Reset + + ) : null} setExcludedFiles(new Set())} + onPress={() => setIsEditingFiles((current) => !current)} > - Reset + + {isEditingFiles ? "Done" : "Edit"} + - ) : null} - setIsEditingFiles((current) => !current)} - > - - {isEditingFiles ? "Done" : "Edit"} - - + - - {allFiles.length === 0 ? ( - - No changed files are available to commit. - - ) : !isEditingFiles ? ( - - {selectedFilePreview.map((file) => ( - - - {file.path} - - - +{file.insertions} - - - -{file.deletions} + {allFiles.length === 0 ? ( + + No changed files are available to commit. + + ) : !isEditingFiles ? ( + + {selectedFilePreview.map((file) => ( + + + {file.path} + + +{file.insertions} + -{file.deletions} + + ))} + {selectedFiles.length > selectedFilePreview.length ? ( + + +{selectedFiles.length - selectedFilePreview.length} more files - - ))} - {selectedFiles.length > selectedFilePreview.length ? ( - - +{selectedFiles.length - selectedFilePreview.length} more files - - ) : null} - - ) : ( - - {allFiles.map((file) => { - const included = !excludedFiles.has(file.path); - return ( - { - setExcludedFiles((current) => { - const next = new Set(current); - if (next.has(file.path)) { - next.delete(file.path); - } else { - next.add(file.path); - } - return next; - }); - }} - > - - - - - {file.path} - - {!included ? ( - - Excluded from this commit + ) : null} + + ) : ( + + {allFiles.map((file) => { + const included = !excludedFiles.has(file.path); + return ( + { + setExcludedFiles((current) => { + const next = new Set(current); + if (next.has(file.path)) { + next.delete(file.path); + } else { + next.add(file.path); + } + return next; + }); + }} + > + + + + + {file.path} - ) : null} - - - - +{file.insertions} - - - -{file.deletions} - + {!included ? ( + + Excluded from this commit + + ) : null} + + + + +{file.insertions} + + + -{file.deletions} + + - - - ); - })} - - )} - - - - Commit message - - + + ); + })} + + )} + - - - void runCommitAction(true)} + + Commit message + - - void runCommitAction(false)} - /> + + + + void runCommitAction(true)} + /> + + + void runCommitAction(false)} + /> + - - + + ); } diff --git a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx index b4f78742141..cddf1c614bd 100644 --- a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx @@ -4,9 +4,11 @@ import * as Arr from "effect/Array"; import * as Result from "effect/Result"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useCallback, useMemo } from "react"; -import { View } from "react-native"; +import { Platform, View } from "react-native"; + import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader"; import { AppText as Text } from "../../../components/AppText"; import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; @@ -101,13 +103,14 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { return ( - + {Platform.OS === "android" ? ( + navigation.goBack()} /> + ) : ( + + )} - + Confirm @@ -118,10 +121,7 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { - + {sheetMenuItems.map(({ item, disabledReason }, index) => ( - {index > 0 ? ( - - ) : null} + {index > 0 ? : null} 0 ? ( <> - + ) : null} - + navigation.navigate("ThreadReview", { environmentId, threadId })} + onPress={() => { + const params = { environmentId, threadId }; + navigation.dispatch( + resolveGitOverviewReviewNavigationAction(presentation) === "replace" + ? StackActions.replace("ThreadReview", params) + : CommonActions.navigate("ThreadReview", params), + ); + }} /> - + - + {isInspector ? ( + + ) : null} - - void gitActions.refreshSelectedThreadGitStatus()} - > - - - - {isInspector ? "Repository" : "Branch"} - - - {currentBranchLabel} - - - {currentStatusSummary} - - + {isInspector ? ( + + void gitActions.refreshSelectedThreadGitStatus()} + > + + + + Repository + + {currentBranchLabel} + + {currentStatusSummary} + + + ) : ( + navigation.goBack()} + actions={[ + { + accessibilityLabel: "Refresh repository status", + disabled: busy, + icon: "arrow.clockwise", + onPress: () => void gitActions.refreshSelectedThreadGitStatus(), + }, + ]} + /> + )} {content} diff --git a/apps/mobile/src/features/threads/git/git-overview-navigation.test.ts b/apps/mobile/src/features/threads/git/git-overview-navigation.test.ts new file mode 100644 index 00000000000..b50bc42a9b3 --- /dev/null +++ b/apps/mobile/src/features/threads/git/git-overview-navigation.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveGitOverviewReviewNavigationAction } from "./git-overview-navigation"; + +describe("resolveGitOverviewReviewNavigationAction", () => { + it("replaces the sheet so Back returns directly to the thread", () => { + expect(resolveGitOverviewReviewNavigationAction("sheet")).toBe("replace"); + }); + + it("pushes Review normally when Git is a persistent inspector", () => { + expect(resolveGitOverviewReviewNavigationAction("inspector")).toBe("navigate"); + }); +}); diff --git a/apps/mobile/src/features/threads/git/git-overview-navigation.ts b/apps/mobile/src/features/threads/git/git-overview-navigation.ts new file mode 100644 index 00000000000..53927356c65 --- /dev/null +++ b/apps/mobile/src/features/threads/git/git-overview-navigation.ts @@ -0,0 +1,5 @@ +export function resolveGitOverviewReviewNavigationAction( + presentation: "sheet" | "inspector", +): "replace" | "navigate" { + return presentation === "sheet" ? "replace" : "navigate"; +} diff --git a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx index 8045de78228..61346fcef0f 100644 --- a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx +++ b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx @@ -1,8 +1,9 @@ -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../../components/AppSymbol"; import type { ComponentProps } from "react"; import { Pressable, View } from "react-native"; import { useThemeColor } from "../../../lib/useThemeColor"; import { AppText as Text } from "../../../components/AppText"; +import { cn } from "../../../lib/cn"; /* ─── Shared sheet components ──────────────────────────────────────── */ @@ -13,51 +14,36 @@ export function SheetActionButton(props: { readonly tone?: "primary" | "secondary" | "danger"; readonly onPress: () => void; }) { - const primaryBg = useThemeColor("--color-primary"); const primaryFg = useThemeColor("--color-primary-foreground"); - const dangerBg = useThemeColor("--color-danger"); - const dangerBorder = useThemeColor("--color-danger-border"); const dangerFg = useThemeColor("--color-danger-foreground"); - const secondaryBg = useThemeColor("--color-secondary"); - const secondaryBorder = useThemeColor("--color-secondary-border"); const secondaryFg = useThemeColor("--color-secondary-foreground"); const tone = props.tone ?? "secondary"; - const colors = - tone === "primary" - ? { - backgroundColor: primaryBg, - borderColor: "transparent", - textColor: primaryFg, - } - : tone === "danger" - ? { - backgroundColor: dangerBg, - borderColor: dangerBorder, - textColor: dangerFg, - } - : { - backgroundColor: secondaryBg, - borderColor: secondaryBorder, - textColor: secondaryFg, - }; + const textColor = tone === "primary" ? primaryFg : tone === "danger" ? dangerFg : secondaryFg; return ( - + {props.label} @@ -68,10 +54,7 @@ export function SheetActionButton(props: { export function MetaCard(props: { readonly label: string; readonly value: string }) { return ( - + {props.label} @@ -93,9 +76,8 @@ export function SheetListRow(props: { return ( diff --git a/apps/mobile/src/features/threads/markdownCodeHighlightState.ts b/apps/mobile/src/features/threads/markdownCodeHighlightState.ts new file mode 100644 index 00000000000..c415cf73980 --- /dev/null +++ b/apps/mobile/src/features/threads/markdownCodeHighlightState.ts @@ -0,0 +1,87 @@ +import { useAtomValue } from "@effect/atom-react"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useMemo } from "react"; + +import { + highlightCodeSnippet, + type ReviewDiffTheme, + type ReviewHighlightedToken, +} from "../review/shikiReviewHighlighter"; + +const MARKDOWN_CODE_HIGHLIGHT_IDLE_TTL_MS = 5 * 60_000; + +export type MarkdownHighlightedCode = ReadonlyArray>; + +export interface MarkdownCodeHighlightInput { + readonly code: string; + readonly enabled: boolean; + readonly language: string; + readonly theme: ReviewDiffTheme; +} + +type MarkdownCodeHighlighter = ( + input: MarkdownCodeHighlightInput, +) => Promise; + +class MarkdownCodeHighlightCacheKey extends Data.Class {} + +class MarkdownCodeHighlightError extends Data.TaggedError("MarkdownCodeHighlightError")<{ + readonly cause: unknown; +}> {} + +export function createMarkdownCodeHighlightAtomFamily(options?: { + readonly highlight?: MarkdownCodeHighlighter; + readonly idleTtlMs?: number; +}) { + const highlight = + options?.highlight ?? + ((input: MarkdownCodeHighlightInput) => + input.enabled + ? highlightCodeSnippet({ + code: input.code, + language: input.language, + theme: input.theme, + }) + : Promise.resolve(null)); + const idleTtlMs = options?.idleTtlMs ?? MARKDOWN_CODE_HIGHLIGHT_IDLE_TTL_MS; + const family = Atom.family((request: MarkdownCodeHighlightCacheKey) => + Atom.make( + Effect.tryPromise({ + try: () => highlight(request), + catch: (cause) => new MarkdownCodeHighlightError({ cause }), + }), + ).pipe( + Atom.setIdleTTL(idleTtlMs), + Atom.withLabel(`mobile:thread-markdown-code-highlight:${request.theme}:${request.language}`), + ), + ); + + return (input: MarkdownCodeHighlightInput) => family(new MarkdownCodeHighlightCacheKey(input)); +} + +export const markdownCodeHighlightAtom = createMarkdownCodeHighlightAtomFamily(); + +export function useMarkdownCodeHighlight(input: { + readonly code: string; + readonly enabled: boolean; + readonly language: string | null | undefined; + readonly theme: ReviewDiffTheme; +}): MarkdownHighlightedCode | null { + const normalizedLanguage = input.language?.trim() || "text"; + const enabled = input.enabled && Boolean(input.language?.trim()); + const atomLanguage = enabled ? normalizedLanguage : "text"; + const highlightAtom = useMemo( + () => + markdownCodeHighlightAtom({ + code: enabled ? input.code : "", + enabled, + language: atomLanguage, + theme: input.theme, + }), + [atomLanguage, enabled, input.code, input.theme], + ); + const result = useAtomValue(highlightAtom); + return AsyncResult.isSuccess(result) ? result.value : null; +} diff --git a/apps/mobile/src/features/threads/sidebar-filter-button.tsx b/apps/mobile/src/features/threads/sidebar-filter-button.tsx index 642a4a957bb..b1afe594f96 100644 --- a/apps/mobile/src/features/threads/sidebar-filter-button.tsx +++ b/apps/mobile/src/features/threads/sidebar-filter-button.tsx @@ -1,4 +1,4 @@ -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../components/AppSymbol"; import { Pressable, StyleSheet, useColorScheme } from "react-native"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -22,16 +22,17 @@ export function SidebarFilterButton(props: { return ( [ - styles.button, props.grouped ? { backgroundColor: pressed ? pressedBackgroundColor : "transparent", borderWidth: 0 } : { backgroundColor: pressed ? pressedBackgroundColor : idleBackgroundColor, borderColor, + borderWidth: StyleSheet.hairlineWidth, }, ]} > @@ -39,17 +40,3 @@ export function SidebarFilterButton(props: { ); } - -const styles = StyleSheet.create({ - button: { - // Match the native glass UIBarButtonItem group metrics (~50pt slots, - // 44pt bar height, label-colored ~20pt glyphs). - width: 50, - height: 44, - borderRadius: 22, - borderWidth: StyleSheet.hairlineWidth, - alignItems: "center", - justifyContent: "center", - cursor: "pointer", - }, -}); diff --git a/apps/mobile/src/features/threads/sidebar-header-actions.android.tsx b/apps/mobile/src/features/threads/sidebar-header-actions.android.tsx new file mode 100644 index 00000000000..1321c82c0d8 --- /dev/null +++ b/apps/mobile/src/features/threads/sidebar-header-actions.android.tsx @@ -0,0 +1,16 @@ +import { View } from "react-native"; + +import { T3HeaderButton } from "../../native/T3HeaderButton.android"; +import type { SidebarHeaderActionsProps } from "./sidebar-header-actions"; + +export function SidebarHeaderActions(props: SidebarHeaderActionsProps) { + return ( + + + + ); +} diff --git a/apps/mobile/src/features/threads/sidebar-header-actions.tsx b/apps/mobile/src/features/threads/sidebar-header-actions.tsx index e193f1f2d9b..b8c8525b0a3 100644 --- a/apps/mobile/src/features/threads/sidebar-header-actions.tsx +++ b/apps/mobile/src/features/threads/sidebar-header-actions.tsx @@ -1,4 +1,4 @@ -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../components/AppSymbol"; import { Pressable, StyleSheet, View, useColorScheme } from "react-native"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -24,17 +24,18 @@ function FallbackHeaderButton(props: { return ( [ - styles.button, props.grouped ? { backgroundColor: pressed ? pressedBackgroundColor : "transparent", borderWidth: 0 } : { backgroundColor: pressed ? pressedBackgroundColor : idleBackgroundColor, borderColor, + borderWidth: StyleSheet.hairlineWidth, }, ]} > @@ -45,7 +46,7 @@ function FallbackHeaderButton(props: { export function SidebarHeaderActions(props: SidebarHeaderActionsProps) { return ( - + ); } - -const styles = StyleSheet.create({ - actions: { - flexDirection: "row", - alignItems: "center", - gap: 2, - }, - button: { - // Match the native glass UIBarButtonItem group metrics. - width: 50, - height: 44, - borderRadius: 22, - borderWidth: StyleSheet.hairlineWidth, - alignItems: "center", - justifyContent: "center", - }, -}); diff --git a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx index f1e06926d0b..a94c033fa4d 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 { renderCompactBrandTitle } from "../../components/CompactBrandTitle"; import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; const SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -34,10 +35,11 @@ const SIDEBAR_SCREEN_OPTIONS: SidebarScreenOptions = { headerShadowVisible: false, headerShown: true, headerStyle: { backgroundColor: "transparent" }, + headerTitle: renderCompactBrandTitle, headerTitleStyle: { fontSize: 18, fontWeight: "800" }, headerTransparent: true, scrollEdgeEffects: SCROLL_EDGE_EFFECTS, - title: "Threads", + title: "T3 Code", unstable_navigationItemStyle: "editor", }; diff --git a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx index 2c8ec73342d..9b41ed4e362 100644 --- a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx +++ b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx @@ -75,7 +75,7 @@ export function ThreadInspectorContentStack(props: { const Route = props.Route; return ( - + , +) { + const dark = colorScheme === "dark"; + switch (state) { + case "open": + return dark ? "#34d399" : "#059669"; + case "merged": + return dark ? "#a78bfa" : "#7c3aed"; + case "closed": + return dark ? "#a1a1aa" : "#71717a"; + } +} + +function PullRequestIcon(props: { readonly size: number; readonly color: string }) { + return ( + + + + + + + ); +} + /* ─── Project group header ───────────────────────────────────────────── */ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: { @@ -94,6 +131,7 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: > {props.title} @@ -236,7 +273,6 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { const compact = props.variant === "compact"; const separatorColor = useThemeColor("--color-separator"); const iconSubtleColor = useThemeColor("--color-icon-subtle"); - const foregroundColor = useThemeColor("--color-foreground"); const mutedColor = useThemeColor("--color-foreground-muted"); const pressedBackgroundColor = useThemeColor("--color-subtle"); @@ -254,17 +290,14 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { ); const statusPill = ( - + Pending ); const subtitleRow = subtitleParts.length > 0 ? ( - + {subtitleParts.join(" · ")} @@ -311,12 +347,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { {statusPill} - - {timestamp} - + {timestamp} - + - + {pendingTask.title} {statusPill} - + {timestamp} @@ -408,6 +431,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { >["simultaneousWithExternalGesture"]; }) { const { width: windowWidth } = useWindowDimensions(); + const colorScheme = useColorScheme(); const compact = props.variant === "compact"; const selected = props.selected === true; // Recycling-safe: resets when the list container is reused for another @@ -418,12 +442,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const iconSubtleColor = useThemeColor("--color-icon-subtle"); const screenColor = useThemeColor("--color-screen"); const drawerColor = useThemeColor("--color-drawer"); - const foregroundColor = useThemeColor("--color-foreground"); - const mutedColor = useThemeColor("--color-foreground-muted"); const pressedBackgroundColor = useThemeColor("--color-subtle"); const selectedBackgroundColor = useThemeColor("--color-user-bubble"); - const selectedForegroundColor = useThemeColor("--color-user-bubble-foreground"); - const selectedMutedColor = useThemeColor("--color-user-bubble-foreground-muted"); const { thread, onSelectThread, onArchiveThread, onDeleteThread } = props; const status = resolveThreadStatus(thread); @@ -431,13 +451,12 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const timestamp = relativeTime( thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, ); + const threadAccessibilityLabel = pr ? `${thread.title}, ${pr.accessibilityLabel}` : thread.title; const subtitleParts = [props.environmentLabel, thread.branch].filter((part): part is string => Boolean(part), ); const backgroundColor = compact ? screenColor : drawerColor; - const effectiveForeground = selected ? selectedForegroundColor : foregroundColor; - const effectiveMuted = selected ? selectedMutedColor : mutedColor; const effectivePressedBackground = selected ? "rgba(255,255,255,0.16)" : pressedBackgroundColor; const effectiveStatus = selected && status @@ -464,10 +483,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ); const statusPill = effectiveStatus ? ( - + {effectiveStatus.label} @@ -476,32 +492,36 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const subtitleRow = subtitleParts.length > 0 || pr !== null ? ( - + {subtitleParts.length > 0 ? ( <> - {subtitleParts.join(" · ")} ) : null} {pr !== null ? ( - - {pr.label} - + + + + {pr.label} + + ) : null} ) : null; @@ -510,7 +530,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { compact ? ( { @@ -540,12 +560,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { {statusPill} - - {timestamp} - + {timestamp} setHovered(true)} @@ -584,21 +599,25 @@ export const ThreadListRow = memo(function ThreadListRow(props: { paddingVertical: 10, })} > - + {thread.title} {statusPill} {timestamp} @@ -626,12 +645,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: { threadTitle={thread.title} > {(close) => ( - // Messages-style row actions: a real UIContextMenuInteraction on - // long-press / pointer right-click, with the row as the zoom preview. - // Requires the patched @react-native-menu (see - // patches/@react-native-menu__menu@2.0.0.patch): in long-press mode - // the interaction is hosted by the component view and the underlying - // UIButton passes touches through, so row taps keep working. + // Messages-style row actions on long-press. iOS: a real + // UIContextMenuInteraction with the row as the zoom preview (needs the + // patched @react-native-menu, see + // patches/@react-native-menu__menu@2.0.0.patch — in long-press mode the + // interaction is hosted by the component view and the underlying + // UIButton passes touches through, so row taps keep working). Android: + // ControlPillMenu injects onLongPress into the row and anchors the + // token-styled dropdown to it; taps and swipes are untouched. 0 ? cleaned : null; } -function workRowSymbolName(icon: ThreadFeedActivity["icon"]): SFSymbol { +function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName { switch (icon) { case "agent": - return "sparkles"; + return { ios: "sparkles", android: "auto_awesome" }; case "alert": - return "exclamationmark.triangle"; + return { ios: "exclamationmark.triangle", android: "error" }; case "check": - return "checkmark"; + return { ios: "checkmark", android: "check" }; case "command": - return "terminal"; + return { ios: "terminal", android: "terminal" }; case "edit": - return "square.and.pencil"; + return { ios: "square.and.pencil", android: "edit" }; case "eye": - return "eye"; + return { ios: "eye", android: "visibility" }; case "globe": - return "globe"; + return { ios: "globe", android: "public" }; case "hammer": - return "hammer"; + return { ios: "hammer", android: "construction" }; case "message": - return "bubble.left"; + return { ios: "bubble.left", android: "chat_bubble" }; case "warning": - return "xmark"; + return { ios: "xmark", android: "close" }; case "wrench": - return "wrench"; + return { ios: "wrench", android: "build" }; case "zap": - return "bolt"; + return { ios: "bolt", android: "bolt" }; } } @@ -240,7 +240,11 @@ export function ThreadWorkLog(props: { {canExpand ? ( (); diff --git a/apps/mobile/src/lib/base64.ts b/apps/mobile/src/lib/base64.ts new file mode 100644 index 00000000000..640e2260be4 --- /dev/null +++ b/apps/mobile/src/lib/base64.ts @@ -0,0 +1,4 @@ +export function estimateBase64ByteSize(base64: string): number { + const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; + return Math.floor((base64.length * 3) / 4) - padding; +} diff --git a/apps/mobile/src/lib/breadcrumbPersist.ts b/apps/mobile/src/lib/breadcrumbPersist.ts new file mode 100644 index 00000000000..2c6a63c48c9 --- /dev/null +++ b/apps/mobile/src/lib/breadcrumbPersist.ts @@ -0,0 +1,102 @@ +import { Directory, File, Paths } from "expo-file-system"; +import { requireNativeModule } from "expo"; + +import { getBreadcrumbs, setBreadcrumbPersistHook } from "./breadcrumbs"; + +const CRASH_LOG_DIRECTORY = "crash-logs"; +const LAST_BREADCRUMBS_FILE = "last-breadcrumbs.json"; +/** Quiet period before a trailing flush. */ +const FLUSH_DEBOUNCE_MS = 400; +/** While activity continues, still flush at least this often. */ +const FLUSH_MAX_WAIT_MS = 400; + +let debounceTimer: ReturnType | null = null; +let maxWaitTimer: ReturnType | null = null; +let installed = false; + +export function installBreadcrumbPersistence(): void { + if (installed) { + return; + } + installed = true; + setBreadcrumbPersistHook(() => { + scheduleBreadcrumbFlush(); + }); +} + +/** Immediate flush for fatal paths; ignore errors. */ +export function flushBreadcrumbsSync(): void { + clearFlushTimers(); + writeBreadcrumbsToDisk(); +} + +function clearFlushTimers(): void { + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + if (maxWaitTimer !== null) { + clearTimeout(maxWaitTimer); + maxWaitTimer = null; + } +} + +function scheduleBreadcrumbFlush(): void { + // Trailing debounce: reset on each breadcrumb. + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + } + debounceTimer = setTimeout(() => { + debounceTimer = null; + // Quiet period elapsed; drop max-wait so the next burst starts a new cap. + if (maxWaitTimer !== null) { + clearTimeout(maxWaitTimer); + maxWaitTimer = null; + } + writeBreadcrumbsToDisk(); + }, FLUSH_DEBOUNCE_MS); + + // Max-wait: do not reset while activity continues, so continuous bursts still + // flush about every FLUSH_MAX_WAIT_MS (needed when a native kill skips sync flush). + if (maxWaitTimer === null) { + maxWaitTimer = setTimeout(() => { + maxWaitTimer = null; + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + writeBreadcrumbsToDisk(); + }, FLUSH_MAX_WAIT_MS); + } +} + +function writeBreadcrumbsToDisk(): void { + const payload = JSON.stringify({ + breadcrumbs: getBreadcrumbs(), + updatedAt: new Date().toISOString(), + }); + const relativePath = `${CRASH_LOG_DIRECTORY}/${LAST_BREADCRUMBS_FILE}`; + + try { + const native = requireNativeModule("T3NativeControls") as { + writeSyncText?: (relativePath: string, contents: string) => boolean; + }; + if (typeof native.writeSyncText === "function") { + native.writeSyncText(relativePath, payload); + } + } catch { + // fall through to Expo FS + } + + try { + const directory = new Directory(Paths.document, CRASH_LOG_DIRECTORY); + directory.create({ idempotent: true, intermediates: true }); + const file = new File(directory, LAST_BREADCRUMBS_FILE); + if (!file.exists) { + file.create({ intermediates: true, overwrite: true }); + } + file.write(payload); + } catch { + // Best-effort only. + } +} diff --git a/apps/mobile/src/lib/breadcrumbs.test.ts b/apps/mobile/src/lib/breadcrumbs.test.ts new file mode 100644 index 00000000000..7a0f9894391 --- /dev/null +++ b/apps/mobile/src/lib/breadcrumbs.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { addBreadcrumb, breadcrumbCount, clearBreadcrumbs, getBreadcrumbs } from "./breadcrumbs"; + +describe("breadcrumbs", () => { + it("records entries in order and exposes a copy", () => { + clearBreadcrumbs(); + addBreadcrumb("nav", { path: "Home" }); + addBreadcrumb("outbox.dispatch", { messageId: "m1" }); + + expect(getBreadcrumbs()).toEqual([ + expect.objectContaining({ type: "nav", data: { path: "Home" } }), + expect.objectContaining({ type: "outbox.dispatch", data: { messageId: "m1" } }), + ]); + expect(breadcrumbCount()).toBe(2); + }); + + it("caps ring size and drops the oldest entries", () => { + clearBreadcrumbs(); + for (let index = 0; index < 100; index += 1) { + addBreadcrumb("tick", { index }); + } + + expect(breadcrumbCount()).toBe(80); + expect(getBreadcrumbs()[0]?.data?.index).toBe(20); + expect(getBreadcrumbs().at(-1)?.data?.index).toBe(99); + }); + + it("truncates long string values", () => { + clearBreadcrumbs(); + addBreadcrumb("msg", { text: "x".repeat(250) }); + + const text = getBreadcrumbs()[0]?.data?.text; + expect(typeof text).toBe("string"); + expect(String(text).length).toBeLessThanOrEqual(201); + expect(String(text).endsWith("…")).toBe(true); + }); +}); diff --git a/apps/mobile/src/lib/breadcrumbs.ts b/apps/mobile/src/lib/breadcrumbs.ts new file mode 100644 index 00000000000..7dd1c2b9cba --- /dev/null +++ b/apps/mobile/src/lib/breadcrumbs.ts @@ -0,0 +1,55 @@ +/** Compact trail kept in memory and flushed with fatal/error crash records. */ + +export type BreadcrumbData = Readonly>; + +export type Breadcrumb = { + readonly t: string; + readonly type: string; + readonly data?: BreadcrumbData; +}; + +const MAX_BREADCRUMBS = 80; + +const ring: Breadcrumb[] = []; +let onBreadcrumbAdded: (() => void) | null = null; + +/** Optional disk flusher installed by crashLog / breadcrumbPersist. */ +export function setBreadcrumbPersistHook(hook: (() => void) | null): void { + onBreadcrumbAdded = hook; +} + +export function addBreadcrumb(type: string, data?: BreadcrumbData): void { + const entry: Breadcrumb = + data === undefined + ? { t: new Date().toISOString(), type } + : { t: new Date().toISOString(), type, data: sanitizeBreadcrumbData(data) }; + ring.push(entry); + if (ring.length > MAX_BREADCRUMBS) { + ring.splice(0, ring.length - MAX_BREADCRUMBS); + } + onBreadcrumbAdded?.(); +} + +export function getBreadcrumbs(): ReadonlyArray { + return ring.slice(); +} + +export function clearBreadcrumbs(): void { + ring.length = 0; +} + +export function breadcrumbCount(): number { + return ring.length; +} + +function sanitizeBreadcrumbData(data: BreadcrumbData): BreadcrumbData { + const out: Record = {}; + for (const [key, value] of Object.entries(data)) { + if (typeof value === "string") { + out[key] = value.length > 200 ? `${value.slice(0, 200)}…` : value; + } else { + out[key] = value; + } + } + return out; +} diff --git a/apps/mobile/src/lib/composerImages.test.ts b/apps/mobile/src/lib/composerImages.test.ts index 40e00a271f7..21f2edaf52f 100644 --- a/apps/mobile/src/lib/composerImages.test.ts +++ b/apps/mobile/src/lib/composerImages.test.ts @@ -36,7 +36,37 @@ vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id", })); -import { convertPastedImagesToAttachments, isOwnedPastedImageUri } from "./composerImages"; +import { + convertPastedImagesToAttachments, + isOwnedPastedImageUri, + toUploadChatImageAttachments, +} from "./composerImages"; + +describe("toUploadChatImageAttachments", () => { + it("strips client draft id and previewUri for the startTurn wire shape", () => { + expect( + toUploadChatImageAttachments([ + { + id: "client-draft-id", + type: "image", + name: "pasted-image.png", + mimeType: "image/png", + sizeBytes: 12, + dataUrl: "data:image/png;base64,AA==", + previewUri: "file:///tmp/preview.png", + }, + ]), + ).toEqual([ + { + type: "image", + name: "pasted-image.png", + mimeType: "image/png", + sizeBytes: 12, + dataUrl: "data:image/png;base64,AA==", + }, + ]); + }); +}); describe("native pasted image cleanup", () => { beforeEach(() => { diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index 13b53af724e..5c79b5b5eb8 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -3,6 +3,7 @@ import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type UploadChatImageAttachment, } from "@t3tools/contracts"; +import { estimateBase64ByteSize } from "./base64"; import { uuidv4 } from "./uuid"; export interface DraftComposerImageAttachment extends UploadChatImageAttachment { @@ -10,13 +11,21 @@ export interface DraftComposerImageAttachment extends UploadChatImageAttachment readonly previewUri: string; } -const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste"; - -function estimateBase64ByteSize(base64: string): number { - const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; - return Math.floor((base64.length * 3) / 4) - padding; +/** Wire shape for startTurn: pure uploads without client draft id / previewUri. */ +export function toUploadChatImageAttachments( + attachments: ReadonlyArray, +): ReadonlyArray { + return attachments.map((attachment) => ({ + type: attachment.type, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + dataUrl: attachment.dataUrl, + })); } +const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste"; + async function loadImagePicker() { try { return await import("expo-image-picker"); diff --git a/apps/mobile/src/lib/crashLog.test.ts b/apps/mobile/src/lib/crashLog.test.ts new file mode 100644 index 00000000000..219815d8655 --- /dev/null +++ b/apps/mobile/src/lib/crashLog.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { addBreadcrumb, clearBreadcrumbs } from "./breadcrumbs"; +import { buildCrashRecord, buildMinimalCrashRecord, shouldPersistNonFatal } from "./crashLogRecord"; + +describe("crashLog records", () => { + it("captures message, stack, and breadcrumbs for fatals", () => { + clearBreadcrumbs(); + addBreadcrumb("nav", { path: "Thread" }); + addBreadcrumb("outbox.dispatch", { messageId: "m1" }); + + const error = new Error("boom"); + const record = buildCrashRecord(error, true, 7); + + expect(record.isFatal).toBe(true); + expect(record.message).toBe("boom"); + expect(record.name).toBe("Error"); + expect(record.handlerInvocation).toBe(7); + expect(record.source).toBe("error-utils"); + expect(record.stack).toContain("boom"); + expect(record.breadcrumbs.map((entry) => entry.type)).toEqual(["nav", "outbox.dispatch"]); + }); + + it("builds a minimal record without breadcrumbs for the first write", () => { + clearBreadcrumbs(); + addBreadcrumb("nav", { path: "Thread" }); + + const record = buildMinimalCrashRecord(new Error("early"), true, 1); + expect(record.breadcrumbs).toEqual([]); + expect(record.message).toBe("early"); + expect(record.source).toBe("error-utils"); + expect(record.isFatal).toBe(true); + }); + + it("stringifies non-Error values", () => { + clearBreadcrumbs(); + const record = buildCrashRecord("string-throw", false, 1); + expect(record.message).toBe("string-throw"); + expect(record.name).toBeNull(); + expect(record.stack).toBeNull(); + expect(record.isFatal).toBe(false); + }); + + it("truncates huge messages and stacks", () => { + const huge = "x".repeat(20_000); + const error = new Error(huge); + error.stack = "y".repeat(60_000); + const record = buildMinimalCrashRecord(error, true, 1); + expect(record.message.length).toBeLessThanOrEqual(8_001); + expect(record.stack?.length).toBeLessThanOrEqual(48_001); + }); + + it("only persists non-fatals that look like real Errors with stacks", () => { + expect(shouldPersistNonFatal(new Error("x"))).toBe(true); + expect(shouldPersistNonFatal("x")).toBe(false); + expect(shouldPersistNonFatal({ message: "x" })).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/crashLog.ts b/apps/mobile/src/lib/crashLog.ts new file mode 100644 index 00000000000..755ba1f723c --- /dev/null +++ b/apps/mobile/src/lib/crashLog.ts @@ -0,0 +1,91 @@ +import { Directory, File, Paths } from "expo-file-system"; +import type { ErrorUtils as ErrorUtilsInterface } from "react-native"; + +const CRASH_LOG_DIRECTORY = "crash-logs"; +const MAX_PERSISTED_CRASH_LOGS = 20; +const REPORTED_ON_LAUNCH_COUNT = 3; + +let crashSequence = 0; + +function safeErrorMessage(error: unknown): string { + try { + return String(error); + } catch { + return "[Uncoercible error]"; + } +} + +export function installCrashLogger(): void { + const errorUtils = (globalThis as { ErrorUtils?: ErrorUtilsInterface }).ErrorUtils; + if (errorUtils === undefined) { + return; + } + const previousHandler = errorUtils.getGlobalHandler(); + errorUtils.setGlobalHandler((error: unknown, isFatal?: boolean) => { + if (isFatal === true) { + try { + persistCrashRecord(error); + } catch { + // Never let the logger mask the original error. + } + } + previousHandler(error, isFatal); + }); + // Deferred so install (which runs before the app module graph) does no + // file IO on the startup path. + setTimeout(() => { + reportAndPrunePreviousCrashes(); + }, 0); +} + +function persistCrashRecord(error: unknown): void { + const directory = new Directory(Paths.document, CRASH_LOG_DIRECTORY); + directory.create({ idempotent: true, intermediates: true }); + const cause = error instanceof Error ? error : null; + const record = { + capturedAt: new Date().toISOString(), + isFatal: true, + message: cause?.message ?? safeErrorMessage(error), + name: cause?.name ?? null, + stack: cause?.stack ?? null, + }; + crashSequence += 1; + // Keep lexical directory ordering aligned with capture order for records + // written within the same millisecond. + const sequence = String(crashSequence).padStart(6, "0"); + const file = new File(directory, `crash-${Date.now()}-${sequence}.json`); + file.create({ intermediates: true, overwrite: true }); + file.write(JSON.stringify(record, null, 2)); +} + +function reportAndPrunePreviousCrashes(): void { + try { + const directory = new Directory(Paths.document, CRASH_LOG_DIRECTORY); + if (!directory.exists) { + return; + } + const files = directory + .list() + .filter( + (entry): entry is File => + entry instanceof File && entry.name.startsWith("crash-") && entry.name.endsWith(".json"), + ) + .sort((a, b) => a.name.localeCompare(b.name)); + for (const file of files.slice(-REPORTED_ON_LAUNCH_COUNT)) { + try { + console.warn(`[crash-log] previous fatal JS error (${file.name}):`, file.textSync()); + } catch { + // Skip unreadable records. + } + } + for (const file of files.slice(0, Math.max(0, files.length - MAX_PERSISTED_CRASH_LOGS))) { + try { + file.delete(); + } catch { + // Leave undeletable records for the next prune. + } + } + } catch { + // Reporting past crashes must never affect startup. + } +} diff --git a/apps/mobile/src/lib/crashLogRecord.ts b/apps/mobile/src/lib/crashLogRecord.ts new file mode 100644 index 00000000000..b4ca72f200d --- /dev/null +++ b/apps/mobile/src/lib/crashLogRecord.ts @@ -0,0 +1,97 @@ +import { getBreadcrumbs, type Breadcrumb } from "./breadcrumbs"; + +export type CrashLogRecord = { + readonly breadcrumbs: ReadonlyArray; + readonly capturedAt: string; + readonly handlerInvocation: number; + readonly isFatal: boolean; + readonly message: string; + readonly name: string | null; + readonly source?: string; + readonly stack: string | null; +}; + +const MAX_MESSAGE_CHARS = 8_000; +const MAX_STACK_CHARS = 48_000; + +export function buildMinimalCrashRecord( + error: unknown, + isFatal: boolean, + handlerInvocation: number, +): CrashLogRecord { + return { + breadcrumbs: [], + capturedAt: new Date().toISOString(), + handlerInvocation, + isFatal, + message: truncate(safeMessage(error), MAX_MESSAGE_CHARS), + name: safeName(error), + source: "error-utils", + stack: truncateNullable(safeStack(error), MAX_STACK_CHARS), + }; +} + +export function buildCrashRecord( + error: unknown, + isFatal: boolean, + handlerInvocation: number, +): CrashLogRecord { + return { + breadcrumbs: getBreadcrumbs(), + capturedAt: new Date().toISOString(), + handlerInvocation, + isFatal, + message: truncate(safeMessage(error), MAX_MESSAGE_CHARS), + name: safeName(error), + source: "error-utils", + stack: truncateNullable(safeStack(error), MAX_STACK_CHARS), + }; +} + +export function shouldPersistNonFatal(error: unknown): boolean { + return ( + error instanceof Error && + typeof error.stack === "string" && + error.stack.length > 0 && + error.message.length > 0 + ); +} + +function safeMessage(error: unknown): string { + if (error instanceof Error) { + return error.message.length > 0 ? error.message : error.name || "Error"; + } + if (typeof error === "string") { + return error; + } + try { + return String(error); + } catch { + return "unknown-error"; + } +} + +function safeName(error: unknown): string | null { + if (error instanceof Error) { + return error.name || null; + } + return null; +} + +function safeStack(error: unknown): string | null { + if (error instanceof Error && typeof error.stack === "string") { + return error.stack; + } + return null; +} + +function truncate(text: string, max: number): string { + return text.length > max ? `${text.slice(0, max)}…` : text; +} + +function truncateNullable(text: string | null, max: number): string | null { + if (text === null) { + return null; + } + return truncate(text, max); +} diff --git a/apps/mobile/src/lib/installCrashLog.ts b/apps/mobile/src/lib/installCrashLog.ts new file mode 100644 index 00000000000..ac3d1b76558 --- /dev/null +++ b/apps/mobile/src/lib/installCrashLog.ts @@ -0,0 +1,3 @@ +import { installCrashLogger } from "./crashLog"; + +installCrashLogger(); diff --git a/apps/mobile/src/lib/navigationBreadcrumb.test.ts b/apps/mobile/src/lib/navigationBreadcrumb.test.ts new file mode 100644 index 00000000000..ffa35664f71 --- /dev/null +++ b/apps/mobile/src/lib/navigationBreadcrumb.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { routePathFromNavigationState } from "./navigationBreadcrumb"; + +describe("routePathFromNavigationState", () => { + it("joins nested active routes", () => { + expect( + routePathFromNavigationState({ + index: 0, + routes: [ + { + key: "root", + name: "Root", + state: { + index: 1, + routes: [ + { key: "home", name: "Home" }, + { key: "thread", name: "Thread" }, + ], + }, + }, + ], + }), + ).toBe("Root/Thread"); + }); + + it("returns empty for missing state", () => { + expect(routePathFromNavigationState(undefined)).toBe(""); + }); +}); diff --git a/apps/mobile/src/lib/navigationBreadcrumb.ts b/apps/mobile/src/lib/navigationBreadcrumb.ts new file mode 100644 index 00000000000..6b23e7b4073 --- /dev/null +++ b/apps/mobile/src/lib/navigationBreadcrumb.ts @@ -0,0 +1,27 @@ +import type { NavigationState, PartialState } from "@react-navigation/native"; + +import { addBreadcrumb } from "./breadcrumbs"; + +type NavState = NavigationState | PartialState | undefined; + +/** Best-effort route path for crash breadcrumbs (e.g. Root/Thread). */ +export function routePathFromNavigationState(state: NavState): string { + if (state === undefined || !("routes" in state) || state.routes === undefined) { + return ""; + } + const index = "index" in state && typeof state.index === "number" ? state.index : 0; + const route = state.routes[index]; + if (route === undefined) { + return ""; + } + const nested = routePathFromNavigationState(route.state as NavState); + return nested.length > 0 ? `${route.name}/${nested}` : String(route.name); +} + +export function recordNavigationBreadcrumb(state: NavState): void { + const path = routePathFromNavigationState(state); + if (path.length === 0) { + return; + } + addBreadcrumb("nav", { path }); +} diff --git a/apps/mobile/src/lib/projectThreadStartTurn.ts b/apps/mobile/src/lib/projectThreadStartTurn.ts index 1330ffddbff..310f1818590 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.ts @@ -8,7 +8,7 @@ import { type RuntimeMode, } from "@t3tools/contracts"; -import type { DraftComposerImageAttachment } from "./composerImages"; +import { toUploadChatImageAttachments, type DraftComposerImageAttachment } from "./composerImages"; export function deriveThreadTitleFromPrompt(value: string): string { const trimmed = value.trim(); @@ -56,7 +56,7 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe messageId: MessageId.make(spec.messageId), role: "user" as const, text: spec.text, - attachments: spec.attachments, + attachments: toUploadChatImageAttachments(spec.attachments), }, modelSelection: spec.modelSelection, titleSeed: title, diff --git a/apps/mobile/src/lib/runtime.ts b/apps/mobile/src/lib/runtime.ts index 51a4885562c..98730edfbfc 100644 --- a/apps/mobile/src/lib/runtime.ts +++ b/apps/mobile/src/lib/runtime.ts @@ -8,6 +8,7 @@ import { cryptoLayer } from "../features/cloud/dpop"; import { managedRelayClientLayer } from "../features/cloud/managedRelayLayer"; import { resolveCloudPublicConfig } from "../features/cloud/publicConfig"; import { tracingLayer } from "../features/observability/tracing"; +import * as Persistence from "../persistence/layer"; function configuredRelayUrl(): string { return resolveCloudPublicConfig().relay.url ?? "http://relay.invalid"; @@ -20,6 +21,7 @@ type RuntimeLayerSource = | typeof Socket.layerWebSocketConstructorGlobal | typeof cryptoLayer | typeof httpClientLayer + | typeof Persistence.layer | typeof tracingLayer; const runtimeLayer = Layer.merge( @@ -29,6 +31,7 @@ const runtimeLayer = Layer.merge( Layer.provideMerge(cryptoLayer), Layer.provideMerge(httpClientLayer), Layer.provideMerge(tracingLayer.pipe(Layer.provide(httpClientLayer))), + Layer.provideMerge(Persistence.layer), ); export const runtime: ManagedRuntime.ManagedRuntime< diff --git a/apps/mobile/src/lib/serialized-async-queue.test.ts b/apps/mobile/src/lib/serialized-async-queue.test.ts new file mode 100644 index 00000000000..71dc9aa765a --- /dev/null +++ b/apps/mobile/src/lib/serialized-async-queue.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { SerializedAsyncQueue } from "./serialized-async-queue"; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +describe("SerializedAsyncQueue", () => { + it("does not let a newer operation overtake an in-flight operation", async () => { + const queue = new SerializedAsyncQueue(); + const firstGate = deferred(); + const events: string[] = []; + + const first = queue.run(async () => { + events.push("first:start"); + await firstGate.promise; + events.push("first:end"); + }); + const second = queue.run(async () => { + events.push("second"); + }); + + await Promise.resolve(); + expect(events).toEqual(["first:start"]); + firstGate.resolve(); + await Promise.all([first, second]); + expect(events).toEqual(["first:start", "first:end", "second"]); + }); + + it("continues after a rejected operation", async () => { + const queue = new SerializedAsyncQueue(); + const first = queue.run(async () => { + throw new Error("failed"); + }); + const second = queue.run(async () => "recovered"); + + await expect(first).rejects.toThrow("failed"); + await expect(second).resolves.toBe("recovered"); + }); +}); diff --git a/apps/mobile/src/lib/serialized-async-queue.ts b/apps/mobile/src/lib/serialized-async-queue.ts new file mode 100644 index 00000000000..e989eb7da65 --- /dev/null +++ b/apps/mobile/src/lib/serialized-async-queue.ts @@ -0,0 +1,16 @@ +/** + * Runs asynchronous operations in call order while keeping the queue usable + * after an individual operation rejects. + */ +export class SerializedAsyncQueue { + private tail: Promise = Promise.resolve(); + + run(operation: () => Promise): Promise { + const result = this.tail.then(operation, operation); + this.tail = result.then( + () => undefined, + () => undefined, + ); + return result; + } +} diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index 084f9430d08..a97252c7b72 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -3,34 +3,103 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => { const values = new Map(); + let preferencesJson: string | null = null; + let preferencesUpdatedAt = 0; + let loadPreferencesFails = false; + let savePreferencesFails = false; return { - clear: () => values.clear(), + clear: () => { + values.clear(); + preferencesJson = null; + preferencesUpdatedAt = 0; + loadPreferencesFails = false; + savePreferencesFails = false; + }, + getStoredValue: (key: string) => values.get(key) ?? null, + getPreferencesJson: () => preferencesJson, + setPreferencesJson: (value: string, updatedAt: number) => { + preferencesJson = value; + preferencesUpdatedAt = updatedAt; + }, + setDatabaseFailures: (load: boolean, save: boolean) => { + loadPreferencesFails = load; + savePreferencesFails = save; + }, getItemAsync: vi.fn((key: string) => Promise.resolve(values.get(key) ?? null)), setItemAsync: vi.fn((key: string, value: string) => { values.set(key, value); return Promise.resolve(); }), + deleteItemAsync: vi.fn((key: string) => { + values.delete(key); + return Promise.resolve(); + }), + database: { + closeAsync: vi.fn(() => Promise.resolve()), + execAsync: vi.fn(() => Promise.resolve()), + withExclusiveTransactionAsync: vi.fn( + (run: (transaction: { execAsync: () => Promise }) => Promise) => + run({ execAsync: () => Promise.resolve() }), + ), + getFirstAsync: vi.fn((sql: string) => { + if (sql.includes("PRAGMA user_version")) { + return Promise.resolve({ user_version: 1 }); + } + if (loadPreferencesFails) { + return Promise.reject(new Error("database unavailable")); + } + return Promise.resolve( + preferencesJson === null + ? null + : { payload: preferencesJson, updatedAt: preferencesUpdatedAt }, + ); + }), + runAsync: vi.fn((_sql: string, payload?: unknown, updatedAt?: unknown) => { + if (savePreferencesFails) { + return Promise.reject(new Error("database unavailable")); + } + if (typeof payload === "string") { + preferencesJson = payload; + } + if (typeof updatedAt === "number") { + preferencesUpdatedAt = updatedAt; + } + return Promise.resolve(); + }), + }, }; }); vi.mock("expo-secure-store", () => ({ + deleteItemAsync: mocks.deleteItemAsync, getItemAsync: mocks.getItemAsync, setItemAsync: mocks.setItemAsync, })); +vi.mock("expo-sqlite", () => ({ + openDatabaseAsync: vi.fn(() => Promise.resolve(mocks.database)), +})); + +vi.mock("expo-crypto", () => ({ + getRandomBytes: vi.fn(() => new Uint8Array(16)), +})); + +vi.mock("expo-constants", () => ({ + default: { expoConfig: { extra: {} } }, +})); + vi.mock("react-native", () => ({ Platform: { OS: "ios", }, })); -vi.mock("./runtime", () => ({ - runtime: { - runPromise: vi.fn(), - }, -})); - -import { loadSavedConnections, saveConnection } from "./storage"; +import { + loadPreferences, + loadSavedConnections, + saveConnection, + savePreferencesPatch, +} from "../persistence/imperative"; import { toStableSavedRemoteConnection } from "./connection"; const managedConnection = { @@ -100,4 +169,75 @@ describe("mobile connection storage", () => { warn.mockRestore(); }); + + it("loads legacy preferences when SQLite is unavailable", async () => { + mocks.setDatabaseFailures(true, true); + await mocks.setItemAsync("t3code.preferences", JSON.stringify({ baseFontSize: 17 })); + + await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 }); + }); + + it("falls back to secure storage when SQLite cannot save preferences", async () => { + mocks.setDatabaseFailures(true, true); + await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 }); + const fallback = JSON.parse(mocks.getStoredValue("t3code.preferences.fallback") ?? "") as { + readonly payload: string; + readonly updatedAt: number; + }; + expect(JSON.parse(fallback.payload)).toEqual({ baseFontSize: 19 }); + expect(fallback.updatedAt).toEqual(expect.any(Number)); + }); + + it("reconciles fallback preferences after SQLite recovers", async () => { + mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 15 }), 10); + await mocks.setItemAsync( + "t3code.preferences.fallback", + JSON.stringify({ + payload: JSON.stringify({ baseFontSize: 19 }), + updatedAt: 20, + }), + ); + + await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 19 }); + expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 19 }); + expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull(); + }); + + it("ignores a stale fallback when its previous deletion failed", async () => { + mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 21 }), 30); + await mocks.setItemAsync( + "t3code.preferences.fallback", + JSON.stringify({ + payload: JSON.stringify({ baseFontSize: 19 }), + updatedAt: 20, + }), + ); + + await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 21 }); + expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 21 }); + expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull(); + }); + + it("ignores an invalid fallback even when it has a newer timestamp", async () => { + mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 21 }), 30); + await mocks.setItemAsync( + "t3code.preferences.fallback", + JSON.stringify({ payload: "{", updatedAt: 40 }), + ); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 21 }); + expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 21 }); + expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull(); + + warn.mockRestore(); + }); + + it("keeps SQLite authoritative when stale legacy preferences remain", async () => { + mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 21 }), 30); + await mocks.setItemAsync("t3code.preferences", JSON.stringify({ baseFontSize: 19 })); + + await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 21 }); + expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 21 }); + }); }); diff --git a/apps/mobile/src/lib/storage.ts b/apps/mobile/src/lib/storage.ts deleted file mode 100644 index ae73ac7b4d9..00000000000 --- a/apps/mobile/src/lib/storage.ts +++ /dev/null @@ -1,291 +0,0 @@ -import * as Arr from "effect/Array"; -import { pipe } from "effect/Function"; -import * as Schema from "effect/Schema"; -import * as SecureStore from "expo-secure-store"; -import { EnvironmentId } from "@t3tools/contracts"; - -import { - isRelayManagedConnection, - type SavedRemoteConnection, - toStableSavedRemoteConnection, -} from "./connection"; - -const CONNECTIONS_KEY = "t3code.connections"; -const PREFERENCES_KEY = "t3code.preferences"; -const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id"; -const AGENT_AWARENESS_REGISTRATION_KEY = "t3code.agent-awareness.registration"; -const MobileStorageKey = Schema.Literals([ - CONNECTIONS_KEY, - PREFERENCES_KEY, - AGENT_AWARENESS_DEVICE_ID_KEY, - AGENT_AWARENESS_REGISTRATION_KEY, -]); -type MobileStorageKeyValue = typeof MobileStorageKey.Type; - -export class MobileSecureStorageError extends Schema.TaggedErrorClass()( - "MobileSecureStorageError", - { - operation: Schema.Literals(["read", "write", "generate-device-id"]), - key: MobileStorageKey, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Mobile secure storage operation ${this.operation} failed for key ${this.key}.`; - } -} - -export class MobileStorageDecodeError extends Schema.TaggedErrorClass()( - "MobileStorageDecodeError", - { - key: MobileStorageKey, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to decode mobile storage value for key ${this.key}.`; - } -} - -export class MobileStorageEncodeError extends Schema.TaggedErrorClass()( - "MobileStorageEncodeError", - { - key: MobileStorageKey, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to encode mobile storage value for key ${this.key}.`; - } -} - -export interface Preferences { - readonly liveActivitiesEnabled?: boolean; - readonly baseFontSize?: number; - /** Terminal font size override; null/absent means derived from baseFontSize. */ - readonly terminalFontSize?: number | null; - /** Legacy key predating baseFontSize; read once for migration. */ - readonly markdownFontSize?: number; - /** Code/diff font size override; null/absent means derived from baseFontSize. */ - readonly codeFontSize?: number | null; - readonly codeWordBreak?: boolean; - /** Cloud account ids that opted out of the T3 Connect onboarding sheet. */ - readonly connectOnboardingOptOutAccounts?: ReadonlyArray; -} - -async function readStorageItem(key: MobileStorageKeyValue): Promise { - try { - return await SecureStore.getItemAsync(key); - } catch (cause) { - throw new MobileSecureStorageError({ operation: "read", key, cause }); - } -} - -async function writeStorageItem(key: MobileStorageKeyValue, value: string): Promise { - try { - await SecureStore.setItemAsync(key, value); - } catch (cause) { - throw new MobileSecureStorageError({ operation: "write", key, cause }); - } -} - -async function readJsonStorageItem(key: MobileStorageKeyValue): Promise { - const raw = (await readStorageItem(key)) ?? ""; - if (!raw.trim()) { - return null; - } - - try { - return JSON.parse(raw) as T; - } catch (cause) { - console.warn( - "[mobile-storage] ignored invalid JSON", - new MobileStorageDecodeError({ key, cause }), - ); - return null; - } -} - -async function writeJsonStorageItem(key: MobileStorageKeyValue, value: unknown) { - let encoded: string; - try { - encoded = JSON.stringify(value); - } catch (cause) { - throw new MobileStorageEncodeError({ key, cause }); - } - await writeStorageItem(key, encoded); -} - -export async function loadSavedConnections(): Promise> { - const parsed = await readJsonStorageItem<{ - readonly connections?: ReadonlyArray; - }>(CONNECTIONS_KEY); - if (!parsed) { - return []; - } - - return pipe( - parsed.connections ?? [], - Arr.filter( - (c) => !!c.environmentId && (!!c.bearerToken?.trim() || isRelayManagedConnection(c)), - ), - ); -} - -export async function saveConnection(connection: SavedRemoteConnection): Promise { - const current = await loadSavedConnections(); - const stableConnection = toStableSavedRemoteConnection(connection); - const next = current.some((entry) => entry.environmentId === connection.environmentId) - ? pipe( - current, - Arr.map((entry) => - entry.environmentId === connection.environmentId ? stableConnection : entry, - ), - ) - : pipe(current, Arr.append(stableConnection)); - - await writeJsonStorageItem(CONNECTIONS_KEY, { connections: next }); -} - -export async function clearSavedConnection(environmentId: EnvironmentId): Promise { - const current = await loadSavedConnections(); - const next = pipe( - current, - Arr.filter((entry) => entry.environmentId !== environmentId), - ); - await writeJsonStorageItem(CONNECTIONS_KEY, { connections: next }); -} - -export async function loadPreferences(): Promise { - const parsed = await readJsonStorageItem(PREFERENCES_KEY); - if (!parsed || typeof parsed !== "object") { - return {}; - } - - const preferences: { - liveActivitiesEnabled?: boolean; - baseFontSize?: number; - terminalFontSize?: number | null; - markdownFontSize?: number; - codeFontSize?: number | null; - codeWordBreak?: boolean; - connectOnboardingOptOutAccounts?: ReadonlyArray; - } = {}; - - if (typeof parsed.liveActivitiesEnabled === "boolean") { - preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled; - } - if (typeof parsed.baseFontSize === "number") { - preferences.baseFontSize = parsed.baseFontSize; - } - if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) { - preferences.terminalFontSize = parsed.terminalFontSize; - } - if (typeof parsed.markdownFontSize === "number") { - preferences.markdownFontSize = parsed.markdownFontSize; - } - if (typeof parsed.codeFontSize === "number" || parsed.codeFontSize === null) { - preferences.codeFontSize = parsed.codeFontSize; - } - if (typeof parsed.codeWordBreak === "boolean") { - preferences.codeWordBreak = parsed.codeWordBreak; - } - if (Array.isArray(parsed.connectOnboardingOptOutAccounts)) { - preferences.connectOnboardingOptOutAccounts = parsed.connectOnboardingOptOutAccounts.filter( - (account): account is string => typeof account === "string", - ); - } - - return preferences; -} - -// Preference writes are read-modify-write over one JSON blob; concurrent -// writers would drop each other's fields, so all writes are serialized here. -let preferencesWriteQueue: Promise = Promise.resolve(); - -export async function updatePreferences( - update: (current: Preferences) => Partial, -): Promise { - const task = preferencesWriteQueue.then(async () => { - const current = await loadPreferences(); - const next: Preferences = { - ...current, - ...update(current), - }; - await writeJsonStorageItem(PREFERENCES_KEY, next); - return next; - }); - preferencesWriteQueue = task.catch(() => undefined); - return task; -} - -export async function savePreferencesPatch(patch: Partial): Promise { - return updatePreferences(() => patch); -} - -export async function loadOrCreateAgentAwarenessDeviceId(): Promise { - const existing = await readStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY); - if (existing?.trim()) { - return existing; - } - - const deviceId = await import("./uuid") - .then(({ uuidv4 }) => uuidv4()) - .catch((cause) => { - throw new MobileSecureStorageError({ - operation: "generate-device-id", - key: AGENT_AWARENESS_DEVICE_ID_KEY, - cause, - }); - }); - await writeStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY, deviceId); - return deviceId; -} - -export async function loadAgentAwarenessDeviceId(): Promise { - const existing = await readStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY); - return existing?.trim() ? existing : null; -} - -export interface AgentAwarenessRegistrationRecord { - readonly identity: string; - readonly signature: string; - // Last push-to-start token the relay accepted. Registrations triggered - // without a token event merge it back in so token absence never reads as a - // change (which would defeat the register-once skip every launch). - readonly pushToStartToken?: string; -} - -// Remembers the account identity and payload signature the relay last accepted -// so the app does not re-register on every launch while nothing has changed. -// Cleared only on sign-out. -export async function loadAgentAwarenessRegistrationRecord(): Promise { - const parsed = await readJsonStorageItem( - AGENT_AWARENESS_REGISTRATION_KEY, - ); - if ( - !parsed || - typeof parsed !== "object" || - typeof parsed.identity !== "string" || - typeof parsed.signature !== "string" - ) { - return null; - } - return { - identity: parsed.identity, - signature: parsed.signature, - ...(typeof parsed.pushToStartToken === "string" && parsed.pushToStartToken - ? { pushToStartToken: parsed.pushToStartToken } - : {}), - }; -} - -export async function saveAgentAwarenessRegistrationRecord( - record: AgentAwarenessRegistrationRecord, -): Promise { - await writeJsonStorageItem(AGENT_AWARENESS_REGISTRATION_KEY, record); -} - -export async function clearAgentAwarenessRegistrationRecord(): Promise { - await writeStorageItem(AGENT_AWARENESS_REGISTRATION_KEY, ""); -} diff --git a/apps/mobile/src/lib/useFontFamily.ts b/apps/mobile/src/lib/useFontFamily.ts new file mode 100644 index 00000000000..09805ae1154 --- /dev/null +++ b/apps/mobile/src/lib/useFontFamily.ts @@ -0,0 +1,15 @@ +import { useCSSVariable } from "uniwind"; + +const FONT_FAMILY_VARIABLES = { + regular: "--font-sans", + medium: "--font-medium", + bold: "--font-bold", +} as const; + +/** + * Resolves a font family for APIs that require a style object or native prop. + * Prefer Uniwind font classes when the target component accepts `className`. + */ +export function useFontFamily(weight: keyof typeof FONT_FAMILY_VARIABLES): string { + return useCSSVariable(FONT_FAMILY_VARIABLES[weight]) as string; +} diff --git a/apps/mobile/src/native/SelectableMarkdownText.ios.tsx b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx index 488766f3695..a614247a4ed 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.ios.tsx +++ b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx @@ -2,10 +2,13 @@ import { SelectableMarkdownText as T3SelectableMarkdownText, type SelectableMarkdownTextProps, } from "@t3tools/mobile-markdown-text/renderer"; +import { View } from "react-native"; import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter"; -type MobileSelectableMarkdownTextProps = Omit; +type MobileSelectableMarkdownTextProps = Omit & { + readonly fillWidth?: boolean; +}; export type { NativeMarkdownTextStyle, @@ -16,6 +19,21 @@ export function hasNativeSelectableMarkdownText(): boolean { return true; } -export function SelectableMarkdownText(props: MobileSelectableMarkdownTextProps) { - return ; +export function SelectableMarkdownText({ + fillWidth = false, + ...props +}: MobileSelectableMarkdownTextProps) { + const content = ( + + ); + + if (!fillWidth) { + return content; + } + + return {content}; } diff --git a/apps/mobile/src/native/SelectableMarkdownText.tsx b/apps/mobile/src/native/SelectableMarkdownText.tsx index 403f32a1de4..e536048a241 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.tsx +++ b/apps/mobile/src/native/SelectableMarkdownText.tsx @@ -1,6 +1,8 @@ import type { SelectableMarkdownTextProps } from "@t3tools/mobile-markdown-text/renderer"; -type MobileSelectableMarkdownTextProps = Omit; +type MobileSelectableMarkdownTextProps = Omit & { + readonly fillWidth?: boolean; +}; export type { NativeMarkdownTextStyle, diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index e2708757995..8a8c355b760 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -11,6 +11,7 @@ import { useEffect, useLayoutEffect, useMemo, + useRef, type ReactElement, type ReactNode, } from "react"; @@ -61,20 +62,116 @@ function normalizeScreenOptions( return normalized as NativeStackNavigationOptions; } +function optionsSignature(value: unknown, seen = new WeakSet()): string { + if (value === null) return "null"; + switch (typeof value) { + case "boolean": + case "number": + case "string": + return JSON.stringify(value); + case "undefined": + return "undefined"; + case "function": + // Header factories are frequently recreated inline. Their source is + // stable across equivalent renders, while a reference comparison would + // make navigation.setOptions re-enter the navigator indefinitely. + return `function:${Function.prototype.toString.call(value)}`; + case "symbol": + return `symbol:${String(value)}`; + case "bigint": + return `bigint:${String(value)}`; + case "object": { + const object = value as object; + if (seen.has(object)) return "[circular]"; + seen.add(object); + if (Array.isArray(value)) { + return `[${value.map((entry) => optionsSignature(entry, seen)).join(",")}]`; + } + // React refs carry mutable native instances that must not make static + // screen options appear different after every render. + if ("current" in object) return "[ref]"; + return `{${Object.keys(value as Record) + .sort() + .map( + (key) => + `${JSON.stringify(key)}:${optionsSignature((value as Record)[key], seen)}`, + ) + .join(",")}}`; + } + } + return String(value); +} + +function stabilizeOptionFunctions( + value: unknown, + path: string, + latestFunctions: Map unknown>, + wrappers: Map unknown>, + seen = new WeakSet(), +): unknown { + if (typeof value === "function") { + latestFunctions.set(path, value as (...args: unknown[]) => unknown); + let wrapper = wrappers.get(path); + if (!wrapper) { + wrapper = (...args: unknown[]) => { + return latestFunctions.get(path)?.(...args); + }; + wrappers.set(path, wrapper); + } + return wrapper; + } + if (Array.isArray(value)) { + if (seen.has(value)) return value; + seen.add(value); + return value.map((entry, index) => + stabilizeOptionFunctions(entry, `${path}[${index}]`, latestFunctions, wrappers, seen), + ); + } + if (value !== null && typeof value === "object") { + if (seen.has(value) || "current" in value) return value; + seen.add(value); + return Object.fromEntries( + Object.entries(value as Record).map(([key, entry]) => [ + key, + stabilizeOptionFunctions(entry, `${path}.${key}`, latestFunctions, wrappers, seen), + ]), + ); + } + return value; +} + export function NativeStackScreenOptions(props: { readonly options?: AppNativeStackNavigationOptions; readonly listeners?: Record void>; readonly name?: string; }) { const navigation = useNativeStackNavigation(); + const lastAppliedOptionsSignatureRef = useRef(undefined); + const latestOptionFunctionsRef = useRef(new Map unknown>()); + const optionFunctionWrappersRef = useRef(new Map unknown>()); const normalizedOptions = useMemo(() => normalizeScreenOptions(props.options), [props.options]); + const stableOptions = normalizedOptions + ? (stabilizeOptionFunctions( + normalizedOptions, + "options", + latestOptionFunctionsRef.current, + optionFunctionWrappersRef.current, + ) as NativeStackNavigationOptions) + : undefined; useLayoutEffect(() => { - if (!navigation || !normalizedOptions) { + if (!navigation || !stableOptions) { + return; + } + const signature = optionsSignature(stableOptions); + // Avoid re-entering navigation state when semantically equal options are + // reapplied every layout (common when callers pass unstable object literals). + if (lastAppliedOptionsSignatureRef.current === signature) { return; } - navigation.setOptions(normalizedOptions); - }, [navigation, normalizedOptions]); + lastAppliedOptionsSignatureRef.current = signature; + navigation.setOptions(stableOptions); + }, [navigation, stableOptions]); useEffect(() => { if (!navigation || !props.listeners) { @@ -195,7 +292,7 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null { if (typeName === "NativeHeaderToolbarButton") { return { type: "button", - label: "", + label: typeof child.props.label === "string" ? child.props.label : "", accessibilityLabel: typeof child.props.accessibilityLabel === "string" ? child.props.accessibilityLabel @@ -293,6 +390,7 @@ function NativeHeaderToolbarButton(_props: { readonly accessibilityLabel?: string; readonly disabled?: boolean; readonly icon?: string; + readonly label?: string; readonly onPress?: () => void; readonly separateBackground?: boolean; readonly tintColor?: ColorValue; diff --git a/apps/mobile/src/native/T3ComposerEditor.android.tsx b/apps/mobile/src/native/T3ComposerEditor.android.tsx new file mode 100644 index 00000000000..ccf88c2de4e --- /dev/null +++ b/apps/mobile/src/native/T3ComposerEditor.android.tsx @@ -0,0 +1,6 @@ +export { ComposerEditor } from "./T3ComposerEditor.native"; +export type { + ComposerEditorHandle, + ComposerEditorProps, + ComposerEditorSelection, +} from "./T3ComposerEditor.types"; diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index 1089ab3ac1f..4e9d62ad2c2 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -15,6 +15,7 @@ import { Image, StyleSheet } from "react-native"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; import { useThemeColor } from "../lib/useThemeColor"; +import { useFontFamily } from "../lib/useFontFamily"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; import { acknowledgeComposerNativeEvent, @@ -117,6 +118,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 fontFamily = useFontFamily("regular"); useImperativeHandle( ref, @@ -226,9 +228,7 @@ export function ComposerEditor({ themeJson={themeJson} placeholder={props.placeholder ?? ""} fontFamily={ - typeof resolvedTextStyle.fontFamily === "string" - ? resolvedTextStyle.fontFamily - : "DMSans_400Regular" + typeof resolvedTextStyle.fontFamily === "string" ? resolvedTextStyle.fontFamily : fontFamily } fontSize={ typeof resolvedTextStyle.fontSize === "number" diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx new file mode 100644 index 00000000000..84d1c22084f --- /dev/null +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -0,0 +1,286 @@ +import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; +import { requireNativeView } from "expo"; +import { + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, + type Ref, +} from "react"; +import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } 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 { useFontFamily } from "../lib/useFontFamily"; +import { useThemeColor } from "../lib/useThemeColor"; +import { + acknowledgeComposerNativeEvent, + isComposerNativeEcho, + pruneAcknowledgedComposerNativeEvents, + resolveComposerControlledEventCount, + type ComposerNativeEventSnapshot, +} from "./composerEditorRevision"; +import type { ComposerEditorProps, ComposerEditorSelection } from "./T3ComposerEditor.types"; + +const NATIVE_MODULE_NAME = "T3ComposerEditor"; +const EMPTY_SKILLS: NonNullable = []; + +type NativeEditorEvent = NativeSyntheticEvent<{ + readonly value: string; + readonly selection: ComposerEditorSelection; + readonly eventCount: number; +}>; + +type NativeSelectionEvent = NativeSyntheticEvent<{ + readonly value: string; + readonly selection: ComposerEditorSelection; + readonly eventCount: number; +}>; + +type NativePasteImagesEvent = NativeSyntheticEvent<{ + readonly uris: ReadonlyArray; +}>; + +interface NativeComposerEditorRef { + focus: () => Promise; + blur: () => Promise; + setSelection: (start: number, end: number) => Promise; +} + +interface NativeComposerEditorProps extends ViewProps { + readonly ref?: Ref; + readonly controlledDocumentJson: string; + readonly themeJson: string; + readonly placeholder: string; + readonly fontFamily: string; + readonly fontSize: number; + readonly lineHeight: number; + readonly contentInsetVertical: number; + readonly singleLineCentered: boolean; + readonly editable: boolean; + readonly scrollEnabled: boolean; + readonly autoFocus: boolean; + readonly autoCorrect: boolean; + readonly spellCheck: boolean; + readonly onComposerChange: (event: NativeEditorEvent) => void; + readonly onComposerSelectionChange?: (event: NativeSelectionEvent) => void; + readonly onComposerPasteImages?: (event: NativePasteImagesEvent) => void; + readonly onComposerFocus?: () => void; + readonly onComposerBlur?: () => void; +} + +const NativeView = requireNativeView(NATIVE_MODULE_NAME); + +function basename(path: string): string { + const separator = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + return separator >= 0 ? path.slice(separator + 1) : path; +} + +function fileIconUri(path: string): string { + return Image.resolveAssetSource(markdownFileIconSource(resolveMarkdownFileIcon(path))).uri; +} + +export function ComposerEditor({ + ref, + skills = EMPTY_SKILLS, + selection, + style, + textStyle, + onChangeText, + onSelectionChange, + onPasteImages, + onFocus, + onBlur, + contentInsetVertical = 0, + ...props +}: ComposerEditorProps) { + const nativeRef = useRef(null); + const mostRecentEventCountRef = useRef(0); + const [mostRecentEventCount, setMostRecentEventCount] = useState(0); + const [nativeEventSequence, setNativeEventSequence] = useState(0); + const previousRenderedEventSequenceRef = useRef(0); + const nativeEventSnapshotsRef = useRef([ + { eventCount: 0, value: props.value, selection: selection ?? null }, + ]); + const [initialConfirmedTokens] = useState(() => collectComposerInlineTokens(props.value)); + const confirmedTokensRef = useRef(initialConfirmedTokens); + const textColor = useThemeColor("--color-foreground"); + const placeholderColor = useThemeColor("--color-placeholder"); + const chipBackground = useThemeColor("--color-subtle"); + const chipBorder = useThemeColor("--color-border"); + const chipText = useThemeColor("--color-foreground"); + const skillBackground = useThemeColor("--color-inline-skill-background"); + const skillBorder = useThemeColor("--color-inline-skill-border"); + const skillText = useThemeColor("--color-inline-skill-foreground"); + const fileTint = useThemeColor("--color-icon-muted"); + + useImperativeHandle( + ref, + () => ({ + focus: () => void nativeRef.current?.focus(), + blur: () => void nativeRef.current?.blur(), + setSelection: (nextSelection) => + void nativeRef.current?.setSelection(nextSelection.start, nextSelection.end), + }), + [], + ); + + const skillLabels = useMemo( + () => new Map(skills.map((skill) => [skill.name, skill.displayName?.trim() || skill.name])), + [skills], + ); + const tokensJson = useMemo(() => { + const tokens = collectComposerInlineTokens(props.value, { + preserveTrailingFrom: confirmedTokensRef.current, + }); + confirmedTokensRef.current = tokens; + return JSON.stringify( + tokens.map((token) => ({ + type: token.type, + source: token.source, + start: token.start, + end: token.end, + label: + token.type === "skill" + ? (skillLabels.get(token.value) ?? token.value) + : basename(token.value), + iconUri: token.type === "mention" ? fileIconUri(token.value) : null, + })), + ); + }, [props.value, skillLabels]); + const includesNativeEvent = nativeEventSequence !== previousRenderedEventSequenceRef.current; + const controlledEventCount = includesNativeEvent + ? resolveComposerControlledEventCount( + props.value, + selection ?? null, + mostRecentEventCount, + nativeEventSnapshotsRef.current, + ) + : mostRecentEventCount; + const acknowledgesLatestNativeEvent = isComposerNativeEcho( + props.value, + selection ?? null, + mostRecentEventCount, + nativeEventSnapshotsRef.current, + ); + const isNativeEcho = + includesNativeEvent && + controlledEventCount === mostRecentEventCount && + acknowledgesLatestNativeEvent; + const controlledDocumentJson = JSON.stringify({ + value: props.value, + selection: isNativeEcho ? null : (selection ?? null), + tokensJson, + mostRecentEventCount: controlledEventCount, + isNativeEcho, + }); + useEffect(() => { + previousRenderedEventSequenceRef.current = nativeEventSequence; + }, [nativeEventSequence]); + useEffect(() => { + if (!acknowledgesLatestNativeEvent) return; + nativeEventSnapshotsRef.current = pruneAcknowledgedComposerNativeEvents( + nativeEventSnapshotsRef.current, + mostRecentEventCount, + ); + }, [acknowledgesLatestNativeEvent, mostRecentEventCount]); + const acceptNativeEvent = useCallback( + (eventCount: number, value: string, nextSelection: ComposerEditorSelection) => { + const acknowledgedEventCount = acknowledgeComposerNativeEvent( + mostRecentEventCountRef.current, + eventCount, + ); + if (acknowledgedEventCount === null) { + return false; + } + mostRecentEventCountRef.current = acknowledgedEventCount; + nativeEventSnapshotsRef.current.push({ + eventCount: acknowledgedEventCount, + value, + selection: nextSelection, + }); + return acknowledgedEventCount; + }, + [], + ); + const themeJson = JSON.stringify({ + text: String(textColor), + placeholder: String(placeholderColor), + chipBackground: String(chipBackground), + chipBorder: String(chipBorder), + chipText: String(chipText), + skillBackground: String(skillBackground), + skillBorder: String(skillBorder), + skillText: String(skillText), + fileTint: String(fileTint), + }); + 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} + /> + ); +} + +export type { + ComposerEditorHandle, + ComposerEditorProps, + ComposerEditorSelection, +} from "./T3ComposerEditor.types"; diff --git a/apps/mobile/src/native/T3ComposerEditor.tsx b/apps/mobile/src/native/T3ComposerEditor.tsx index 27e61e1b99c..e082d3892ad 100644 --- a/apps/mobile/src/native/T3ComposerEditor.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.tsx @@ -3,6 +3,7 @@ import { useImperativeHandle, useRef } from "react"; import { TextInput, type TextInput as RNTextInput } from "react-native"; import { useThemeColor } from "../lib/useThemeColor"; +import { useFontFamily } from "../lib/useFontFamily"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; import { useNativePaste } from "../lib/useNativePaste"; import type { ComposerEditorProps } from "./T3ComposerEditor.types"; @@ -15,12 +16,14 @@ export function ComposerEditor({ style, textStyle, contentInsetVertical = 0, + singleLineCentered: _singleLineCentered, ...props }: ComposerEditorProps) { const inputRef = useRef(null); const bodyText = useScaledTextRole("body"); const foregroundColor = useThemeColor("--color-foreground"); const placeholderColor = useThemeColor("--color-placeholder"); + const fontFamily = useFontFamily("regular"); const handlePaste = useNativePaste((uris) => onPasteImages?.(uris)); useImperativeHandle( @@ -48,7 +51,7 @@ export function ComposerEditor({ flex: 1, minHeight: 0, color: foregroundColor, - fontFamily: "DMSans_400Regular", + fontFamily, ...bodyText, paddingVertical: contentInsetVertical, }, diff --git a/apps/mobile/src/native/T3ComposerEditor.types.ts b/apps/mobile/src/native/T3ComposerEditor.types.ts index f7760c395ea..bfc47ed367b 100644 --- a/apps/mobile/src/native/T3ComposerEditor.types.ts +++ b/apps/mobile/src/native/T3ComposerEditor.types.ts @@ -28,6 +28,8 @@ export interface ComposerEditorProps { readonly spellCheck?: boolean; readonly multiline?: boolean; readonly contentInsetVertical?: number; + /** Android: center a single line vertically (collapsed pill); no-op on iOS. */ + readonly singleLineCentered?: boolean; readonly style?: StyleProp; readonly textStyle?: StyleProp; readonly onChangeText: (value: string) => void; diff --git a/apps/mobile/src/native/T3HeaderButton.android.tsx b/apps/mobile/src/native/T3HeaderButton.android.tsx new file mode 100644 index 00000000000..74908abd16c --- /dev/null +++ b/apps/mobile/src/native/T3HeaderButton.android.tsx @@ -0,0 +1,26 @@ +import { requireNativeView } from "expo"; +import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from "react-native"; + +interface NativeHeaderButtonProps extends ViewProps { + readonly label: string; + readonly systemImage: "gearshape" | "square.and.pencil"; + readonly onTriggered: (event: NativeSyntheticEvent>) => void; +} + +const NativeHeaderButton = requireNativeView("T3NativeControls"); + +export function T3HeaderButton(props: { + readonly accessibilityLabel: string; + readonly icon: NativeHeaderButtonProps["systemImage"]; + readonly onPress: () => void; + readonly style?: StyleProp; +}) { + return ( + + ); +} diff --git a/apps/mobile/src/native/T3KeyboardCommands.tsx b/apps/mobile/src/native/T3KeyboardCommands.tsx index 87629e6d676..1f49dd172c5 100644 --- a/apps/mobile/src/native/T3KeyboardCommands.tsx +++ b/apps/mobile/src/native/T3KeyboardCommands.tsx @@ -9,5 +9,5 @@ export function T3KeyboardCommands( readonly onCommand: (command: HardwareKeyboardCommand) => void; }>, ) { - return {props.children}; + return {props.children}; } diff --git a/apps/mobile/src/native/nativeStackOptionsSignature.test.ts b/apps/mobile/src/native/nativeStackOptionsSignature.test.ts new file mode 100644 index 00000000000..31032361878 --- /dev/null +++ b/apps/mobile/src/native/nativeStackOptionsSignature.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { buildNativeStackOptionsSignature } from "./nativeStackOptionsSignature"; + +describe("buildNativeStackOptionsSignature", () => { + it("treats fresh object/function identities with the same content as equal", () => { + const items = [ + { + identifier: "thread-right-git", + label: "Git", + type: "button", + }, + ]; + + const first = buildNativeStackOptionsSignature({ + headerTitle: "Catch-up thread", + title: "Catch-up thread", + headerBackVisible: true, + unstable_headerRightItems: () => items, + unstable_headerSubtitle: "proj · env", + }); + const second = buildNativeStackOptionsSignature({ + headerTitle: "Catch-up thread", + title: "Catch-up thread", + headerBackVisible: true, + unstable_headerRightItems: () => items, + unstable_headerSubtitle: "proj · env", + }); + + expect(first).toBe(second); + expect(first.length).toBeGreaterThan(0); + }); + + it("changes when title or item content changes", () => { + const base = buildNativeStackOptionsSignature({ + headerTitle: "A", + unstable_headerRightItems: () => [{ identifier: "a", type: "button" }], + }); + const renamed = buildNativeStackOptionsSignature({ + headerTitle: "B", + unstable_headerRightItems: () => [{ identifier: "a", type: "button" }], + }); + const reitemed = buildNativeStackOptionsSignature({ + headerTitle: "A", + unstable_headerRightItems: () => [{ identifier: "b", type: "button" }], + }); + + expect(renamed).not.toBe(base); + expect(reitemed).not.toBe(base); + }); + + it("ignores function-only differences inside header items", () => { + const withPressA = buildNativeStackOptionsSignature({ + unstable_headerRightItems: () => [ + { + identifier: "x", + onPress: () => "a", + type: "button", + }, + ], + }); + const withPressB = buildNativeStackOptionsSignature({ + unstable_headerRightItems: () => [ + { + identifier: "x", + onPress: () => "b", + type: "button", + }, + ], + }); + + expect(withPressA).toBe(withPressB); + }); +}); diff --git a/apps/mobile/src/native/nativeStackOptionsSignature.ts b/apps/mobile/src/native/nativeStackOptionsSignature.ts new file mode 100644 index 00000000000..e39ec462e86 --- /dev/null +++ b/apps/mobile/src/native/nativeStackOptionsSignature.ts @@ -0,0 +1,52 @@ +/** + * Structural signature for native stack screen options so callers can skip + * setOptions when they pass a fresh object/function identity with the same + * content. Unstable setOptions loops re-enter PreventRemoveProvider and crash + * Release builds with "Maximum update depth exceeded". + */ +export function buildNativeStackOptionsSignature(options: unknown): string { + if (options === undefined || options === null || typeof options !== "object") { + return ""; + } + const record = options as Record; + return stableJsonStringify({ + headerBackVisible: record.headerBackVisible ?? null, + headerSearchBarOptions: record.headerSearchBarOptions ?? null, + headerTintColor: record.headerTintColor === undefined ? null : String(record.headerTintColor), + headerTitle: record.headerTitle ?? null, + headerTitleStyle: record.headerTitleStyle ?? null, + title: record.title ?? null, + unstable_headerCenterItems: invokeHeaderItemsFactory(record.unstable_headerCenterItems), + unstable_headerLeftItems: invokeHeaderItemsFactory(record.unstable_headerLeftItems), + unstable_headerRightItems: invokeHeaderItemsFactory(record.unstable_headerRightItems), + unstable_headerSubtitle: record.unstable_headerSubtitle ?? null, + unstable_headerToolbarItems: invokeHeaderItemsFactory(record.unstable_headerToolbarItems), + }); +} + +function invokeHeaderItemsFactory(value: unknown): unknown { + if (typeof value !== "function") { + return value ?? null; + } + try { + return (value as () => unknown)(); + } catch { + return "[header-items-threw]"; + } +} + +function stableJsonStringify(value: unknown): string { + try { + return JSON.stringify(value, (_key, entry) => { + if (typeof entry === "function") { + return "[fn]"; + } + if (typeof entry === "symbol") { + return String(entry); + } + return entry; + }); + } catch { + return "[unserializable]"; + } +} diff --git a/apps/mobile/src/persistence/imperative.ts b/apps/mobile/src/persistence/imperative.ts new file mode 100644 index 00000000000..b66f6597c15 --- /dev/null +++ b/apps/mobile/src/persistence/imperative.ts @@ -0,0 +1,53 @@ +import * as Effect from "effect/Effect"; + +import { runtime } from "../lib/runtime"; +import * as MobilePreferences from "./mobile-preferences"; +import * as MobileStorage from "./mobile-storage"; + +export type { Preferences } from "./mobile-preferences"; +export type { AgentAwarenessRegistrationRecord, RecentThreadShortcut } from "./mobile-storage"; +export { MobilePreferencesLoadError, MobilePreferencesSaveError } from "./mobile-preferences"; +export { + MobileDeviceIdGenerationError, + MobileStorageDecodeError, + MobileStorageEncodeError, +} from "./mobile-storage"; +export { MobileSecureStorageError } from "./mobile-secure-storage"; + +const runStorage = ( + use: (storage: MobileStorage.MobileStorage["Service"]) => Effect.Effect, +) => runtime.runPromise(MobileStorage.MobileStorage.pipe(Effect.flatMap(use))); + +const runPreferences = ( + use: (store: MobilePreferences.MobilePreferencesStore["Service"]) => Effect.Effect, +) => runtime.runPromise(MobilePreferences.MobilePreferencesStore.pipe(Effect.flatMap(use))); + +export const loadSavedConnections = () => runStorage((storage) => storage.loadSavedConnections); +export const saveConnection = ( + connection: Parameters[0], +) => runStorage((storage) => storage.saveConnection(connection)); + +export const loadPreferences = () => runPreferences((store) => store.load); +export const savePreferencesPatch = (patch: Partial) => + runPreferences((store) => store.savePatch(patch)); +export const updatePreferences = ( + transform: (current: MobilePreferences.Preferences) => Partial, +) => runPreferences((store) => store.update(transform)); + +export const loadOrCreateAgentAwarenessDeviceId = () => + runStorage((storage) => storage.loadOrCreateAgentAwarenessDeviceId); +export const loadAgentAwarenessDeviceId = () => + runStorage((storage) => storage.loadAgentAwarenessDeviceId); +export const loadAgentAwarenessRegistrationRecord = () => + runStorage((storage) => storage.loadAgentAwarenessRegistrationRecord); +export const saveAgentAwarenessRegistrationRecord = ( + record: MobileStorage.AgentAwarenessRegistrationRecord, +) => runStorage((storage) => storage.saveAgentAwarenessRegistrationRecord(record)); +export const clearAgentAwarenessRegistrationRecord = () => + runStorage((storage) => storage.clearAgentAwarenessRegistrationRecord); + +export const loadRecentThreadShortcuts = () => + runStorage((storage) => storage.loadRecentThreadShortcuts); +export const saveRecentThreadShortcuts = ( + threads: ReadonlyArray, +) => runStorage((storage) => storage.saveRecentThreadShortcuts(threads)); diff --git a/apps/mobile/src/persistence/layer.ts b/apps/mobile/src/persistence/layer.ts new file mode 100644 index 00000000000..5a9d1dacff3 --- /dev/null +++ b/apps/mobile/src/persistence/layer.ts @@ -0,0 +1,16 @@ +import * as Layer from "effect/Layer"; + +import * as EnvironmentCacheStore from "../connection/environment-cache-store"; +import * as MobileDatabase from "./mobile-database"; +import * as MobilePreferences from "./mobile-preferences"; +import * as MobileSecureStorage from "./mobile-secure-storage"; +import * as MobileStorage from "./mobile-storage"; + +const baseLayer = Layer.merge(MobileDatabase.layer, MobileSecureStorage.layer); +const dependentLayer = Layer.mergeAll( + MobilePreferences.layer, + MobileStorage.layer, + EnvironmentCacheStore.layer, +).pipe(Layer.provide(baseLayer)); + +export const layer = Layer.merge(baseLayer, dependentLayer); diff --git a/apps/mobile/src/persistence/mobile-database.test.ts b/apps/mobile/src/persistence/mobile-database.test.ts new file mode 100644 index 00000000000..56c3e31657e --- /dev/null +++ b/apps/mobile/src/persistence/mobile-database.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { vi } from "vite-plus/test"; + +const openDatabaseAsync = vi.hoisted(() => vi.fn()); + +vi.mock("expo-sqlite", () => ({ openDatabaseAsync })); + +import { decodeLegacyCacheRecord, make } from "./mobile-database"; + +describe("mobile database legacy cache migration", () => { + it.effect("keeps acquisition failures typed on database operations", () => + Effect.scoped( + Effect.gen(function* () { + openDatabaseAsync.mockRejectedValueOnce(new Error("SQLite unavailable")); + + const database = yield* make; + const result = yield* Effect.result(database.loadPreferencesJson); + + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "MobileDatabaseError", operation: "open" }, + }); + }), + ), + ); + + it("maps legacy thread records to their SQLite identity", () => { + const payload = JSON.stringify({ + schemaVersion: 2, + environmentId: "environment-1", + threadId: "thread-1", + snapshot: {}, + }); + + expect(decodeLegacyCacheRecord("connection-thread-snapshots", payload)).toEqual({ + environmentId: "environment-1", + kind: "thread", + cacheKey: "thread-1", + schemaVersion: 2, + payload, + }); + }); + + it("preserves the old shell payload for schema decoding after migration", () => { + const payload = JSON.stringify({ + schemaVersion: 1, + environmentId: "environment-1", + snapshotReceivedAt: "2026-07-01T00:00:00.000Z", + snapshot: {}, + }); + + expect(decodeLegacyCacheRecord("shell-snapshots", payload)).toEqual({ + environmentId: "environment-1", + kind: "shell", + cacheKey: "snapshot", + schemaVersion: 1, + payload, + }); + }); + + it("skips malformed legacy records", () => { + expect(decodeLegacyCacheRecord("connection-vcs-refs", "{not-json")).toBeNull(); + expect( + decodeLegacyCacheRecord( + "connection-vcs-refs", + JSON.stringify({ schemaVersion: 1, environmentId: "environment-1" }), + ), + ).toBeNull(); + }); +}); diff --git a/apps/mobile/src/persistence/mobile-database.ts b/apps/mobile/src/persistence/mobile-database.ts new file mode 100644 index 00000000000..33eb50f640c --- /dev/null +++ b/apps/mobile/src/persistence/mobile-database.ts @@ -0,0 +1,409 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import type { SQLiteDatabase } from "expo-sqlite"; + +const DATABASE_NAME = "t3code-client.db"; +const DATABASE_SCHEMA_VERSION = 1; +const LEGACY_CACHE_DIRECTORIES = [ + "connection-shell-snapshots", + "shell-snapshots", + "connection-thread-snapshots", + "connection-server-configs", + "connection-vcs-refs", +] as const; + +export const ClientCacheKind = Schema.Literals(["shell", "thread", "server-config", "vcs-refs"]); +export type ClientCacheKind = typeof ClientCacheKind.Type; + +export interface ClientCacheSummaryRow { + readonly environmentId: EnvironmentId; + readonly kind: ClientCacheKind; + readonly recordCount: number; + readonly payloadBytes: number; +} + +export interface StoredPreferencesJson { + readonly payload: string; + readonly updatedAt: number; +} + +const ClientCacheSummaryRows = Schema.Array( + Schema.Struct({ + environmentId: Schema.String, + kind: ClientCacheKind, + recordCount: Schema.Number, + payloadBytes: Schema.Number, + }), +); + +const MobileDatabaseOperation = Schema.Literals([ + "open", + "migrate", + "load-cache", + "save-cache", + "remove-cache", + "clear-environment-cache", + "clear-all-caches", + "inspect-caches", + "load-preferences", + "save-preferences", +]); + +export class MobileDatabaseError extends Schema.TaggedErrorClass()( + "MobileDatabaseError", + { + operation: MobileDatabaseOperation, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Mobile database operation failed: ${this.operation}.`; + } +} + +function databaseError(operation: typeof MobileDatabaseOperation.Type) { + return (cause: unknown) => new MobileDatabaseError({ operation, cause }); +} + +interface LegacyCacheRecord { + readonly environmentId: string; + readonly kind: ClientCacheKind; + readonly cacheKey: string; + readonly schemaVersion: number; + readonly payload: string; +} + +function objectRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null ? (value as Record) : null; +} + +export function decodeLegacyCacheRecord( + directoryName: (typeof LEGACY_CACHE_DIRECTORIES)[number], + payload: string, +): LegacyCacheRecord | null { + let parsed: Record | null; + try { + parsed = objectRecord(JSON.parse(payload)); + } catch { + return null; + } + if ( + parsed === null || + typeof parsed.environmentId !== "string" || + typeof parsed.schemaVersion !== "number" + ) { + return null; + } + + switch (directoryName) { + case "connection-shell-snapshots": + case "shell-snapshots": + return { + environmentId: parsed.environmentId, + kind: "shell", + cacheKey: "snapshot", + schemaVersion: parsed.schemaVersion, + payload, + }; + case "connection-thread-snapshots": + return typeof parsed.threadId === "string" + ? { + environmentId: parsed.environmentId, + kind: "thread", + cacheKey: parsed.threadId, + schemaVersion: parsed.schemaVersion, + payload, + } + : null; + case "connection-server-configs": + return { + environmentId: parsed.environmentId, + kind: "server-config", + cacheKey: "config", + schemaVersion: parsed.schemaVersion, + payload, + }; + case "connection-vcs-refs": + return typeof parsed.cwd === "string" + ? { + environmentId: parsed.environmentId, + kind: "vcs-refs", + cacheKey: parsed.cwd, + schemaVersion: parsed.schemaVersion, + payload, + } + : null; + } +} + +async function migrateLegacyFileCaches(database: SQLiteDatabase): Promise { + try { + const { Directory, File, Paths } = await import("expo-file-system"); + let complete = true; + const listFiles = ( + directory: InstanceType, + ): Array> => + directory.list().flatMap((entry) => (entry instanceof File ? [entry] : listFiles(entry))); + + for (const directoryName of LEGACY_CACHE_DIRECTORIES) { + try { + const directory = new Directory(Paths.document, directoryName); + if (!directory.exists) continue; + for (const file of listFiles(directory)) { + const payload = await file.text(); + const record = decodeLegacyCacheRecord(directoryName, payload); + if (record === null) continue; + await database.runAsync( + `INSERT INTO client_cache + (environment_id, kind, cache_key, schema_version, payload, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (environment_id, kind, cache_key) DO NOTHING`, + record.environmentId, + record.kind, + record.cacheKey, + record.schemaVersion, + record.payload, + Date.now(), + ); + } + directory.delete(); + } catch (cause) { + complete = false; + console.warn(`[mobile-database] could not migrate legacy cache ${directoryName}`, cause); + } + } + return complete; + } catch (cause) { + console.warn("[mobile-database] could not load legacy cache migration", cause); + return false; + } +} + +export class MobileDatabase extends Context.Service< + MobileDatabase, + { + readonly loadCache: ( + environmentId: EnvironmentId, + kind: ClientCacheKind, + cacheKey: string, + ) => Effect.Effect, MobileDatabaseError>; + readonly saveCache: ( + environmentId: EnvironmentId, + kind: ClientCacheKind, + cacheKey: string, + schemaVersion: number, + payload: string, + ) => Effect.Effect; + readonly removeCache: ( + environmentId: EnvironmentId, + kind: ClientCacheKind, + cacheKey: string, + ) => Effect.Effect; + readonly clearEnvironmentCache: ( + environmentId: EnvironmentId, + ) => Effect.Effect; + readonly clearAllCaches: Effect.Effect; + readonly inspectCaches: Effect.Effect< + ReadonlyArray, + MobileDatabaseError + >; + readonly loadPreferencesJson: Effect.Effect< + Option.Option, + MobileDatabaseError + >; + readonly savePreferencesJson: ( + payload: string, + updatedAt: number, + ) => Effect.Effect; + } +>()("@t3tools/mobile/persistence/MobileDatabase") {} + +const makeAvailable = Effect.gen(function* () { + const database = yield* Effect.acquireRelease( + Effect.tryPromise({ + try: async () => { + const SQLite = await import("expo-sqlite"); + return SQLite.openDatabaseAsync(DATABASE_NAME); + }, + catch: databaseError("open"), + }), + (openDatabase) => Effect.promise(() => openDatabase.closeAsync()).pipe(Effect.ignore), + ); + + yield* Effect.tryPromise({ + try: async () => { + await database.execAsync("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;"); + const schema = await database.getFirstAsync<{ readonly user_version: number }>( + "PRAGMA user_version", + ); + await database.withExclusiveTransactionAsync(async (transaction) => { + await transaction.execAsync(` + CREATE TABLE IF NOT EXISTS client_cache ( + environment_id TEXT NOT NULL, + kind TEXT NOT NULL, + cache_key TEXT NOT NULL, + schema_version INTEGER NOT NULL, + payload TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (environment_id, kind, cache_key) + ) WITHOUT ROWID; + + CREATE INDEX IF NOT EXISTS client_cache_environment_updated + ON client_cache (environment_id, updated_at DESC); + + CREATE TABLE IF NOT EXISTS client_preferences ( + singleton INTEGER PRIMARY KEY NOT NULL CHECK (singleton = 1), + payload TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + `); + }); + if ((schema?.user_version ?? 0) < DATABASE_SCHEMA_VERSION) { + const migrated = await migrateLegacyFileCaches(database); + if (migrated) { + await database.execAsync(`PRAGMA user_version = ${DATABASE_SCHEMA_VERSION};`); + } + } + }, + catch: databaseError("migrate"), + }); + + return MobileDatabase.of({ + loadCache: Effect.fn("MobileDatabase.loadCache")((environmentId, kind, cacheKey) => + Effect.tryPromise({ + try: () => + database.getFirstAsync<{ readonly payload: string }>( + `SELECT payload + FROM client_cache + WHERE environment_id = ? AND kind = ? AND cache_key = ?`, + environmentId, + kind, + cacheKey, + ), + catch: databaseError("load-cache"), + }).pipe(Effect.map((row) => Option.fromNullishOr(row?.payload))), + ), + saveCache: Effect.fn("MobileDatabase.saveCache")( + (environmentId, kind, cacheKey, schemaVersion, payload) => + Effect.tryPromise({ + try: () => + database.runAsync( + `INSERT INTO client_cache + (environment_id, kind, cache_key, schema_version, payload, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (environment_id, kind, cache_key) DO UPDATE SET + schema_version = excluded.schema_version, + payload = excluded.payload, + updated_at = excluded.updated_at`, + environmentId, + kind, + cacheKey, + schemaVersion, + payload, + Date.now(), + ), + catch: databaseError("save-cache"), + }).pipe(Effect.asVoid), + ), + removeCache: Effect.fn("MobileDatabase.removeCache")((environmentId, kind, cacheKey) => + Effect.tryPromise({ + try: () => + database.runAsync( + `DELETE FROM client_cache + WHERE environment_id = ? AND kind = ? AND cache_key = ?`, + environmentId, + kind, + cacheKey, + ), + catch: databaseError("remove-cache"), + }).pipe(Effect.asVoid), + ), + clearEnvironmentCache: Effect.fn("MobileDatabase.clearEnvironmentCache")((environmentId) => + Effect.tryPromise({ + try: () => + database.runAsync("DELETE FROM client_cache WHERE environment_id = ?", environmentId), + catch: databaseError("clear-environment-cache"), + }).pipe(Effect.asVoid), + ), + clearAllCaches: Effect.tryPromise({ + try: () => database.runAsync("DELETE FROM client_cache"), + catch: databaseError("clear-all-caches"), + }).pipe(Effect.asVoid), + inspectCaches: Effect.tryPromise({ + try: () => + database.getAllAsync(` + SELECT + environment_id AS environmentId, + kind, + COUNT(*) AS recordCount, + COALESCE(SUM(LENGTH(CAST(payload AS BLOB))), 0) AS payloadBytes + FROM client_cache + GROUP BY environment_id, kind + ORDER BY environment_id, kind + `), + catch: databaseError("inspect-caches"), + }).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(ClientCacheSummaryRows)), + Effect.mapError(databaseError("inspect-caches")), + Effect.map( + (rows): ReadonlyArray => + rows.map((row) => ({ + environmentId: row.environmentId as EnvironmentId, + kind: row.kind, + recordCount: row.recordCount, + payloadBytes: row.payloadBytes, + })), + ), + ), + loadPreferencesJson: Effect.tryPromise({ + try: () => + database.getFirstAsync( + `SELECT payload, updated_at AS updatedAt + FROM client_preferences + WHERE singleton = 1`, + ), + catch: databaseError("load-preferences"), + }).pipe(Effect.map(Option.fromNullishOr)), + savePreferencesJson: Effect.fn("MobileDatabase.savePreferencesJson")((payload, updatedAt) => + Effect.tryPromise({ + try: () => + database.runAsync( + `INSERT INTO client_preferences (singleton, payload, updated_at) + VALUES (1, ?, ?) + ON CONFLICT (singleton) DO UPDATE SET + payload = excluded.payload, + updated_at = excluded.updated_at`, + payload, + updatedAt, + ), + catch: databaseError("save-preferences"), + }).pipe(Effect.asVoid), + ), + }); +}); + +function makeUnavailable(error: MobileDatabaseError): MobileDatabase["Service"] { + const fail = Effect.fail(error); + return MobileDatabase.of({ + loadCache: () => fail, + saveCache: () => fail, + removeCache: () => fail, + clearEnvironmentCache: () => fail, + clearAllCaches: fail, + inspectCaches: fail, + loadPreferencesJson: fail, + savePreferencesJson: () => fail, + }); +} + +export const make = Effect.result(makeAvailable).pipe( + Effect.map((result) => + result._tag === "Success" ? result.success : makeUnavailable(result.failure), + ), +); + +export const layer = Layer.effect(MobileDatabase, make); diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts new file mode 100644 index 00000000000..6971570b0e6 --- /dev/null +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -0,0 +1,305 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import * as MobileDatabase from "./mobile-database"; +import * as MobileSecureStorage from "./mobile-secure-storage"; +import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-storage"; + +const PREFERENCES_KEY = "t3code.preferences"; +const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback"; + +export interface Preferences { + readonly liveActivitiesEnabled?: boolean; + readonly baseFontSize?: number; + readonly terminalFontSize?: number | null; + readonly markdownFontSize?: number; + readonly codeFontSize?: number | null; + readonly codeWordBreak?: boolean; + readonly connectOnboardingOptOutAccounts?: ReadonlyArray; + readonly collapsedProjectGroups?: readonly string[]; +} + +export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( + "MobilePreferencesLoadError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to load mobile preferences."; + } +} + +export class MobilePreferencesSaveError extends Schema.TaggedErrorClass()( + "MobilePreferencesSaveError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to save mobile preferences."; + } +} + +interface PreferencesFallback { + readonly payload: string; + readonly updatedAt: number; + readonly preferences: Preferences; +} + +export class MobilePreferencesStore extends Context.Service< + MobilePreferencesStore, + { + readonly load: Effect.Effect; + readonly savePatch: ( + patch: Partial, + ) => Effect.Effect; + readonly update: ( + transform: (current: Preferences) => Partial, + ) => Effect.Effect; + } +>()("@t3tools/mobile/persistence/MobilePreferencesStore") {} + +function sanitizePreferences(parsed: Preferences): Preferences { + const preferences: { + liveActivitiesEnabled?: boolean; + baseFontSize?: number; + terminalFontSize?: number | null; + markdownFontSize?: number; + codeFontSize?: number | null; + codeWordBreak?: boolean; + connectOnboardingOptOutAccounts?: ReadonlyArray; + collapsedProjectGroups?: readonly string[]; + } = {}; + + if (typeof parsed.liveActivitiesEnabled === "boolean") { + preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled; + } + if (typeof parsed.baseFontSize === "number") preferences.baseFontSize = parsed.baseFontSize; + if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) { + preferences.terminalFontSize = parsed.terminalFontSize; + } + if (typeof parsed.markdownFontSize === "number") { + preferences.markdownFontSize = parsed.markdownFontSize; + } + if (typeof parsed.codeFontSize === "number" || parsed.codeFontSize === null) { + preferences.codeFontSize = parsed.codeFontSize; + } + if (typeof parsed.codeWordBreak === "boolean") preferences.codeWordBreak = parsed.codeWordBreak; + if (Array.isArray(parsed.connectOnboardingOptOutAccounts)) { + preferences.connectOnboardingOptOutAccounts = parsed.connectOnboardingOptOutAccounts.filter( + (account): account is string => typeof account === "string", + ); + } + if (Array.isArray(parsed.collapsedProjectGroups)) { + preferences.collapsedProjectGroups = parsed.collapsedProjectGroups.filter( + (key): key is string => typeof key === "string", + ); + } + return preferences; +} + +export const make = Effect.fn("MobilePreferencesStore.make")(function* () { + const database = yield* MobileDatabase.MobileDatabase; + const secureStorage = yield* MobileSecureStorage.MobileSecureStorage; + const lock = yield* Semaphore.make(1); + const lastUpdatedAt = yield* Ref.make(0); + + const parsePayload = (raw: string | null): Preferences | null => { + if (raw === null || !raw.trim()) return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (cause) { + console.warn( + "[mobile-storage] ignored invalid JSON", + new MobileStorageDecodeError({ key: PREFERENCES_KEY, cause }), + ); + return null; + } + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) + ? (parsed as Preferences) + : null; + }; + + const parseFallback = (raw: string | null): PreferencesFallback | null => { + if (raw === null || !raw.trim()) return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (cause) { + console.warn( + "[mobile-storage] ignored invalid JSON", + new MobileStorageDecodeError({ key: PREFERENCES_FALLBACK_KEY, cause }), + ); + return null; + } + if ( + typeof parsed !== "object" || + parsed === null || + !("payload" in parsed) || + typeof parsed.payload !== "string" || + !("updatedAt" in parsed) || + typeof parsed.updatedAt !== "number" + ) { + return null; + } + const preferences = parsePayload(parsed.payload); + return preferences === null + ? null + : { payload: parsed.payload, updatedAt: parsed.updatedAt, preferences }; + }; + + const encode = Effect.fn("MobilePreferencesStore.encode")(function* ( + key: string, + value: unknown, + ) { + return yield* Effect.try({ + try: () => JSON.stringify(value), + catch: (cause) => new MobileStorageEncodeError({ key, cause }), + }); + }); + + const nextUpdatedAt = Ref.modify(lastUpdatedAt, (last) => { + const next = Math.max(Date.now(), last + 1); + return [next, next] as const; + }); + + const saveJson = Effect.fn("MobilePreferencesStore.saveJson")(function* ( + payload: string, + updatedAt?: number, + ) { + const timestamp = updatedAt ?? (yield* nextUpdatedAt); + yield* Ref.update(lastUpdatedAt, (last) => Math.max(last, timestamp)); + const databaseResult = yield* Effect.result(database.savePreferencesJson(payload, timestamp)); + if (databaseResult._tag === "Failure") { + yield* Effect.logWarning("Database unavailable; saving preferences to secure storage.").pipe( + Effect.annotateLogs({ cause: databaseResult.failure }), + ); + const fallback = yield* encode(PREFERENCES_FALLBACK_KEY, { payload, updatedAt: timestamp }); + yield* secureStorage.setItem(PREFERENCES_FALLBACK_KEY, fallback); + return; + } + yield* secureStorage + .removeItem(PREFERENCES_FALLBACK_KEY) + .pipe( + Effect.catch((error) => + Effect.logWarning("Could not remove the mobile preferences fallback.").pipe( + Effect.annotateLogs({ error }), + ), + ), + ); + }); + + const loadUnlocked = Effect.gen(function* () { + const databaseResult = yield* Effect.result(database.loadPreferencesJson); + const databaseAvailable = databaseResult._tag === "Success"; + const storedJson = databaseAvailable + ? databaseResult.success + : Option.none(); + if (databaseResult._tag === "Failure") { + yield* Effect.logWarning("Database unavailable; loading fallback preferences.").pipe( + Effect.annotateLogs({ cause: databaseResult.failure }), + ); + } + + const fallbackResult = yield* Effect.result(secureStorage.getItem(PREFERENCES_FALLBACK_KEY)); + let fallbackJson: string | null = null; + if (fallbackResult._tag === "Success") { + fallbackJson = fallbackResult.success; + } else if (Option.isNone(storedJson)) { + return yield* fallbackResult.failure; + } else { + yield* Effect.logWarning("Could not inspect the mobile preferences fallback.").pipe( + Effect.annotateLogs({ error: fallbackResult.failure }), + ); + } + + const fallback = parseFallback(fallbackJson); + const storedPreferences = Option.isSome(storedJson) + ? parsePayload(storedJson.value.payload) + : null; + const fallbackIsNewer = + fallback !== null && + (storedPreferences === null || + (Option.isSome(storedJson) && fallback.updatedAt > storedJson.value.updatedAt)); + + let parsed: Preferences | null = null; + if (fallbackIsNewer) { + parsed = fallback.preferences; + yield* Ref.update(lastUpdatedAt, (last) => Math.max(last, fallback.updatedAt)); + if (databaseAvailable) yield* saveJson(fallback.payload, fallback.updatedAt); + } else if (storedPreferences !== null && Option.isSome(storedJson)) { + parsed = storedPreferences; + yield* Ref.update(lastUpdatedAt, (last) => Math.max(last, storedJson.value.updatedAt)); + if (fallbackJson !== null) { + yield* secureStorage + .removeItem(PREFERENCES_FALLBACK_KEY) + .pipe( + Effect.catch((error) => + Effect.logWarning("Could not remove a stale mobile preferences fallback.").pipe( + Effect.annotateLogs({ error }), + ), + ), + ); + } + } + + if (parsed === null) { + const legacyJson = yield* secureStorage.getItem(PREFERENCES_KEY); + const legacyPreferences = parsePayload(legacyJson); + parsed = legacyPreferences; + if (legacyJson !== null && legacyPreferences !== null && databaseAvailable) { + yield* saveJson(legacyJson); + yield* secureStorage + .removeItem(PREFERENCES_KEY) + .pipe( + Effect.catch((error) => + Effect.logWarning("Could not remove migrated mobile preferences.").pipe( + Effect.annotateLogs({ error }), + ), + ), + ); + } + } + + return parsed === null ? {} : sanitizePreferences(parsed); + }); + + const load = lock + .withPermits(1)(loadUnlocked) + .pipe(Effect.mapError((cause) => new MobilePreferencesLoadError({ cause }))); + + const update = Effect.fn("MobilePreferencesStore.update")((transform) => + lock + .withPermits(1)( + Effect.gen(function* () { + const current = yield* loadUnlocked; + const patch = yield* Effect.try({ + try: () => transform(current), + catch: (cause) => new MobilePreferencesSaveError({ cause }), + }); + const next: Preferences = { ...current, ...patch }; + const payload = yield* encode(PREFERENCES_KEY, next); + yield* saveJson(payload); + return next; + }), + ) + .pipe( + Effect.mapError((cause) => + cause instanceof MobilePreferencesSaveError + ? cause + : new MobilePreferencesSaveError({ cause }), + ), + ), + ); + + return MobilePreferencesStore.of({ + load, + update, + savePatch: (patch) => update(() => patch), + }); +}); + +export const layer = Layer.effect(MobilePreferencesStore, make()); diff --git a/apps/mobile/src/persistence/mobile-secure-storage.ts b/apps/mobile/src/persistence/mobile-secure-storage.ts new file mode 100644 index 00000000000..0ab7c9e4d96 --- /dev/null +++ b/apps/mobile/src/persistence/mobile-secure-storage.ts @@ -0,0 +1,52 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as SecureStore from "expo-secure-store"; + +const MobileSecureStorageOperation = Schema.Literals(["read", "write", "delete"]); + +export class MobileSecureStorageError extends Schema.TaggedErrorClass()( + "MobileSecureStorageError", + { + operation: MobileSecureStorageOperation, + key: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Mobile secure storage operation ${this.operation} failed for key ${this.key}.`; + } +} + +export class MobileSecureStorage extends Context.Service< + MobileSecureStorage, + { + readonly getItem: (key: string) => Effect.Effect; + readonly setItem: (key: string, value: string) => Effect.Effect; + readonly removeItem: (key: string) => Effect.Effect; + } +>()("@t3tools/mobile/persistence/MobileSecureStorage") {} + +export const make = MobileSecureStorage.of({ + getItem: Effect.fn("MobileSecureStorage.getItem")((key) => + Effect.tryPromise({ + try: () => SecureStore.getItemAsync(key), + catch: (cause) => new MobileSecureStorageError({ operation: "read", key, cause }), + }), + ), + setItem: Effect.fn("MobileSecureStorage.setItem")((key, value) => + Effect.tryPromise({ + try: () => SecureStore.setItemAsync(key, value), + catch: (cause) => new MobileSecureStorageError({ operation: "write", key, cause }), + }), + ), + removeItem: Effect.fn("MobileSecureStorage.removeItem")((key) => + Effect.tryPromise({ + try: () => SecureStore.deleteItemAsync(key), + catch: (cause) => new MobileSecureStorageError({ operation: "delete", key, cause }), + }), + ), +}); + +export const layer = Layer.succeed(MobileSecureStorage, make); diff --git a/apps/mobile/src/persistence/mobile-storage.ts b/apps/mobile/src/persistence/mobile-storage.ts new file mode 100644 index 00000000000..266e8c1a9a8 --- /dev/null +++ b/apps/mobile/src/persistence/mobile-storage.ts @@ -0,0 +1,266 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import * as Arr from "effect/Array"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import { pipe } from "effect/Function"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { + isRelayManagedConnection, + type SavedRemoteConnection, + toStableSavedRemoteConnection, +} from "../lib/connection"; +import * as MobileSecureStorage from "./mobile-secure-storage"; + +const CONNECTIONS_KEY = "t3code.connections"; +const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id"; +const AGENT_AWARENESS_REGISTRATION_KEY = "t3code.agent-awareness.registration"; +const RECENT_THREAD_SHORTCUTS_KEY = "t3code.recent-thread-shortcuts"; + +export class MobileStorageDecodeError extends Schema.TaggedErrorClass()( + "MobileStorageDecodeError", + { + key: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode mobile storage value for key ${this.key}.`; + } +} + +export class MobileStorageEncodeError extends Schema.TaggedErrorClass()( + "MobileStorageEncodeError", + { + key: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to encode mobile storage value for key ${this.key}.`; + } +} + +export class MobileDeviceIdGenerationError extends Schema.TaggedErrorClass()( + "MobileDeviceIdGenerationError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to generate the mobile agent-awareness device id."; + } +} + +export interface AgentAwarenessRegistrationRecord { + readonly identity: string; + readonly signature: string; + readonly pushToStartToken?: string; +} + +export interface RecentThreadShortcut { + readonly environmentId: string; + readonly threadId: string; + readonly title: string; +} + +export class MobileStorage extends Context.Service< + MobileStorage, + { + readonly loadSavedConnections: Effect.Effect< + ReadonlyArray, + MobileSecureStorage.MobileSecureStorageError + >; + readonly saveConnection: ( + connection: SavedRemoteConnection, + ) => Effect.Effect< + void, + MobileSecureStorage.MobileSecureStorageError | MobileStorageEncodeError + >; + readonly clearSavedConnection: ( + environmentId: EnvironmentId, + ) => Effect.Effect< + void, + MobileSecureStorage.MobileSecureStorageError | MobileStorageEncodeError + >; + readonly loadOrCreateAgentAwarenessDeviceId: Effect.Effect< + string, + MobileSecureStorage.MobileSecureStorageError | MobileDeviceIdGenerationError + >; + readonly loadAgentAwarenessDeviceId: Effect.Effect< + string | null, + MobileSecureStorage.MobileSecureStorageError + >; + readonly loadAgentAwarenessRegistrationRecord: Effect.Effect< + AgentAwarenessRegistrationRecord | null, + MobileSecureStorage.MobileSecureStorageError + >; + readonly saveAgentAwarenessRegistrationRecord: ( + record: AgentAwarenessRegistrationRecord, + ) => Effect.Effect< + void, + MobileSecureStorage.MobileSecureStorageError | MobileStorageEncodeError + >; + readonly clearAgentAwarenessRegistrationRecord: Effect.Effect< + void, + MobileSecureStorage.MobileSecureStorageError + >; + readonly loadRecentThreadShortcuts: Effect.Effect< + ReadonlyArray, + MobileSecureStorage.MobileSecureStorageError + >; + readonly saveRecentThreadShortcuts: ( + threads: ReadonlyArray, + ) => Effect.Effect< + void, + MobileSecureStorage.MobileSecureStorageError | MobileStorageEncodeError + >; + } +>()("@t3tools/mobile/persistence/MobileStorage") {} + +export const make = Effect.fn("MobileStorage.make")(function* () { + const secureStorage = yield* MobileSecureStorage.MobileSecureStorage; + + const parseJson = (key: string, raw: string): A | null => { + if (!raw.trim()) return null; + try { + return JSON.parse(raw) as A; + } catch (cause) { + console.warn( + "[mobile-storage] ignored invalid JSON", + new MobileStorageDecodeError({ key, cause }), + ); + return null; + } + }; + + const readJson = Effect.fn("MobileStorage.readJson")(function* (key: string) { + const raw = (yield* secureStorage.getItem(key)) ?? ""; + return parseJson(key, raw); + }); + + const writeJson = Effect.fn("MobileStorage.writeJson")(function* (key: string, value: unknown) { + const encoded = yield* Effect.try({ + try: () => JSON.stringify(value), + catch: (cause) => new MobileStorageEncodeError({ key, cause }), + }); + yield* secureStorage.setItem(key, encoded); + }); + + const loadSavedConnections = readJson<{ + readonly connections?: ReadonlyArray; + }>(CONNECTIONS_KEY).pipe( + Effect.map((parsed) => + pipe( + parsed?.connections ?? [], + Arr.filter( + (connection) => + !!connection.environmentId && + (!!connection.bearerToken?.trim() || isRelayManagedConnection(connection)), + ), + ), + ), + ); + + const saveConnection = Effect.fn("MobileStorage.saveConnection")(function* ( + connection: SavedRemoteConnection, + ) { + const current = yield* loadSavedConnections; + const stableConnection = toStableSavedRemoteConnection(connection); + const next = current.some((entry) => entry.environmentId === connection.environmentId) + ? pipe( + current, + Arr.map((entry) => + entry.environmentId === connection.environmentId ? stableConnection : entry, + ), + ) + : pipe(current, Arr.append(stableConnection)); + yield* writeJson(CONNECTIONS_KEY, { connections: next }); + }); + + const clearSavedConnection = Effect.fn("MobileStorage.clearSavedConnection")(function* ( + environmentId: EnvironmentId, + ) { + const current = yield* loadSavedConnections; + const next = pipe( + current, + Arr.filter((entry) => entry.environmentId !== environmentId), + ); + yield* writeJson(CONNECTIONS_KEY, { connections: next }); + }); + + const loadOrCreateAgentAwarenessDeviceId = Effect.gen(function* () { + const existing = yield* secureStorage.getItem(AGENT_AWARENESS_DEVICE_ID_KEY); + if (existing?.trim()) return existing; + const deviceId = yield* Effect.tryPromise({ + try: () => import("../lib/uuid").then(({ uuidv4 }) => uuidv4()), + catch: (cause) => new MobileDeviceIdGenerationError({ cause }), + }); + yield* secureStorage.setItem(AGENT_AWARENESS_DEVICE_ID_KEY, deviceId); + return deviceId; + }); + + const loadAgentAwarenessDeviceId = secureStorage + .getItem(AGENT_AWARENESS_DEVICE_ID_KEY) + .pipe(Effect.map((existing) => (existing?.trim() ? existing : null))); + + const loadAgentAwarenessRegistrationRecord = readJson( + AGENT_AWARENESS_REGISTRATION_KEY, + ).pipe( + Effect.map((parsed) => { + if ( + !parsed || + typeof parsed !== "object" || + typeof parsed.identity !== "string" || + typeof parsed.signature !== "string" + ) { + return null; + } + return { + identity: parsed.identity, + signature: parsed.signature, + ...(typeof parsed.pushToStartToken === "string" && parsed.pushToStartToken + ? { pushToStartToken: parsed.pushToStartToken } + : {}), + }; + }), + ); + + // Threads most recently opened on this device, newest first — the source + // for the launcher's dynamic "recent thread" app shortcuts. + const loadRecentThreadShortcuts = readJson<{ + readonly threads?: ReadonlyArray; + }>(RECENT_THREAD_SHORTCUTS_KEY).pipe( + Effect.map((parsed) => + pipe( + parsed?.threads ?? [], + Arr.filter( + (thread) => + typeof thread?.environmentId === "string" && + thread.environmentId.length > 0 && + typeof thread.threadId === "string" && + thread.threadId.length > 0 && + typeof thread.title === "string", + ), + ), + ), + ); + + return MobileStorage.of({ + loadSavedConnections, + saveConnection, + clearSavedConnection, + loadOrCreateAgentAwarenessDeviceId, + loadAgentAwarenessDeviceId, + loadAgentAwarenessRegistrationRecord, + saveAgentAwarenessRegistrationRecord: (record) => + writeJson(AGENT_AWARENESS_REGISTRATION_KEY, record), + clearAgentAwarenessRegistrationRecord: secureStorage.setItem( + AGENT_AWARENESS_REGISTRATION_KEY, + "", + ), + loadRecentThreadShortcuts, + saveRecentThreadShortcuts: (threads) => writeJson(RECENT_THREAD_SHORTCUTS_KEY, { threads }), + }); +}); + +export const layer = Layer.effect(MobileStorage, make()); diff --git a/apps/mobile/src/state/client-cache-state.ts b/apps/mobile/src/state/client-cache-state.ts new file mode 100644 index 00000000000..3912857b575 --- /dev/null +++ b/apps/mobile/src/state/client-cache-state.ts @@ -0,0 +1,87 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { Atom } from "effect/unstable/reactivity"; + +import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database"; +import * as Runtime from "../lib/runtime"; + +export interface EnvironmentClientCacheSummary { + readonly environmentId: EnvironmentId; + readonly recordCount: number; + readonly payloadBytes: number; + readonly kinds: Readonly>>; +} + +export interface ClientCacheSummary { + readonly recordCount: number; + readonly payloadBytes: number; + readonly environments: ReadonlyArray; +} + +export type ClientCacheClearScope = + | { readonly type: "all" } + | { readonly type: "environment"; readonly environmentId: EnvironmentId }; + +function aggregateCacheSummary( + rows: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly kind: ClientCacheKind; + readonly recordCount: number; + readonly payloadBytes: number; + }>, +): ClientCacheSummary { + const environments = new Map(); + let recordCount = 0; + let payloadBytes = 0; + + for (const row of rows) { + recordCount += row.recordCount; + payloadBytes += row.payloadBytes; + const current = environments.get(row.environmentId) ?? { + environmentId: row.environmentId, + recordCount: 0, + payloadBytes: 0, + kinds: {}, + }; + environments.set(row.environmentId, { + environmentId: row.environmentId, + recordCount: current.recordCount + row.recordCount, + payloadBytes: current.payloadBytes + row.payloadBytes, + kinds: { ...current.kinds, [row.kind]: row.recordCount }, + }); + } + + return { + recordCount, + payloadBytes, + environments: [...environments.values()], + }; +} + +const clientCacheRuntime = Atom.runtime(Runtime.runtimeContextLayer); + +export const clientCacheSummaryAtom = clientCacheRuntime + .atom( + MobileDatabase.pipe( + Effect.flatMap((database) => database.inspectCaches), + Effect.map(aggregateCacheSummary), + ), + ) + .pipe(Atom.withLabel("mobile:client-cache:summary")); + +export const clearClientCacheAtom = clientCacheRuntime + .fn((scope: ClientCacheClearScope, get) => + MobileDatabase.pipe( + Effect.flatMap((database) => + scope.type === "all" + ? database.clearAllCaches + : database.clearEnvironmentCache(scope.environmentId), + ), + Effect.tap(() => + Effect.sync(() => { + get.refresh(clientCacheSummaryAtom); + }), + ), + ), + ) + .pipe(Atom.withLabel("mobile:client-cache:clear")); diff --git a/apps/mobile/src/state/preferences.test.ts b/apps/mobile/src/state/preferences.test.ts new file mode 100644 index 00000000000..c53594eb230 --- /dev/null +++ b/apps/mobile/src/state/preferences.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { vi } from "vite-plus/test"; + +vi.mock("expo-secure-store", () => ({ + getItemAsync: vi.fn(), + setItemAsync: vi.fn(), +})); + +vi.mock("react-native", () => ({ + Platform: { OS: "ios" }, +})); + +vi.mock("../lib/runtime", async () => { + const Layer = await import("effect/Layer"); + return { + runtime: { runPromise: vi.fn() }, + runtimeContextLayer: Layer.empty, + }; +}); + +import type { Preferences } from "../persistence/mobile-preferences"; +import { + createMobilePreferencesState, + MobilePreferencesLoadError, + MobilePreferencesSaveError, + MobilePreferencesStore, +} from "./preferences"; + +function deferred() { + let resolve!: (value: A) => void; + const promise = new Promise((resume) => { + resolve = resume; + }); + return { promise, resolve } as const; +} + +function makePreferencesState( + service: Omit & + Partial>, +) { + const completeService = MobilePreferencesStore.of({ + ...service, + update: + service.update ?? + ((transform) => + service.load.pipe( + Effect.flatMap((current) => service.savePatch(transform(current))), + Effect.mapError((cause) => + cause._tag === "MobilePreferencesSaveError" + ? cause + : new MobilePreferencesSaveError({ cause }), + ), + )), + }); + return createMobilePreferencesState( + Atom.runtime(Layer.succeed(MobilePreferencesStore, completeService)), + ); +} + +describe("mobile preferences state", () => { + it.effect("shares one preference load across consumers", () => + Effect.gen(function* () { + const load = vi.fn(() => Promise.resolve({ baseFontSize: 17 })); + const state = makePreferencesState({ + load: Effect.promise(load), + savePatch: (patch) => Effect.succeed(patch), + }); + const registry = AtomRegistry.make(); + const unmountFirst = registry.mount(state.preferencesAtom); + const unmountSecond = registry.mount(state.preferencesAtom); + + expect( + yield* AtomRegistry.getResult(registry, state.preferencesAtom, { + suspendOnWaiting: true, + }), + ).toEqual({ baseFontSize: 17 }); + expect(load).toHaveBeenCalledTimes(1); + + unmountSecond(); + unmountFirst(); + registry.dispose(); + }), + ); + + it.effect("preserves an optimistic patch when the initial load finishes later", () => + Effect.gen(function* () { + const pendingLoad = deferred(); + const savePatch = vi.fn((patch: Partial) => Effect.succeed(patch)); + const state = makePreferencesState({ + load: Effect.promise(() => pendingLoad.promise), + savePatch, + }); + const registry = AtomRegistry.make(); + const unmountPreferences = registry.mount(state.preferencesAtom); + const unmountUpdate = registry.mount(state.updatePreferencesAtom); + + registry.set(state.updatePreferencesAtom, { + collapsedProjectGroups: ["project:new"], + }); + pendingLoad.resolve({ + baseFontSize: 18, + collapsedProjectGroups: ["project:old"], + }); + + const preferences = yield* AtomRegistry.getResult(registry, state.preferencesAtom, { + suspendOnWaiting: true, + }); + expect(preferences).toEqual({ + baseFontSize: 18, + collapsedProjectGroups: ["project:new"], + }); + expect(savePatch).toHaveBeenCalledWith({ + collapsedProjectGroups: ["project:new"], + }); + expect(AsyncResult.isFailure(registry.get(state.updatePreferencesAtom))).toBe(false); + + unmountUpdate(); + unmountPreferences(); + registry.dispose(); + }), + ); + + it.effect("falls back to empty preferences when secure storage cannot be read", () => + Effect.gen(function* () { + const state = makePreferencesState({ + load: Effect.fail( + new MobilePreferencesLoadError({ + cause: new Error("secure storage unavailable"), + }), + ), + savePatch: (patch) => Effect.succeed(patch), + }); + const registry = AtomRegistry.make(); + const unmount = registry.mount(state.preferencesAtom); + + expect( + yield* AtomRegistry.getResult(registry, state.preferencesAtom, { + suspendOnWaiting: true, + }), + ).toEqual({}); + + unmount(); + registry.dispose(); + }), + ); + + it.effect("does not roll back a newer optimistic write with the same value", () => + Effect.gen(function* () { + let saveCount = 0; + const state = makePreferencesState({ + load: Effect.succeed({ baseFontSize: 16, codeFontSize: 13 }), + savePatch: (patch) => { + saveCount += 1; + return saveCount === 1 + ? Effect.fail(new MobilePreferencesSaveError({ cause: new Error("write failed") })) + : Effect.succeed(patch); + }, + }); + const registry = AtomRegistry.make(); + const unmountPreferences = registry.mount(state.preferencesAtom); + const unmountUpdate = registry.mount(state.updatePreferencesAtom); + + yield* AtomRegistry.getResult(registry, state.preferencesAtom, { + suspendOnWaiting: true, + }); + registry.set(state.updatePreferencesAtom, { baseFontSize: 18 }); + registry.set(state.updatePreferencesAtom, { baseFontSize: 18, codeFontSize: 15 }); + + yield* Effect.promise(() => + vi.waitFor(() => { + expect(AsyncResult.isFailure(registry.get(state.updatePreferencesAtom))).toBe(false); + expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual( + { + baseFontSize: 18, + codeFontSize: 15, + }, + ); + }), + ); + + unmountUpdate(); + unmountPreferences(); + registry.dispose(); + }), + ); + + it.effect("rolls back an optimistic field when its save fails", () => + Effect.gen(function* () { + const state = makePreferencesState({ + load: Effect.succeed({ baseFontSize: 16 }), + savePatch: () => + Effect.fail(new MobilePreferencesSaveError({ cause: new Error("write failed") })), + }); + const registry = AtomRegistry.make(); + const unmountPreferences = registry.mount(state.preferencesAtom); + const unmountUpdate = registry.mount(state.updatePreferencesAtom); + + yield* AtomRegistry.getResult(registry, state.preferencesAtom, { + suspendOnWaiting: true, + }); + registry.set(state.updatePreferencesAtom, { baseFontSize: 18 }); + + yield* Effect.promise(() => + vi.waitFor(() => { + expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual( + { + baseFontSize: 16, + }, + ); + }), + ); + + unmountUpdate(); + unmountPreferences(); + registry.dispose(); + }), + ); + + it.effect("rolls back to the last confirmed value after a later save fails", () => + Effect.gen(function* () { + let saveCount = 0; + const state = makePreferencesState({ + load: Effect.succeed({ baseFontSize: 16 }), + savePatch: () => { + saveCount += 1; + return saveCount === 1 + ? Effect.succeed({ baseFontSize: 14 }) + : Effect.fail(new MobilePreferencesSaveError({ cause: new Error("write failed") })); + }, + }); + const registry = AtomRegistry.make(); + const unmountPreferences = registry.mount(state.preferencesAtom); + const unmountUpdate = registry.mount(state.updatePreferencesAtom); + + yield* AtomRegistry.getResult(registry, state.preferencesAtom, { + suspendOnWaiting: true, + }); + registry.set(state.updatePreferencesAtom, { baseFontSize: 14 }); + yield* Effect.promise(() => + vi.waitFor(() => { + expect(saveCount).toBe(1); + expect(registry.get(state.updatePreferencesAtom).waiting).toBe(false); + expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual( + { + baseFontSize: 14, + }, + ); + }), + ); + + registry.set(state.updatePreferencesAtom, { baseFontSize: 18 }); + yield* Effect.promise(() => + vi.waitFor(() => { + expect(saveCount).toBe(2); + expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual( + { + baseFontSize: 14, + }, + ); + }), + ); + + unmountUpdate(); + unmountPreferences(); + registry.dispose(); + }), + ); +}); diff --git a/apps/mobile/src/state/preferences.ts b/apps/mobile/src/state/preferences.ts new file mode 100644 index 00000000000..d173cf55be5 --- /dev/null +++ b/apps/mobile/src/state/preferences.ts @@ -0,0 +1,124 @@ +import * as Effect from "effect/Effect"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { MobilePreferencesStore, type Preferences } from "../persistence/mobile-preferences"; +import * as Runtime from "../lib/runtime"; + +export { + MobilePreferencesLoadError, + MobilePreferencesSaveError, + MobilePreferencesStore, +} from "../persistence/mobile-preferences"; + +interface OptimisticPreferences { + readonly values: Partial; + readonly versions: Partial>; +} + +/** + * Owns the device preference blob for the lifetime of the app registry. + * Optimistic patches are kept separately so writes made while persistence is + * still loading cannot be replaced by the eventual read result. + */ +export function createMobilePreferencesState(runtime: Atom.AtomRuntime) { + const storedPreferencesAtom = runtime + .atom( + MobilePreferencesStore.pipe( + Effect.flatMap((store) => store.load), + Effect.catch((error) => + Effect.logWarning("Could not load mobile preferences.", error).pipe( + Effect.as({}), + ), + ), + ), + ) + .pipe(Atom.keepAlive, Atom.withLabel("mobile:preferences:stored")); + + const optimisticPatchAtom = Atom.make({ values: {}, versions: {} }).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:preferences:optimistic-patch"), + ); + const confirmedPreferencesAtom = Atom.make({}).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:preferences:confirmed"), + ); + let nextPatchVersion = 0; + + const preferencesAtom = Atom.make((get) => { + const stored = get(storedPreferencesAtom); + const confirmed = get(confirmedPreferencesAtom); + const optimistic = get(optimisticPatchAtom); + return AsyncResult.map(stored, (preferences) => ({ + ...preferences, + ...confirmed, + ...optimistic.values, + })); + }).pipe(Atom.keepAlive, Atom.withLabel("mobile:preferences")); + + const updatePreferencesAtom = runtime + .fn( + (patch: Partial, get) => { + const version = ++nextPatchVersion; + const current = get(optimisticPatchAtom); + const versions = { ...current.versions }; + for (const key of Object.keys(patch) as Array) { + versions[key] = version; + } + get.set(optimisticPatchAtom, { + values: { ...current.values, ...patch }, + versions, + }); + return MobilePreferencesStore.pipe( + Effect.flatMap((store) => store.savePatch(patch)), + Effect.tap((saved) => + Effect.sync(() => { + get.set(confirmedPreferencesAtom, saved); + const optimistic = get(optimisticPatchAtom); + const values = { ...optimistic.values } as Record; + const currentVersions = { ...optimistic.versions } as Record; + for (const key of Object.keys(patch) as Array) { + if (optimistic.versions[key] === version) { + delete values[key]; + delete currentVersions[key]; + } + } + get.set(optimisticPatchAtom, { + values: values as Partial, + versions: currentVersions as Partial>, + }); + }), + ), + Effect.tapError(() => + Effect.sync(() => { + const optimistic = get(optimisticPatchAtom); + const values = { ...optimistic.values } as Record; + const currentVersions = { ...optimistic.versions } as Record; + for (const key of Object.keys(patch) as Array) { + if (optimistic.versions[key] === version) { + delete values[key]; + delete currentVersions[key]; + } + } + get.set(optimisticPatchAtom, { + values: values as Partial, + versions: currentVersions as Partial>, + }); + }), + ), + ); + }, + // The storage layer serializes preference read-modify-write operations. + // Keep every invocation alive so one preference update cannot interrupt + // another update to a different field in the shared blob. + { concurrent: true }, + ) + .pipe(Atom.keepAlive, Atom.withLabel("mobile:preferences:update")); + + return { preferencesAtom, updatePreferencesAtom } as const; +} + +const mobilePreferencesRuntime = Atom.runtime(Runtime.runtimeContextLayer); +export const mobilePreferencesState = createMobilePreferencesState(mobilePreferencesRuntime); + +export const mobilePreferencesAtom = mobilePreferencesState.preferencesAtom; +export const updateMobilePreferencesAtom = mobilePreferencesState.updatePreferencesAtom; diff --git a/apps/mobile/src/state/query.ts b/apps/mobile/src/state/query.ts index c29d01d397b..e2adffbdfb7 100644 --- a/apps/mobile/src/state/query.ts +++ b/apps/mobile/src/state/query.ts @@ -1,5 +1,6 @@ import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; -import * as Cause from "effect/Cause"; +import { causeFailureMessage } from "@t3tools/client-runtime/errors"; +import type * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -15,10 +16,7 @@ export interface EnvironmentQueryView { } function formatError(cause: Cause.Cause): string { - const error = Cause.squash(cause); - return error instanceof Error && error.message.trim().length > 0 - ? error.message - : "The environment request failed."; + return causeFailureMessage(cause, "The environment request failed."); } export function useEnvironmentQuery( diff --git a/apps/mobile/src/state/thread-outbox-manager.ts b/apps/mobile/src/state/thread-outbox-manager.ts index fc3e5069dc9..19f89d13c51 100644 --- a/apps/mobile/src/state/thread-outbox-manager.ts +++ b/apps/mobile/src/state/thread-outbox-manager.ts @@ -101,7 +101,10 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { cause, }); } - setMessages([...currentMessages(), message]); + setMessages([ + ...currentMessages().filter((candidate) => candidate.messageId !== message.messageId), + message, + ]); }); // Rewrites an already-queued message. A no-op when the message has been diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index 2ccaa471c87..6c665c432f4 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -294,6 +294,31 @@ describe("thread outbox", () => { registry.dispose(); }); + it("replaces an existing message when an enqueue retry uses the same id", async () => { + const registry = AtomRegistry.make(); + const manager = createThreadOutboxManager({ + registry, + storage: { + load: async () => [], + write: async () => undefined, + remove: async () => undefined, + }, + }); + const message = queuedMessage({ + messageId: "message-1", + createdAt: "2026-06-08T10:00:01.000Z", + }); + const retried = { ...message, text: "retried" }; + + await manager.enqueue(message); + await manager.enqueue(retried); + + expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ + "environment-1:thread-1": [retried], + }); + registry.dispose(); + }); + it("updates a queued message in place but never resurrects a removed one", async () => { const registry = AtomRegistry.make(); const stored = new Map(); diff --git a/apps/mobile/src/state/thread-pr-presentation.ts b/apps/mobile/src/state/thread-pr-presentation.ts new file mode 100644 index 00000000000..601e29fa444 --- /dev/null +++ b/apps/mobile/src/state/thread-pr-presentation.ts @@ -0,0 +1,36 @@ +import type { VcsStatusResult } from "@t3tools/contracts"; +import { resolveChangeRequestPresentation } from "@t3tools/shared/sourceControl"; + +export type ThreadPr = NonNullable; + +export interface ThreadPrPresentation { + readonly number: number; + readonly state: ThreadPr["state"]; + readonly url: string; + /** Compact pull request number label, e.g. "3774". */ + readonly label: string; + /** Full, provider-aware label for assistive technologies. */ + readonly accessibilityLabel: string; + readonly textClassName: string; +} + +const PR_STATE_TEXT_CLASS: Record = { + open: "text-emerald-600 dark:text-emerald-400", + merged: "text-violet-600 dark:text-violet-400", + closed: "text-zinc-500 dark:text-zinc-400", +}; + +export function presentThreadPr( + pr: ThreadPr, + provider: VcsStatusResult["sourceControlProvider"] | null | undefined, +): ThreadPrPresentation { + const presentation = resolveChangeRequestPresentation(provider); + return { + number: pr.number, + state: pr.state, + url: pr.url, + label: String(pr.number), + accessibilityLabel: `#${pr.number} ${presentation.longName} ${pr.state}`, + textClassName: PR_STATE_TEXT_CLASS[pr.state], + }; +} diff --git a/apps/mobile/src/state/threadQueryState.ts b/apps/mobile/src/state/threadQueryState.ts new file mode 100644 index 00000000000..a919fe0db88 --- /dev/null +++ b/apps/mobile/src/state/threadQueryState.ts @@ -0,0 +1,26 @@ +import { + EMPTY_ENVIRONMENT_THREAD_STATE, + type EnvironmentThreadState, +} from "@t3tools/client-runtime/state/threads"; +import { causeFailureMessage } from "@t3tools/client-runtime/errors"; +import type * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; + +function formatThreadStateFailure(cause: Cause.Cause): string { + return causeFailureMessage(cause, "Could not load conversation."); +} + +export function environmentThreadStateFromAsyncResult( + result: AsyncResult.AsyncResult, +): EnvironmentThreadState { + const value = Option.getOrNull(AsyncResult.value(result)); + if (AsyncResult.isFailure(result)) { + return { + ...(value ?? EMPTY_ENVIRONMENT_THREAD_STATE), + error: Option.some(formatThreadStateFailure(result.cause)), + }; + } + + return value ?? EMPTY_ENVIRONMENT_THREAD_STATE; +} diff --git a/apps/mobile/src/state/threads.test.ts b/apps/mobile/src/state/threads.test.ts new file mode 100644 index 00000000000..7285a9fa2b8 --- /dev/null +++ b/apps/mobile/src/state/threads.test.ts @@ -0,0 +1,40 @@ +import { EMPTY_ENVIRONMENT_THREAD_STATE } from "@t3tools/client-runtime/state/threads"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { describe, expect, it } from "vite-plus/test"; + +import { environmentThreadStateFromAsyncResult } from "./threadQueryState"; + +describe("environmentThreadStateFromAsyncResult", () => { + it("returns the empty state while the first value is loading", () => { + expect(environmentThreadStateFromAsyncResult(AsyncResult.initial(true))).toEqual( + EMPTY_ENVIRONMENT_THREAD_STATE, + ); + }); + + it("surfaces a failure when no previous value exists", () => { + const state = environmentThreadStateFromAsyncResult( + AsyncResult.failure(Cause.fail("thread sync failed")), + ); + + expect(Option.getOrNull(state.data)).toBeNull(); + expect(Option.getOrNull(state.error)).toBe("thread sync failed"); + }); + + it("preserves previous thread data while surfacing a refresh failure", () => { + const previousState = { + ...EMPTY_ENVIRONMENT_THREAD_STATE, + status: "live" as const, + }; + const previousSuccess = AsyncResult.success(previousState); + const state = environmentThreadStateFromAsyncResult( + AsyncResult.failure(Cause.fail(new Error("refresh failed")), { + previousSuccess: Option.some(previousSuccess), + }), + ); + + expect(state.status).toBe("live"); + expect(Option.getOrNull(state.error)).toBe("refresh failed"); + }); +}); diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index 34dc85f1689..cf6c1c2fe7a 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -1,4 +1,4 @@ -import { useAtomValue } from "@effect/atom-react"; +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; import { createEnvironmentThreadDetailAtoms, createEnvironmentThreadShellAtoms, @@ -8,12 +8,12 @@ import { createThreadEnvironmentAtoms, } from "@t3tools/client-runtime/state/threads"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { environmentSnapshotAtom } from "./shell"; +import { environmentThreadStateFromAsyncResult } from "./threadQueryState"; export const threadEnvironment = createThreadEnvironmentAtoms(connectionAtomRuntime); export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); @@ -29,18 +29,33 @@ const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_ Atom.withLabel("mobile-environment-thread:empty"), ); -export function useEnvironmentThread( +export interface EnvironmentThreadQuery { + readonly state: EnvironmentThreadState; + readonly isPending: boolean; + readonly refresh: () => void; +} + +export function useEnvironmentThreadQuery( environmentId: EnvironmentId | null, threadId: ThreadId | null, -): EnvironmentThreadState { - const result = useAtomValue( +): EnvironmentThreadQuery { + const atom = environmentId !== null && threadId !== null ? environmentThreads.stateAtom(environmentId, threadId) - : EMPTY_THREAD_STATE_ATOM, - ); - const state = Option.getOrElse( - AsyncResult.value(result), - () => EMPTY_ENVIRONMENT_THREAD_STATE, - ) as EnvironmentThreadState; - return state; + : EMPTY_THREAD_STATE_ATOM; + const result = useAtomValue(atom); + const refresh = useAtomRefresh(atom); + + return { + state: environmentThreadStateFromAsyncResult(result), + isPending: environmentId !== null && threadId !== null && result.waiting, + refresh, + }; +} + +export function useEnvironmentThread( + environmentId: EnvironmentId | null, + threadId: ThreadId | null, +): EnvironmentThreadState { + return useEnvironmentThreadQuery(environmentId, threadId).state; } diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index d02abb6a265..fdabe67bc71 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -8,7 +8,9 @@ import { decodePersistedComposerDrafts, type ComposerDraft, getComposerDraftSnapshot, + mergeComposerDraftContentState, removeComposerDraftsForEnvironment, + restoreComposerDraftSnapshotState, } from "./use-composer-drafts"; const DRAFT: ComposerDraft = { @@ -94,6 +96,7 @@ describe("mobile composer drafts", () => { const draft: ComposerDraft = { text: "send this", attachments: [], + importedShareIds: ["share-1"], modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", @@ -108,7 +111,8 @@ describe("mobile composer drafts", () => { expect(clearComposerDraftContentState({ [draftKey]: draft }, draftKey)).toEqual({ [draftKey]: { - ...draft, + modelSelection: draft.modelSelection, + workspaceSelection: draft.workspaceSelection, text: "", attachments: [], }, @@ -131,6 +135,90 @@ describe("mobile composer drafts", () => { expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft); }); + it("merges shared content into a project draft without duplicating retries", () => { + const draftKey = "new-task:environment-1:project-1"; + const sharedAttachment = { + id: "share-1:image:0", + type: "image" as const, + name: "Screenshot.png", + mimeType: "image/png", + sizeBytes: 3, + dataUrl: "data:image/png;base64,YWJj", + previewUri: "data:image/png;base64,YWJj", + }; + const existing: Record = { + [draftKey]: { text: "Existing context", attachments: [] }, + }; + const content = { + text: "Shared note", + attachments: [sharedAttachment], + sourceShareId: "share-1", + }; + + const merged = mergeComposerDraftContentState(existing, draftKey, content); + expect(merged[draftKey]).toMatchObject({ + text: "Existing context\n\nShared note", + attachments: [sharedAttachment], + importedShareIds: ["share-1"], + }); + expect(mergeComposerDraftContentState(merged, draftKey, content)).toBe(merged); + + const edited = { + ...merged, + [draftKey]: { ...merged[draftKey]!, text: "User edited the imported context" }, + }; + expect(mergeComposerDraftContentState(edited, draftKey, content)).toBe(edited); + }); + + it("preserves existing images when shared content exceeds the draft attachment limit", () => { + const draftKey = "new-task:environment-1:project-1"; + const image = (id: string) => ({ + id, + type: "image" as const, + name: `${id}.png`, + mimeType: "image/png", + sizeBytes: 3, + dataUrl: "data:image/png;base64,YWJj", + previewUri: "data:image/png;base64,YWJj", + }); + const existingImage = image("existing"); + const sharedImages = Array.from({ length: 8 }, (_, index) => image(`shared-${index}`)); + + const merged = mergeComposerDraftContentState( + { [draftKey]: { text: "", attachments: [existingImage] } }, + draftKey, + { text: "", attachments: sharedImages }, + ); + + expect(merged[draftKey]?.attachments).toHaveLength(8); + expect(merged[draftKey]?.attachments[0]).toEqual(existingImage); + expect(merged[draftKey]?.attachments.at(-1)?.id).toBe("shared-6"); + }); + + it("restores the exact draft captured before an interrupted share import", () => { + const draftKey = "new-task:environment-1:project-1"; + const beforeImport: ComposerDraft = { + text: "Existing context", + attachments: [], + runtimeMode: "approval-required", + }; + const imported: ComposerDraft = { + ...beforeImport, + text: "Existing context\n\nShared note", + importedShareIds: ["share-1"], + }; + + expect( + restoreComposerDraftSnapshotState({ [draftKey]: imported }, draftKey, beforeImport), + ).toEqual({ [draftKey]: beforeImport }); + expect( + restoreComposerDraftSnapshotState({ [draftKey]: imported }, draftKey, { + text: "", + attachments: [], + }), + ).toEqual({}); + }); + it("removes only drafts owned by the selected environment", () => { const environmentId = EnvironmentId.make("environment-cloud"); const retainedEnvironmentId = EnvironmentId.make("environment-local"); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index aa2e2dd5cca..cdef999e043 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1,6 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import { ModelSelection as ModelSelectionSchema, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ProviderInteractionMode as ProviderInteractionModeSchema, RuntimeMode as RuntimeModeSchema, type EnvironmentId, @@ -14,6 +15,7 @@ import { Atom } from "effect/unstable/reactivity"; import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; +import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; import { appAtomRegistry } from "./atom-registry"; const COMPOSER_DRAFTS_SCHEMA_VERSION = 1; @@ -38,12 +40,19 @@ export class ComposerDraftPersistenceError extends Schema.TaggedErrorClass; + readonly importedShareIds?: ReadonlyArray; readonly modelSelection?: ModelSelection; readonly runtimeMode?: RuntimeMode; readonly interactionMode?: ProviderInteractionMode; readonly workspaceSelection?: ComposerDraftWorkspaceSelection; } +export interface ComposerDraftContent { + readonly text: string; + readonly attachments: ReadonlyArray; + readonly sourceShareId?: string; +} + export interface ComposerDraftWorkspaceSelection { readonly mode: "local" | "worktree"; readonly branch: string | null; @@ -66,6 +75,7 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ const ComposerDraftSchema = Schema.Struct({ text: Schema.String, attachments: Schema.Array(DraftComposerImageAttachmentSchema), + importedShareIds: Schema.optional(Schema.Array(Schema.String)), modelSelection: Schema.optional(ModelSelectionSchema), runtimeMode: Schema.optional(RuntimeModeSchema), interactionMode: Schema.optional(ProviderInteractionModeSchema), @@ -93,6 +103,7 @@ export const composerDraftsAtom = Atom.make>({}).p let loadPromise: Promise | null = null; let persistTimer: ReturnType | null = null; +const persistenceQueue = new SerializedAsyncQueue(); function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { if (!draft) { @@ -193,7 +204,7 @@ async function writePersistedComposerDrafts(drafts: Record): Promise { try { - await writePersistedComposerDrafts(drafts); + await persistenceQueue.run(() => writePersistedComposerDrafts(drafts)); } catch (error) { console.warn("[composer-drafts] failed to persist drafts", error); // Draft persistence is best-effort; in-memory drafts still keep working. @@ -366,8 +377,9 @@ export function clearComposerDraftContentState( if (!existing) { return current; } + const { importedShareIds: _importedShareIds, ...retained } = existing; const draft = { - ...existing, + ...retained, text: "", attachments: [], }; @@ -382,6 +394,138 @@ export function clearComposerDraftContentState( }; } +export function restoreComposerDraftSnapshotState( + current: Record, + draftKey: string, + snapshot: ComposerDraft, +): Record { + const next = { ...current }; + if (isEmptyDraft(snapshot)) { + delete next[draftKey]; + } else { + next[draftKey] = snapshot; + } + return next; +} + +function mergeComposerDraftText(existing: string, incoming: string): string { + if (incoming.length === 0) { + return existing; + } + if (existing.length === 0) { + return incoming; + } + // Import retries are possible after an interrupted native handoff. Keep the + // operation idempotent when the same shared text is already present. + if (existing === incoming || existing.endsWith(`\n\n${incoming}`)) { + return existing; + } + return `${existing}\n\n${incoming}`; +} + +export function mergeComposerDraftContentState( + current: Record, + draftKey: string, + content: ComposerDraftContent, +): Record { + const existing = normalizeDraft(current[draftKey]); + if (content.sourceShareId && existing.importedShareIds?.includes(content.sourceShareId)) { + return current; + } + const attachmentIds = new Set(existing.attachments.map((attachment) => attachment.id)); + const incomingAttachments = content.attachments.filter((attachment) => { + if (attachmentIds.has(attachment.id)) { + return false; + } + attachmentIds.add(attachment.id); + return true; + }); + const attachments = [...existing.attachments, ...incomingAttachments].slice( + 0, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + ); + const text = mergeComposerDraftText(existing.text, content.text); + const importedShareIds = content.sourceShareId + ? [...(existing.importedShareIds ?? []), content.sourceShareId] + : existing.importedShareIds; + if ( + text === existing.text && + attachments.length === existing.attachments.length && + importedShareIds === existing.importedShareIds + ) { + return current; + } + return { + ...current, + [draftKey]: { + ...existing, + text, + attachments, + ...(importedShareIds ? { importedShareIds } : {}), + }, + }; +} + +/** + * Atomically moves an incoming share into a project-scoped composer draft. + * The durable write happens before the share inbox item can be acknowledged. + */ +export async function mergeComposerDraftContent( + draftKey: string, + content: ComposerDraftContent, +): Promise<{ readonly skippedAttachmentCount: number }> { + ensureComposerDraftsLoaded(); + if (loadPromise !== null) { + await loadPromise; + } + if (persistTimer !== null) { + clearTimeout(persistTimer); + persistTimer = null; + } + const current = appAtomRegistry.get(composerDraftsAtom); + const next = mergeComposerDraftContentState(current, draftKey, content); + const currentAttachmentIds = new Set( + normalizeDraft(current[draftKey]).attachments.map((attachment) => attachment.id), + ); + const nextAttachmentIds = new Set( + normalizeDraft(next[draftKey]).attachments.map((attachment) => attachment.id), + ); + const skippedAttachmentCount = content.attachments.filter( + (attachment) => + !currentAttachmentIds.has(attachment.id) && !nextAttachmentIds.has(attachment.id), + ).length; + // Publish the content and its import receipt together before the filesystem + // await. Typing during persistence then builds on the receipt-bearing state, + // and its debounced write is serialized after this transaction. + if (next !== current) { + appAtomRegistry.set(composerDraftsAtom, next); + } + await persistenceQueue.run(() => writePersistedComposerDrafts(next)); + return { skippedAttachmentCount }; +} + +/** Restores the exact content/settings captured before an interrupted import. */ +export async function restoreComposerDraftSnapshot( + draftKey: string, + snapshot: ComposerDraft, +): Promise { + ensureComposerDraftsLoaded(); + if (loadPromise !== null) { + await loadPromise; + } + if (persistTimer !== null) { + clearTimeout(persistTimer); + persistTimer = null; + } + const next = restoreComposerDraftSnapshotState( + appAtomRegistry.get(composerDraftsAtom), + draftKey, + snapshot, + ); + appAtomRegistry.set(composerDraftsAtom, next); + await persistenceQueue.run(() => writePersistedComposerDrafts(next)); +} + export function clearComposerDraftContent(draftKey: string): void { updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey)); } diff --git a/apps/mobile/src/state/use-thread-detail.ts b/apps/mobile/src/state/use-thread-detail.ts index 8c48aa880ec..ff6c6dda75b 100644 --- a/apps/mobile/src/state/use-thread-detail.ts +++ b/apps/mobile/src/state/use-thread-detail.ts @@ -3,7 +3,11 @@ import type { EnvironmentThread } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId, OrchestrationV2ThreadProjection, ThreadId } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; -import { environmentThreadDetails, useEnvironmentThread } from "./threads"; +import { + environmentThreadDetails, + useEnvironmentThread, + useEnvironmentThreadQuery, +} from "./threads"; import { useThreadSelection } from "./use-thread-selection"; const EMPTY_THREAD_PROJECTION_ATOM = Atom.make(null).pipe( @@ -22,9 +26,17 @@ export function useThreadDetail(target: ThreadDetailTarget) { return useEnvironmentThread(target.environmentId, target.threadId); } +export function useThreadDetailQuery(target: ThreadDetailTarget) { + return useEnvironmentThreadQuery(target.environmentId, target.threadId); +} + export function useSelectedThreadDetailState() { + return useSelectedThreadDetailQuery().state; +} + +export function useSelectedThreadDetailQuery() { const { selectedThread } = useThreadSelection(); - return useThreadDetail({ + return useThreadDetailQuery({ environmentId: selectedThread?.environmentId ?? null, threadId: selectedThread?.id ?? null, }); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index b82214c9e2a..09ad34fe9b0 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -16,6 +16,8 @@ import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; +import { addBreadcrumb } from "../lib/breadcrumbs"; +import { toUploadChatImageAttachments } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; import { randomHex } from "../lib/uuid"; @@ -223,7 +225,7 @@ export function useThreadOutboxDrain(): void { messageId: queuedMessage.messageId, role: "user", text: queuedMessage.text, - attachments: queuedMessage.attachments, + attachments: toUploadChatImageAttachments(queuedMessage.attachments), }, modelSelection: settings.modelSelection, runtimeMode: settings.runtimeMode, @@ -337,6 +339,13 @@ export function useThreadOutboxDrain(): void { } beginDispatchingQueuedMessage(nextQueuedMessage.messageId); + addBreadcrumb("outbox.dispatch", { + action: deliveryAction, + creation: creation !== undefined, + environmentId: nextQueuedMessage.environmentId, + messageId: nextQueuedMessage.messageId, + threadId: nextQueuedMessage.threadId, + }); const removeQueuedMessage = (warning: string) => removeThreadOutboxMessage(nextQueuedMessage).then( () => true, @@ -362,6 +371,11 @@ export function useThreadOutboxDrain(): void { : Promise.resolve(false); void delivery .then((sent) => { + addBreadcrumb("outbox.result", { + messageId: nextQueuedMessage.messageId, + sent, + threadId: nextQueuedMessage.threadId, + }); if (sent) { retryAttemptRef.current.delete(nextQueuedMessage.messageId); retryNotBeforeRef.current.delete(nextQueuedMessage.messageId); diff --git a/apps/mobile/src/state/use-thread-pr.test.ts b/apps/mobile/src/state/use-thread-pr.test.ts new file mode 100644 index 00000000000..a355dddcd43 --- /dev/null +++ b/apps/mobile/src/state/use-thread-pr.test.ts @@ -0,0 +1,36 @@ +import type { VcsStatusResult } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { presentThreadPr } from "./thread-pr-presentation"; + +const pullRequest: NonNullable = { + number: 3774, + title: "Desktop-style pull request indicator", + url: "https://github.com/t3tools/t3code/pull/3774", + baseRef: "main", + headRef: "codex/desktop-style-pr-indicator", + state: "merged", +}; + +describe("presentThreadPr", () => { + it("uses the compact pull request number label without a hash prefix", () => { + expect(presentThreadPr(pullRequest, undefined)).toMatchObject({ + label: "3774", + accessibilityLabel: "#3774 pull request merged", + textClassName: "text-violet-600 dark:text-violet-400", + }); + }); + + it("uses merge-request terminology for GitLab", () => { + expect( + presentThreadPr(pullRequest, { + kind: "gitlab", + name: "GitLab", + baseUrl: "https://gitlab.com", + }), + ).toMatchObject({ + label: "3774", + accessibilityLabel: "#3774 merge request merged", + }); + }); +}); diff --git a/apps/mobile/src/state/use-thread-pr.ts b/apps/mobile/src/state/use-thread-pr.ts index b7d890bbe7f..a3440cd4848 100644 --- a/apps/mobile/src/state/use-thread-pr.ts +++ b/apps/mobile/src/state/use-thread-pr.ts @@ -1,40 +1,14 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import type { VcsStatusResult } from "@t3tools/contracts"; -import { resolveChangeRequestPresentation } from "@t3tools/shared/sourceControl"; import { useEnvironmentQuery } from "./query"; +import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation"; import { vcsEnvironment } from "./vcs"; -export type ThreadPr = NonNullable; - -export interface ThreadPrPresentation { - readonly number: number; - readonly state: ThreadPr["state"]; - readonly url: string; - /** Compact chip label, e.g. "PR open" / "MR merged". */ - readonly label: string; - readonly textClassName: string; -} - -const PR_STATE_TEXT_CLASS: Record = { - open: "text-emerald-600 dark:text-emerald-400", - merged: "text-violet-600 dark:text-violet-400", - closed: "text-zinc-500 dark:text-zinc-400", -}; - -export function presentThreadPr( - pr: ThreadPr, - provider: VcsStatusResult["sourceControlProvider"] | null | undefined, -): ThreadPrPresentation { - const shortName = resolveChangeRequestPresentation(provider).shortName; - return { - number: pr.number, - state: pr.state, - url: pr.url, - label: `${shortName} ${pr.state}`, - textClassName: PR_STATE_TEXT_CLASS[pr.state], - }; -} +export { + presentThreadPr, + type ThreadPr, + type ThreadPrPresentation, +} from "./thread-pr-presentation"; /** * Live PR status for a thread's branch. Subscriptions are deduplicated per diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index 524540ad83d..85e874b339c 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -13,6 +13,7 @@ import { type EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import * as Option from "effect/Option"; +import { copySorted } from "@t3tools/shared/Array"; import { useProject, useThreadShell } from "../state/entities"; import { useEnvironmentThread } from "../state/threads"; @@ -56,7 +57,7 @@ function threadDetailToShell( projection: OrchestrationV2ThreadProjection, ): EnvironmentThreadShell { const thread = projection.thread; - const runsByOrdinal = projection.runs.toSorted((left, right) => right.ordinal - left.ordinal); + const runsByOrdinal = copySorted(projection.runs, (left, right) => right.ordinal - left.ordinal); const latestRun = runsByOrdinal[0] ?? null; const activeRun = runsByOrdinal.find( diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index f790e71f5cd..06a22754e55 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1,5 +1,6 @@ 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 Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -228,6 +229,7 @@ describe("AssetAccess", () => { const fallbackResult = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, }); + expect(fallbackResult.relativeUrl.endsWith(`/${PROJECT_FAVICON_FALLBACK_MARKER}`)).toBe(true); const fallbackSuffix = fallbackResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); const fallbackSeparatorIndex = fallbackSuffix.indexOf("/"); expect( @@ -235,7 +237,7 @@ describe("AssetAccess", () => { fallbackSuffix.slice(0, fallbackSeparatorIndex), fallbackSuffix.slice(fallbackSeparatorIndex + 1), ), - ).toEqual({ kind: "project-favicon-fallback" }); + ).toBeNull(); }).pipe(Effect.provide(testLayer)), ); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 8d8ecbc2af3..b469e0e315b 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -19,6 +19,7 @@ import { WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, } from "@t3tools/shared/filePreview"; +import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -40,7 +41,6 @@ import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; export const ASSET_ROUTE_PREFIX = "/api/assets"; -export const FALLBACK_PROJECT_FAVICON_SVG = ``; const SIGNING_SECRET_NAME = "asset-access-signing-key"; const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; @@ -91,9 +91,7 @@ const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema); const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson); const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson); -export type ResolvedAsset = - | { readonly kind: "file"; readonly path: string } - | { readonly kind: "project-favicon-fallback" }; +export type ResolvedAsset = { readonly kind: "file"; readonly path: string }; function decodeClaims(encodedPayload: string): AssetClaims | null { try { @@ -326,7 +324,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i relativePath, expiresAt, }; - fileName = relativePath ? path.basename(relativePath) : "favicon.svg"; + fileName = relativePath ? path.basename(relativePath) : PROJECT_FAVICON_FALLBACK_MARKER; break; } } @@ -391,9 +389,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( } if (claims.kind === "project-favicon") { - if (claims.relativePath === null) { - return { kind: "project-favicon-fallback" } satisfies ResolvedAsset; - } + if (claims.relativePath === null) return null; const faviconPath = yield* resolveCanonicalWorkspaceFileForRequest({ workspaceRoot: claims.workspaceRoot, relativePath: claims.relativePath, diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index fc7a9ef13a2..b9bb40f372d 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -25,11 +25,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { OtlpTracer } from "effect/unstable/observability"; import * as ServerConfig from "./config.ts"; -import { - ASSET_ROUTE_PREFIX, - FALLBACK_PROJECT_FAVICON_SVG, - resolveAsset, -} from "./assets/AssetAccess.ts"; +import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; @@ -196,17 +192,6 @@ export const assetRouteLayer = HttpRouter.add( if (!asset) { return HttpServerResponse.text("Not Found", { status: 404 }); } - if (asset.kind === "project-favicon-fallback") { - return HttpServerResponse.text(FALLBACK_PROJECT_FAVICON_SVG, { - status: 200, - contentType: "image/svg+xml", - headers: { - "Cache-Control": "private, max-age=3600", - "X-Content-Type-Options": "nosniff", - }, - }); - } - return yield* HttpServerResponse.file(asset.path, { status: 200, headers: { diff --git a/apps/server/src/imageMime.test.ts b/apps/server/src/imageMime.test.ts index cc1cc2ff776..e87e4dcbd0d 100644 --- a/apps/server/src/imageMime.test.ts +++ b/apps/server/src/imageMime.test.ts @@ -32,6 +32,63 @@ describe("imageMime", () => { }); }); + it("rejects payload with characters outside the base64 alphabet", () => { + expect(parseBase64DataUrl("data:image/png;base64,SGVs!bG8=")).toBeNull(); + expect(parseBase64DataUrl("data:image/png;base64,SGVs,bG8=")).toBeNull(); + }); + + it("rejects structurally malformed base64", () => { + // '=' before the trailing padding position + expect(parseBase64DataUrl("data:image/png;base64,AB=CD===")).toBeNull(); + expect(parseBase64DataUrl("data:image/png;base64,SGV=bG8=")).toBeNull(); + // more than two padding characters + expect(parseBase64DataUrl("data:image/png;base64,SGVsbG8=====AAA")).toBeNull(); + // length not a multiple of 4 + expect(parseBase64DataUrl("data:image/png;base64,SGVsbG8")).toBeNull(); + }); + + it("accepts base64 with one or two trailing padding characters", () => { + expect(parseBase64DataUrl("data:image/png;base64,SGVsbA==")).toEqual({ + mimeType: "image/png", + base64: "SGVsbA==", + }); + expect(parseBase64DataUrl("data:image/png;base64,SGVsbG8h")).toEqual({ + mimeType: "image/png", + base64: "SGVsbG8h", + }); + }); + + it("rejects empty and whitespace-only payloads", () => { + expect(parseBase64DataUrl("data:image/png;base64,")).toBeNull(); + expect(parseBase64DataUrl("data:image/png;base64, \r\n")).toBeNull(); + }); + + it("parses a case-insensitive scheme and mime type", () => { + expect(parseBase64DataUrl("DATA:IMAGE/PNG;BASE64,SGVsbG8=")).toEqual({ + mimeType: "image/png", + base64: "SGVsbG8=", + }); + }); + + it("parses a multi-megabyte payload from a deep call stack", () => { + // Regression: matching the payload with a regex borrowed the JS call + // stack, so a ~10 MB image parsed inside fiber execution threw + // "RangeError: Maximum call stack size exceeded". + const dataUrl = `data:image/png;base64,${"A".repeat(14_000_000)}`; + const atDepth = (depth: number): ReturnType => + depth === 0 ? parseBase64DataUrl(dataUrl) : atDepth(depth - 1); + const findMaxDepth = (depth: number): number => { + try { + return findMaxDepth(depth + 1); + } catch { + return depth; + } + }; + const result = atDepth(Math.floor(findMaxDepth(0) * 0.85)); + expect(result?.mimeType).toBe("image/png"); + expect(result?.base64.length).toBe(14_000_000); + }); + it("does not read inherited keys from mime extension map", () => { expect(inferImageExtension({ mimeType: "constructor" })).toBe(".bin"); }); diff --git a/apps/server/src/imageMime.ts b/apps/server/src/imageMime.ts index c8761285abe..66ce6096e85 100644 --- a/apps/server/src/imageMime.ts +++ b/apps/server/src/imageMime.ts @@ -29,17 +29,43 @@ export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([ ".webp", ]); +// Whether `code` is a character the base64 payload may contain, aside from +// the whitespace handled separately below. +function isBase64Char(code: number): boolean { + return ( + (code >= 0x61 && code <= 0x7a) || // a-z + (code >= 0x41 && code <= 0x5a) || // A-Z + (code >= 0x30 && code <= 0x39) || // 0-9 + code === 0x2b || // + + code === 0x2f || // / + code === 0x3d // = + ); +} + +function isBase64Whitespace(code: number): boolean { + return code === 0x0d || code === 0x0a || code === 0x20; // \r \n space +} + +// Data URLs carry the full image payload, so this parser must never run a +// regex across the payload: V8's regex engine borrows the JS call stack, and +// matching a multi-megabyte string from a deep call stack (e.g. inside fiber +// execution) throws "Maximum call stack size exceeded". export function parseBase64DataUrl( dataUrl: string, ): { readonly mimeType: string; readonly base64: string } | null { - const match = /^data:([^,]+),([a-z0-9+/=\r\n ]+)$/i.exec(dataUrl.trim()); - if (!match) return null; + const trimmed = dataUrl.trim(); + if (trimmed.slice(0, 5).toLowerCase() !== "data:") return null; + + const commaIndex = trimmed.indexOf(","); + if (commaIndex === -1) return null; + const header = trimmed.slice(5, commaIndex); + if (header.length === 0) return null; const headerParts: Array = []; - for (const part of (match[1] ?? "").split(";")) { - const trimmed = part.trim(); - if (trimmed.length > 0) { - headerParts.push(trimmed); + for (const part of header.split(";")) { + const partTrimmed = part.trim(); + if (partTrimmed.length > 0) { + headerParts.push(partTrimmed); } } if (headerParts.length < 2) { @@ -51,8 +77,37 @@ export function parseBase64DataUrl( } const mimeType = headerParts[0]?.toLowerCase(); - const base64 = match[2]?.replace(/\s+/g, ""); - if (!mimeType || !base64) return null; + if (!mimeType) return null; + + const payload = trimmed.slice(commaIndex + 1); + const runs: Array = []; + let runStart = -1; + for (let index = 0; index < payload.length; index += 1) { + const code = payload.charCodeAt(index); + if (isBase64Char(code)) { + if (runStart === -1) runStart = index; + continue; + } + if (!isBase64Whitespace(code)) return null; + if (runStart !== -1) { + runs.push(payload.slice(runStart, index)); + runStart = -1; + } + } + if (runStart !== -1) { + runs.push(payload.slice(runStart)); + } + const base64 = runs.length === 1 ? runs[0]! : runs.join(""); + if (base64.length === 0 || base64.length % 4 !== 0) return null; + const firstPad = base64.indexOf("="); + if (firstPad !== -1) { + // '=' is only valid as one or two trailing padding characters; Node's + // decoder would otherwise silently truncate at the first '='. + if (base64.length - firstPad > 2) return null; + for (let index = firstPad; index < base64.length; index += 1) { + if (base64.charCodeAt(index) !== 0x3d) return null; + } + } return { mimeType, base64 }; } diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index a51ad20afbe..2eef6ac8416 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -411,6 +411,28 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(makeKeybindingsLayer())), ); + it.effect("replacing with a rule that already exists elsewhere does not duplicate it", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "mod+r", command: "script.run-tests.run" }, + { key: "mod+alt+r", command: "script.run-tests.run" }, + ]); + yield* Effect.gen(function* () { + const keybindings = yield* Keybindings.Keybindings; + return yield* keybindings.upsertKeybindingRule({ + key: "mod+alt+r", + command: "script.run-tests.run", + replace: { key: "mod+r", command: "script.run-tests.run" }, + }); + }); + + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); + const persistedView = persisted.map(({ key, command }) => ({ key, command })); + assert.deepEqual(persistedView, [{ key: "mod+alt+r", command: "script.run-tests.run" }]); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + it.effect("removes only the targeted custom keybinding", () => Effect.gen(function* () { const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 5ddae4943f8..18a78fe3623 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -644,7 +644,9 @@ const make = Effect.gen(function* () { const nextConfig = [ ...customConfig.filter((entry) => { if (replaceTarget) { - return !isSameKeybindingRule(entry, replaceTarget); + return ( + !isSameKeybindingRule(entry, replaceTarget) && !isSameKeybindingRule(entry, rule) + ); } return !isSameKeybindingRule(entry, rule); }), diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index c53fa287109..85108db6836 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -1,3 +1,4 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { @@ -82,7 +83,10 @@ function makeMockRuntime(input: { authMethodId: "test", }).pipe( Layer.provide( - Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + Layer.mergeAll( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + NodeCrypto.layer, + ), ), ), ); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts index 5b3c51f7874..698771591f9 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts @@ -3,6 +3,7 @@ import { ProviderReplayEntry, type ProviderReplayTranscript, } from "@t3tools/contracts"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -179,7 +180,10 @@ export function makeAcpReplayRuntime(input: { authMethodId: "replay", }).pipe( Layer.provide( - Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + Layer.mergeAll( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + NodeCrypto.layer, + ), ), ), ); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index acb2a48d575..050aba1427b 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -1,6 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -89,6 +90,7 @@ describe("AcpRegistryAdapterV2", () => { it.effect("opens a real ACP child process resolved from registry configuration", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocatorV2; const path = yield* Path.Path; @@ -113,6 +115,7 @@ describe("AcpRegistryAdapterV2", () => { T3_ACP_SESSION_LIFECYCLE: "1", }, childProcessSpawner, + crypto, fileSystem, idAllocator, resolver: { diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts index 93cbc7f93f3..a2b6f5d8d78 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.testkit.ts @@ -1,5 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { AcpRegistrySettings } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -41,6 +42,7 @@ export function makeAcpRegistryProviderAdapterRegistryReplayLayer(transcript: Ac const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const crypto = yield* Crypto.Crypto; const idAllocator = yield* IdAllocatorV2; const serverConfig = yield* ServerConfig; const replayDir = yield* fileSystem @@ -57,6 +59,7 @@ export function makeAcpRegistryProviderAdapterRegistryReplayLayer(transcript: Ac settings: REPLAY_SETTINGS, environment: {}, childProcessSpawner, + crypto, fileSystem, idAllocator, resolver: { diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts index 66e64d94d19..c7377657c30 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts @@ -49,6 +49,7 @@ export interface AcpRegistryAdapterV2Options { readonly settings: AcpRegistrySettings; readonly environment: NodeJS.ProcessEnv; readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly crypto: Crypto.Crypto; readonly fileSystem: FileSystem.FileSystem; readonly idAllocator: IdAllocatorV2["Service"]; readonly resolver: AcpRegistryResolverShape; @@ -91,7 +92,10 @@ function makeAcpRegistryRuntime(options: AcpRegistryAdapterV2Options) { ...(options.settings.authMethodId ? { authMethodId: options.settings.authMethodId } : {}), }).pipe( Layer.provide( - Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, options.childProcessSpawner), + Layer.mergeAll( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, options.childProcessSpawner), + Layer.succeed(Crypto.Crypto, options.crypto), + ), ), ), ); @@ -139,6 +143,7 @@ export const AcpRegistryAdapterV2Driver: ProviderAdapterDriver< function* (input: ProviderAdapterDriverCreateInput) { const hostEnvironment = yield* HostProcessEnvironment; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocatorV2; const providerEventLoggers = yield* ProviderEventLoggers; @@ -152,6 +157,7 @@ export const AcpRegistryAdapterV2Driver: ProviderAdapterDriver< settings: { ...input.config, enabled: input.enabled }, environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), childProcessSpawner, + crypto, fileSystem, idAllocator, resolver, diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts index 68834a71260..4c6e41c4999 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.testkit.ts @@ -1,5 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { GrokSettings } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -34,6 +35,7 @@ export function makeGrokProviderAdapterRegistryReplayLayer(transcript: AcpReplay const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const crypto = yield* Crypto.Crypto; const idAllocator = yield* IdAllocatorV2; const serverConfig = yield* ServerConfig; const replayDir = yield* fileSystem @@ -50,6 +52,7 @@ export function makeGrokProviderAdapterRegistryReplayLayer(transcript: AcpReplay settings: DEFAULT_GROK_SETTINGS, environment: {}, childProcessSpawner, + crypto, fileSystem, idAllocator, serverConfig, diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts index f74db916d83..a610b1d851f 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts @@ -84,6 +84,7 @@ export interface GrokAdapterV2Options { readonly settings: GrokSettings; readonly environment: NodeJS.ProcessEnv; readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly crypto: Crypto.Crypto; readonly fileSystem: FileSystem.FileSystem; readonly idAllocator: IdAllocatorV2["Service"]; readonly serverConfig: ServerConfig["Service"]; @@ -141,7 +142,7 @@ export function makeGrokAdapterV2(options: GrokAdapterV2Options) { grokSettings: options.settings, environment: options.environment, childProcessSpawner: options.childProcessSpawner, - })), + }).pipe(Effect.provideService(Crypto.Crypto, options.crypto))), registerExtensions: registerGrokAcpExtensions, extractSubagentUpdate: extractXAiAcpSubagentUpdate, ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }), @@ -172,6 +173,7 @@ export const GrokAdapterV2Driver: ProviderAdapterDriver) { const hostEnvironment = yield* HostProcessEnvironment; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocatorV2; const providerEventLoggers = yield* ProviderEventLoggers; @@ -182,6 +184,7 @@ export const GrokAdapterV2Driver: ProviderAdapterDriver Stream.Stream; + /** + * Like `stream`, but inserts a catch-up-complete signal after the finite + * replay window and before live events. Used by thread resume when clients + * request a completion marker. + */ + readonly streamWithCatchUpComplete: (input: { + readonly threadId?: ThreadId; + readonly afterSequence: number; + }) => Stream.Stream< + | { readonly kind: "stored"; readonly stored: OrchestrationV2StoredEvent } + | { readonly kind: "catch-up-complete" }, + EventSinkV2Error + >; readonly latestSequence: (input?: { readonly threadId?: ThreadId; }) => Effect.Effect; @@ -463,6 +476,43 @@ const baseLayer: Layer.Layer< }), ); + const streamWithCatchUpComplete = (input: { + readonly threadId?: ThreadId; + readonly afterSequence: number; + }) => + Stream.unwrap( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(liveEvents); + const highWater = yield* eventStore.latestSequence(); + const afterSequence = input.afterSequence; + const replay = catchUp({ + afterSequence, + throughSequence: highWater, + ...(input.threadId === undefined ? {} : { threadId: input.threadId }), + }).pipe(Stream.map((stored) => ({ kind: "stored" as const, stored }))); + const live = Stream.fromSubscription(subscription).pipe( + Stream.filter((stored) => stored.sequence > Math.max(highWater, afterSequence)), + Stream.filter( + (stored) => input.threadId === undefined || stored.event.threadId === input.threadId, + ), + Stream.map((stored) => ({ kind: "stored" as const, stored })), + ); + return Stream.concat( + replay, + Stream.concat(Stream.succeed({ kind: "catch-up-complete" as const }), live), + ); + }), + ).pipe( + Stream.mapError( + (cause) => + new EventSinkStreamError({ + ...(input.threadId === undefined ? {} : { threadId: input.threadId }), + afterSequence: input.afterSequence, + cause, + }), + ), + ); + return EventSinkV2.of({ write: (input) => writeEffect({ ...input, effects: [] }).pipe( @@ -532,6 +582,7 @@ const baseLayer: Layer.Layer< }), ), ), + streamWithCatchUpComplete, latestSequence: (input) => eventStore.latestSequence(input).pipe( Effect.mapError( diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index b10e370cc5c..5ba9a39d196 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -159,6 +159,14 @@ export interface OrchestratorV2Shape { readonly threadId?: ThreadId; readonly afterSequence?: number; }) => Stream.Stream; + readonly streamResumeWithCatchUpComplete: (input: { + readonly threadId: ThreadId; + readonly afterSequence: number; + }) => Stream.Stream< + | { readonly kind: "stored"; readonly stored: OrchestrationV2StoredEvent } + | { readonly kind: "catch-up-complete" }, + OrchestratorV2Error + >; readonly streamDomainEvents: Stream.Stream; } @@ -5228,6 +5236,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ), + streamResumeWithCatchUpComplete: (input) => + eventSink + .streamWithCatchUpComplete({ + threadId: input.threadId, + afterSequence: input.afterSequence, + }) + .pipe( + Stream.mapError( + (cause) => + new OrchestratorDomainEventStreamError({ + cause, + }), + ), + ), streamDomainEvents: eventSink.stream().pipe( Stream.map((stored) => stored.event), Stream.mapError( @@ -5314,6 +5336,12 @@ export const layerUnavailable: Layer.Layer = Layer.succeed( cause: "Orchestration V2 live runtime is not configured.", }), ), + streamResumeWithCatchUpComplete: () => + Stream.fail( + new OrchestratorDomainEventStreamError({ + cause: "Orchestration V2 live runtime is not configured.", + }), + ), streamDomainEvents: Stream.fail( new OrchestratorDomainEventStreamError({ cause: "Orchestration V2 live runtime is not configured.", diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 7a4301980e5..76c2b67516b 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -163,6 +163,7 @@ export interface ThreadManagementServiceShape { readonly getThreadEventSequence: OrchestratorV2["Service"]["getThreadEventSequence"]; readonly streamStoredEvents: OrchestratorV2["Service"]["streamStoredEvents"]; readonly streamStoredEventsFrom: OrchestratorV2["Service"]["streamStoredEventsFrom"]; + readonly streamResumeWithCatchUpComplete: OrchestratorV2["Service"]["streamResumeWithCatchUpComplete"]; readonly streamDomainEvents: OrchestratorV2["Service"]["streamDomainEvents"]; } @@ -462,6 +463,7 @@ const make = Effect.gen(function* () { getThreadEventSequence: orchestrator.getThreadEventSequence, streamStoredEvents: orchestrator.streamStoredEvents, streamStoredEventsFrom: orchestrator.streamStoredEventsFrom, + streamResumeWithCatchUpComplete: orchestrator.streamResumeWithCatchUpComplete, streamDomainEvents: orchestrator.streamDomainEvents, }); }); diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index a0cc0eb38a0..8ab1b537329 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -5,6 +5,7 @@ import type { OrchestrationReadModel, OrchestrationThread, } from "@t3tools/contracts/legacy-orchestration"; +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; import * as Effect from "effect/Effect"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; @@ -70,6 +71,30 @@ export function requireProjectAbsent(input: { ); } +export function requireActiveProjectWorkspaceRootAbsent(input: { + readonly readModel: OrchestrationReadModel; + readonly command: OrchestrationCommand; + readonly workspaceRoot: string; + readonly exceptProjectId?: ProjectId; +}): Effect.Effect { + const normalizedWorkspaceRoot = normalizeProjectPathForComparison(input.workspaceRoot); + const existingProject = input.readModel.projects.find( + (project) => + project.deletedAt === null && + normalizeProjectPathForComparison(project.workspaceRoot) === normalizedWorkspaceRoot && + project.id !== input.exceptProjectId, + ); + if (existingProject === undefined) { + return Effect.void; + } + return Effect.fail( + invariantError( + input.command.type, + `Active project '${existingProject.id}' already exists for workspace root '${normalizedWorkspaceRoot}'.`, + ), + ); +} + export function requireThread(input: { readonly readModel: OrchestrationReadModel; readonly command: OrchestrationCommand; diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 4638b116e0b..e5fd5d655c9 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -12,6 +12,7 @@ import type * as PlatformError from "effect/PlatformError"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; import { listThreadsByProjectId, + requireActiveProjectWorkspaceRootAbsent, requireProject, requireProjectAbsent, requireThread, @@ -111,6 +112,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, projectId: command.projectId, }); + yield* requireActiveProjectWorkspaceRootAbsent({ + readModel, + command, + workspaceRoot: command.workspaceRoot, + exceptProjectId: command.projectId, + }); return { ...(yield* withEventBase({ @@ -138,6 +145,14 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, projectId: command.projectId, }); + if (command.workspaceRoot !== undefined) { + yield* requireActiveProjectWorkspaceRootAbsent({ + readModel, + command, + workspaceRoot: command.workspaceRoot, + exceptProjectId: command.projectId, + }); + } const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ @@ -313,11 +328,17 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.meta.update": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); + const branch = + command.branch !== undefined && + command.expectedBranch !== undefined && + thread.branch !== command.expectedBranch + ? thread.branch + : command.branch; const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ @@ -333,7 +354,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.modelSelection !== undefined ? { modelSelection: command.modelSelection } : {}), - ...(command.branch !== undefined ? { branch: command.branch } : {}), + ...(branch !== undefined ? { branch } : {}), ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), updatedAt: occurredAt, }, diff --git a/apps/server/src/persistence/ProviderSessionRuntime.ts b/apps/server/src/persistence/ProviderSessionRuntime.ts index 1cb31d99c41..bffdac594ff 100644 --- a/apps/server/src/persistence/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/ProviderSessionRuntime.ts @@ -1,7 +1,9 @@ +import * as Arr from "effect/Array"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -275,18 +277,30 @@ export const make = Effect.gen(function* () { ), ), Effect.flatMap((rows) => + // Skip rows that no longer decode (e.g. written by an older build) + // instead of failing the whole list — one stale row must not disable + // every consumer that enumerates sessions, such as the reaper. Effect.forEach(rows, (row) => decodeRuntimeRow(row).pipe( - Effect.mapError((cause) => - PersistenceDecodeError.fromSchemaError( - "ProviderSessionRuntimeRepository.list:decodeRows", - cause, - { threadId: row.threadId }, - ), + Effect.map(Option.some), + Effect.catch((cause) => + Effect.logWarning("provider.session.runtime.row-skipped", { + threadId: row.threadId, + error: PersistenceDecodeError.fromSchemaError( + "ProviderSessionRuntimeRepository.list:decodeRows", + cause, + { threadId: row.threadId }, + ).message, + }).pipe(Effect.as(Option.none())), ), ), ), ), + Effect.map((decoded) => + Arr.filterMap(decoded, (row) => + Option.isSome(row) ? Result.succeed(row.value) : Result.failVoid, + ), + ), ); const deleteByThreadId: ProviderSessionRuntimeRepository["Service"]["deleteByThreadId"] = ( diff --git a/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts b/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts index f7425200fd1..379b06e2a22 100644 --- a/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts +++ b/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts @@ -182,7 +182,7 @@ describe("persistence error correlation", () => { }).pipe(Effect.provide(authPairingLinkLayer)), ); - it.effect("correlates provider runtime SQL and per-row decode failures by thread", () => + it.effect("skips undecodable provider runtime rows and correlates SQL failures by thread", () => Effect.gen(function* () { const runtimes = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const sql = yield* SqlClient.SqlClient; @@ -215,16 +215,24 @@ describe("persistence error correlation", () => { ) `; - const decodeError = yield* Effect.flip(runtimes.list()); - assert.instanceOf(decodeError, PersistenceErrors.PersistenceDecodeError); - assert.deepStrictEqual(decodeError.correlation, { threadId }); - assert.equal( - decodeError.message, - `Decode error in ProviderSessionRuntimeRepository.list:decodeRows: ${decodeError.issue}`, + const validThreadId = ThreadId.make("thread-valid"); + yield* runtimes.upsert({ + threadId: validThreadId, + providerName: "codex", + providerInstanceId: null, + adapterKey: "codex", + runtimeMode: "full-access", + status: "running", + lastSeenAt, + resumeCursor: null, + runtimePayload: null, + }); + + const listed = yield* runtimes.list(); + assert.deepStrictEqual( + listed.map((runtime) => runtime.threadId), + [validThreadId], ); - assert.notInclude(decodeError.issue, runtimePayload); - assert.notInclude(decodeError.message, runtimePayload); - assert.notInclude(decodeError.message, lastSeenAt); yield* sql`DROP TABLE provider_session_runtime`; const sqlFailure = yield* Effect.flip( diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 1b5bd2fe290..f3a659c75b4 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -1,3 +1,5 @@ +import type { ProviderInteractionMode } from "@t3tools/contracts"; + const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = ` ## T3 Code collaborative browser @@ -155,3 +157,26 @@ In Default mode, strongly prefer making reasonable assumptions and executing the ${T3_CODE_BROWSER_TOOL_INSTRUCTIONS} ${T3_CODE_THREAD_ORCHESTRATION_INSTRUCTIONS} `; + +export interface CodexRuntimeInfo { + readonly model: string; + readonly reasoningEffort: string; +} + +// Values come from trusted config, but keep the block single-line regardless. +function toSingleLine(value: string): string { + return value.replaceAll(/\s+/g, " ").trim(); +} + +export function buildCodexDeveloperInstructions( + interactionMode: ProviderInteractionMode, + runtime: CodexRuntimeInfo, +): string { + const base = + interactionMode === "plan" + ? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS + : CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS; + return `${base} + +In case you're asked: you are running in T3 Code through the Codex harness, as ${toSingleLine(runtime.model)} with ${toSingleLine(runtime.reasoningEffort)} reasoning effort. No need to mention this otherwise.`; +} diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index c4b95525b1e..6934d799953 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -121,6 +121,7 @@ export const ClaudeDriver: ProviderDriver = { Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; + const { cwd } = yield* ServerConfig; const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const processEnv = mergeProviderInstanceEnvironment(environment); @@ -167,11 +168,11 @@ export const ClaudeDriver: ProviderDriver = { capacity: 1, timeToLive: CAPABILITIES_PROBE_TTL, lookup: () => - probeClaudeCapabilities(effectiveConfig, processEnv).pipe( + probeClaudeCapabilities(effectiveConfig, processEnv, cwd).pipe( Effect.provideService(Path.Path, path), ), }); - const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig); + const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); const checkProvider = checkClaudeProviderStatus( effectiveConfig, diff --git a/apps/server/src/provider/Drivers/ClaudeHome.test.ts b/apps/server/src/provider/Drivers/ClaudeHome.test.ts index 87298f80125..a7ff41fc89e 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.test.ts @@ -31,14 +31,23 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { const resolved = path.resolve(NodeOS.homedir(), ".claude-work"); expect(yield* resolveClaudeHomePath({ homePath })).toBe(resolved); - expect((yield* makeClaudeEnvironment({ homePath })).HOME).toBe(resolved); + expect((yield* makeClaudeEnvironment({ homePath })).CLAUDE_CONFIG_DIR).toBe(resolved); expect(yield* makeClaudeContinuationGroupKey({ homePath })).toBe(`claude:home:${resolved}`); expect(yield* makeClaudeCapabilitiesCacheKey({ binaryPath: "claude", homePath })).toBe( - `claude\0${resolved}`, + `claude\0${resolved}\0`, ); }), ); + it.effect("separates capability probes by cwd", () => + Effect.gen(function* () { + const config = { binaryPath: "claude", homePath: "" }; + const first = yield* makeClaudeCapabilitiesCacheKey(config, "/repo-a"); + const second = yield* makeClaudeCapabilitiesCacheKey(config, "/repo-b"); + expect(first).not.toBe(second); + }), + ); + it.effect("keeps continuation compatible across instances with the same Claude HOME", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index 65c74f9764a..b3ef22b640c 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -24,7 +24,13 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function const resolvedHomePath = yield* resolveClaudeHomePath(config); return { ...resolvedBaseEnv, - HOME: resolvedHomePath, + // Isolate this instance's config via CLAUDE_CONFIG_DIR rather than HOME. + // Overriding HOME also relocates the macOS login keychain lookup + // ($HOME/Library/Keychains), so the spawned CLI can't find its stored + // OAuth credentials and reports "Not logged in". CLAUDE_CONFIG_DIR points + // Claude Code at its config dir directly while leaving HOME (and the + // keychain) intact. + CLAUDE_CONFIG_DIR: resolvedHomePath, }; }); @@ -38,8 +44,9 @@ export const makeClaudeContinuationGroupKey = Effect.fn("makeClaudeContinuationG export const makeClaudeCapabilitiesCacheKey = Effect.fn("makeClaudeCapabilitiesCacheKey")( function* ( config: Pick, + cwd?: string, ): Effect.fn.Return { const resolvedHomePath = yield* resolveClaudeHomePath(config); - return `${config.binaryPath}\0${resolvedHomePath}`; + return `${config.binaryPath}\0${resolvedHomePath}\0${cwd ?? ""}`; }, ); diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index ec78b1665ef..03c717abb14 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -114,6 +114,9 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { const sessionsTarget = yield* fileSystem.readLink(path.join(shadowHome, "sessions")); const configTarget = yield* fileSystem.readLink(path.join(shadowHome, "config.toml")); + const mcpOauthLocksTarget = yield* fileSystem.readLink( + path.join(shadowHome, "mcp-oauth-locks"), + ); const modelsCacheExists = yield* fileSystem.exists( path.join(shadowHome, "models_cache.json"), ); @@ -124,12 +127,45 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { expect(sessionsTarget).toBe(path.join(sharedHome, "sessions")); expect(configTarget).toBe(path.join(sharedHome, "config.toml")); + expect(mcpOauthLocksTarget).toBe(path.join(sharedHome, "mcp-oauth-locks")); expect(modelsCacheExists).toBe(false); expect(authLinkResult._tag).toBe("Failure"); expect(authContents).toContain("shadow"); }), ); + it.effect("replaces Codex-created local MCP OAuth locks with the shared lock directory", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + const sharedLocks = path.join(sharedHome, "mcp-oauth-locks"); + const shadowLocks = path.join(shadowHome, "mcp-oauth-locks"); + + yield* writeTextFile(path.join(sharedLocks, "file-store.lock"), ""); + yield* writeTextFile(path.join(shadowLocks, "file-store.lock"), ""); + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + + yield* materializeCodexShadowHome(layout); + + const locksTarget = yield* fileSystem.readLink(shadowLocks); + const sharedLockExists = yield* fileSystem.exists( + path.join(sharedLocks, "file-store.lock"), + ); + + expect(locksTarget).toBe(sharedLocks); + expect(sharedLockExists).toBe(true); + }), + ); + it.effect("accepts Codex-created shadow-local runtime directories", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index d2d09e9d844..92e923c0b66 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -26,10 +26,12 @@ const KNOWN_SHARED_DIRECTORIES = [ "plugins", "cache", "logs", + "mcp-oauth-locks", ] as const; const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "models_cache.json"]); const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp"]); +const REPLACEABLE_SHARED_RUNTIME_DIRECTORIES = new Set(["mcp-oauth-locks"]); function resolveHomePath(path: Path.Path, value: string | undefined): string { const expanded = @@ -225,16 +227,6 @@ const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (inpu linkPath: link, }); - if (state._tag === "NotSymlink") { - return yield* new CodexShadowHomeEntryConflictError({ - sharedHomePath: input.sharedHomePath, - effectiveHomePath: input.effectiveHomePath, - entryName: input.entryName, - linkPath: link, - targetPath: target, - }); - } - const createLink = input.fileSystem.symlink(target, link).pipe( Effect.catchTags({ PlatformError: (cause) => @@ -250,6 +242,33 @@ const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (inpu }), ); + if (state._tag === "NotSymlink") { + if (!REPLACEABLE_SHARED_RUNTIME_DIRECTORIES.has(input.entryName)) { + return yield* new CodexShadowHomeEntryConflictError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + entryName: input.entryName, + linkPath: link, + targetPath: target, + }); + } + + yield* input.fileSystem.remove(link, { recursive: true }).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + operation: "remove", + path: link, + entryName: input.entryName, + cause, + }), + }), + ); + return yield* createLink; + } + if (state._tag === "Missing") { return yield* createLink; } diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index f5cc9528cdd..c152fa2efe1 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -86,6 +86,7 @@ export const GrokDriver: ProviderDriver = { defaultConfig: (): GrokSettings => decodeGrokSettings({}), create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; @@ -128,6 +129,7 @@ export const GrokDriver: ProviderDriver = { const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv).pipe( Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 59b7d0464de..af8f5d6704b 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -583,6 +583,7 @@ function waitForAbortSignal(signal: AbortSignal): Promise { const probeClaudeCapabilities = ( claudeSettings: ClaudeSettings, environment?: NodeJS.ProcessEnv, + cwd?: string, ) => { const abort = new AbortController(); return Effect.gen(function* () { @@ -602,6 +603,7 @@ const probeClaudeCapabilities = ( settingSources: ["user", "project", "local"], allowedTools: [], env: claudeEnvironment, + ...(cwd ? { cwd } : {}), stderr: () => {}, }, }); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 811c362f1e0..a2182cfb73c 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -56,6 +56,8 @@ const REASONING_EFFORT_LABELS: Readonly> = { medium: "Medium", high: "High", xhigh: "Extra High", + max: "Max", + ultra: "Ultra", }; const DEFAULT_SERVICE_TIER_ID = "default"; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 8aeacd870cc..1527072dae7 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -4,11 +4,12 @@ import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { describe } from "vite-plus/test"; -import { ThreadId } from "@t3tools/contracts"; +import { DEFAULT_MODEL, ThreadId } from "@t3tools/contracts"; import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexRpc from "effect-codex-app-server/rpc"; import { + buildCodexDeveloperInstructions, CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, } from "../CodexDeveloperInstructions.ts"; @@ -118,7 +119,10 @@ describe("buildTurnStartParams", () => { settings: { model: "gpt-5.3-codex", reasoning_effort: "medium", - developer_instructions: CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, + developer_instructions: buildCodexDeveloperInstructions("plan", { + model: "gpt-5.3-codex", + reasoningEffort: "medium", + }), }, }, }); @@ -163,12 +167,31 @@ describe("buildTurnStartParams", () => { settings: { model: "gpt-5.3-codex", reasoning_effort: "medium", - developer_instructions: CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, + developer_instructions: buildCodexDeveloperInstructions("default", { + model: "gpt-5.3-codex", + reasoningEffort: "medium", + }), }, }, }); }); + it("reports the same fallback model and effort in settings and instructions", () => { + const params = Effect.runSync( + buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + prompt: "Go", + interactionMode: "default", + }), + ); + + const settings = params.collaborationMode?.settings; + NodeAssert.equal(settings?.model, DEFAULT_MODEL); + NodeAssert.equal(settings?.reasoning_effort, "medium"); + NodeAssert.ok(settings?.developer_instructions?.includes(`as ${DEFAULT_MODEL} with medium`)); + }); + it("omits collaboration mode when interaction mode is absent", () => { const params = Effect.runSync( buildTurnStartParams({ @@ -194,6 +217,53 @@ describe("buildTurnStartParams", () => { }); }); +describe("buildCodexDeveloperInstructions", () => { + it("appends runtime info after the mode instructions", () => { + const instructions = buildCodexDeveloperInstructions("default", { + model: "gpt-5.3-codex", + reasoningEffort: "high", + }); + + NodeAssert.ok(instructions.startsWith(CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS)); + NodeAssert.match(instructions, /T3 Code/); + NodeAssert.match(instructions, /Codex harness/); + NodeAssert.match(instructions, /as gpt-5\.3-codex with high reasoning effort/); + }); + + it("includes runtime info alongside plan mode instructions", () => { + const instructions = buildCodexDeveloperInstructions("plan", { + model: "gpt-5.3-codex", + reasoningEffort: "medium", + }); + + NodeAssert.ok(instructions.startsWith(CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS)); + NodeAssert.match(instructions, /as gpt-5\.3-codex with medium reasoning effort/); + }); + + it("varies with the model and effort of each turn", () => { + const first = buildCodexDeveloperInstructions("default", { + model: "gpt-5.3-codex", + reasoningEffort: "medium", + }); + const second = buildCodexDeveloperInstructions("default", { + model: "gpt-5.4", + reasoningEffort: "high", + }); + + NodeAssert.notEqual(first, second); + }); + + it("flattens multiline metadata into single-line runtime info", () => { + const instructions = buildCodexDeveloperInstructions("default", { + model: "gpt\n5.3\ncodex", + reasoningEffort: " high\neffort ", + }); + + NodeAssert.match(instructions, /as gpt 5\.3 codex with high effort reasoning effort/); + NodeAssert.doesNotMatch(instructions, /[^<]*\n/); + }); +}); + describe("T3 browser developer instructions", () => { it("prefers the product-native preview tools in both collaboration modes", () => { for (const instructions of [ diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 99ac498f0c3..91938b5355d 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -37,10 +37,7 @@ import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { buildCodexInitializeParams } from "./CodexProvider.ts"; import { expandHomePath } from "../../pathExpansion.ts"; -import { - CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, - CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, -} from "../CodexDeveloperInstructions.ts"; +import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnStartResponse); const PROVIDER = ProviderDriverKind.make("codex"); @@ -331,15 +328,16 @@ function buildCodexCollaborationMode(input: { return undefined; } const model = normalizeCodexModelSlug(input.model) ?? DEFAULT_MODEL; + const reasoningEffort = input.effort ?? "medium"; return { mode: input.interactionMode, settings: { model, - reasoning_effort: input.effort ?? "medium", - developer_instructions: - input.interactionMode === "plan" - ? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS - : CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, + reasoning_effort: reasoningEffort, + developer_instructions: buildCodexDeveloperInstructions(input.interactionMode, { + model, + reasoningEffort, + }), }, }; } diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index c13abc1115e..502c16f9b70 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -7,6 +7,7 @@ import { } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; import { causeErrorTag } from "@t3tools/shared/observability"; +import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -166,7 +167,11 @@ const runGrokVersionCommand = ( export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, -): Effect.fn.Return { +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto +> { const checkedAt = DateTime.formatIso(yield* DateTime.now); const fallbackModels = grokModelsFromSettings(grokSettings.customModels); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 842bb4f9629..f86b2bdf642 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1396,7 +1396,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge( mockCommandSpawnerLayer((command, args) => { - if (command === "agent") { + if (command === "cursor-agent") { cursorSpawned = true; } const joined = args.join(" "); @@ -1719,8 +1719,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); - it.effect("runs Claude status probes with the configured Claude HOME", () => { - const claudeHome = "/tmp/t3code-claude-home"; + it.effect("runs Claude status probes with the configured CLAUDE_CONFIG_DIR", () => { + const claudeConfigDir = "/tmp/t3code-claude-home"; const recorded = recordingMockSpawnerLayer((args) => { const joined = args.join(" "); if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; @@ -1737,14 +1737,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te const status = yield* checkClaudeProviderStatus( { ...defaultClaudeSettings, - homePath: claudeHome, + homePath: claudeConfigDir, }, claudeCapabilities(), ); assert.strictEqual(status.status, "ready"); assert.deepStrictEqual( - recorded.commands.map((command) => command.env?.HOME), - [claudeHome], + recorded.commands.map((command) => command.env?.CLAUDE_CONFIG_DIR), + [claudeConfigDir], ); }).pipe(Effect.provide(recorded.layer)); }); diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index d535f1500a5..b09a3170096 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -203,6 +203,43 @@ describe("AcpSessionRuntime", () => { ), ); + it.effect("keeps assistant item IDs unique when a provider session restarts", () => { + const collectFirstAssistantItemId = Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + const started = yield* runtime.start(); + expect(started.sessionId).toBe("mock-session-1"); + + yield* runtime.prompt({ + prompt: [{ type: "text", text: "hi" }], + }); + + const events = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 4))); + const assistantStart = events.find((event) => event._tag === "AssistantItemStarted"); + expect(assistantStart?._tag).toBe("AssistantItemStarted"); + return assistantStart?._tag === "AssistantItemStarted" ? assistantStart.itemId : ""; + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }), + ), + Effect.scoped, + ); + + return Effect.gen(function* () { + const beforeRestart = yield* collectFirstAssistantItemId; + const afterRestart = yield* collectFirstAssistantItemId; + + expect(afterRestart).not.toBe(beforeRestart); + }).pipe(Effect.provide(NodeServices.layer)); + }); + it.effect("drops session updates emitted for a child ACP session", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index d80005633c7..b2b0c5aee4a 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -1,6 +1,7 @@ import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -281,7 +282,7 @@ export class AcpSessionRuntime extends Context.Service< ): Layer.Layer< AcpSessionRuntime, EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto > { return Layer.effect(AcpSessionRuntime, make(options)); } @@ -312,14 +313,24 @@ export const make = ( ): Effect.Effect< AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner | Scope.Scope + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope > => Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const runtimeScope = yield* Scope.Scope; const eventQueue = yield* Queue.unbounded(); const modeStateRef = yield* Ref.make(undefined); const toolCallsRef = yield* Ref.make(new Map()); + const assistantItemRuntimeId = yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to generate an ACP assistant item runtime identifier.", + cause, + }), + ), + ); const assistantSegmentRef = yield* Ref.make({ nextSegmentIndex: 0 }); const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); const startStateRef = yield* Ref.make({ _tag: "NotStarted" }); @@ -429,6 +440,7 @@ export const make = ( modeStateRef, toolCallsRef, assistantSegmentRef, + assistantItemRuntimeId, params: notification, }); }), @@ -991,7 +1003,7 @@ export const layer = ( ): Layer.Layer< AcpSessionRuntime, EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto > => Layer.effect(AcpSessionRuntime, make(options)); function sessionConfigOptionsFromSetup( @@ -1023,12 +1035,14 @@ const handleSessionUpdate = ({ modeStateRef, toolCallsRef, assistantSegmentRef, + assistantItemRuntimeId, params, }: { readonly queue: Queue.Queue; readonly modeStateRef: Ref.Ref; readonly toolCallsRef: Ref.Ref>; readonly assistantSegmentRef: Ref.Ref; + readonly assistantItemRuntimeId: string; readonly params: EffectAcpSchema.SessionNotification; }): Effect.Effect => Effect.gen(function* () { @@ -1076,6 +1090,7 @@ const handleSessionUpdate = ({ queue, assistantSegmentRef, sessionId: params.sessionId, + assistantItemRuntimeId, }); yield* Queue.offer(queue, { ...event, @@ -1113,17 +1128,19 @@ function shouldEmitToolCallUpdate( return previous === undefined || previous.title !== next.title || previous.detail !== next.detail; } -const assistantItemId = (sessionId: string, segmentIndex: number) => - `assistant:${sessionId}:segment:${segmentIndex}`; +const assistantItemId = (sessionId: string, runtimeId: string, segmentIndex: number) => + `assistant:${sessionId}:runtime:${runtimeId}:segment:${segmentIndex}`; const ensureActiveAssistantSegment = ({ queue, assistantSegmentRef, sessionId, + assistantItemRuntimeId, }: { readonly queue: Queue.Queue; readonly assistantSegmentRef: Ref.Ref; readonly sessionId: string; + readonly assistantItemRuntimeId: string; }) => Ref.modify( assistantSegmentRef, @@ -1131,7 +1148,7 @@ const ensureActiveAssistantSegment = ({ if (current.activeItemId) { return [{ itemId: current.activeItemId }, current] as const; } - const itemId = assistantItemId(sessionId, current.nextSegmentIndex); + const itemId = assistantItemId(sessionId, assistantItemRuntimeId, current.nextSegmentIndex); return [ { itemId, diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index 3e6d63a5393..c928b3ed80e 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -1,4 +1,5 @@ import { type GrokSettings, ProviderDriverKind } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Scope from "effect/Scope"; @@ -55,7 +56,7 @@ export const makeGrokAcpRuntime = ( ): Effect.Effect< AcpSessionRuntime.AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, - Scope.Scope + Crypto.Crypto | Scope.Scope > => Effect.gen(function* () { const acpContext = yield* Layer.build( diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 3a1cabc4a27..1cf7e8dffec 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1334,6 +1334,63 @@ it.layer( }), ); + it.effect("strips AppImage runtime env from terminal sessions", () => + Effect.gen(function* () { + const appDir = "/tmp/.mount_T3Codeabc123"; + const { manager, ptyAdapter } = yield* createManager(5, { + env: { + APPIMAGE: "/home/user/T3-Code.AppImage", + APPDIR: appDir, + ARGV0: "/home/user/T3-Code.AppImage", + OWD: "/home/user/project", + PATH: `${appDir}/usr/bin:${appDir}:/usr/local/bin:/usr/bin:/bin`, + LD_LIBRARY_PATH: `${appDir}/usr/lib:/home/user/.local/lib`, + TEST_TERMINAL_KEEP: "keep-me", + }, + }); + yield* manager.open(openInput()); + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; + + // AppImage runtime markers must never reach the PTY — tools inside the + // terminal otherwise resolve against the AppImage mount (e.g. PHP_BINARY + // reporting the AppImage path instead of the real binary). + expect(spawnInput.env.APPIMAGE).toBeUndefined(); + expect(spawnInput.env.APPDIR).toBeUndefined(); + expect(spawnInput.env.ARGV0).toBeUndefined(); + expect(spawnInput.env.OWD).toBeUndefined(); + // PATH/LD_LIBRARY_PATH keep the user's real entries but drop the AppImage + // mount segments that the runtime prepended. + expect(spawnInput.env.PATH).toBe("/usr/local/bin:/usr/bin:/bin"); + expect(spawnInput.env.LD_LIBRARY_PATH).toBe("/home/user/.local/lib"); + // Unrelated host vars still pass through untouched. + expect(spawnInput.env.TEST_TERMINAL_KEEP).toBe("keep-me"); + }), + ); + + it.effect("leaves the environment untouched when not launched from an AppImage", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + env: { + PATH: "/usr/local/bin:/usr/bin:/bin", + LD_LIBRARY_PATH: "/home/user/.local/lib", + // Without APPIMAGE/APPDIR set, OWD is an ordinary variable and must + // not be stripped — only an AppImage launch gives it special meaning. + OWD: "/home/user/keep-this", + }, + }); + yield* manager.open(openInput()); + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; + + expect(spawnInput.env.PATH).toBe("/usr/local/bin:/usr/bin:/bin"); + expect(spawnInput.env.LD_LIBRARY_PATH).toBe("/home/user/.local/lib"); + expect(spawnInput.env.OWD).toBe("/home/user/keep-this"); + }), + ); + it.effect("injects runtime env overrides into spawned terminals", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 6347fdfc64d..caa5106bb9f 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1065,6 +1065,53 @@ function shouldExcludeTerminalEnvKey(key: string): boolean { return TERMINAL_ENV_BLOCKLIST.has(normalizedKey); } +// Marker variables the AppImage runtime injects into the process it launches. +// They describe the AppImage itself, not the user's session, so terminals must +// not inherit them. +const APPIMAGE_RUNTIME_ENV_KEYS = ["APPIMAGE", "APPDIR", "ARGV0", "OWD"] as const; +// PATH-style variables the AppImage runtime prepends with its temporary mount +// (e.g. /tmp/.mount_T3-XXXX/usr/bin). Only the mount segments are dropped; the +// user's real entries are preserved. +const APPIMAGE_PATH_LIKE_ENV_KEYS = ["PATH", "LD_LIBRARY_PATH"] as const; + +function isPathSegmentUnderAppDir(segment: string, appDir: string): boolean { + return segment === appDir || segment.startsWith(`${appDir}/`); +} + +// On Linux AppImage builds the runtime mounts the app under a temporary dir and +// injects APPIMAGE/APPDIR/ARGV0/OWD plus mount entries on PATH/LD_LIBRARY_PATH. +// The integrated terminal inherits the server process environment, so without +// this scrub those leak into the PTY and tools resolve against the AppImage +// mount instead of the user's real environment (e.g. `php` reporting +// PHP_BINARY as the AppImage path). See issue #1699. The scrub is gated on an +// actual AppImage launch so non-AppImage environments are left untouched. +function stripAppImageRuntimeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + if (env.APPIMAGE === undefined && env.APPDIR === undefined) return env; + + const scrubbed: NodeJS.ProcessEnv = { ...env }; + for (const key of APPIMAGE_RUNTIME_ENV_KEYS) { + delete scrubbed[key]; + } + + const appDir = env.APPDIR?.replace(/\/+$/, ""); + if (appDir) { + for (const key of APPIMAGE_PATH_LIKE_ENV_KEYS) { + const value = scrubbed[key]; + if (value === undefined) continue; + const kept = value + .split(":") + .filter((segment) => segment.length > 0 && !isPathSegmentUnderAppDir(segment, appDir)); + if (kept.length > 0) { + scrubbed[key] = kept.join(":"); + } else { + delete scrubbed[key]; + } + } + } + + return scrubbed; +} + function createTerminalSpawnEnv( baseEnv: NodeJS.ProcessEnv, runtimeEnv?: Record | null, @@ -1080,7 +1127,7 @@ function createTerminalSpawnEnv( spawnEnv[key] = value; } } - return spawnEnv; + return stripAppImageRuntimeEnv(spawnEnv); } function normalizedRuntimeEnv( diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index c8fe4ead3be..0f3905a0cb1 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -51,8 +51,8 @@ function makeFakeClaudeBinary(dir: string) { " exit 4", " }", "fi", - 'if [ -n "$T3_FAKE_CLAUDE_HOME_MUST_BE" ] && [ "$HOME" != "$T3_FAKE_CLAUDE_HOME_MUST_BE" ]; then', - ' printf "%s\\n" "HOME was $HOME" >&2', + 'if [ -n "$T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE" ] && [ "$CLAUDE_CONFIG_DIR" != "$T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE" ]; then', + ' printf "%s\\n" "CLAUDE_CONFIG_DIR was $CLAUDE_CONFIG_DIR" >&2', " exit 5", "fi", 'if [ -n "$T3_FAKE_CLAUDE_STDERR" ]; then', @@ -76,7 +76,7 @@ function withFakeClaudeEnv( argsMustContain?: string; argsMustNotContain?: string; stdinMustContain?: string; - homeMustBe?: string; + configDirMustBe?: string; claudeConfig?: Partial; }, effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, @@ -92,7 +92,7 @@ function withFakeClaudeEnv( const previousArgsMustContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; const previousArgsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; const previousStdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; - const previousHomeMustBe = process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; + const previousConfigDirMustBe = process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE; yield* Effect.acquireRelease( Effect.sync(() => { @@ -129,10 +129,10 @@ function withFakeClaudeEnv( delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; } - if (input.homeMustBe !== undefined) { - process.env.T3_FAKE_CLAUDE_HOME_MUST_BE = input.homeMustBe; + if (input.configDirMustBe !== undefined) { + process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE = input.configDirMustBe; } else { - delete process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; + delete process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE; } }), () => @@ -175,10 +175,10 @@ function withFakeClaudeEnv( process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = previousStdinMustContain; } - if (previousHomeMustBe === undefined) { - delete process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; + if (previousConfigDirMustBe === undefined) { + delete process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE; } else { - process.env.T3_FAKE_CLAUDE_HOME_MUST_BE = previousHomeMustBe; + process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE = previousConfigDirMustBe; } }), ); @@ -286,10 +286,10 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { ), ); - it.effect("runs Claude text generation with the configured Claude HOME", () => + it.effect("runs Claude text generation with the configured CLAUDE_CONFIG_DIR", () => Effect.gen(function* () { const path = yield* Path.Path; - const claudeHome = path.join(process.cwd(), ".claude-work-test"); + const claudeConfigDir = path.join(process.cwd(), ".claude-work-test"); return yield* withFakeClaudeEnv( { // @effect-diagnostics-next-line preferSchemaOverJson:off @@ -298,8 +298,8 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { title: "Use Claude home", }, }), - homeMustBe: claudeHome, - claudeConfig: { homePath: claudeHome }, + configDirMustBe: claudeConfigDir, + claudeConfig: { homePath: claudeConfigDir }, }, (textGeneration) => Effect.gen(function* () { diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index 1bb58216305..fd367acdf4c 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -1,3 +1,4 @@ +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -37,6 +38,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, ) { + const crypto = yield* Crypto.Crypto; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const runGrokJson = ({ @@ -65,7 +67,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi childProcessSpawner: commandSpawner, cwd, clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, - }); + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); yield* runtime.handleSessionUpdate((notification) => { const update = notification.update; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index dc58fc2543c..8c627525b4c 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -6,6 +6,9 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Scope from "effect/Scope"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; @@ -20,6 +23,21 @@ const TestLayer = GitVcsDriver.layer.pipe( Layer.provideMerge(NodeServices.layer), ); +const makeNonRepositoryHandle = () => + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.encodeText(Stream.make("fatal: not a git repository")), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + const makeTmpDir = ( prefix = "git-vcs-driver-test-", ): Effect.Effect => @@ -77,7 +95,62 @@ const initRepoWithCommit = ( return { initialBranch }; }); +it.effect("uses stable diagnostics for every parsed non-repository command", () => { + const commands: Array<{ readonly args: ReadonlyArray; readonly lcAll?: string }> = []; + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + if (!ChildProcess.isStandardCommand(command)) { + return assert.fail("expected a standard Git command"); + } + commands.push({ + args: command.args, + ...(command.options.env?.LC_ALL ? { lcAll: command.options.env.LC_ALL } : {}), + }); + return makeNonRepositoryHandle(); + }), + ); + const nodeServicesLayer = Layer.merge( + NodeServices.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const layer = GitVcsDriver.layer.pipe( + Layer.provide(ServerConfigLayer), + Layer.provideMerge(nodeServicesLayer), + ); + + return Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const cwd = "/repo"; + + yield* driver.statusDetailsLocal(cwd); + yield* driver.statusDetailsRemote(cwd, { refreshUpstream: false }); + yield* driver.listRefs({ cwd }); + + assert.deepStrictEqual(commands, [ + { args: ["status", "--porcelain=2", "--branch"], lcAll: "C" }, + { args: ["rev-parse", "--abbrev-ref", "HEAD"], lcAll: "C" }, + { args: ["branch", "--no-color", "--no-column"], lcAll: "C" }, + ]); + }).pipe(Effect.provide(layer)); +}); + it.layer(TestLayer)("GitVcsDriver core integration", (it) => { + describe("process environment", () => { + it.effect("preserves the caller locale for general Git subprocesses", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + + const locale = yield* git( + cwd, + ["-c", 'alias.print-locale=!printf "%s" "$LC_ALL"', "print-locale"], + { LC_ALL: "zh_CN.UTF-8" }, + ); + + assert.equal(locale, "zh_CN.UTF-8"); + }), + ); + }); + describe("structured errors", () => { it.effect("preserves structured spawn context and the platform cause", () => Effect.gen(function* () { @@ -620,6 +693,24 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.notInclude(status, "a.txt"); }), ); + + it.effect("treats selected file paths literally", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* writeTextFile(cwd, "selected[1].txt", "literal\n"); + yield* writeTextFile(cwd, "selected1.txt", "pattern match\n"); + + yield* driver.prepareCommitContext(cwd, ["selected[1].txt"]); + + assert.equal(yield* git(cwd, ["diff", "--cached", "--name-only"]), "selected[1].txt"); + + const status = yield* git(cwd, ["status", "--porcelain"]); + assert.include(status, "?? selected1.txt"); + }), + ); }); describe("remote operations", () => { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index a406cbce549..6293ff7c29c 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -830,6 +830,20 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ); + const executeGitWithStableDiagnostics = ( + operation: string, + cwd: string, + args: readonly string[], + options: ExecuteGitOptions = {}, + ): Effect.Effect => + executeGit(operation, cwd, args, { + ...options, + env: { + ...options.env, + LC_ALL: "C", + }, + }); + const runGit = ( operation: string, cwd: string, @@ -1184,7 +1198,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); const readStatusDetailsRemote = Effect.fn("readStatusDetailsRemote")(function* (cwd: string) { - const branchResult = yield* executeGit( + const branchResult = yield* executeGitWithStableDiagnostics( "GitVcsDriver.statusDetailsRemote.branch", cwd, ["rev-parse", "--abbrev-ref", "HEAD"], @@ -1304,7 +1318,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (cwd: string) { - const statusResult = yield* executeGit( + const statusResult = yield* executeGitWithStableDiagnostics( "GitVcsDriver.statusDetails.status", cwd, ["status", "--porcelain=2", "--branch"], @@ -1524,6 +1538,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ); yield* runGit("GitVcsDriver.prepareCommitContext.addSelected", cwd, [ + "--literal-pathspecs", "add", "-A", "--", @@ -1985,7 +2000,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const branchRecencyPromise = readBranchRecency(input.cwd).pipe( Effect.orElseSucceed(() => new Map()), ); - const localBranchResult = yield* executeGit( + const localBranchResult = yield* executeGitWithStableDiagnostics( "GitVcsDriver.listRefs.branchNoColor", input.cwd, ["branch", "--no-color", "--no-column"], diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 111dfb882d4..e74c9ba712a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -34,6 +34,7 @@ import { OrchestrationV2GetShellSnapshotError, OrchestrationV2GetThreadProjectionError, OrchestrationV2ThreadLaunchError, + type OrchestrationV2ThreadStreamItem, type OrchestrationProjectShell, type ProjectEntriesFailure, type ProjectFileFailure, @@ -593,6 +594,7 @@ const makeWsRpcLayer = ( otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, }, settings, + threadResumeCompletionMarker: true, }; }); @@ -602,7 +604,11 @@ const makeWsRpcLayer = ( .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subscribeThread")( - function* (input: { readonly threadId: ThreadId; readonly afterSequence?: number }) { + function* (input: { + readonly threadId: ThreadId; + readonly afterSequence?: number; + readonly requestCompletionMarker?: boolean; + }) { yield* Effect.annotateCurrentSpan({ "orchestration_v2.thread_id": input.threadId, }); @@ -637,6 +643,35 @@ const makeWsRpcLayer = ( // published during the replay window is lost; overlapping events are // deduped by sequence on the client. if (input.afterSequence !== undefined) { + if (input.requestCompletionMarker === true) { + // Opt-in marker after catch-up so warm clients leave syncing without + // a full body retransmit, including when replay is empty. + return threadManagement + .streamResumeWithCatchUpComplete({ + threadId: input.threadId, + afterSequence: input.afterSequence, + }) + .pipe( + Stream.map( + (item): OrchestrationV2ThreadStreamItem => + item.kind === "catch-up-complete" + ? { kind: "synchronized" } + : { + kind: "event", + sequence: item.stored.sequence, + event: item.stored.event, + }, + ), + Stream.mapError( + (cause) => + new OrchestrationV2GetThreadProjectionError({ + threadId: input.threadId, + message: `Failed while streaming orchestration V2 thread ${input.threadId}`, + cause, + }), + ), + ); + } return eventStreamFrom(input.afterSequence); } diff --git a/apps/web/public/apple-touch-icon.png b/apps/web/public/apple-touch-icon.png index e0e1b9659b8..3eed25ea6b7 100644 Binary files a/apps/web/public/apple-touch-icon.png and b/apps/web/public/apple-touch-icon.png differ diff --git a/apps/web/public/favicon-16x16.png b/apps/web/public/favicon-16x16.png index 673d8459998..a3431b8c6df 100644 Binary files a/apps/web/public/favicon-16x16.png and b/apps/web/public/favicon-16x16.png differ diff --git a/apps/web/public/favicon-32x32.png b/apps/web/public/favicon-32x32.png index 25bcc95d4aa..862f7629971 100644 Binary files a/apps/web/public/favicon-32x32.png and b/apps/web/public/favicon-32x32.png differ diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico index 36975b99782..750da22602e 100644 Binary files a/apps/web/public/favicon.ico and b/apps/web/public/favicon.ico differ diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index d3c7f6a8dab..6f89d86df88 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -25,6 +25,89 @@ describe("browser target resolver", () => { }); }); + it("maps localhost URL navigation onto a remote Tailscale IPv4 host", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://localhost:5173/dashboard?mode=test#results", + }), + ).toEqual({ + requestedUrl: "http://localhost:5173/dashboard?mode=test#results", + resolvedUrl: "http://100.65.180.100:5173/dashboard?mode=test#results", + resolutionKind: "direct-private-network", + environmentId: "environment-1", + }); + }); + + it("preserves URL credentials when mapping localhost onto a remote host", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://user:p%40ss@localhost:5173/dashboard", + }).resolvedUrl, + ).toBe("http://user:p%40ss@100.65.180.100:5173/dashboard"); + }); + + it("maps credentialed localhost URLs onto private IPv6 hosts", async () => { + readPreparedConnection.mockReturnValue({ + httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773", + }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://user:p%40ss@localhost:5173/dashboard?mode=test#results", + }).resolvedUrl, + ).toBe("http://user:p%40ss@[fd7a:115c:a1e0::53]:5173/dashboard?mode=test#results"); + }); + + it("maps schemeless localhost navigation onto a remote environment host", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "localhost:3000/app", + }).resolvedUrl, + ).toBe("http://192.168.1.25:3000/app"); + }); + + it("keeps localhost navigation local for a local environment", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "localhost:3000/app", + }), + ).toEqual({ + requestedUrl: "localhost:3000/app", + resolvedUrl: "localhost:3000/app", + resolutionKind: "direct", + environmentId: "environment-1", + }); + }); + + it("keeps localhost navigation local for the full IPv4 loopback range", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.2:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://localhost:3000/app", + }), + ).toEqual({ + requestedUrl: "http://localhost:3000/app", + resolvedUrl: "http://localhost:3000/app", + resolutionKind: "direct", + environmentId: "environment-1", + }); + }); + it("refuses public relay hosts until the authenticated gateway exists", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://relay.example.com" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); @@ -34,6 +117,12 @@ describe("browser target resolver", () => { port: 5173, }), ).toThrow(/authenticated preview gateway/); + expect(() => + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://localhost:5173", + }), + ).toThrow(/authenticated preview gateway/); }); it("normalizes schemeless localhost server-picker values", async () => { @@ -63,7 +152,9 @@ describe("browser target resolver", () => { }); it("supports private IPv6 environment hosts", async () => { - readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://[::1]:3773" }); + readPreparedConnection.mockReturnValue({ + httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773", + }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); expect( resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { @@ -71,7 +162,18 @@ describe("browser target resolver", () => { port: 5173, path: "/app?mode=test", }).resolvedUrl, - ).toBe("http://[::1]:5173/app?mode=test"); + ).toBe("http://[fd7a:115c:a1e0::53]:5173/app?mode=test"); + }); + + it("supports a local IPv6 environment host", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://[::1]:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "environment-port", + port: 5173, + }).resolvedUrl, + ).toBe("http://[::1]:5173/"); }); it("leaves malformed input for the normal navigation error path", async () => { diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 9142cce1e72..9b201dbdbae 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -7,43 +7,60 @@ import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; import { readPreparedConnection } from "~/state/session"; +const normalizeHostname = (host: string): string => host.toLowerCase().replace(/^\[|\]$/g, ""); + +const parseIpv4Address = (host: string): readonly number[] | null => { + const parts = normalizeHostname(host).split(".").map(Number); + return parts.length === 4 && + parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) + ? parts + : null; +}; + +const isLocalLoopbackHost = (host: string): boolean => { + const normalized = normalizeHostname(host); + if (normalized === "localhost" || normalized === "::1") return true; + return parseIpv4Address(normalized)?.[0] === 127; +}; + const isPrivateNetworkHost = (host: string): boolean => { - const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); - if (normalized === "localhost" || normalized === "::1" || normalized.endsWith(".local")) { + const normalized = normalizeHostname(host); + if (isLocalLoopbackHost(normalized) || normalized.endsWith(".local")) { return true; } if (normalized.endsWith(".ts.net")) return true; - const parts = normalized.split(".").map(Number); - if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) return false; + const parts = parseIpv4Address(normalized); + if (parts) { + return ( + parts[0] === 10 || + (parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) || + (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || + (parts[0] === 192 && parts[1] === 168) || + (parts[0] === 169 && parts[1] === 254) + ); + } + const firstIpv6Token = normalized.split(":", 1)[0] ?? ""; + if (!normalized.includes(":") || !/^[\da-f]{1,4}$/u.test(firstIpv6Token)) return false; + const firstIpv6Hextet = Number.parseInt(firstIpv6Token, 16); return ( - parts[0] === 10 || - (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || - (parts[0] === 192 && parts[1] === 168) || - parts[0] === 127 || - (parts[0] === 169 && parts[1] === 254) + Number.isInteger(firstIpv6Hextet) && + ((firstIpv6Hextet & 0xfe00) === 0xfc00 || (firstIpv6Hextet & 0xffc0) === 0xfe80) ); }; -const isLocalLoopbackHost = (host: string): boolean => { - const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); - return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1"; +const readEnvironmentUrl = (environmentId: EnvironmentId): URL => { + const connection = readPreparedConnection(environmentId); + if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); + return new URL(connection.httpBaseUrl); }; -export function resolveBrowserNavigationTarget( +const resolveEnvironmentPortTarget = ( environmentId: EnvironmentId, - target: BrowserNavigationTarget, -): PreviewUrlResolution { - if (target.kind === "url") { - return { - requestedUrl: target.url, - resolvedUrl: target.url, - resolutionKind: "direct", - environmentId, - }; - } - const connection = readPreparedConnection(environmentId); - if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); - const environmentUrl = new URL(connection.httpBaseUrl); + target: Extract, + environmentUrl: URL, + requestedUrl?: string, + sourceUrl?: URL, +): PreviewUrlResolution => { if (!isPrivateNetworkHost(environmentUrl.hostname)) { throw new Error( "This environment port needs the planned authenticated preview gateway; its server address is not directly private-network reachable.", @@ -51,40 +68,72 @@ export function resolveBrowserNavigationTarget( } const protocol = target.protocol ?? "http"; const path = target.path?.startsWith("/") ? target.path : `/${target.path ?? ""}`; - const requestedUrl = `${protocol}://localhost:${target.port}${path}`; const normalizedEnvironmentHost = environmentUrl.hostname.replace(/^\[|\]$/g, ""); const resolvedHost = normalizedEnvironmentHost.includes(":") ? `[${normalizedEnvironmentHost}]` : normalizedEnvironmentHost; - const resolved = new URL(path, `${protocol}://${resolvedHost}:${target.port}`); + const resolved = sourceUrl + ? new URL(sourceUrl) + : new URL(path, `${protocol}://${resolvedHost}:${target.port}`); + if (sourceUrl) { + resolved.hostname = resolvedHost; + resolved.port = String(target.port); + } return { - requestedUrl, + requestedUrl: requestedUrl ?? `${protocol}://localhost:${target.port}${path}`, resolvedUrl: resolved.toString(), - resolutionKind: - normalizedEnvironmentHost === "localhost" || normalizedEnvironmentHost === "127.0.0.1" - ? "direct" - : "direct-private-network", + resolutionKind: isLocalLoopbackHost(normalizedEnvironmentHost) + ? "direct" + : "direct-private-network", environmentId, }; +}; + +export function resolveBrowserNavigationTarget( + environmentId: EnvironmentId, + target: BrowserNavigationTarget, +): PreviewUrlResolution { + if (target.kind === "url") { + let parsed: URL | null = null; + try { + parsed = new URL(normalizePreviewUrl(target.url)); + } catch { + // Preserve the existing direct-navigation behavior so the preview host + // reports malformed URL errors through its normal navigation path. + } + if (parsed && isLoopbackHost(parsed.hostname)) { + const environmentUrl = readEnvironmentUrl(environmentId); + if (parsed.hostname === "0.0.0.0" || !isLocalLoopbackHost(environmentUrl.hostname)) { + return resolveEnvironmentPortTarget( + environmentId, + { + kind: "environment-port", + port: Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)), + protocol: parsed.protocol === "https:" ? "https" : "http", + path: `${parsed.pathname}${parsed.search}${parsed.hash}`, + }, + environmentUrl, + target.url, + parsed, + ); + } + } + return { + requestedUrl: target.url, + resolvedUrl: target.url, + resolutionKind: "direct", + environmentId, + }; + } + return resolveEnvironmentPortTarget(environmentId, target, readEnvironmentUrl(environmentId)); } export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string { try { const normalizedUrl = normalizePreviewUrl(rawUrl); - const parsed = new URL(normalizedUrl); - if (!isLoopbackHost(parsed.hostname)) return normalizedUrl; - const connection = readPreparedConnection(environmentId); - if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); - const environmentUrl = new URL(connection.httpBaseUrl); - if (parsed.hostname !== "0.0.0.0" && isLocalLoopbackHost(environmentUrl.hostname)) { - return normalizedUrl; - } - const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)); return resolveBrowserNavigationTarget(environmentId, { - kind: "environment-port", - port, - protocol: parsed.protocol === "https:" ? "https" : "http", - path: `${parsed.pathname}${parsed.search}${parsed.hash}`, + kind: "url", + url: normalizedUrl, }).resolvedUrl; } catch { return rawUrl; diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 0f1a8f9d429..07ec0dc9077 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,6 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; -import { useEffect, type CSSProperties, type ReactNode } from "react"; -import { useNavigate } from "@tanstack/react-router"; +import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; +import { useLocation, useNavigate } from "@tanstack/react-router"; import { isElectron } from "../env"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; @@ -55,11 +55,36 @@ function SidebarControl() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); + const pathname = useLocation({ select: (location) => location.pathname }); + const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); + const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => { + const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState; + return isMacosDesktop && typeof getWindowFullscreenState === "function" + ? getWindowFullscreenState() + : false; + }); const macosWindowControlsStyle = - isElectron && isMacPlatform(navigator.platform) + isMacosDesktop && !isWindowFullscreen ? ({ "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } as CSSProperties) : undefined; + useEffect(() => { + if (!isMacosDesktop) return; + const bridge = window.desktopBridge; + if (!bridge) return; + const { getWindowFullscreenState, onWindowFullscreenStateChange } = bridge; + if ( + typeof getWindowFullscreenState !== "function" || + typeof onWindowFullscreenStateChange !== "function" + ) { + return; + } + + const unsubscribe = onWindowFullscreenStateChange(setIsWindowFullscreen); + setIsWindowFullscreen(getWindowFullscreenState()); + return unsubscribe; + }, [isMacosDesktop]); + useEffect(() => { const onMenuAction = window.desktopBridge?.onMenuAction; if (typeof onMenuAction !== "function") { @@ -68,14 +93,17 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { const unsubscribe = onMenuAction((action) => { if (action === "open-settings") { - void navigate({ to: "/settings" }); + const isSettingsRoute = /^\/settings(\/|$)/.test(pathname); + if (!isSettingsRoute) { + void navigate({ to: "/settings" }); + } } }); return () => { unsubscribe?.(); }; - }, [navigate]); + }, [navigate, pathname]); return ( diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b04e98a8a60..c85340c715b 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -32,7 +32,7 @@ import React, { useState, type ReactNode, } from "react"; -import type { Components } from "react-markdown"; +import type { Components, Options as ReactMarkdownOptions } from "react-markdown"; import ReactMarkdown from "react-markdown"; import { defaultUrlTransform } from "react-markdown"; import rehypeRaw from "rehype-raw"; @@ -60,6 +60,7 @@ import { serializeTableElementToCsv, serializeTableElementToMarkdown, } from "../markdown-clipboard"; +import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { normalizeMarkdownLinkDestination, resolveMarkdownFileLinkMeta, @@ -164,6 +165,24 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { }, } satisfies Parameters[0]; +const CHAT_MARKDOWN_REMARK_PLUGINS = [ + remarkGfm, + remarkNormalizeListItemIndentation, + remarkPreserveCodeMeta, +] satisfies NonNullable; + +const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ + remarkGfm, + remarkNormalizeListItemIndentation, + remarkBreaks, + remarkPreserveCodeMeta, +] satisfies NonNullable; + +const CHAT_MARKDOWN_REHYPE_PLUGINS = [ + rehypeRaw, + [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], +] satisfies NonNullable; + function extractFenceLanguage(className: string | undefined): string { const match = className?.match(CODE_FENCE_LANGUAGE_REGEX); const raw = match?.[1] ?? "text"; @@ -1545,11 +1564,9 @@ function ChatMarkdown({ > diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 9b9fb7192ab..af53375baeb 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,4 +1,4 @@ -import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, RunId } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import type { Thread } from "../types"; @@ -331,6 +331,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { localDispatch, phase: "ready", latestRun: completedTurn, + latestUserMessageAt: localDispatch.latestUserMessageAt, runtime: readySession, hasPendingApproval: false, hasPendingUserInput: false, @@ -356,6 +357,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { localDispatch, phase: "ready", latestRun: newerTurn, + latestUserMessageAt: localDispatch.latestUserMessageAt, runtime: { ...readySession, updatedAt: newerTurn.completedAt }, hasPendingApproval: false, hasPendingUserInput: false, @@ -382,6 +384,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { localDispatch, phase: "running", latestRun: runningTurn, + latestUserMessageAt: localDispatch.latestUserMessageAt, runtime: { ...readySession, status: "running", @@ -397,6 +400,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { localDispatch, phase: "running", latestRun: runningTurn, + latestUserMessageAt: localDispatch.latestUserMessageAt, runtime: { ...readySession, status: "running", @@ -409,12 +413,46 @@ describe("hasServerAcknowledgedLocalDispatch", () => { ).toBe(true); }); + it("acknowledges a steering message projected onto the current running turn", () => { + const runningTurn = { + ...completedTurn, + status: "running" as const, + completedAt: null, + }; + const runningSession = { + ...readySession, + status: "running" as const, + activeRunId: runningTurn.runId, + }; + const localDispatch = createLocalDispatchSnapshot( + makeThread({ + latestRun: runningTurn, + runtime: runningSession, + latestUserMessageAt: runningTurn.requestedAt, + }), + ); + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "running", + latestRun: runningTurn, + latestUserMessageAt: "2026-03-29T00:00:05.000Z", + runtime: runningSession, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(true); + }); + it("acknowledges pending user interaction and errors immediately", () => { const localDispatch = createLocalDispatchSnapshot(makeThread()); const common = { localDispatch, phase: "ready" as const, latestRun: null, + latestUserMessageAt: localDispatch.latestUserMessageAt, runtime: null, hasPendingApproval: false, hasPendingUserInput: false, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index efa6b0691f1..babc691111a 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -368,6 +368,7 @@ export interface LocalDispatchSnapshot { latestRunRequestedAt: string | null; latestRunStartedAt: string | null; latestRunCompletedAt: string | null; + latestUserMessageAt: string | null; runtimeStatus: NonNullable["status"] | null; runtimeUpdatedAt: string | null; } @@ -385,6 +386,7 @@ export function createLocalDispatchSnapshot( latestRunRequestedAt: latestRun?.requestedAt ?? null, latestRunStartedAt: latestRun?.startedAt ?? null, latestRunCompletedAt: latestRun?.completedAt ?? null, + latestUserMessageAt: activeThread?.latestUserMessageAt ?? null, runtimeStatus: runtime?.status ?? null, runtimeUpdatedAt: runtime?.updatedAt ?? null, }; @@ -394,6 +396,7 @@ export function hasServerAcknowledgedLocalDispatch(input: { localDispatch: LocalDispatchSnapshot | null; phase: SessionPhase; latestRun: Thread["latestRun"] | null; + latestUserMessageAt: string | null; runtime: Thread["runtime"] | null; hasPendingApproval: boolean; hasPendingUserInput: boolean; @@ -408,6 +411,8 @@ export function hasServerAcknowledgedLocalDispatch(input: { const latestRun = input.latestRun ?? null; const runtime = input.runtime ?? null; + const latestUserMessageChanged = + input.localDispatch.latestUserMessageAt !== input.latestUserMessageAt; const latestRunChanged = input.localDispatch.latestRunId !== (latestRun?.runId ?? null) || input.localDispatch.latestRunRequestedAt !== (latestRun?.requestedAt ?? null) || @@ -415,6 +420,13 @@ export function hasServerAcknowledgedLocalDispatch(input: { input.localDispatch.latestRunCompletedAt !== (latestRun?.completedAt ?? null); if (input.phase === "running") { + // Steering adds a user message to the current running turn without + // necessarily changing any of the turn timestamps. Treat that projected + // message as the server acknowledgment so the composer does not remain + // stuck in its local "Sending" state until the turn settles. + if (latestUserMessageChanged) { + return true; + } if (!latestRunChanged) { return false; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8b1d2c672ba..3fbedf7f6ed 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -56,6 +56,7 @@ import { useRef, useState, } from "react"; +import { flushSync } from "react-dom"; import { useNavigate } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { @@ -211,6 +212,7 @@ import { } from "../state/entities"; import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; +import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; @@ -230,6 +232,14 @@ import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { QueuedRunsControl } from "./chat/QueuedRunsControl"; import type { ComposerDispatchMode } from "./chat/composerDispatch"; +import { + DRAFT_HERO_TRANSITION_ANIMATION_ID, + DRAFT_HERO_TRANSITION_DURATION_MS, + DRAFT_HERO_TRANSITION_EASING, + MOBILE_COMPOSER_VIEW_TRANSITION_NAME, + MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME, + runMobileComposerTransition, +} from "./chat/draftHeroTransition"; import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, @@ -272,6 +282,78 @@ const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_ATTACHMENT_IDS: string[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; +function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { + const transitionGroupRef = useRef(null); + const composerAnchorRef = useRef(null); + const previousStateRef = useRef(isDraftHeroState); + const previousComposerRectRef = useRef(null); + const animationRef = useRef(null); + const attachTransitionGroupRef = (element: HTMLDivElement | null) => { + transitionGroupRef.current = element; + }; + const attachComposerAnchorRef = (element: HTMLDivElement | null) => { + composerAnchorRef.current = element; + }; + const captureComposerRect = () => { + previousComposerRectRef.current = composerAnchorRef.current?.getBoundingClientRect() ?? null; + }; + + useLayoutEffect(() => { + const transitionGroup = transitionGroupRef.current; + const nextComposerRect = composerAnchorRef.current?.getBoundingClientRect() ?? null; + const stateChanged = previousStateRef.current !== isDraftHeroState; + const prefersReducedMotion = + typeof window !== "undefined" && + window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; + const mobileComposerTransitionActive = + typeof document !== "undefined" && + document.documentElement.dataset.mobileComposerRouteTransition === "true"; + + animationRef.current?.cancel(); + animationRef.current = null; + + const previousComposerRect = previousComposerRectRef.current; + if ( + stateChanged && + !prefersReducedMotion && + !mobileComposerTransitionActive && + transitionGroup && + previousComposerRect && + nextComposerRect && + typeof transitionGroup.animate === "function" + ) { + const translateX = previousComposerRect.left - nextComposerRect.left; + const translateY = previousComposerRect.top - nextComposerRect.top; + if (Math.abs(translateX) >= 0.5 || Math.abs(translateY) >= 0.5) { + const animation = transitionGroup.animate( + [ + { transform: `translate3d(${translateX}px, ${translateY}px, 0)` }, + { transform: "translate3d(0, 0, 0)" }, + ], + { + duration: DRAFT_HERO_TRANSITION_DURATION_MS, + easing: DRAFT_HERO_TRANSITION_EASING, + }, + ); + animation.id = DRAFT_HERO_TRANSITION_ANIMATION_ID; + animationRef.current = animation; + void animation.finished + .catch(() => undefined) + .then(() => { + if (animationRef.current !== animation) { + return; + } + animationRef.current = null; + }); + } + } + + previousStateRef.current = isDraftHeroState; + previousComposerRectRef.current = nextComposerRect; + }, [isDraftHeroState]); + + return [attachTransitionGroupRef, attachComposerAnchorRef, captureComposerRect] as const; +} const PreviewPanel = lazy(() => import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), ); @@ -358,6 +440,7 @@ type ChatViewProps = threadId: ThreadId; onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; + forceExpandedMobileComposer?: boolean; routeKind: "server"; draftId?: never; } @@ -366,6 +449,7 @@ type ChatViewProps = threadId: ThreadId; onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; + forceExpandedMobileComposer?: boolean; routeKind: "draft"; draftId: DraftId; }; @@ -387,6 +471,7 @@ function useLocalDispatchState(input: { threadError: string | null | undefined; }) { const [localDispatch, setLocalDispatch] = useState(null); + const latestUserMessageAt = input.activeThread?.latestUserMessageAt ?? null; const resetLocalDispatch = useCallback(() => { setLocalDispatch(null); @@ -398,6 +483,7 @@ function useLocalDispatchState(input: { localDispatch, phase: input.phase, latestRun: input.activeLatestRun, + latestUserMessageAt, runtime: input.activeThread?.runtime ?? null, hasPendingApproval: input.activePendingApproval !== null, hasPendingUserInput: input.activePendingUserInput !== null, @@ -410,6 +496,7 @@ function useLocalDispatchState(input: { input.activeThread?.runtime, input.phase, input.threadError, + latestUserMessageAt, localDispatch, ], ); @@ -998,6 +1085,14 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane ); }); +// Errors surface through two maps (draft-keyed and thread-keyed) whose entries +// can race around promotion, so each write carries its time to let the latest +// one win when they collide. +type LocalThreadErrorEntry = { + readonly message: string | null; + readonly at: number; +}; + function ChatViewContent(props: ChatViewProps) { const { environmentId, @@ -1005,6 +1100,7 @@ function ChatViewContent(props: ChatViewProps) { routeKind, onDiffPanelOpen, reserveTitleBarControlInset = true, + forceExpandedMobileComposer = false, } = props; const draftId = routeKind === "draft" ? props.draftId : null; const routeThreadRef = useMemo( @@ -1057,21 +1153,17 @@ function ChatViewContent(props: ChatViewProps) { ); const composerDraftTarget: ScopedThreadRef | DraftId = routeKind === "server" ? routeThreadRef : props.draftId; - const serverThread = useThreadShell(routeKind === "server" ? routeThreadRef : null); - const serverThreadProjection = useThreadProjection( - routeKind === "server" ? routeThreadRef : null, - ); + const serverThread = useThreadShell(routeThreadRef); + const serverThreadProjection = useThreadProjection(routeThreadRef); const serverProjection = serverThreadProjection?.projection ?? null; - const serverVisibleTurnItems = useThreadVisibleTurnItems( - routeKind === "server" ? routeThreadRef : null, - ); + const serverVisibleTurnItems = useThreadVisibleTurnItems(routeThreadRef); const committedServerMessageIds = useMemo( () => new Set(serverProjection?.messages.map((message) => message.id) ?? []), [serverProjection], ); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); - const activeThreadLastVisitedAt = useUiStateStore((store) => - routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined, + const activeThreadLastVisitedAt = useUiStateStore( + (store) => store.threadLastVisitedAtById[routeThreadKey], ); const settings = useEnvironmentSettings(environmentId); const setStickyComposerModelSelection = useComposerDraftStore( @@ -1136,10 +1228,10 @@ function ChatViewContent(props: ChatViewProps) { const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState< - Record + Record >({}); const [localServerErrorsByThreadKey, setLocalServerErrorsByThreadKey] = useState< - Record + Record >({}); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); @@ -1254,11 +1346,45 @@ function ChatViewContent(props: ChatViewProps) { ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; const fallbackDraftProject = useProject(fallbackDraftProjectRef); - const localDraftError = - routeKind === "server" && serverThread - ? null - : ((draftId ? localDraftErrorsByDraftId[draftId] : null) ?? null); - const localServerError = localServerErrorsByThreadKey[routeThreadKey] ?? null; + const localDraftError = serverThread + ? null + : ((draftId ? localDraftErrorsByDraftId[draftId]?.message : null) ?? null); + const localServerError = localServerErrorsByThreadKey[routeThreadKey]?.message ?? null; + // Draft errors are keyed by draftId while server errors are keyed by thread + // key, so a pending draft entry must migrate when the server thread loads or + // a failed send would silently disappear on promotion. When both keys hold + // an entry, the most recent write wins. + useEffect(() => { + if (!serverThread || !draftId) { + return; + } + const pendingDraftEntry = localDraftErrorsByDraftId[draftId]; + if (pendingDraftEntry === undefined) { + return; + } + setLocalDraftErrorsByDraftId((existing) => { + if (existing[draftId] === undefined) { + return existing; + } + const next = { ...existing }; + delete next[draftId]; + return next; + }); + setLocalServerErrorsByThreadKey((existing) => { + const currentEntry = existing[routeThreadKey]; + if ( + currentEntry !== undefined && + (currentEntry.at > pendingDraftEntry.at || + currentEntry.message === pendingDraftEntry.message) + ) { + return existing; + } + return { + ...existing, + [routeThreadKey]: pendingDraftEntry, + }; + }); + }, [draftId, localDraftErrorsByDraftId, routeThreadKey, serverThread]); const localDraftThread = useMemo( () => draftThread @@ -1273,7 +1399,10 @@ function ChatViewContent(props: ChatViewProps) { : undefined, [draftThread, fallbackDraftProject?.defaultModelSelection, threadId], ); - const isServerThread = routeKind === "server" && serverThread !== null; + // Promotion is data-driven: the draft route keeps rendering while the + // server thread (same pre-allocated ref) starts, so live state must not + // depend on which route is mounted. + const isServerThread = serverThread !== null; const activeThread = isServerThread ? serverThread : localDraftThread; const serverLatestRun = useMemo( () => (serverProjection === null ? null : deriveLatestThreadRun(serverProjection)), @@ -2081,6 +2210,16 @@ function ChatViewContent(props: ChatViewProps) { [optimisticUserMessages], ); const timelineEntries = isServerThread ? serverTimelineEntries : draftTimelineEntries; + const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); + const draftHeroDockRequested = + activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; + const isDraftHeroState = + isLocalDraftThread && timelineEntries.length === 0 && !isWorking && !draftHeroDockRequested; + const [ + attachDraftHeroTransitionGroupRef, + attachDraftHeroComposerAnchorRef, + captureDraftHeroComposerRect, + ] = useDraftHeroLayoutTransition(isDraftHeroState); const { turnDiffSummaries } = useTurnDiffSummaries(serverProjection); const turnDiffSummaryByAssistantMessageId = useMemo(() => { const byMessageId = new Map(); @@ -2105,12 +2244,13 @@ function ChatViewContent(props: ChatViewProps) { worktreePath: activeThread?.worktreePath ?? null, }) : null; + const gitStatusCwd = activeThread?.worktreePath ?? gitCwd; const gitStatusQuery = useEnvironmentQuery( - gitCwd === null + gitStatusCwd === null ? null : vcsEnvironment.status({ environmentId, - input: { cwd: gitCwd }, + input: { cwd: gitStatusCwd }, }), ); const keybindings = useAtomValue(primaryServerKeybindingsAtom); @@ -2156,6 +2296,9 @@ function ChatViewContent(props: ChatViewProps) { terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; // Default true while loading to avoid toolbar flicker. const isGitRepo = gitStatusQuery.data?.isRepo ?? true; + const initialDiffPanelGitScope = + gitStatusQuery.data?.hasWorkingTreeChanges === true ? "unstaged" : "branch"; + const diffPanelGitStatusResolutionKey = gitStatusQuery.data ? "resolved" : "pending"; const terminalShortcutLabelOptions = useMemo( () => ({ context: { @@ -2227,6 +2370,7 @@ function ChatViewContent(props: ChatViewProps) { (targetThreadId: ThreadId | null, error: string | null) => { if (!targetThreadId) return; const nextError = sanitizeThreadErrorMessage(error); + const nextEntry: LocalThreadErrorEntry = { message: nextError, at: Date.now() }; if ( serverThread && targetThreadId === routeThreadRef.threadId && @@ -2234,24 +2378,24 @@ function ChatViewContent(props: ChatViewProps) { serverThread.id === targetThreadId ) { setLocalServerErrorsByThreadKey((existing) => { - if ((existing[routeThreadKey] ?? null) === nextError) { + if ((existing[routeThreadKey]?.message ?? null) === nextError) { return existing; } return { ...existing, - [routeThreadKey]: nextError, + [routeThreadKey]: nextEntry, }; }); return; } const localDraftErrorKey = draftId ?? targetThreadId; setLocalDraftErrorsByDraftId((existing) => { - if ((existing[localDraftErrorKey] ?? null) === nextError) { + if ((existing[localDraftErrorKey]?.message ?? null) === nextError) { return existing; } return { ...existing, - [localDraftErrorKey]: nextError, + [localDraftErrorKey]: nextEntry, }; }); }, @@ -2772,9 +2916,19 @@ function ChatViewContent(props: ChatViewProps) { }, [activeThreadRef, openPreview]); const addDiffSurface = useCallback(() => { if (!activeThreadRef || !isServerThread || !isGitRepo) return; + if (planSidebarOpen) { + dismissPlanSidebarForCurrentTurn(); + } useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); - }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]); + }, [ + activeThreadRef, + dismissPlanSidebarForCurrentTurn, + isGitRepo, + isServerThread, + onDiffPanelOpen, + planSidebarOpen, + ]); const openChangesFromThreadPanel = useCallback(() => { addDiffSurface(); }, [addDiffSurface]); @@ -4106,7 +4260,16 @@ function ChatViewContent(props: ChatViewProps) { } return; } - if (!activeProject) return; + if (!activeProject) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Choose a project first", + description: "This draft no longer points to an available project.", + }), + ); + return; + } const threadIdForSend = activeThread.id; const isFirstMessage = !isServerThread || activeMessageCount === 0; const baseBranchForWorktree = @@ -4124,6 +4287,21 @@ function ChatViewContent(props: ChatViewProps) { } sendInFlightRef.current = true; + if (isDraftHeroState && activeThreadKey) { + let resolveDockStarted: (() => void) | undefined; + const dockStarted = new Promise((resolve) => { + resolveDockStarted = resolve; + }); + const dockTransition = runMobileComposerTransition(() => { + flushSync(() => { + captureDraftHeroComposerRect(); + setDockedDraftHeroThreadKey(activeThreadKey); + }); + resolveDockStarted?.(); + }); + void dockTransition.catch(() => resolveDockStarted?.()); + await dockStarted; + } beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); const composerImagesSnapshot = [...composerImages]; @@ -4383,6 +4561,9 @@ function ChatViewContent(props: ChatViewProps) { } sendInFlightRef.current = false; if (!turnStartSucceeded) { + setDockedDraftHeroThreadKey((currentThreadKey) => + currentThreadKey === activeThreadKey ? null : currentThreadKey, + ); resetLocalDispatch(); } }; @@ -5094,7 +5275,12 @@ function ChatViewContent(props: ChatViewProps) { /> ) : activeRightPanelSurface?.kind === "diff" ? ( - + ) : activeRightPanelSurface?.kind === "plan" ? ( {/* scroll to end pill — shown when user has scrolled away from the live edge */} @@ -5346,106 +5533,156 @@ function ChatViewContent(props: ChatViewProps) { )} - {/* Input bar */} + {/* Input bar — centered hero while a draft has no messages, docked at the bottom otherwise */}
+ {!isDraftHeroState ? ( +