From 5e5d88ecaa475ce971a510d183adf56cb3825d8e Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 14 May 2026 09:49:49 -0700 Subject: [PATCH 01/16] Reapply renderer stress fixes with instant terminal restore --- apps/desktop/docs/RENDERER_STRESS_QA.md | 230 +++ apps/desktop/docs/V2_LAUNCH_TEST_PLAN.md | 3 + apps/desktop/package.json | 4 +- .../prepare-renderer-stress-fixtures.ts | 876 +++++++++ apps/desktop/scripts/stress-renderer.ts | 1709 +++++++++++++++++ apps/desktop/src/main/index.ts | 18 + apps/desktop/src/main/lib/extensions/index.ts | 1 + .../host-service/useDiffStats/useDiffStats.ts | 35 +- .../host-service/useGitStatus/useGitStatus.ts | 11 +- apps/desktop/src/renderer/index.tsx | 13 + apps/desktop/src/renderer/lib/posthog.test.ts | 27 + apps/desktop/src/renderer/lib/posthog.ts | 45 +- .../renderer/lib/terminal/terminal-addons.ts | 40 +- .../lib/terminal/terminal-image-addon.test.ts | 35 + .../lib/terminal/terminal-image-addon.ts | 17 + .../terminal-runtime-registry.test.ts | 421 ++++ .../lib/terminal/terminal-runtime-registry.ts | 232 ++- .../lib/terminal/terminal-runtime.test.ts | 67 + .../renderer/lib/terminal/terminal-runtime.ts | 256 ++- .../lib/terminal/terminal-session-replay.ts | 17 + .../terminal-webgl-addon-controller.test.ts | 180 ++ .../terminal-webgl-addon-controller.ts | 170 ++ .../terminal-webgl-canvas-registry.ts | 24 + .../terminal/terminal-ws-transport.test.ts | 129 +- .../lib/terminal/terminal-ws-transport.ts | 52 +- .../DashboardSidebar/DashboardSidebar.tsx | 12 +- .../DashboardSidebarHoverCardOverlay.tsx | 2 +- .../DashboardSidebarProjectSection.tsx | 4 + ...ashboardSidebarCollapsedProjectContent.tsx | 5 +- ...DashboardSidebarExpandedProjectContent.tsx | 7 +- .../DashboardSidebarWorkspaceItem.tsx | 30 +- ...useDashboardSidebarWorkspaceItemActions.ts | 19 +- .../SortableWorkspaceItem.tsx | 14 +- .../useDashboardSidebarShortcuts.ts | 19 +- .../hooks/useHybridSearch/useHybridSearch.ts | 54 +- .../hooks/useTasksData/useTasksData.tsx | 3 +- .../utils/workspace-navigation.test.ts | 141 ++ .../_dashboard/utils/workspace-navigation.ts | 171 +- .../BackgroundTerminalsButton.tsx | 2 + .../WorkspaceSidebar/WorkspaceSidebar.tsx | 10 +- .../ChangesFileList/ChangesFileList.tsx | 5 +- .../ChangesFoldersView/ChangesFoldersView.tsx | 100 +- .../ChangesSection/ChangesSection.tsx | 24 +- .../hooks/useChangesTab/useChangesTab.tsx | 23 +- .../hooks/useReviewTab/useReviewTab.tsx | 6 +- .../hooks/useChangeset/useChangeset.ts | 8 +- .../useOpenInExternalEditor.ts | 11 +- .../BrowserPane/browserRuntimeRegistry.ts | 127 +- .../components/TerminalPane/TerminalPane.tsx | 20 +- .../TerminalSessionDropdown.tsx | 9 - .../hooks/usePaneRegistry/usePaneRegistry.tsx | 11 +- .../useRendererStressWorkspaceBridge/index.ts | 1 + .../useRendererStressWorkspaceBridge.ts | 681 +++++++ .../useSidebarDiffRef/useSidebarDiffRef.ts | 7 +- .../v2-workspace/$workspaceId/page.tsx | 50 +- .../_dashboard/v2-workspace/layout.tsx | 7 +- .../V2WorkspacesList/V2WorkspacesList.tsx | 7 +- .../V2WorkspaceRow/V2WorkspaceRow.tsx | 5 +- .../hooks/useSystemFonts/useSystemFonts.ts | 150 +- .../useSplitOrientation.ts | 9 +- .../TabsContent/Terminal/helpers.ts | 37 +- .../stores/v2-workspace-navigation.ts | 29 + .../shared/workspace-run-definition.test.ts | 60 + .../src/shared/workspace-run-definition.ts | 13 +- packages/host-service/src/app.ts | 7 +- .../pull-requests/pull-requests.test.ts | 37 + .../runtime/pull-requests/pull-requests.ts | 58 +- .../host-service/src/trpc/router/git/git.ts | 91 + .../src/trpc/router/git/utils/config-write.ts | 8 +- .../trpc/router/git/utils/git-helpers.test.ts | 39 +- .../src/trpc/router/git/utils/git-helpers.ts | 36 +- .../project/utils/ensure-main-workspace.ts | 30 + .../shared/adopt-existing-worktree.ts | 2 +- .../workspace-creation/shared/git-config.ts | 23 +- .../src/trpc/router/workspaces/workspaces.ts | 52 +- .../bug-hunt-3.integration.test.ts | 45 +- .../test/integration/git.integration.test.ts | 21 + .../workspace-cleanup.integration.test.ts | 54 +- ...orkspace-create-delete.integration.test.ts | 78 + .../react/components/Workspace/Workspace.tsx | 6 +- .../Workspace/components/TabBar/TabBar.tsx | 182 +- .../TabBar/components/TabItem/TabItem.tsx | 27 +- .../components/TabBar/utils/index.ts | 8 +- .../components/TabBar/utils/utils.test.ts | 36 + .../components/TabBar/utils/utils.ts | 39 + packages/shared/src/host-info.ts | 5 +- .../OverflowFadeContainer.tsx | 8 +- packages/ui/src/hooks/use-overflow-fade.ts | 26 +- 88 files changed, 6977 insertions(+), 446 deletions(-) create mode 100644 apps/desktop/docs/RENDERER_STRESS_QA.md create mode 100644 apps/desktop/scripts/prepare-renderer-stress-fixtures.ts create mode 100644 apps/desktop/scripts/stress-renderer.ts create mode 100644 apps/desktop/src/renderer/lib/posthog.test.ts create mode 100644 apps/desktop/src/renderer/lib/terminal/terminal-image-addon.test.ts create mode 100644 apps/desktop/src/renderer/lib/terminal/terminal-image-addon.ts create mode 100644 apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts create mode 100644 apps/desktop/src/renderer/lib/terminal/terminal-runtime.test.ts create mode 100644 apps/desktop/src/renderer/lib/terminal/terminal-session-replay.ts create mode 100644 apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon-controller.test.ts create mode 100644 apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon-controller.ts create mode 100644 apps/desktop/src/renderer/lib/terminal/terminal-webgl-canvas-registry.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/useRendererStressWorkspaceBridge.ts create mode 100644 apps/desktop/src/renderer/stores/v2-workspace-navigation.ts create mode 100644 packages/panes/src/react/components/Workspace/components/TabBar/utils/utils.test.ts diff --git a/apps/desktop/docs/RENDERER_STRESS_QA.md b/apps/desktop/docs/RENDERER_STRESS_QA.md new file mode 100644 index 00000000000..1d742bfee27 --- /dev/null +++ b/apps/desktop/docs/RENDERER_STRESS_QA.md @@ -0,0 +1,230 @@ +# Renderer Stress QA Fixtures and Instrumentation + +This is the repeatable process for local renderer QA with seeded V2 +workspaces, large dirty diffs, terminal stress, WebGL context-loss coverage, +and renderer instrumentation. Use it before broad manual QA when a change may +affect workspace switching, changes/diff panels, terminal rendering, tab/pane +state, or renderer responsiveness. + +## Safety + +- Run this only against a local dev `SUPERSET_HOME_DIR`. The fixture command + writes local TanStack and host-service SQLite rows. +- Do not point this at production or shared data. +- The fixture worktrees are disposable. By default they live at + `~/workplace/playground/superset-renderer-stress-fixtures`. +- If the desktop app is already open when fixtures are created, restart it + before running stress so the main process sees the seeded host DB state. + +## What The Fixture Command Seeds + +`bun --cwd apps/desktop stress:renderer:fixtures` creates: + +- A disposable git repo with many tracked files. +- Multiple git worktrees with large dirty changes, deletes, new files, and + renames. +- A V2 project row in the local persisted collections DB. +- V2 workspace rows for the generated worktrees. +- Matching host-service `projects` and `workspaces` rows in + `host//host.db`. + +The stress harness then drives those workspaces through the renderer using the +Chrome DevTools Protocol (CDP) and the in-app renderer stress bridge. + +## Prepare Fixture Workspaces + +For a normal large-diff run: + +```bash +bun --cwd apps/desktop stress:renderer:fixtures -- --json +``` + +For a smaller smoke fixture while iterating: + +```bash +bun --cwd apps/desktop stress:renderer:fixtures -- \ + --workspace-count 2 \ + --changed-files 20 \ + --lines-per-file 20 \ + --base-files 80 \ + --json +``` + +If you are running with `SKIP_ENV_VALIDATION=1`, the renderer may use the +mock auth organization id (`mock-org-id`). In that case, seed the fixture into +that org and pass a host id from an existing local host DB: + +```bash +bun --cwd apps/desktop stress:renderer:fixtures -- \ + --organization-id mock-org-id \ + --host-id \ + --workspace-count 2 \ + --changed-files 20 \ + --lines-per-file 20 \ + --base-files 80 \ + --json +``` + +If the fixture command reports `Host DB not found` for the mock org, copy an +existing local host DB into the mock org path before seeding: + +```bash +mkdir -p superset-dev-data/host/mock-org-id +sqlite3 superset-dev-data/host//host.db \ + ".backup 'superset-dev-data/host/mock-org-id/host.db'" +``` + +Keep the `workspaceIds` from the JSON output. Most targeted QA runs should pass +those ids explicitly so the harness exercises real seeded workspaces instead +of falling back to hash navigation. + +## Start The Desktop App With CDP + +Start the dev app in one shell: + +```bash +SKIP_ENV_VALIDATION=1 \ +SUPERSET_RENDERER_STRESS_CDP_PORT=9333 \ +bun --cwd apps/desktop dev +``` + +`SUPERSET_RENDERER_STRESS_CDP_PORT` opens the Electron renderer for CDP-based +stress automation. The main process also disables extension loading while this +variable is set so extension noise does not affect results. + +## Run The Stress Harness + +Run stress from another shell after the app has loaded: + +```bash +bun --cwd apps/desktop stress:renderer -- \ + --port 9333 \ + --scenario all \ + --workspace-ids , \ + --iterations 1000 \ + --route-iterations 240 \ + --heavy-iterations 500 \ + --timeout-ms 300000 +``` + +For the terminal-heavy flow we used in this PR: + +```bash +bun --cwd apps/desktop stress:renderer -- \ + --port 9333 \ + --scenario terminal-heavy \ + --workspace-ids , \ + --terminal-iterations 200 \ + --terminal-tab-count 32 \ + --terminal-panes-per-tab 4 \ + --terminal-lines 80 \ + --terminal-payload-bytes 4096 \ + --interval-ms 0 \ + --settle-ms 1500 \ + --timeout-ms 300000 \ + --max-heartbeat-delay-ms 10000 \ + --max-long-task-ms 10000 \ + --progress-every 10 +``` + +The terminal-heavy scenario forces terminal WebGL context loss by default. Add +`--no-terminal-webgl-loss` when you need a control run without forced context +loss. + +## Useful Scenarios + +- `workspace-switch`: repeatedly activates workspaces. +- `workspace-switch-heavy`: activates workspaces while generating synthetic + tabs and panes. +- `workspace-heavy`: runs mixed workspace, pane, browser, and diff actions. +- `route-sweep`: navigates renderer routes. +- `terminal-heavy`: creates synthetic terminal tabs/panes, writes large ANSI + payloads, switches tabs, and forces terminal WebGL context loss. +- `all`: combines route, workspace, heavy, and terminal coverage. + +## Instrumentation Knobs + +Use these options when a run fails or feels slow: + +```bash +--profile-cpu +--react-probe +--json +--progress-every 10 +--max-heartbeat-delay-ms +--max-long-task-ms +--timeout-ms +``` + +What to look at: + +- `errorCount` and `errors`: uncaught renderer errors and unhandled rejections. +- `maxHeartbeatDelayMs`: event-loop stalls detected by the renderer heartbeat. +- `maxLongTaskDurationMs` and `longTasks`: browser long-task evidence. +- `terminalWebglContextLosses`: terminal canvas/WebGL context-loss samples. +- CPU profile output: hottest JS frames during the run. +- React probe output: commit/component counts when the React DevTools hook is + available. + +For strict automated runs, keep the default heartbeat and long-task thresholds. +For crash reproduction or exploratory QA, raise them so the harness can keep +running long enough to collect the actual renderer error. + +## Reproducing A Suspected Regression + +Use paired runs so the result identifies the failing subsystem instead of just +the commit range: + +1. Run the fixture command and record the generated workspace ids. +2. Start the app with CDP enabled. +3. Run the target stress scenario on the current branch. +4. Locally revert only the suspected fix with `git revert --no-commit ` + or restore only the relevant files. +5. Restart the app with CDP enabled. +6. Run the exact same stress command with the same workspace ids. +7. Restore the branch and rerun the command once more. + +In this PR, that process showed the renderer did not need a full process crash +to fail. The failing signal was an unhandled rejection: + +```text +RangeError: WebAssembly.instantiate(): Out of memory +``` + +The stack came from `@xterm/addon-image`, and it reproduced both with the +WebGL context-loss fix reverted and restored. That isolated the issue to +per-terminal image decoder memory, not daemon update/attach behavior and not +terminal sizing. + +## Manual QA After Stress + +After an automated run passes, keep the app open and manually check: + +- Workspace switching between the seeded large-diff workspaces. +- Changes panel status, file list, file selection, diff render, and staging. +- Terminal tab switching, pane splitting, clear, search, and resize. +- New terminal creation immediately after attach. The terminal should size + correctly without waiting for a later layout event. +- Returning to an existing terminal. It should not perform a full replay just + because the user switched away and back. +- Navigation away from and back to the workspace route. +- Opening and closing enough terminal tabs/panes to verify WebGL fallback does + not change terminal geometry. + +## Cleanup + +The fixture command removes previous rows for the generated +`renderer-stress-large-diff` project before inserting fresh rows. To remove the +fixture files manually: + +```bash +rm -rf ~/workplace/playground/superset-renderer-stress-fixtures +``` + +If you copied a host DB into `mock-org-id` only for stress testing, remove it +when you want to return to the normal local auth org: + +```bash +rm -rf superset-dev-data/host/mock-org-id +``` + diff --git a/apps/desktop/docs/V2_LAUNCH_TEST_PLAN.md b/apps/desktop/docs/V2_LAUNCH_TEST_PLAN.md index b95f581ecfe..a38a2917756 100644 --- a/apps/desktop/docs/V2_LAUNCH_TEST_PLAN.md +++ b/apps/desktop/docs/V2_LAUNCH_TEST_PLAN.md @@ -6,6 +6,9 @@ Pair with `V2_LAUNCH_CONTEXT.md` for architectural background and ## Setup +For renderer stress fixtures, CDP instrumentation, terminal-heavy runs, and +large-diff workspace QA, see `RENDERER_STRESS_QA.md`. + 1. `bun dev` at the repo root. 2. Ensure your active org has V2 cloud enabled (or you're testing a V2 project). diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 971517aeb38..f40f9a31f1d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -34,7 +34,9 @@ "generate:routes": "tsr generate", "pretypecheck": "bun run generate:icons && bun run generate:routes", "typecheck": "tsc --noEmit", - "test": "bun test" + "test": "bun test", + "stress:renderer": "bun run scripts/stress-renderer.ts", + "stress:renderer:fixtures": "bun run scripts/prepare-renderer-stress-fixtures.ts" }, "dependencies": { "@ai-sdk/anthropic": "3.0.64", diff --git a/apps/desktop/scripts/prepare-renderer-stress-fixtures.ts b/apps/desktop/scripts/prepare-renderer-stress-fixtures.ts new file mode 100644 index 00000000000..1b46c5aed95 --- /dev/null +++ b/apps/desktop/scripts/prepare-renderer-stress-fixtures.ts @@ -0,0 +1,876 @@ +#!/usr/bin/env bun + +import { Database } from "bun:sqlite"; +import { randomUUID } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { homedir, hostname } from "node:os"; +import { basename, join, resolve } from "node:path"; + +interface Args { + root: string; + supersetHome: string; + workspaceCount: number; + changedFiles: number; + linesPerFile: number; + baseFiles: number; + organizationId?: string; + hostId?: string; + json: boolean; + help: boolean; +} + +interface FixtureWorkspace { + id: string; + branch: string; + worktreePath: string; +} + +interface CollectionRow { + key: string; + value: string; + row_version: number; +} + +const repoRoot = resolve(import.meta.dirname, "../../.."); +const envPath = resolve(repoRoot, ".env"); +const fixtureSlug = "renderer-stress-large-diff"; +const fixtureProjectName = "Renderer Stress Large Diff"; + +function parseEnvFile(path: string): Record { + if (!existsSync(path)) return {}; + const text = readFileSync(path, "utf8"); + const values: Record = {}; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line); + if (!match) continue; + const [, key, rawValue] = match; + values[key] = rawValue.trim().replace(/^["']|["']$/g, ""); + } + return values; +} + +const envFile = parseEnvFile(envPath); + +function defaultSupersetHome(): string { + return ( + envFile.SUPERSET_HOME_DIR ?? + process.env.SUPERSET_HOME_DIR ?? + resolve(repoRoot, "superset-dev-data") + ); +} + +function parseArgs(argv: string[]): Args { + const args: Args = { + root: join( + homedir(), + "workplace", + "playground", + "superset-renderer-stress-fixtures", + ), + supersetHome: defaultSupersetHome(), + workspaceCount: 8, + changedFiles: 220, + linesPerFile: 80, + baseFiles: 320, + organizationId: + envFile.SUPERSET_ORGANIZATION_ID ?? process.env.SUPERSET_ORGANIZATION_ID, + hostId: undefined, + json: false, + help: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const readValue = () => { + const value = argv[index + 1]; + if (!value) throw new Error(`Missing value for ${arg}`); + index += 1; + return value; + }; + const readNumber = () => { + const value = Number(readValue()); + if (!Number.isInteger(value) || value < 1) { + throw new Error(`Invalid positive integer for ${arg}`); + } + return value; + }; + + switch (arg) { + case "--help": + case "-h": + args.help = true; + break; + case "--root": + args.root = resolve(readValue()); + break; + case "--superset-home": + args.supersetHome = resolve(readValue()); + break; + case "--workspace-count": + args.workspaceCount = readNumber(); + break; + case "--changed-files": + args.changedFiles = readNumber(); + break; + case "--lines-per-file": + args.linesPerFile = readNumber(); + break; + case "--base-files": + args.baseFiles = readNumber(); + break; + case "--organization-id": + args.organizationId = readValue(); + break; + case "--host-id": + args.hostId = readValue(); + break; + case "--json": + args.json = true; + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + + return args; +} + +function usage(): string { + return `Prepare renderer stress fixture workspaces with large dirty git changes. + +This seeds both the persisted V2 collections and the host-service SQLite DB. +Run it before starting the desktop dev app, or restart the app after running it. + +Usage: + bun --cwd apps/desktop stress:renderer:fixtures + bun --cwd apps/desktop stress:renderer:fixtures -- --workspace-count 10 --changed-files 300 + +Options: + --root Fixture root. Default: ~/workplace/playground/superset-renderer-stress-fixtures + --superset-home Superset home dir. Default: SUPERSET_HOME_DIR, .env, or ./superset-dev-data + --workspace-count Number of workspaces to create. Default: 8 + --changed-files Dirty changed files per workspace. Default: 220 + --lines-per-file Lines appended to each modified file. Default: 80 + --base-files Tracked files in the base repo. Default: 320 + --organization-id Organization id. Default: inferred from persisted collections + --host-id Host machine id. Default: inferred from existing host DB rows + --json Print machine-readable fixture metadata +`; +} + +async function run( + command: string, + args: string[], + options: { cwd?: string } = {}, +): Promise { + const process = Bun.spawn([command, ...args], { + cwd: options.cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(process.stdout).text(), + new Response(process.stderr).text(), + process.exited, + ]); + if (code !== 0) { + throw new Error( + `${command} ${args.join(" ")} failed with ${code}\n${stderr || stdout}`, + ); + } + return stdout.trim(); +} + +function quoteIdentifier(name: string): string { + if (!/^[A-Za-z0-9_]+$/.test(name)) { + throw new Error(`Unsafe SQLite identifier: ${name}`); + } + return `"${name}"`; +} + +function getCollectionTable(db: Database, collectionId: string): string { + const row = db + .query("select table_name from collection_registry where collection_id = ?") + .get(collectionId) as { table_name?: string } | null; + if (!row?.table_name) { + throw new Error(`Persisted collection not found: ${collectionId}`); + } + return row.table_name; +} + +function inferOrganizationId(db: Database, explicit?: string): string { + if (explicit) return explicit; + const row = db + .query( + "select collection_id from collection_registry where collection_id like 'v2_workspaces-%' limit 1", + ) + .get() as { collection_id?: string } | null; + const prefix = "v2_workspaces-"; + if (!row?.collection_id?.startsWith(prefix)) { + throw new Error( + "Could not infer organization id from persisted collections", + ); + } + return row.collection_id.slice(prefix.length); +} + +function getCollectionRows(db: Database, tableName: string): CollectionRow[] { + return db + .query( + `select key, value, row_version from ${quoteIdentifier(tableName)} order by row_version`, + ) + .all() as CollectionRow[]; +} + +function deleteCollectionKeys( + db: Database, + tableName: string, + keys: string[], +): void { + if (keys.length === 0) return; + const statement = db.query( + `delete from ${quoteIdentifier(tableName)} where key = ?`, + ); + const remove = db.transaction((items: string[]) => { + for (const key of items) statement.run(key); + }); + remove(keys); +} + +function getNextRowVersion(db: Database, collectionId: string): number { + const fromVersion = db + .query( + "select latest_row_version from collection_version where collection_id = ?", + ) + .get(collectionId) as { latest_row_version?: number } | null; + return (fromVersion?.latest_row_version ?? 0) + 1; +} + +function setCollectionVersion( + db: Database, + collectionId: string, + latestRowVersion: number, +): void { + db.query( + `insert into collection_version (collection_id, latest_row_version) + values (?, ?) + on conflict(collection_id) do update set latest_row_version = excluded.latest_row_version`, + ).run(collectionId, latestRowVersion); +} + +function insertCollectionValue( + db: Database, + tableName: string, + key: string, + value: unknown, + metadata: unknown, + rowVersion: number, +): void { + db.query( + `insert or replace into ${quoteIdentifier( + tableName, + )} (key, value, metadata, row_version) values (?, ?, ?, ?)`, + ).run(key, JSON.stringify(value), JSON.stringify(metadata), rowVersion); +} + +function parseCollectionValue(row: CollectionRow): T | null { + try { + return JSON.parse(row.value) as T; + } catch { + return null; + } +} + +function slugify(value: string): string { + return ( + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60) || "renderer-stress" + ); +} + +function collectionKeyExists( + db: Database, + tableName: string, + key: string, +): boolean { + const row = db + .query(`select 1 as found from ${quoteIdentifier(tableName)} where key = ?`) + .get(key) as { found?: number } | null; + return Boolean(row?.found); +} + +function ensureOrganizationRow({ + tanstackDb, + organizationId, + now, +}: { + tanstackDb: Database; + organizationId: string; + now: string; +}): void { + const organizationCollectionId = "organizations"; + const organizationTable = getCollectionTable( + tanstackDb, + organizationCollectionId, + ); + const organizationKey = `s:${organizationId}`; + if (collectionKeyExists(tanstackDb, organizationTable, organizationKey)) { + return; + } + + const rowVersion = getNextRowVersion(tanstackDb, organizationCollectionId); + insertCollectionValue( + tanstackDb, + organizationTable, + organizationKey, + { + allowedDomains: [], + createdAt: now, + id: organizationId, + logo: null, + metadata: null, + name: `Renderer Stress ${organizationId}`, + slug: slugify(organizationId), + stripeCustomerId: null, + }, + { relation: ["auth", "organizations"], operation: "insert" }, + rowVersion, + ); + setCollectionVersion(tanstackDb, organizationCollectionId, rowVersion); +} + +function ensureHostRow({ + tanstackDb, + hostCollectionId, + hostTable, + organizationId, + hostId, + now, +}: { + tanstackDb: Database; + hostCollectionId: string; + hostTable: string; + organizationId: string; + hostId: string; + now: string; +}): void { + const hostKey = `s:${hostId}`; + if (collectionKeyExists(tanstackDb, hostTable, hostKey)) { + return; + } + + const rowVersion = getNextRowVersion(tanstackDb, hostCollectionId); + insertCollectionValue( + tanstackDb, + hostTable, + hostKey, + { + createdAt: now, + createdByUserId: null, + isOnline: true, + machineId: hostId, + name: hostname() || "Renderer Stress Host", + organizationId, + updatedAt: now, + }, + { relation: ["public", "v2_hosts"], operation: "insert" }, + rowVersion, + ); + setCollectionVersion(tanstackDb, hostCollectionId, rowVersion); +} + +function inferHostId({ + explicit, + tanstackDb, + hostDb, + workspaceTable, + hostTable, +}: { + explicit?: string; + tanstackDb: Database; + hostDb: Database; + workspaceTable: string; + hostTable: string; +}): string { + if (explicit) return explicit; + + const hostWorkspaceIds = new Set( + ( + hostDb.query("select id from workspaces").all() as Array<{ id: string }> + ).map((row) => row.id), + ); + + for (const row of getCollectionRows(tanstackDb, workspaceTable)) { + const workspace = parseCollectionValue<{ id: string; hostId?: string }>( + row, + ); + if ( + workspace?.id && + hostWorkspaceIds.has(workspace.id) && + workspace.hostId + ) { + return workspace.hostId; + } + } + + for (const row of getCollectionRows(tanstackDb, hostTable)) { + const host = parseCollectionValue<{ + machineId?: string; + isOnline?: boolean; + }>(row); + if (host?.machineId && host.isOnline !== false) { + return host.machineId; + } + } + + throw new Error("Could not infer host id; pass --host-id"); +} + +async function writeBaseRepo({ + repoPath, + baseFiles, + linesPerFile, +}: { + repoPath: string; + baseFiles: number; + linesPerFile: number; +}): Promise { + await mkdir(repoPath, { recursive: true }); + await run("git", ["init", "-b", "main"], { cwd: repoPath }); + await run("git", ["config", "user.name", "Renderer Stress"], { + cwd: repoPath, + }); + await run( + "git", + ["config", "user.email", "renderer-stress@example.invalid"], + { + cwd: repoPath, + }, + ); + + for (let index = 0; index < baseFiles; index += 1) { + const moduleId = String(index % 20).padStart(2, "0"); + const fileId = String(index).padStart(3, "0"); + const dir = join(repoPath, "src", `module-${moduleId}`); + await mkdir(dir, { recursive: true }); + const body = Array.from( + { length: Math.max(8, Math.floor(linesPerFile / 2)) }, + (_, line) => `export const value${line} = ${index + line};`, + ).join("\n"); + await writeFile(join(dir, `file-${fileId}.ts`), `${body}\n`); + } + + await writeFile( + join(repoPath, "README.md"), + `# Renderer Stress Fixture\n\nGenerated for large-diff renderer stress tests.\n`, + ); + await run("git", ["add", "."], { cwd: repoPath }); + await run("git", ["commit", "-m", "seed renderer stress fixture"], { + cwd: repoPath, + }); +} + +async function mutateWorktree({ + worktreePath, + workspaceIndex, + changedFiles, + linesPerFile, + baseFiles, +}: { + worktreePath: string; + workspaceIndex: number; + changedFiles: number; + linesPerFile: number; + baseFiles: number; +}): Promise { + for (let index = 0; index < changedFiles; index += 1) { + const fileIndex = (workspaceIndex * 37 + index) % baseFiles; + const moduleId = String(fileIndex % 20).padStart(2, "0"); + const fileId = String(fileIndex).padStart(3, "0"); + const filePath = join( + worktreePath, + "src", + `module-${moduleId}`, + `file-${fileId}.ts`, + ); + const body = Array.from( + { length: linesPerFile }, + (_, line) => + `export const stress${workspaceIndex}_${index}_${line} = ${workspaceIndex + index + line};`, + ).join("\n"); + await writeFile(filePath, `${body}\n`, { flag: "a" }); + } + + for ( + let index = 0; + index < Math.max(12, Math.floor(changedFiles / 8)); + index += 1 + ) { + const fileIndex = (workspaceIndex * 11 + index) % baseFiles; + const moduleId = String(fileIndex % 20).padStart(2, "0"); + const fileId = String(fileIndex).padStart(3, "0"); + await rm( + join(worktreePath, "src", `module-${moduleId}`, `file-${fileId}.ts`), + { + force: true, + }, + ); + } + + for ( + let index = 0; + index < Math.max(16, Math.floor(changedFiles / 6)); + index += 1 + ) { + const dir = join(worktreePath, "generated", `workspace-${workspaceIndex}`); + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, `new-file-${String(index).padStart(3, "0")}.ts`), + `export const generated = ${workspaceIndex * 1000 + index};\n`.repeat( + Math.max(12, Math.floor(linesPerFile / 2)), + ), + ); + } + + for ( + let index = 0; + index < Math.max(4, Math.floor(changedFiles / 50)); + index += 1 + ) { + const fileIndex = (workspaceIndex * 17 + index + 40) % baseFiles; + const moduleId = String(fileIndex % 20).padStart(2, "0"); + const fileId = String(fileIndex).padStart(3, "0"); + const oldPath = join("src", `module-${moduleId}`, `file-${fileId}.ts`); + const newDir = join("renamed", `workspace-${workspaceIndex}`); + await mkdir(join(worktreePath, newDir), { recursive: true }); + await run("git", ["mv", oldPath, join(newDir, `renamed-${fileId}.ts`)], { + cwd: worktreePath, + }).catch(() => undefined); + } + + await writeFile( + join(worktreePath, `large-single-file-${workspaceIndex}.txt`), + Array.from( + { length: linesPerFile * 20 }, + (_, line) => `workspace ${workspaceIndex} large line ${line}`, + ).join("\n"), + ); + + await run("git", ["add", "src", "renamed"], { cwd: worktreePath }).catch( + () => undefined, + ); +} + +async function createGitFixtures(args: Args): Promise<{ + projectId: string; + repoPath: string; + workspaces: FixtureWorkspace[]; +}> { + const repoPath = join(args.root, "large-diff-repo"); + const worktreesRoot = join(args.root, "worktrees"); + + await rm(repoPath, { recursive: true, force: true }); + await rm(worktreesRoot, { recursive: true, force: true }); + await mkdir(worktreesRoot, { recursive: true }); + await writeBaseRepo({ + repoPath, + baseFiles: Math.max(args.baseFiles, args.changedFiles + 40), + linesPerFile: args.linesPerFile, + }); + + const workspaces: FixtureWorkspace[] = []; + for (let index = 0; index < args.workspaceCount; index += 1) { + const branch = `renderer-stress/large-${String(index + 1).padStart(2, "0")}`; + const worktreePath = join( + worktreesRoot, + `large-${String(index + 1).padStart(2, "0")}`, + ); + await run("git", ["worktree", "add", "-b", branch, worktreePath, "main"], { + cwd: repoPath, + }); + await mutateWorktree({ + worktreePath, + workspaceIndex: index, + changedFiles: args.changedFiles, + linesPerFile: args.linesPerFile, + baseFiles: Math.max(args.baseFiles, args.changedFiles + 40), + }); + workspaces.push({ + id: randomUUID(), + branch, + worktreePath, + }); + } + + return { + projectId: randomUUID(), + repoPath, + workspaces, + }; +} + +function removePreviousFixtureRows({ + tanstackDb, + hostDb, + projectTable, + workspaceTable, + repoPath, +}: { + tanstackDb: Database; + hostDb: Database; + projectTable: string; + workspaceTable: string; + repoPath: string; +}): void { + const oldProjectIds = new Set(); + const projectKeysToDelete: string[] = []; + for (const row of getCollectionRows(tanstackDb, projectTable)) { + const project = parseCollectionValue<{ + id: string; + slug?: string; + repoCloneUrl?: string | null; + }>(row); + if ( + project?.id && + (project.slug === fixtureSlug || + project.repoCloneUrl === `file://${repoPath}`) + ) { + oldProjectIds.add(project.id); + projectKeysToDelete.push(row.key); + } + } + + const workspaceKeysToDelete: string[] = []; + for (const row of getCollectionRows(tanstackDb, workspaceTable)) { + const workspace = parseCollectionValue<{ + projectId?: string; + branch?: string; + }>(row); + if ( + workspace?.projectId && + (oldProjectIds.has(workspace.projectId) || + workspace.branch?.startsWith("renderer-stress/large-")) + ) { + workspaceKeysToDelete.push(row.key); + } + } + + deleteCollectionKeys(tanstackDb, workspaceTable, workspaceKeysToDelete); + deleteCollectionKeys(tanstackDb, projectTable, projectKeysToDelete); + + const hostProjectRows = hostDb + .query("select id from projects where repo_path = ?") + .all(repoPath) as Array<{ id: string }>; + for (const row of hostProjectRows) { + hostDb.query("delete from workspaces where project_id = ?").run(row.id); + hostDb.query("delete from projects where id = ?").run(row.id); + } +} + +async function seedDatabases({ + args, + projectId, + repoPath, + workspaces, +}: { + args: Args; + projectId: string; + repoPath: string; + workspaces: FixtureWorkspace[]; +}): Promise<{ organizationId: string; hostId: string }> { + const tanstackPath = join(args.supersetHome, "tanstack-db.sqlite"); + if (!existsSync(tanstackPath)) { + throw new Error(`TanStack DB not found: ${tanstackPath}`); + } + + const tanstackDb = new Database(tanstackPath); + const organizationId = inferOrganizationId(tanstackDb, args.organizationId); + const hostDbPath = join(args.supersetHome, "host", organizationId, "host.db"); + if (!existsSync(hostDbPath)) { + throw new Error(`Host DB not found: ${hostDbPath}`); + } + + const hostDb = new Database(hostDbPath); + const projectCollectionId = `v2_projects-${organizationId}`; + const workspaceCollectionId = `v2_workspaces-${organizationId}`; + const hostCollectionId = `v2_hosts-${organizationId}`; + const projectTable = getCollectionTable(tanstackDb, projectCollectionId); + const workspaceTable = getCollectionTable(tanstackDb, workspaceCollectionId); + const hostTable = getCollectionTable(tanstackDb, hostCollectionId); + const hostId = inferHostId({ + explicit: args.hostId, + tanstackDb, + hostDb, + workspaceTable, + hostTable, + }); + + const now = new Date().toISOString(); + ensureOrganizationRow({ tanstackDb, organizationId, now }); + ensureHostRow({ + tanstackDb, + hostCollectionId, + hostTable, + organizationId, + hostId, + now, + }); + + removePreviousFixtureRows({ + tanstackDb, + hostDb, + projectTable, + workspaceTable, + repoPath, + }); + + const projectVersion = getNextRowVersion(tanstackDb, projectCollectionId); + let workspaceVersion = getNextRowVersion(tanstackDb, workspaceCollectionId); + + insertCollectionValue( + tanstackDb, + projectTable, + `s:${projectId}`, + { + createdAt: now, + githubRepositoryId: null, + iconUrl: null, + id: projectId, + name: fixtureProjectName, + organizationId, + repoCloneUrl: `file://${repoPath}`, + slug: fixtureSlug, + updatedAt: now, + }, + { relation: ["public", "v2_projects"], operation: "insert" }, + projectVersion, + ); + + hostDb + .query( + `insert into projects + (id, repo_path, repo_provider, repo_owner, repo_name, repo_url, remote_name, created_at) + values (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + projectId, + repoPath, + "local", + null, + basename(repoPath), + null, + null, + Date.now(), + ); + + for (const workspace of workspaces) { + const headSha = await run("git", ["rev-parse", "HEAD"], { + cwd: workspace.worktreePath, + }); + hostDb + .query( + `insert into workspaces + (id, project_id, worktree_path, branch, head_sha, created_at) + values (?, ?, ?, ?, ?, ?)`, + ) + .run( + workspace.id, + projectId, + workspace.worktreePath, + workspace.branch, + headSha, + Date.now(), + ); + + insertCollectionValue( + tanstackDb, + workspaceTable, + `s:${workspace.id}`, + { + branch: workspace.branch, + createdAt: now, + createdByUserId: null, + hostId, + id: workspace.id, + name: `large diff ${workspace.branch.split("/").at(-1)}`, + organizationId, + projectId, + taskId: null, + type: "worktree", + updatedAt: now, + }, + { relation: ["public", "v2_workspaces"], operation: "insert" }, + workspaceVersion, + ); + workspaceVersion += 1; + } + + setCollectionVersion(tanstackDb, projectCollectionId, projectVersion); + setCollectionVersion(tanstackDb, workspaceCollectionId, workspaceVersion - 1); + + tanstackDb.close(); + hostDb.close(); + return { organizationId, hostId }; +} + +async function main(): Promise { + const args = parseArgs(Bun.argv.slice(2)); + if (args.help) { + console.log(usage()); + return; + } + + const { projectId, repoPath, workspaces } = await createGitFixtures(args); + const { organizationId, hostId } = await seedDatabases({ + args, + projectId, + repoPath, + workspaces, + }); + + const result = { + organizationId, + hostId, + projectId, + repoPath, + workspaceIds: workspaces.map((workspace) => workspace.id), + workspaceCount: workspaces.length, + changedFilesPerWorkspace: args.changedFiles, + fixtureRoot: args.root, + stressCommand: `bun --cwd apps/desktop stress:renderer -- --scenario all --workspace-ids ${workspaces + .map((workspace) => workspace.id) + .join( + ",", + )} --iterations 1000 --route-iterations 240 --heavy-iterations 500 --timeout-ms 300000`, + }; + + if (args.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + console.log("[stress:fixtures] prepared large-diff renderer fixtures"); + console.log(` org: ${organizationId}`); + console.log(` host: ${hostId}`); + console.log(` project: ${projectId}`); + console.log(` repo: ${repoPath}`); + console.log(` workspaces: ${workspaces.length}`); + console.log(` workspace ids: ${result.workspaceIds.join(",")}`); + console.log( + " restart the desktop dev app before running stress if it was open", + ); + console.log(` ${result.stressCommand}`); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + console.error(""); + console.error(usage()); + process.exit(1); +}); diff --git a/apps/desktop/scripts/stress-renderer.ts b/apps/desktop/scripts/stress-renderer.ts new file mode 100644 index 00000000000..47c3a7f36c5 --- /dev/null +++ b/apps/desktop/scripts/stress-renderer.ts @@ -0,0 +1,1709 @@ +#!/usr/bin/env bun + +interface Args { + host: string; + port: number; + scenario: StressScenario; + iterations: number; + routeIterations: number; + heavyIterations: number; + terminalIterations: number; + terminalTabCount: number; + terminalPanesPerTab: number; + terminalLines: number; + terminalPayloadBytes: number; + forceTerminalWebglLoss: boolean; + includeTerminalAction: boolean; + profileCpu: boolean; + reactProbe: boolean; + progressEvery: number; + intervalMs: number; + settleMs: number; + timeoutMs: number; + maxHeartbeatDelayMs: number; + maxLongTaskMs: number; + selector: string; + workspaceIds: string[]; + json: boolean; + help: boolean; +} + +type StressScenario = + | "all" + | "route-sweep" + | "terminal-heavy" + | "workspace-heavy" + | "workspace-switch" + | "workspace-switch-heavy"; + +interface CdpTarget { + type?: string; + title?: string; + url?: string; + webSocketDebuggerUrl?: string; +} + +interface CdpResponse { + id?: number; + result?: unknown; + error?: { + code: number; + message: string; + }; + method?: string; + params?: unknown; +} + +interface RuntimeConsoleApiCalledEvent { + type?: string; + args?: Array<{ + type?: string; + value?: unknown; + description?: string; + }>; +} + +interface RuntimeEvaluateResult { + result?: { + type?: string; + value?: unknown; + description?: string; + }; + exceptionDetails?: { + text?: string; + exception?: { + description?: string; + value?: unknown; + }; + }; +} + +interface CpuProfile { + nodes: Array<{ + id: number; + parent?: number; + children?: number[]; + callFrame: { + functionName?: string; + url?: string; + lineNumber?: number; + columnNumber?: number; + }; + hitCount?: number; + }>; + samples?: number[]; + timeDeltas?: number[]; +} + +interface CpuProfileResult { + profile?: CpuProfile; +} + +interface CpuProfileFrameSummary { + functionName: string; + url: string; + lineNumber: number; + columnNumber: number; + selfTimeMs: number; + sampleCount: number; + parentFunctionName?: string; + parentUrl?: string; + parentLineNumber?: number; + parentColumnNumber?: number; +} + +interface RendererStressResult { + scenario: StressScenario; + iterations: number; + operationCount: number; + targetCount: number; + activationModeCounts: Record; + routeCount: number; + routeIterations: number; + routesVisited: string[]; + heavyIterations: number; + heavyActionCounts: Record; + heavyActionErrors: string[]; + heavyActionCatalogue: string[]; + terminalIterations: number; + terminalActionCounts: Record; + terminalActionErrors: string[]; + terminalStressSummary: unknown; + terminalWebglContextLosses: unknown[]; + workspaceSummary: unknown; + reactProbeSummary: unknown; + durationMs: number; + maxHeartbeatDelayMs: number; + heartbeatDelaySamples: number[]; + maxLongTaskDurationMs: number; + longTaskCount: number; + longTasks: Array<{ + duration: number; + startTime: number; + name: string; + }>; + slowOperations: Array<{ + phase: string; + label: string; + durationMs: number; + index: number; + }>; + errorCount: number; + errors: string[]; + startMemory: unknown; + endMemory: unknown; + finalLocation: string; +} + +const DEFAULT_SELECTOR = "[data-renderer-stress-workspace-id]"; + +interface TerminalWebglContextLossRecord { + index: number; + terminalCount: number; + canvasCount: number; + webglContextCount: number; + lostContextCount: number; + unsupportedContextCount: number; +} + +function parseArgs(argv: string[]): Args { + const args: Args = { + host: "127.0.0.1", + port: Number(process.env.SUPERSET_RENDERER_STRESS_CDP_PORT ?? 9333), + scenario: "workspace-switch", + iterations: 500, + routeIterations: 0, + heavyIterations: 0, + terminalIterations: 0, + terminalTabCount: 24, + terminalPanesPerTab: 4, + terminalLines: 40, + terminalPayloadBytes: 1024, + forceTerminalWebglLoss: true, + includeTerminalAction: false, + profileCpu: false, + reactProbe: false, + progressEvery: 100, + intervalMs: 0, + settleMs: 1000, + timeoutMs: 30_000, + maxHeartbeatDelayMs: 500, + maxLongTaskMs: 500, + selector: DEFAULT_SELECTOR, + workspaceIds: [], + json: false, + help: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const readValue = () => { + const value = argv[index + 1]; + if (!value) throw new Error(`Missing value for ${arg}`); + index += 1; + return value; + }; + const readNumber = () => { + const value = Number(readValue()); + if (!Number.isFinite(value)) throw new Error(`Invalid number for ${arg}`); + return value; + }; + + switch (arg) { + case "--help": + case "-h": + args.help = true; + break; + case "--host": + args.host = readValue(); + break; + case "--port": + args.port = readNumber(); + break; + case "--scenario": { + const scenario = readValue(); + if ( + scenario !== "all" && + scenario !== "route-sweep" && + scenario !== "terminal-heavy" && + scenario !== "workspace-heavy" && + scenario !== "workspace-switch" && + scenario !== "workspace-switch-heavy" + ) { + throw new Error(`Invalid scenario for ${arg}: ${scenario}`); + } + args.scenario = scenario; + break; + } + case "--iterations": + args.iterations = readNumber(); + break; + case "--route-iterations": + args.routeIterations = readNumber(); + break; + case "--heavy-iterations": + args.heavyIterations = readNumber(); + break; + case "--terminal-iterations": + args.terminalIterations = readNumber(); + break; + case "--terminal-tab-count": + args.terminalTabCount = readNumber(); + break; + case "--terminal-panes-per-tab": + args.terminalPanesPerTab = readNumber(); + break; + case "--terminal-lines": + args.terminalLines = readNumber(); + break; + case "--terminal-payload-bytes": + args.terminalPayloadBytes = readNumber(); + break; + case "--no-terminal-webgl-loss": + args.forceTerminalWebglLoss = false; + break; + case "--include-terminal-action": + args.includeTerminalAction = true; + break; + case "--profile-cpu": + args.profileCpu = true; + break; + case "--react-probe": + args.reactProbe = true; + break; + case "--progress-every": + args.progressEvery = readNumber(); + break; + case "--interval-ms": + args.intervalMs = readNumber(); + break; + case "--settle-ms": + args.settleMs = readNumber(); + break; + case "--timeout-ms": + args.timeoutMs = readNumber(); + break; + case "--max-heartbeat-delay-ms": + args.maxHeartbeatDelayMs = readNumber(); + break; + case "--max-long-task-ms": + args.maxLongTaskMs = readNumber(); + break; + case "--selector": + args.selector = readValue(); + break; + case "--workspace-ids": + args.workspaceIds = readValue() + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + break; + case "--json": + args.json = true; + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + + return args; +} + +function usage() { + return `Renderer stress harness + +Start the desktop app with CDP enabled: + SUPERSET_RENDERER_STRESS_CDP_PORT=9333 bun --cwd apps/desktop dev + +Run the workspace switching stress test from another shell: + bun --cwd apps/desktop stress:renderer -- --port 9333 --iterations 1000 --interval-ms 0 + +Run route and workspace action stress: + bun --cwd apps/desktop stress:renderer -- --port 9333 --scenario all --iterations 1000 --route-iterations 200 --heavy-iterations 300 + +Options: + --port CDP port. Default: env SUPERSET_RENDERER_STRESS_CDP_PORT or 9333 + --host CDP host. Default: 127.0.0.1 + --scenario workspace-switch, workspace-switch-heavy, route-sweep, workspace-heavy, terminal-heavy, or all. Default: workspace-switch + --iterations Workspace activations. Default: 500 + --route-iterations Route navigations. Default: --iterations + --heavy-iterations Mixed pane/tab/browser/diff actions. Default: min(--iterations, 300) + --terminal-iterations Terminal tab switch/write/context-loss cycles. Default: min(--iterations, 200) + --terminal-tab-count Synthetic terminal tabs. Default: 24 + --terminal-panes-per-tab Terminal panes per synthetic tab. Default: 4 + Also controls generated tabs/panes for workspace-switch-heavy. + --terminal-lines ANSI output lines per terminal write. Default: 40 + --terminal-payload-bytes Repeated payload bytes per line. Default: 1024 + --no-terminal-webgl-loss Do not force WEBGL_lose_context during terminal stress + --include-terminal-action Include one real backend terminal launch in heavy stress. Default: false + --profile-cpu Capture a CDP CPU profile and print hottest JS frames + --react-probe Capture React commit/component counts via React DevTools hook when available + --progress-every Emit progress every n operations. Set 0 to disable. Default: 100 + --interval-ms Delay between activations. Default: 0 + --settle-ms Delay after the final activation. Default: 1000 + --timeout-ms CDP command timeout. Default: 30000 + --max-heartbeat-delay-ms Fail if event-loop heartbeat exceeds this. Default: 500 + --max-long-task-ms Fail if a renderer long task exceeds this. Default: 500 + --selector Workspace target selector. Default: ${DEFAULT_SELECTOR} + --workspace-ids Optional explicit workspace ids; falls back to hash navigation if needed + --json Print only JSON summary +`; +} + +function messageDataToString(data: unknown): string { + if (typeof data === "string") return data; + if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8"); + if (ArrayBuffer.isView(data)) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString( + "utf8", + ); + } + return String(data); +} + +function summarizeCpuProfile( + profile: CpuProfile, + limit = 30, +): CpuProfileFrameSummary[] { + const nodesById = new Map(profile.nodes.map((node) => [node.id, node])); + const parentByNodeId = new Map(); + for (const node of profile.nodes) { + if (node.parent != null) parentByNodeId.set(node.id, node.parent); + for (const childId of node.children ?? []) { + parentByNodeId.set(childId, node.id); + } + } + const selfTimeByNodeId = new Map(); + const sampleCountByNodeId = new Map(); + const samples = profile.samples ?? []; + const timeDeltas = profile.timeDeltas ?? []; + + for (let index = 0; index < samples.length; index += 1) { + const nodeId = samples[index]; + sampleCountByNodeId.set(nodeId, (sampleCountByNodeId.get(nodeId) ?? 0) + 1); + selfTimeByNodeId.set( + nodeId, + (selfTimeByNodeId.get(nodeId) ?? 0) + (timeDeltas[index] ?? 0) / 1000, + ); + } + + return Array.from(nodesById.entries()) + .map(([nodeId, node]) => { + const callFrame = node.callFrame; + const parent = nodesById.get(parentByNodeId.get(nodeId) ?? -1); + const parentCallFrame = parent?.callFrame; + return { + functionName: callFrame.functionName || "(anonymous)", + url: callFrame.url || "", + lineNumber: callFrame.lineNumber ?? 0, + columnNumber: callFrame.columnNumber ?? 0, + selfTimeMs: selfTimeByNodeId.get(nodeId) ?? 0, + sampleCount: sampleCountByNodeId.get(nodeId) ?? node.hitCount ?? 0, + parentFunctionName: parentCallFrame?.functionName || undefined, + parentUrl: parentCallFrame?.url || undefined, + parentLineNumber: parentCallFrame?.lineNumber, + parentColumnNumber: parentCallFrame?.columnNumber, + }; + }) + .filter((frame) => frame.selfTimeMs > 0 || frame.sampleCount > 0) + .sort((left, right) => right.selfTimeMs - left.selfTimeMs) + .slice(0, limit); +} + +class CdpClient { + private nextId = 1; + private pending = new Map< + number, + { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer: ReturnType; + } + >(); + private listeners = new Map void>>(); + + private constructor(private readonly ws: WebSocket) { + this.ws.addEventListener("message", (event) => { + this.onMessage(messageDataToString(event.data)); + }); + this.ws.addEventListener("close", () => { + this.rejectPending(new Error("CDP socket closed")); + }); + this.ws.addEventListener("error", () => { + this.rejectPending(new Error("CDP socket errored")); + }); + } + + static connect(url: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + const timer = setTimeout(() => { + ws.close(); + reject(new Error(`Timed out connecting to ${url}`)); + }, timeoutMs); + ws.addEventListener("open", () => { + clearTimeout(timer); + resolve(new CdpClient(ws)); + }); + ws.addEventListener("error", () => { + clearTimeout(timer); + reject(new Error(`Failed to connect to ${url}`)); + }); + }); + } + + send( + method: string, + params: Record = {}, + timeoutMs = 10_000, + ): Promise { + const id = this.nextId; + this.nextId += 1; + const payload = JSON.stringify({ id, method, params }); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`CDP command timed out: ${method}`)); + }, timeoutMs); + this.pending.set(id, { + resolve: (value) => resolve(value as T), + reject, + timer, + }); + this.ws.send(payload); + }); + } + + close(): void { + this.ws.close(); + } + + on(method: string, listener: (params: unknown) => void): () => void { + const listeners = this.listeners.get(method) ?? new Set(); + listeners.add(listener); + this.listeners.set(method, listeners); + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + this.listeners.delete(method); + } + }; + } + + private onMessage(raw: string): void { + const message = JSON.parse(raw) as CdpResponse; + if (typeof message.method === "string") { + const listeners = this.listeners.get(message.method); + if (listeners) { + for (const listener of listeners) { + listener(message.params); + } + } + if (typeof message.id !== "number") return; + } + if (typeof message.id !== "number") return; + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + clearTimeout(pending.timer); + if (message.error) { + pending.reject( + new Error(`CDP error ${message.error.code}: ${message.error.message}`), + ); + return; + } + pending.resolve(message.result); + } + + private rejectPending(error: Error): void { + for (const [id, pending] of this.pending) { + clearTimeout(pending.timer); + pending.reject(error); + this.pending.delete(id); + } + } +} + +async function getRendererTarget(args: Args): Promise { + const response = await fetch(`http://${args.host}:${args.port}/json/list`); + if (!response.ok) { + throw new Error( + `Failed to query CDP targets: ${response.status} ${response.statusText}`, + ); + } + const targets = (await response.json()) as CdpTarget[]; + const target = targets.find( + (candidate) => + candidate.webSocketDebuggerUrl && + candidate.type === "page" && + !candidate.url?.startsWith("devtools://"), + ); + if (!target?.webSocketDebuggerUrl) { + throw new Error("No renderer page target with a CDP socket was found"); + } + return target; +} + +function rendererStress(options: { + scenario: StressScenario; + iterations: number; + routeIterations: number; + heavyIterations: number; + terminalIterations: number; + terminalTabCount: number; + terminalPanesPerTab: number; + terminalLines: number; + terminalPayloadBytes: number; + forceTerminalWebglLoss: boolean; + includeTerminalAction: boolean; + reactProbe: boolean; + progressEvery: number; + intervalMs: number; + settleMs: number; + selector: string; + workspaceIds: string[]; +}): Promise { + const webglContextRecoverySettleMs = 3500; + type StressWindow = Window & { + performance: Performance & { + memory?: unknown; + }; + }; + + const stressWindow = window as StressWindow; + const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + const nextFrame = () => + new Promise((resolve) => requestAnimationFrame(() => resolve())); + const cssEscape = + stressWindow.CSS?.escape ?? + ((value: string) => value.replace(/["\\]/g, "\\$&")); + const errors: string[] = []; + const longTasks: RendererStressResult["longTasks"] = []; + const heartbeatDelaySamples: number[] = []; + let maxHeartbeatDelayMs = 0; + let expectedHeartbeat = performance.now() + 50; + + const describeError = (error: unknown): string => { + if (error instanceof Error) { + return error.stack ?? error.message; + } + if (typeof error === "string") return error; + return String(error); + }; + + const onError = (event: ErrorEvent) => { + errors.push( + event.error + ? describeError(event.error) + : event.message || "unknown error", + ); + }; + const onUnhandledRejection = (event: PromiseRejectionEvent) => { + errors.push(`Unhandled rejection: ${describeError(event.reason)}`); + }; + const installReactProbe = () => { + if (!options.reactProbe) { + return { + getSummary: () => null, + cleanup: () => {}, + }; + } + + type ReactFiber = { + actualDuration?: number; + child?: ReactFiber | null; + elementType?: unknown; + flags?: number; + sibling?: ReactFiber | null; + type?: unknown; + }; + type ReactFiberRoot = { current?: ReactFiber | null }; + type ReactDevToolsHook = { + onCommitFiberRoot?: ( + rendererId: number, + root: ReactFiberRoot, + priorityLevel?: unknown, + didError?: boolean, + ) => unknown; + }; + type ReactProbeWindow = Window & { + __REACT_DEVTOOLS_GLOBAL_HOOK__?: ReactDevToolsHook; + }; + + const hook = (window as ReactProbeWindow).__REACT_DEVTOOLS_GLOBAL_HOOK__; + if (!hook || typeof hook.onCommitFiberRoot !== "function") { + return { + getSummary: () => ({ + available: false, + reason: "React DevTools global hook is not installed", + }), + cleanup: () => {}, + }; + } + + const previousOnCommitFiberRoot = hook.onCommitFiberRoot; + const componentStats = new Map< + string, + { + commitCount: number; + maxActualDurationMs: number; + totalActualDurationMs: number; + } + >(); + const recentCommits: Array<{ + componentCount: number; + durationMs: number; + fiberCount: number; + index: number; + totalActualDurationMs: number; + }> = []; + let commitCount = 0; + let maxCommitDurationMs = 0; + + const getDisplayName = (fiber: ReactFiber): string | null => { + const candidate = fiber.type ?? fiber.elementType; + if (typeof candidate === "string") return candidate; + if (typeof candidate === "function") { + const named = candidate as { displayName?: string; name?: string }; + return named.displayName ?? named.name ?? null; + } + if (candidate && typeof candidate === "object") { + const named = candidate as { + displayName?: string; + name?: string; + render?: { displayName?: string; name?: string }; + }; + return ( + named.displayName ?? + named.name ?? + named.render?.displayName ?? + named.render?.name ?? + null + ); + } + return null; + }; + + const visitFibers = ( + root: ReactFiber | null | undefined, + visit: (fiber: ReactFiber) => void, + ) => { + const stack: ReactFiber[] = []; + const seen = new Set(); + if (root) stack.push(root); + while (stack.length > 0 && seen.size < 20_000) { + const fiber = stack.pop(); + if (!fiber || seen.has(fiber)) continue; + seen.add(fiber); + visit(fiber); + if (fiber.sibling) stack.push(fiber.sibling); + if (fiber.child) stack.push(fiber.child); + } + return seen.size; + }; + + function onCommitFiberRoot( + this: unknown, + rendererId: number, + root: ReactFiberRoot, + priorityLevel?: unknown, + didError?: boolean, + ) { + const result = previousOnCommitFiberRoot.apply(this, [ + rendererId, + root, + priorityLevel, + didError, + ]); + const startedAt = performance.now(); + const committedComponents = new Set(); + let totalActualDurationMs = 0; + const fiberCount = visitFibers(root.current, (fiber) => { + const name = getDisplayName(fiber); + if (!name) return; + const actualDuration = + typeof fiber.actualDuration === "number" ? fiber.actualDuration : 0; + const flags = typeof fiber.flags === "number" ? fiber.flags : 0; + if (actualDuration <= 0 && flags === 0) return; + + committedComponents.add(name); + totalActualDurationMs += actualDuration; + const current = componentStats.get(name) ?? { + commitCount: 0, + maxActualDurationMs: 0, + totalActualDurationMs: 0, + }; + current.commitCount += 1; + current.totalActualDurationMs += actualDuration; + current.maxActualDurationMs = Math.max( + current.maxActualDurationMs, + actualDuration, + ); + componentStats.set(name, current); + }); + + commitCount += 1; + const durationMs = performance.now() - startedAt; + maxCommitDurationMs = Math.max(maxCommitDurationMs, durationMs); + recentCommits.push({ + componentCount: committedComponents.size, + durationMs, + fiberCount, + index: commitCount, + totalActualDurationMs, + }); + if (recentCommits.length > 50) recentCommits.shift(); + return result; + } + + hook.onCommitFiberRoot = onCommitFiberRoot; + + return { + getSummary: () => ({ + available: true, + commitCount, + maxCommitDurationMs, + recentCommits: recentCommits.slice(-10), + topComponents: Array.from(componentStats.entries()) + .map(([name, stats]) => ({ name, ...stats })) + .sort((left, right) => { + const durationDelta = + right.totalActualDurationMs - left.totalActualDurationMs; + if (durationDelta !== 0) return durationDelta; + return right.commitCount - left.commitCount; + }) + .slice(0, 30), + }), + cleanup: () => { + if (hook.onCommitFiberRoot === onCommitFiberRoot) { + hook.onCommitFiberRoot = previousOnCommitFiberRoot; + } + }, + }; + }; + + window.addEventListener("error", onError); + window.addEventListener("unhandledrejection", onUnhandledRejection); + const reactProbe = installReactProbe(); + + const heartbeat = setInterval(() => { + const now = performance.now(); + const delay = Math.max(0, now - expectedHeartbeat); + if (delay > maxHeartbeatDelayMs) maxHeartbeatDelayMs = delay; + if (delay > 50) heartbeatDelaySamples.push(delay); + expectedHeartbeat = now + 50; + }, 50); + + let longTaskObserver: PerformanceObserver | null = null; + try { + longTaskObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + longTasks.push({ + duration: entry.duration, + startTime: entry.startTime, + name: entry.name, + }); + } + }); + longTaskObserver.observe({ entryTypes: ["longtask"] }); + } catch { + longTaskObserver = null; + } + + const getTargets = () => { + if (options.workspaceIds.length > 0) return options.workspaceIds; + const ids = Array.from(document.querySelectorAll(options.selector)) + .map((element) => + element.getAttribute("data-renderer-stress-workspace-id"), + ) + .filter((value): value is string => !!value); + return Array.from(new Set(ids)); + }; + + const activateWorkspace = async (workspaceId: string) => { + const target = document.querySelector( + `${options.selector}[data-renderer-stress-workspace-id="${cssEscape( + workspaceId, + )}"]`, + ); + if (target) { + target.click(); + return "click"; + } + await navigateTo(`/v2-workspace/${encodeURIComponent(workspaceId)}/`); + return "navigate"; + }; + + type RendererStressBridge = { + workspaceId: string; + projectId: string; + captureBaseline: () => void; + restoreBaseline: () => void; + getSummary: () => unknown; + addTab: (kind: string, index: number, paneCount?: number) => void; + openPane: (kind: string, index: number) => void; + splitActivePane: (kind: string, index: number) => void; + switchTab: (index: number) => void; + closeActivePane: () => void; + closeOldestTab: (keepCount?: number) => void; + churnActivePaneData: (index: number) => void; + replaceWithGeneratedLayout: (tabCount: number, panesPerTab: number) => void; + replaceWithGeneratedTerminalLayout: ( + tabCount: number, + panesPerTab: number, + ) => void; + replaceWithGeneratedMixedLayout: ( + tabCount: number, + panesPerTab: number, + ) => void; + writeTerminalStressOutput: ( + index: number, + lines: number, + payloadBytes: number, + ) => Promise<{ + terminalCount: number; + writtenCount: number; + failedCount: number; + byteLength: number; + }>; + forceTerminalWebglContextLoss: () => { + terminalCount: number; + canvasCount: number; + webglContextCount: number; + lostContextCount: number; + unsupportedContextCount: number; + }; + getTerminalStressSummary: () => unknown; + releaseStressTerminalRuntimes: () => void; + addRealTerminalTab: () => Promise; + showChangesSidebar: () => void; + }; + + type RendererStressWindow = Window & { + __SUPERSET_RENDERER_STRESS__?: RendererStressBridge; + __SUPERSET_RENDERER_STRESS_NAVIGATE__?: (path: string) => Promise; + }; + + const getBridge = () => + (window as RendererStressWindow).__SUPERSET_RENDERER_STRESS__ ?? null; + + const waitForBridge = async (workspaceId?: string, attempts = 250) => { + for (let attempt = 0; attempt < attempts; attempt += 1) { + const bridge = getBridge(); + if (bridge && (!workspaceId || bridge.workspaceId === workspaceId)) { + return bridge; + } + await sleep(20); + } + throw new Error( + workspaceId + ? `Renderer stress workspace bridge did not mount for ${workspaceId}` + : "Renderer stress workspace bridge did not mount", + ); + }; + + const navigateTo = async (path: string) => { + const stressNavigate = (window as RendererStressWindow) + .__SUPERSET_RENDERER_STRESS_NAVIGATE__; + if (stressNavigate) { + await stressNavigate(path); + } else { + window.location.hash = path; + } + await sleep(options.intervalMs); + }; + + const withTimeout = async ( + promise: Promise, + timeoutMs: number, + label: string, + ): Promise => { + let timer: ReturnType | null = null; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), + timeoutMs, + ); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + }; + + const findWorkspaceBridge = async (workspaceIds: string[]) => { + const failures: string[] = []; + for (const candidateWorkspaceId of workspaceIds) { + await navigateTo( + `/v2-workspace/${encodeURIComponent(candidateWorkspaceId)}/`, + ); + try { + const bridge = await waitForBridge(candidateWorkspaceId, 500); + return { workspaceId: candidateWorkspaceId, bridge }; + } catch (error) { + failures.push( + `${candidateWorkspaceId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + throw new Error( + `No V2 workspace stress bridge mounted. Tried ${workspaceIds.length} workspace route(s): ${failures.join( + "; ", + )}`, + ); + }; + + const buildRoutePaths = ( + targets: string[], + metadata: { projectId?: string; workspaceId?: string }, + ) => { + const staticPaths = [ + "/", + "/v2-workspaces/", + "/workspaces/", + "/workspace/", + "/tasks/", + "/automations/", + "/settings/", + "/settings/account/", + "/settings/agents/", + "/settings/api-keys/", + "/settings/appearance/", + "/settings/behavior/", + "/settings/billing/", + "/settings/billing/plans/", + "/settings/experimental/", + "/settings/git/", + "/settings/hosts/", + "/settings/integrations/", + "/settings/keyboard/", + "/settings/links/", + "/settings/models/", + "/settings/organization/", + "/settings/permissions/", + "/settings/presets/", + "/settings/projects/", + "/settings/ringtones/", + "/settings/security/", + "/settings/teams/", + "/settings/terminal/", + "/setup/adopt-worktrees/", + "/setup/gh-cli/", + "/setup/permissions/", + "/setup/project/", + "/setup/providers/", + "/setup/providers/claude-code/", + "/setup/providers/claude-code/api-key/", + "/setup/providers/claude-code/custom/", + "/setup/providers/codex/", + "/setup/providers/codex/api-key/", + "/setup/providers/codex/custom/", + "/welcome/", + "/new-project/", + ]; + const dynamicPaths = targets.flatMap((workspaceId) => [ + `/v2-workspace/${encodeURIComponent(workspaceId)}/`, + `/workspace/${encodeURIComponent(workspaceId)}/`, + ]); + if (metadata.projectId) { + const projectId = encodeURIComponent(metadata.projectId); + dynamicPaths.push( + `/project/${projectId}/`, + `/settings/projects/${projectId}/`, + `/settings/project/${projectId}/cloud/`, + `/settings/project/${projectId}/cloud/secrets/`, + `/tasks/issue/1/?project=${projectId}`, + `/tasks/pr/1/?project=${projectId}`, + ); + } + return Array.from(new Set([...staticPaths, ...dynamicPaths])); + }; + + const heavyActionCatalogue = [ + "replace generated multi-tab/multi-pane workspace layout", + "open changes sidebar", + "create file tabs", + "create diff tabs", + "create browser tabs/webviews", + "create chat tabs", + "create comment tabs", + "split active pane with file/diff/browser/chat/comment panes", + "open panes through same-kind replacement path", + "rapid tab switching", + "active pane data churn", + "close active panes", + "close old tabs while preserving a warm set", + ...(options.includeTerminalAction + ? ["single real terminal tab launch"] + : []), + "synthetic terminal layout with parked xterm runtimes", + "high-volume terminal ANSI output", + "forced terminal WebGL context loss and fallback recovery", + "restore original pane layout", + ]; + + const shouldRunWorkspaceSwitch = + options.scenario === "workspace-switch" || + options.scenario === "workspace-switch-heavy" || + options.scenario === "all"; + const shouldPrepareHeavyWorkspaceSwitch = + options.scenario === "workspace-switch-heavy"; + const shouldRunRouteSweep = + options.scenario === "route-sweep" || options.scenario === "all"; + const shouldRunWorkspaceHeavy = + options.scenario === "workspace-heavy" || options.scenario === "all"; + const shouldRunTerminalHeavy = + options.scenario === "terminal-heavy" || options.scenario === "all"; + + return (async () => { + const startedAt = performance.now(); + const startMemory = stressWindow.performance.memory ?? null; + const targets = getTargets(); + const requiredTargetCount = shouldRunWorkspaceSwitch ? 2 : 1; + if (targets.length < requiredTargetCount) { + throw new Error( + `Need at least ${requiredTargetCount} workspace target(s), found ${targets.length}. Open a workspace list/sidebar or pass --workspace-ids.`, + ); + } + + const activationModeCounts: Record = {}; + const routesVisited: string[] = []; + const heavyActionCounts: Record = {}; + const heavyActionErrors: string[] = []; + const terminalActionCounts: Record = {}; + const terminalActionErrors: string[] = []; + const terminalWebglContextLosses: TerminalWebglContextLossRecord[] = []; + const slowOperations: RendererStressResult["slowOperations"] = []; + const heavyWorkspaceSwitchWorkspaceIds: string[] = []; + const heavyWorkspaceSwitchSummaries: unknown[] = []; + let operationCount = 0; + let workspaceSummary: unknown = null; + let terminalStressSummary: unknown = null; + let routeCount = 0; + const routeIterations = + options.routeIterations > 0 + ? options.routeIterations + : options.iterations; + const heavyIterations = + options.heavyIterations > 0 + ? options.heavyIterations + : Math.min(options.iterations, 300); + const terminalIterations = + options.terminalIterations > 0 + ? options.terminalIterations + : Math.min(options.iterations, 200); + const reportProgress = ( + phase: string, + index: number, + total: number, + force = false, + ) => { + if (options.progressEvery <= 0 && !force) return; + if (!force && index % options.progressEvery !== 0) return; + console.info( + "[stress:renderer:progress]", + JSON.stringify({ + phase, + index, + total, + operationCount, + elapsedMs: Math.round(performance.now() - startedAt), + }), + ); + }; + const recordOperationDuration = ( + phase: string, + label: string, + index: number, + startedAt: number, + ) => { + const durationMs = performance.now() - startedAt; + if (durationMs < 100) return; + slowOperations.push({ phase, label, index, durationMs }); + }; + const prepareHeavyWorkspaceSwitchTargets = async () => { + const tabCount = Math.max( + 1, + Math.min(40, Math.floor(options.terminalTabCount)), + ); + const panesPerTab = Math.max( + 1, + Math.min(8, Math.floor(options.terminalPanesPerTab)), + ); + + reportProgress("workspace-switch-heavy-prepare", 0, targets.length, true); + for (let index = 0; index < targets.length; index += 1) { + const target = targets[index]; + await navigateTo(`/v2-workspace/${encodeURIComponent(target)}/`); + const bridge = await waitForBridge(target, 500); + + let operationStartedAt = performance.now(); + bridge.captureBaseline(); + bridge.replaceWithGeneratedMixedLayout(tabCount, panesPerTab); + bridge.showChangesSidebar(); + recordOperationDuration( + "workspace-switch-heavy-prepare", + `replace-generated-mixed-layout:${target}`, + index, + operationStartedAt, + ); + heavyActionCounts["replace-generated-mixed-layout"] = + (heavyActionCounts["replace-generated-mixed-layout"] ?? 0) + 1; + operationCount += 1; + + operationStartedAt = performance.now(); + for (let tabIndex = 0; tabIndex < tabCount; tabIndex += 1) { + bridge.switchTab(tabIndex); + await nextFrame(); + } + recordOperationDuration( + "workspace-switch-heavy-prepare", + `warm-generated-tabs:${target}`, + index, + operationStartedAt, + ); + heavyActionCounts["warm-generated-tabs"] = + (heavyActionCounts["warm-generated-tabs"] ?? 0) + tabCount; + operationCount += tabCount; + + heavyWorkspaceSwitchWorkspaceIds.push(target); + heavyWorkspaceSwitchSummaries.push(bridge.getSummary()); + reportProgress( + "workspace-switch-heavy-prepare", + index + 1, + targets.length, + ); + } + }; + const restoreHeavyWorkspaceSwitchTargets = async () => { + for ( + let index = heavyWorkspaceSwitchWorkspaceIds.length - 1; + index >= 0; + index -= 1 + ) { + const target = heavyWorkspaceSwitchWorkspaceIds[index]; + const operationStartedAt = performance.now(); + await navigateTo(`/v2-workspace/${encodeURIComponent(target)}/`); + const bridge = await waitForBridge(target, 500); + bridge.restoreBaseline(); + recordOperationDuration( + "workspace-switch-heavy-restore", + target, + index, + operationStartedAt, + ); + } + }; + + if (shouldPrepareHeavyWorkspaceSwitch) { + await prepareHeavyWorkspaceSwitchTargets(); + workspaceSummary = { + preparedWorkspaceSummaries: heavyWorkspaceSwitchSummaries, + }; + } + + if (shouldRunWorkspaceSwitch) { + reportProgress("workspace-switch", 0, options.iterations, true); + for (let index = 0; index < options.iterations; index += 1) { + const target = targets[index % targets.length]; + const operationStartedAt = performance.now(); + const mode = await activateWorkspace(target); + recordOperationDuration( + "workspace-switch", + `${mode}:${target}`, + index, + operationStartedAt, + ); + activationModeCounts[mode] = (activationModeCounts[mode] ?? 0) + 1; + operationCount += 1; + reportProgress("workspace-switch", index + 1, options.iterations); + await sleep(options.intervalMs); + } + } + + if (shouldPrepareHeavyWorkspaceSwitch) { + await restoreHeavyWorkspaceSwitchTargets(); + } + + let workspaceId = targets[0]; + let metadata: { projectId?: string; workspaceId?: string } = {}; + if ( + shouldRunRouteSweep || + shouldRunWorkspaceHeavy || + shouldRunTerminalHeavy + ) { + const mounted = await findWorkspaceBridge(targets); + workspaceId = mounted.workspaceId; + const bridge = mounted.bridge; + metadata = { + projectId: bridge.projectId, + workspaceId: bridge.workspaceId, + }; + } + + if (shouldRunRouteSweep) { + const routePaths = buildRoutePaths(targets, metadata); + routeCount = routePaths.length; + reportProgress("route-sweep", 0, routeIterations, true); + for (let index = 0; index < routeIterations; index += 1) { + const routePath = routePaths[index % routePaths.length]; + routesVisited.push(routePath); + const operationStartedAt = performance.now(); + await navigateTo(routePath); + recordOperationDuration( + "route-sweep", + routePath, + index, + operationStartedAt, + ); + operationCount += 1; + reportProgress("route-sweep", index + 1, routeIterations); + } + await navigateTo(`/v2-workspace/${encodeURIComponent(workspaceId)}/`); + await waitForBridge(workspaceId); + } + + if (shouldRunWorkspaceHeavy) { + const activeBridge = await waitForBridge(workspaceId); + activeBridge.captureBaseline(); + let operationStartedAt = performance.now(); + activeBridge.replaceWithGeneratedLayout(12, 3); + recordOperationDuration( + "workspace-heavy", + "replace-generated-layout", + -2, + operationStartedAt, + ); + heavyActionCounts["replace-generated-layout"] = 1; + operationCount += 1; + operationStartedAt = performance.now(); + activeBridge.showChangesSidebar(); + recordOperationDuration( + "workspace-heavy", + "show-changes-sidebar", + -1, + operationStartedAt, + ); + heavyActionCounts["show-changes-sidebar"] = 1; + operationCount += 1; + reportProgress("workspace-heavy", 0, heavyIterations, true); + + const paneKinds = ["file", "diff", "browser", "chat", "comment"]; + for (let index = 0; index < heavyIterations; index += 1) { + const kind = paneKinds[index % paneKinds.length]; + const action = index % 12; + const operationStartedAt = performance.now(); + let actionLabel = "unknown"; + try { + if (options.includeTerminalAction && index === 0) { + actionLabel = "add-real-terminal-tab"; + await withTimeout( + activeBridge.addRealTerminalTab(), + 3000, + "add-real-terminal-tab", + ); + heavyActionCounts["add-real-terminal-tab"] = + (heavyActionCounts["add-real-terminal-tab"] ?? 0) + 1; + } else if (action === 0) { + actionLabel = `add-tab:${kind}`; + activeBridge.addTab(kind, index, (index % 4) + 1); + heavyActionCounts["add-tab"] = + (heavyActionCounts["add-tab"] ?? 0) + 1; + } else if (action === 1 || action === 2) { + actionLabel = `split-active-pane:${kind}`; + activeBridge.splitActivePane(kind, index); + heavyActionCounts["split-active-pane"] = + (heavyActionCounts["split-active-pane"] ?? 0) + 1; + } else if (action === 3) { + actionLabel = `open-pane:${kind}`; + activeBridge.openPane(kind, index); + heavyActionCounts["open-pane"] = + (heavyActionCounts["open-pane"] ?? 0) + 1; + } else if (action === 4 || action === 5) { + actionLabel = "switch-tab"; + activeBridge.switchTab(index); + heavyActionCounts["switch-tab"] = + (heavyActionCounts["switch-tab"] ?? 0) + 1; + } else if (action === 6 || action === 7) { + actionLabel = "churn-active-pane-data"; + activeBridge.churnActivePaneData(index); + heavyActionCounts["churn-active-pane-data"] = + (heavyActionCounts["churn-active-pane-data"] ?? 0) + 1; + } else if (action === 8) { + actionLabel = "close-active-pane"; + activeBridge.closeActivePane(); + heavyActionCounts["close-active-pane"] = + (heavyActionCounts["close-active-pane"] ?? 0) + 1; + } else if (action === 9) { + actionLabel = "close-oldest-tab"; + activeBridge.closeOldestTab(10); + heavyActionCounts["close-oldest-tab"] = + (heavyActionCounts["close-oldest-tab"] ?? 0) + 1; + } else { + actionLabel = `add-single-pane-tab:${kind}`; + activeBridge.addTab(kind, index); + heavyActionCounts["add-single-pane-tab"] = + (heavyActionCounts["add-single-pane-tab"] ?? 0) + 1; + } + } catch (error) { + heavyActionErrors.push( + `heavy action ${index} failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + recordOperationDuration( + "workspace-heavy", + actionLabel, + index, + operationStartedAt, + ); + operationCount += 1; + reportProgress("workspace-heavy", index + 1, heavyIterations); + await sleep(options.intervalMs); + } + workspaceSummary = activeBridge.getSummary(); + activeBridge.restoreBaseline(); + heavyActionCounts["restore-baseline"] = 1; + operationCount += 1; + } + + if (shouldRunTerminalHeavy) { + const activeBridge = await waitForBridge(workspaceId); + const terminalTabCount = Math.max( + 1, + Math.min(80, Math.floor(options.terminalTabCount)), + ); + const terminalPanesPerTab = Math.max( + 1, + Math.min(8, Math.floor(options.terminalPanesPerTab)), + ); + const webglLossCadence = Math.max(4, Math.floor(terminalTabCount / 2)); + + activeBridge.captureBaseline(); + try { + let operationStartedAt = performance.now(); + activeBridge.replaceWithGeneratedTerminalLayout( + terminalTabCount, + terminalPanesPerTab, + ); + await nextFrame(); + recordOperationDuration( + "terminal-heavy", + "replace-generated-terminal-layout", + -1, + operationStartedAt, + ); + terminalActionCounts["replace-generated-terminal-layout"] = 1; + operationCount += 1; + reportProgress("terminal-heavy", 0, terminalIterations, true); + + for (let index = 0; index < terminalIterations; index += 1) { + operationStartedAt = performance.now(); + let actionLabel = "switch-write-terminal-output"; + try { + activeBridge.switchTab(index % terminalTabCount); + terminalActionCounts["switch-tab"] = + (terminalActionCounts["switch-tab"] ?? 0) + 1; + await nextFrame(); + if (options.intervalMs > 0) { + await sleep(options.intervalMs); + } + + const writeResult = await withTimeout( + activeBridge.writeTerminalStressOutput( + index, + options.terminalLines, + options.terminalPayloadBytes, + ), + 15_000, + "write-terminal-stress-output", + ); + terminalActionCounts["write-output"] = + (terminalActionCounts["write-output"] ?? 0) + 1; + if ( + index >= terminalTabCount && + writeResult.failedCount > 0 && + terminalActionErrors.length < 20 + ) { + terminalActionErrors.push( + `terminal write ${index} reached ${writeResult.writtenCount}/${writeResult.terminalCount} runtimes (${writeResult.byteLength} bytes)`, + ); + } + + if ( + options.forceTerminalWebglLoss && + index > 0 && + index % webglLossCadence === 0 + ) { + actionLabel = "switch-write-force-webgl-context-loss"; + const lossResult = activeBridge.forceTerminalWebglContextLoss(); + terminalWebglContextLosses.push({ + index, + ...lossResult, + }); + terminalActionCounts["force-webgl-context-loss"] = + (terminalActionCounts["force-webgl-context-loss"] ?? 0) + 1; + operationCount += 1; + } + } catch (error) { + terminalActionErrors.push( + `terminal action ${index} failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + recordOperationDuration( + "terminal-heavy", + actionLabel, + index, + operationStartedAt, + ); + operationCount += 1; + reportProgress("terminal-heavy", index + 1, terminalIterations); + } + + if ( + options.forceTerminalWebglLoss && + terminalWebglContextLosses.some( + (result) => result.lostContextCount > 0, + ) + ) { + await sleep(webglContextRecoverySettleMs); + } + + terminalStressSummary = activeBridge.getTerminalStressSummary(); + } finally { + activeBridge.restoreBaseline(); + terminalActionCounts["restore-baseline"] = 1; + operationCount += 1; + } + } + await sleep(options.settleMs); + + const durationMs = performance.now() - startedAt; + const maxLongTaskDurationMs = longTasks.reduce( + (max, task) => Math.max(max, task.duration), + 0, + ); + return { + scenario: options.scenario, + iterations: options.iterations, + operationCount, + targetCount: targets.length, + activationModeCounts, + routeCount, + routeIterations: shouldRunRouteSweep ? routeIterations : 0, + routesVisited: Array.from(new Set(routesVisited)), + heavyIterations: shouldRunWorkspaceHeavy ? heavyIterations : 0, + heavyActionCounts, + heavyActionErrors: heavyActionErrors.slice(0, 20), + heavyActionCatalogue, + terminalIterations: shouldRunTerminalHeavy ? terminalIterations : 0, + terminalActionCounts, + terminalActionErrors: terminalActionErrors.slice(0, 20), + terminalStressSummary, + terminalWebglContextLosses: terminalWebglContextLosses.slice(-20), + workspaceSummary, + reactProbeSummary: reactProbe.getSummary(), + durationMs, + maxHeartbeatDelayMs, + heartbeatDelaySamples: heartbeatDelaySamples.slice(-20), + maxLongTaskDurationMs, + longTaskCount: longTasks.length, + longTasks: longTasks + .slice() + .sort((left, right) => right.duration - left.duration) + .slice(0, 10), + slowOperations: slowOperations + .slice() + .sort((left, right) => right.durationMs - left.durationMs) + .slice(0, 20), + errorCount: errors.length, + errors: errors.slice(0, 20), + startMemory, + endMemory: stressWindow.performance.memory ?? null, + finalLocation: window.location.href, + }; + })().finally(() => { + clearInterval(heartbeat); + reactProbe.cleanup(); + longTaskObserver?.disconnect(); + window.removeEventListener("error", onError); + window.removeEventListener("unhandledrejection", onUnhandledRejection); + }); +} + +async function main() { + const args = parseArgs(Bun.argv.slice(2)); + if (args.help) { + console.log(usage()); + return; + } + + const target = await getRendererTarget(args); + if (!args.json) { + console.log( + `[stress:renderer] attaching to ${target.title || target.url || "renderer"}`, + ); + } + const cdp = await CdpClient.connect( + target.webSocketDebuggerUrl ?? "", + args.timeoutMs, + ); + + try { + await cdp.send("Runtime.enable", {}, args.timeoutMs); + await cdp.send("Performance.enable", {}, args.timeoutMs); + const startDomCounters = await cdp + .send("Memory.getDOMCounters", {}, args.timeoutMs) + .catch((error) => ({ error: String(error) })); + if (args.profileCpu) { + await cdp.send("Profiler.enable", {}, args.timeoutMs); + await cdp.send("Profiler.setSamplingInterval", { interval: 1000 }); + await cdp.send("Profiler.start", {}, args.timeoutMs); + } + const removeConsoleListener = cdp.on( + "Runtime.consoleAPICalled", + (params) => { + if (args.json) return; + const event = params as RuntimeConsoleApiCalledEvent; + const firstArg = event.args?.[0]; + if (firstArg?.value !== "[stress:renderer:progress]") return; + const payload = event.args?.[1]?.value; + if (typeof payload !== "string") return; + try { + const progress = JSON.parse(payload) as { + phase?: string; + index?: number; + total?: number; + operationCount?: number; + elapsedMs?: number; + }; + console.log( + `[stress:renderer] ${progress.phase}: ${progress.index}/${progress.total} operations=${progress.operationCount} elapsed=${progress.elapsedMs}ms`, + ); + } catch { + console.log(`[stress:renderer] ${payload}`); + } + }, + ); + const evaluation = await cdp.send( + "Runtime.evaluate", + { + expression: `(${rendererStress.toString()})(${JSON.stringify({ + scenario: args.scenario, + iterations: args.iterations, + routeIterations: args.routeIterations, + heavyIterations: args.heavyIterations, + terminalIterations: args.terminalIterations, + terminalTabCount: args.terminalTabCount, + terminalPanesPerTab: args.terminalPanesPerTab, + terminalLines: args.terminalLines, + terminalPayloadBytes: args.terminalPayloadBytes, + forceTerminalWebglLoss: args.forceTerminalWebglLoss, + includeTerminalAction: args.includeTerminalAction, + reactProbe: args.reactProbe, + progressEvery: args.progressEvery, + intervalMs: args.intervalMs, + settleMs: args.settleMs, + selector: args.selector, + workspaceIds: args.workspaceIds, + })})`, + awaitPromise: true, + returnByValue: true, + }, + args.timeoutMs, + ); + removeConsoleListener(); + const cpuProfileTopFrames = args.profileCpu + ? await cdp + .send("Profiler.stop", {}, args.timeoutMs) + .then((result) => + result.profile ? summarizeCpuProfile(result.profile) : [], + ) + .catch((error) => [{ error: String(error) }]) + : null; + + if (evaluation.exceptionDetails) { + throw new Error( + evaluation.exceptionDetails.exception?.description ?? + evaluation.exceptionDetails.text ?? + "Renderer stress script threw", + ); + } + + const summary = evaluation.result?.value as RendererStressResult; + const cdpMetrics = await cdp + .send("Performance.getMetrics", {}, args.timeoutMs) + .catch((error) => ({ error: String(error) })); + const endDomCounters = await cdp + .send("Memory.getDOMCounters", {}, args.timeoutMs) + .catch((error) => ({ error: String(error) })); + const output = { + ...summary, + cdpMetrics, + cdpDomCounters: { + start: startDomCounters, + end: endDomCounters, + }, + cpuProfileTopFrames, + thresholds: { + maxHeartbeatDelayMs: args.maxHeartbeatDelayMs, + maxLongTaskMs: args.maxLongTaskMs, + }, + }; + + const failures: string[] = []; + if (summary.errorCount > 0) { + failures.push(`${summary.errorCount} renderer error(s) observed`); + } + if (summary.terminalActionErrors.length > 0) { + failures.push( + `${summary.terminalActionErrors.length} terminal stress error(s) observed`, + ); + } + if (summary.maxHeartbeatDelayMs > args.maxHeartbeatDelayMs) { + failures.push( + `heartbeat delay ${summary.maxHeartbeatDelayMs.toFixed( + 1, + )}ms exceeded ${args.maxHeartbeatDelayMs}ms`, + ); + } + if (summary.maxLongTaskDurationMs > args.maxLongTaskMs) { + failures.push( + `long task ${summary.maxLongTaskDurationMs.toFixed( + 1, + )}ms exceeded ${args.maxLongTaskMs}ms`, + ); + } + + if (args.json) { + console.log(JSON.stringify({ ...output, failures }, null, 2)); + } else { + console.log(JSON.stringify(output, null, 2)); + if (failures.length > 0) { + console.error(`[stress:renderer] failed: ${failures.join("; ")}`); + } else { + console.log("[stress:renderer] passed"); + } + } + + if (failures.length > 0) process.exitCode = 1; + } finally { + cdp.close(); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + console.error(""); + console.error(usage()); + process.exit(1); +}); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index c4913ecdeb2..5a0c146f013 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -53,6 +53,24 @@ import { MainWindow } from "./windows/main"; console.log("[main] Local database ready:", !!localDb); const IS_DEV = process.env.NODE_ENV === "development"; +const rendererStressCdpPort = + process.env.SUPERSET_RENDERER_STRESS_CDP_PORT?.trim(); + +if (IS_DEV && rendererStressCdpPort) { + if (/^\d+$/.test(rendererStressCdpPort)) { + app.commandLine.appendSwitch( + "remote-debugging-port", + rendererStressCdpPort, + ); + console.log( + `[main] Renderer stress CDP enabled on 127.0.0.1:${rendererStressCdpPort}`, + ); + } else { + console.warn( + `[main] Ignoring invalid SUPERSET_RENDERER_STRESS_CDP_PORT=${rendererStressCdpPort}`, + ); + } +} void applyShellEnvToProcess().catch((error) => { console.error("[main] Failed to apply shell environment:", error); diff --git a/apps/desktop/src/main/lib/extensions/index.ts b/apps/desktop/src/main/lib/extensions/index.ts index b49d48afbe9..0af5d81ba43 100644 --- a/apps/desktop/src/main/lib/extensions/index.ts +++ b/apps/desktop/src/main/lib/extensions/index.ts @@ -169,6 +169,7 @@ function resolveWebviewExtensionPath(): string | null { export async function loadReactDevToolsExtension(): Promise { if (env.NODE_ENV !== "development") return; + if (process.env.SUPERSET_RENDERER_STRESS_CDP_PORT) return; const extensionPath = resolveReactDevToolsPath(); if (!extensionPath) { diff --git a/apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts b/apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts index 1fe9dcc5f39..2181b86dc0a 100644 --- a/apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts +++ b/apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts @@ -9,7 +9,15 @@ export interface DiffStats { deletions: number; } -export function useDiffStats(workspaceId: string): DiffStats | null { +interface UseDiffStatsOptions { + enabled?: boolean; +} + +export function useDiffStats( + workspaceId: string, + options: UseDiffStatsOptions = {}, +): DiffStats | null { + const { enabled = true } = options; const hostUrl = useWorkspaceHostUrl(workspaceId); const queryClient = useQueryClient(); const queryKey = useMemo( @@ -17,12 +25,12 @@ export function useDiffStats(workspaceId: string): DiffStats | null { [hostUrl, workspaceId], ); - const { data: status } = useQuery({ + const { data: stats } = useQuery({ queryKey, - enabled: Boolean(workspaceId) && Boolean(hostUrl), + enabled: enabled && Boolean(workspaceId) && Boolean(hostUrl), queryFn: () => { if (!hostUrl) return null; - return getHostServiceClientByUrl(hostUrl).git.getStatus.query({ + return getHostServiceClientByUrl(hostUrl).git.getDiffStats.query({ workspaceId, }); }, @@ -34,22 +42,7 @@ export function useDiffStats(workspaceId: string): DiffStats | null { void queryClient.invalidateQueries({ queryKey }); }, [queryClient, queryKey]); - useWorkspaceEvent("git:changed", workspaceId, invalidate); - - return useMemo(() => { - if (!status) return null; - - const byPath = new Map(); - for (const file of status.againstBase) byPath.set(file.path, file); - for (const file of status.staged) byPath.set(file.path, file); - for (const file of status.unstaged) byPath.set(file.path, file); + useWorkspaceEvent("git:changed", workspaceId, invalidate, enabled); - let additions = 0; - let deletions = 0; - for (const file of byPath.values()) { - additions += file.additions; - deletions += file.deletions; - } - return { additions, deletions }; - }, [status]); + return stats ?? null; } diff --git a/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts b/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts index 0596ef443b4..6fee440a84c 100644 --- a/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts +++ b/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts @@ -13,18 +13,21 @@ import { useWorkspaceEvent } from "../useWorkspaceEvent"; * both `.git/` metadata writes and worktree file edits — no client-side * debounce needed. */ -export function useGitStatus(workspaceId: string) { +export function useGitStatus(workspaceId: string, enabled = true) { const utils = workspaceTrpc.useUtils(); const baseBranchQuery = workspaceTrpc.git.getBaseBranch.useQuery( { workspaceId }, - { staleTime: Number.POSITIVE_INFINITY, enabled: Boolean(workspaceId) }, + { + staleTime: Number.POSITIVE_INFINITY, + enabled: enabled && Boolean(workspaceId), + }, ); const baseBranch = baseBranchQuery.data?.baseBranch ?? null; const query = workspaceTrpc.git.getStatus.useQuery( { workspaceId, baseBranch: baseBranch ?? undefined }, - { refetchOnWindowFocus: true, enabled: Boolean(workspaceId) }, + { refetchOnWindowFocus: true, enabled: enabled && Boolean(workspaceId) }, ); const invalidate = useCallback(() => { @@ -35,7 +38,7 @@ export function useGitStatus(workspaceId: string) { void utils.git.getBaseBranch.invalidate({ workspaceId }); }, [utils, workspaceId]); - useWorkspaceEvent("git:changed", workspaceId, invalidate); + useWorkspaceEvent("git:changed", workspaceId, invalidate, enabled); return query; } diff --git a/apps/desktop/src/renderer/index.tsx b/apps/desktop/src/renderer/index.tsx index 3f951aa1b89..eeee7e80d08 100644 --- a/apps/desktop/src/renderer/index.tsx +++ b/apps/desktop/src/renderer/index.tsx @@ -42,6 +42,19 @@ const handleDeepLink = (path: string) => { console.log("[deep-link] Navigating to:", path); router.navigate({ to: path }); }; + +declare global { + interface Window { + __SUPERSET_RENDERER_STRESS_NAVIGATE__?: (path: string) => Promise; + } +} + +if (process.env.NODE_ENV === "development") { + window.__SUPERSET_RENDERER_STRESS_NAVIGATE__ = async (path: string) => { + await router.navigate({ to: path }); + }; +} + const ipcRenderer = window.ipcRenderer as typeof window.ipcRenderer | undefined; if (ipcRenderer) { ipcRenderer.on("deep-link-navigate", handleDeepLink); diff --git a/apps/desktop/src/renderer/lib/posthog.test.ts b/apps/desktop/src/renderer/lib/posthog.test.ts new file mode 100644 index 00000000000..2d4405825f2 --- /dev/null +++ b/apps/desktop/src/renderer/lib/posthog.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "bun:test"; +import { + buildPostHogInitConfig, + POSTHOG_SESSION_REPLAY_BLOCK_SELECTOR, +} from "./posthog"; + +describe("buildPostHogInitConfig", () => { + it("blocks terminal replay surfaces and disables canvas recording", () => { + const config = buildPostHogInitConfig(); + + expect(config.disable_session_recording).toBe(true); + expect(config.autocapture).toBe(false); + expect(config.disable_scroll_properties).toBe(true); + expect(POSTHOG_SESSION_REPLAY_BLOCK_SELECTOR).toContain(".xterm"); + expect(POSTHOG_SESSION_REPLAY_BLOCK_SELECTOR).toContain( + "[data-terminal-webgl-canvas]", + ); + expect(config.session_recording?.blockSelector).toBe( + POSTHOG_SESSION_REPLAY_BLOCK_SELECTOR, + ); + expect(config.session_recording?.captureCanvas).toEqual({ + recordCanvas: false, + canvasFps: 0, + canvasQuality: "0", + }); + }); +}); diff --git a/apps/desktop/src/renderer/lib/posthog.ts b/apps/desktop/src/renderer/lib/posthog.ts index 180e2ca11d2..fa6946e8407 100644 --- a/apps/desktop/src/renderer/lib/posthog.ts +++ b/apps/desktop/src/renderer/lib/posthog.ts @@ -1,30 +1,61 @@ +import type { PostHogConfig } from "posthog-js/dist/module.full.no-external"; import posthogFull from "posthog-js/dist/module.full.no-external"; import type { PostHog } from "posthog-js/react"; import { env } from "../env.renderer"; +import { + TERMINAL_SESSION_REPLAY_BLOCK_ATTRIBUTE, + TERMINAL_SESSION_REPLAY_BLOCK_CLASS, +} from "./terminal/terminal-session-replay"; // Cast to standard PostHog type for compatibility with posthog-js/react export const posthog = posthogFull as unknown as PostHog; -export function initPostHog() { - if (!env.NEXT_PUBLIC_POSTHOG_KEY) { - console.log("[posthog] No key configured, skipping"); - return; - } +export const POSTHOG_SESSION_REPLAY_BLOCK_SELECTOR = [ + `.${TERMINAL_SESSION_REPLAY_BLOCK_CLASS}`, + `[${TERMINAL_SESSION_REPLAY_BLOCK_ATTRIBUTE}]`, + "[data-ph-no-capture]", + "[data-terminal-webgl-canvas]", + ".xterm", + ".xterm-screen", + ".xterm-viewport", + ".xterm-helper-textarea", +].join(", "); - posthogFull.init(env.NEXT_PUBLIC_POSTHOG_KEY, { +export function buildPostHogInitConfig(): Partial { + return { api_host: env.NEXT_PUBLIC_POSTHOG_HOST, defaults: "2025-11-30", + autocapture: false, capture_pageview: false, capture_pageleave: false, capture_exceptions: true, + disable_scroll_properties: true, + disable_session_recording: true, person_profiles: "identified_only", persistence: "localStorage", debug: false, + session_recording: { + blockSelector: POSTHOG_SESSION_REPLAY_BLOCK_SELECTOR, + captureCanvas: { + recordCanvas: false, + canvasFps: 0, + canvasQuality: "0", + }, + }, loaded: (ph) => { ph.register({ app_name: "desktop", platform: window.navigator.platform, }); }, - }); + }; +} + +export function initPostHog() { + if (!env.NEXT_PUBLIC_POSTHOG_KEY) { + console.log("[posthog] No key configured, skipping"); + return; + } + + posthogFull.init(env.NEXT_PUBLIC_POSTHOG_KEY, buildPostHogInitConfig()); } diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts b/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts index 85faa897cdb..a8cc2fa946c 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts @@ -1,11 +1,11 @@ import { ClipboardAddon } from "@xterm/addon-clipboard"; -import { ImageAddon } from "@xterm/addon-image"; import { LigaturesAddon } from "@xterm/addon-ligatures"; import { ProgressAddon } from "@xterm/addon-progress"; import { SearchAddon } from "@xterm/addon-search"; import { Unicode11Addon } from "@xterm/addon-unicode11"; -import { WebglAddon } from "@xterm/addon-webgl"; import type { Terminal as XTerm } from "@xterm/xterm"; +import { createTerminalImageAddon } from "./terminal-image-addon"; +import { loadTerminalWebglAddon } from "./terminal-webgl-addon-controller"; export interface LoadAddonsResult { searchAddon: SearchAddon; @@ -13,25 +13,19 @@ export interface LoadAddonsResult { dispose: () => void; } -// Once WebGL fails, skip it for all subsequent runtimes (VS Code pattern). -let suggestedRendererType: "webgl" | "dom" | undefined; - /** * Load optional addons onto an already-opened terminal. Returns a cleanup - * function and addon instances. WebGL is deferred to rAF to avoid - * racing with xterm's post-open viewport sync. + * function and addon instances. WebGL setup/teardown is delegated to + * loadTerminalWebglAddon. */ export function loadAddons(terminal: XTerm): LoadAddonsResult { - let disposed = false; - let webglAddon: WebglAddon | null = null; - terminal.loadAddon(new ClipboardAddon()); const unicode11 = new Unicode11Addon(); terminal.loadAddon(unicode11); terminal.unicode.activeVersion = "11"; - terminal.loadAddon(new ImageAddon()); + terminal.loadAddon(createTerminalImageAddon()); const searchAddon = new SearchAddon(); terminal.loadAddon(searchAddon); @@ -43,33 +37,13 @@ export function loadAddons(terminal: XTerm): LoadAddonsResult { terminal.loadAddon(new LigaturesAddon()); } catch {} - const rafId = requestAnimationFrame(() => { - if (disposed || suggestedRendererType === "dom") return; - - try { - webglAddon = new WebglAddon(); - webglAddon.onContextLoss(() => { - webglAddon?.dispose(); - webglAddon = null; - terminal.refresh(0, terminal.rows - 1); - }); - terminal.loadAddon(webglAddon); - } catch { - suggestedRendererType = "dom"; - webglAddon = null; - } - }); + const webglAddon = loadTerminalWebglAddon(terminal); return { searchAddon, progressAddon, dispose: () => { - disposed = true; - cancelAnimationFrame(rafId); - try { - webglAddon?.dispose(); - } catch {} - webglAddon = null; + webglAddon.dispose(); }, }; } diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-image-addon.test.ts b/apps/desktop/src/renderer/lib/terminal/terminal-image-addon.test.ts new file mode 100644 index 00000000000..ca99e180ae1 --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-image-addon.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, mock } from "bun:test"; + +const imageAddonOptions: unknown[] = []; + +class FakeImageAddon { + constructor(options: unknown) { + imageAddonOptions.push(options); + } +} + +mock.module("@xterm/addon-image", () => ({ + ImageAddon: FakeImageAddon, +})); + +const { createTerminalImageAddon, TERMINAL_IMAGE_ADDON_OPTIONS } = await import( + "./terminal-image-addon" +); + +describe("createTerminalImageAddon", () => { + it("bounds per-terminal image decoder memory", () => { + imageAddonOptions.length = 0; + + const addon = createTerminalImageAddon(); + + expect(addon).toBeInstanceOf(FakeImageAddon); + expect(imageAddonOptions).toEqual([TERMINAL_IMAGE_ADDON_OPTIONS]); + expect(TERMINAL_IMAGE_ADDON_OPTIONS).toMatchObject({ + iipSupport: true, + kittySupport: true, + pixelLimit: 1_048_576, + sixelSupport: false, + storageLimit: 16, + }); + }); +}); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-image-addon.ts b/apps/desktop/src/renderer/lib/terminal/terminal-image-addon.ts new file mode 100644 index 00000000000..58986a834c1 --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-image-addon.ts @@ -0,0 +1,17 @@ +import { type IImageAddonOptions, ImageAddon } from "@xterm/addon-image"; + +export const TERMINAL_IMAGE_ADDON_OPTIONS = { + enableSizeReports: true, + iipSizeLimit: 8_000_000, + iipSupport: true, + kittySizeLimit: 8_000_000, + kittySupport: true, + pixelLimit: 1_048_576, + showPlaceholder: true, + sixelSupport: false, + storageLimit: 16, +} satisfies IImageAddonOptions; + +export function createTerminalImageAddon(): ImageAddon { + return new ImageAddon(TERMINAL_IMAGE_ADDON_OPTIONS); +} diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts new file mode 100644 index 00000000000..c45684de03d --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts @@ -0,0 +1,421 @@ +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + it, + mock, +} from "bun:test"; +import type { TerminalAppearance } from "./appearance"; +import type { TerminalRuntime } from "./terminal-runtime"; + +type FakeTerminal = TerminalRuntime["terminal"] & { + emitWriteComplete: () => void; +}; + +type FakeRuntime = TerminalRuntime & { + terminal: FakeTerminal; + wrapper: HTMLDivElement & { + _canvasList: HTMLCanvasElement[]; + connected: boolean; + }; +}; + +type MockedTerminalRuntimeModule = typeof import("./terminal-runtime"); + +const createdRuntimes: FakeRuntime[] = []; +const createRuntimeMock = mock( + ( + _terminalId: string, + _appearance: TerminalAppearance, + _options?: { initialBuffer?: string }, + ): TerminalRuntime => { + const runtime = createFakeRuntime(_terminalId); + createdRuntimes.push(runtime); + return runtime; + }, +); +const attachToContainerMock = mock( + ( + runtime: TerminalRuntime, + container: HTMLDivElement, + onResize?: () => void, + ) => { + runtime.container = container; + const wrapper = runtime.wrapper as FakeRuntime["wrapper"]; + wrapper.connected = false; + onResize?.(); + }, +); +const detachFromContainerMock = mock((runtime: TerminalRuntime) => { + runtime.container = null; + const wrapper = runtime.wrapper as FakeRuntime["wrapper"]; + wrapper.connected = true; +}); +const disposeRuntimeMock = mock((runtime: TerminalRuntime) => { + runtime.container = null; + const wrapper = runtime.wrapper as FakeRuntime["wrapper"]; + wrapper.connected = false; +}); +const updateRuntimeAppearanceMock = mock( + (_runtime: TerminalRuntime, _appearance: TerminalAppearance) => {}, +); +const focusRuntimeMock = mock((_runtime: TerminalRuntime) => {}); +const writeRuntimeOutputMock = mock( + ( + runtime: TerminalRuntime, + data: string | Uint8Array, + callback?: () => void, + ) => { + if ((typeof data === "string" ? data.length : data.byteLength) > 0) { + runtime.hasBufferedContent = true; + } + runtime.terminal.write(data, callback); + }, +); +const shouldReplayTerminalRuntimeMock = mock( + (runtime: TerminalRuntime) => !runtime.hasBufferedContent, +); + +mock.module( + "./terminal-runtime", + (): Partial => ({ + attachToContainer: attachToContainerMock, + createRuntime: createRuntimeMock, + detachFromContainer: detachFromContainerMock, + disposeRuntime: disposeRuntimeMock, + focusRuntime: focusRuntimeMock, + shouldReplayTerminalRuntime: shouldReplayTerminalRuntimeMock, + updateRuntimeAppearance: updateRuntimeAppearanceMock, + writeRuntimeOutput: writeRuntimeOutputMock, + }), +); + +const { terminalRuntimeRegistry } = await import("./terminal-runtime-registry"); + +const appearance: TerminalAppearance = { + background: "#000000", + fontFamily: "Menlo", + fontSize: 13, + theme: {}, +}; + +function createFakeTerminal(): FakeTerminal { + let writeCallback: (() => void) | undefined; + return { + cols: 120, + rows: 32, + write: mock((_data: string | Uint8Array, callback?: () => void) => { + writeCallback = callback; + }), + emitWriteComplete: () => writeCallback?.(), + getSelection: mock(() => ""), + clear: mock(() => {}), + scrollToBottom: mock(() => {}), + paste: mock((_text: string) => {}), + options: { linkHandler: null }, + } as unknown as FakeTerminal; +} + +function createFakeRuntime(terminalId: string): FakeRuntime { + const wrapper = { + _canvasList: [] as HTMLCanvasElement[], + connected: false, + querySelectorAll: mock((_selector: string) => wrapper._canvasList), + } as unknown as FakeRuntime["wrapper"]; + Object.defineProperty(wrapper, "isConnected", { + get: () => wrapper.connected, + }); + + return { + terminalId, + terminal: createFakeTerminal(), + fitAddon: {} as TerminalRuntime["fitAddon"], + serializeAddon: { + serialize: mock(() => `serialized:${terminalId}`), + } as unknown as TerminalRuntime["serializeAddon"], + searchAddon: null, + progressAddon: null, + wrapper, + container: null, + resizeObserver: null, + _disposeResizeObserver: null, + lastCols: 120, + lastRows: 32, + _disposeAddons: null, + _disposeImagePasteFallback: null, + _outputQueue: [], + _outputEnqueued: false, + _outputQueuedBytes: 0, + hasBufferedContent: false, + }; +} + +function createContainer(): HTMLDivElement { + return {} as HTMLDivElement; +} + +function createCanvas(options: { + isTerminalWebglCanvas?: boolean; + context?: WebGL2RenderingContext | null; +}): HTMLCanvasElement & { + getContext: ReturnType; +} { + const attributes = new Map(); + if (options.isTerminalWebglCanvas) { + attributes.set("data-terminal-webgl-canvas", "true"); + } + const canvas = { + getAttribute: mock((name: string) => attributes.get(name) ?? null), + getContext: mock((_contextId: string) => options.context ?? null), + } as unknown as HTMLCanvasElement & { + getContext: ReturnType; + }; + + return canvas; +} + +function createWebglContext(extension: unknown): WebGL2RenderingContext { + return { + getExtension: mock((_name: string) => extension), + } as unknown as WebGL2RenderingContext; +} + +function clearRegistry() { + for (const terminalId of terminalRuntimeRegistry.getAllTerminalIds()) { + terminalRuntimeRegistry.dispose(terminalId); + } +} + +beforeEach(() => { + clearRegistry(); + for (const fn of [ + createRuntimeMock, + attachToContainerMock, + detachFromContainerMock, + disposeRuntimeMock, + updateRuntimeAppearanceMock, + focusRuntimeMock, + shouldReplayTerminalRuntimeMock, + writeRuntimeOutputMock, + ]) { + fn.mockClear(); + } + createdRuntimes.length = 0; +}); + +afterEach(() => { + clearRegistry(); +}); + +afterAll(() => { + clearRegistry(); + mock.restore(); +}); + +describe("terminalRuntimeRegistry", () => { + it("reuses the renderer runtime across detach and remount", () => { + const firstContainer = createContainer(); + const secondContainer = createContainer(); + + terminalRuntimeRegistry.mount( + "terminal-1", + firstContainer, + appearance, + "workspace-a", + ); + terminalRuntimeRegistry.detach("terminal-1", "workspace-a"); + terminalRuntimeRegistry.mount( + "terminal-1", + secondContainer, + appearance, + "workspace-a", + ); + + expect(createRuntimeMock).toHaveBeenCalledTimes(1); + expect(attachToContainerMock).toHaveBeenCalledTimes(2); + expect(detachFromContainerMock).toHaveBeenCalledTimes(1); + expect(updateRuntimeAppearanceMock).toHaveBeenCalledTimes(1); + expect(terminalRuntimeRegistry.getStressDebugInfo("terminal-1")).toEqual([ + expect.objectContaining({ + terminalId: "terminal-1", + instanceId: "workspace-a", + hasRuntime: true, + isAttached: true, + }), + ]); + }); + + it("reuses a parked runtime when a background terminal opens in a new pane", () => { + terminalRuntimeRegistry.mount( + "terminal-1", + createContainer(), + appearance, + "old-pane", + ); + terminalRuntimeRegistry.detach("terminal-1", "old-pane"); + + terminalRuntimeRegistry.mount( + "terminal-1", + createContainer(), + appearance, + "new-pane", + ); + + expect(createRuntimeMock).toHaveBeenCalledTimes(1); + expect(attachToContainerMock).toHaveBeenCalledTimes(2); + expect(terminalRuntimeRegistry.getStressDebugInfo("terminal-1")).toEqual([ + expect.objectContaining({ + terminalId: "terminal-1", + instanceId: "new-pane", + hasRuntime: true, + isAttached: true, + }), + ]); + }); + + it("releasing one terminal instance leaves sibling instances alive", () => { + terminalRuntimeRegistry.mount( + "terminal-1", + createContainer(), + appearance, + "workspace-a", + ); + terminalRuntimeRegistry.mount( + "terminal-1", + createContainer(), + appearance, + "workspace-b", + ); + + terminalRuntimeRegistry.release("terminal-1", "workspace-a"); + + expect(disposeRuntimeMock).toHaveBeenCalledTimes(1); + expect(terminalRuntimeRegistry.getStressDebugInfo("terminal-1")).toEqual([ + expect.objectContaining({ + terminalId: "terminal-1", + instanceId: "workspace-b", + hasRuntime: true, + }), + ]); + expect(terminalRuntimeRegistry.getAllTerminalIds()).toEqual( + new Set(["terminal-1"]), + ); + }); + + it("does not expand stress queries when only an instance id is provided", () => { + terminalRuntimeRegistry.mount( + "terminal-a", + createContainer(), + appearance, + "instance-a", + ); + terminalRuntimeRegistry.mount( + "terminal-b", + createContainer(), + appearance, + "instance-b", + ); + + expect( + terminalRuntimeRegistry.getStressDebugInfo(undefined, "instance-a"), + ).toEqual([]); + expect( + terminalRuntimeRegistry.forceWebglContextLossForStress( + undefined, + "instance-a", + ), + ).toEqual({ + terminalCount: 0, + canvasCount: 0, + webglContextCount: 0, + lostContextCount: 0, + unsupportedContextCount: 0, + }); + }); + + it("can force WebGL context loss on terminal canvases during stress runs", () => { + const loseContext = mock(() => {}); + const canvas = createCanvas({ + isTerminalWebglCanvas: true, + context: createWebglContext({ loseContext }), + }); + + terminalRuntimeRegistry.mount( + "terminal-1", + createContainer(), + appearance, + "workspace-a", + ); + const runtime = createdRuntimes[0]; + if (!runtime) throw new Error("expected runtime"); + runtime.wrapper._canvasList = [canvas]; + + const result = terminalRuntimeRegistry.forceWebglContextLossForStress( + "terminal-1", + "workspace-a", + ); + + expect(result).toEqual({ + terminalCount: 1, + canvasCount: 1, + webglContextCount: 1, + lostContextCount: 1, + unsupportedContextCount: 0, + }); + expect(canvas.getContext).toHaveBeenCalled(); + expect(loseContext).toHaveBeenCalled(); + }); + + it("does not create WebGL contexts on unmarked stress canvases", () => { + const canvas = createCanvas({ + context: createWebglContext({ loseContext: mock(() => {}) }), + }); + + terminalRuntimeRegistry.mount( + "terminal-1", + createContainer(), + appearance, + "workspace-a", + ); + const runtime = createdRuntimes[0]; + if (!runtime) throw new Error("expected runtime"); + runtime.wrapper._canvasList = [canvas]; + + const result = terminalRuntimeRegistry.forceWebglContextLossForStress( + "terminal-1", + "workspace-a", + ); + + expect(result).toEqual({ + terminalCount: 1, + canvasCount: 1, + webglContextCount: 0, + lostContextCount: 0, + unsupportedContextCount: 0, + }); + expect(canvas.getContext).not.toHaveBeenCalled(); + }); + + it("accepts stress output without waiting for xterm write completion", async () => { + terminalRuntimeRegistry.mount( + "terminal-1", + createContainer(), + appearance, + "workspace-a", + ); + + const accepted = await terminalRuntimeRegistry.writeForStress( + "terminal-1", + "large output", + "workspace-a", + ); + + expect(accepted).toBe(true); + expect(writeRuntimeOutputMock).toHaveBeenCalledWith( + createdRuntimes[0], + "large output", + ); + }); +}); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts index c9dad761712..2d8a1cd7546 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -11,9 +11,13 @@ import { createRuntime, detachFromContainer, disposeRuntime, + focusRuntime, + shouldReplayTerminalRuntime, type TerminalRuntime, updateRuntimeAppearance, + writeRuntimeOutput, } from "./terminal-runtime"; +import { isTerminalWebglCanvas } from "./terminal-webgl-canvas-registry"; import { type ConnectionState, clearLogs, @@ -37,6 +41,75 @@ interface RegistryEntry { pendingLinkHandlers: TerminalLinkHandlers | null; } +export interface TerminalWebglContextLossResult { + terminalCount: number; + canvasCount: number; + webglContextCount: number; + lostContextCount: number; + unsupportedContextCount: number; +} + +export interface TerminalRuntimeStressDebugInfo { + terminalId: string; + instanceId: string; + hasRuntime: boolean; + isAttached: boolean; + isParked: boolean; + cols: number | null; + rows: number | null; + canvasCount: number; + connectionState: ConnectionState; +} + +interface WebglLoseContextExtension { + loseContext: () => void; +} + +function getTerminalWebglContext( + canvas: HTMLCanvasElement, +): WebGL2RenderingContext | null { + if (!isTerminalWebglCanvas(canvas)) return null; + return canvas.getContext("webgl2") as WebGL2RenderingContext | null; +} + +function forceRuntimeWebglContextLoss( + runtime: TerminalRuntime, +): Omit { + const canvases = Array.from(runtime.wrapper.querySelectorAll("canvas")); + let webglContextCount = 0; + let lostContextCount = 0; + let unsupportedContextCount = 0; + + for (const canvas of canvases) { + const context = getTerminalWebglContext(canvas); + if (!context) continue; + + webglContextCount += 1; + const extension = context.getExtension( + "WEBGL_lose_context", + ) as WebglLoseContextExtension | null; + + if (!extension) { + unsupportedContextCount += 1; + continue; + } + + try { + extension.loseContext(); + lostContextCount += 1; + } catch { + unsupportedContextCount += 1; + } + } + + return { + canvasCount: canvases.length, + webglContextCount, + lostContextCount, + unsupportedContextCount, + }; +} + class TerminalRuntimeRegistryImpl { private entries = new Map(); private entryKeysByTerminalId = new Map>(); @@ -53,6 +126,9 @@ class TerminalRuntimeRegistryImpl { let entry = this.entries.get(key); if (entry) return entry; + entry = this.takeParkedEntry(terminalId, instanceId); + if (entry) return entry; + entry = { terminalId, instanceId, @@ -72,6 +148,35 @@ class TerminalRuntimeRegistryImpl { return entry; } + private takeParkedEntry( + terminalId: string, + instanceId: string, + ): RegistryEntry | undefined { + for (const entry of this.getEntries(terminalId)) { + if (entry.instanceId === instanceId) continue; + if (!entry.runtime || entry.runtime.container) continue; + return this.rekeyEntry(entry, instanceId); + } + return undefined; + } + + private rekeyEntry( + entry: RegistryEntry, + nextInstanceId: string, + ): RegistryEntry { + const prevKey = this.getEntryKey(entry.terminalId, entry.instanceId); + const nextKey = this.getEntryKey(entry.terminalId, nextInstanceId); + this.entries.delete(prevKey); + entry.instanceId = nextInstanceId; + this.entries.set(nextKey, entry); + + const keys = this.entryKeysByTerminalId.get(entry.terminalId) ?? new Set(); + keys.delete(prevKey); + keys.add(nextKey); + this.entryKeysByTerminalId.set(entry.terminalId, keys); + return entry; + } + private getEntry( terminalId: string, instanceId?: string, @@ -99,6 +204,19 @@ class TerminalRuntimeRegistryImpl { .filter((entry): entry is RegistryEntry => Boolean(entry)); } + private listEntries( + terminalId?: string, + instanceId?: string, + ): RegistryEntry[] { + if (terminalId && instanceId) { + const entry = this.getEntry(terminalId, instanceId); + return entry ? [entry] : []; + } + if (terminalId) return this.getEntries(terminalId); + if (instanceId) return []; + return Array.from(this.entries.values()); + } + private deleteEntry(entry: RegistryEntry) { const key = this.getEntryKey(entry.terminalId, entry.instanceId); this.entries.delete(key); @@ -174,7 +292,18 @@ class TerminalRuntimeRegistryImpl { connect(terminalId: string, wsUrl: string, instanceId = terminalId) { const entry = this.getEntry(terminalId, instanceId); if (!entry?.runtime) return; - connect(entry.transport, entry.runtime.terminal, wsUrl); + const { runtime } = entry; + connect( + entry.transport, + runtime.terminal, + wsUrl, + (data) => { + writeRuntimeOutput(runtime, data); + }, + { + replay: shouldReplayTerminalRuntime(runtime), + }, + ); } /** @@ -194,7 +323,18 @@ class TerminalRuntimeRegistryImpl { if (!entry?.runtime) return; if (entry.transport.connectionState === "disconnected") return; if (entry.transport.currentUrl === wsUrl) return; - connect(entry.transport, entry.runtime.terminal, wsUrl); + const { runtime } = entry; + connect( + entry.transport, + runtime.terminal, + wsUrl, + (data) => { + writeRuntimeOutput(runtime, data); + }, + { + replay: shouldReplayTerminalRuntime(runtime), + }, + ); } /** @@ -226,6 +366,13 @@ class TerminalRuntimeRegistryImpl { detachFromContainer(entry.runtime); } + focus(terminalId: string, instanceId = terminalId) { + const entry = this.getEntry(terminalId, instanceId); + if (!entry?.runtime) return; + + focusRuntime(entry.runtime); + } + updateAppearance( terminalId: string, appearance: TerminalAppearance, @@ -311,6 +458,87 @@ class TerminalRuntimeRegistryImpl { sendInput(entry.transport, data); } + writeForStress( + terminalId: string, + data: string, + instanceId?: string, + timeoutMs = 5000, + ): Promise { + const entry = this.getEntry(terminalId, instanceId); + if (!entry?.runtime) return Promise.resolve(false); + const { runtime } = entry; + + return new Promise((resolve) => { + let settled = false; + let timeoutId: ReturnType | null = null; + const settle = (value: boolean) => { + if (settled) return; + settled = true; + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + resolve(value); + }; + + timeoutId = setTimeout(() => settle(false), timeoutMs); + try { + writeRuntimeOutput(runtime, data); + queueMicrotask(() => settle(true)); + } catch { + settle(false); + } + }); + } + + forceWebglContextLossForStress( + terminalId?: string, + instanceId?: string, + ): TerminalWebglContextLossResult { + const result: TerminalWebglContextLossResult = { + terminalCount: 0, + canvasCount: 0, + webglContextCount: 0, + lostContextCount: 0, + unsupportedContextCount: 0, + }; + + for (const entry of this.listEntries(terminalId, instanceId)) { + if (!entry.runtime) continue; + result.terminalCount += 1; + const runtimeResult = forceRuntimeWebglContextLoss(entry.runtime); + result.canvasCount += runtimeResult.canvasCount; + result.webglContextCount += runtimeResult.webglContextCount; + result.lostContextCount += runtimeResult.lostContextCount; + result.unsupportedContextCount += runtimeResult.unsupportedContextCount; + } + + return result; + } + + getStressDebugInfo( + terminalId?: string, + instanceId?: string, + ): TerminalRuntimeStressDebugInfo[] { + return this.listEntries(terminalId, instanceId).map((entry) => { + const runtime = entry.runtime; + return { + terminalId: entry.terminalId, + instanceId: entry.instanceId, + hasRuntime: Boolean(runtime), + isAttached: Boolean(runtime?.container), + isParked: Boolean( + runtime && !runtime.container && runtime.wrapper.isConnected, + ), + cols: runtime?.terminal.cols ?? null, + rows: runtime?.terminal.rows ?? null, + canvasCount: runtime + ? runtime.wrapper.querySelectorAll("canvas").length + : 0, + connectionState: entry.transport.connectionState, + }; + }); + } + findNext(terminalId: string, query: string, instanceId?: string): boolean { const entry = this.getEntry(terminalId, instanceId); return entry?.runtime?.searchAddon?.findNext(query) ?? false; diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.test.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.test.ts new file mode 100644 index 00000000000..f7b5a82b9da --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { TerminalRuntime } from "./terminal-runtime"; +import { writeRuntimeOutput } from "./terminal-runtime"; + +function createFakeRuntime( + container: HTMLDivElement | null, +): TerminalRuntime & { + terminal: TerminalRuntime["terminal"] & { + write: ReturnType; + }; +} { + return { + terminalId: "terminal-1", + terminal: { + write: mock((_data: string | Uint8Array, callback?: () => void) => { + callback?.(); + }), + } as unknown as TerminalRuntime["terminal"] & { + write: ReturnType; + }, + fitAddon: {} as TerminalRuntime["fitAddon"], + serializeAddon: {} as TerminalRuntime["serializeAddon"], + searchAddon: null, + progressAddon: null, + wrapper: {} as HTMLDivElement, + container, + resizeObserver: null, + _disposeResizeObserver: null, + lastCols: 120, + lastRows: 32, + _disposeAddons: null, + _disposeImagePasteFallback: null, + _outputQueue: [], + _outputEnqueued: false, + _outputQueuedBytes: 0, + hasBufferedContent: false, + }; +} + +describe("terminal runtime output", () => { + test("writes parked output immediately instead of queueing replay", () => { + const runtime = createFakeRuntime(null); + const output = new Uint8Array(128 * 1024); + + writeRuntimeOutput(runtime, output); + + expect(runtime.terminal.write).toHaveBeenCalledTimes(1); + expect(runtime.terminal.write.mock.calls[0]?.[0]).toBe(output); + expect(runtime._outputQueue).toHaveLength(0); + expect(runtime._outputQueuedBytes).toBe(0); + expect(runtime.hasBufferedContent).toBe(true); + }); + + test("drains any visible backlog before writing parked output", () => { + const runtime = createFakeRuntime({} as HTMLDivElement); + + writeRuntimeOutput(runtime, "x".repeat(10_000)); + expect(runtime._outputQueue.length).toBeGreaterThan(0); + + runtime.container = null; + writeRuntimeOutput(runtime, "tail"); + + expect(runtime._outputQueue).toHaveLength(0); + expect(runtime._outputQueuedBytes).toBe(0); + expect(runtime.terminal.write.mock.calls.at(-1)?.[0]).toBe("tail"); + }); +}); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts index fd85b46761d..1dfe16a6a6b 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -9,6 +9,7 @@ import { loadAddons } from "./terminal-addons"; import { installImagePasteFallback } from "./terminal-image-paste-fallback"; import { installTerminalKeyEventHandler } from "./terminal-key-event-handler"; import { getTerminalParkingContainer } from "./terminal-parking"; +import { markTerminalSessionReplayBlocked } from "./terminal-session-replay"; const SERIALIZE_SCROLLBACK = 1000; const STORAGE_KEY_PREFIX = "terminal-buffer:"; @@ -16,6 +17,22 @@ const DIMS_KEY_PREFIX = "terminal-dims:"; const DEFAULT_COLS = 120; const DEFAULT_ROWS = 32; const RESIZE_DEBOUNCE_MS = 75; +const OUTPUT_CHUNK_BYTES = 4096; +const BACKGROUND_OUTPUT_WRITES_PER_FRAME = 2; + +type TerminalOutputData = string | Uint8Array; + +interface TerminalOutputQueueItem { + data: TerminalOutputData; + byteLength: number; + callback?: () => void; +} + +const runtimesWithQueuedOutput = new Set(); +let outputFlushRafId: number | null = null; +let outputFlushTimeoutId: ReturnType | null = null; +let pendingFocusRuntime: TerminalRuntime | null = null; +let focusRafId: number | null = null; export interface TerminalRuntime { terminalId: string; @@ -32,6 +49,144 @@ export interface TerminalRuntime { lastRows: number; _disposeAddons: (() => void) | null; _disposeImagePasteFallback: (() => void) | null; + _outputQueue: TerminalOutputQueueItem[]; + _outputEnqueued: boolean; + _outputQueuedBytes: number; + hasBufferedContent: boolean; +} + +function getOutputByteLength(data: TerminalOutputData): number { + if (typeof data === "string") return data.length; + return data.byteLength; +} + +function splitStringAtOutputBoundary(value: string, start: number): number { + const end = Math.min(value.length, start + OUTPUT_CHUNK_BYTES); + if (end >= value.length) return value.length; + const code = value.charCodeAt(end - 1); + return code >= 0xd800 && code <= 0xdbff ? end - 1 : end; +} + +function splitOutputData( + data: TerminalOutputData, + callback?: () => void, +): TerminalOutputQueueItem[] { + const byteLength = getOutputByteLength(data); + if (byteLength <= OUTPUT_CHUNK_BYTES) { + return [{ data, byteLength, callback }]; + } + + const items: TerminalOutputQueueItem[] = []; + if (typeof data === "string") { + for (let start = 0; start < data.length; ) { + const end = splitStringAtOutputBoundary(data, start); + const chunk = data.slice(start, end); + items.push({ data: chunk, byteLength: chunk.length }); + start = end; + } + } else { + for (let start = 0; start < data.byteLength; start += OUTPUT_CHUNK_BYTES) { + const chunk = data.slice(start, start + OUTPUT_CHUNK_BYTES); + items.push({ data: chunk, byteLength: chunk.byteLength }); + } + } + + const lastItem = items.at(-1); + if (lastItem) lastItem.callback = callback; + return items; +} + +function scheduleQueuedOutputFlush() { + if (outputFlushRafId !== null || outputFlushTimeoutId !== null) return; + if (typeof requestAnimationFrame !== "function") { + outputFlushTimeoutId = setTimeout(flushQueuedOutput, 0); + return; + } + outputFlushRafId = requestAnimationFrame(flushQueuedOutput); +} + +function flushQueuedOutput() { + outputFlushRafId = null; + if (outputFlushTimeoutId !== null) { + clearTimeout(outputFlushTimeoutId); + outputFlushTimeoutId = null; + } + + let processed = 0; + for (const runtime of Array.from(runtimesWithQueuedOutput)) { + runtimesWithQueuedOutput.delete(runtime); + if (!runtime.container) { + flushQueuedRuntimeOutputNow(runtime); + continue; + } + const item = runtime._outputQueue.shift(); + if (!item) { + runtime._outputEnqueued = false; + continue; + } + + processed += 1; + runtime._outputQueuedBytes = Math.max( + 0, + runtime._outputQueuedBytes - item.byteLength, + ); + runtime.terminal.write(item.data, item.callback); + + if (runtime._outputQueue.length > 0) { + runtimesWithQueuedOutput.add(runtime); + } else { + runtime._outputEnqueued = false; + } + + if (processed >= BACKGROUND_OUTPUT_WRITES_PER_FRAME) break; + } + + if (runtimesWithQueuedOutput.size > 0) { + scheduleQueuedOutputFlush(); + } +} + +function enqueueRuntimeOutput( + runtime: TerminalRuntime, + item: TerminalOutputQueueItem, +) { + if (!runtime.container) { + runtime.terminal.write(item.data, item.callback); + return; + } + + runtime._outputQueuedBytes += item.byteLength; + runtime._outputQueue.push(item); + if (!runtime._outputEnqueued) { + runtime._outputEnqueued = true; + runtimesWithQueuedOutput.add(runtime); + } + scheduleQueuedOutputFlush(); +} + +function flushQueuedRuntimeOutputNow(runtime: TerminalRuntime) { + runtimesWithQueuedOutput.delete(runtime); + runtime._outputEnqueued = false; + while (runtime._outputQueue.length > 0) { + const item = runtime._outputQueue.shift(); + if (!item) break; + runtime._outputQueuedBytes = Math.max( + 0, + runtime._outputQueuedBytes - item.byteLength, + ); + runtime.terminal.write(item.data, item.callback); + } + runtime._outputQueuedBytes = 0; +} + +function clearQueuedRuntimeOutput(runtime: TerminalRuntime) { + runtimesWithQueuedOutput.delete(runtime); + runtime._outputEnqueued = false; + runtime._outputQueuedBytes = 0; + const queue = runtime._outputQueue.splice(0); + for (const item of queue) { + item.callback?.(); + } } function createTerminal( @@ -72,11 +227,15 @@ function persistBuffer(terminalId: string, serializeAddon: SerializeAddon) { } catch {} } -function restoreBuffer(terminalId: string, terminal: XTerm) { +function restoreBuffer(terminalId: string, terminal: XTerm): boolean { try { const data = localStorage.getItem(`${STORAGE_KEY_PREFIX}${terminalId}`); - if (data) terminal.write(data); + if (data) { + terminal.write(data); + return true; + } } catch {} + return false; } function clearPersistedBuffer(terminalId: string) { @@ -116,6 +275,23 @@ function clearPersistedDimensions(terminalId: string) { } catch {} } +function disposeTerminalAfterPendingRefresh(terminal: XTerm) { + const disposeTerminal = () => { + try { + terminal.dispose(); + } catch {} + }; + + if (typeof requestAnimationFrame !== "function") { + setTimeout(disposeTerminal, 0); + return; + } + + requestAnimationFrame(() => { + requestAnimationFrame(disposeTerminal); + }); +} + function hostIsVisible(container: HTMLDivElement | null): boolean { if (!container) return false; return container.clientWidth > 0 && container.clientHeight > 0; @@ -205,6 +381,7 @@ export function createRuntime( const wrapper = document.createElement("div"); wrapper.style.width = "100%"; wrapper.style.height = "100%"; + markTerminalSessionReplayBlocked(wrapper); terminal.open(wrapper); installTerminalKeyEventHandler(terminal); @@ -212,10 +389,14 @@ export function createRuntime( // Activate Unicode 11 widths (inside loadAddons) before restoring the buffer, // else CJK/emoji/ZWJ widths get baked wrong into the replay. (#3572) const addonsResult = loadAddons(terminal); + let hasBufferedContent = false; if (options.initialBuffer !== undefined) { - terminal.write(options.initialBuffer); + if (options.initialBuffer.length > 0) { + terminal.write(options.initialBuffer); + hasBufferedContent = true; + } } else { - restoreBuffer(terminalId, terminal); + hasBufferedContent = restoreBuffer(terminalId, terminal); } const disposeImagePasteFallback = installImagePasteFallback( @@ -238,9 +419,44 @@ export function createRuntime( lastRows: rows, _disposeAddons: addonsResult.dispose, _disposeImagePasteFallback: disposeImagePasteFallback, + _outputQueue: [], + _outputEnqueued: false, + _outputQueuedBytes: 0, + hasBufferedContent, }; } +export function shouldReplayTerminalRuntime(runtime: TerminalRuntime): boolean { + return !runtime.hasBufferedContent; +} + +export function writeRuntimeOutput( + runtime: TerminalRuntime, + data: TerminalOutputData, + callback?: () => void, +) { + const byteLength = getOutputByteLength(data); + if (byteLength > 0) { + runtime.hasBufferedContent = true; + } + if (!runtime.container) { + if (runtime._outputQueue.length > 0) { + flushQueuedRuntimeOutputNow(runtime); + } + runtime.terminal.write(data, callback); + return; + } + + const items = splitOutputData(data, callback); + if (runtime._outputQueue.length === 0 && items.length === 1) { + runtime.terminal.write(data, callback); + return; + } + for (const item of items) { + enqueueRuntimeOutput(runtime, item); + } +} + export function attachToContainer( runtime: TerminalRuntime, container: HTMLDivElement, @@ -258,6 +474,7 @@ export function attachToContainer( runtime.container = container; container.appendChild(runtime.wrapper); + if (measureAndResize(runtime)) onResize?.(); runtime._disposeResizeObserver?.(); @@ -269,9 +486,36 @@ export function attachToContainer( runtime.resizeObserver = observer; runtime._disposeResizeObserver = scheduler.dispose; + if (runtime._outputQueue.length > 0 && !runtime._outputEnqueued) { + runtime._outputEnqueued = true; + runtimesWithQueuedOutput.add(runtime); + scheduleQueuedOutputFlush(); + } +} + +function focusRuntimeNow(runtime: TerminalRuntime) { + if (!runtime.container) return; + const element = runtime.terminal.element; + if (element?.contains(document.activeElement)) return; + const textarea = runtime.terminal.textarea; + if (textarea) { + textarea.focus({ preventScroll: true }); + return; + } runtime.terminal.focus(); } +export function focusRuntime(runtime: TerminalRuntime) { + pendingFocusRuntime = runtime; + if (focusRafId !== null) return; + focusRafId = requestAnimationFrame(() => { + focusRafId = null; + const nextRuntime = pendingFocusRuntime; + pendingFocusRuntime = null; + if (nextRuntime) focusRuntimeNow(nextRuntime); + }); +} + export function detachFromContainer(runtime: TerminalRuntime) { persistBuffer(runtime.terminalId, runtime.serializeAddon); persistDimensions(runtime.terminalId, runtime.lastCols, runtime.lastRows); @@ -283,6 +527,7 @@ export function detachFromContainer(runtime: TerminalRuntime) { // see getTerminalParkingContainer. getTerminalParkingContainer().appendChild(runtime.wrapper); runtime.container = null; + flushQueuedRuntimeOutputNow(runtime); } export function updateRuntimeAppearance( @@ -318,14 +563,15 @@ export function disposeRuntime( runtime._disposeImagePasteFallback = null; runtime._disposeAddons?.(); runtime._disposeAddons = null; + clearQueuedRuntimeOutput(runtime); runtime._disposeResizeObserver?.(); runtime._disposeResizeObserver = null; runtime.resizeObserver?.disconnect(); runtime.resizeObserver = null; runtime.wrapper.remove(); - runtime.terminal.dispose(); if (clearPersistedState) { clearPersistedBuffer(runtime.terminalId); clearPersistedDimensions(runtime.terminalId); } + disposeTerminalAfterPendingRefresh(runtime.terminal); } diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-session-replay.ts b/apps/desktop/src/renderer/lib/terminal/terminal-session-replay.ts new file mode 100644 index 00000000000..42d67eac80e --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-session-replay.ts @@ -0,0 +1,17 @@ +export const TERMINAL_SESSION_REPLAY_BLOCK_CLASS = "ph-no-capture"; +export const TERMINAL_SESSION_REPLAY_BLOCK_ATTRIBUTE = + "data-terminal-replay-blocked"; + +type ReplayBlockedElement = Element & { + classList?: { + add?: (...tokens: string[]) => void; + }; + setAttribute?: (qualifiedName: string, value: string) => void; +}; + +export function markTerminalSessionReplayBlocked(element: Element): void { + const target = element as ReplayBlockedElement; + target.classList?.add?.(TERMINAL_SESSION_REPLAY_BLOCK_CLASS); + target.setAttribute?.("data-ph-no-capture", "true"); + target.setAttribute?.(TERMINAL_SESSION_REPLAY_BLOCK_ATTRIBUTE, "true"); +} diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon-controller.test.ts b/apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon-controller.test.ts new file mode 100644 index 00000000000..50d27e5756b --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon-controller.test.ts @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import type { Terminal as XTerm } from "@xterm/xterm"; + +const disposedAddons: FakeWebglAddon[] = []; + +class FakeWebglAddon { + onContextLoss = mock((_callback: () => void) => {}); + + dispose() { + disposedAddons.push(this); + } +} + +mock.module("@xterm/addon-webgl", () => ({ + WebglAddon: FakeWebglAddon, +})); + +const { loadTerminalWebglAddon, resetTerminalWebglAddonStateForTesting } = + await import("./terminal-webgl-addon-controller"); +const { isTerminalWebglCanvas, TERMINAL_WEBGL_CANVAS_ATTRIBUTE } = await import( + "./terminal-webgl-canvas-registry" +); + +function installImmediateAnimationFrames() { + const mutableGlobal = globalThis as typeof globalThis & { + requestAnimationFrame?: typeof requestAnimationFrame; + cancelAnimationFrame?: typeof cancelAnimationFrame; + }; + const previousRequestAnimationFrame = mutableGlobal.requestAnimationFrame; + const previousCancelAnimationFrame = mutableGlobal.cancelAnimationFrame; + + let rafId = 0; + mutableGlobal.requestAnimationFrame = ((callback: FrameRequestCallback) => { + rafId += 1; + callback(rafId); + return rafId; + }) as typeof requestAnimationFrame; + mutableGlobal.cancelAnimationFrame = mock(() => {}); + + return () => { + if (previousRequestAnimationFrame) { + mutableGlobal.requestAnimationFrame = previousRequestAnimationFrame; + } else { + Reflect.deleteProperty(mutableGlobal, "requestAnimationFrame"); + } + if (previousCancelAnimationFrame) { + mutableGlobal.cancelAnimationFrame = previousCancelAnimationFrame; + } else { + Reflect.deleteProperty(mutableGlobal, "cancelAnimationFrame"); + } + }; +} + +function createFakeTerminal() { + const webglCanvasAttributes = new Map(); + const webglCanvasClasses = new Set(); + const webglCanvas = { + classList: { + add: mock((className: string) => { + webglCanvasClasses.add(className); + }), + }, + getAttribute: mock( + (name: string) => webglCanvasAttributes.get(name) ?? null, + ), + removeAttribute: mock((name: string) => { + webglCanvasAttributes.delete(name); + }), + setAttribute: mock((name: string, value: string) => { + webglCanvasAttributes.set(name, value); + }), + } as unknown as HTMLCanvasElement; + const element = new EventTarget() as EventTarget & { + querySelectorAll: (selector: string) => T[]; + }; + element.querySelectorAll = () => [ + webglCanvas as unknown as T, + ]; + + const loadedAddons: FakeWebglAddon[] = []; + const loadAddon = mock((addon: FakeWebglAddon) => { + loadedAddons.push(addon); + }); + const refresh = mock(() => {}); + + return { + loadedAddons, + lossTarget: element, + webglCanvas, + webglCanvasAttributes, + webglCanvasClasses, + terminal: { + element, + loadAddon, + refresh, + rows: 10, + } as unknown as XTerm, + loadAddon, + refresh, + }; +} + +describe("loadTerminalWebglAddon", () => { + beforeEach(() => { + disposedAddons.length = 0; + resetTerminalWebglAddonStateForTesting(); + }); + + it("falls back to DOM on terminal WebGL context loss", async () => { + const restoreAnimationFrames = installImmediateAnimationFrames(); + + try { + const { + loadedAddons, + lossTarget, + terminal, + loadAddon, + refresh, + webglCanvas, + webglCanvasAttributes, + webglCanvasClasses, + } = createFakeTerminal(); + + loadTerminalWebglAddon(terminal); + + expect(loadAddon).toHaveBeenCalledTimes(1); + expect(loadedAddons).toHaveLength(1); + expect(loadedAddons[0]?.onContextLoss).toHaveBeenCalledTimes(1); + expect(isTerminalWebglCanvas(webglCanvas)).toBe(true); + expect(webglCanvasClasses.has("ph-no-capture")).toBe(true); + expect(webglCanvasAttributes.get("data-ph-no-capture")).toBe("true"); + expect(webglCanvasAttributes.get(TERMINAL_WEBGL_CANVAS_ATTRIBUTE)).toBe( + "true", + ); + + const lossEvent = new Event("webglcontextlost", { cancelable: true }); + lossTarget.dispatchEvent(lossEvent); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(lossEvent.defaultPrevented).toBe(true); + expect(disposedAddons).toEqual([loadedAddons[0]]); + expect(isTerminalWebglCanvas(webglCanvas)).toBe(false); + expect(refresh).toHaveBeenCalledWith(0, 9); + + loadTerminalWebglAddon(terminal); + + expect(loadAddon).toHaveBeenCalledTimes(1); + } finally { + restoreAnimationFrames(); + } + }); + + it("cancels pending WebGL setup when disposed before the next frame", async () => { + const mutableGlobal = globalThis as typeof globalThis & { + requestAnimationFrame?: typeof requestAnimationFrame; + cancelAnimationFrame?: typeof cancelAnimationFrame; + }; + const previousRequestAnimationFrame = mutableGlobal.requestAnimationFrame; + const previousCancelAnimationFrame = mutableGlobal.cancelAnimationFrame; + Reflect.deleteProperty(mutableGlobal, "requestAnimationFrame"); + Reflect.deleteProperty(mutableGlobal, "cancelAnimationFrame"); + + try { + const { terminal, loadAddon } = createFakeTerminal(); + const handle = loadTerminalWebglAddon(terminal); + + handle.dispose(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(loadAddon).toHaveBeenCalledTimes(0); + } finally { + if (previousRequestAnimationFrame) { + mutableGlobal.requestAnimationFrame = previousRequestAnimationFrame; + } + if (previousCancelAnimationFrame) { + mutableGlobal.cancelAnimationFrame = previousCancelAnimationFrame; + } + } + }); +}); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon-controller.ts b/apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon-controller.ts new file mode 100644 index 00000000000..2a7e0b9c401 --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon-controller.ts @@ -0,0 +1,170 @@ +import { WebglAddon } from "@xterm/addon-webgl"; +import type { Terminal as XTerm } from "@xterm/xterm"; +import { markTerminalSessionReplayBlocked } from "./terminal-session-replay"; +import { + markTerminalWebglCanvas, + resetTerminalWebglCanvasRegistryForTesting, + unmarkTerminalWebglCanvas, +} from "./terminal-webgl-canvas-registry"; + +export interface TerminalWebglAddonHandle { + dispose: () => void; +} + +// Once WebGL fails, skip it for all subsequent runtimes (VS Code pattern). +let suggestedRendererType: "dom" | undefined; + +export function resetTerminalWebglAddonStateForTesting(): void { + suggestedRendererType = undefined; + resetTerminalWebglCanvasRegistryForTesting(); +} + +function afterPendingXtermRefresh(callback: () => void): void { + if (typeof requestAnimationFrame !== "function") { + setTimeout(callback, 0); + return; + } + requestAnimationFrame(() => { + requestAnimationFrame(callback); + }); +} + +function scheduleAnimationFrame(callback: () => void): () => void { + if (typeof requestAnimationFrame !== "function") { + const timeoutId = setTimeout(callback, 0); + return () => clearTimeout(timeoutId); + } + + const rafId = requestAnimationFrame(callback); + return () => cancelAnimationFrame(rafId); +} + +export function loadTerminalWebglAddon( + terminal: XTerm, +): TerminalWebglAddonHandle { + let disposed = false; + let webglAddon: WebglAddon | null = null; + let cancelPendingEnable: (() => void) | null = null; + let fallbackTimeoutId: ReturnType | null = null; + let rootContextLossRemove: (() => void) | null = null; + let fallbackScheduled = false; + let markedWebglCanvases: HTMLCanvasElement[] = []; + + const removeContextLossListener = () => { + rootContextLossRemove?.(); + rootContextLossRemove = null; + }; + + const clearFallbackTimeout = () => { + if (fallbackTimeoutId === null) return; + clearTimeout(fallbackTimeoutId); + fallbackTimeoutId = null; + }; + + const unmarkWebglCanvases = () => { + for (const canvas of markedWebglCanvases) { + unmarkTerminalWebglCanvas(canvas); + } + markedWebglCanvases = []; + }; + + const markWebglCanvases = () => { + unmarkWebglCanvases(); + const element = terminal.element; + if (!element) return; + markedWebglCanvases = Array.from( + element.querySelectorAll( + ".xterm-screen canvas:not(.xterm-link-layer)", + ), + ); + for (const canvas of markedWebglCanvases) { + markTerminalWebglCanvas(canvas); + markTerminalSessionReplayBlocked(canvas); + } + }; + + const repaint = () => { + try { + terminal.refresh(0, Math.max(0, terminal.rows - 1)); + } catch {} + }; + + const disposeAddon = (options: { markDomFallback: boolean }) => { + cancelPendingEnable?.(); + cancelPendingEnable = null; + clearFallbackTimeout(); + removeContextLossListener(); + unmarkWebglCanvases(); + fallbackScheduled = false; + const addon = webglAddon; + webglAddon = null; + if (options.markDomFallback) { + suggestedRendererType = "dom"; + } + if (addon) { + try { + addon.dispose(); + } catch {} + } + afterPendingXtermRefresh(repaint); + }; + + const fallbackToDom = () => { + if (disposed || fallbackScheduled) return; + fallbackScheduled = true; + suggestedRendererType = "dom"; + fallbackTimeoutId = setTimeout(() => { + fallbackTimeoutId = null; + disposeAddon({ markDomFallback: false }); + }, 0); + }; + + const attachContextLossListener = () => { + removeContextLossListener(); + const element = terminal.element; + if (!element) return; + + const onRootContextLost = (event: Event) => { + event.preventDefault(); + fallbackToDom(); + }; + element.addEventListener("webglcontextlost", onRootContextLost, true); + rootContextLossRemove = () => { + element.removeEventListener("webglcontextlost", onRootContextLost, true); + }; + }; + + if (suggestedRendererType !== "dom") { + cancelPendingEnable = scheduleAnimationFrame(() => { + cancelPendingEnable = null; + if (disposed || webglAddon || suggestedRendererType === "dom") return; + + let addon: WebglAddon | null = null; + try { + addon = new WebglAddon(); + addon.onContextLoss(fallbackToDom); + fallbackScheduled = false; + attachContextLossListener(); + terminal.loadAddon(addon); + webglAddon = addon; + markWebglCanvases(); + } catch { + suggestedRendererType = "dom"; + removeContextLossListener(); + unmarkWebglCanvases(); + if (addon) { + try { + addon.dispose(); + } catch {} + } + } + }); + } + + return { + dispose: () => { + disposed = true; + disposeAddon({ markDomFallback: false }); + }, + }; +} diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-webgl-canvas-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-webgl-canvas-registry.ts new file mode 100644 index 00000000000..75cdd04649d --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-webgl-canvas-registry.ts @@ -0,0 +1,24 @@ +export const TERMINAL_WEBGL_CANVAS_ATTRIBUTE = "data-terminal-webgl-canvas"; + +let terminalWebglCanvases = new WeakSet(); + +export function markTerminalWebglCanvas(canvas: HTMLCanvasElement): void { + terminalWebglCanvases.add(canvas); + canvas.setAttribute(TERMINAL_WEBGL_CANVAS_ATTRIBUTE, "true"); +} + +export function unmarkTerminalWebglCanvas(canvas: HTMLCanvasElement): void { + terminalWebglCanvases.delete(canvas); + canvas.removeAttribute(TERMINAL_WEBGL_CANVAS_ATTRIBUTE); +} + +export function isTerminalWebglCanvas(canvas: HTMLCanvasElement): boolean { + return ( + terminalWebglCanvases.has(canvas) || + canvas.getAttribute(TERMINAL_WEBGL_CANVAS_ATTRIBUTE) === "true" + ); +} + +export function resetTerminalWebglCanvasRegistryForTesting(): void { + terminalWebglCanvases = new WeakSet(); +} diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts index a133122d71b..2d0efe9634d 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts @@ -1,6 +1,10 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import type { Terminal as XTerm } from "@xterm/xterm"; -import { connect, createTransport } from "./terminal-ws-transport"; +import { + connect, + createTransport, + disposeTransport, +} from "./terminal-ws-transport"; type Listener = (event: { data?: unknown; @@ -66,21 +70,40 @@ const originalWebSocket = globalThis.WebSocket; function createMockTerminal( cols = 101, rows = 27, -): XTerm & { emitData(data: string): void } { - let onDataListener: ((data: string) => void) | null = null; +): XTerm & { + disposedInputListenerCount(): number; + emitData(data: string): void; +} { + const dataListeners: Array<{ + disposed: boolean; + listener: (data: string) => void; + }> = []; return { cols, rows, onData: (listener: (data: string) => void) => { - onDataListener = listener; - return { dispose() {} }; + const record = { disposed: false, listener }; + dataListeners.push(record); + return { + dispose() { + record.disposed = true; + }, + }; + }, + disposedInputListenerCount() { + return dataListeners.filter((record) => record.disposed).length; }, emitData(data: string) { - onDataListener?.(data); + for (const record of dataListeners) { + if (!record.disposed) record.listener(data); + } }, write() {}, writeln() {}, - } as unknown as XTerm & { emitData(data: string): void }; + } as unknown as XTerm & { + disposedInputListenerCount(): number; + emitData(data: string): void; + }; } beforeEach(() => { @@ -93,6 +116,33 @@ afterEach(() => { }); describe("terminal-ws-transport", () => { + test("can suppress replay on the initial connect when xterm already has content", () => { + const transport = createTransport(); + const terminal = createMockTerminal(); + + connect(transport, terminal, "ws://host/terminal/t1", undefined, { + replay: false, + }); + + const socket = MockWebSocket.instances[0]; + expect(socket?.url).toBe("ws://host/terminal/t1?replay=0"); + }); + + test("skips replay after PTY bytes have already landed", () => { + const transport = createTransport(); + const terminal = createMockTerminal(); + + connect(transport, terminal, "ws://host/terminal/t1"); + const firstSocket = MockWebSocket.instances[0]; + if (!firstSocket) throw new Error("expected first websocket instance"); + firstSocket.message(new Uint8Array([1, 2, 3]).buffer); + + connect(transport, terminal, "ws://host/terminal/t2"); + + const secondSocket = MockWebSocket.instances[1]; + expect(secondSocket?.url).toBe("ws://host/terminal/t2?replay=0"); + }); + test("server-sent error routes to logs, not xterm, and stops reconnect", () => { const transport = createTransport(); const writelnCalls: string[] = []; @@ -156,4 +206,67 @@ describe("terminal-ws-transport", () => { { type: "input", data: "b" }, ]); }); + + test("reconnecting replaces the previous xterm input subscription", () => { + const transport = createTransport(); + const terminal = createMockTerminal(); + + connect(transport, terminal, "ws://host/terminal/t1"); + const firstSocket = MockWebSocket.instances[0]; + if (!firstSocket) throw new Error("expected first websocket instance"); + firstSocket.open(); + firstSocket.message(JSON.stringify({ type: "attached", terminalId: "t1" })); + + connect(transport, terminal, "ws://host/terminal/t2"); + const secondSocket = MockWebSocket.instances[1]; + if (!secondSocket) throw new Error("expected second websocket instance"); + secondSocket.open(); + secondSocket.message( + JSON.stringify({ type: "attached", terminalId: "t2" }), + ); + terminal.emitData("x"); + + expect(firstSocket.readyState).toBe(MockWebSocket.CLOSED); + expect(terminal.disposedInputListenerCount()).toBe(1); + expect(firstSocket.sent.map((payload) => JSON.parse(payload))).toEqual([ + { type: "resize", cols: 101, rows: 27 }, + ]); + expect(secondSocket.sent.map((payload) => JSON.parse(payload))).toEqual([ + { type: "resize", cols: 101, rows: 27 }, + { type: "input", data: "x" }, + ]); + }); + + test("dispose clears pending reconnect and title timers", async () => { + const transport = createTransport(); + const terminal = createMockTerminal(); + const titleListener = mock(() => {}); + const logListener = mock(() => {}); + transport.titleListeners.add(titleListener); + transport.logListeners.add(logListener); + + connect(transport, terminal, "ws://host/terminal/t1"); + const socket = MockWebSocket.instances[0]; + if (!socket) throw new Error("expected websocket instance"); + socket.open(); + socket.message(JSON.stringify({ type: "title", title: "busy" })); + socket.message(JSON.stringify({ type: "attached", terminalId: "t1" })); + socket.close(1006, "lost"); + + expect(transport._titleNotifyTimer).not.toBeNull(); + expect(transport._reconnectTimer).not.toBeNull(); + + disposeTransport(transport); + + expect(transport._titleNotifyTimer).toBeNull(); + expect(transport._reconnectTimer).toBeNull(); + expect(transport._writeOutput).toBeNull(); + expect(transport.titleListeners.size).toBe(0); + expect(transport.logListeners.size).toBe(0); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(titleListener).not.toHaveBeenCalled(); + expect(logListener).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts index 3daa24211ee..aad7a4995a8 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts @@ -4,6 +4,7 @@ import type { Terminal as XTerm } from "@xterm/xterm"; export type ConnectionState = "disconnected" | "connecting" | "open" | "closed"; export type TerminalLogLevel = "info" | "warn" | "error"; +export type TerminalOutputWriter = (data: Uint8Array) => void; export interface TerminalLogEntry { id: number; @@ -12,6 +13,14 @@ export interface TerminalLogEntry { message: string; } +export interface TerminalConnectOptions { + /** + * False suppresses server replay for this attach. Replay is otherwise + * allowed only until this transport has received PTY bytes. + */ + replay?: boolean; +} + // PTY output bytes arrive as binary WebSocket frames and are fed straight // into xterm.write(Uint8Array) — no UTF-8 decoding hop, so multi-byte // codepoints that straddle a frame boundary stay intact (xterm.js buffers @@ -47,6 +56,8 @@ export interface TerminalTransport { _titleNotifyTimer: ReturnType | null; /** The xterm instance used for reconnection. */ _terminal: XTerm | null; + /** Writes PTY output into xterm, optionally with renderer-side backpressure. */ + _writeOutput: TerminalOutputWriter | null; /** Set when the server signals the session is done (PTY exit or fatal * attach error). Suppresses the auto-reconnect loop. */ _terminated: boolean; @@ -57,6 +68,12 @@ export interface TerminalTransport { * with no output still gets replay on the next connect. */ _hasReceivedBytes: boolean; + /** + * Sticky suppression for runtimes restored from renderer-side scrollback. + * Auto-reconnects must keep requesting `replay=0` even if the socket drops + * before any new bytes arrive on this specific transport. + */ + _suppressReplay: boolean; } const MAX_LOG_ENTRIES = 200; @@ -151,7 +168,9 @@ export function createTransport(): TerminalTransport { _titleNotifyTimer: null, _reconnectAttempt: 0, _terminal: null, + _writeOutput: null, _hasReceivedBytes: false, + _suppressReplay: false, _terminated: false, }; } @@ -175,7 +194,12 @@ function scheduleReconnect(transport: TerminalTransport) { transport.currentUrl && transport._terminal ) { - connect(transport, transport._terminal, transport.currentUrl); + connect( + transport, + transport._terminal, + transport.currentUrl, + transport._writeOutput ?? undefined, + ); } }, delay); } @@ -219,6 +243,8 @@ export function connect( transport: TerminalTransport, terminal: XTerm, wsUrl: string, + writeOutput?: TerminalOutputWriter, + options: TerminalConnectOptions = {}, ) { // Idempotent: skip if already connected/connecting to the same endpoint. const isActive = @@ -234,11 +260,21 @@ export function connect( cancelReconnect(transport); transport.currentUrl = wsUrl; transport._terminal = terminal; + transport._writeOutput = writeOutput ?? ((data) => terminal.write(data)); transport._terminated = false; + if (options.replay === false) { + transport._suppressReplay = true; + } else if (options.replay === true) { + transport._suppressReplay = false; + } setConnectionState(transport, "connecting"); - const actualUrl = transport._hasReceivedBytes - ? appendQueryParam(wsUrl, "replay", "0") - : wsUrl; + const shouldReplay = + options.replay !== false && + !transport._suppressReplay && + !transport._hasReceivedBytes; + const actualUrl = shouldReplay + ? wsUrl + : appendQueryParam(wsUrl, "replay", "0"); const openSocket = () => { // Bail if the transport raced into a different URL or was disconnected @@ -307,8 +343,11 @@ function attachSocketListeners( // channel; renderer treats them identically). Pipe straight into // xterm without any decoding step. if (event.data instanceof ArrayBuffer) { - terminal.write(new Uint8Array(event.data)); - transport._hasReceivedBytes = true; + const data = new Uint8Array(event.data); + transport._writeOutput?.(data); + if (data.byteLength > 0) { + transport._hasReceivedBytes = true; + } return; } @@ -435,6 +474,7 @@ export function disposeTransport(transport: TerminalTransport) { setTerminalTitle(transport, undefined); transport.onDataDisposable?.dispose(); transport.onDataDisposable = null; + transport._writeOutput = null; transport.stateListeners.clear(); if (transport._titleNotifyTimer !== null) { clearTimeout(transport._titleNotifyTimer); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx index ecbd1a2d622..c68897d1a3f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx @@ -28,6 +28,7 @@ import { V2AvailableBanner } from "renderer/components/V2AvailableBanner"; import { useHotkeyDisplay } from "renderer/hotkeys"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { useV2WorkspaceNavigationStore } from "renderer/stores/v2-workspace-navigation"; import { DashboardSidebarHeader } from "./components/DashboardSidebarHeader"; import { DashboardSidebarHelpMenu } from "./components/DashboardSidebarHelpMenu"; import { DashboardSidebarHoverCardOverlay } from "./components/DashboardSidebarHoverCardOverlay"; @@ -48,6 +49,7 @@ interface SortableProjectWrapperProps { project: DashboardSidebarProject; isCollapsed: boolean; isDraggingProject: boolean; + activeWorkspaceId: string | null; workspaceShortcutLabels: Map; onWorkspaceHover: (workspaceId: string) => void | Promise; onToggleCollapse: (projectId: string) => void; @@ -57,6 +59,7 @@ const SortableProjectWrapper = memo(function SortableProjectWrapper({ project, isCollapsed, isDraggingProject, + activeWorkspaceId, workspaceShortcutLabels, onWorkspaceHover, onToggleCollapse, @@ -83,6 +86,7 @@ const SortableProjectWrapper = memo(function SortableProjectWrapper({ project={project} isSidebarCollapsed={isCollapsed} isDraggingProject={isDraggingProject} + activeWorkspaceId={activeWorkspaceId} workspaceShortcutLabels={workspaceShortcutLabels} onWorkspaceHover={onWorkspaceHover} onToggleCollapse={onToggleCollapse} @@ -105,7 +109,11 @@ export function DashboardSidebar({ const isSettingsOpen = !!matchRoute({ to: "/settings", fuzzy: true }); const { activeHostUrl } = useLocalHostService(); const v2RouteMatch = matchRoute({ to: "/v2-workspace/$workspaceId" }); - const activeV2WorkspaceId = v2RouteMatch ? v2RouteMatch.workspaceId : null; + const routeV2WorkspaceId = v2RouteMatch ? v2RouteMatch.workspaceId : null; + const pendingV2WorkspaceId = useV2WorkspaceNavigationStore( + (state) => state.pendingWorkspaceId, + ); + const activeV2WorkspaceId = pendingV2WorkspaceId ?? routeV2WorkspaceId; const sensors = useSensors( useSensor(MouseSensor, { activationConstraint: { distance: 8 } }), @@ -204,6 +212,7 @@ export function DashboardSidebar({ project={project} isCollapsed={isCollapsed} isDraggingProject={activeProject != null} + activeWorkspaceId={activeV2WorkspaceId} workspaceShortcutLabels={workspaceShortcutLabels} onWorkspaceHover={refreshWorkspacePullRequest} onToggleCollapse={toggleProjectCollapsed} @@ -218,6 +227,7 @@ export function DashboardSidebar({ {}} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHoverCardOverlay/DashboardSidebarHoverCardOverlay.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHoverCardOverlay/DashboardSidebarHoverCardOverlay.tsx index 7d6cc1495f8..5cd5c977ca8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHoverCardOverlay/DashboardSidebarHoverCardOverlay.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHoverCardOverlay/DashboardSidebarHoverCardOverlay.tsx @@ -27,7 +27,7 @@ export function DashboardSidebarHoverCardOverlay({ virtualRef.current = anchorElement; const open = hoveredId !== null && payload !== null && !contextMenuOpen; - const diffStats = useDiffStats(hoveredId ?? ""); + const diffStats = useDiffStats(hoveredId ?? "", { enabled: open }); // Suppress the transform transition until Radix has placed the popover at // its real anchor — otherwise the initial jump from the off-screen measuring diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx index df2d8c24766..71bb6b5fe8e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx @@ -17,6 +17,7 @@ interface DashboardSidebarProjectSectionProps { project: DashboardSidebarProject; isSidebarCollapsed?: boolean; isDraggingProject?: boolean; + activeWorkspaceId: string | null; workspaceShortcutLabels: Map; onWorkspaceHover: (workspaceId: string) => void | Promise; onToggleCollapse: (projectId: string) => void; @@ -28,6 +29,7 @@ export function DashboardSidebarProjectSection({ project, isSidebarCollapsed = false, isDraggingProject = false, + activeWorkspaceId, workspaceShortcutLabels, onWorkspaceHover, onToggleCollapse, @@ -76,6 +78,7 @@ export function DashboardSidebarProjectSection({ isCollapsed={project.isCollapsed} totalWorkspaceCount={totalWorkspaceCount} workspaces={flattenedCollapsedWorkspaces} + activeWorkspaceId={activeWorkspaceId} workspaceShortcutLabels={workspaceShortcutLabels} onWorkspaceHover={onWorkspaceHover} onToggleCollapse={() => onToggleCollapse(project.id)} @@ -125,6 +128,7 @@ export function DashboardSidebarProjectSection({ projectId={project.id} isCollapsed={project.isCollapsed} projectChildren={project.children} + activeWorkspaceId={activeWorkspaceId} workspaceShortcutLabels={workspaceShortcutLabels} onWorkspaceHover={onWorkspaceHover} onDeleteSection={deleteSection} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarCollapsedProjectContent/DashboardSidebarCollapsedProjectContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarCollapsedProjectContent/DashboardSidebarCollapsedProjectContent.tsx index ac4479825dc..6ea47b4ce27 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarCollapsedProjectContent/DashboardSidebarCollapsedProjectContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarCollapsedProjectContent/DashboardSidebarCollapsedProjectContent.tsx @@ -13,6 +13,7 @@ interface DashboardSidebarCollapsedProjectContentProps isCollapsed: boolean; totalWorkspaceCount: number; workspaces: DashboardSidebarWorkspace[]; + activeWorkspaceId: string | null; workspaceShortcutLabels: Map; onWorkspaceHover: (workspaceId: string) => void | Promise; onToggleCollapse: () => void; @@ -29,6 +30,7 @@ export const DashboardSidebarCollapsedProjectContent = forwardRef< isCollapsed, totalWorkspaceCount, workspaces, + activeWorkspaceId, workspaceShortcutLabels, onWorkspaceHover, onToggleCollapse, @@ -82,9 +84,10 @@ export const DashboardSidebarCollapsedProjectContent = forwardRef< onWorkspaceHover(workspace.id)} + onWorkspaceHover={onWorkspaceHover} shortcutLabel={workspaceShortcutLabels.get(workspace.id)} isCollapsed + isActive={workspace.id === activeWorkspaceId} /> ))} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx index 955f8387875..f6b56786d70 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx @@ -16,6 +16,7 @@ interface DashboardSidebarExpandedProjectContentProps { projectId: string; isCollapsed: boolean; projectChildren: DashboardSidebarProjectChild[]; + activeWorkspaceId: string | null; workspaceShortcutLabels: Map; onWorkspaceHover: (workspaceId: string) => void | Promise; onDeleteSection: (sectionId: string) => void; @@ -27,6 +28,7 @@ export function DashboardSidebarExpandedProjectContent({ projectId, isCollapsed, projectChildren, + activeWorkspaceId, workspaceShortcutLabels, onWorkspaceHover, onDeleteSection, @@ -116,9 +118,8 @@ export function DashboardSidebarExpandedProjectContent({ activeId === id ? predictedColor : group?.color } isInSection={groupInfo.has(parsed.realId)} - onHoverCardOpen={() => - onWorkspaceHover(parsed.realId) - } + isActive={parsed.realId === activeWorkspaceId} + onWorkspaceHover={onWorkspaceHover} shortcutLabel={workspaceShortcutLabels.get( parsed.realId, )} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx index 0f4a79be2bb..8eb0b6c3c8b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useDiffStats } from "renderer/hooks/host-service/useDiffStats"; import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; import { useDeletingWorkspaces } from "renderer/routes/_authenticated/providers/DeletingWorkspacesProvider"; @@ -15,18 +15,20 @@ import { useDashboardSidebarWorkspaceItemActions } from "./hooks/useDashboardSid interface DashboardSidebarWorkspaceItemProps { workspace: DashboardSidebarWorkspace; - onHoverCardOpen?: () => void; + onWorkspaceHover?: (workspaceId: string) => void | Promise; shortcutLabel?: string; isCollapsed?: boolean; isInSection?: boolean; + isActive?: boolean; } -export function DashboardSidebarWorkspaceItem({ +function DashboardSidebarWorkspaceItemComponent({ workspace, - onHoverCardOpen, + onWorkspaceHover, shortcutLabel, isCollapsed = false, isInSection = false, + isActive = false, }: DashboardSidebarWorkspaceItemProps) { const { id, @@ -40,7 +42,11 @@ export function DashboardSidebarWorkspaceItem({ pullRequest, } = workspace; const isMainWorkspace = workspace.type === "main"; - const diffStats = useDiffStats(id); + const isPending = !!creationStatus; + const isFailedInFlight = creationStatus === "failed"; + const diffStats = useDiffStats(id, { + enabled: !isCollapsed && !isPending, + }); const workspaceStatus = useV2WorkspaceNotificationStatus(id); const { cancelRename, @@ -52,7 +58,6 @@ export function DashboardSidebarWorkspaceItem({ handleOpenInFinder, handleRemoveFromSidebar, handleToggleUnread, - isActive, isDeleteDialogOpen, isUnread, isRenaming, @@ -67,6 +72,7 @@ export function DashboardSidebarWorkspaceItem({ projectId, workspaceName: name, branch, + isActive, isMainWorkspace, }); @@ -77,8 +83,6 @@ export function DashboardSidebarWorkspaceItem({ const handleAfterBranchRename = (newBranchName: string) => { v2WorkspaceActions.updateWorkspace(id, { branch: newBranchName }); }; - const isPending = !!creationStatus; - const isFailedInFlight = creationStatus === "failed"; // Keep the delete dialog outside the hidden wrapper below — the destroy // flow reopens it into an error pane on conflict/teardown-failed. const isDeleting = useDeletingWorkspaces().isDeleting(id); @@ -111,8 +115,8 @@ export function DashboardSidebarWorkspaceItem({ const isHovered = hoverHoveredId === id; useEffect(() => { - if (isHovered && hostType === "local-device") onHoverCardOpen?.(); - }, [isHovered, hostType, onHoverCardOpen]); + if (isHovered && hostType === "local-device") onWorkspaceHover?.(id); + }, [isHovered, hostType, onWorkspaceHover, id]); useEffect(() => { if (!isHovered) return; hoverSyncIfHovered(id, hoverPayload); @@ -144,6 +148,7 @@ export function DashboardSidebarWorkspaceItem({ onClick={handleClick} creationStatus={creationStatus} pullRequestState={pullRequest?.state ?? null} + data-renderer-stress-workspace-id={id} aria-label={ creationStatus ? `Creating workspace: ${name}` : undefined } @@ -224,6 +229,7 @@ export function DashboardSidebarWorkspaceItem({ isInSection={isInSection} onClick={handleClick} onDoubleClick={isPending ? undefined : startRename} + data-renderer-stress-workspace-id={id} onRemoveFromSidebarClick={handleRemoveFromSidebar} onCloseWorkspaceClick={ isFailedInFlight @@ -291,3 +297,7 @@ export function DashboardSidebarWorkspaceItem({ ); } + +export const DashboardSidebarWorkspaceItem = memo( + DashboardSidebarWorkspaceItemComponent, +); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts index 2e7313d0984..35f07e405e1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts @@ -1,10 +1,11 @@ import { toast } from "@superset/ui/sonner"; -import { useMatchRoute, useNavigate } from "@tanstack/react-router"; +import { useNavigate } from "@tanstack/react-router"; import { useState } from "react"; import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { electronTrpcClient } from "renderer/lib/trpc-client"; import { useDashboardSidebarSectionRename } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSectionRenameContext"; +import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; @@ -19,6 +20,7 @@ interface UseDashboardSidebarWorkspaceItemActionsOptions { projectId: string; workspaceName: string; branch: string; + isActive: boolean; isMainWorkspace?: boolean; } @@ -27,10 +29,10 @@ export function useDashboardSidebarWorkspaceItemActions({ projectId, workspaceName, branch, + isActive, isMainWorkspace = false, }: UseDashboardSidebarWorkspaceItemActionsOptions) { const navigate = useNavigate(); - const matchRoute = useMatchRoute(); const { activeHostUrl } = useLocalHostService(); const { copyToClipboard } = useCopyToClipboard(); const { v2Workspaces: workspaceActions } = useOptimisticCollectionActions(); @@ -47,19 +49,11 @@ export function useDashboardSidebarWorkspaceItemActions({ const [renameValue, setRenameValue] = useState(workspaceName); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const isActive = !!matchRoute({ - to: "/v2-workspace/$workspaceId", - params: { workspaceId }, - fuzzy: true, - }); - const handleClick = () => { if (isRenaming) return; clearWorkspaceAttention(workspaceId); - navigate({ - to: "/v2-workspace/$workspaceId", - params: { workspaceId }, - }); + if (isActive) return; + void navigateToV2Workspace(workspaceId, navigate); }; const startRename = () => { @@ -171,7 +165,6 @@ export function useDashboardSidebarWorkspaceItemActions({ handleOpenInFinder, handleRemoveFromSidebar, handleToggleUnread, - isActive, isDeleteDialogOpen, isRenaming, isUnread, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx index 47cb8dfeba5..a47622cd858 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx @@ -1,5 +1,6 @@ import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; +import { memo } from "react"; import type { DashboardSidebarWorkspace } from "../../types"; import { DashboardSidebarWorkspaceItem } from "../DashboardSidebarWorkspaceItem"; @@ -8,17 +9,19 @@ interface SortableWorkspaceItemProps { workspace: DashboardSidebarWorkspace; accentColor?: string | null; isInSection?: boolean; - onHoverCardOpen?: () => void; + isActive?: boolean; + onWorkspaceHover?: (workspaceId: string) => void | Promise; shortcutLabel?: string; disabled?: boolean; } -export function SortableWorkspaceItem({ +export const SortableWorkspaceItem = memo(function SortableWorkspaceItem({ sortableId, workspace, accentColor, isInSection, - onHoverCardOpen, + isActive = false, + onWorkspaceHover, shortcutLabel, disabled, }: SortableWorkspaceItemProps) { @@ -45,10 +48,11 @@ export function SortableWorkspaceItem({ > ); -} +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts index 82413df4232..874d9d6a10d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts @@ -3,6 +3,7 @@ import { useCallback, useMemo, useRef } from "react"; import { useHotkey } from "renderer/hotkeys"; import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; +import { useV2WorkspaceNavigationStore } from "renderer/stores/v2-workspace-navigation"; import type { DashboardSidebarProject } from "../../types"; import { getProjectChildrenWorkspaces } from "../../utils/projectChildren"; @@ -108,7 +109,7 @@ export function useDashboardSidebarShortcuts( const workspace = flattenedWorkspaces[index]; if (workspace) { revealWorkspace(workspace.id); - navigateToV2Workspace(workspace.id, navigate); + void navigateToV2Workspace(workspace.id, navigate); } }, [flattenedWorkspaces, navigate, revealWorkspace], @@ -131,29 +132,33 @@ export function useDashboardSidebarShortcuts( }); const currentWorkspaceId = currentWorkspaceMatch !== false ? currentWorkspaceMatch.workspaceId : null; + const pendingWorkspaceId = useV2WorkspaceNavigationStore( + (state) => state.pendingWorkspaceId, + ); + const activeWorkspaceId = pendingWorkspaceId ?? currentWorkspaceId; useHotkey("PREV_WORKSPACE", () => { - if (!currentWorkspaceId || flattenedWorkspaces.length === 0) return; + if (!activeWorkspaceId || flattenedWorkspaces.length === 0) return; const index = flattenedWorkspaces.findIndex( - (w) => w.id === currentWorkspaceId, + (w) => w.id === activeWorkspaceId, ); if (index === -1) return; const prevIndex = index <= 0 ? flattenedWorkspaces.length - 1 : index - 1; const target = flattenedWorkspaces[prevIndex]; revealWorkspace(target.id); - navigateToV2Workspace(target.id, navigate); + void navigateToV2Workspace(target.id, navigate); }); useHotkey("NEXT_WORKSPACE", () => { - if (!currentWorkspaceId || flattenedWorkspaces.length === 0) return; + if (!activeWorkspaceId || flattenedWorkspaces.length === 0) return; const index = flattenedWorkspaces.findIndex( - (w) => w.id === currentWorkspaceId, + (w) => w.id === activeWorkspaceId, ); if (index === -1) return; const nextIndex = index >= flattenedWorkspaces.length - 1 ? 0 : index + 1; const target = flattenedWorkspaces[nextIndex]; revealWorkspace(target.id); - navigateToV2Workspace(target.id, navigate); + void navigateToV2Workspace(target.id, navigate); }); return workspaceShortcutLabels; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useHybridSearch/useHybridSearch.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useHybridSearch/useHybridSearch.ts index ec33397b0d5..953bb1a25ee 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useHybridSearch/useHybridSearch.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useHybridSearch/useHybridSearch.ts @@ -15,35 +15,42 @@ interface SearchResult { matchType: "exact" | "fuzzy"; } -export function useHybridSearch(tasks: T[]) { +export function useHybridSearch( + tasks: T[], + enabled = true, +) { const exactFuse = useMemo( () => - new Fuse(tasks, { - keys: [ - { name: "slug", weight: 2 }, - { name: "labels", weight: 1 }, - ], - threshold: 0, - includeScore: true, - ignoreLocation: true, - useExtendedSearch: false, - }), - [tasks], + enabled + ? new Fuse(tasks, { + keys: [ + { name: "slug", weight: 2 }, + { name: "labels", weight: 1 }, + ], + threshold: 0, + includeScore: true, + ignoreLocation: true, + useExtendedSearch: false, + }) + : null, + [tasks, enabled], ); const fuzzyFuse = useMemo( () => - new Fuse(tasks, { - keys: [ - { name: "title", weight: 2 }, - { name: "description", weight: 1 }, - ], - threshold: 0.3, - includeScore: true, - ignoreLocation: true, - useExtendedSearch: false, - }), - [tasks], + enabled + ? new Fuse(tasks, { + keys: [ + { name: "title", weight: 2 }, + { name: "description", weight: 1 }, + ], + threshold: 0.3, + includeScore: true, + ignoreLocation: true, + useExtendedSearch: false, + }) + : null, + [tasks, enabled], ); const search = useCallback( @@ -55,6 +62,7 @@ export function useHybridSearch(tasks: T[]) { matchType: "exact" as const, })); } + if (!exactFuse || !fuzzyFuse) return []; const exactMatches = exactFuse.search(query); const exactIds = new Set(exactMatches.map((m) => m.item.id)); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksData/useTasksData.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksData/useTasksData.tsx index e70ae23cdf9..2e5e6b17f6c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksData/useTasksData.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksData/useTasksData.tsx @@ -74,7 +74,8 @@ export function useTasksData({ .sort(compareTasks); }, [allData]); - const { search } = useHybridSearch(sortedData); + const hasSearchQuery = searchQuery.trim().length > 0; + const { search } = useHybridSearch(sortedData, hasSearchQuery); const searchedData = useMemo(() => { if (!searchQuery.trim()) { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.test.ts new file mode 100644 index 00000000000..19908f902f8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import type { UseNavigateResult } from "@tanstack/react-router"; +import { useV2WorkspaceNavigationStore } from "renderer/stores/v2-workspace-navigation"; +import { + navigateToV2Workspace, + resetV2WorkspaceNavigationStateForTesting, +} from "./workspace-navigation"; + +function createDeferred() { + let resolve!: () => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, reject, resolve }; +} + +describe("navigateToV2Workspace", () => { + beforeEach(() => { + resetV2WorkspaceNavigationStateForTesting(); + useV2WorkspaceNavigationStore.setState({ pendingWorkspaceId: null }); + }); + + it("starts the first plain workspace switch immediately", async () => { + const calls: unknown[] = []; + const firstNavigation = createDeferred(); + const navigate = ((options: unknown) => { + calls.push(options); + return firstNavigation.promise; + }) as UseNavigateResult; + + const promise = navigateToV2Workspace("workspace-a", navigate); + + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + to: "/v2-workspace/$workspaceId", + params: { workspaceId: "workspace-a" }, + search: {}, + }); + expect(useV2WorkspaceNavigationStore.getState().pendingWorkspaceId).toBe( + "workspace-a", + ); + + firstNavigation.resolve(); + await promise; + expect( + useV2WorkspaceNavigationStore.getState().pendingWorkspaceId, + ).toBeNull(); + }); + + it("coalesces plain switches while a route transition is in flight", async () => { + const calls: unknown[] = []; + const firstNavigation = createDeferred(); + const queuedNavigation = createDeferred(); + const navigate = ((options: unknown) => { + calls.push(options); + return calls.length === 1 + ? firstNavigation.promise + : queuedNavigation.promise; + }) as UseNavigateResult; + + const first = navigateToV2Workspace("workspace-a", navigate); + const second = navigateToV2Workspace("workspace-b", navigate); + const third = navigateToV2Workspace("workspace-c", navigate); + + expect(calls).toHaveLength(1); + expect(useV2WorkspaceNavigationStore.getState().pendingWorkspaceId).toBe( + "workspace-c", + ); + + firstNavigation.resolve(); + await first; + await Promise.resolve(); + + expect(calls).toHaveLength(2); + expect(calls[1]).toMatchObject({ + to: "/v2-workspace/$workspaceId", + params: { workspaceId: "workspace-c" }, + search: {}, + }); + + queuedNavigation.resolve(); + await Promise.all([second, third]); + expect( + useV2WorkspaceNavigationStore.getState().pendingWorkspaceId, + ).toBeNull(); + }); + + it("does not delay focused workspace navigation requests", async () => { + const calls: unknown[] = []; + const navigate = ((options: unknown) => { + calls.push(options); + return Promise.resolve(); + }) as UseNavigateResult; + + await navigateToV2Workspace("workspace-a", navigate, { + search: { terminalId: "terminal-a" }, + }); + + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + to: "/v2-workspace/$workspaceId", + params: { workspaceId: "workspace-a" }, + search: { terminalId: "terminal-a" }, + }); + }); + + it("cancels a queued plain switch when a focused request arrives", async () => { + const calls: unknown[] = []; + const firstNavigation = createDeferred(); + const focusedNavigation = createDeferred(); + const navigate = ((options: unknown) => { + calls.push(options); + return calls.length === 1 + ? firstNavigation.promise + : focusedNavigation.promise; + }) as UseNavigateResult; + + const first = navigateToV2Workspace("workspace-a", navigate); + const pendingPlainSwitch = navigateToV2Workspace("workspace-b", navigate); + const focusedSwitch = navigateToV2Workspace("workspace-c", navigate, { + search: { terminalId: "terminal-b" }, + }); + await pendingPlainSwitch; + + expect(calls).toHaveLength(2); + expect(calls[1]).toMatchObject({ + to: "/v2-workspace/$workspaceId", + params: { workspaceId: "workspace-c" }, + search: { terminalId: "terminal-b" }, + }); + + firstNavigation.resolve(); + focusedNavigation.resolve(); + await Promise.all([first, focusedSwitch]); + expect( + useV2WorkspaceNavigationStore.getState().pendingWorkspaceId, + ).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts index a807561923b..8bdd49b005d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts @@ -2,6 +2,10 @@ import type { NavigateOptions, UseNavigateResult, } from "@tanstack/react-router"; +import { + clearPendingV2WorkspaceNavigation, + setPendingV2WorkspaceNavigation, +} from "renderer/stores/v2-workspace-navigation"; export interface WorkspaceSearchParams { tabId?: string; @@ -17,6 +21,118 @@ export interface V2WorkspaceSearchParams { openUrlRequestId?: string; } +interface V2WorkspaceNavigationRequest { + workspaceId: string; + navigate: UseNavigateResult; + search: V2WorkspaceSearchParams; + options: Omit; +} + +interface QueuedV2WorkspaceNavigation extends V2WorkspaceNavigationRequest { + waiters: Array<{ + resolve: () => void; + reject: (error: unknown) => void; + }>; +} + +let inFlightV2WorkspaceNavigation: Promise | null = null; +let queuedV2WorkspaceNavigation: QueuedV2WorkspaceNavigation | null = null; + +export function resetV2WorkspaceNavigationStateForTesting(): void { + inFlightV2WorkspaceNavigation = null; + queuedV2WorkspaceNavigation = null; +} + +function observeNavigationFailure( + promise: Promise, + context: string, +): Promise { + void promise.catch((error) => { + console.warn(`[workspace-navigation] ${context} failed`, error); + }); + return promise; +} + +function hasSearchParams(search: V2WorkspaceSearchParams): boolean { + return Object.values(search).some((value) => value !== undefined); +} + +function runV2WorkspaceNavigation({ + workspaceId, + navigate, + search, + options, +}: V2WorkspaceNavigationRequest): Promise { + return navigate({ + to: "/v2-workspace/$workspaceId", + params: { workspaceId }, + search, + ...options, + }); +} + +function completeWaiters( + waiters: QueuedV2WorkspaceNavigation["waiters"], + result: { status: "resolved" } | { status: "rejected"; error: unknown }, +): void { + for (const waiter of waiters) { + if (result.status === "resolved") { + waiter.resolve(); + } else { + waiter.reject(result.error); + } + } +} + +function drainQueuedV2WorkspaceNavigation(): void { + if (inFlightV2WorkspaceNavigation || !queuedV2WorkspaceNavigation) return; + + const navigation = queuedV2WorkspaceNavigation; + queuedV2WorkspaceNavigation = null; + const promise = startV2WorkspaceNavigation(navigation); + promise.then( + () => completeWaiters(navigation.waiters, { status: "resolved" }), + (error) => + completeWaiters(navigation.waiters, { status: "rejected", error }), + ); +} + +function startV2WorkspaceNavigation( + request: V2WorkspaceNavigationRequest, +): Promise { + const promise = runV2WorkspaceNavigation(request); + inFlightV2WorkspaceNavigation = promise; + void promise.then( + () => { + if (inFlightV2WorkspaceNavigation !== promise) return; + inFlightV2WorkspaceNavigation = null; + drainQueuedV2WorkspaceNavigation(); + }, + () => { + if (inFlightV2WorkspaceNavigation !== promise) return; + inFlightV2WorkspaceNavigation = null; + drainQueuedV2WorkspaceNavigation(); + }, + ); + return promise; +} + +function queueV2WorkspaceNavigation( + request: V2WorkspaceNavigationRequest, +): Promise { + return new Promise((resolve, reject) => { + const waiters = queuedV2WorkspaceNavigation?.waiters ?? []; + waiters.push({ resolve, reject }); + queuedV2WorkspaceNavigation = { ...request, waiters }; + }); +} + +function cancelQueuedV2WorkspaceNavigation(): void { + const navigation = queuedV2WorkspaceNavigation; + queuedV2WorkspaceNavigation = null; + completeWaiters(navigation?.waiters ?? [], { status: "resolved" }); +} + /** * Navigate to a workspace and update localStorage to remember it as the last viewed workspace. * This ensures the workspace will be restored when the app is reopened. @@ -34,12 +150,15 @@ export function navigateToWorkspace( ): Promise { const { search, ...rest } = options ?? {}; localStorage.setItem("lastViewedWorkspaceId", workspaceId); - return navigate({ - to: "/workspace/$workspaceId", - params: { workspaceId }, - search: search ?? {}, - ...rest, - }); + return observeNavigationFailure( + navigate({ + to: "/workspace/$workspaceId", + params: { workspaceId }, + search: search ?? {}, + ...rest, + }), + `navigate to workspace ${workspaceId}`, + ); } /** @@ -53,10 +172,38 @@ export function navigateToV2Workspace( }, ): Promise { const { search, ...rest } = options ?? {}; - return navigate({ - to: "/v2-workspace/$workspaceId", - params: { workspaceId }, - search: search ?? {}, - ...rest, - }); + const resolvedSearch = search ?? {}; + const isPlainWorkspaceSwitch = + !rest.replace && !hasSearchParams(resolvedSearch); + + setPendingV2WorkspaceNavigation(workspaceId); + + if (!isPlainWorkspaceSwitch) { + cancelQueuedV2WorkspaceNavigation(); + } + + const promise = + isPlainWorkspaceSwitch && inFlightV2WorkspaceNavigation + ? queueV2WorkspaceNavigation({ + workspaceId, + navigate, + search: resolvedSearch, + options: rest, + }) + : startV2WorkspaceNavigation({ + workspaceId, + navigate, + search: resolvedSearch, + options: rest, + }); + + void promise.then( + () => clearPendingV2WorkspaceNavigation(workspaceId), + () => clearPendingV2WorkspaceNavigation(workspaceId), + ); + + return observeNavigationFailure( + promise, + `navigate to v2 workspace ${workspaceId}`, + ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.tsx index c17c0aa6cf7..2819991208e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.tsx @@ -12,6 +12,7 @@ import { toast } from "@superset/ui/sonner"; import { workspaceTrpc } from "@superset/workspace-client"; import { Archive, ChevronDown, Trash2 } from "lucide-react"; import { useMemo, useState } from "react"; +import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; import { getRelativeTime } from "renderer/screens/main/components/WorkspacesListView/utils"; import { useStore } from "zustand"; import type { StoreApi } from "zustand/vanilla"; @@ -82,6 +83,7 @@ export function BackgroundTerminalsButton({ const handleKill = async (terminalId: string) => { try { await killSession.mutateAsync({ terminalId, workspaceId }); + terminalRuntimeRegistry.dispose(terminalId); } catch (error) { console.error( "[BackgroundTerminalsButton] Failed to kill session:", diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx index d7972188da9..d79047a8b5e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -115,11 +115,18 @@ export function WorkspaceSidebar({ return () => ro.disconnect(); }, []); - const gitStatus = useGitStatus(workspaceId); + const isFilesTabActive = activeTab === "files"; + const isChangesTabActive = activeTab === "changes"; + const isReviewTabActive = activeTab === "review"; + const gitStatus = useGitStatus( + workspaceId, + isFilesTabActive || isChangesTabActive, + ); const changesTabDef = useChangesTab({ workspaceId, gitStatus, + enabled: isChangesTabActive, selectedFilePath, onSelectFile: onSelectDiffFile, onOpenFile: onSelectFile, @@ -131,6 +138,7 @@ export function WorkspaceSidebar({ const reviewTab = useReviewTab({ workspaceId, + enabled: isReviewTabActive, onOpenComment, onOpenInDiff: onSelectDiffFile ? (path, line, openInNewTab) => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx index cba18a18de1..61be82f48b8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx @@ -82,7 +82,10 @@ export const ChangesFileList = memo(function ChangesFileList({ } return ( -
+
{GROUP_ORDER.map((key) => { const groupFiles = grouped[key]; if (groupFiles.length === 0) return null; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx index e525781f33d..aa804845031 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx @@ -1,3 +1,4 @@ +import { defaultRangeExtractor, useVirtualizer } from "@tanstack/react-virtual"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ChangesetFile } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; import type { FoldSignal } from "../../ChangesFileList"; @@ -6,6 +7,8 @@ import { FolderHeader } from "./components/FolderHeader"; const ROOT_FOLDER_KEY = ""; const ROOT_FOLDER_LABEL = "Root Path"; +const ESTIMATED_ROW_HEIGHT = 24; +const OVERSCAN = 8; interface ChangesFoldersViewProps { files: ChangesetFile[]; @@ -23,6 +26,10 @@ interface FolderGroup { files: ChangesetFile[]; } +type GroupedRow = + | { kind: "folder"; key: string; group: FolderGroup } + | { kind: "file"; key: string; file: ChangesetFile }; + /** * Render a flat list of changed files grouped by their immediate parent * folder (one level deep — v1's "grouped" mode, not the full tree). @@ -45,6 +52,7 @@ export const ChangesFoldersView = memo(function ChangesFoldersView({ onOpenFile, onOpenInEditor, }: ChangesFoldersViewProps) { + const listRef = useRef(null); const groups = useMemo(() => groupFilesByFolder(files), [files]); const [closedFolders, setClosedFolders] = useState>(new Set()); @@ -72,26 +80,73 @@ export const ChangesFoldersView = memo(function ChangesFoldersView({ ); }, [foldSignal, groups]); + const rows = useMemo(() => { + const nextRows: GroupedRow[] = []; + for (const group of groups) { + nextRows.push({ + kind: "folder", + key: `folder:${group.folderPath || "__root__"}`, + group, + }); + if (closedFolders.has(group.folderPath)) continue; + for (const file of group.files) { + nextRows.push({ + kind: "file", + key: `file:${file.source.kind}:${file.path}`, + file, + }); + } + } + return nextRows; + }, [closedFolders, groups]); + + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => + listRef.current?.closest( + "[data-changes-file-list-scroll]", + ) as HTMLElement | null, + estimateSize: () => ESTIMATED_ROW_HEIGHT, + getItemKey: (index) => rows[index]?.key ?? index, + rangeExtractor: defaultRangeExtractor, + overscan: OVERSCAN, + scrollMargin: listRef.current?.offsetTop ?? 0, + }); + + const items = virtualizer.getVirtualItems(); + return ( -
- {groups.map((group) => { - const isRoot = group.folderPath === ROOT_FOLDER_KEY; - const isOpen = !closedFolders.has(group.folderPath); - // `folderPath` ("" for the root group) is already the unique - // per-group discriminator — `groupFilesByFolder` keys a Map by it. - return ( -
- toggleFolder(group.folderPath)} - /> - {isOpen && - group.files.map((file) => ( +
+
+ {items.map((virtualRow) => { + const row = rows[virtualRow.index]; + if (!row) return null; + return ( +
+ {row.kind === "folder" ? ( + toggleFolder(row.group.folderPath)} + /> + ) : ( - ))} -
- ); - })} + )} +
+ ); + })} +
); }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesSection/ChangesSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesSection/ChangesSection.tsx index f4ad7d1ecce..4277aa6dd05 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesSection/ChangesSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesSection/ChangesSection.tsx @@ -1,14 +1,9 @@ -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@superset/ui/collapsible"; import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { workspaceTrpc } from "@superset/workspace-client"; import { ChevronRight, Minus, Plus } from "lucide-react"; -import { type ReactNode, useState } from "react"; +import { type ReactNode, useId, useState } from "react"; import { LuUndo2 } from "react-icons/lu"; import { DiscardConfirmDialog } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/DiscardConfirmDialog"; @@ -31,6 +26,7 @@ export function ChangesSection({ }: ChangesSectionProps) { const [open, setOpen] = useState(defaultOpen); const [showConfirm, setShowConfirm] = useState(false); + const contentId = useId(); const utils = workspaceTrpc.useUtils(); const invalidate = () => { @@ -112,9 +108,15 @@ export function ChangesSection({ const StagingToggleIcon = isUnstaged ? Plus : Minus; return ( - +
- + {stagingActions && (
@@ -161,7 +163,7 @@ export function ChangesSection({
)}
- {children} + {open &&
{children}
} {stagingActions && ( )} - +
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx index 54c63d00187..36cfe295f98 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx @@ -23,6 +23,7 @@ export type { ChangesFilter, ChangesViewMode }; interface UseChangesTabParams { workspaceId: string; gitStatus: ReturnType; + enabled?: boolean; /** Absolute path of the file whose diff/preview is currently open. */ selectedFilePath?: string; onSelectFile?: (path: string, openInNewTab?: boolean) => void; @@ -32,6 +33,7 @@ interface UseChangesTabParams { export function useChangesTab({ workspaceId, gitStatus: status, + enabled = true, selectedFilePath, onSelectFile, onOpenFile, @@ -47,18 +49,21 @@ export function useChangesTab({ const baseBranchQuery = workspaceTrpc.git.getBaseBranch.useQuery( { workspaceId }, - { staleTime: Number.POSITIVE_INFINITY }, + { staleTime: Number.POSITIVE_INFINITY, enabled }, ); const baseBranch = baseBranchQuery.data?.baseBranch ?? null; - const ref = useSidebarDiffRef(workspaceId); - const { files, isLoading } = useChangeset({ workspaceId, ref }); + const ref = useSidebarDiffRef(workspaceId, enabled); + const { files, isLoading } = useChangeset({ workspaceId, ref, enabled }); - const workspaceQuery = workspaceTrpc.workspace.get.useQuery({ - id: workspaceId, - }); + const workspaceQuery = workspaceTrpc.workspace.get.useQuery( + { + id: workspaceId, + }, + { enabled }, + ); const worktreePath = workspaceQuery.data?.worktreePath; - const openInExternalEditor = useOpenInExternalEditor(workspaceId); + const openInExternalEditor = useOpenInExternalEditor(workspaceId, enabled); const handleOpenInEditor = useCallback( (relativePath: string) => { @@ -106,12 +111,12 @@ export function useChangesTab({ const commits = workspaceTrpc.git.listCommits.useQuery( { workspaceId, baseBranch: baseBranch ?? undefined }, - { refetchOnWindowFocus: true }, + { enabled, refetchOnWindowFocus: true }, ); const branches = workspaceTrpc.git.listBranches.useQuery( { workspaceId }, - { refetchInterval: 30_000, refetchOnWindowFocus: true }, + { enabled, refetchInterval: 30_000, refetchOnWindowFocus: true }, ); const renameBranchMutation = workspaceTrpc.git.renameBranch.useMutation(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/useReviewTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/useReviewTab.tsx index 0f5a034cb37..3d9d896865d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/useReviewTab.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/useReviewTab.tsx @@ -17,19 +17,21 @@ type V2ThreadsData = RouterOutputs["git"]["getPullRequestThreads"]; interface UseReviewTabParams { workspaceId: string; + enabled?: boolean; onOpenComment?: (comment: CommentPaneData) => void; onOpenInDiff?: (path: string, line?: number, openInNewTab?: boolean) => void; } export function useReviewTab({ workspaceId, + enabled = true, onOpenComment, onOpenInDiff, }: UseReviewTabParams): SidebarTabDefinition { const prQuery = workspaceTrpc.git.getPullRequest.useQuery( { workspaceId }, { - enabled: !!workspaceId, + enabled: enabled && !!workspaceId, refetchInterval: 10_000, refetchOnWindowFocus: true, staleTime: 10_000, @@ -40,7 +42,7 @@ export function useReviewTab({ const threadsQuery = workspaceTrpc.git.getPullRequestThreads.useQuery( { workspaceId }, { - enabled: !!workspaceId && hasPR, + enabled: enabled && !!workspaceId && hasPR, refetchInterval: 30_000, refetchOnWindowFocus: true, }, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/useChangeset.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/useChangeset.ts index bfb7e9f02b7..7893a675c54 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/useChangeset.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/useChangeset.ts @@ -7,6 +7,7 @@ import type { ChangesetFile, DiffRef } from "./types"; interface UseChangesetArgs { workspaceId: string; ref: DiffRef; + enabled?: boolean; } interface UseChangesetResult { @@ -19,6 +20,7 @@ interface UseChangesetResult { export function useChangeset({ workspaceId, ref, + enabled = true, }: UseChangesetArgs): UseChangesetResult { const utils = workspaceTrpc.useUtils(); @@ -29,7 +31,7 @@ export function useChangeset({ baseBranch: ref.kind === "against-base" ? (ref.baseBranch ?? undefined) : undefined, }, - { enabled: needsStatus, staleTime: Number.POSITIVE_INFINITY }, + { enabled: enabled && needsStatus, staleTime: Number.POSITIVE_INFINITY }, ); const commitQuery = workspaceTrpc.git.getCommitFiles.useQuery( @@ -41,7 +43,7 @@ export function useChangeset({ } : { workspaceId, commitHash: "" }, { - enabled: ref.kind === "commit", + enabled: enabled && ref.kind === "commit", staleTime: Number.POSITIVE_INFINITY, }, ); @@ -59,7 +61,7 @@ export function useChangeset({ void utils.git.getDiff.invalidate({ workspaceId }); } }, - needsStatus, + enabled && needsStatus, ); const files = useMemo(() => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/useOpenInExternalEditor.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/useOpenInExternalEditor.ts index f394b0a1a2b..c3d6768e530 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/useOpenInExternalEditor.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/useOpenInExternalEditor.ts @@ -13,7 +13,7 @@ export interface OpenInExternalEditorOptions { column?: number; } -export function useOpenInExternalEditor(workspaceId: string) { +export function useOpenInExternalEditor(workspaceId: string, enabled = true) { const collections = useCollections(); const { machineId } = useLocalHostService(); const { data: workspaceRows = [] } = useLiveQuery( @@ -34,9 +34,12 @@ export function useOpenInExternalEditor(workspaceId: string) { // can't look this up on its own (v2 projects aren't in the v1 localDb). const { app: v2PreferredApp } = useV2ProjectDefaultApp(projectId); - const workspaceQuery = workspaceTrpc.workspace.get.useQuery({ - id: workspaceId, - }); + const workspaceQuery = workspaceTrpc.workspace.get.useQuery( + { + id: workspaceId, + }, + { enabled }, + ); const worktreePath = workspaceQuery.data?.worktreePath ?? undefined; return useCallback( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts index 31ebb1cc4a6..52bcd3ac0f2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts @@ -27,6 +27,7 @@ interface RegistryEntry { placeholder: HTMLElement | null; resizeObserver: ResizeObserver | null; visible: boolean; + lastLayoutRect: BrowserLayoutRect | null; } const EMPTY_STATE: BrowserRuntimeState = Object.freeze({ @@ -40,6 +41,17 @@ const EMPTY_STATE: BrowserRuntimeState = Object.freeze({ }); const ROOT_CONTAINER_ID = "browser-runtime-root"; +const MAX_BROWSER_LAYOUTS_PER_FRAME = 4; +const BROWSER_LAYOUT_FRAME_BUDGET_MS = 8; +const BROWSER_LAYOUT_COALESCE_MS = 50; +const BROWSER_LAYOUT_IDLE_TIMEOUT_MS = 150; + +interface BrowserLayoutRect { + top: number; + left: number; + width: number; + height: number; +} class BrowserRuntimeRegistryImpl { private entries = new Map(); @@ -48,6 +60,10 @@ class BrowserRuntimeRegistryImpl { private globalListenersInstalled = false; private windowDragPassthrough = false; private shellInteractionPassthrough = false; + private pendingLayoutEntries = new Set(); + private layoutFrameId: number | null = null; + private layoutTimerId: number | null = null; + private layoutIdleCallbackId: number | null = null; private getListeners(paneId: string): Set<() => void> { let set = this.listenersByPaneId.get(paneId); @@ -109,7 +125,7 @@ class BrowserRuntimeRegistryImpl { window.addEventListener("resize", () => { for (const entry of this.entries.values()) { - if (entry.placeholder) this.updateLayout(entry); + if (entry.placeholder) this.scheduleLayout(entry); } }); } @@ -143,14 +159,104 @@ class BrowserRuntimeRegistryImpl { } } + private scheduleLayout(entry: RegistryEntry) { + this.pendingLayoutEntries.add(entry); + this.scheduleDeferredLayoutFlush(); + } + + private scheduleDeferredLayoutFlush() { + if ( + this.layoutFrameId !== null || + this.layoutTimerId !== null || + this.layoutIdleCallbackId !== null + ) { + return; + } + + const requestFlushFrame = () => { + this.layoutTimerId = null; + this.layoutIdleCallbackId = null; + if (this.pendingLayoutEntries.size === 0) return; + this.scheduleImmediateLayoutFlush(); + }; + + if (typeof window.requestIdleCallback === "function") { + this.layoutIdleCallbackId = window.requestIdleCallback( + requestFlushFrame, + { + timeout: BROWSER_LAYOUT_IDLE_TIMEOUT_MS, + }, + ); + return; + } + + this.layoutTimerId = window.setTimeout( + requestFlushFrame, + BROWSER_LAYOUT_COALESCE_MS, + ); + } + + private scheduleImmediateLayoutFlush() { + if (this.layoutFrameId !== null) return; + if (typeof requestAnimationFrame !== "function") { + this.flushPendingLayouts(); + return; + } + this.layoutFrameId = requestAnimationFrame(() => { + this.flushPendingLayouts(); + }); + } + + private flushPendingLayouts() { + this.layoutFrameId = null; + const startedAt = + typeof performance === "undefined" ? 0 : performance.now(); + let processed = 0; + + for (const pendingEntry of Array.from(this.pendingLayoutEntries)) { + this.pendingLayoutEntries.delete(pendingEntry); + this.updateLayout(pendingEntry); + processed += 1; + + if (processed >= MAX_BROWSER_LAYOUTS_PER_FRAME) break; + if ( + startedAt > 0 && + performance.now() - startedAt >= BROWSER_LAYOUT_FRAME_BUDGET_MS + ) { + break; + } + } + + if (this.pendingLayoutEntries.size > 0) { + this.scheduleImmediateLayoutFlush(); + } + } + private updateLayout(entry: RegistryEntry) { - if (!entry.placeholder) return; + if (!entry.placeholder || !entry.visible) return; const rect = entry.placeholder.getBoundingClientRect(); + const nextRect = { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + }; + const previousRect = entry.lastLayoutRect; const w = entry.webview; - w.style.top = `${rect.top}px`; - w.style.left = `${rect.left}px`; - w.style.width = `${rect.width}px`; - w.style.height = `${rect.height}px`; + if ( + !previousRect || + previousRect.top !== nextRect.top || + previousRect.left !== nextRect.left || + previousRect.width !== nextRect.width || + previousRect.height !== nextRect.height + ) { + w.style.top = `${nextRect.top}px`; + w.style.left = `${nextRect.left}px`; + w.style.width = `${nextRect.width}px`; + w.style.height = `${nextRect.height}px`; + entry.lastLayoutRect = nextRect; + } + w.style.visibility = "visible"; } private notify(paneId: string) { @@ -212,6 +318,7 @@ class BrowserRuntimeRegistryImpl { placeholder: null, resizeObserver: null, visible: false, + lastLayoutRect: null, }; const firePersist = () => { @@ -384,13 +491,12 @@ class BrowserRuntimeRegistryImpl { entry.resizeObserver?.disconnect(); const observer = new ResizeObserver(() => { - if (entry) this.updateLayout(entry); + if (entry) this.scheduleLayout(entry); }); observer.observe(placeholder); entry.resizeObserver = observer; - this.updateLayout(entry); - entry.webview.style.visibility = "visible"; + this.scheduleLayout(entry); this.applyPointerPassthrough(); } @@ -398,16 +504,19 @@ class BrowserRuntimeRegistryImpl { const entry = this.entries.get(paneId); if (!entry) return; entry.onPersist = null; + this.pendingLayoutEntries.delete(entry); entry.placeholder = null; entry.resizeObserver?.disconnect(); entry.resizeObserver = null; entry.visible = false; + entry.lastLayoutRect = null; entry.webview.style.visibility = "hidden"; } destroy(paneId: string): void { const entry = this.entries.get(paneId); if (!entry) return; + this.pendingLayoutEntries.delete(entry); entry.resizeObserver?.disconnect(); entry.detachHandlers(); entry.webview.remove(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index 7ccc6122c1d..d5ce97474a4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -3,6 +3,7 @@ import { cn } from "@superset/ui/utils"; import { workspaceTrpc } from "@superset/workspace-client"; import "@xterm/xterm/css/xterm.css"; import { + memo, useCallback, useEffect, useMemo, @@ -48,7 +49,7 @@ interface TerminalPaneProps { onRevealPath: (path: string, options?: { isDirectory?: boolean }) => void; } -export function TerminalPane({ +function TerminalPaneComponent({ ctx, workspaceId, onOpenFile, @@ -153,6 +154,11 @@ export function TerminalPane({ }; }, [terminalId, terminalInstanceId]); + useEffect(() => { + if (!ctx.isActive) return; + terminalRuntimeRegistry.focus(terminalId, terminalInstanceId); + }, [ctx.isActive, terminalId, terminalInstanceId]); + const lastInvalidatedOpenSessionRef = useRef(null); useEffect(() => { const invalidateSessionsAfterSocketOpen = () => { @@ -438,6 +444,18 @@ export function TerminalPane({ ); } +export const TerminalPane = memo( + TerminalPaneComponent, + (prev, next) => + prev.workspaceId === next.workspaceId && + prev.onOpenFile === next.onOpenFile && + prev.onRevealPath === next.onRevealPath && + prev.ctx.store === next.ctx.store && + prev.ctx.isActive === next.ctx.isActive && + prev.ctx.pane.id === next.ctx.pane.id && + prev.ctx.pane.data === next.ctx.pane.data, +); + // Compute "what would clicking right now do?" for the live link tooltip. // Folders use the hardcoded folderIntent rule; files/urls go through the // settings-driven policies. Returns null when no modifier is held or the diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx index 222983bcc3a..7c289ebcade 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx @@ -13,7 +13,6 @@ import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { Check, ChevronDown, LoaderCircle, Plus, Trash2 } from "lucide-react"; import { useCallback, useMemo, useState, useSyncExternalStore } from "react"; -import { markTerminalForBackground } from "renderer/lib/terminal/terminal-background-intents"; import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; import type { PaneViewerData, @@ -167,10 +166,6 @@ export function TerminalSessionDropdown({ return; } - if ((terminalPaneLocations.get(terminalId)?.length ?? 0) === 0) { - markTerminalForBackground(terminalId); - } - state.setPaneData({ paneId: context.pane.id, data: { @@ -221,10 +216,6 @@ export function TerminalSessionDropdown({ const handleNewTerminal = () => { const state = context.store.getState(); - const terminalPaneLocations = getTerminalPaneLocations(context); - if ((terminalPaneLocations.get(terminalId)?.length ?? 0) === 0) { - markTerminalForBackground(terminalId); - } state.setPaneData({ paneId: context.pane.id, data: { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index 4f8fa8c5c83..db3150b4ee0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -41,7 +41,11 @@ import type { PaneViewerData, TerminalPaneData, } from "../../types"; -import { BrowserPane, BrowserPaneToolbar } from "./components/BrowserPane"; +import { + BrowserPane, + BrowserPaneToolbar, + browserRuntimeRegistry, +} from "./components/BrowserPane"; import { ChatPane } from "./components/ChatPane"; import { ChatPaneTitle } from "./components/ChatPane/components/ChatPaneTitle"; import { CommentPane } from "./components/CommentPane"; @@ -272,7 +276,6 @@ export function usePaneRegistry({ onAfterClose: (pane) => { const { terminalId } = pane.data as TerminalPaneData; if (consumeTerminalBackgroundIntent(terminalId)) { - terminalRuntimeRegistry.release(terminalId); return; } clearV2TerminalRunStatus(terminalId, workspaceId); @@ -416,7 +419,9 @@ export function usePaneRegistry({ renderToolbar: (ctx: RendererContext) => ( ), - // Destruction handled by useGlobalBrowserLifecycle for now. + onAfterClose: (pane) => { + browserRuntimeRegistry.destroy(pane.id); + }, contextMenuActions: (_ctx, defaults) => defaults.map((d) => d.key === "close-pane" ? { ...d, label: "Close Browser" } : d, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/index.ts new file mode 100644 index 00000000000..34745d466bd --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/index.ts @@ -0,0 +1 @@ +export { useRendererStressWorkspaceBridge } from "./useRendererStressWorkspaceBridge"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/useRendererStressWorkspaceBridge.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/useRendererStressWorkspaceBridge.ts new file mode 100644 index 00000000000..3eb37cf5de1 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/useRendererStressWorkspaceBridge.ts @@ -0,0 +1,681 @@ +import type { SelectV2Workspace } from "@superset/db/schema"; +import type { + CreatePaneInput, + LayoutNode, + Pane, + Tab, + WorkspaceState, + WorkspaceStore, +} from "@superset/panes"; +import { useEffect, useRef } from "react"; +import { + type TerminalRuntimeStressDebugInfo, + type TerminalWebglContextLossResult, + terminalRuntimeRegistry, +} from "renderer/lib/terminal/terminal-runtime-registry"; +import type { StoreApi } from "zustand/vanilla"; +import type { + BrowserPaneData, + ChatPaneData, + CommentPaneData, + DiffPaneData, + FilePaneData, + PaneViewerData, + TerminalPaneData, +} from "../../types"; + +type RendererStressPaneKind = "browser" | "chat" | "comment" | "diff" | "file"; +type RendererStressTabKind = RendererStressPaneKind | "terminal"; + +interface RendererStressSummary { + workspaceId: string; + projectId: string; + activeTabId: string | null; + tabCount: number; + paneCount: number; + paneKindCounts: Record; + filePathCount: number; +} + +interface RendererStressTerminalSummary extends RendererStressSummary { + stressTerminalIdCount: number; + terminalRuntimeCount: number; + terminalRuntimes: TerminalRuntimeStressDebugInfo[]; +} + +interface RendererStressTerminalWriteResult { + terminalCount: number; + writtenCount: number; + failedCount: number; + byteLength: number; +} + +interface RendererStressWorkspaceBridge { + workspaceId: string; + projectId: string; + captureBaseline: () => void; + restoreBaseline: () => void; + getSummary: () => RendererStressSummary; + addTab: ( + kind: RendererStressTabKind, + index: number, + paneCount?: number, + ) => void; + openPane: (kind: RendererStressPaneKind, index: number) => void; + splitActivePane: (kind: RendererStressPaneKind, index: number) => void; + switchTab: (index: number) => void; + closeActivePane: () => void; + closeOldestTab: (keepCount?: number) => void; + churnActivePaneData: (index: number) => void; + replaceWithGeneratedLayout: (tabCount: number, panesPerTab: number) => void; + replaceWithGeneratedTerminalLayout: ( + tabCount: number, + panesPerTab: number, + ) => void; + replaceWithGeneratedMixedLayout: ( + tabCount: number, + panesPerTab: number, + ) => void; + writeTerminalStressOutput: ( + index: number, + lines: number, + payloadBytes: number, + ) => Promise; + forceTerminalWebglContextLoss: () => TerminalWebglContextLossResult; + getTerminalStressSummary: () => RendererStressTerminalSummary; + releaseStressTerminalRuntimes: () => void; + addRealTerminalTab: () => Promise; + showChangesSidebar: () => void; +} + +declare global { + interface Window { + __SUPERSET_RENDERER_STRESS__?: RendererStressWorkspaceBridge; + __SUPERSET_RENDERER_STRESS_STATE__?: RendererStressGlobalState; + } +} + +const FALLBACK_FILE_ROOT = "/tmp/superset-renderer-stress"; + +interface RendererStressGlobalState { + baselinesByWorkspaceId: Record>; + terminalIdsByWorkspaceId: Record; +} + +function getRendererStressGlobalState(): RendererStressGlobalState { + window.__SUPERSET_RENDERER_STRESS_STATE__ ??= { + baselinesByWorkspaceId: {}, + terminalIdsByWorkspaceId: {}, + }; + return window.__SUPERSET_RENDERER_STRESS_STATE__; +} + +function cloneWorkspaceState( + state: WorkspaceState, +): WorkspaceState { + return JSON.parse(JSON.stringify(state)) as WorkspaceState; +} + +function getStoreStateSnapshot( + store: StoreApi>, +): WorkspaceState { + const state = store.getState(); + return cloneWorkspaceState({ + version: state.version, + tabs: state.tabs, + activeTabId: state.activeTabId, + }); +} + +function getFilePath( + filePaths: string[], + index: number, + worktreePath?: string | null, +): string { + if (filePaths.length > 0) { + return filePaths[index % filePaths.length]; + } + if (worktreePath) { + const moduleId = String(index % 20).padStart(2, "0"); + const fileId = String(index % 200).padStart(3, "0"); + return `${worktreePath}/src/module-${moduleId}/file-${fileId}.ts`; + } + return `${FALLBACK_FILE_ROOT}/fixture-${index % 25}.tsx`; +} + +function createStressTerminalId(workspaceId: string, index: number): string { + return `renderer-stress-terminal-${workspaceId}-${index}`; +} + +function createStressTerminalPane( + workspaceId: string, + index: number, + rememberStressTerminalId: (terminalId: string) => void, +): CreatePaneInput { + const terminalId = createStressTerminalId(workspaceId, index); + rememberStressTerminalId(terminalId); + return { + kind: "terminal", + data: { + terminalId, + } satisfies TerminalPaneData, + }; +} + +function buildStressLayoutNode( + paneIds: string[], + direction: "horizontal" | "vertical" = "vertical", +): LayoutNode { + if (paneIds.length === 1) { + const [paneId] = paneIds as [string]; + return { type: "pane", paneId }; + } + + const mid = Math.ceil(paneIds.length / 2); + const nextDirection = direction === "vertical" ? "horizontal" : "vertical"; + + return { + type: "split", + direction, + first: buildStressLayoutNode(paneIds.slice(0, mid), nextDirection), + second: buildStressLayoutNode(paneIds.slice(mid), nextDirection), + }; +} + +function buildGeneratedWorkspaceState( + workspaceId: string, + tabCount: number, + panesPerTab: number, + createPane: ( + tabIndex: number, + paneOffset: number, + ) => CreatePaneInput, +): WorkspaceState { + const createdAt = Date.now(); + const tabs: Tab[] = Array.from( + { length: tabCount }, + (_, tabIndex) => { + const panes: Record> = {}; + const paneIds: string[] = []; + + for (let paneOffset = 0; paneOffset < panesPerTab; paneOffset += 1) { + const input = createPane(tabIndex, paneOffset); + const paneId = + input.id ?? + `renderer-stress-pane-${workspaceId}-${tabIndex}-${paneOffset}`; + panes[paneId] = { + id: paneId, + kind: input.kind, + titleOverride: input.titleOverride, + pinned: input.pinned, + data: input.data, + }; + paneIds.push(paneId); + } + + return { + id: `renderer-stress-tab-${workspaceId}-${tabIndex}`, + createdAt: createdAt + tabIndex, + activePaneId: paneIds[0] ?? null, + layout: buildStressLayoutNode(paneIds), + panes, + }; + }, + ); + + return { + version: 1, + tabs, + activeTabId: tabs.at(-1)?.id ?? null, + }; +} + +function createStressPane( + kind: RendererStressPaneKind, + index: number, + filePaths: string[], + worktreePath?: string | null, +): CreatePaneInput { + const filePath = getFilePath(filePaths, index, worktreePath); + switch (kind) { + case "browser": + return { + kind, + data: { + url: index % 3 === 0 ? "about:blank" : "https://example.com/", + } satisfies BrowserPaneData, + }; + case "chat": + return { + kind, + data: { + sessionId: null, + launchConfig: + index % 2 === 0 + ? { initialPrompt: `renderer stress prompt ${index}` } + : null, + } satisfies ChatPaneData, + }; + case "comment": + return { + kind, + data: { + commentId: `renderer-stress-comment-${index}`, + authorLogin: "renderer-stress", + body: `Renderer stress comment ${index}`, + path: filePath, + line: (index % 200) + 1, + } satisfies CommentPaneData, + }; + case "diff": + return { + kind, + data: { + path: filePath, + collapsedFiles: index % 2 === 0 ? [] : [filePath], + expandedFiles: [filePath], + focusLine: (index % 200) + 1, + focusTick: Date.now(), + } satisfies DiffPaneData, + }; + case "file": + return { + kind, + data: { + filePath, + mode: index % 3 === 0 ? "preview" : "editor", + } satisfies FilePaneData, + }; + } +} + +function getNextPaneKind(index: number): RendererStressPaneKind { + const kinds: RendererStressPaneKind[] = [ + "file", + "diff", + "browser", + "chat", + "comment", + ]; + return kinds[index % kinds.length]; +} + +function isRendererStressPaneKind( + kind: string, +): kind is RendererStressPaneKind { + return ( + kind === "browser" || + kind === "chat" || + kind === "comment" || + kind === "diff" || + kind === "file" + ); +} + +function collectStressTerminalRefs( + state: WorkspaceState, + stressTerminalIds: Set, +): Array<{ terminalId: string; instanceId: string }> { + const refs: Array<{ terminalId: string; instanceId: string }> = []; + for (const tab of state.tabs) { + for (const pane of Object.values(tab.panes)) { + if (pane.kind !== "terminal") continue; + const terminalId = (pane.data as TerminalPaneData).terminalId; + if (!stressTerminalIds.has(terminalId)) continue; + refs.push({ terminalId, instanceId: pane.id }); + } + } + return refs; +} + +function makeTerminalStressOutput( + index: number, + lines: number, + payloadBytes: number, +): string { + const boundedLines = Math.max(1, Math.min(500, Math.floor(lines))); + const boundedPayloadBytes = Math.max( + 0, + Math.min(4096, Math.floor(payloadBytes)), + ); + const seed = `terminal-stress-${index}-`; + const payload = + boundedPayloadBytes > 0 + ? seed + .repeat(Math.ceil(boundedPayloadBytes / seed.length)) + .slice(0, boundedPayloadBytes) + : ""; + const chunks: string[] = []; + + for (let line = 0; line < boundedLines; line += 1) { + const color = (index + line) % 256; + const red = (index * 17 + line * 3) % 256; + const green = (index * 29 + line * 5) % 256; + const blue = (index * 31 + line * 7) % 256; + if (line % 25 === 0) { + chunks.push(`\x1b]0;renderer terminal stress ${index}.${line}\x07`); + } + chunks.push( + `\x1b[38;2;${red};${green};${blue}m\x1b[48;5;${color}m` + + `[${String(index).padStart(4, "0")}:${String(line).padStart(4, "0")}] ` + + `${payload}\x1b[0m\r\n`, + ); + } + + return chunks.join(""); +} + +function getSummary( + workspace: SelectV2Workspace, + store: StoreApi>, + filePaths: string[], +): RendererStressSummary { + const state = store.getState(); + const paneKindCounts: Record = {}; + let paneCount = 0; + for (const tab of state.tabs) { + for (const pane of Object.values(tab.panes)) { + paneCount += 1; + paneKindCounts[pane.kind] = (paneKindCounts[pane.kind] ?? 0) + 1; + } + } + return { + workspaceId: workspace.id, + projectId: workspace.projectId, + activeTabId: state.activeTabId, + tabCount: state.tabs.length, + paneCount, + paneKindCounts, + filePathCount: filePaths.length, + }; +} + +export function useRendererStressWorkspaceBridge({ + workspace, + store, + filePaths, + worktreePath, + addTerminalTab, + showChangesSidebar, +}: { + workspace: SelectV2Workspace; + store: StoreApi>; + filePaths: string[]; + worktreePath?: string | null; + addTerminalTab: () => Promise; + showChangesSidebar: () => void; +}) { + const baselineRef = useRef | null>(null); + const stressTerminalIdsRef = useRef>(new Set()); + + useEffect(() => { + if (process.env.NODE_ENV !== "development") return; + + const stressState = getRendererStressGlobalState(); + stressTerminalIdsRef.current = new Set( + stressState.terminalIdsByWorkspaceId[workspace.id] ?? [], + ); + const getBridgeSummary = () => getSummary(workspace, store, filePaths); + const rememberStressTerminalId = (terminalId: string) => { + stressTerminalIdsRef.current.add(terminalId); + stressState.terminalIdsByWorkspaceId[workspace.id] = Array.from( + stressTerminalIdsRef.current, + ); + }; + const releaseStressTerminalRuntimes = () => { + const terminalIds = new Set([ + ...stressTerminalIdsRef.current, + ...(stressState.terminalIdsByWorkspaceId[workspace.id] ?? []), + ]); + for (const terminalId of terminalIds) { + terminalRuntimeRegistry.release(terminalId); + } + stressTerminalIdsRef.current.clear(); + delete stressState.terminalIdsByWorkspaceId[workspace.id]; + }; + const getStressTerminalRefs = () => + collectStressTerminalRefs(store.getState(), stressTerminalIdsRef.current); + const bridge: RendererStressWorkspaceBridge = { + workspaceId: workspace.id, + projectId: workspace.projectId, + captureBaseline: () => { + const baseline = getStoreStateSnapshot(store); + baselineRef.current = baseline; + stressState.baselinesByWorkspaceId[workspace.id] = baseline; + }, + restoreBaseline: () => { + releaseStressTerminalRuntimes(); + const baseline = + stressState.baselinesByWorkspaceId[workspace.id] ?? + baselineRef.current; + if (!baseline) return; + store.getState().replaceState(cloneWorkspaceState(baseline)); + delete stressState.baselinesByWorkspaceId[workspace.id]; + }, + getSummary: getBridgeSummary, + addTab: (kind, index, paneCount = 1) => { + if (kind === "terminal") { + void addTerminalTab(); + return; + } + const count = Math.max(1, Math.min(8, Math.floor(paneCount))); + store.getState().addTab({ + panes: Array.from({ length: count }, (_, offset) => + createStressPane( + offset === 0 ? kind : getNextPaneKind(index + offset), + index + offset, + filePaths, + worktreePath, + ), + ) as [ + CreatePaneInput, + ...CreatePaneInput[], + ], + }); + }, + openPane: (kind, index) => { + store.getState().openPane({ + pane: createStressPane(kind, index, filePaths, worktreePath), + }); + }, + splitActivePane: (kind, index) => { + const state = store.getState(); + const active = state.getActivePane(); + if (!active) { + bridge.addTab(kind, index); + return; + } + state.splitPane({ + tabId: active.tabId, + paneId: active.pane.id, + position: index % 2 === 0 ? "right" : "bottom", + newPane: createStressPane(kind, index, filePaths, worktreePath), + }); + }, + switchTab: (index) => { + const state = store.getState(); + if (state.tabs.length === 0) return; + state.setActiveTab(state.tabs[index % state.tabs.length].id); + }, + closeActivePane: () => { + const state = store.getState(); + const active = state.getActivePane(); + if (!active) return; + state.closePane({ tabId: active.tabId, paneId: active.pane.id }); + }, + closeOldestTab: (keepCount = 4) => { + const state = store.getState(); + if (state.tabs.length <= keepCount) return; + state.removeTab(state.tabs[0].id); + }, + churnActivePaneData: (index) => { + const state = store.getState(); + const active = state.getActivePane(); + if (!active) { + bridge.addTab("file", index); + return; + } + if (!isRendererStressPaneKind(active.pane.kind)) { + bridge.addTab("file", index); + return; + } + const nextKind = active.pane.kind; + state.setPaneData({ + paneId: active.pane.id, + data: createStressPane(nextKind, index, filePaths, worktreePath).data, + }); + }, + replaceWithGeneratedLayout: (tabCount, panesPerTab) => { + releaseStressTerminalRuntimes(); + const boundedTabCount = Math.max(1, Math.min(50, Math.floor(tabCount))); + const boundedPaneCount = Math.max( + 1, + Math.min(8, Math.floor(panesPerTab)), + ); + store.getState().replaceState( + buildGeneratedWorkspaceState( + workspace.id, + boundedTabCount, + boundedPaneCount, + (tabIndex, paneOffset) => { + const paneIndex = tabIndex * 10 + paneOffset; + return createStressPane( + paneOffset === 0 + ? getNextPaneKind(tabIndex) + : getNextPaneKind(paneIndex), + paneIndex, + filePaths, + worktreePath, + ); + }, + ), + ); + }, + replaceWithGeneratedTerminalLayout: (tabCount, panesPerTab) => { + releaseStressTerminalRuntimes(); + const boundedTabCount = Math.max(1, Math.min(80, Math.floor(tabCount))); + const boundedPaneCount = Math.max( + 1, + Math.min(8, Math.floor(panesPerTab)), + ); + store + .getState() + .replaceState( + buildGeneratedWorkspaceState( + workspace.id, + boundedTabCount, + boundedPaneCount, + (tabIndex, paneOffset) => + createStressTerminalPane( + workspace.id, + tabIndex * boundedPaneCount + paneOffset, + rememberStressTerminalId, + ), + ), + ); + }, + replaceWithGeneratedMixedLayout: (tabCount, panesPerTab) => { + releaseStressTerminalRuntimes(); + const boundedTabCount = Math.max(1, Math.min(80, Math.floor(tabCount))); + const boundedPaneCount = Math.max( + 1, + Math.min(8, Math.floor(panesPerTab)), + ); + store.getState().replaceState( + buildGeneratedWorkspaceState( + workspace.id, + boundedTabCount, + boundedPaneCount, + (tabIndex, paneOffset) => { + const paneIndex = tabIndex * boundedPaneCount + paneOffset; + if (paneIndex % 4 === 0) { + return createStressTerminalPane( + workspace.id, + paneIndex, + rememberStressTerminalId, + ); + } + return createStressPane( + getNextPaneKind(paneIndex), + paneIndex, + filePaths, + worktreePath, + ); + }, + ), + ); + }, + writeTerminalStressOutput: async (index, lines, payloadBytes) => { + const refs = getStressTerminalRefs(); + const output = makeTerminalStressOutput(index, lines, payloadBytes); + const results = await Promise.all( + refs.map((ref) => + terminalRuntimeRegistry.writeForStress( + ref.terminalId, + output, + ref.instanceId, + ), + ), + ); + const writtenCount = results.filter(Boolean).length; + return { + terminalCount: refs.length, + writtenCount, + failedCount: refs.length - writtenCount, + byteLength: output.length, + }; + }, + forceTerminalWebglContextLoss: () => { + const result: TerminalWebglContextLossResult = { + terminalCount: 0, + canvasCount: 0, + webglContextCount: 0, + lostContextCount: 0, + unsupportedContextCount: 0, + }; + for (const ref of getStressTerminalRefs()) { + const partial = + terminalRuntimeRegistry.forceWebglContextLossForStress( + ref.terminalId, + ref.instanceId, + ); + result.terminalCount += partial.terminalCount; + result.canvasCount += partial.canvasCount; + result.webglContextCount += partial.webglContextCount; + result.lostContextCount += partial.lostContextCount; + result.unsupportedContextCount += partial.unsupportedContextCount; + } + return result; + }, + getTerminalStressSummary: () => { + const stressTerminalIds = stressTerminalIdsRef.current; + const terminalRuntimes = terminalRuntimeRegistry + .getStressDebugInfo() + .filter((runtime) => stressTerminalIds.has(runtime.terminalId)); + return { + ...getBridgeSummary(), + stressTerminalIdCount: stressTerminalIds.size, + terminalRuntimeCount: terminalRuntimes.length, + terminalRuntimes, + }; + }, + releaseStressTerminalRuntimes, + addRealTerminalTab: addTerminalTab, + showChangesSidebar, + }; + + window.__SUPERSET_RENDERER_STRESS__ = bridge; + return () => { + if (window.__SUPERSET_RENDERER_STRESS__ === bridge) { + delete window.__SUPERSET_RENDERER_STRESS__; + } + }; + }, [ + addTerminalTab, + filePaths, + showChangesSidebar, + store, + workspace, + worktreePath, + ]); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/useSidebarDiffRef.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/useSidebarDiffRef.ts index cbf779c25b7..77c459db6e8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/useSidebarDiffRef.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/useSidebarDiffRef.ts @@ -5,7 +5,10 @@ import { useMemo } from "react"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { DiffRef } from "../useChangeset/types"; -export function useSidebarDiffRef(workspaceId: string): DiffRef { +export function useSidebarDiffRef( + workspaceId: string, + enabled = true, +): DiffRef { const collections = useCollections(); const { data: rows = [] } = useLiveQuery( (query) => @@ -19,7 +22,7 @@ export function useSidebarDiffRef(workspaceId: string): DiffRef { const baseBranchQuery = workspaceTrpc.git.getBaseBranch.useQuery( { workspaceId }, - { staleTime: Number.POSITIVE_INFINITY }, + { staleTime: Number.POSITIVE_INFINITY, enabled }, ); const baseBranch = baseBranchQuery.data?.baseBranch ?? null; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 5a4a06fa7f9..b0f45d802fb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -1,7 +1,7 @@ import { Workspace } from "@superset/panes"; import { workspaceTrpc } from "@superset/workspace-client"; import { createFileRoute } from "@tanstack/react-router"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { useQuickOpenStore } from "renderer/commandPalette/ui/QuickOpen/quickOpenStore"; import { useV2UserPreferences } from "renderer/hooks/useV2UserPreferences"; @@ -27,6 +27,8 @@ import { useDefaultPaneActions } from "./hooks/useDefaultPaneActions"; import { useDirtyTabCloseGuard } from "./hooks/useDirtyTabCloseGuard"; import { usePaneRegistry } from "./hooks/usePaneRegistry"; import { renderBrowserTabIcon } from "./hooks/usePaneRegistry/components/BrowserPane"; +import { browserRuntimeRegistry } from "./hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry"; +import { useRendererStressWorkspaceBridge } from "./hooks/useRendererStressWorkspaceBridge"; import { useV2PresetExecution } from "./hooks/useV2PresetExecution"; import { useV2TerminalLauncher } from "./hooks/useV2TerminalLauncher"; import { useV2WorkspacePaneLayout } from "./hooks/useV2WorkspacePaneLayout"; @@ -97,10 +99,18 @@ function V2WorkspacePage() { ); } - return ; + return ( + + ); } -function V2WorkspaceContent() { +function V2WorkspaceContent({ + worktreePath, +}: { + worktreePath?: string | null; +}) { const { terminalId, chatSessionId, @@ -121,6 +131,17 @@ function V2WorkspaceContent() { } = useV2UserPreferences(); const showPresetsBar = v2UserPreferences.showPresetsBar; const { store } = useV2WorkspacePaneLayout(); + useEffect(() => { + return () => { + for (const tab of store.getState().tabs) { + for (const pane of Object.values(tab.panes)) { + if (pane.kind === "browser") { + browserRuntimeRegistry.detach(pane.id); + } + } + } + }; + }, [store]); useClearActivePaneAttention({ store }); const launcher = useV2TerminalLauncher(); const { matchedPresets, executePreset, resolvePresetCommands } = @@ -177,6 +198,29 @@ function V2WorkspaceContent() { openCommentPane, } = useWorkspacePaneOpeners({ store, launcher }); + const rendererStressFilePaths = useMemo( + () => + Array.from( + new Set([ + ...Array.from(openFilePaths), + ...recentFiles.map((file) => file.absolutePath), + ]), + ), + [openFilePaths, recentFiles], + ); + const showRendererStressChangesSidebar = useCallback(() => { + setRightSidebarOpen(true); + setRightSidebarTab("changes"); + }, [setRightSidebarOpen, setRightSidebarTab]); + useRendererStressWorkspaceBridge({ + workspace, + store, + filePaths: rendererStressFilePaths, + worktreePath, + addTerminalTab, + showChangesSidebar: showRendererStressChangesSidebar, + }); + const quickOpenOpen = useQuickOpenStore( (s) => s.open && s.target?.workspaceId === workspaceId, ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx index 05a8de21c94..e7e40e9096f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx @@ -56,7 +56,7 @@ function V2WorkspaceLayout() { const hostStatus = useRemoteHostStatus(workspace); - if (!workspaceId || !isReady || !workspaces) { + if (!workspaceId || !workspaces || (!isReady && !workspace)) { return
; } @@ -96,8 +96,11 @@ function V2WorkspaceLayout() { return
; } + // TanStack Router reuses the Outlet subtree across param-only transitions. + // A workspace switch must remount pane state instead of looking like every + // pane in the previous workspace was closed. return ( - + ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx index 0adb0f7ce65..d1c05cbecc4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx @@ -27,6 +27,7 @@ import { useV2WorkspacesFilterStore, } from "renderer/routes/_authenticated/_dashboard/v2-workspaces/stores/v2WorkspacesFilterStore"; import { useV2ProjectLocalMetaStore } from "renderer/stores/v2-project-local-meta"; +import { useV2WorkspaceNavigationStore } from "renderer/stores/v2-workspace-navigation"; import { V2WorkspaceProjectIcon } from "../V2WorkspaceProjectIcon"; import { SortableHeader } from "./components/SortableHeader"; import { V2WorkspaceRow } from "./components/V2WorkspaceRow"; @@ -131,6 +132,10 @@ export function V2WorkspacesList({ workspaces }: V2WorkspacesListProps) { }); const currentWorkspaceId = currentWorkspaceMatch !== false ? currentWorkspaceMatch.workspaceId : null; + const pendingWorkspaceId = useV2WorkspaceNavigationStore( + (state) => state.pendingWorkspaceId, + ); + const activeWorkspaceId = pendingWorkspaceId ?? currentWorkspaceId; const searchQuery = useV2WorkspacesFilterStore((state) => state.searchQuery); const deviceFilter = useV2WorkspacesFilterStore( @@ -265,7 +270,7 @@ export function V2WorkspacesList({ workspaces }: V2WorkspacesListProps) { ))}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx index 75b7b933f2d..6b8c08b0a77 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx @@ -57,7 +57,9 @@ export function V2WorkspaceRow({ !workspace.hostIsOnline && workspace.hostType !== "local-device"; const handleOpen = useCallback(() => { - const open = () => navigateToV2Workspace(workspace.id, navigate); + const open = () => { + void navigateToV2Workspace(workspace.id, navigate); + }; if (workspace.hostType === "local-device") { open(); return; @@ -155,6 +157,7 @@ export function V2WorkspaceRow({
f.family), ]); +const FONT_DISCOVERY_BATCH_SIZE = 16; +const FONT_DISCOVERY_START_DELAY_MS = 1000; + +function yieldFontDiscoveryWork(): Promise { + if (typeof window.requestIdleCallback === "function") { + return new Promise((resolve) => { + window.requestIdleCallback(() => resolve(), { timeout: 100 }); + }); + } + return new Promise((resolve) => setTimeout(resolve, 0)); +} // Reuse a single canvas context for all font measurements let sharedCtx: CanvasRenderingContext2D | null = null; @@ -102,94 +113,119 @@ function classifyFont(family: string): FontCategory { return "other"; } -function isMonospaceByMeasurement(family: string): boolean { - const ctx = getCanvasCtx(); - if (!ctx) return false; - ctx.font = `16px "${family}"`; - const narrowWidth = ctx.measureText("iiiiii").width; - const wideWidth = ctx.measureText("MMMMMM").width; - return Math.abs(narrowWidth - wideWidth) < 1; -} - -function discoverSystemFonts(): FontInfo[] { +async function discoverSystemFonts(): Promise { const result: FontInfo[] = []; + let checked = 0; for (const family of WELL_KNOWN_NERD) { if (isFontAvailable(family)) { result.push({ family, category: "nerd" }); } + checked += 1; + if (checked % FONT_DISCOVERY_BATCH_SIZE === 0) { + await yieldFontDiscoveryWork(); + } } for (const family of WELL_KNOWN_MONO) { if (isFontAvailable(family)) { result.push({ family, category: "mono" }); } + checked += 1; + if (checked % FONT_DISCOVERY_BATCH_SIZE === 0) { + await yieldFontDiscoveryWork(); + } } return result; } let cachedFonts: FontInfo[] | null = null; +let fontDiscoveryPromise: Promise | null = null; -export function useSystemFonts() { - const [fonts, setFonts] = useState(cachedFonts ?? []); - const [isLoading, setIsLoading] = useState(cachedFonts === null); +async function loadSystemFonts(): Promise { + if (cachedFonts) return cachedFonts; + if (fontDiscoveryPromise) return fontDiscoveryPromise; - useEffect(() => { - if (cachedFonts) return; + fontDiscoveryPromise = (async () => { + await document.fonts.ready; + await yieldFontDiscoveryWork(); - let cancelled = false; - - async function loadFonts() { - await document.fonts.ready; + const result: FontInfo[] = []; + const seen = new Set(); - const result: FontInfo[] = []; - const seen = new Set(); - - // Add registered @font-face fonts only if they loaded successfully - for (const font of REGISTERED_FONTS) { - if (isFontAvailable(font.family)) { - result.push(font); - seen.add(font.family); - } + // Add registered @font-face fonts only if they loaded successfully. + for (const font of REGISTERED_FONTS) { + if (isFontAvailable(font.family)) { + result.push(font); + seen.add(font.family); } + } - for (const font of discoverSystemFonts()) { - if (!seen.has(font.family)) { - seen.add(font.family); - result.push(font); - } + for (const font of await discoverSystemFonts()) { + if (!seen.has(font.family)) { + seen.add(font.family); + result.push(font); } + } + + if (window.queryLocalFonts) { + try { + const fontData = await window.queryLocalFonts(); + let checked = 0; + for (const fd of fontData) { + if (seen.has(fd.family)) continue; + seen.add(fd.family); + + result.push({ family: fd.family, category: classifyFont(fd.family) }); - if (window.queryLocalFonts) { - try { - const fontData = await window.queryLocalFonts(); - for (const fd of fontData) { - if (seen.has(fd.family)) continue; - seen.add(fd.family); - - let category = classifyFont(fd.family); - if (category === "other" && isMonospaceByMeasurement(fd.family)) { - category = "mono"; - } - result.push({ family: fd.family, category }); + checked += 1; + if (checked % FONT_DISCOVERY_BATCH_SIZE === 0) { + await yieldFontDiscoveryWork(); } - } catch (err) { - console.warn("[useSystemFonts] queryLocalFonts failed:", err); } + } catch (err) { + console.warn("[useSystemFonts] queryLocalFonts failed:", err); } + } - result.sort((a, b) => a.family.localeCompare(b.family)); + result.sort((a, b) => a.family.localeCompare(b.family)); + cachedFonts = result; + return result; + })(); - cachedFonts = result; - if (!cancelled) { - setFonts(result); - setIsLoading(false); - } - } + try { + return await fontDiscoveryPromise; + } catch (error) { + fontDiscoveryPromise = null; + throw error; + } +} - loadFonts().catch((err) => { - console.warn("[useSystemFonts] Font loading failed:", err); - }); +export function useSystemFonts() { + const [fonts, setFonts] = useState(cachedFonts ?? []); + const [isLoading, setIsLoading] = useState(cachedFonts === null); + + useEffect(() => { + if (cachedFonts) return; + + let cancelled = false; + const timeoutId = window.setTimeout(() => { + void yieldFontDiscoveryWork() + .then(() => { + if (cancelled) return null; + return loadSystemFonts(); + }) + .then((result) => { + if (cancelled || !result) return; + setFonts(result); + setIsLoading(false); + }) + .catch((err) => { + if (!cancelled) setIsLoading(false); + console.warn("[useSystemFonts] Font loading failed:", err); + }); + }, FONT_DISCOVERY_START_DELAY_MS); return () => { cancelled = true; + window.clearTimeout(timeoutId); }; }, []); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/hooks/useSplitOrientation/useSplitOrientation.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/hooks/useSplitOrientation/useSplitOrientation.ts index a239d6e86fa..6b4eccc3b8c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/hooks/useSplitOrientation/useSplitOrientation.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/hooks/useSplitOrientation/useSplitOrientation.ts @@ -12,14 +12,13 @@ export function useSplitOrientation( const container = containerRef.current; if (!container) return; - const updateOrientation = () => { - const { width, height } = container.getBoundingClientRect(); + const updateOrientation = ({ width, height }: DOMRectReadOnly) => { setSplitOrientation(width >= height ? "vertical" : "horizontal"); }; - updateOrientation(); - - const resizeObserver = new ResizeObserver(updateOrientation); + const resizeObserver = new ResizeObserver(([entry]) => { + if (entry) updateOrientation(entry.contentRect); + }); resizeObserver.observe(container); return () => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index c6f2d1c3571..61242a23609 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -1,15 +1,15 @@ import { toast } from "@superset/ui/sonner"; import { ClipboardAddon } from "@xterm/addon-clipboard"; import { FitAddon } from "@xterm/addon-fit"; -import { ImageAddon } from "@xterm/addon-image"; import { LigaturesAddon } from "@xterm/addon-ligatures"; import { SearchAddon } from "@xterm/addon-search"; import { Unicode11Addon } from "@xterm/addon-unicode11"; -import { WebglAddon } from "@xterm/addon-webgl"; import type { ITheme } from "@xterm/xterm"; import { Terminal as XTerm } from "@xterm/xterm"; import type { DetectedLink } from "renderer/lib/terminal/links"; +import { createTerminalImageAddon } from "renderer/lib/terminal/terminal-image-addon"; import { TerminalLinkManager } from "renderer/lib/terminal/terminal-link-manager"; +import { loadTerminalWebglAddon } from "renderer/lib/terminal/terminal-webgl-addon-controller"; import { electronTrpcClient as trpcClient } from "renderer/lib/trpc-client"; import { toXtermTheme } from "renderer/stores/theme/utils"; import { @@ -56,9 +56,6 @@ export function getDefaultTerminalBg(): string { return getDefaultTerminalTheme().background ?? "#151110"; } -// Once WebGL fails, skip it for all subsequent terminals (VS Code pattern). -let suggestedRendererType: "webgl" | "dom" | undefined; - export interface CreateTerminalOptions { /** * Workspace id used for worktree lookup during path stat/resolution. @@ -101,10 +98,7 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { const clipboardAddon = new ClipboardAddon(); const unicode11Addon = new Unicode11Addon(); - const imageAddon = new ImageAddon(); - - let disposed = false; - let webglAddon: WebglAddon | null = null; + const imageAddon = createTerminalImageAddon(); // Open into a detached wrapper div — not the live container. const wrapper = document.createElement("div"); @@ -124,23 +118,7 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { // Ligatures not supported by current font } - // Defer WebGL to rAF to avoid racing xterm's post-open viewport sync. - const rafId = requestAnimationFrame(() => { - if (disposed || suggestedRendererType === "dom") return; - - try { - webglAddon = new WebglAddon(); - webglAddon.onContextLoss(() => { - webglAddon?.dispose(); - webglAddon = null; - xterm.refresh(0, xterm.rows - 1); - }); - xterm.loadAddon(webglAddon); - } catch { - suggestedRendererType = "dom"; - webglAddon = null; - } - }); + const webglAddon = loadTerminalWebglAddon(xterm); const cleanupQuerySuppression = suppressQueryResponses(xterm); @@ -204,14 +182,9 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { wrapper, linkManager, cleanup: () => { - disposed = true; - cancelAnimationFrame(rafId); cleanupQuerySuppression(); linkManager.dispose(); - try { - webglAddon?.dispose(); - } catch {} - webglAddon = null; + webglAddon.dispose(); }, }; } diff --git a/apps/desktop/src/renderer/stores/v2-workspace-navigation.ts b/apps/desktop/src/renderer/stores/v2-workspace-navigation.ts new file mode 100644 index 00000000000..00560af0a59 --- /dev/null +++ b/apps/desktop/src/renderer/stores/v2-workspace-navigation.ts @@ -0,0 +1,29 @@ +import { create } from "zustand"; + +interface V2WorkspaceNavigationState { + pendingWorkspaceId: string | null; + setPendingWorkspaceId: (workspaceId: string) => void; + clearPendingWorkspaceId: (workspaceId: string) => void; +} + +export const useV2WorkspaceNavigationStore = create( + (set) => ({ + pendingWorkspaceId: null, + setPendingWorkspaceId: (workspaceId) => + set({ pendingWorkspaceId: workspaceId }), + clearPendingWorkspaceId: (workspaceId) => + set((state) => + state.pendingWorkspaceId === workspaceId + ? { pendingWorkspaceId: null } + : state, + ), + }), +); + +export function setPendingV2WorkspaceNavigation(workspaceId: string): void { + useV2WorkspaceNavigationStore.getState().setPendingWorkspaceId(workspaceId); +} + +export function clearPendingV2WorkspaceNavigation(workspaceId: string): void { + useV2WorkspaceNavigationStore.getState().clearPendingWorkspaceId(workspaceId); +} diff --git a/apps/desktop/src/shared/workspace-run-definition.test.ts b/apps/desktop/src/shared/workspace-run-definition.test.ts index 117d1eb6120..a093aedd3a9 100644 --- a/apps/desktop/src/shared/workspace-run-definition.test.ts +++ b/apps/desktop/src/shared/workspace-run-definition.test.ts @@ -84,4 +84,64 @@ describe("selectWorkspaceRunDefinition", () => { commands: ["npm run dev"], }); }); + + it("ignores non-string config commands instead of throwing", () => { + const definition = selectWorkspaceRunDefinition({ + projectId: "project-a", + configRunCommands: [ + { name: "Dev server", command: "bun dev" }, + "bun start", + ], + presets: [], + }); + + expect(definition).toEqual({ + source: "project-config", + projectId: "project-a", + commands: ["bun start"], + }); + }); + + it("falls back when all config commands are non-strings", () => { + const definition = selectWorkspaceRunDefinition({ + projectId: "project-a", + configRunCommands: [{ name: "Dev server", command: "bun dev" }], + presets: [ + { + id: "preset-global", + name: "Global dev", + commands: ["npm run dev"], + useAsWorkspaceRun: true, + }, + ], + }); + + expect(definition).toEqual({ + source: "terminal-preset", + presetId: "preset-global", + name: "Global dev", + commands: ["npm run dev"], + }); + }); + + it("ignores non-string preset commands instead of throwing", () => { + const definition = selectWorkspaceRunDefinition({ + projectId: "project-a", + presets: [ + { + id: "preset-global", + name: "Global dev", + commands: [{ name: "Dev server" }, "npm run dev"], + useAsWorkspaceRun: true, + }, + ], + }); + + expect(definition).toEqual({ + source: "terminal-preset", + presetId: "preset-global", + name: "Global dev", + commands: ["npm run dev"], + }); + }); }); diff --git a/apps/desktop/src/shared/workspace-run-definition.ts b/apps/desktop/src/shared/workspace-run-definition.ts index 16c1e44d045..19ba4ad7d86 100644 --- a/apps/desktop/src/shared/workspace-run-definition.ts +++ b/apps/desktop/src/shared/workspace-run-definition.ts @@ -21,14 +21,17 @@ export type WorkspaceRunDefinition = export interface WorkspaceRunPresetLike { id: string; name: string; - commands: string[]; + commands: unknown[]; cwd?: string; projectIds?: string[] | null; useAsWorkspaceRun?: boolean; } -function nonEmptyCommands(commands: readonly string[] | null | undefined) { - return (commands ?? []).filter((command) => command.trim().length > 0); +function nonEmptyCommands(commands: readonly unknown[] | null | undefined) { + return (commands ?? []).filter( + (command): command is string => + typeof command === "string" && command.trim().length > 0, + ); } function normalizeCwd(cwd: string | undefined): string | undefined { @@ -42,7 +45,7 @@ export function configRunToWorkspaceRun({ cwd, }: { projectId: string; - commands: readonly string[] | null | undefined; + commands: readonly unknown[] | null | undefined; cwd?: string; }): WorkspaceRunDefinition | null { const resolvedCommands = nonEmptyCommands(commands); @@ -77,7 +80,7 @@ export function selectWorkspaceRunDefinition({ configCwd, }: { presets: readonly WorkspaceRunPresetLike[]; - configRunCommands?: readonly string[] | null; + configRunCommands?: readonly unknown[] | null; projectId: string; configCwd?: string; }): WorkspaceRunDefinition | null { diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index 41e8d331f56..098ab18a113 100644 --- a/packages/host-service/src/app.ts +++ b/packages/host-service/src/app.ts @@ -215,7 +215,7 @@ export function createApp(options: CreateAppOptions): CreateAppResult { console.warn("[host-service] revokeAllSessions failed:", err); } try { - pullRequestRuntime.stop(); + await pullRequestRuntime.stop(); } catch (err) { console.warn("[host-service] pullRequestRuntime.stop failed:", err); } @@ -229,6 +229,11 @@ export function createApp(options: CreateAppOptions): CreateAppResult { } catch (err) { console.warn("[host-service] gitWatcher.close failed:", err); } + try { + await filesystem.close(); + } catch (err) { + console.warn("[host-service] filesystem.close failed:", err); + } if (ownsDb) { try { (db as unknown as { $client?: { close: () => void } }).$client?.close(); diff --git a/packages/host-service/src/runtime/pull-requests/pull-requests.test.ts b/packages/host-service/src/runtime/pull-requests/pull-requests.test.ts index 68a73096f66..c7ec18b33d2 100644 --- a/packages/host-service/src/runtime/pull-requests/pull-requests.test.ts +++ b/packages/host-service/src/runtime/pull-requests/pull-requests.test.ts @@ -381,3 +381,40 @@ describe("PullRequestRuntimeManager direct checkout PR linking", () => { expect(state.workspace.pullRequestId).toBe("pr-existing"); }); }); + +describe("PullRequestRuntimeManager shutdown", () => { + test("stop waits for startup background work before returning", async () => { + const manager = createManager(makeState("main")); + let releaseSync: () => void = () => { + throw new Error("sync did not start"); + }; + let resolveStarted: () => void = () => {}; + const started = new Promise((resolve) => { + resolveStarted = resolve; + }); + let completed = false; + const internals = manager as unknown as { + syncWorkspaceBranches: () => Promise; + refreshEligibleProjects: () => Promise; + }; + internals.syncWorkspaceBranches = async () => { + resolveStarted(); + await new Promise((resolve) => { + releaseSync = resolve; + }); + completed = true; + }; + internals.refreshEligibleProjects = async () => {}; + + manager.start(); + await started; + + const stopPromise = manager.stop(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(completed).toBe(false); + + releaseSync(); + await stopPromise; + expect(completed).toBe(true); + }); +}); diff --git a/packages/host-service/src/runtime/pull-requests/pull-requests.ts b/packages/host-service/src/runtime/pull-requests/pull-requests.ts index 37ee51783ee..b043a33be49 100644 --- a/packages/host-service/src/runtime/pull-requests/pull-requests.ts +++ b/packages/host-service/src/runtime/pull-requests/pull-requests.ts @@ -271,10 +271,12 @@ export class PullRequestRuntimeManager { string, { running: Promise; rerunPending: boolean } >(); + private readonly backgroundTasks = new Set>(); private readonly pullRequestHeadCache = new Map< string, { promise: Promise; fetchedAt: number } >(); + private stopped = false; constructor(options: PullRequestRuntimeManagerOptions) { this.db = options.db; @@ -291,12 +293,17 @@ export class PullRequestRuntimeManager { this.unsubscribeFromGitWatcher ) return; + this.stopped = false; // One initial sweep so workspaces that existed before this manager // started have correct branch/sha/upstream rows even if no `.git/` // activity has happened since the last process boot. - void this.syncWorkspaceBranches(); - void this.refreshEligibleProjects(); + this.runBackgroundTask("syncWorkspaceBranches", () => + this.syncWorkspaceBranches(), + ); + this.runBackgroundTask("refreshEligibleProjects", () => + this.refreshEligibleProjects(), + ); // Steady-state: react to real `.git/` activity per workspace. Per-workspace // debounce lives in `GitWatcher` (300 ms), and concurrent project refreshes @@ -304,25 +311,60 @@ export class PullRequestRuntimeManager { // workspace so two debounce-separated bursts can't race their git reads // and have the slower one overwrite the newer snapshot. this.unsubscribeFromGitWatcher = this.gitWatcher.onChanged((event) => { - void this.enqueueWorkspaceSync(event.workspaceId); + if (!this.stopped) void this.enqueueWorkspaceSync(event.workspaceId); }); // Long-cadence safety net for `GitWatcher` overflow / error paths. this.safetyNetTimer = setInterval(() => { - void this.syncWorkspaceBranches(); + this.runBackgroundTask("syncWorkspaceBranches", () => + this.syncWorkspaceBranches(), + ); }, SAFETY_NET_INTERVAL_MS); this.projectRefreshTimer = setInterval(() => { - void this.refreshEligibleProjects(); + this.runBackgroundTask("refreshEligibleProjects", () => + this.refreshEligibleProjects(), + ); }, PROJECT_REFRESH_INTERVAL_MS); } - stop() { + async stop(): Promise { + this.stopped = true; if (this.safetyNetTimer) clearInterval(this.safetyNetTimer); if (this.projectRefreshTimer) clearInterval(this.projectRefreshTimer); this.unsubscribeFromGitWatcher?.(); this.safetyNetTimer = null; this.projectRefreshTimer = null; this.unsubscribeFromGitWatcher = null; + await this.drainInFlightWork(); + this.pullRequestHeadCache.clear(); + } + + private runBackgroundTask(label: string, task: () => Promise): void { + const promise = task() + .catch((error) => { + console.warn(`[host-service:pull-request-runtime] ${label} failed`, { + error, + }); + }) + .finally(() => { + this.backgroundTasks.delete(promise); + }); + this.backgroundTasks.add(promise); + } + + private async drainInFlightWork(): Promise { + for (let pass = 0; pass < 10; pass++) { + const tasks = [ + ...this.backgroundTasks, + ...Array.from( + this.workspaceSyncState.values(), + (state) => state.running, + ), + ...this.inFlightProjects.values(), + ]; + if (tasks.length === 0) return; + await Promise.allSettled(tasks); + } } async getPullRequestsByWorkspaces( @@ -450,6 +492,7 @@ export class PullRequestRuntimeManager { } private async syncWorkspaceBranches(): Promise { + if (this.stopped) return; // Route every workspace through the same per-workspace queue as the // watcher path, so a concurrent watcher-triggered sync can't race the // sweep's read+write and clobber the newer snapshot. enqueueWorkspaceSync @@ -461,11 +504,13 @@ export class PullRequestRuntimeManager { // original sweep's behavior. refreshProject inside each sync still // dedupes across workspaces in the same project via inFlightProjects. for (const row of ids) { + if (this.stopped) break; await this.enqueueWorkspaceSync(row.id); } } private enqueueWorkspaceSync(workspaceId: string): Promise { + if (this.stopped) return Promise.resolve(); // Coalesce: if a sync is already running for this workspace, just mark // "rerun pending" — there's no value in queuing N back-to-back syncs // when only the final state matters. At most one sync runs and one @@ -569,6 +614,7 @@ export class PullRequestRuntimeManager { } private async refreshEligibleProjects(): Promise { + if (this.stopped) return; const rows = this.db .select({ projectId: workspaces.projectId, diff --git a/packages/host-service/src/trpc/router/git/git.ts b/packages/host-service/src/trpc/router/git/git.ts index 047188fc440..aab11db90fa 100644 --- a/packages/host-service/src/trpc/router/git/git.ts +++ b/packages/host-service/src/trpc/router/git/git.ts @@ -27,6 +27,7 @@ import { getDefaultBranchName, mapGitStatus, parseNumstat, + parseNumstatRecords, resolveBaseComparison, } from "./utils/git-helpers"; import { @@ -58,6 +59,45 @@ function assertSafeRelativePath(filePath: string): void { } } +interface DiffStats { + additions: number; + deletions: number; +} + +function addDiffStats( + byPath: Map, + path: string, + stats: DiffStats, +): void { + const existing = byPath.get(path); + byPath.set(path, { + additions: (existing?.additions ?? 0) + stats.additions, + deletions: (existing?.deletions ?? 0) + stats.deletions, + }); +} + +function applyNumstatToStatsMap( + byPath: Map, + raw: string, +): void { + for (const record of parseNumstatRecords(raw)) { + addDiffStats(byPath, record.path, { + additions: record.additions, + deletions: record.deletions, + }); + } +} + +function sumDiffStats(byPath: Map): DiffStats { + let additions = 0; + let deletions = 0; + for (const file of byPath.values()) { + additions += file.additions; + deletions += file.deletions; + } + return { additions, deletions }; +} + export const gitRouter = router({ listBranches: queryProcedure .input(z.object({ workspaceId: z.string() })) @@ -247,6 +287,57 @@ export const gitRouter = router({ }; }), + getDiffStats: queryProcedure + .meta({ timeoutMs: 10_000 }) + .input( + z.object({ + workspaceId: z.string(), + baseBranch: z.string().optional(), + }), + ) + .query(async ({ ctx, input }) => { + const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const git = await ctx.git(worktreePath); + const base = await resolveBaseComparison(git, input.baseBranch); + const baseRef = base?.baseRef ?? "HEAD"; + + const [againstBaseRaw, stagedRaw, unstagedRaw, untrackedRaw] = + await Promise.all([ + git + .raw(["diff", "--numstat", "-z", `${baseRef}...HEAD`]) + .catch(() => ""), + git.raw(["diff", "--numstat", "-z", "--cached"]).catch(() => ""), + git.raw(["diff", "--numstat", "-z"]).catch(() => ""), + git + .raw(["ls-files", "--others", "--exclude-standard", "-z"]) + .catch(() => ""), + ]); + + const byPath = new Map(); + applyNumstatToStatsMap(byPath, againstBaseRaw); + applyNumstatToStatsMap(byPath, stagedRaw); + applyNumstatToStatsMap(byPath, unstagedRaw); + + const untrackedFiles: ChangedFile[] = untrackedRaw + .split("\0") + .filter((path) => path.length > 0) + .map((path) => ({ + path, + status: "untracked", + additions: 0, + deletions: 0, + })); + await countUntrackedFileLines(worktreePath, untrackedFiles); + for (const file of untrackedFiles) { + addDiffStats(byPath, file.path, { + additions: file.additions, + deletions: file.deletions, + }); + } + + return sumDiffStats(byPath); + }), + listCommits: queryProcedure .meta({ timeoutMs: 30_000 }) .input( diff --git a/packages/host-service/src/trpc/router/git/utils/config-write.ts b/packages/host-service/src/trpc/router/git/utils/config-write.ts index a76a0f99c46..cb17d9c712e 100644 --- a/packages/host-service/src/trpc/router/git/utils/config-write.ts +++ b/packages/host-service/src/trpc/router/git/utils/config-write.ts @@ -1,5 +1,3 @@ -import type { SimpleGit } from "simple-git"; - /** * Run a `git config` write with bounded retries on `.git/config.lock` * contention. @@ -15,8 +13,12 @@ import type { SimpleGit } from "simple-git"; * second writer just waits its turn instead of bubbling a confusing 500 * to the renderer. */ +interface GitConfigWriter { + raw(args: string[]): Promise; +} + export async function gitConfigWrite( - git: SimpleGit, + git: GitConfigWriter, args: string[], options: { retries?: number; baseDelayMs?: number } = {}, ): Promise { diff --git a/packages/host-service/src/trpc/router/git/utils/git-helpers.test.ts b/packages/host-service/src/trpc/router/git/utils/git-helpers.test.ts index 4ce2ecc6b12..92f5d488880 100644 --- a/packages/host-service/src/trpc/router/git/utils/git-helpers.test.ts +++ b/packages/host-service/src/trpc/router/git/utils/git-helpers.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { parseNameStatus, parseNumstat } from "./git-helpers"; +import { + parseNameStatus, + parseNumstat, + parseNumstatRecords, +} from "./git-helpers"; describe("parseNumstat", () => { test("regular file entry", () => { @@ -80,6 +84,39 @@ describe("parseNumstat", () => { }); }); +describe("parseNumstatRecords", () => { + test("returns one record per regular file", () => { + const raw = "5\t2\tsrc/foo.ts\x003\t0\tsrc/bar.ts\x00"; + expect(parseNumstatRecords(raw)).toEqual([ + { path: "src/foo.ts", additions: 5, deletions: 2 }, + { path: "src/bar.ts", additions: 3, deletions: 0 }, + ]); + }); + + test("rename is represented once by destination path", () => { + const raw = "4\t3\t\x00src/old.ts\x00src/new.ts\x00"; + expect(parseNumstatRecords(raw)).toEqual([ + { + path: "src/new.ts", + oldPath: "src/old.ts", + additions: 4, + deletions: 3, + }, + ]); + }); + + test("omits empty oldPath for malformed rename records", () => { + const raw = "4\t3\t\x00\x00src/new.ts\x00"; + expect(parseNumstatRecords(raw)).toEqual([ + { + path: "src/new.ts", + additions: 4, + deletions: 3, + }, + ]); + }); +}); + describe("parseNameStatus", () => { test("regular modification", () => { const raw = "M\x00src/foo.ts\x00"; diff --git a/packages/host-service/src/trpc/router/git/utils/git-helpers.ts b/packages/host-service/src/trpc/router/git/utils/git-helpers.ts index f0b0dc833a9..6eb8968a42c 100644 --- a/packages/host-service/src/trpc/router/git/utils/git-helpers.ts +++ b/packages/host-service/src/trpc/router/git/utils/git-helpers.ts @@ -73,6 +73,31 @@ export function parseNumstat( raw: string, ): Map { const result = new Map(); + for (const record of parseNumstatRecords(raw)) { + const stats = { + additions: record.additions, + deletions: record.deletions, + }; + result.set(record.path, stats); + if (record.oldPath) result.set(record.oldPath, stats); + } + return result; +} + +export interface NumstatRecord { + path: string; + oldPath?: string; + additions: number; + deletions: number; +} + +/** + * Parse `git diff --numstat -z` into one record per changed file. Unlike + * `parseNumstat`, renamed files are returned once under the destination path + * so callers that sum totals do not double-count the old and new names. + */ +export function parseNumstatRecords(raw: string): NumstatRecord[] { + const result: NumstatRecord[] = []; const entries = raw.split("\0"); for (let i = 0; i < entries.length; i++) { const entry = entries[i]; @@ -90,10 +115,15 @@ export function parseNumstat( if (pathMaybe === "") { const oldPath = entries[++i] ?? ""; const newPath = entries[++i] ?? ""; - if (newPath) result.set(newPath, stats); - if (oldPath) result.set(oldPath, stats); + if (newPath) { + result.push({ + path: newPath, + ...(oldPath ? { oldPath } : {}), + ...stats, + }); + } } else { - result.set(pathMaybe, stats); + result.push({ path: pathMaybe, ...stats }); } } return result; diff --git a/packages/host-service/src/trpc/router/project/utils/ensure-main-workspace.ts b/packages/host-service/src/trpc/router/project/utils/ensure-main-workspace.ts index 122e2b668d5..8a25c61fb79 100644 --- a/packages/host-service/src/trpc/router/project/utils/ensure-main-workspace.ts +++ b/packages/host-service/src/trpc/router/project/utils/ensure-main-workspace.ts @@ -8,6 +8,12 @@ export type EnsureMainWorkspaceContext = Pick< "api" | "db" | "git" | "organizationId" >; +const mainWorkspaceEnsuresInFlight = new Map>(); + +function mainWorkspaceEnsureKey(projectId: string, repoPath: string): string { + return `${projectId}\0${repoPath}`; +} + async function getCurrentBranchName( git: Awaited>, ): Promise { @@ -58,6 +64,30 @@ export async function ensureMainWorkspaceStrict( ctx: EnsureMainWorkspaceContext, projectId: string, repoPath: string, +): Promise<{ id: string }> { + const key = mainWorkspaceEnsureKey(projectId, repoPath); + const inFlight = mainWorkspaceEnsuresInFlight.get(key); + if (inFlight) return await inFlight; + + const promise = ensureMainWorkspaceStrictUncoalesced( + ctx, + projectId, + repoPath, + ); + mainWorkspaceEnsuresInFlight.set(key, promise); + try { + return await promise; + } finally { + if (mainWorkspaceEnsuresInFlight.get(key) === promise) { + mainWorkspaceEnsuresInFlight.delete(key); + } + } +} + +async function ensureMainWorkspaceStrictUncoalesced( + ctx: EnsureMainWorkspaceContext, + projectId: string, + repoPath: string, ): Promise<{ id: string }> { const git = await ctx.git(repoPath); const branch = await getCurrentBranchName(git); diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/adopt-existing-worktree.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/adopt-existing-worktree.ts index 8b898047423..95ad5def667 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/shared/adopt-existing-worktree.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/adopt-existing-worktree.ts @@ -295,7 +295,7 @@ async function recordBaseBranch( baseBranch: string | undefined, ): Promise { if (!baseBranch) return; - await gitConfigWrite(git as Parameters[0], [ + await gitConfigWrite(git, [ "config", `branch.${branch}.base`, baseBranch, diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/git-config.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/git-config.ts index 296722f4d56..2bb9d6eb687 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/shared/git-config.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/git-config.ts @@ -1,3 +1,4 @@ +import { gitConfigWrite } from "../../git/utils/config-write"; import type { GitClient } from "./types"; export async function enablePushAutoSetupRemote( @@ -5,16 +6,14 @@ export async function enablePushAutoSetupRemote( worktreePath: string, logPrefix: string, ): Promise { - await git - .raw([ - "-C", - worktreePath, - "config", - "--local", - "push.autoSetupRemote", - "true", - ]) - .catch((err) => { - console.warn(`${logPrefix} failed to set push.autoSetupRemote:`, err); - }); + await gitConfigWrite(git, [ + "-C", + worktreePath, + "config", + "--local", + "push.autoSetupRemote", + "true", + ]).catch((err) => { + console.warn(`${logPrefix} failed to set push.autoSetupRemote:`, err); + }); } diff --git a/packages/host-service/src/trpc/router/workspaces/workspaces.ts b/packages/host-service/src/trpc/router/workspaces/workspaces.ts index 323a2832dba..cfabd8dfc1a 100644 --- a/packages/host-service/src/trpc/router/workspaces/workspaces.ts +++ b/packages/host-service/src/trpc/router/workspaces/workspaces.ts @@ -15,6 +15,7 @@ import { import type { HostServiceContext } from "../../../types"; import { protectedProcedure, router } from "../../index"; import { type AgentRunResult, runAgentInWorkspace } from "../agents"; +import { gitConfigWrite } from "../git/utils/config-write"; import { ensureMainWorkspace } from "../project/utils/ensure-main-workspace"; import { adoptExistingWorktree } from "../workspace-creation/shared/adopt-existing-worktree"; import { @@ -350,20 +351,18 @@ async function recordBaseBranchConfig(args: { branch: string; baseBranch: string; }): Promise { - await args.git - .raw([ - "-C", - args.worktreePath, - "config", - `branch.${args.branch}.base`, - args.baseBranch, - ]) - .catch((err) => { - console.warn( - `[workspaces.create] failed to record base branch ${args.baseBranch}:`, - err, - ); - }); + await gitConfigWrite(args.git, [ + "-C", + args.worktreePath, + "config", + `branch.${args.branch}.base`, + args.baseBranch, + ]).catch((err) => { + console.warn( + `[workspaces.create] failed to record base branch ${args.baseBranch}:`, + err, + ); + }); } /** @@ -909,18 +908,19 @@ export const workspacesRouter = router({ if (!plan.usedExistingBranch && plan.startPoint.kind !== "head") { const baseShortName = plan.startPoint.shortName; - await git - .raw([ - "config", - `branch.${resolvedBranch}.base`, - baseShortName, - ]) - .catch((err) => { - console.warn( - `[workspaces.create] failed to record base branch ${baseShortName}:`, - err, - ); - }); + await gitConfigWrite(git, [ + "-C", + worktreePath, + "config", + "--local", + `branch.${resolvedBranch}.base`, + baseShortName, + ]).catch((err) => { + console.warn( + `[workspaces.create] failed to record base branch ${baseShortName}:`, + err, + ); + }); } workspaceRow = await registerCloudAndLocal({ diff --git a/packages/host-service/test/integration/bug-hunt-3.integration.test.ts b/packages/host-service/test/integration/bug-hunt-3.integration.test.ts index 7ef51694722..baa0d778271 100644 --- a/packages/host-service/test/integration/bug-hunt-3.integration.test.ts +++ b/packages/host-service/test/integration/bug-hunt-3.integration.test.ts @@ -238,7 +238,7 @@ describe("bug-hunt-3: more concurrency probes", () => { repo.dispose(); }); - test("BUG: parallel workspace.create calls for different branches can race on the same .git/config", async () => { + test("parallel workspace.create calls for different branches retry .git/config lock contention", async () => { host = await createTestHost({ apiOverrides: { "host.ensure.mutate": () => ({ machineId: "m1" }), @@ -261,18 +261,29 @@ describe("bug-hunt-3: more concurrency probes", () => { // Two different branches in parallel — they both call // `git worktree add` and `git branch..base` writes via // ensureMainWorkspace / inside the procedure. - const results = await Promise.allSettled([ - host.trpc.workspaces.create.mutate({ - projectId, - name: "a", - branch: "feature/a", - }), - host.trpc.workspaces.create.mutate({ - projectId, - name: "b", - branch: "feature/b", - }), - ]); + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(args.map(String).join(" ")); + originalWarn(...args); + }; + let results: PromiseSettledResult[] = []; + try { + results = await Promise.allSettled([ + host.trpc.workspaces.create.mutate({ + projectId, + name: "a", + branch: "feature/a", + }), + host.trpc.workspaces.create.mutate({ + projectId, + name: "b", + branch: "feature/b", + }), + ]); + } finally { + console.warn = originalWarn; + } // Document current behavior. If both succeed, great — we have no // bug. If one fails with a config-lock or worktree-lock error, @@ -287,5 +298,13 @@ describe("bug-hunt-3: more concurrency probes", () => { // We currently expect this to be tolerated. If it starts failing, // flip to a `test.todo` documenting the regression. expect(failures.length).toBe(0); + expect( + warnings.some( + (warning) => + warning.includes("could not lock config file") || + warning.includes("failed to record base branch") || + warning.includes("failed to set push.autoSetupRemote"), + ), + ).toBe(false); }); }); diff --git a/packages/host-service/test/integration/git.integration.test.ts b/packages/host-service/test/integration/git.integration.test.ts index 270347b3c4e..5f1dc53c865 100644 --- a/packages/host-service/test/integration/git.integration.test.ts +++ b/packages/host-service/test/integration/git.integration.test.ts @@ -59,6 +59,27 @@ describe("git router integration", () => { ); }); + test("getDiffStats returns sidebar totals without full status payload", async () => { + writeFileSync(join(scenario.repo.repoPath, "staged.txt"), "one\ntwo\n"); + await scenario.repo.git.add("staged.txt"); + writeFileSync(join(scenario.repo.repoPath, "mixed.txt"), "base\n"); + await scenario.repo.git.add("mixed.txt"); + writeFileSync( + join(scenario.repo.repoPath, "mixed.txt"), + "base\nunstaged one\nunstaged two\n", + ); + writeFileSync( + join(scenario.repo.repoPath, "untracked.txt"), + "alpha\nbeta\ngamma\n", + ); + + const stats = await scenario.host.trpc.git.getDiffStats.query({ + workspaceId: scenario.workspaceId, + }); + + expect(stats).toEqual({ additions: 8, deletions: 0 }); + }); + test("getBaseBranch returns null when not configured", async () => { const result = await scenario.host.trpc.git.getBaseBranch.query({ workspaceId: scenario.workspaceId, diff --git a/packages/host-service/test/integration/workspace-cleanup.integration.test.ts b/packages/host-service/test/integration/workspace-cleanup.integration.test.ts index f8f4fccdeba..390ea3b20c9 100644 --- a/packages/host-service/test/integration/workspace-cleanup.integration.test.ts +++ b/packages/host-service/test/integration/workspace-cleanup.integration.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; -import { rmSync, writeFileSync } from "node:fs"; +import { existsSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { TRPCClientError } from "@trpc/client"; import { eq } from "drizzle-orm"; @@ -192,6 +192,58 @@ describe("workspaceCleanup.destroy integration", () => { expect(worktreeList).toContain(otherWorktreePath); }); + test("concurrent destroy of the same workspace rejects the second call and completes cleanup", async () => { + await scenario.dispose(); + + let releaseCloudDelete!: () => void; + const cloudDeleteStarted = Promise.withResolvers(); + const cloudDeleteRelease = new Promise((resolve) => { + releaseCloudDelete = resolve; + }); + scenario = await createFeatureWorktreeScenario({ + hostOptions: { + apiOverrides: { + "v2Workspace.getFromHost.query": cloudOk.workspaceGetFromHost({ + type: "feature", + }), + "v2Workspace.delete.mutate": async () => { + cloudDeleteStarted.resolve(); + await cloudDeleteRelease; + return { success: true }; + }, + }, + }, + }); + + const first = scenario.host.trpc.workspaceCleanup.destroy.mutate({ + workspaceId: scenario.featureWorkspaceId, + deleteBranch: true, + force: true, + }); + await cloudDeleteStarted.promise; + + await expect( + scenario.host.trpc.workspaceCleanup.destroy.mutate({ + workspaceId: scenario.featureWorkspaceId, + force: true, + }), + ).rejects.toThrow(/Deletion already in progress/); + + releaseCloudDelete(); + const result = await first; + expect(result.success).toBe(true); + expect(result.worktreeRemoved).toBe(true); + expect(result.branchDeleted).toBe(true); + expect(existsSync(scenario.worktreePath)).toBe(false); + + const remaining = scenario.host.db + .select() + .from(workspaces) + .where(eq(workspaces.id, scenario.featureWorkspaceId)) + .all(); + expect(remaining).toHaveLength(0); + }); + test("returns success when no local workspace row exists, still calls cloud delete", async () => { await scenario.dispose(); const fresh = await createBasicScenario({ diff --git a/packages/host-service/test/integration/workspace-create-delete.integration.test.ts b/packages/host-service/test/integration/workspace-create-delete.integration.test.ts index 8c8db4824f7..bd7f7d18e47 100644 --- a/packages/host-service/test/integration/workspace-create-delete.integration.test.ts +++ b/packages/host-service/test/integration/workspace-create-delete.integration.test.ts @@ -287,6 +287,84 @@ describe("workspace.create + workspace.delete integration", () => { ).toBe(true); }); + test("parallel create() then destroy() churn leaves no duplicate rows or stale worktrees", async () => { + const scenario = await createProjectScenario({ + hostOptions: { + apiOverrides: { + ...cloudFlows.workspaceCreateOk(), + ...cloudFlows.workspaceDeleteOk(), + }, + }, + }); + dispose = scenario.dispose; + + const branches = ["feature/churn-a", "feature/churn-b", "feature/churn-c"]; + const createResults = await Promise.all( + branches.map((branch) => + scenario.host.trpc.workspaces.create.mutate({ + projectId: scenario.projectId, + name: branch, + branch, + }), + ), + ); + + const createdRows = createResults.map((result) => result.workspace); + expect(createdRows.map((row) => row.branch).sort()).toEqual( + branches.toSorted(), + ); + + const rowsAfterCreate = scenario.host.db.select().from(workspaces).all(); + const featureRows = rowsAfterCreate.filter((row) => + branches.includes(row.branch), + ); + const mainRows = rowsAfterCreate.filter( + (row) => row.worktreePath === scenario.repo.repoPath, + ); + expect(featureRows).toHaveLength(branches.length); + expect(mainRows).toHaveLength(1); + for (const row of featureRows) { + expect(existsSync(row.worktreePath)).toBe(true); + } + + const destroyResults = await Promise.all( + createdRows.map((row) => + scenario.host.trpc.workspaceCleanup.destroy.mutate({ + workspaceId: row.id, + deleteBranch: true, + force: true, + }), + ), + ); + expect(destroyResults.every((result) => result.success)).toBe(true); + + const rowsAfterDestroy = scenario.host.db.select().from(workspaces).all(); + expect( + rowsAfterDestroy.filter((row) => branches.includes(row.branch)), + ).toHaveLength(0); + expect( + rowsAfterDestroy.filter( + (row) => row.worktreePath === scenario.repo.repoPath, + ), + ).toHaveLength(1); + for (const row of featureRows) { + expect(existsSync(row.worktreePath)).toBe(false); + } + + const worktreeList = await scenario.repo.git.raw([ + "worktree", + "list", + "--porcelain", + ]); + for (const row of featureRows) { + expect(worktreeList).not.toContain(row.worktreePath); + } + const localBranches = await scenario.repo.git.branchLocal(); + for (const branch of branches) { + expect(localBranches.all).not.toContain(branch); + } + }); + test("delete() requires authentication", async () => { const scenario = await createBasicScenario(); dispose = scenario.dispose; diff --git a/packages/panes/src/react/components/Workspace/Workspace.tsx b/packages/panes/src/react/components/Workspace/Workspace.tsx index 370d9b668d3..807a4beeb83 100644 --- a/packages/panes/src/react/components/Workspace/Workspace.tsx +++ b/packages/panes/src/react/components/Workspace/Workspace.tsx @@ -40,7 +40,11 @@ export function Workspace({ } for (const [prevId, prevPane] of previousPanesRef.current) { if (!current.has(prevId)) { - registry[prevPane.kind]?.onAfterClose?.(prevPane); + try { + registry[prevPane.kind]?.onAfterClose?.(prevPane); + } catch (err) { + console.error("onAfterClose threw", err); + } } } previousPanesRef.current = current; diff --git a/packages/panes/src/react/components/Workspace/components/TabBar/TabBar.tsx b/packages/panes/src/react/components/Workspace/components/TabBar/TabBar.tsx index 4d3c117d869..0ffdcb8753d 100644 --- a/packages/panes/src/react/components/Workspace/components/TabBar/TabBar.tsx +++ b/packages/panes/src/react/components/Workspace/components/TabBar/TabBar.tsx @@ -4,12 +4,14 @@ import { DropdownMenuContent, DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; -import { OverflowFadeContainer } from "@superset/ui/overflow-fade-container"; import { PlusIcon } from "lucide-react"; import { - type ComponentProps, type ReactNode, + type UIEvent, useCallback, + useEffect, + useLayoutEffect, + useMemo, useRef, useState, } from "react"; @@ -18,7 +20,7 @@ import type { Tab } from "../../../../../types"; import type { PaneRegistry } from "../../../../types"; import { PANE_DRAG_TYPE } from "../Tab/components/Pane/components/PaneHeader"; import { TAB_DRAG_TYPE, TabItem } from "./components/TabItem"; -import { computeInsertIndex, TAB_WIDTH } from "./utils"; +import { computeInsertIndex, getVisibleTabWindow, TAB_WIDTH } from "./utils"; interface TabBarProps { tabs: Tab[]; @@ -87,7 +89,12 @@ export function TabBar({ renderTabAccessory, }: TabBarProps) { const tabsTrackRef = useRef(null); - const [hasHorizontalOverflow, setHasHorizontalOverflow] = useState(false); + const scrollContainerRef = useRef(null); + const scrollMetricsFrameRef = useRef(null); + const [scrollMetrics, setScrollMetrics] = useState({ + clientWidth: 0, + scrollLeft: 0, + }); const insertIndexRef = useRef(null); const [insertIndex, setInsertIndex] = useState(null); @@ -142,6 +149,119 @@ export function TabBar({ [tabs, onReorderTab, onMovePaneToNewTab], ); + const readScrollMetrics = useCallback(() => { + const node = scrollContainerRef.current; + if (!node) return; + const nextMetrics = { + clientWidth: node.clientWidth, + scrollLeft: node.scrollLeft, + }; + setScrollMetrics((currentMetrics) => + currentMetrics.clientWidth === nextMetrics.clientWidth && + currentMetrics.scrollLeft === nextMetrics.scrollLeft + ? currentMetrics + : nextMetrics, + ); + }, []); + + const scheduleScrollMetricsUpdate = useCallback(() => { + if (scrollMetricsFrameRef.current !== null) return; + if (typeof requestAnimationFrame !== "function") { + readScrollMetrics(); + return; + } + scrollMetricsFrameRef.current = requestAnimationFrame(() => { + scrollMetricsFrameRef.current = null; + readScrollMetrics(); + }); + }, [readScrollMetrics]); + + const handleScroll = useCallback( + (_event: UIEvent) => { + scheduleScrollMetricsUpdate(); + }, + [scheduleScrollMetricsUpdate], + ); + + useEffect( + () => () => { + if ( + scrollMetricsFrameRef.current !== null && + typeof cancelAnimationFrame === "function" + ) { + cancelAnimationFrame(scrollMetricsFrameRef.current); + scrollMetricsFrameRef.current = null; + } + }, + [], + ); + + useLayoutEffect(() => { + const node = scrollContainerRef.current; + if (!node) return; + + readScrollMetrics(); + const resizeObserver = new ResizeObserver(scheduleScrollMetricsUpdate); + resizeObserver.observe(node); + + return () => { + resizeObserver.disconnect(); + }; + }, [readScrollMetrics, scheduleScrollMetricsUpdate]); + + useLayoutEffect(() => { + readScrollMetrics(); + }, [readScrollMetrics]); + + const activeTabIndex = useMemo( + () => tabs.findIndex((tab) => tab.id === activeTabId), + [activeTabId, tabs], + ); + const totalTabsWidth = tabs.length * TAB_WIDTH; + const addTabButtonWidth = 40; + const hasHorizontalOverflow = + scrollMetrics.clientWidth > 0 && + totalTabsWidth + addTabButtonWidth > scrollMetrics.clientWidth + 1; + const inlineAddTabWidth = hasHorizontalOverflow ? 0 : addTabButtonWidth; + const tabsTrackWidth = totalTabsWidth + inlineAddTabWidth; + + useLayoutEffect(() => { + const node = scrollContainerRef.current; + const viewportWidth = scrollMetrics.clientWidth; + if (!node || activeTabIndex < 0 || viewportWidth <= 0) return; + + const tabLeft = activeTabIndex * TAB_WIDTH; + const tabRight = tabLeft + TAB_WIDTH; + const viewportLeft = scrollMetrics.scrollLeft; + const viewportRight = viewportLeft + viewportWidth; + let nextScrollLeft = viewportLeft; + + if (tabLeft < viewportLeft) { + nextScrollLeft = tabLeft; + } else if (tabRight > viewportRight) { + nextScrollLeft = tabRight - viewportWidth; + } + + if (nextScrollLeft === viewportLeft) return; + + const boundedScrollLeft = Math.max( + 0, + Math.min(nextScrollLeft, tabsTrackWidth - viewportWidth), + ); + node.scrollLeft = boundedScrollLeft; + setScrollMetrics((currentMetrics) => + currentMetrics.clientWidth === viewportWidth && + currentMetrics.scrollLeft === boundedScrollLeft + ? currentMetrics + : { clientWidth: viewportWidth, scrollLeft: boundedScrollLeft }, + ); + }, [ + activeTabIndex, + scrollMetrics.clientWidth, + scrollMetrics.scrollLeft, + tabsTrackWidth, + ]); + // Clear indicator when cursor leaves the tab bar if (!isOver && insertIndexRef.current !== null) { insertIndexRef.current = null; @@ -155,15 +275,30 @@ export function TabBar({ [connectDrop], ); - const handleOverflowChange = useCallback< - NonNullable< - ComponentProps["onOverflowChange"] - > - >((state) => { - setHasHorizontalOverflow(state.hasOverflowX); + const setScrollContainerRef = useCallback((node: HTMLDivElement | null) => { + scrollContainerRef.current = node; }, []); const insertLineLeft = insertIndex !== null ? insertIndex * TAB_WIDTH : null; + const visibleTabWindow = useMemo( + () => + getVisibleTabWindow({ + clientWidth: scrollMetrics.clientWidth, + scrollLeft: scrollMetrics.scrollLeft, + tabCount: tabs.length, + }), + [scrollMetrics.clientWidth, scrollMetrics.scrollLeft, tabs.length], + ); + const visibleTabs = useMemo( + () => + tabs + .slice(visibleTabWindow.start, visibleTabWindow.end) + .map((tab, offset) => ({ + index: visibleTabWindow.start + offset, + tab, + })), + [tabs, visibleTabWindow], + ); if (tabs.length === 0) { return ( @@ -189,23 +324,27 @@ export function TabBar({ ref={setRootRef} className="group/root-tabs flex h-10 min-w-0 shrink-0 items-stretch border-b border-border bg-background" > - -
- {tabs.map((tab, i) => ( +
+ {visibleTabs.map(({ tab, index }) => (
onSelectTab(tab.id)} onClose={() => onCloseTab(tab.id)} @@ -224,12 +363,15 @@ export function TabBar({ /> )} {!hasHorizontalOverflow && ( -
+
)}
- +
{hasHorizontalOverflow && (
diff --git a/packages/panes/src/react/components/Workspace/components/TabBar/components/TabItem/TabItem.tsx b/packages/panes/src/react/components/Workspace/components/TabBar/components/TabItem/TabItem.tsx index 935b9431f42..8f64793493f 100644 --- a/packages/panes/src/react/components/Workspace/components/TabBar/components/TabItem/TabItem.tsx +++ b/packages/panes/src/react/components/Workspace/components/TabBar/components/TabItem/TabItem.tsx @@ -6,11 +6,10 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "@superset/ui/context-menu"; -import { OverflowFadeText } from "@superset/ui/overflow-fade-text"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { PencilIcon, XIcon } from "lucide-react"; -import { type ReactNode, useCallback, useRef, useState } from "react"; +import { memo, type ReactNode, useCallback, useRef, useState } from "react"; import { useDrag, useDrop } from "react-dnd"; import type { Tab } from "../../../../../../../types"; import type { PaneRegistry } from "../../../../../../types"; @@ -35,7 +34,7 @@ interface TabItemProps { accessory?: ReactNode; } -export function TabItem({ +function TabItemComponent({ tab, tabs, registry, @@ -156,9 +155,7 @@ export function TabItem({ type="button" > {icon && {icon}} - - {title} - + {title} @@ -217,3 +214,21 @@ export function TabItem({ ); } + +function areTabItemPropsEqual( + previous: TabItemProps, + next: TabItemProps, +) { + return ( + previous.tab === next.tab && + previous.tabs === next.tabs && + previous.registry === next.registry && + previous.index === next.index && + previous.isActive === next.isActive + ); +} + +export const TabItem = memo( + TabItemComponent, + areTabItemPropsEqual, +) as typeof TabItemComponent; diff --git a/packages/panes/src/react/components/Workspace/components/TabBar/utils/index.ts b/packages/panes/src/react/components/Workspace/components/TabBar/utils/index.ts index a4f01d7c568..5537a977da1 100644 --- a/packages/panes/src/react/components/Workspace/components/TabBar/utils/index.ts +++ b/packages/panes/src/react/components/Workspace/components/TabBar/utils/index.ts @@ -1 +1,7 @@ -export { computeInsertIndex, TAB_WIDTH } from "./utils"; +export { + computeInsertIndex, + getVisibleTabWindow, + TAB_WIDTH, + TAB_WINDOW_OVERSCAN, + TAB_WINDOWING_THRESHOLD, +} from "./utils"; diff --git a/packages/panes/src/react/components/Workspace/components/TabBar/utils/utils.test.ts b/packages/panes/src/react/components/Workspace/components/TabBar/utils/utils.test.ts new file mode 100644 index 00000000000..31eb548e440 --- /dev/null +++ b/packages/panes/src/react/components/Workspace/components/TabBar/utils/utils.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "bun:test"; +import { getVisibleTabWindow, TAB_WIDTH } from "./utils"; + +describe("getVisibleTabWindow", () => { + it("renders all tabs below the windowing threshold", () => { + expect( + getVisibleTabWindow({ + clientWidth: TAB_WIDTH * 2, + scrollLeft: TAB_WIDTH * 3, + tabCount: 12, + }), + ).toEqual({ start: 0, end: 12 }); + }); + + it("limits large tab sets to the visible range plus overscan", () => { + expect( + getVisibleTabWindow({ + clientWidth: TAB_WIDTH * 3, + overscan: 2, + scrollLeft: TAB_WIDTH * 10, + tabCount: 80, + }), + ).toEqual({ start: 8, end: 15 }); + }); + + it("falls back to the initial window before layout has measured", () => { + expect( + getVisibleTabWindow({ + clientWidth: 0, + scrollLeft: 0, + tabCount: 80, + windowingThreshold: 24, + }), + ).toEqual({ start: 0, end: 24 }); + }); +}); diff --git a/packages/panes/src/react/components/Workspace/components/TabBar/utils/utils.ts b/packages/panes/src/react/components/Workspace/components/TabBar/utils/utils.ts index d3cd75e1d07..6ca8324fdf6 100644 --- a/packages/panes/src/react/components/Workspace/components/TabBar/utils/utils.ts +++ b/packages/panes/src/react/components/Workspace/components/TabBar/utils/utils.ts @@ -1,4 +1,43 @@ export const TAB_WIDTH = 160; +export const TAB_WINDOWING_THRESHOLD = 12; +export const TAB_WINDOW_OVERSCAN = 4; + +interface VisibleTabWindowInput { + clientWidth: number; + overscan?: number; + scrollLeft: number; + tabCount: number; + windowingThreshold?: number; +} + +export function getVisibleTabWindow({ + clientWidth, + overscan = TAB_WINDOW_OVERSCAN, + scrollLeft, + tabCount, + windowingThreshold = TAB_WINDOWING_THRESHOLD, +}: VisibleTabWindowInput): { end: number; start: number } { + if (tabCount <= 0) { + return { start: 0, end: 0 }; + } + + if (tabCount <= windowingThreshold) { + return { start: 0, end: tabCount }; + } + + if (clientWidth <= 0) { + return { start: 0, end: Math.min(tabCount, windowingThreshold) }; + } + + const boundedScrollLeft = Math.max(0, scrollLeft); + const visibleStart = Math.floor(boundedScrollLeft / TAB_WIDTH); + const visibleEnd = Math.ceil((boundedScrollLeft + clientWidth) / TAB_WIDTH); + + return { + start: Math.max(0, visibleStart - overscan), + end: Math.min(tabCount, visibleEnd + overscan), + }; +} export function computeInsertIndex( clientX: number, diff --git a/packages/shared/src/host-info.ts b/packages/shared/src/host-info.ts index 449162efb29..268e24f5b3d 100644 --- a/packages/shared/src/host-info.ts +++ b/packages/shared/src/host-info.ts @@ -6,6 +6,7 @@ import { homedir, hostname, platform } from "node:os"; // Salt value preserved verbatim across the rename to keep existing host ids // stable for users already registered against the cloud. const APP_HOST_SALT = "superset-desktop-device-id-v1"; +const MACHINE_ID_COMMAND_TIMEOUT_MS = 1500; function getRawMachineId(): string { try { @@ -15,7 +16,7 @@ function getRawMachineId(): string { const output = execFileSync( "ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], - { encoding: "utf8" }, + { encoding: "utf8", timeout: MACHINE_ID_COMMAND_TIMEOUT_MS }, ); const match = output.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/); if (match?.[1]) return match[1]; @@ -34,7 +35,7 @@ function getRawMachineId(): string { "/v", "MachineGuid", ], - { encoding: "utf8" }, + { encoding: "utf8", timeout: MACHINE_ID_COMMAND_TIMEOUT_MS }, ); const match = output.match(/MachineGuid\s+REG_SZ\s+(\S+)/); if (match?.[1]) return match[1]; diff --git a/packages/ui/src/components/overflow-fade/OverflowFadeContainer/OverflowFadeContainer.tsx b/packages/ui/src/components/overflow-fade/OverflowFadeContainer/OverflowFadeContainer.tsx index 3870c8e0a0a..7aac864e912 100644 --- a/packages/ui/src/components/overflow-fade/OverflowFadeContainer/OverflowFadeContainer.tsx +++ b/packages/ui/src/components/overflow-fade/OverflowFadeContainer/OverflowFadeContainer.tsx @@ -28,6 +28,11 @@ interface OverflowFadeContainerProps extends ComponentProps<"div"> { * scrollers such as tabs; avoid on large or virtualized lists without profiling. */ observeChildren?: boolean; + /** + * Re-measure overflow when the caller knows scrollable content changed + * without relying on observing every child node. + */ + measureKey?: unknown; } export function OverflowFadeContainer({ @@ -36,6 +41,7 @@ export function OverflowFadeContainer({ fadeEdges = DEFAULT_FADE_EDGES, onOverflowChange, observeChildren = false, + measureKey, ...props }: OverflowFadeContainerProps) { const { @@ -46,7 +52,7 @@ export function OverflowFadeContainer({ canScrollRight, canScrollBottom, canScrollLeft, - } = useOverflowFade({ observeChildren }); + } = useOverflowFade({ measureKey, observeChildren }); const setRef = (node: HTMLDivElement | null) => { ref.current = node; diff --git a/packages/ui/src/hooks/use-overflow-fade.ts b/packages/ui/src/hooks/use-overflow-fade.ts index 7e8dabf51dc..d38579b0c20 100644 --- a/packages/ui/src/hooks/use-overflow-fade.ts +++ b/packages/ui/src/hooks/use-overflow-fade.ts @@ -3,6 +3,7 @@ import { useCallback, useLayoutEffect, useRef, useState } from "react"; interface UseOverflowFadeOptions { + measureKey?: unknown; observeChildren?: boolean; observeParent?: boolean; } @@ -54,13 +55,15 @@ function areOverflowStatesEqual( } export function useOverflowFade({ + measureKey, observeChildren = false, observeParent = false, }: UseOverflowFadeOptions = {}) { const ref = useRef(null); + const frameIdRef = useRef(null); const [state, setState] = useState(INITIAL_STATE); - const updateOverflow = useCallback(() => { + const measureOverflow = useCallback(() => { const node = ref.current; if (!node) return; @@ -72,6 +75,18 @@ export function useOverflowFade({ ); }, []); + const updateOverflow = useCallback(() => { + if (frameIdRef.current !== null) return; + if (typeof requestAnimationFrame !== "function") { + measureOverflow(); + return; + } + frameIdRef.current = requestAnimationFrame(() => { + frameIdRef.current = null; + measureOverflow(); + }); + }, [measureOverflow]); + useLayoutEffect(() => { const node = ref.current; if (!node) return; @@ -106,6 +121,10 @@ export function useOverflowFade({ window.addEventListener("resize", updateOverflow); return () => { + if (frameIdRef.current !== null) { + cancelAnimationFrame(frameIdRef.current); + frameIdRef.current = null; + } resizeObserver.disconnect(); mutationObserver?.disconnect(); node.removeEventListener("scroll", updateOverflow); @@ -113,6 +132,11 @@ export function useOverflowFade({ }; }, [observeChildren, observeParent, updateOverflow]); + useLayoutEffect(() => { + void measureKey; + updateOverflow(); + }, [measureKey, updateOverflow]); + return { ref, ...state, From aa5373963ef55ea75277438cf6c61d40bdd81ea9 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 14 May 2026 10:02:14 -0700 Subject: [PATCH 02/16] Avoid sync terminal drain on workspace switch --- .../terminal-runtime-registry.test.ts | 1 - .../lib/terminal/terminal-runtime.test.ts | 132 ++++++++++++++++-- .../renderer/lib/terminal/terminal-runtime.ts | 68 ++------- 3 files changed, 130 insertions(+), 71 deletions(-) diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts index c45684de03d..0ab14cb848a 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts @@ -147,7 +147,6 @@ function createFakeRuntime(terminalId: string): FakeRuntime { _disposeImagePasteFallback: null, _outputQueue: [], _outputEnqueued: false, - _outputQueuedBytes: 0, hasBufferedContent: false, }; } diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.test.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.test.ts index f7b5a82b9da..b69dffd66d3 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.test.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.test.ts @@ -1,6 +1,11 @@ import { describe, expect, mock, test } from "bun:test"; import type { TerminalRuntime } from "./terminal-runtime"; -import { writeRuntimeOutput } from "./terminal-runtime"; +import { detachFromContainer, writeRuntimeOutput } from "./terminal-runtime"; + +type MutableGlobal = typeof globalThis & { + document?: Document; + requestAnimationFrame?: typeof requestAnimationFrame; +}; function createFakeRuntime( container: HTMLDivElement | null, @@ -19,7 +24,9 @@ function createFakeRuntime( write: ReturnType; }, fitAddon: {} as TerminalRuntime["fitAddon"], - serializeAddon: {} as TerminalRuntime["serializeAddon"], + serializeAddon: { + serialize: mock(() => ""), + } as unknown as TerminalRuntime["serializeAddon"], searchAddon: null, progressAddon: null, wrapper: {} as HTMLDivElement, @@ -32,36 +39,131 @@ function createFakeRuntime( _disposeImagePasteFallback: null, _outputQueue: [], _outputEnqueued: false, - _outputQueuedBytes: 0, hasBufferedContent: false, }; } +async function drainQueuedOutput(runtime: TerminalRuntime) { + for ( + let attempt = 0; + runtime._outputQueue.length > 0 && attempt < 100; + attempt++ + ) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + expect(runtime._outputQueue).toHaveLength(0); +} + +async function withOutputFlushTimers(callback: () => Promise | T) { + const mutableGlobal = globalThis as MutableGlobal; + const hadRequestAnimationFrame = "requestAnimationFrame" in mutableGlobal; + const previousRequestAnimationFrame = mutableGlobal.requestAnimationFrame; + + Object.defineProperty(mutableGlobal, "requestAnimationFrame", { + configurable: true, + writable: true, + value: undefined, + }); + + try { + return await callback(); + } finally { + if (hadRequestAnimationFrame) { + Object.defineProperty(mutableGlobal, "requestAnimationFrame", { + configurable: true, + writable: true, + value: previousRequestAnimationFrame, + }); + } else { + Reflect.deleteProperty(mutableGlobal, "requestAnimationFrame"); + } + } +} + +async function withFakeDocument(callback: () => Promise | T) { + const mutableGlobal = globalThis as MutableGlobal; + const hadDocument = "document" in mutableGlobal; + const previousDocument = mutableGlobal.document; + const fakeElement = { + id: "", + style: {}, + appendChild: mock(() => null), + setAttribute: mock(() => {}), + }; + const fakeDocument = { + body: { + appendChild: mock(() => null), + }, + createElement: mock(() => fakeElement), + getElementById: mock(() => null), + }; + + Object.defineProperty(mutableGlobal, "document", { + configurable: true, + writable: true, + value: fakeDocument, + }); + + try { + return await callback(); + } finally { + if (hadDocument) { + Object.defineProperty(mutableGlobal, "document", { + configurable: true, + writable: true, + value: previousDocument, + }); + } else { + Reflect.deleteProperty(mutableGlobal, "document"); + } + } +} + describe("terminal runtime output", () => { - test("writes parked output immediately instead of queueing replay", () => { + test("writes small parked output immediately when there is no backlog", () => { const runtime = createFakeRuntime(null); - const output = new Uint8Array(128 * 1024); + const output = "tail"; writeRuntimeOutput(runtime, output); expect(runtime.terminal.write).toHaveBeenCalledTimes(1); expect(runtime.terminal.write.mock.calls[0]?.[0]).toBe(output); expect(runtime._outputQueue).toHaveLength(0); - expect(runtime._outputQueuedBytes).toBe(0); expect(runtime.hasBufferedContent).toBe(true); }); - test("drains any visible backlog before writing parked output", () => { - const runtime = createFakeRuntime({} as HTMLDivElement); + test("queues large parked output instead of blocking the renderer", async () => { + await withOutputFlushTimers(async () => { + const runtime = createFakeRuntime(null); + const output = new Uint8Array(128 * 1024); - writeRuntimeOutput(runtime, "x".repeat(10_000)); - expect(runtime._outputQueue.length).toBeGreaterThan(0); + writeRuntimeOutput(runtime, output); - runtime.container = null; - writeRuntimeOutput(runtime, "tail"); + expect(runtime.terminal.write).not.toHaveBeenCalled(); + expect(runtime._outputQueue.length).toBeGreaterThan(1); + expect(runtime.hasBufferedContent).toBe(true); - expect(runtime._outputQueue).toHaveLength(0); - expect(runtime._outputQueuedBytes).toBe(0); - expect(runtime.terminal.write.mock.calls.at(-1)?.[0]).toBe("tail"); + await drainQueuedOutput(runtime); + }); + }); + + test("does not synchronously drain backlog when parking a runtime", async () => { + await withOutputFlushTimers(async () => { + await withFakeDocument(async () => { + const runtime = createFakeRuntime({} as HTMLDivElement); + + writeRuntimeOutput(runtime, "x".repeat(10_000)); + const queuedBeforeDetach = runtime._outputQueue.length; + expect(queuedBeforeDetach).toBeGreaterThan(0); + + detachFromContainer(runtime); + + expect(runtime.container).toBeNull(); + expect(runtime.terminal.write).not.toHaveBeenCalled(); + expect(runtime._outputQueue).toHaveLength(queuedBeforeDetach); + + await drainQueuedOutput(runtime); + }); + }); }); }); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts index 1dfe16a6a6b..36bb0d4de78 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -24,7 +24,6 @@ type TerminalOutputData = string | Uint8Array; interface TerminalOutputQueueItem { data: TerminalOutputData; - byteLength: number; callback?: () => void; } @@ -51,7 +50,6 @@ export interface TerminalRuntime { _disposeImagePasteFallback: (() => void) | null; _outputQueue: TerminalOutputQueueItem[]; _outputEnqueued: boolean; - _outputQueuedBytes: number; hasBufferedContent: boolean; } @@ -73,7 +71,7 @@ function splitOutputData( ): TerminalOutputQueueItem[] { const byteLength = getOutputByteLength(data); if (byteLength <= OUTPUT_CHUNK_BYTES) { - return [{ data, byteLength, callback }]; + return [{ data, callback }]; } const items: TerminalOutputQueueItem[] = []; @@ -81,13 +79,13 @@ function splitOutputData( for (let start = 0; start < data.length; ) { const end = splitStringAtOutputBoundary(data, start); const chunk = data.slice(start, end); - items.push({ data: chunk, byteLength: chunk.length }); + items.push({ data: chunk }); start = end; } } else { for (let start = 0; start < data.byteLength; start += OUTPUT_CHUNK_BYTES) { const chunk = data.slice(start, start + OUTPUT_CHUNK_BYTES); - items.push({ data: chunk, byteLength: chunk.byteLength }); + items.push({ data: chunk }); } } @@ -115,10 +113,6 @@ function flushQueuedOutput() { let processed = 0; for (const runtime of Array.from(runtimesWithQueuedOutput)) { runtimesWithQueuedOutput.delete(runtime); - if (!runtime.container) { - flushQueuedRuntimeOutputNow(runtime); - continue; - } const item = runtime._outputQueue.shift(); if (!item) { runtime._outputEnqueued = false; @@ -126,10 +120,6 @@ function flushQueuedOutput() { } processed += 1; - runtime._outputQueuedBytes = Math.max( - 0, - runtime._outputQueuedBytes - item.byteLength, - ); runtime.terminal.write(item.data, item.callback); if (runtime._outputQueue.length > 0) { @@ -146,43 +136,24 @@ function flushQueuedOutput() { } } +function scheduleRuntimeQueuedOutput(runtime: TerminalRuntime) { + if (runtime._outputQueue.length === 0 || runtime._outputEnqueued) return; + runtime._outputEnqueued = true; + runtimesWithQueuedOutput.add(runtime); + scheduleQueuedOutputFlush(); +} + function enqueueRuntimeOutput( runtime: TerminalRuntime, item: TerminalOutputQueueItem, ) { - if (!runtime.container) { - runtime.terminal.write(item.data, item.callback); - return; - } - - runtime._outputQueuedBytes += item.byteLength; runtime._outputQueue.push(item); - if (!runtime._outputEnqueued) { - runtime._outputEnqueued = true; - runtimesWithQueuedOutput.add(runtime); - } - scheduleQueuedOutputFlush(); -} - -function flushQueuedRuntimeOutputNow(runtime: TerminalRuntime) { - runtimesWithQueuedOutput.delete(runtime); - runtime._outputEnqueued = false; - while (runtime._outputQueue.length > 0) { - const item = runtime._outputQueue.shift(); - if (!item) break; - runtime._outputQueuedBytes = Math.max( - 0, - runtime._outputQueuedBytes - item.byteLength, - ); - runtime.terminal.write(item.data, item.callback); - } - runtime._outputQueuedBytes = 0; + scheduleRuntimeQueuedOutput(runtime); } function clearQueuedRuntimeOutput(runtime: TerminalRuntime) { runtimesWithQueuedOutput.delete(runtime); runtime._outputEnqueued = false; - runtime._outputQueuedBytes = 0; const queue = runtime._outputQueue.splice(0); for (const item of queue) { item.callback?.(); @@ -421,7 +392,6 @@ export function createRuntime( _disposeImagePasteFallback: disposeImagePasteFallback, _outputQueue: [], _outputEnqueued: false, - _outputQueuedBytes: 0, hasBufferedContent, }; } @@ -439,14 +409,6 @@ export function writeRuntimeOutput( if (byteLength > 0) { runtime.hasBufferedContent = true; } - if (!runtime.container) { - if (runtime._outputQueue.length > 0) { - flushQueuedRuntimeOutputNow(runtime); - } - runtime.terminal.write(data, callback); - return; - } - const items = splitOutputData(data, callback); if (runtime._outputQueue.length === 0 && items.length === 1) { runtime.terminal.write(data, callback); @@ -486,11 +448,7 @@ export function attachToContainer( runtime.resizeObserver = observer; runtime._disposeResizeObserver = scheduler.dispose; - if (runtime._outputQueue.length > 0 && !runtime._outputEnqueued) { - runtime._outputEnqueued = true; - runtimesWithQueuedOutput.add(runtime); - scheduleQueuedOutputFlush(); - } + scheduleRuntimeQueuedOutput(runtime); } function focusRuntimeNow(runtime: TerminalRuntime) { @@ -527,7 +485,7 @@ export function detachFromContainer(runtime: TerminalRuntime) { // see getTerminalParkingContainer. getTerminalParkingContainer().appendChild(runtime.wrapper); runtime.container = null; - flushQueuedRuntimeOutputNow(runtime); + scheduleRuntimeQueuedOutput(runtime); } export function updateRuntimeAppearance( From 7a91cd1a24fa2f19964556c78f9a058a282086cf Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 14 May 2026 10:37:35 -0700 Subject: [PATCH 03/16] Revert risky terminal lifecycle changes --- .../terminal-runtime-registry.test.ts | 72 +----- .../lib/terminal/terminal-runtime-registry.ts | 96 +------- .../lib/terminal/terminal-runtime.test.ts | 169 -------------- .../renderer/lib/terminal/terminal-runtime.ts | 214 +----------------- .../terminal/terminal-ws-transport.test.ts | 129 +---------- .../lib/terminal/terminal-ws-transport.ts | 52 +---- .../BackgroundTerminalsButton.tsx | 2 - .../components/TerminalPane/TerminalPane.tsx | 20 +- .../TerminalSessionDropdown.tsx | 9 + .../hooks/usePaneRegistry/usePaneRegistry.tsx | 1 + 10 files changed, 44 insertions(+), 720 deletions(-) delete mode 100644 apps/desktop/src/renderer/lib/terminal/terminal-runtime.test.ts diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts index 0ab14cb848a..fc3bc16d72d 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts @@ -11,7 +11,7 @@ import type { TerminalAppearance } from "./appearance"; import type { TerminalRuntime } from "./terminal-runtime"; type FakeTerminal = TerminalRuntime["terminal"] & { - emitWriteComplete: () => void; + write: ReturnType; }; type FakeRuntime = TerminalRuntime & { @@ -27,11 +27,11 @@ type MockedTerminalRuntimeModule = typeof import("./terminal-runtime"); const createdRuntimes: FakeRuntime[] = []; const createRuntimeMock = mock( ( - _terminalId: string, + terminalId: string, _appearance: TerminalAppearance, _options?: { initialBuffer?: string }, ): TerminalRuntime => { - const runtime = createFakeRuntime(_terminalId); + const runtime = createFakeRuntime(terminalId); createdRuntimes.push(runtime); return runtime; }, @@ -61,22 +61,6 @@ const disposeRuntimeMock = mock((runtime: TerminalRuntime) => { const updateRuntimeAppearanceMock = mock( (_runtime: TerminalRuntime, _appearance: TerminalAppearance) => {}, ); -const focusRuntimeMock = mock((_runtime: TerminalRuntime) => {}); -const writeRuntimeOutputMock = mock( - ( - runtime: TerminalRuntime, - data: string | Uint8Array, - callback?: () => void, - ) => { - if ((typeof data === "string" ? data.length : data.byteLength) > 0) { - runtime.hasBufferedContent = true; - } - runtime.terminal.write(data, callback); - }, -); -const shouldReplayTerminalRuntimeMock = mock( - (runtime: TerminalRuntime) => !runtime.hasBufferedContent, -); mock.module( "./terminal-runtime", @@ -85,10 +69,7 @@ mock.module( createRuntime: createRuntimeMock, detachFromContainer: detachFromContainerMock, disposeRuntime: disposeRuntimeMock, - focusRuntime: focusRuntimeMock, - shouldReplayTerminalRuntime: shouldReplayTerminalRuntimeMock, updateRuntimeAppearance: updateRuntimeAppearanceMock, - writeRuntimeOutput: writeRuntimeOutputMock, }), ); @@ -102,14 +83,10 @@ const appearance: TerminalAppearance = { }; function createFakeTerminal(): FakeTerminal { - let writeCallback: (() => void) | undefined; return { cols: 120, rows: 32, - write: mock((_data: string | Uint8Array, callback?: () => void) => { - writeCallback = callback; - }), - emitWriteComplete: () => writeCallback?.(), + write: mock((_data: string | Uint8Array) => {}), getSelection: mock(() => ""), clear: mock(() => {}), scrollToBottom: mock(() => {}), @@ -145,9 +122,6 @@ function createFakeRuntime(terminalId: string): FakeRuntime { lastRows: 32, _disposeAddons: null, _disposeImagePasteFallback: null, - _outputQueue: [], - _outputEnqueued: false, - hasBufferedContent: false, }; } @@ -165,14 +139,12 @@ function createCanvas(options: { if (options.isTerminalWebglCanvas) { attributes.set("data-terminal-webgl-canvas", "true"); } - const canvas = { + return { getAttribute: mock((name: string) => attributes.get(name) ?? null), getContext: mock((_contextId: string) => options.context ?? null), } as unknown as HTMLCanvasElement & { getContext: ReturnType; }; - - return canvas; } function createWebglContext(extension: unknown): WebGL2RenderingContext { @@ -195,9 +167,6 @@ beforeEach(() => { detachFromContainerMock, disposeRuntimeMock, updateRuntimeAppearanceMock, - focusRuntimeMock, - shouldReplayTerminalRuntimeMock, - writeRuntimeOutputMock, ]) { fn.mockClear(); } @@ -246,34 +215,6 @@ describe("terminalRuntimeRegistry", () => { ]); }); - it("reuses a parked runtime when a background terminal opens in a new pane", () => { - terminalRuntimeRegistry.mount( - "terminal-1", - createContainer(), - appearance, - "old-pane", - ); - terminalRuntimeRegistry.detach("terminal-1", "old-pane"); - - terminalRuntimeRegistry.mount( - "terminal-1", - createContainer(), - appearance, - "new-pane", - ); - - expect(createRuntimeMock).toHaveBeenCalledTimes(1); - expect(attachToContainerMock).toHaveBeenCalledTimes(2); - expect(terminalRuntimeRegistry.getStressDebugInfo("terminal-1")).toEqual([ - expect.objectContaining({ - terminalId: "terminal-1", - instanceId: "new-pane", - hasRuntime: true, - isAttached: true, - }), - ]); - }); - it("releasing one terminal instance leaves sibling instances alive", () => { terminalRuntimeRegistry.mount( "terminal-1", @@ -412,8 +353,7 @@ describe("terminalRuntimeRegistry", () => { ); expect(accepted).toBe(true); - expect(writeRuntimeOutputMock).toHaveBeenCalledWith( - createdRuntimes[0], + expect(createdRuntimes[0]?.terminal.write).toHaveBeenCalledWith( "large output", ); }); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts index 2d8a1cd7546..c85847d2d77 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -11,11 +11,8 @@ import { createRuntime, detachFromContainer, disposeRuntime, - focusRuntime, - shouldReplayTerminalRuntime, type TerminalRuntime, updateRuntimeAppearance, - writeRuntimeOutput, } from "./terminal-runtime"; import { isTerminalWebglCanvas } from "./terminal-webgl-canvas-registry"; import { @@ -126,9 +123,6 @@ class TerminalRuntimeRegistryImpl { let entry = this.entries.get(key); if (entry) return entry; - entry = this.takeParkedEntry(terminalId, instanceId); - if (entry) return entry; - entry = { terminalId, instanceId, @@ -148,35 +142,6 @@ class TerminalRuntimeRegistryImpl { return entry; } - private takeParkedEntry( - terminalId: string, - instanceId: string, - ): RegistryEntry | undefined { - for (const entry of this.getEntries(terminalId)) { - if (entry.instanceId === instanceId) continue; - if (!entry.runtime || entry.runtime.container) continue; - return this.rekeyEntry(entry, instanceId); - } - return undefined; - } - - private rekeyEntry( - entry: RegistryEntry, - nextInstanceId: string, - ): RegistryEntry { - const prevKey = this.getEntryKey(entry.terminalId, entry.instanceId); - const nextKey = this.getEntryKey(entry.terminalId, nextInstanceId); - this.entries.delete(prevKey); - entry.instanceId = nextInstanceId; - this.entries.set(nextKey, entry); - - const keys = this.entryKeysByTerminalId.get(entry.terminalId) ?? new Set(); - keys.delete(prevKey); - keys.add(nextKey); - this.entryKeysByTerminalId.set(entry.terminalId, keys); - return entry; - } - private getEntry( terminalId: string, instanceId?: string, @@ -292,18 +257,7 @@ class TerminalRuntimeRegistryImpl { connect(terminalId: string, wsUrl: string, instanceId = terminalId) { const entry = this.getEntry(terminalId, instanceId); if (!entry?.runtime) return; - const { runtime } = entry; - connect( - entry.transport, - runtime.terminal, - wsUrl, - (data) => { - writeRuntimeOutput(runtime, data); - }, - { - replay: shouldReplayTerminalRuntime(runtime), - }, - ); + connect(entry.transport, entry.runtime.terminal, wsUrl); } /** @@ -323,18 +277,7 @@ class TerminalRuntimeRegistryImpl { if (!entry?.runtime) return; if (entry.transport.connectionState === "disconnected") return; if (entry.transport.currentUrl === wsUrl) return; - const { runtime } = entry; - connect( - entry.transport, - runtime.terminal, - wsUrl, - (data) => { - writeRuntimeOutput(runtime, data); - }, - { - replay: shouldReplayTerminalRuntime(runtime), - }, - ); + connect(entry.transport, entry.runtime.terminal, wsUrl); } /** @@ -366,13 +309,6 @@ class TerminalRuntimeRegistryImpl { detachFromContainer(entry.runtime); } - focus(terminalId: string, instanceId = terminalId) { - const entry = this.getEntry(terminalId, instanceId); - if (!entry?.runtime) return; - - focusRuntime(entry.runtime); - } - updateAppearance( terminalId: string, appearance: TerminalAppearance, @@ -462,32 +398,16 @@ class TerminalRuntimeRegistryImpl { terminalId: string, data: string, instanceId?: string, - timeoutMs = 5000, ): Promise { const entry = this.getEntry(terminalId, instanceId); if (!entry?.runtime) return Promise.resolve(false); - const { runtime } = entry; - - return new Promise((resolve) => { - let settled = false; - let timeoutId: ReturnType | null = null; - const settle = (value: boolean) => { - if (settled) return; - settled = true; - if (timeoutId !== null) { - clearTimeout(timeoutId); - } - resolve(value); - }; - timeoutId = setTimeout(() => settle(false), timeoutMs); - try { - writeRuntimeOutput(runtime, data); - queueMicrotask(() => settle(true)); - } catch { - settle(false); - } - }); + try { + entry.runtime.terminal.write(data); + return Promise.resolve(true); + } catch { + return Promise.resolve(false); + } } forceWebglContextLossForStress( diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.test.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.test.ts deleted file mode 100644 index b69dffd66d3..00000000000 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { describe, expect, mock, test } from "bun:test"; -import type { TerminalRuntime } from "./terminal-runtime"; -import { detachFromContainer, writeRuntimeOutput } from "./terminal-runtime"; - -type MutableGlobal = typeof globalThis & { - document?: Document; - requestAnimationFrame?: typeof requestAnimationFrame; -}; - -function createFakeRuntime( - container: HTMLDivElement | null, -): TerminalRuntime & { - terminal: TerminalRuntime["terminal"] & { - write: ReturnType; - }; -} { - return { - terminalId: "terminal-1", - terminal: { - write: mock((_data: string | Uint8Array, callback?: () => void) => { - callback?.(); - }), - } as unknown as TerminalRuntime["terminal"] & { - write: ReturnType; - }, - fitAddon: {} as TerminalRuntime["fitAddon"], - serializeAddon: { - serialize: mock(() => ""), - } as unknown as TerminalRuntime["serializeAddon"], - searchAddon: null, - progressAddon: null, - wrapper: {} as HTMLDivElement, - container, - resizeObserver: null, - _disposeResizeObserver: null, - lastCols: 120, - lastRows: 32, - _disposeAddons: null, - _disposeImagePasteFallback: null, - _outputQueue: [], - _outputEnqueued: false, - hasBufferedContent: false, - }; -} - -async function drainQueuedOutput(runtime: TerminalRuntime) { - for ( - let attempt = 0; - runtime._outputQueue.length > 0 && attempt < 100; - attempt++ - ) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } - expect(runtime._outputQueue).toHaveLength(0); -} - -async function withOutputFlushTimers(callback: () => Promise | T) { - const mutableGlobal = globalThis as MutableGlobal; - const hadRequestAnimationFrame = "requestAnimationFrame" in mutableGlobal; - const previousRequestAnimationFrame = mutableGlobal.requestAnimationFrame; - - Object.defineProperty(mutableGlobal, "requestAnimationFrame", { - configurable: true, - writable: true, - value: undefined, - }); - - try { - return await callback(); - } finally { - if (hadRequestAnimationFrame) { - Object.defineProperty(mutableGlobal, "requestAnimationFrame", { - configurable: true, - writable: true, - value: previousRequestAnimationFrame, - }); - } else { - Reflect.deleteProperty(mutableGlobal, "requestAnimationFrame"); - } - } -} - -async function withFakeDocument(callback: () => Promise | T) { - const mutableGlobal = globalThis as MutableGlobal; - const hadDocument = "document" in mutableGlobal; - const previousDocument = mutableGlobal.document; - const fakeElement = { - id: "", - style: {}, - appendChild: mock(() => null), - setAttribute: mock(() => {}), - }; - const fakeDocument = { - body: { - appendChild: mock(() => null), - }, - createElement: mock(() => fakeElement), - getElementById: mock(() => null), - }; - - Object.defineProperty(mutableGlobal, "document", { - configurable: true, - writable: true, - value: fakeDocument, - }); - - try { - return await callback(); - } finally { - if (hadDocument) { - Object.defineProperty(mutableGlobal, "document", { - configurable: true, - writable: true, - value: previousDocument, - }); - } else { - Reflect.deleteProperty(mutableGlobal, "document"); - } - } -} - -describe("terminal runtime output", () => { - test("writes small parked output immediately when there is no backlog", () => { - const runtime = createFakeRuntime(null); - const output = "tail"; - - writeRuntimeOutput(runtime, output); - - expect(runtime.terminal.write).toHaveBeenCalledTimes(1); - expect(runtime.terminal.write.mock.calls[0]?.[0]).toBe(output); - expect(runtime._outputQueue).toHaveLength(0); - expect(runtime.hasBufferedContent).toBe(true); - }); - - test("queues large parked output instead of blocking the renderer", async () => { - await withOutputFlushTimers(async () => { - const runtime = createFakeRuntime(null); - const output = new Uint8Array(128 * 1024); - - writeRuntimeOutput(runtime, output); - - expect(runtime.terminal.write).not.toHaveBeenCalled(); - expect(runtime._outputQueue.length).toBeGreaterThan(1); - expect(runtime.hasBufferedContent).toBe(true); - - await drainQueuedOutput(runtime); - }); - }); - - test("does not synchronously drain backlog when parking a runtime", async () => { - await withOutputFlushTimers(async () => { - await withFakeDocument(async () => { - const runtime = createFakeRuntime({} as HTMLDivElement); - - writeRuntimeOutput(runtime, "x".repeat(10_000)); - const queuedBeforeDetach = runtime._outputQueue.length; - expect(queuedBeforeDetach).toBeGreaterThan(0); - - detachFromContainer(runtime); - - expect(runtime.container).toBeNull(); - expect(runtime.terminal.write).not.toHaveBeenCalled(); - expect(runtime._outputQueue).toHaveLength(queuedBeforeDetach); - - await drainQueuedOutput(runtime); - }); - }); - }); -}); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts index 36bb0d4de78..fd85b46761d 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -9,7 +9,6 @@ import { loadAddons } from "./terminal-addons"; import { installImagePasteFallback } from "./terminal-image-paste-fallback"; import { installTerminalKeyEventHandler } from "./terminal-key-event-handler"; import { getTerminalParkingContainer } from "./terminal-parking"; -import { markTerminalSessionReplayBlocked } from "./terminal-session-replay"; const SERIALIZE_SCROLLBACK = 1000; const STORAGE_KEY_PREFIX = "terminal-buffer:"; @@ -17,21 +16,6 @@ const DIMS_KEY_PREFIX = "terminal-dims:"; const DEFAULT_COLS = 120; const DEFAULT_ROWS = 32; const RESIZE_DEBOUNCE_MS = 75; -const OUTPUT_CHUNK_BYTES = 4096; -const BACKGROUND_OUTPUT_WRITES_PER_FRAME = 2; - -type TerminalOutputData = string | Uint8Array; - -interface TerminalOutputQueueItem { - data: TerminalOutputData; - callback?: () => void; -} - -const runtimesWithQueuedOutput = new Set(); -let outputFlushRafId: number | null = null; -let outputFlushTimeoutId: ReturnType | null = null; -let pendingFocusRuntime: TerminalRuntime | null = null; -let focusRafId: number | null = null; export interface TerminalRuntime { terminalId: string; @@ -48,116 +32,6 @@ export interface TerminalRuntime { lastRows: number; _disposeAddons: (() => void) | null; _disposeImagePasteFallback: (() => void) | null; - _outputQueue: TerminalOutputQueueItem[]; - _outputEnqueued: boolean; - hasBufferedContent: boolean; -} - -function getOutputByteLength(data: TerminalOutputData): number { - if (typeof data === "string") return data.length; - return data.byteLength; -} - -function splitStringAtOutputBoundary(value: string, start: number): number { - const end = Math.min(value.length, start + OUTPUT_CHUNK_BYTES); - if (end >= value.length) return value.length; - const code = value.charCodeAt(end - 1); - return code >= 0xd800 && code <= 0xdbff ? end - 1 : end; -} - -function splitOutputData( - data: TerminalOutputData, - callback?: () => void, -): TerminalOutputQueueItem[] { - const byteLength = getOutputByteLength(data); - if (byteLength <= OUTPUT_CHUNK_BYTES) { - return [{ data, callback }]; - } - - const items: TerminalOutputQueueItem[] = []; - if (typeof data === "string") { - for (let start = 0; start < data.length; ) { - const end = splitStringAtOutputBoundary(data, start); - const chunk = data.slice(start, end); - items.push({ data: chunk }); - start = end; - } - } else { - for (let start = 0; start < data.byteLength; start += OUTPUT_CHUNK_BYTES) { - const chunk = data.slice(start, start + OUTPUT_CHUNK_BYTES); - items.push({ data: chunk }); - } - } - - const lastItem = items.at(-1); - if (lastItem) lastItem.callback = callback; - return items; -} - -function scheduleQueuedOutputFlush() { - if (outputFlushRafId !== null || outputFlushTimeoutId !== null) return; - if (typeof requestAnimationFrame !== "function") { - outputFlushTimeoutId = setTimeout(flushQueuedOutput, 0); - return; - } - outputFlushRafId = requestAnimationFrame(flushQueuedOutput); -} - -function flushQueuedOutput() { - outputFlushRafId = null; - if (outputFlushTimeoutId !== null) { - clearTimeout(outputFlushTimeoutId); - outputFlushTimeoutId = null; - } - - let processed = 0; - for (const runtime of Array.from(runtimesWithQueuedOutput)) { - runtimesWithQueuedOutput.delete(runtime); - const item = runtime._outputQueue.shift(); - if (!item) { - runtime._outputEnqueued = false; - continue; - } - - processed += 1; - runtime.terminal.write(item.data, item.callback); - - if (runtime._outputQueue.length > 0) { - runtimesWithQueuedOutput.add(runtime); - } else { - runtime._outputEnqueued = false; - } - - if (processed >= BACKGROUND_OUTPUT_WRITES_PER_FRAME) break; - } - - if (runtimesWithQueuedOutput.size > 0) { - scheduleQueuedOutputFlush(); - } -} - -function scheduleRuntimeQueuedOutput(runtime: TerminalRuntime) { - if (runtime._outputQueue.length === 0 || runtime._outputEnqueued) return; - runtime._outputEnqueued = true; - runtimesWithQueuedOutput.add(runtime); - scheduleQueuedOutputFlush(); -} - -function enqueueRuntimeOutput( - runtime: TerminalRuntime, - item: TerminalOutputQueueItem, -) { - runtime._outputQueue.push(item); - scheduleRuntimeQueuedOutput(runtime); -} - -function clearQueuedRuntimeOutput(runtime: TerminalRuntime) { - runtimesWithQueuedOutput.delete(runtime); - runtime._outputEnqueued = false; - const queue = runtime._outputQueue.splice(0); - for (const item of queue) { - item.callback?.(); - } } function createTerminal( @@ -198,15 +72,11 @@ function persistBuffer(terminalId: string, serializeAddon: SerializeAddon) { } catch {} } -function restoreBuffer(terminalId: string, terminal: XTerm): boolean { +function restoreBuffer(terminalId: string, terminal: XTerm) { try { const data = localStorage.getItem(`${STORAGE_KEY_PREFIX}${terminalId}`); - if (data) { - terminal.write(data); - return true; - } + if (data) terminal.write(data); } catch {} - return false; } function clearPersistedBuffer(terminalId: string) { @@ -246,23 +116,6 @@ function clearPersistedDimensions(terminalId: string) { } catch {} } -function disposeTerminalAfterPendingRefresh(terminal: XTerm) { - const disposeTerminal = () => { - try { - terminal.dispose(); - } catch {} - }; - - if (typeof requestAnimationFrame !== "function") { - setTimeout(disposeTerminal, 0); - return; - } - - requestAnimationFrame(() => { - requestAnimationFrame(disposeTerminal); - }); -} - function hostIsVisible(container: HTMLDivElement | null): boolean { if (!container) return false; return container.clientWidth > 0 && container.clientHeight > 0; @@ -352,7 +205,6 @@ export function createRuntime( const wrapper = document.createElement("div"); wrapper.style.width = "100%"; wrapper.style.height = "100%"; - markTerminalSessionReplayBlocked(wrapper); terminal.open(wrapper); installTerminalKeyEventHandler(terminal); @@ -360,14 +212,10 @@ export function createRuntime( // Activate Unicode 11 widths (inside loadAddons) before restoring the buffer, // else CJK/emoji/ZWJ widths get baked wrong into the replay. (#3572) const addonsResult = loadAddons(terminal); - let hasBufferedContent = false; if (options.initialBuffer !== undefined) { - if (options.initialBuffer.length > 0) { - terminal.write(options.initialBuffer); - hasBufferedContent = true; - } + terminal.write(options.initialBuffer); } else { - hasBufferedContent = restoreBuffer(terminalId, terminal); + restoreBuffer(terminalId, terminal); } const disposeImagePasteFallback = installImagePasteFallback( @@ -390,35 +238,9 @@ export function createRuntime( lastRows: rows, _disposeAddons: addonsResult.dispose, _disposeImagePasteFallback: disposeImagePasteFallback, - _outputQueue: [], - _outputEnqueued: false, - hasBufferedContent, }; } -export function shouldReplayTerminalRuntime(runtime: TerminalRuntime): boolean { - return !runtime.hasBufferedContent; -} - -export function writeRuntimeOutput( - runtime: TerminalRuntime, - data: TerminalOutputData, - callback?: () => void, -) { - const byteLength = getOutputByteLength(data); - if (byteLength > 0) { - runtime.hasBufferedContent = true; - } - const items = splitOutputData(data, callback); - if (runtime._outputQueue.length === 0 && items.length === 1) { - runtime.terminal.write(data, callback); - return; - } - for (const item of items) { - enqueueRuntimeOutput(runtime, item); - } -} - export function attachToContainer( runtime: TerminalRuntime, container: HTMLDivElement, @@ -436,7 +258,6 @@ export function attachToContainer( runtime.container = container; container.appendChild(runtime.wrapper); - if (measureAndResize(runtime)) onResize?.(); runtime._disposeResizeObserver?.(); @@ -448,32 +269,9 @@ export function attachToContainer( runtime.resizeObserver = observer; runtime._disposeResizeObserver = scheduler.dispose; - scheduleRuntimeQueuedOutput(runtime); -} - -function focusRuntimeNow(runtime: TerminalRuntime) { - if (!runtime.container) return; - const element = runtime.terminal.element; - if (element?.contains(document.activeElement)) return; - const textarea = runtime.terminal.textarea; - if (textarea) { - textarea.focus({ preventScroll: true }); - return; - } runtime.terminal.focus(); } -export function focusRuntime(runtime: TerminalRuntime) { - pendingFocusRuntime = runtime; - if (focusRafId !== null) return; - focusRafId = requestAnimationFrame(() => { - focusRafId = null; - const nextRuntime = pendingFocusRuntime; - pendingFocusRuntime = null; - if (nextRuntime) focusRuntimeNow(nextRuntime); - }); -} - export function detachFromContainer(runtime: TerminalRuntime) { persistBuffer(runtime.terminalId, runtime.serializeAddon); persistDimensions(runtime.terminalId, runtime.lastCols, runtime.lastRows); @@ -485,7 +283,6 @@ export function detachFromContainer(runtime: TerminalRuntime) { // see getTerminalParkingContainer. getTerminalParkingContainer().appendChild(runtime.wrapper); runtime.container = null; - scheduleRuntimeQueuedOutput(runtime); } export function updateRuntimeAppearance( @@ -521,15 +318,14 @@ export function disposeRuntime( runtime._disposeImagePasteFallback = null; runtime._disposeAddons?.(); runtime._disposeAddons = null; - clearQueuedRuntimeOutput(runtime); runtime._disposeResizeObserver?.(); runtime._disposeResizeObserver = null; runtime.resizeObserver?.disconnect(); runtime.resizeObserver = null; runtime.wrapper.remove(); + runtime.terminal.dispose(); if (clearPersistedState) { clearPersistedBuffer(runtime.terminalId); clearPersistedDimensions(runtime.terminalId); } - disposeTerminalAfterPendingRefresh(runtime.terminal); } diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts index 2d0efe9634d..a133122d71b 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts @@ -1,10 +1,6 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import type { Terminal as XTerm } from "@xterm/xterm"; -import { - connect, - createTransport, - disposeTransport, -} from "./terminal-ws-transport"; +import { connect, createTransport } from "./terminal-ws-transport"; type Listener = (event: { data?: unknown; @@ -70,40 +66,21 @@ const originalWebSocket = globalThis.WebSocket; function createMockTerminal( cols = 101, rows = 27, -): XTerm & { - disposedInputListenerCount(): number; - emitData(data: string): void; -} { - const dataListeners: Array<{ - disposed: boolean; - listener: (data: string) => void; - }> = []; +): XTerm & { emitData(data: string): void } { + let onDataListener: ((data: string) => void) | null = null; return { cols, rows, onData: (listener: (data: string) => void) => { - const record = { disposed: false, listener }; - dataListeners.push(record); - return { - dispose() { - record.disposed = true; - }, - }; - }, - disposedInputListenerCount() { - return dataListeners.filter((record) => record.disposed).length; + onDataListener = listener; + return { dispose() {} }; }, emitData(data: string) { - for (const record of dataListeners) { - if (!record.disposed) record.listener(data); - } + onDataListener?.(data); }, write() {}, writeln() {}, - } as unknown as XTerm & { - disposedInputListenerCount(): number; - emitData(data: string): void; - }; + } as unknown as XTerm & { emitData(data: string): void }; } beforeEach(() => { @@ -116,33 +93,6 @@ afterEach(() => { }); describe("terminal-ws-transport", () => { - test("can suppress replay on the initial connect when xterm already has content", () => { - const transport = createTransport(); - const terminal = createMockTerminal(); - - connect(transport, terminal, "ws://host/terminal/t1", undefined, { - replay: false, - }); - - const socket = MockWebSocket.instances[0]; - expect(socket?.url).toBe("ws://host/terminal/t1?replay=0"); - }); - - test("skips replay after PTY bytes have already landed", () => { - const transport = createTransport(); - const terminal = createMockTerminal(); - - connect(transport, terminal, "ws://host/terminal/t1"); - const firstSocket = MockWebSocket.instances[0]; - if (!firstSocket) throw new Error("expected first websocket instance"); - firstSocket.message(new Uint8Array([1, 2, 3]).buffer); - - connect(transport, terminal, "ws://host/terminal/t2"); - - const secondSocket = MockWebSocket.instances[1]; - expect(secondSocket?.url).toBe("ws://host/terminal/t2?replay=0"); - }); - test("server-sent error routes to logs, not xterm, and stops reconnect", () => { const transport = createTransport(); const writelnCalls: string[] = []; @@ -206,67 +156,4 @@ describe("terminal-ws-transport", () => { { type: "input", data: "b" }, ]); }); - - test("reconnecting replaces the previous xterm input subscription", () => { - const transport = createTransport(); - const terminal = createMockTerminal(); - - connect(transport, terminal, "ws://host/terminal/t1"); - const firstSocket = MockWebSocket.instances[0]; - if (!firstSocket) throw new Error("expected first websocket instance"); - firstSocket.open(); - firstSocket.message(JSON.stringify({ type: "attached", terminalId: "t1" })); - - connect(transport, terminal, "ws://host/terminal/t2"); - const secondSocket = MockWebSocket.instances[1]; - if (!secondSocket) throw new Error("expected second websocket instance"); - secondSocket.open(); - secondSocket.message( - JSON.stringify({ type: "attached", terminalId: "t2" }), - ); - terminal.emitData("x"); - - expect(firstSocket.readyState).toBe(MockWebSocket.CLOSED); - expect(terminal.disposedInputListenerCount()).toBe(1); - expect(firstSocket.sent.map((payload) => JSON.parse(payload))).toEqual([ - { type: "resize", cols: 101, rows: 27 }, - ]); - expect(secondSocket.sent.map((payload) => JSON.parse(payload))).toEqual([ - { type: "resize", cols: 101, rows: 27 }, - { type: "input", data: "x" }, - ]); - }); - - test("dispose clears pending reconnect and title timers", async () => { - const transport = createTransport(); - const terminal = createMockTerminal(); - const titleListener = mock(() => {}); - const logListener = mock(() => {}); - transport.titleListeners.add(titleListener); - transport.logListeners.add(logListener); - - connect(transport, terminal, "ws://host/terminal/t1"); - const socket = MockWebSocket.instances[0]; - if (!socket) throw new Error("expected websocket instance"); - socket.open(); - socket.message(JSON.stringify({ type: "title", title: "busy" })); - socket.message(JSON.stringify({ type: "attached", terminalId: "t1" })); - socket.close(1006, "lost"); - - expect(transport._titleNotifyTimer).not.toBeNull(); - expect(transport._reconnectTimer).not.toBeNull(); - - disposeTransport(transport); - - expect(transport._titleNotifyTimer).toBeNull(); - expect(transport._reconnectTimer).toBeNull(); - expect(transport._writeOutput).toBeNull(); - expect(transport.titleListeners.size).toBe(0); - expect(transport.logListeners.size).toBe(0); - - await new Promise((resolve) => setTimeout(resolve, 100)); - - expect(titleListener).not.toHaveBeenCalled(); - expect(logListener).toHaveBeenCalledTimes(1); - }); }); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts index aad7a4995a8..3daa24211ee 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts @@ -4,7 +4,6 @@ import type { Terminal as XTerm } from "@xterm/xterm"; export type ConnectionState = "disconnected" | "connecting" | "open" | "closed"; export type TerminalLogLevel = "info" | "warn" | "error"; -export type TerminalOutputWriter = (data: Uint8Array) => void; export interface TerminalLogEntry { id: number; @@ -13,14 +12,6 @@ export interface TerminalLogEntry { message: string; } -export interface TerminalConnectOptions { - /** - * False suppresses server replay for this attach. Replay is otherwise - * allowed only until this transport has received PTY bytes. - */ - replay?: boolean; -} - // PTY output bytes arrive as binary WebSocket frames and are fed straight // into xterm.write(Uint8Array) — no UTF-8 decoding hop, so multi-byte // codepoints that straddle a frame boundary stay intact (xterm.js buffers @@ -56,8 +47,6 @@ export interface TerminalTransport { _titleNotifyTimer: ReturnType | null; /** The xterm instance used for reconnection. */ _terminal: XTerm | null; - /** Writes PTY output into xterm, optionally with renderer-side backpressure. */ - _writeOutput: TerminalOutputWriter | null; /** Set when the server signals the session is done (PTY exit or fatal * attach error). Suppresses the auto-reconnect loop. */ _terminated: boolean; @@ -68,12 +57,6 @@ export interface TerminalTransport { * with no output still gets replay on the next connect. */ _hasReceivedBytes: boolean; - /** - * Sticky suppression for runtimes restored from renderer-side scrollback. - * Auto-reconnects must keep requesting `replay=0` even if the socket drops - * before any new bytes arrive on this specific transport. - */ - _suppressReplay: boolean; } const MAX_LOG_ENTRIES = 200; @@ -168,9 +151,7 @@ export function createTransport(): TerminalTransport { _titleNotifyTimer: null, _reconnectAttempt: 0, _terminal: null, - _writeOutput: null, _hasReceivedBytes: false, - _suppressReplay: false, _terminated: false, }; } @@ -194,12 +175,7 @@ function scheduleReconnect(transport: TerminalTransport) { transport.currentUrl && transport._terminal ) { - connect( - transport, - transport._terminal, - transport.currentUrl, - transport._writeOutput ?? undefined, - ); + connect(transport, transport._terminal, transport.currentUrl); } }, delay); } @@ -243,8 +219,6 @@ export function connect( transport: TerminalTransport, terminal: XTerm, wsUrl: string, - writeOutput?: TerminalOutputWriter, - options: TerminalConnectOptions = {}, ) { // Idempotent: skip if already connected/connecting to the same endpoint. const isActive = @@ -260,21 +234,11 @@ export function connect( cancelReconnect(transport); transport.currentUrl = wsUrl; transport._terminal = terminal; - transport._writeOutput = writeOutput ?? ((data) => terminal.write(data)); transport._terminated = false; - if (options.replay === false) { - transport._suppressReplay = true; - } else if (options.replay === true) { - transport._suppressReplay = false; - } setConnectionState(transport, "connecting"); - const shouldReplay = - options.replay !== false && - !transport._suppressReplay && - !transport._hasReceivedBytes; - const actualUrl = shouldReplay - ? wsUrl - : appendQueryParam(wsUrl, "replay", "0"); + const actualUrl = transport._hasReceivedBytes + ? appendQueryParam(wsUrl, "replay", "0") + : wsUrl; const openSocket = () => { // Bail if the transport raced into a different URL or was disconnected @@ -343,11 +307,8 @@ function attachSocketListeners( // channel; renderer treats them identically). Pipe straight into // xterm without any decoding step. if (event.data instanceof ArrayBuffer) { - const data = new Uint8Array(event.data); - transport._writeOutput?.(data); - if (data.byteLength > 0) { - transport._hasReceivedBytes = true; - } + terminal.write(new Uint8Array(event.data)); + transport._hasReceivedBytes = true; return; } @@ -474,7 +435,6 @@ export function disposeTransport(transport: TerminalTransport) { setTerminalTitle(transport, undefined); transport.onDataDisposable?.dispose(); transport.onDataDisposable = null; - transport._writeOutput = null; transport.stateListeners.clear(); if (transport._titleNotifyTimer !== null) { clearTimeout(transport._titleNotifyTimer); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.tsx index 2819991208e..c17c0aa6cf7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/BackgroundTerminalsButton/BackgroundTerminalsButton.tsx @@ -12,7 +12,6 @@ import { toast } from "@superset/ui/sonner"; import { workspaceTrpc } from "@superset/workspace-client"; import { Archive, ChevronDown, Trash2 } from "lucide-react"; import { useMemo, useState } from "react"; -import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; import { getRelativeTime } from "renderer/screens/main/components/WorkspacesListView/utils"; import { useStore } from "zustand"; import type { StoreApi } from "zustand/vanilla"; @@ -83,7 +82,6 @@ export function BackgroundTerminalsButton({ const handleKill = async (terminalId: string) => { try { await killSession.mutateAsync({ terminalId, workspaceId }); - terminalRuntimeRegistry.dispose(terminalId); } catch (error) { console.error( "[BackgroundTerminalsButton] Failed to kill session:", diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index d5ce97474a4..7ccc6122c1d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -3,7 +3,6 @@ import { cn } from "@superset/ui/utils"; import { workspaceTrpc } from "@superset/workspace-client"; import "@xterm/xterm/css/xterm.css"; import { - memo, useCallback, useEffect, useMemo, @@ -49,7 +48,7 @@ interface TerminalPaneProps { onRevealPath: (path: string, options?: { isDirectory?: boolean }) => void; } -function TerminalPaneComponent({ +export function TerminalPane({ ctx, workspaceId, onOpenFile, @@ -154,11 +153,6 @@ function TerminalPaneComponent({ }; }, [terminalId, terminalInstanceId]); - useEffect(() => { - if (!ctx.isActive) return; - terminalRuntimeRegistry.focus(terminalId, terminalInstanceId); - }, [ctx.isActive, terminalId, terminalInstanceId]); - const lastInvalidatedOpenSessionRef = useRef(null); useEffect(() => { const invalidateSessionsAfterSocketOpen = () => { @@ -444,18 +438,6 @@ function TerminalPaneComponent({ ); } -export const TerminalPane = memo( - TerminalPaneComponent, - (prev, next) => - prev.workspaceId === next.workspaceId && - prev.onOpenFile === next.onOpenFile && - prev.onRevealPath === next.onRevealPath && - prev.ctx.store === next.ctx.store && - prev.ctx.isActive === next.ctx.isActive && - prev.ctx.pane.id === next.ctx.pane.id && - prev.ctx.pane.data === next.ctx.pane.data, -); - // Compute "what would clicking right now do?" for the live link tooltip. // Folders use the hardcoded folderIntent rule; files/urls go through the // settings-driven policies. Returns null when no modifier is held or the diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx index 7c289ebcade..222983bcc3a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx @@ -13,6 +13,7 @@ import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { Check, ChevronDown, LoaderCircle, Plus, Trash2 } from "lucide-react"; import { useCallback, useMemo, useState, useSyncExternalStore } from "react"; +import { markTerminalForBackground } from "renderer/lib/terminal/terminal-background-intents"; import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; import type { PaneViewerData, @@ -166,6 +167,10 @@ export function TerminalSessionDropdown({ return; } + if ((terminalPaneLocations.get(terminalId)?.length ?? 0) === 0) { + markTerminalForBackground(terminalId); + } + state.setPaneData({ paneId: context.pane.id, data: { @@ -216,6 +221,10 @@ export function TerminalSessionDropdown({ const handleNewTerminal = () => { const state = context.store.getState(); + const terminalPaneLocations = getTerminalPaneLocations(context); + if ((terminalPaneLocations.get(terminalId)?.length ?? 0) === 0) { + markTerminalForBackground(terminalId); + } state.setPaneData({ paneId: context.pane.id, data: { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index db3150b4ee0..5955300a5ae 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -276,6 +276,7 @@ export function usePaneRegistry({ onAfterClose: (pane) => { const { terminalId } = pane.data as TerminalPaneData; if (consumeTerminalBackgroundIntent(terminalId)) { + terminalRuntimeRegistry.release(terminalId); return; } clearV2TerminalRunStatus(terminalId, workspaceId); From 911674070569d6511bcc808102db7b7d27c89ebf Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 14 May 2026 10:43:59 -0700 Subject: [PATCH 04/16] Fix electron-log mock shape in desktop tests --- apps/desktop/src/main/lib/host-service-coordinator.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/desktop/src/main/lib/host-service-coordinator.test.ts b/apps/desktop/src/main/lib/host-service-coordinator.test.ts index d3feabd0ee4..cbe59424b37 100644 --- a/apps/desktop/src/main/lib/host-service-coordinator.test.ts +++ b/apps/desktop/src/main/lib/host-service-coordinator.test.ts @@ -70,6 +70,11 @@ mock.module("electron-log/main", () => ({ info: () => {}, warn: () => {}, error: () => {}, + transports: { + file: { + level: "info", + }, + }, }, })); From 84c8bcbaf13b50ccd466faa7a360c16857c08c36 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 14 May 2026 10:54:33 -0700 Subject: [PATCH 05/16] Limit sidebar diff stat queries to active workspace --- .../DashboardSidebarWorkspaceItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx index 8eb0b6c3c8b..c5a461f9b09 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx @@ -45,7 +45,7 @@ function DashboardSidebarWorkspaceItemComponent({ const isPending = !!creationStatus; const isFailedInFlight = creationStatus === "failed"; const diffStats = useDiffStats(id, { - enabled: !isCollapsed && !isPending, + enabled: !isCollapsed && !isPending && isActive, }); const workspaceStatus = useV2WorkspaceNotificationStatus(id); const { From 1b8d97a947f1cd3870a8287922e2f7a66c44ff83 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 14 May 2026 11:06:54 -0700 Subject: [PATCH 06/16] Restore immediate workspace navigation --- .../DashboardSidebar/DashboardSidebar.tsx | 12 +- .../DashboardSidebarProjectSection.tsx | 4 - ...ashboardSidebarCollapsedProjectContent.tsx | 5 +- ...DashboardSidebarExpandedProjectContent.tsx | 7 +- .../DashboardSidebarWorkspaceItem.tsx | 26 +-- ...useDashboardSidebarWorkspaceItemActions.ts | 19 +- .../SortableWorkspaceItem.tsx | 14 +- .../useDashboardSidebarShortcuts.ts | 13 +- .../utils/workspace-navigation.test.ts | 141 --------------- .../_dashboard/utils/workspace-navigation.ts | 171 ++---------------- .../V2WorkspacesList/V2WorkspacesList.tsx | 7 +- .../stores/v2-workspace-navigation.ts | 29 --- 12 files changed, 50 insertions(+), 398 deletions(-) delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.test.ts delete mode 100644 apps/desktop/src/renderer/stores/v2-workspace-navigation.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx index c68897d1a3f..ecbd1a2d622 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx @@ -28,7 +28,6 @@ import { V2AvailableBanner } from "renderer/components/V2AvailableBanner"; import { useHotkeyDisplay } from "renderer/hotkeys"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; -import { useV2WorkspaceNavigationStore } from "renderer/stores/v2-workspace-navigation"; import { DashboardSidebarHeader } from "./components/DashboardSidebarHeader"; import { DashboardSidebarHelpMenu } from "./components/DashboardSidebarHelpMenu"; import { DashboardSidebarHoverCardOverlay } from "./components/DashboardSidebarHoverCardOverlay"; @@ -49,7 +48,6 @@ interface SortableProjectWrapperProps { project: DashboardSidebarProject; isCollapsed: boolean; isDraggingProject: boolean; - activeWorkspaceId: string | null; workspaceShortcutLabels: Map; onWorkspaceHover: (workspaceId: string) => void | Promise; onToggleCollapse: (projectId: string) => void; @@ -59,7 +57,6 @@ const SortableProjectWrapper = memo(function SortableProjectWrapper({ project, isCollapsed, isDraggingProject, - activeWorkspaceId, workspaceShortcutLabels, onWorkspaceHover, onToggleCollapse, @@ -86,7 +83,6 @@ const SortableProjectWrapper = memo(function SortableProjectWrapper({ project={project} isSidebarCollapsed={isCollapsed} isDraggingProject={isDraggingProject} - activeWorkspaceId={activeWorkspaceId} workspaceShortcutLabels={workspaceShortcutLabels} onWorkspaceHover={onWorkspaceHover} onToggleCollapse={onToggleCollapse} @@ -109,11 +105,7 @@ export function DashboardSidebar({ const isSettingsOpen = !!matchRoute({ to: "/settings", fuzzy: true }); const { activeHostUrl } = useLocalHostService(); const v2RouteMatch = matchRoute({ to: "/v2-workspace/$workspaceId" }); - const routeV2WorkspaceId = v2RouteMatch ? v2RouteMatch.workspaceId : null; - const pendingV2WorkspaceId = useV2WorkspaceNavigationStore( - (state) => state.pendingWorkspaceId, - ); - const activeV2WorkspaceId = pendingV2WorkspaceId ?? routeV2WorkspaceId; + const activeV2WorkspaceId = v2RouteMatch ? v2RouteMatch.workspaceId : null; const sensors = useSensors( useSensor(MouseSensor, { activationConstraint: { distance: 8 } }), @@ -212,7 +204,6 @@ export function DashboardSidebar({ project={project} isCollapsed={isCollapsed} isDraggingProject={activeProject != null} - activeWorkspaceId={activeV2WorkspaceId} workspaceShortcutLabels={workspaceShortcutLabels} onWorkspaceHover={refreshWorkspacePullRequest} onToggleCollapse={toggleProjectCollapsed} @@ -227,7 +218,6 @@ export function DashboardSidebar({ {}} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx index 71bb6b5fe8e..df2d8c24766 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx @@ -17,7 +17,6 @@ interface DashboardSidebarProjectSectionProps { project: DashboardSidebarProject; isSidebarCollapsed?: boolean; isDraggingProject?: boolean; - activeWorkspaceId: string | null; workspaceShortcutLabels: Map; onWorkspaceHover: (workspaceId: string) => void | Promise; onToggleCollapse: (projectId: string) => void; @@ -29,7 +28,6 @@ export function DashboardSidebarProjectSection({ project, isSidebarCollapsed = false, isDraggingProject = false, - activeWorkspaceId, workspaceShortcutLabels, onWorkspaceHover, onToggleCollapse, @@ -78,7 +76,6 @@ export function DashboardSidebarProjectSection({ isCollapsed={project.isCollapsed} totalWorkspaceCount={totalWorkspaceCount} workspaces={flattenedCollapsedWorkspaces} - activeWorkspaceId={activeWorkspaceId} workspaceShortcutLabels={workspaceShortcutLabels} onWorkspaceHover={onWorkspaceHover} onToggleCollapse={() => onToggleCollapse(project.id)} @@ -128,7 +125,6 @@ export function DashboardSidebarProjectSection({ projectId={project.id} isCollapsed={project.isCollapsed} projectChildren={project.children} - activeWorkspaceId={activeWorkspaceId} workspaceShortcutLabels={workspaceShortcutLabels} onWorkspaceHover={onWorkspaceHover} onDeleteSection={deleteSection} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarCollapsedProjectContent/DashboardSidebarCollapsedProjectContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarCollapsedProjectContent/DashboardSidebarCollapsedProjectContent.tsx index 6ea47b4ce27..ac4479825dc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarCollapsedProjectContent/DashboardSidebarCollapsedProjectContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarCollapsedProjectContent/DashboardSidebarCollapsedProjectContent.tsx @@ -13,7 +13,6 @@ interface DashboardSidebarCollapsedProjectContentProps isCollapsed: boolean; totalWorkspaceCount: number; workspaces: DashboardSidebarWorkspace[]; - activeWorkspaceId: string | null; workspaceShortcutLabels: Map; onWorkspaceHover: (workspaceId: string) => void | Promise; onToggleCollapse: () => void; @@ -30,7 +29,6 @@ export const DashboardSidebarCollapsedProjectContent = forwardRef< isCollapsed, totalWorkspaceCount, workspaces, - activeWorkspaceId, workspaceShortcutLabels, onWorkspaceHover, onToggleCollapse, @@ -84,10 +82,9 @@ export const DashboardSidebarCollapsedProjectContent = forwardRef< onWorkspaceHover(workspace.id)} shortcutLabel={workspaceShortcutLabels.get(workspace.id)} isCollapsed - isActive={workspace.id === activeWorkspaceId} /> ))}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx index f6b56786d70..955f8387875 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx @@ -16,7 +16,6 @@ interface DashboardSidebarExpandedProjectContentProps { projectId: string; isCollapsed: boolean; projectChildren: DashboardSidebarProjectChild[]; - activeWorkspaceId: string | null; workspaceShortcutLabels: Map; onWorkspaceHover: (workspaceId: string) => void | Promise; onDeleteSection: (sectionId: string) => void; @@ -28,7 +27,6 @@ export function DashboardSidebarExpandedProjectContent({ projectId, isCollapsed, projectChildren, - activeWorkspaceId, workspaceShortcutLabels, onWorkspaceHover, onDeleteSection, @@ -118,8 +116,9 @@ export function DashboardSidebarExpandedProjectContent({ activeId === id ? predictedColor : group?.color } isInSection={groupInfo.has(parsed.realId)} - isActive={parsed.realId === activeWorkspaceId} - onWorkspaceHover={onWorkspaceHover} + onHoverCardOpen={() => + onWorkspaceHover(parsed.realId) + } shortcutLabel={workspaceShortcutLabels.get( parsed.realId, )} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx index c5a461f9b09..f40d01be103 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useDiffStats } from "renderer/hooks/host-service/useDiffStats"; import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; import { useDeletingWorkspaces } from "renderer/routes/_authenticated/providers/DeletingWorkspacesProvider"; @@ -15,20 +15,18 @@ import { useDashboardSidebarWorkspaceItemActions } from "./hooks/useDashboardSid interface DashboardSidebarWorkspaceItemProps { workspace: DashboardSidebarWorkspace; - onWorkspaceHover?: (workspaceId: string) => void | Promise; + onHoverCardOpen?: () => void; shortcutLabel?: string; isCollapsed?: boolean; isInSection?: boolean; - isActive?: boolean; } -function DashboardSidebarWorkspaceItemComponent({ +export function DashboardSidebarWorkspaceItem({ workspace, - onWorkspaceHover, + onHoverCardOpen, shortcutLabel, isCollapsed = false, isInSection = false, - isActive = false, }: DashboardSidebarWorkspaceItemProps) { const { id, @@ -44,9 +42,6 @@ function DashboardSidebarWorkspaceItemComponent({ const isMainWorkspace = workspace.type === "main"; const isPending = !!creationStatus; const isFailedInFlight = creationStatus === "failed"; - const diffStats = useDiffStats(id, { - enabled: !isCollapsed && !isPending && isActive, - }); const workspaceStatus = useV2WorkspaceNotificationStatus(id); const { cancelRename, @@ -58,6 +53,7 @@ function DashboardSidebarWorkspaceItemComponent({ handleOpenInFinder, handleRemoveFromSidebar, handleToggleUnread, + isActive, isDeleteDialogOpen, isUnread, isRenaming, @@ -72,9 +68,11 @@ function DashboardSidebarWorkspaceItemComponent({ projectId, workspaceName: name, branch, - isActive, isMainWorkspace, }); + const diffStats = useDiffStats(id, { + enabled: !isCollapsed && !isPending && isActive, + }); const { v2Workspaces: v2WorkspaceActions } = useOptimisticCollectionActions(); const [renameBranchTarget, setRenameBranchTarget] = useState( @@ -115,8 +113,8 @@ function DashboardSidebarWorkspaceItemComponent({ const isHovered = hoverHoveredId === id; useEffect(() => { - if (isHovered && hostType === "local-device") onWorkspaceHover?.(id); - }, [isHovered, hostType, onWorkspaceHover, id]); + if (isHovered && hostType === "local-device") onHoverCardOpen?.(); + }, [isHovered, hostType, onHoverCardOpen]); useEffect(() => { if (!isHovered) return; hoverSyncIfHovered(id, hoverPayload); @@ -297,7 +295,3 @@ function DashboardSidebarWorkspaceItemComponent({ ); } - -export const DashboardSidebarWorkspaceItem = memo( - DashboardSidebarWorkspaceItemComponent, -); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts index 35f07e405e1..2e7313d0984 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts @@ -1,11 +1,10 @@ import { toast } from "@superset/ui/sonner"; -import { useNavigate } from "@tanstack/react-router"; +import { useMatchRoute, useNavigate } from "@tanstack/react-router"; import { useState } from "react"; import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { electronTrpcClient } from "renderer/lib/trpc-client"; import { useDashboardSidebarSectionRename } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSectionRenameContext"; -import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; @@ -20,7 +19,6 @@ interface UseDashboardSidebarWorkspaceItemActionsOptions { projectId: string; workspaceName: string; branch: string; - isActive: boolean; isMainWorkspace?: boolean; } @@ -29,10 +27,10 @@ export function useDashboardSidebarWorkspaceItemActions({ projectId, workspaceName, branch, - isActive, isMainWorkspace = false, }: UseDashboardSidebarWorkspaceItemActionsOptions) { const navigate = useNavigate(); + const matchRoute = useMatchRoute(); const { activeHostUrl } = useLocalHostService(); const { copyToClipboard } = useCopyToClipboard(); const { v2Workspaces: workspaceActions } = useOptimisticCollectionActions(); @@ -49,11 +47,19 @@ export function useDashboardSidebarWorkspaceItemActions({ const [renameValue, setRenameValue] = useState(workspaceName); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const isActive = !!matchRoute({ + to: "/v2-workspace/$workspaceId", + params: { workspaceId }, + fuzzy: true, + }); + const handleClick = () => { if (isRenaming) return; clearWorkspaceAttention(workspaceId); - if (isActive) return; - void navigateToV2Workspace(workspaceId, navigate); + navigate({ + to: "/v2-workspace/$workspaceId", + params: { workspaceId }, + }); }; const startRename = () => { @@ -165,6 +171,7 @@ export function useDashboardSidebarWorkspaceItemActions({ handleOpenInFinder, handleRemoveFromSidebar, handleToggleUnread, + isActive, isDeleteDialogOpen, isRenaming, isUnread, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx index a47622cd858..47cb8dfeba5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx @@ -1,6 +1,5 @@ import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { memo } from "react"; import type { DashboardSidebarWorkspace } from "../../types"; import { DashboardSidebarWorkspaceItem } from "../DashboardSidebarWorkspaceItem"; @@ -9,19 +8,17 @@ interface SortableWorkspaceItemProps { workspace: DashboardSidebarWorkspace; accentColor?: string | null; isInSection?: boolean; - isActive?: boolean; - onWorkspaceHover?: (workspaceId: string) => void | Promise; + onHoverCardOpen?: () => void; shortcutLabel?: string; disabled?: boolean; } -export const SortableWorkspaceItem = memo(function SortableWorkspaceItem({ +export function SortableWorkspaceItem({ sortableId, workspace, accentColor, isInSection, - isActive = false, - onWorkspaceHover, + onHoverCardOpen, shortcutLabel, disabled, }: SortableWorkspaceItemProps) { @@ -48,11 +45,10 @@ export const SortableWorkspaceItem = memo(function SortableWorkspaceItem({ >
); -}); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts index 874d9d6a10d..6e96aab93d0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts @@ -3,7 +3,6 @@ import { useCallback, useMemo, useRef } from "react"; import { useHotkey } from "renderer/hotkeys"; import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; -import { useV2WorkspaceNavigationStore } from "renderer/stores/v2-workspace-navigation"; import type { DashboardSidebarProject } from "../../types"; import { getProjectChildrenWorkspaces } from "../../utils/projectChildren"; @@ -132,15 +131,11 @@ export function useDashboardSidebarShortcuts( }); const currentWorkspaceId = currentWorkspaceMatch !== false ? currentWorkspaceMatch.workspaceId : null; - const pendingWorkspaceId = useV2WorkspaceNavigationStore( - (state) => state.pendingWorkspaceId, - ); - const activeWorkspaceId = pendingWorkspaceId ?? currentWorkspaceId; useHotkey("PREV_WORKSPACE", () => { - if (!activeWorkspaceId || flattenedWorkspaces.length === 0) return; + if (!currentWorkspaceId || flattenedWorkspaces.length === 0) return; const index = flattenedWorkspaces.findIndex( - (w) => w.id === activeWorkspaceId, + (w) => w.id === currentWorkspaceId, ); if (index === -1) return; const prevIndex = index <= 0 ? flattenedWorkspaces.length - 1 : index - 1; @@ -150,9 +145,9 @@ export function useDashboardSidebarShortcuts( }); useHotkey("NEXT_WORKSPACE", () => { - if (!activeWorkspaceId || flattenedWorkspaces.length === 0) return; + if (!currentWorkspaceId || flattenedWorkspaces.length === 0) return; const index = flattenedWorkspaces.findIndex( - (w) => w.id === activeWorkspaceId, + (w) => w.id === currentWorkspaceId, ); if (index === -1) return; const nextIndex = index >= flattenedWorkspaces.length - 1 ? 0 : index + 1; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.test.ts deleted file mode 100644 index 19908f902f8..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { beforeEach, describe, expect, it } from "bun:test"; -import type { UseNavigateResult } from "@tanstack/react-router"; -import { useV2WorkspaceNavigationStore } from "renderer/stores/v2-workspace-navigation"; -import { - navigateToV2Workspace, - resetV2WorkspaceNavigationStateForTesting, -} from "./workspace-navigation"; - -function createDeferred() { - let resolve!: () => void; - let reject!: (error: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, reject, resolve }; -} - -describe("navigateToV2Workspace", () => { - beforeEach(() => { - resetV2WorkspaceNavigationStateForTesting(); - useV2WorkspaceNavigationStore.setState({ pendingWorkspaceId: null }); - }); - - it("starts the first plain workspace switch immediately", async () => { - const calls: unknown[] = []; - const firstNavigation = createDeferred(); - const navigate = ((options: unknown) => { - calls.push(options); - return firstNavigation.promise; - }) as UseNavigateResult; - - const promise = navigateToV2Workspace("workspace-a", navigate); - - expect(calls).toHaveLength(1); - expect(calls[0]).toMatchObject({ - to: "/v2-workspace/$workspaceId", - params: { workspaceId: "workspace-a" }, - search: {}, - }); - expect(useV2WorkspaceNavigationStore.getState().pendingWorkspaceId).toBe( - "workspace-a", - ); - - firstNavigation.resolve(); - await promise; - expect( - useV2WorkspaceNavigationStore.getState().pendingWorkspaceId, - ).toBeNull(); - }); - - it("coalesces plain switches while a route transition is in flight", async () => { - const calls: unknown[] = []; - const firstNavigation = createDeferred(); - const queuedNavigation = createDeferred(); - const navigate = ((options: unknown) => { - calls.push(options); - return calls.length === 1 - ? firstNavigation.promise - : queuedNavigation.promise; - }) as UseNavigateResult; - - const first = navigateToV2Workspace("workspace-a", navigate); - const second = navigateToV2Workspace("workspace-b", navigate); - const third = navigateToV2Workspace("workspace-c", navigate); - - expect(calls).toHaveLength(1); - expect(useV2WorkspaceNavigationStore.getState().pendingWorkspaceId).toBe( - "workspace-c", - ); - - firstNavigation.resolve(); - await first; - await Promise.resolve(); - - expect(calls).toHaveLength(2); - expect(calls[1]).toMatchObject({ - to: "/v2-workspace/$workspaceId", - params: { workspaceId: "workspace-c" }, - search: {}, - }); - - queuedNavigation.resolve(); - await Promise.all([second, third]); - expect( - useV2WorkspaceNavigationStore.getState().pendingWorkspaceId, - ).toBeNull(); - }); - - it("does not delay focused workspace navigation requests", async () => { - const calls: unknown[] = []; - const navigate = ((options: unknown) => { - calls.push(options); - return Promise.resolve(); - }) as UseNavigateResult; - - await navigateToV2Workspace("workspace-a", navigate, { - search: { terminalId: "terminal-a" }, - }); - - expect(calls).toHaveLength(1); - expect(calls[0]).toMatchObject({ - to: "/v2-workspace/$workspaceId", - params: { workspaceId: "workspace-a" }, - search: { terminalId: "terminal-a" }, - }); - }); - - it("cancels a queued plain switch when a focused request arrives", async () => { - const calls: unknown[] = []; - const firstNavigation = createDeferred(); - const focusedNavigation = createDeferred(); - const navigate = ((options: unknown) => { - calls.push(options); - return calls.length === 1 - ? firstNavigation.promise - : focusedNavigation.promise; - }) as UseNavigateResult; - - const first = navigateToV2Workspace("workspace-a", navigate); - const pendingPlainSwitch = navigateToV2Workspace("workspace-b", navigate); - const focusedSwitch = navigateToV2Workspace("workspace-c", navigate, { - search: { terminalId: "terminal-b" }, - }); - await pendingPlainSwitch; - - expect(calls).toHaveLength(2); - expect(calls[1]).toMatchObject({ - to: "/v2-workspace/$workspaceId", - params: { workspaceId: "workspace-c" }, - search: { terminalId: "terminal-b" }, - }); - - firstNavigation.resolve(); - focusedNavigation.resolve(); - await Promise.all([first, focusedSwitch]); - expect( - useV2WorkspaceNavigationStore.getState().pendingWorkspaceId, - ).toBeNull(); - }); -}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts index 8bdd49b005d..a807561923b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts @@ -2,10 +2,6 @@ import type { NavigateOptions, UseNavigateResult, } from "@tanstack/react-router"; -import { - clearPendingV2WorkspaceNavigation, - setPendingV2WorkspaceNavigation, -} from "renderer/stores/v2-workspace-navigation"; export interface WorkspaceSearchParams { tabId?: string; @@ -21,118 +17,6 @@ export interface V2WorkspaceSearchParams { openUrlRequestId?: string; } -interface V2WorkspaceNavigationRequest { - workspaceId: string; - navigate: UseNavigateResult; - search: V2WorkspaceSearchParams; - options: Omit; -} - -interface QueuedV2WorkspaceNavigation extends V2WorkspaceNavigationRequest { - waiters: Array<{ - resolve: () => void; - reject: (error: unknown) => void; - }>; -} - -let inFlightV2WorkspaceNavigation: Promise | null = null; -let queuedV2WorkspaceNavigation: QueuedV2WorkspaceNavigation | null = null; - -export function resetV2WorkspaceNavigationStateForTesting(): void { - inFlightV2WorkspaceNavigation = null; - queuedV2WorkspaceNavigation = null; -} - -function observeNavigationFailure( - promise: Promise, - context: string, -): Promise { - void promise.catch((error) => { - console.warn(`[workspace-navigation] ${context} failed`, error); - }); - return promise; -} - -function hasSearchParams(search: V2WorkspaceSearchParams): boolean { - return Object.values(search).some((value) => value !== undefined); -} - -function runV2WorkspaceNavigation({ - workspaceId, - navigate, - search, - options, -}: V2WorkspaceNavigationRequest): Promise { - return navigate({ - to: "/v2-workspace/$workspaceId", - params: { workspaceId }, - search, - ...options, - }); -} - -function completeWaiters( - waiters: QueuedV2WorkspaceNavigation["waiters"], - result: { status: "resolved" } | { status: "rejected"; error: unknown }, -): void { - for (const waiter of waiters) { - if (result.status === "resolved") { - waiter.resolve(); - } else { - waiter.reject(result.error); - } - } -} - -function drainQueuedV2WorkspaceNavigation(): void { - if (inFlightV2WorkspaceNavigation || !queuedV2WorkspaceNavigation) return; - - const navigation = queuedV2WorkspaceNavigation; - queuedV2WorkspaceNavigation = null; - const promise = startV2WorkspaceNavigation(navigation); - promise.then( - () => completeWaiters(navigation.waiters, { status: "resolved" }), - (error) => - completeWaiters(navigation.waiters, { status: "rejected", error }), - ); -} - -function startV2WorkspaceNavigation( - request: V2WorkspaceNavigationRequest, -): Promise { - const promise = runV2WorkspaceNavigation(request); - inFlightV2WorkspaceNavigation = promise; - void promise.then( - () => { - if (inFlightV2WorkspaceNavigation !== promise) return; - inFlightV2WorkspaceNavigation = null; - drainQueuedV2WorkspaceNavigation(); - }, - () => { - if (inFlightV2WorkspaceNavigation !== promise) return; - inFlightV2WorkspaceNavigation = null; - drainQueuedV2WorkspaceNavigation(); - }, - ); - return promise; -} - -function queueV2WorkspaceNavigation( - request: V2WorkspaceNavigationRequest, -): Promise { - return new Promise((resolve, reject) => { - const waiters = queuedV2WorkspaceNavigation?.waiters ?? []; - waiters.push({ resolve, reject }); - queuedV2WorkspaceNavigation = { ...request, waiters }; - }); -} - -function cancelQueuedV2WorkspaceNavigation(): void { - const navigation = queuedV2WorkspaceNavigation; - queuedV2WorkspaceNavigation = null; - completeWaiters(navigation?.waiters ?? [], { status: "resolved" }); -} - /** * Navigate to a workspace and update localStorage to remember it as the last viewed workspace. * This ensures the workspace will be restored when the app is reopened. @@ -150,15 +34,12 @@ export function navigateToWorkspace( ): Promise { const { search, ...rest } = options ?? {}; localStorage.setItem("lastViewedWorkspaceId", workspaceId); - return observeNavigationFailure( - navigate({ - to: "/workspace/$workspaceId", - params: { workspaceId }, - search: search ?? {}, - ...rest, - }), - `navigate to workspace ${workspaceId}`, - ); + return navigate({ + to: "/workspace/$workspaceId", + params: { workspaceId }, + search: search ?? {}, + ...rest, + }); } /** @@ -172,38 +53,10 @@ export function navigateToV2Workspace( }, ): Promise { const { search, ...rest } = options ?? {}; - const resolvedSearch = search ?? {}; - const isPlainWorkspaceSwitch = - !rest.replace && !hasSearchParams(resolvedSearch); - - setPendingV2WorkspaceNavigation(workspaceId); - - if (!isPlainWorkspaceSwitch) { - cancelQueuedV2WorkspaceNavigation(); - } - - const promise = - isPlainWorkspaceSwitch && inFlightV2WorkspaceNavigation - ? queueV2WorkspaceNavigation({ - workspaceId, - navigate, - search: resolvedSearch, - options: rest, - }) - : startV2WorkspaceNavigation({ - workspaceId, - navigate, - search: resolvedSearch, - options: rest, - }); - - void promise.then( - () => clearPendingV2WorkspaceNavigation(workspaceId), - () => clearPendingV2WorkspaceNavigation(workspaceId), - ); - - return observeNavigationFailure( - promise, - `navigate to v2 workspace ${workspaceId}`, - ); + return navigate({ + to: "/v2-workspace/$workspaceId", + params: { workspaceId }, + search: search ?? {}, + ...rest, + }); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx index d1c05cbecc4..0adb0f7ce65 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx @@ -27,7 +27,6 @@ import { useV2WorkspacesFilterStore, } from "renderer/routes/_authenticated/_dashboard/v2-workspaces/stores/v2WorkspacesFilterStore"; import { useV2ProjectLocalMetaStore } from "renderer/stores/v2-project-local-meta"; -import { useV2WorkspaceNavigationStore } from "renderer/stores/v2-workspace-navigation"; import { V2WorkspaceProjectIcon } from "../V2WorkspaceProjectIcon"; import { SortableHeader } from "./components/SortableHeader"; import { V2WorkspaceRow } from "./components/V2WorkspaceRow"; @@ -132,10 +131,6 @@ export function V2WorkspacesList({ workspaces }: V2WorkspacesListProps) { }); const currentWorkspaceId = currentWorkspaceMatch !== false ? currentWorkspaceMatch.workspaceId : null; - const pendingWorkspaceId = useV2WorkspaceNavigationStore( - (state) => state.pendingWorkspaceId, - ); - const activeWorkspaceId = pendingWorkspaceId ?? currentWorkspaceId; const searchQuery = useV2WorkspacesFilterStore((state) => state.searchQuery); const deviceFilter = useV2WorkspacesFilterStore( @@ -270,7 +265,7 @@ export function V2WorkspacesList({ workspaces }: V2WorkspacesListProps) { ))}
diff --git a/apps/desktop/src/renderer/stores/v2-workspace-navigation.ts b/apps/desktop/src/renderer/stores/v2-workspace-navigation.ts deleted file mode 100644 index 00560af0a59..00000000000 --- a/apps/desktop/src/renderer/stores/v2-workspace-navigation.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { create } from "zustand"; - -interface V2WorkspaceNavigationState { - pendingWorkspaceId: string | null; - setPendingWorkspaceId: (workspaceId: string) => void; - clearPendingWorkspaceId: (workspaceId: string) => void; -} - -export const useV2WorkspaceNavigationStore = create( - (set) => ({ - pendingWorkspaceId: null, - setPendingWorkspaceId: (workspaceId) => - set({ pendingWorkspaceId: workspaceId }), - clearPendingWorkspaceId: (workspaceId) => - set((state) => - state.pendingWorkspaceId === workspaceId - ? { pendingWorkspaceId: null } - : state, - ), - }), -); - -export function setPendingV2WorkspaceNavigation(workspaceId: string): void { - useV2WorkspaceNavigationStore.getState().setPendingWorkspaceId(workspaceId); -} - -export function clearPendingV2WorkspaceNavigation(workspaceId: string): void { - useV2WorkspaceNavigationStore.getState().clearPendingWorkspaceId(workspaceId); -} From 383eadc08bcf86435485caeb38a6aee13f96ad17 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 14 May 2026 11:14:02 -0700 Subject: [PATCH 07/16] Restore eager changes sidebar rendering --- .../WorkspaceSidebar/WorkspaceSidebar.tsx | 10 +- .../ChangesFileList/ChangesFileList.tsx | 5 +- .../ChangesFoldersView/ChangesFoldersView.tsx | 100 ++++-------------- .../ChangesSection/ChangesSection.tsx | 24 ++--- .../hooks/useChangesTab/useChangesTab.tsx | 23 ++-- .../hooks/useReviewTab/useReviewTab.tsx | 6 +- .../hooks/useChangeset/useChangeset.ts | 8 +- .../useOpenInExternalEditor.ts | 11 +- .../useSidebarDiffRef/useSidebarDiffRef.ts | 7 +- 9 files changed, 55 insertions(+), 139 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx index d79047a8b5e..d7972188da9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -115,18 +115,11 @@ export function WorkspaceSidebar({ return () => ro.disconnect(); }, []); - const isFilesTabActive = activeTab === "files"; - const isChangesTabActive = activeTab === "changes"; - const isReviewTabActive = activeTab === "review"; - const gitStatus = useGitStatus( - workspaceId, - isFilesTabActive || isChangesTabActive, - ); + const gitStatus = useGitStatus(workspaceId); const changesTabDef = useChangesTab({ workspaceId, gitStatus, - enabled: isChangesTabActive, selectedFilePath, onSelectFile: onSelectDiffFile, onOpenFile: onSelectFile, @@ -138,7 +131,6 @@ export function WorkspaceSidebar({ const reviewTab = useReviewTab({ workspaceId, - enabled: isReviewTabActive, onOpenComment, onOpenInDiff: onSelectDiffFile ? (path, line, openInNewTab) => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx index 61be82f48b8..cba18a18de1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx @@ -82,10 +82,7 @@ export const ChangesFileList = memo(function ChangesFileList({ } return ( -
+
{GROUP_ORDER.map((key) => { const groupFiles = grouped[key]; if (groupFiles.length === 0) return null; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx index aa804845031..e525781f33d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx @@ -1,4 +1,3 @@ -import { defaultRangeExtractor, useVirtualizer } from "@tanstack/react-virtual"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ChangesetFile } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; import type { FoldSignal } from "../../ChangesFileList"; @@ -7,8 +6,6 @@ import { FolderHeader } from "./components/FolderHeader"; const ROOT_FOLDER_KEY = ""; const ROOT_FOLDER_LABEL = "Root Path"; -const ESTIMATED_ROW_HEIGHT = 24; -const OVERSCAN = 8; interface ChangesFoldersViewProps { files: ChangesetFile[]; @@ -26,10 +23,6 @@ interface FolderGroup { files: ChangesetFile[]; } -type GroupedRow = - | { kind: "folder"; key: string; group: FolderGroup } - | { kind: "file"; key: string; file: ChangesetFile }; - /** * Render a flat list of changed files grouped by their immediate parent * folder (one level deep — v1's "grouped" mode, not the full tree). @@ -52,7 +45,6 @@ export const ChangesFoldersView = memo(function ChangesFoldersView({ onOpenFile, onOpenInEditor, }: ChangesFoldersViewProps) { - const listRef = useRef(null); const groups = useMemo(() => groupFilesByFolder(files), [files]); const [closedFolders, setClosedFolders] = useState>(new Set()); @@ -80,73 +72,26 @@ export const ChangesFoldersView = memo(function ChangesFoldersView({ ); }, [foldSignal, groups]); - const rows = useMemo(() => { - const nextRows: GroupedRow[] = []; - for (const group of groups) { - nextRows.push({ - kind: "folder", - key: `folder:${group.folderPath || "__root__"}`, - group, - }); - if (closedFolders.has(group.folderPath)) continue; - for (const file of group.files) { - nextRows.push({ - kind: "file", - key: `file:${file.source.kind}:${file.path}`, - file, - }); - } - } - return nextRows; - }, [closedFolders, groups]); - - const virtualizer = useVirtualizer({ - count: rows.length, - getScrollElement: () => - listRef.current?.closest( - "[data-changes-file-list-scroll]", - ) as HTMLElement | null, - estimateSize: () => ESTIMATED_ROW_HEIGHT, - getItemKey: (index) => rows[index]?.key ?? index, - rangeExtractor: defaultRangeExtractor, - overscan: OVERSCAN, - scrollMargin: listRef.current?.offsetTop ?? 0, - }); - - const items = virtualizer.getVirtualItems(); - return ( -
-
- {items.map((virtualRow) => { - const row = rows[virtualRow.index]; - if (!row) return null; - return ( -
- {row.kind === "folder" ? ( - toggleFolder(row.group.folderPath)} - /> - ) : ( +
+ {groups.map((group) => { + const isRoot = group.folderPath === ROOT_FOLDER_KEY; + const isOpen = !closedFolders.has(group.folderPath); + // `folderPath` ("" for the root group) is already the unique + // per-group discriminator — `groupFilesByFolder` keys a Map by it. + return ( +
+ toggleFolder(group.folderPath)} + /> + {isOpen && + group.files.map((file) => ( - )} -
- ); - })} -
+ ))} +
+ ); + })}
); }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesSection/ChangesSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesSection/ChangesSection.tsx index 4277aa6dd05..f4ad7d1ecce 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesSection/ChangesSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesSection/ChangesSection.tsx @@ -1,9 +1,14 @@ +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@superset/ui/collapsible"; import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { workspaceTrpc } from "@superset/workspace-client"; import { ChevronRight, Minus, Plus } from "lucide-react"; -import { type ReactNode, useId, useState } from "react"; +import { type ReactNode, useState } from "react"; import { LuUndo2 } from "react-icons/lu"; import { DiscardConfirmDialog } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/DiscardConfirmDialog"; @@ -26,7 +31,6 @@ export function ChangesSection({ }: ChangesSectionProps) { const [open, setOpen] = useState(defaultOpen); const [showConfirm, setShowConfirm] = useState(false); - const contentId = useId(); const utils = workspaceTrpc.useUtils(); const invalidate = () => { @@ -108,15 +112,9 @@ export function ChangesSection({ const StagingToggleIcon = isUnstaged ? Plus : Minus; return ( -
+
- + {stagingActions && (
@@ -163,7 +161,7 @@ export function ChangesSection({
)}
- {open &&
{children}
} + {children} {stagingActions && ( )} -
+ ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx index 36cfe295f98..54c63d00187 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx @@ -23,7 +23,6 @@ export type { ChangesFilter, ChangesViewMode }; interface UseChangesTabParams { workspaceId: string; gitStatus: ReturnType; - enabled?: boolean; /** Absolute path of the file whose diff/preview is currently open. */ selectedFilePath?: string; onSelectFile?: (path: string, openInNewTab?: boolean) => void; @@ -33,7 +32,6 @@ interface UseChangesTabParams { export function useChangesTab({ workspaceId, gitStatus: status, - enabled = true, selectedFilePath, onSelectFile, onOpenFile, @@ -49,21 +47,18 @@ export function useChangesTab({ const baseBranchQuery = workspaceTrpc.git.getBaseBranch.useQuery( { workspaceId }, - { staleTime: Number.POSITIVE_INFINITY, enabled }, + { staleTime: Number.POSITIVE_INFINITY }, ); const baseBranch = baseBranchQuery.data?.baseBranch ?? null; - const ref = useSidebarDiffRef(workspaceId, enabled); - const { files, isLoading } = useChangeset({ workspaceId, ref, enabled }); + const ref = useSidebarDiffRef(workspaceId); + const { files, isLoading } = useChangeset({ workspaceId, ref }); - const workspaceQuery = workspaceTrpc.workspace.get.useQuery( - { - id: workspaceId, - }, - { enabled }, - ); + const workspaceQuery = workspaceTrpc.workspace.get.useQuery({ + id: workspaceId, + }); const worktreePath = workspaceQuery.data?.worktreePath; - const openInExternalEditor = useOpenInExternalEditor(workspaceId, enabled); + const openInExternalEditor = useOpenInExternalEditor(workspaceId); const handleOpenInEditor = useCallback( (relativePath: string) => { @@ -111,12 +106,12 @@ export function useChangesTab({ const commits = workspaceTrpc.git.listCommits.useQuery( { workspaceId, baseBranch: baseBranch ?? undefined }, - { enabled, refetchOnWindowFocus: true }, + { refetchOnWindowFocus: true }, ); const branches = workspaceTrpc.git.listBranches.useQuery( { workspaceId }, - { enabled, refetchInterval: 30_000, refetchOnWindowFocus: true }, + { refetchInterval: 30_000, refetchOnWindowFocus: true }, ); const renameBranchMutation = workspaceTrpc.git.renameBranch.useMutation(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/useReviewTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/useReviewTab.tsx index 3d9d896865d..0f5a034cb37 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/useReviewTab.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/useReviewTab.tsx @@ -17,21 +17,19 @@ type V2ThreadsData = RouterOutputs["git"]["getPullRequestThreads"]; interface UseReviewTabParams { workspaceId: string; - enabled?: boolean; onOpenComment?: (comment: CommentPaneData) => void; onOpenInDiff?: (path: string, line?: number, openInNewTab?: boolean) => void; } export function useReviewTab({ workspaceId, - enabled = true, onOpenComment, onOpenInDiff, }: UseReviewTabParams): SidebarTabDefinition { const prQuery = workspaceTrpc.git.getPullRequest.useQuery( { workspaceId }, { - enabled: enabled && !!workspaceId, + enabled: !!workspaceId, refetchInterval: 10_000, refetchOnWindowFocus: true, staleTime: 10_000, @@ -42,7 +40,7 @@ export function useReviewTab({ const threadsQuery = workspaceTrpc.git.getPullRequestThreads.useQuery( { workspaceId }, { - enabled: enabled && !!workspaceId && hasPR, + enabled: !!workspaceId && hasPR, refetchInterval: 30_000, refetchOnWindowFocus: true, }, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/useChangeset.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/useChangeset.ts index 7893a675c54..bfb7e9f02b7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/useChangeset.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/useChangeset.ts @@ -7,7 +7,6 @@ import type { ChangesetFile, DiffRef } from "./types"; interface UseChangesetArgs { workspaceId: string; ref: DiffRef; - enabled?: boolean; } interface UseChangesetResult { @@ -20,7 +19,6 @@ interface UseChangesetResult { export function useChangeset({ workspaceId, ref, - enabled = true, }: UseChangesetArgs): UseChangesetResult { const utils = workspaceTrpc.useUtils(); @@ -31,7 +29,7 @@ export function useChangeset({ baseBranch: ref.kind === "against-base" ? (ref.baseBranch ?? undefined) : undefined, }, - { enabled: enabled && needsStatus, staleTime: Number.POSITIVE_INFINITY }, + { enabled: needsStatus, staleTime: Number.POSITIVE_INFINITY }, ); const commitQuery = workspaceTrpc.git.getCommitFiles.useQuery( @@ -43,7 +41,7 @@ export function useChangeset({ } : { workspaceId, commitHash: "" }, { - enabled: enabled && ref.kind === "commit", + enabled: ref.kind === "commit", staleTime: Number.POSITIVE_INFINITY, }, ); @@ -61,7 +59,7 @@ export function useChangeset({ void utils.git.getDiff.invalidate({ workspaceId }); } }, - enabled && needsStatus, + needsStatus, ); const files = useMemo(() => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/useOpenInExternalEditor.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/useOpenInExternalEditor.ts index c3d6768e530..f394b0a1a2b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/useOpenInExternalEditor.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/useOpenInExternalEditor.ts @@ -13,7 +13,7 @@ export interface OpenInExternalEditorOptions { column?: number; } -export function useOpenInExternalEditor(workspaceId: string, enabled = true) { +export function useOpenInExternalEditor(workspaceId: string) { const collections = useCollections(); const { machineId } = useLocalHostService(); const { data: workspaceRows = [] } = useLiveQuery( @@ -34,12 +34,9 @@ export function useOpenInExternalEditor(workspaceId: string, enabled = true) { // can't look this up on its own (v2 projects aren't in the v1 localDb). const { app: v2PreferredApp } = useV2ProjectDefaultApp(projectId); - const workspaceQuery = workspaceTrpc.workspace.get.useQuery( - { - id: workspaceId, - }, - { enabled }, - ); + const workspaceQuery = workspaceTrpc.workspace.get.useQuery({ + id: workspaceId, + }); const worktreePath = workspaceQuery.data?.worktreePath ?? undefined; return useCallback( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/useSidebarDiffRef.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/useSidebarDiffRef.ts index 77c459db6e8..cbf779c25b7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/useSidebarDiffRef.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/useSidebarDiffRef.ts @@ -5,10 +5,7 @@ import { useMemo } from "react"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { DiffRef } from "../useChangeset/types"; -export function useSidebarDiffRef( - workspaceId: string, - enabled = true, -): DiffRef { +export function useSidebarDiffRef(workspaceId: string): DiffRef { const collections = useCollections(); const { data: rows = [] } = useLiveQuery( (query) => @@ -22,7 +19,7 @@ export function useSidebarDiffRef( const baseBranchQuery = workspaceTrpc.git.getBaseBranch.useQuery( { workspaceId }, - { staleTime: Number.POSITIVE_INFINITY, enabled }, + { staleTime: Number.POSITIVE_INFINITY }, ); const baseBranch = baseBranchQuery.data?.baseBranch ?? null; From fbef70726863a9cfbe81a9f618eb69736c80b0c1 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 14 May 2026 12:19:01 -0700 Subject: [PATCH 08/16] Stabilize renderer stress and prune terminal replay changes --- apps/desktop/docs/RENDERER_STRESS_QA.md | 18 +- apps/desktop/scripts/stress-renderer.ts | 58 +----- .../host-service/useGitStatus/useGitStatus.ts | 11 +- apps/desktop/src/renderer/lib/posthog.test.ts | 3 - apps/desktop/src/renderer/lib/posthog.ts | 7 - .../renderer/lib/terminal/terminal-addons.ts | 36 +++- .../terminal-runtime-registry.test.ts | 99 ---------- .../lib/terminal/terminal-runtime-registry.ts | 83 -------- .../renderer/lib/terminal/terminal-runtime.ts | 22 ++- .../lib/terminal/terminal-session-replay.ts | 17 -- .../terminal-webgl-addon-controller.test.ts | 180 ----------------- .../terminal-webgl-addon-controller.ts | 170 ---------------- .../terminal-webgl-canvas-registry.ts | 24 --- .../DashboardSidebarExpandedWorkspaceRow.tsx | 24 +-- .../hooks/useHybridSearch/useHybridSearch.ts | 54 +++--- .../hooks/useTasksData/useTasksData.tsx | 3 +- .../BrowserPane/browserRuntimeRegistry.ts | 127 +----------- .../hooks/usePaneRegistry/usePaneRegistry.tsx | 10 +- .../useRendererStressWorkspaceBridge.ts | 24 --- .../v2-workspace/$workspaceId/page.tsx | 12 -- .../hooks/useSystemFonts/useSystemFonts.ts | 150 ++++++--------- .../useSplitOrientation.ts | 9 +- .../TabsContent/Terminal/helpers.ts | 33 +++- .../shared/workspace-run-definition.test.ts | 60 ------ .../src/shared/workspace-run-definition.ts | 13 +- packages/host-service/src/app.ts | 7 +- .../pull-requests/pull-requests.test.ts | 37 ---- .../runtime/pull-requests/pull-requests.ts | 58 +----- .../src/trpc/router/git/utils/config-write.ts | 8 +- .../project/utils/ensure-main-workspace.ts | 30 --- .../shared/adopt-existing-worktree.ts | 2 +- .../workspace-creation/shared/git-config.ts | 23 +-- .../src/trpc/router/workspaces/workspaces.ts | 52 ++--- .../bug-hunt-3.integration.test.ts | 45 ++--- .../workspace-cleanup.integration.test.ts | 54 +----- ...orkspace-create-delete.integration.test.ts | 78 -------- .../react/components/Workspace/Workspace.tsx | 6 +- .../Workspace/components/TabBar/TabBar.tsx | 182 ++---------------- .../TabBar/components/TabItem/TabItem.tsx | 27 +-- .../components/TabBar/utils/index.ts | 8 +- .../components/TabBar/utils/utils.test.ts | 36 ---- .../components/TabBar/utils/utils.ts | 39 ---- packages/shared/src/host-info.ts | 5 +- .../OverflowFadeContainer.tsx | 8 +- packages/ui/src/hooks/use-overflow-fade.ts | 26 +-- turbo.jsonc | 3 +- 46 files changed, 294 insertions(+), 1687 deletions(-) delete mode 100644 apps/desktop/src/renderer/lib/terminal/terminal-session-replay.ts delete mode 100644 apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon-controller.test.ts delete mode 100644 apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon-controller.ts delete mode 100644 apps/desktop/src/renderer/lib/terminal/terminal-webgl-canvas-registry.ts delete mode 100644 packages/panes/src/react/components/Workspace/components/TabBar/utils/utils.test.ts diff --git a/apps/desktop/docs/RENDERER_STRESS_QA.md b/apps/desktop/docs/RENDERER_STRESS_QA.md index 1d742bfee27..5a82826a62e 100644 --- a/apps/desktop/docs/RENDERER_STRESS_QA.md +++ b/apps/desktop/docs/RENDERER_STRESS_QA.md @@ -1,8 +1,8 @@ # Renderer Stress QA Fixtures and Instrumentation This is the repeatable process for local renderer QA with seeded V2 -workspaces, large dirty diffs, terminal stress, WebGL context-loss coverage, -and renderer instrumentation. Use it before broad manual QA when a change may +workspaces, large dirty diffs, terminal stress, and renderer instrumentation. +Use it before broad manual QA when a change may affect workspace switching, changes/diff panels, terminal rendering, tab/pane state, or renderer responsiveness. @@ -127,10 +127,6 @@ bun --cwd apps/desktop stress:renderer -- \ --progress-every 10 ``` -The terminal-heavy scenario forces terminal WebGL context loss by default. Add -`--no-terminal-webgl-loss` when you need a control run without forced context -loss. - ## Useful Scenarios - `workspace-switch`: repeatedly activates workspaces. @@ -139,7 +135,7 @@ loss. - `workspace-heavy`: runs mixed workspace, pane, browser, and diff actions. - `route-sweep`: navigates renderer routes. - `terminal-heavy`: creates synthetic terminal tabs/panes, writes large ANSI - payloads, switches tabs, and forces terminal WebGL context loss. + payloads, and switches tabs. - `all`: combines route, workspace, heavy, and terminal coverage. ## Instrumentation Knobs @@ -161,7 +157,6 @@ What to look at: - `errorCount` and `errors`: uncaught renderer errors and unhandled rejections. - `maxHeartbeatDelayMs`: event-loop stalls detected by the renderer heartbeat. - `maxLongTaskDurationMs` and `longTasks`: browser long-task evidence. -- `terminalWebglContextLosses`: terminal canvas/WebGL context-loss samples. - CPU profile output: hottest JS frames during the run. - React probe output: commit/component counts when the React DevTools hook is available. @@ -192,7 +187,7 @@ RangeError: WebAssembly.instantiate(): Out of memory ``` The stack came from `@xterm/addon-image`, and it reproduced both with the -WebGL context-loss fix reverted and restored. That isolated the issue to +terminal WebGL changes reverted and restored. That isolated the issue to per-terminal image decoder memory, not daemon update/attach behavior and not terminal sizing. @@ -208,8 +203,8 @@ After an automated run passes, keep the app open and manually check: - Returning to an existing terminal. It should not perform a full replay just because the user switched away and back. - Navigation away from and back to the workspace route. -- Opening and closing enough terminal tabs/panes to verify WebGL fallback does - not change terminal geometry. +- Opening and closing enough terminal tabs/panes to verify terminal geometry + remains stable. ## Cleanup @@ -227,4 +222,3 @@ when you want to return to the normal local auth org: ```bash rm -rf superset-dev-data/host/mock-org-id ``` - diff --git a/apps/desktop/scripts/stress-renderer.ts b/apps/desktop/scripts/stress-renderer.ts index 47c3a7f36c5..83d66255ed7 100644 --- a/apps/desktop/scripts/stress-renderer.ts +++ b/apps/desktop/scripts/stress-renderer.ts @@ -12,7 +12,6 @@ interface Args { terminalPanesPerTab: number; terminalLines: number; terminalPayloadBytes: number; - forceTerminalWebglLoss: boolean; includeTerminalAction: boolean; profileCpu: boolean; reactProbe: boolean; @@ -129,7 +128,6 @@ interface RendererStressResult { terminalActionCounts: Record; terminalActionErrors: string[]; terminalStressSummary: unknown; - terminalWebglContextLosses: unknown[]; workspaceSummary: unknown; reactProbeSummary: unknown; durationMs: number; @@ -157,15 +155,6 @@ interface RendererStressResult { const DEFAULT_SELECTOR = "[data-renderer-stress-workspace-id]"; -interface TerminalWebglContextLossRecord { - index: number; - terminalCount: number; - canvasCount: number; - webglContextCount: number; - lostContextCount: number; - unsupportedContextCount: number; -} - function parseArgs(argv: string[]): Args { const args: Args = { host: "127.0.0.1", @@ -179,7 +168,6 @@ function parseArgs(argv: string[]): Args { terminalPanesPerTab: 4, terminalLines: 40, terminalPayloadBytes: 1024, - forceTerminalWebglLoss: true, includeTerminalAction: false, profileCpu: false, reactProbe: false, @@ -259,9 +247,6 @@ function parseArgs(argv: string[]): Args { case "--terminal-payload-bytes": args.terminalPayloadBytes = readNumber(); break; - case "--no-terminal-webgl-loss": - args.forceTerminalWebglLoss = false; - break; case "--include-terminal-action": args.includeTerminalAction = true; break; @@ -328,13 +313,12 @@ Options: --iterations Workspace activations. Default: 500 --route-iterations Route navigations. Default: --iterations --heavy-iterations Mixed pane/tab/browser/diff actions. Default: min(--iterations, 300) - --terminal-iterations Terminal tab switch/write/context-loss cycles. Default: min(--iterations, 200) + --terminal-iterations Terminal tab switch/write cycles. Default: min(--iterations, 200) --terminal-tab-count Synthetic terminal tabs. Default: 24 --terminal-panes-per-tab Terminal panes per synthetic tab. Default: 4 Also controls generated tabs/panes for workspace-switch-heavy. --terminal-lines ANSI output lines per terminal write. Default: 40 --terminal-payload-bytes Repeated payload bytes per line. Default: 1024 - --no-terminal-webgl-loss Do not force WEBGL_lose_context during terminal stress --include-terminal-action Include one real backend terminal launch in heavy stress. Default: false --profile-cpu Capture a CDP CPU profile and print hottest JS frames --react-probe Capture React commit/component counts via React DevTools hook when available @@ -554,7 +538,6 @@ function rendererStress(options: { terminalPanesPerTab: number; terminalLines: number; terminalPayloadBytes: number; - forceTerminalWebglLoss: boolean; includeTerminalAction: boolean; reactProbe: boolean; progressEvery: number; @@ -563,7 +546,6 @@ function rendererStress(options: { selector: string; workspaceIds: string[]; }): Promise { - const webglContextRecoverySettleMs = 3500; type StressWindow = Window & { performance: Performance & { memory?: unknown; @@ -867,13 +849,6 @@ function rendererStress(options: { failedCount: number; byteLength: number; }>; - forceTerminalWebglContextLoss: () => { - terminalCount: number; - canvasCount: number; - webglContextCount: number; - lostContextCount: number; - unsupportedContextCount: number; - }; getTerminalStressSummary: () => unknown; releaseStressTerminalRuntimes: () => void; addRealTerminalTab: () => Promise; @@ -1078,7 +1053,6 @@ function rendererStress(options: { const heavyActionErrors: string[] = []; const terminalActionCounts: Record = {}; const terminalActionErrors: string[] = []; - const terminalWebglContextLosses: TerminalWebglContextLossRecord[] = []; const slowOperations: RendererStressResult["slowOperations"] = []; const heavyWorkspaceSwitchWorkspaceIds: string[] = []; const heavyWorkspaceSwitchSummaries: unknown[] = []; @@ -1384,7 +1358,6 @@ function rendererStress(options: { 1, Math.min(8, Math.floor(options.terminalPanesPerTab)), ); - const webglLossCadence = Math.max(4, Math.floor(terminalTabCount / 2)); activeBridge.captureBaseline(); try { @@ -1406,7 +1379,7 @@ function rendererStress(options: { for (let index = 0; index < terminalIterations; index += 1) { operationStartedAt = performance.now(); - let actionLabel = "switch-write-terminal-output"; + const actionLabel = "switch-write-terminal-output"; try { activeBridge.switchTab(index % terminalTabCount); terminalActionCounts["switch-tab"] = @@ -1436,22 +1409,6 @@ function rendererStress(options: { `terminal write ${index} reached ${writeResult.writtenCount}/${writeResult.terminalCount} runtimes (${writeResult.byteLength} bytes)`, ); } - - if ( - options.forceTerminalWebglLoss && - index > 0 && - index % webglLossCadence === 0 - ) { - actionLabel = "switch-write-force-webgl-context-loss"; - const lossResult = activeBridge.forceTerminalWebglContextLoss(); - terminalWebglContextLosses.push({ - index, - ...lossResult, - }); - terminalActionCounts["force-webgl-context-loss"] = - (terminalActionCounts["force-webgl-context-loss"] ?? 0) + 1; - operationCount += 1; - } } catch (error) { terminalActionErrors.push( `terminal action ${index} failed: ${ @@ -1469,15 +1426,6 @@ function rendererStress(options: { reportProgress("terminal-heavy", index + 1, terminalIterations); } - if ( - options.forceTerminalWebglLoss && - terminalWebglContextLosses.some( - (result) => result.lostContextCount > 0, - ) - ) { - await sleep(webglContextRecoverySettleMs); - } - terminalStressSummary = activeBridge.getTerminalStressSummary(); } finally { activeBridge.restoreBaseline(); @@ -1509,7 +1457,6 @@ function rendererStress(options: { terminalActionCounts, terminalActionErrors: terminalActionErrors.slice(0, 20), terminalStressSummary, - terminalWebglContextLosses: terminalWebglContextLosses.slice(-20), workspaceSummary, reactProbeSummary: reactProbe.getSummary(), durationMs, @@ -1607,7 +1554,6 @@ async function main() { terminalPanesPerTab: args.terminalPanesPerTab, terminalLines: args.terminalLines, terminalPayloadBytes: args.terminalPayloadBytes, - forceTerminalWebglLoss: args.forceTerminalWebglLoss, includeTerminalAction: args.includeTerminalAction, reactProbe: args.reactProbe, progressEvery: args.progressEvery, diff --git a/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts b/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts index 6fee440a84c..0596ef443b4 100644 --- a/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts +++ b/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts @@ -13,21 +13,18 @@ import { useWorkspaceEvent } from "../useWorkspaceEvent"; * both `.git/` metadata writes and worktree file edits — no client-side * debounce needed. */ -export function useGitStatus(workspaceId: string, enabled = true) { +export function useGitStatus(workspaceId: string) { const utils = workspaceTrpc.useUtils(); const baseBranchQuery = workspaceTrpc.git.getBaseBranch.useQuery( { workspaceId }, - { - staleTime: Number.POSITIVE_INFINITY, - enabled: enabled && Boolean(workspaceId), - }, + { staleTime: Number.POSITIVE_INFINITY, enabled: Boolean(workspaceId) }, ); const baseBranch = baseBranchQuery.data?.baseBranch ?? null; const query = workspaceTrpc.git.getStatus.useQuery( { workspaceId, baseBranch: baseBranch ?? undefined }, - { refetchOnWindowFocus: true, enabled: enabled && Boolean(workspaceId) }, + { refetchOnWindowFocus: true, enabled: Boolean(workspaceId) }, ); const invalidate = useCallback(() => { @@ -38,7 +35,7 @@ export function useGitStatus(workspaceId: string, enabled = true) { void utils.git.getBaseBranch.invalidate({ workspaceId }); }, [utils, workspaceId]); - useWorkspaceEvent("git:changed", workspaceId, invalidate, enabled); + useWorkspaceEvent("git:changed", workspaceId, invalidate); return query; } diff --git a/apps/desktop/src/renderer/lib/posthog.test.ts b/apps/desktop/src/renderer/lib/posthog.test.ts index 2d4405825f2..686e2fc5146 100644 --- a/apps/desktop/src/renderer/lib/posthog.test.ts +++ b/apps/desktop/src/renderer/lib/posthog.test.ts @@ -12,9 +12,6 @@ describe("buildPostHogInitConfig", () => { expect(config.autocapture).toBe(false); expect(config.disable_scroll_properties).toBe(true); expect(POSTHOG_SESSION_REPLAY_BLOCK_SELECTOR).toContain(".xterm"); - expect(POSTHOG_SESSION_REPLAY_BLOCK_SELECTOR).toContain( - "[data-terminal-webgl-canvas]", - ); expect(config.session_recording?.blockSelector).toBe( POSTHOG_SESSION_REPLAY_BLOCK_SELECTOR, ); diff --git a/apps/desktop/src/renderer/lib/posthog.ts b/apps/desktop/src/renderer/lib/posthog.ts index fa6946e8407..ce574f66a81 100644 --- a/apps/desktop/src/renderer/lib/posthog.ts +++ b/apps/desktop/src/renderer/lib/posthog.ts @@ -2,19 +2,12 @@ import type { PostHogConfig } from "posthog-js/dist/module.full.no-external"; import posthogFull from "posthog-js/dist/module.full.no-external"; import type { PostHog } from "posthog-js/react"; import { env } from "../env.renderer"; -import { - TERMINAL_SESSION_REPLAY_BLOCK_ATTRIBUTE, - TERMINAL_SESSION_REPLAY_BLOCK_CLASS, -} from "./terminal/terminal-session-replay"; // Cast to standard PostHog type for compatibility with posthog-js/react export const posthog = posthogFull as unknown as PostHog; export const POSTHOG_SESSION_REPLAY_BLOCK_SELECTOR = [ - `.${TERMINAL_SESSION_REPLAY_BLOCK_CLASS}`, - `[${TERMINAL_SESSION_REPLAY_BLOCK_ATTRIBUTE}]`, "[data-ph-no-capture]", - "[data-terminal-webgl-canvas]", ".xterm", ".xterm-screen", ".xterm-viewport", diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts b/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts index a8cc2fa946c..27c84492771 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts @@ -3,9 +3,9 @@ import { LigaturesAddon } from "@xterm/addon-ligatures"; import { ProgressAddon } from "@xterm/addon-progress"; import { SearchAddon } from "@xterm/addon-search"; import { Unicode11Addon } from "@xterm/addon-unicode11"; +import { WebglAddon } from "@xterm/addon-webgl"; import type { Terminal as XTerm } from "@xterm/xterm"; import { createTerminalImageAddon } from "./terminal-image-addon"; -import { loadTerminalWebglAddon } from "./terminal-webgl-addon-controller"; export interface LoadAddonsResult { searchAddon: SearchAddon; @@ -13,12 +13,18 @@ export interface LoadAddonsResult { dispose: () => void; } +// Once WebGL fails, skip it for all subsequent runtimes (VS Code pattern). +let suggestedRendererType: "webgl" | "dom" | undefined; + /** * Load optional addons onto an already-opened terminal. Returns a cleanup - * function and addon instances. WebGL setup/teardown is delegated to - * loadTerminalWebglAddon. + * function and addon instances. WebGL is deferred to rAF to avoid + * racing with xterm's post-open viewport sync. */ export function loadAddons(terminal: XTerm): LoadAddonsResult { + let disposed = false; + let webglAddon: WebglAddon | null = null; + terminal.loadAddon(new ClipboardAddon()); const unicode11 = new Unicode11Addon(); @@ -37,13 +43,33 @@ export function loadAddons(terminal: XTerm): LoadAddonsResult { terminal.loadAddon(new LigaturesAddon()); } catch {} - const webglAddon = loadTerminalWebglAddon(terminal); + const rafId = requestAnimationFrame(() => { + if (disposed || suggestedRendererType === "dom") return; + + try { + webglAddon = new WebglAddon(); + webglAddon.onContextLoss(() => { + webglAddon?.dispose(); + webglAddon = null; + terminal.refresh(0, terminal.rows - 1); + }); + terminal.loadAddon(webglAddon); + } catch { + suggestedRendererType = "dom"; + webglAddon = null; + } + }); return { searchAddon, progressAddon, dispose: () => { - webglAddon.dispose(); + disposed = true; + cancelAnimationFrame(rafId); + try { + webglAddon?.dispose(); + } catch {} + webglAddon = null; }, }; } diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts index fc3bc16d72d..b352d00b013 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts @@ -129,30 +129,6 @@ function createContainer(): HTMLDivElement { return {} as HTMLDivElement; } -function createCanvas(options: { - isTerminalWebglCanvas?: boolean; - context?: WebGL2RenderingContext | null; -}): HTMLCanvasElement & { - getContext: ReturnType; -} { - const attributes = new Map(); - if (options.isTerminalWebglCanvas) { - attributes.set("data-terminal-webgl-canvas", "true"); - } - return { - getAttribute: mock((name: string) => attributes.get(name) ?? null), - getContext: mock((_contextId: string) => options.context ?? null), - } as unknown as HTMLCanvasElement & { - getContext: ReturnType; - }; -} - -function createWebglContext(extension: unknown): WebGL2RenderingContext { - return { - getExtension: mock((_name: string) => extension), - } as unknown as WebGL2RenderingContext; -} - function clearRegistry() { for (const terminalId of terminalRuntimeRegistry.getAllTerminalIds()) { terminalRuntimeRegistry.dispose(terminalId); @@ -261,81 +237,6 @@ describe("terminalRuntimeRegistry", () => { expect( terminalRuntimeRegistry.getStressDebugInfo(undefined, "instance-a"), ).toEqual([]); - expect( - terminalRuntimeRegistry.forceWebglContextLossForStress( - undefined, - "instance-a", - ), - ).toEqual({ - terminalCount: 0, - canvasCount: 0, - webglContextCount: 0, - lostContextCount: 0, - unsupportedContextCount: 0, - }); - }); - - it("can force WebGL context loss on terminal canvases during stress runs", () => { - const loseContext = mock(() => {}); - const canvas = createCanvas({ - isTerminalWebglCanvas: true, - context: createWebglContext({ loseContext }), - }); - - terminalRuntimeRegistry.mount( - "terminal-1", - createContainer(), - appearance, - "workspace-a", - ); - const runtime = createdRuntimes[0]; - if (!runtime) throw new Error("expected runtime"); - runtime.wrapper._canvasList = [canvas]; - - const result = terminalRuntimeRegistry.forceWebglContextLossForStress( - "terminal-1", - "workspace-a", - ); - - expect(result).toEqual({ - terminalCount: 1, - canvasCount: 1, - webglContextCount: 1, - lostContextCount: 1, - unsupportedContextCount: 0, - }); - expect(canvas.getContext).toHaveBeenCalled(); - expect(loseContext).toHaveBeenCalled(); - }); - - it("does not create WebGL contexts on unmarked stress canvases", () => { - const canvas = createCanvas({ - context: createWebglContext({ loseContext: mock(() => {}) }), - }); - - terminalRuntimeRegistry.mount( - "terminal-1", - createContainer(), - appearance, - "workspace-a", - ); - const runtime = createdRuntimes[0]; - if (!runtime) throw new Error("expected runtime"); - runtime.wrapper._canvasList = [canvas]; - - const result = terminalRuntimeRegistry.forceWebglContextLossForStress( - "terminal-1", - "workspace-a", - ); - - expect(result).toEqual({ - terminalCount: 1, - canvasCount: 1, - webglContextCount: 0, - lostContextCount: 0, - unsupportedContextCount: 0, - }); - expect(canvas.getContext).not.toHaveBeenCalled(); }); it("accepts stress output without waiting for xterm write completion", async () => { diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts index c85847d2d77..7bb2351198a 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -14,7 +14,6 @@ import { type TerminalRuntime, updateRuntimeAppearance, } from "./terminal-runtime"; -import { isTerminalWebglCanvas } from "./terminal-webgl-canvas-registry"; import { type ConnectionState, clearLogs, @@ -38,14 +37,6 @@ interface RegistryEntry { pendingLinkHandlers: TerminalLinkHandlers | null; } -export interface TerminalWebglContextLossResult { - terminalCount: number; - canvasCount: number; - webglContextCount: number; - lostContextCount: number; - unsupportedContextCount: number; -} - export interface TerminalRuntimeStressDebugInfo { terminalId: string; instanceId: string; @@ -58,55 +49,6 @@ export interface TerminalRuntimeStressDebugInfo { connectionState: ConnectionState; } -interface WebglLoseContextExtension { - loseContext: () => void; -} - -function getTerminalWebglContext( - canvas: HTMLCanvasElement, -): WebGL2RenderingContext | null { - if (!isTerminalWebglCanvas(canvas)) return null; - return canvas.getContext("webgl2") as WebGL2RenderingContext | null; -} - -function forceRuntimeWebglContextLoss( - runtime: TerminalRuntime, -): Omit { - const canvases = Array.from(runtime.wrapper.querySelectorAll("canvas")); - let webglContextCount = 0; - let lostContextCount = 0; - let unsupportedContextCount = 0; - - for (const canvas of canvases) { - const context = getTerminalWebglContext(canvas); - if (!context) continue; - - webglContextCount += 1; - const extension = context.getExtension( - "WEBGL_lose_context", - ) as WebglLoseContextExtension | null; - - if (!extension) { - unsupportedContextCount += 1; - continue; - } - - try { - extension.loseContext(); - lostContextCount += 1; - } catch { - unsupportedContextCount += 1; - } - } - - return { - canvasCount: canvases.length, - webglContextCount, - lostContextCount, - unsupportedContextCount, - }; -} - class TerminalRuntimeRegistryImpl { private entries = new Map(); private entryKeysByTerminalId = new Map>(); @@ -410,31 +352,6 @@ class TerminalRuntimeRegistryImpl { } } - forceWebglContextLossForStress( - terminalId?: string, - instanceId?: string, - ): TerminalWebglContextLossResult { - const result: TerminalWebglContextLossResult = { - terminalCount: 0, - canvasCount: 0, - webglContextCount: 0, - lostContextCount: 0, - unsupportedContextCount: 0, - }; - - for (const entry of this.listEntries(terminalId, instanceId)) { - if (!entry.runtime) continue; - result.terminalCount += 1; - const runtimeResult = forceRuntimeWebglContextLoss(entry.runtime); - result.canvasCount += runtimeResult.canvasCount; - result.webglContextCount += runtimeResult.webglContextCount; - result.lostContextCount += runtimeResult.lostContextCount; - result.unsupportedContextCount += runtimeResult.unsupportedContextCount; - } - - return result; - } - getStressDebugInfo( terminalId?: string, instanceId?: string, diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts index fd85b46761d..84c0fe12f9d 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -187,6 +187,25 @@ function createResizeScheduler( return { observe, dispose }; } +function disposeTerminalAfterPendingRefresh(runtime: TerminalRuntime) { + getTerminalParkingContainer().appendChild(runtime.wrapper); + runtime.container = null; + + const dispose = () => { + runtime.wrapper.remove(); + runtime.terminal.dispose(); + }; + + if (typeof requestAnimationFrame !== "function") { + setTimeout(dispose, 0); + return; + } + + requestAnimationFrame(() => { + requestAnimationFrame(dispose); + }); +} + export function createRuntime( terminalId: string, appearance: TerminalAppearance, @@ -322,8 +341,7 @@ export function disposeRuntime( runtime._disposeResizeObserver = null; runtime.resizeObserver?.disconnect(); runtime.resizeObserver = null; - runtime.wrapper.remove(); - runtime.terminal.dispose(); + disposeTerminalAfterPendingRefresh(runtime); if (clearPersistedState) { clearPersistedBuffer(runtime.terminalId); clearPersistedDimensions(runtime.terminalId); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-session-replay.ts b/apps/desktop/src/renderer/lib/terminal/terminal-session-replay.ts deleted file mode 100644 index 42d67eac80e..00000000000 --- a/apps/desktop/src/renderer/lib/terminal/terminal-session-replay.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const TERMINAL_SESSION_REPLAY_BLOCK_CLASS = "ph-no-capture"; -export const TERMINAL_SESSION_REPLAY_BLOCK_ATTRIBUTE = - "data-terminal-replay-blocked"; - -type ReplayBlockedElement = Element & { - classList?: { - add?: (...tokens: string[]) => void; - }; - setAttribute?: (qualifiedName: string, value: string) => void; -}; - -export function markTerminalSessionReplayBlocked(element: Element): void { - const target = element as ReplayBlockedElement; - target.classList?.add?.(TERMINAL_SESSION_REPLAY_BLOCK_CLASS); - target.setAttribute?.("data-ph-no-capture", "true"); - target.setAttribute?.(TERMINAL_SESSION_REPLAY_BLOCK_ATTRIBUTE, "true"); -} diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon-controller.test.ts b/apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon-controller.test.ts deleted file mode 100644 index 50d27e5756b..00000000000 --- a/apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon-controller.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { beforeEach, describe, expect, it, mock } from "bun:test"; -import type { Terminal as XTerm } from "@xterm/xterm"; - -const disposedAddons: FakeWebglAddon[] = []; - -class FakeWebglAddon { - onContextLoss = mock((_callback: () => void) => {}); - - dispose() { - disposedAddons.push(this); - } -} - -mock.module("@xterm/addon-webgl", () => ({ - WebglAddon: FakeWebglAddon, -})); - -const { loadTerminalWebglAddon, resetTerminalWebglAddonStateForTesting } = - await import("./terminal-webgl-addon-controller"); -const { isTerminalWebglCanvas, TERMINAL_WEBGL_CANVAS_ATTRIBUTE } = await import( - "./terminal-webgl-canvas-registry" -); - -function installImmediateAnimationFrames() { - const mutableGlobal = globalThis as typeof globalThis & { - requestAnimationFrame?: typeof requestAnimationFrame; - cancelAnimationFrame?: typeof cancelAnimationFrame; - }; - const previousRequestAnimationFrame = mutableGlobal.requestAnimationFrame; - const previousCancelAnimationFrame = mutableGlobal.cancelAnimationFrame; - - let rafId = 0; - mutableGlobal.requestAnimationFrame = ((callback: FrameRequestCallback) => { - rafId += 1; - callback(rafId); - return rafId; - }) as typeof requestAnimationFrame; - mutableGlobal.cancelAnimationFrame = mock(() => {}); - - return () => { - if (previousRequestAnimationFrame) { - mutableGlobal.requestAnimationFrame = previousRequestAnimationFrame; - } else { - Reflect.deleteProperty(mutableGlobal, "requestAnimationFrame"); - } - if (previousCancelAnimationFrame) { - mutableGlobal.cancelAnimationFrame = previousCancelAnimationFrame; - } else { - Reflect.deleteProperty(mutableGlobal, "cancelAnimationFrame"); - } - }; -} - -function createFakeTerminal() { - const webglCanvasAttributes = new Map(); - const webglCanvasClasses = new Set(); - const webglCanvas = { - classList: { - add: mock((className: string) => { - webglCanvasClasses.add(className); - }), - }, - getAttribute: mock( - (name: string) => webglCanvasAttributes.get(name) ?? null, - ), - removeAttribute: mock((name: string) => { - webglCanvasAttributes.delete(name); - }), - setAttribute: mock((name: string, value: string) => { - webglCanvasAttributes.set(name, value); - }), - } as unknown as HTMLCanvasElement; - const element = new EventTarget() as EventTarget & { - querySelectorAll: (selector: string) => T[]; - }; - element.querySelectorAll = () => [ - webglCanvas as unknown as T, - ]; - - const loadedAddons: FakeWebglAddon[] = []; - const loadAddon = mock((addon: FakeWebglAddon) => { - loadedAddons.push(addon); - }); - const refresh = mock(() => {}); - - return { - loadedAddons, - lossTarget: element, - webglCanvas, - webglCanvasAttributes, - webglCanvasClasses, - terminal: { - element, - loadAddon, - refresh, - rows: 10, - } as unknown as XTerm, - loadAddon, - refresh, - }; -} - -describe("loadTerminalWebglAddon", () => { - beforeEach(() => { - disposedAddons.length = 0; - resetTerminalWebglAddonStateForTesting(); - }); - - it("falls back to DOM on terminal WebGL context loss", async () => { - const restoreAnimationFrames = installImmediateAnimationFrames(); - - try { - const { - loadedAddons, - lossTarget, - terminal, - loadAddon, - refresh, - webglCanvas, - webglCanvasAttributes, - webglCanvasClasses, - } = createFakeTerminal(); - - loadTerminalWebglAddon(terminal); - - expect(loadAddon).toHaveBeenCalledTimes(1); - expect(loadedAddons).toHaveLength(1); - expect(loadedAddons[0]?.onContextLoss).toHaveBeenCalledTimes(1); - expect(isTerminalWebglCanvas(webglCanvas)).toBe(true); - expect(webglCanvasClasses.has("ph-no-capture")).toBe(true); - expect(webglCanvasAttributes.get("data-ph-no-capture")).toBe("true"); - expect(webglCanvasAttributes.get(TERMINAL_WEBGL_CANVAS_ATTRIBUTE)).toBe( - "true", - ); - - const lossEvent = new Event("webglcontextlost", { cancelable: true }); - lossTarget.dispatchEvent(lossEvent); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(lossEvent.defaultPrevented).toBe(true); - expect(disposedAddons).toEqual([loadedAddons[0]]); - expect(isTerminalWebglCanvas(webglCanvas)).toBe(false); - expect(refresh).toHaveBeenCalledWith(0, 9); - - loadTerminalWebglAddon(terminal); - - expect(loadAddon).toHaveBeenCalledTimes(1); - } finally { - restoreAnimationFrames(); - } - }); - - it("cancels pending WebGL setup when disposed before the next frame", async () => { - const mutableGlobal = globalThis as typeof globalThis & { - requestAnimationFrame?: typeof requestAnimationFrame; - cancelAnimationFrame?: typeof cancelAnimationFrame; - }; - const previousRequestAnimationFrame = mutableGlobal.requestAnimationFrame; - const previousCancelAnimationFrame = mutableGlobal.cancelAnimationFrame; - Reflect.deleteProperty(mutableGlobal, "requestAnimationFrame"); - Reflect.deleteProperty(mutableGlobal, "cancelAnimationFrame"); - - try { - const { terminal, loadAddon } = createFakeTerminal(); - const handle = loadTerminalWebglAddon(terminal); - - handle.dispose(); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(loadAddon).toHaveBeenCalledTimes(0); - } finally { - if (previousRequestAnimationFrame) { - mutableGlobal.requestAnimationFrame = previousRequestAnimationFrame; - } - if (previousCancelAnimationFrame) { - mutableGlobal.cancelAnimationFrame = previousCancelAnimationFrame; - } - } - }); -}); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon-controller.ts b/apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon-controller.ts deleted file mode 100644 index 2a7e0b9c401..00000000000 --- a/apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon-controller.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { WebglAddon } from "@xterm/addon-webgl"; -import type { Terminal as XTerm } from "@xterm/xterm"; -import { markTerminalSessionReplayBlocked } from "./terminal-session-replay"; -import { - markTerminalWebglCanvas, - resetTerminalWebglCanvasRegistryForTesting, - unmarkTerminalWebglCanvas, -} from "./terminal-webgl-canvas-registry"; - -export interface TerminalWebglAddonHandle { - dispose: () => void; -} - -// Once WebGL fails, skip it for all subsequent runtimes (VS Code pattern). -let suggestedRendererType: "dom" | undefined; - -export function resetTerminalWebglAddonStateForTesting(): void { - suggestedRendererType = undefined; - resetTerminalWebglCanvasRegistryForTesting(); -} - -function afterPendingXtermRefresh(callback: () => void): void { - if (typeof requestAnimationFrame !== "function") { - setTimeout(callback, 0); - return; - } - requestAnimationFrame(() => { - requestAnimationFrame(callback); - }); -} - -function scheduleAnimationFrame(callback: () => void): () => void { - if (typeof requestAnimationFrame !== "function") { - const timeoutId = setTimeout(callback, 0); - return () => clearTimeout(timeoutId); - } - - const rafId = requestAnimationFrame(callback); - return () => cancelAnimationFrame(rafId); -} - -export function loadTerminalWebglAddon( - terminal: XTerm, -): TerminalWebglAddonHandle { - let disposed = false; - let webglAddon: WebglAddon | null = null; - let cancelPendingEnable: (() => void) | null = null; - let fallbackTimeoutId: ReturnType | null = null; - let rootContextLossRemove: (() => void) | null = null; - let fallbackScheduled = false; - let markedWebglCanvases: HTMLCanvasElement[] = []; - - const removeContextLossListener = () => { - rootContextLossRemove?.(); - rootContextLossRemove = null; - }; - - const clearFallbackTimeout = () => { - if (fallbackTimeoutId === null) return; - clearTimeout(fallbackTimeoutId); - fallbackTimeoutId = null; - }; - - const unmarkWebglCanvases = () => { - for (const canvas of markedWebglCanvases) { - unmarkTerminalWebglCanvas(canvas); - } - markedWebglCanvases = []; - }; - - const markWebglCanvases = () => { - unmarkWebglCanvases(); - const element = terminal.element; - if (!element) return; - markedWebglCanvases = Array.from( - element.querySelectorAll( - ".xterm-screen canvas:not(.xterm-link-layer)", - ), - ); - for (const canvas of markedWebglCanvases) { - markTerminalWebglCanvas(canvas); - markTerminalSessionReplayBlocked(canvas); - } - }; - - const repaint = () => { - try { - terminal.refresh(0, Math.max(0, terminal.rows - 1)); - } catch {} - }; - - const disposeAddon = (options: { markDomFallback: boolean }) => { - cancelPendingEnable?.(); - cancelPendingEnable = null; - clearFallbackTimeout(); - removeContextLossListener(); - unmarkWebglCanvases(); - fallbackScheduled = false; - const addon = webglAddon; - webglAddon = null; - if (options.markDomFallback) { - suggestedRendererType = "dom"; - } - if (addon) { - try { - addon.dispose(); - } catch {} - } - afterPendingXtermRefresh(repaint); - }; - - const fallbackToDom = () => { - if (disposed || fallbackScheduled) return; - fallbackScheduled = true; - suggestedRendererType = "dom"; - fallbackTimeoutId = setTimeout(() => { - fallbackTimeoutId = null; - disposeAddon({ markDomFallback: false }); - }, 0); - }; - - const attachContextLossListener = () => { - removeContextLossListener(); - const element = terminal.element; - if (!element) return; - - const onRootContextLost = (event: Event) => { - event.preventDefault(); - fallbackToDom(); - }; - element.addEventListener("webglcontextlost", onRootContextLost, true); - rootContextLossRemove = () => { - element.removeEventListener("webglcontextlost", onRootContextLost, true); - }; - }; - - if (suggestedRendererType !== "dom") { - cancelPendingEnable = scheduleAnimationFrame(() => { - cancelPendingEnable = null; - if (disposed || webglAddon || suggestedRendererType === "dom") return; - - let addon: WebglAddon | null = null; - try { - addon = new WebglAddon(); - addon.onContextLoss(fallbackToDom); - fallbackScheduled = false; - attachContextLossListener(); - terminal.loadAddon(addon); - webglAddon = addon; - markWebglCanvases(); - } catch { - suggestedRendererType = "dom"; - removeContextLossListener(); - unmarkWebglCanvases(); - if (addon) { - try { - addon.dispose(); - } catch {} - } - } - }); - } - - return { - dispose: () => { - disposed = true; - disposeAddon({ markDomFallback: false }); - }, - }; -} diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-webgl-canvas-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-webgl-canvas-registry.ts deleted file mode 100644 index 75cdd04649d..00000000000 --- a/apps/desktop/src/renderer/lib/terminal/terminal-webgl-canvas-registry.ts +++ /dev/null @@ -1,24 +0,0 @@ -export const TERMINAL_WEBGL_CANVAS_ATTRIBUTE = "data-terminal-webgl-canvas"; - -let terminalWebglCanvases = new WeakSet(); - -export function markTerminalWebglCanvas(canvas: HTMLCanvasElement): void { - terminalWebglCanvases.add(canvas); - canvas.setAttribute(TERMINAL_WEBGL_CANVAS_ATTRIBUTE, "true"); -} - -export function unmarkTerminalWebglCanvas(canvas: HTMLCanvasElement): void { - terminalWebglCanvases.delete(canvas); - canvas.removeAttribute(TERMINAL_WEBGL_CANVAS_ATTRIBUTE); -} - -export function isTerminalWebglCanvas(canvas: HTMLCanvasElement): boolean { - return ( - terminalWebglCanvases.has(canvas) || - canvas.getAttribute(TERMINAL_WEBGL_CANVAS_ATTRIBUTE) === "true" - ); -} - -export function resetTerminalWebglCanvasRegistryForTesting(): void { - terminalWebglCanvases = new WeakSet(); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx index 2eda5ee39c1..19df437c26d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx @@ -1,12 +1,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; -import { - type ComponentPropsWithoutRef, - forwardRef, - useEffect, - useMemo, - useRef, -} from "react"; +import { type ComponentPropsWithoutRef, forwardRef, useMemo } from "react"; import { HiMiniMinus, HiMiniXMark } from "react-icons/hi2"; import type { DiffStats } from "renderer/hooks/host-service/useDiffStats"; import { HotkeyLabel } from "renderer/hotkeys"; @@ -86,18 +80,8 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< creationStatus, } = workspace; const showsStandaloneActiveStripe = accentColor == null; - const localRef = useRef(null); const openUrl = electronTrpc.external.openUrl.useMutation(); - useEffect(() => { - if (isActive) { - localRef.current?.scrollIntoView({ - block: "nearest", - behavior: "smooth", - }); - } - }, [isActive]); - const creationStatusText = useMemo( () => getCreationStatusText(creationStatus), [creationStatus], @@ -116,11 +100,7 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< role={onClick ? "button" : undefined} tabIndex={onClick ? 0 : undefined} aria-disabled={creationStatus ? true : undefined} - ref={(node) => { - localRef.current = node; - if (typeof ref === "function") ref(node); - else if (ref) ref.current = node; - }} + ref={ref} onClick={onClick} onKeyDown={(event) => { if (onClick && (event.key === "Enter" || event.key === " ")) { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useHybridSearch/useHybridSearch.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useHybridSearch/useHybridSearch.ts index 953bb1a25ee..ec33397b0d5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useHybridSearch/useHybridSearch.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useHybridSearch/useHybridSearch.ts @@ -15,42 +15,35 @@ interface SearchResult { matchType: "exact" | "fuzzy"; } -export function useHybridSearch( - tasks: T[], - enabled = true, -) { +export function useHybridSearch(tasks: T[]) { const exactFuse = useMemo( () => - enabled - ? new Fuse(tasks, { - keys: [ - { name: "slug", weight: 2 }, - { name: "labels", weight: 1 }, - ], - threshold: 0, - includeScore: true, - ignoreLocation: true, - useExtendedSearch: false, - }) - : null, - [tasks, enabled], + new Fuse(tasks, { + keys: [ + { name: "slug", weight: 2 }, + { name: "labels", weight: 1 }, + ], + threshold: 0, + includeScore: true, + ignoreLocation: true, + useExtendedSearch: false, + }), + [tasks], ); const fuzzyFuse = useMemo( () => - enabled - ? new Fuse(tasks, { - keys: [ - { name: "title", weight: 2 }, - { name: "description", weight: 1 }, - ], - threshold: 0.3, - includeScore: true, - ignoreLocation: true, - useExtendedSearch: false, - }) - : null, - [tasks, enabled], + new Fuse(tasks, { + keys: [ + { name: "title", weight: 2 }, + { name: "description", weight: 1 }, + ], + threshold: 0.3, + includeScore: true, + ignoreLocation: true, + useExtendedSearch: false, + }), + [tasks], ); const search = useCallback( @@ -62,7 +55,6 @@ export function useHybridSearch( matchType: "exact" as const, })); } - if (!exactFuse || !fuzzyFuse) return []; const exactMatches = exactFuse.search(query); const exactIds = new Set(exactMatches.map((m) => m.item.id)); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksData/useTasksData.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksData/useTasksData.tsx index 2e5e6b17f6c..e70ae23cdf9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksData/useTasksData.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksData/useTasksData.tsx @@ -74,8 +74,7 @@ export function useTasksData({ .sort(compareTasks); }, [allData]); - const hasSearchQuery = searchQuery.trim().length > 0; - const { search } = useHybridSearch(sortedData, hasSearchQuery); + const { search } = useHybridSearch(sortedData); const searchedData = useMemo(() => { if (!searchQuery.trim()) { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts index 52bcd3ac0f2..31ebb1cc4a6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts @@ -27,7 +27,6 @@ interface RegistryEntry { placeholder: HTMLElement | null; resizeObserver: ResizeObserver | null; visible: boolean; - lastLayoutRect: BrowserLayoutRect | null; } const EMPTY_STATE: BrowserRuntimeState = Object.freeze({ @@ -41,17 +40,6 @@ const EMPTY_STATE: BrowserRuntimeState = Object.freeze({ }); const ROOT_CONTAINER_ID = "browser-runtime-root"; -const MAX_BROWSER_LAYOUTS_PER_FRAME = 4; -const BROWSER_LAYOUT_FRAME_BUDGET_MS = 8; -const BROWSER_LAYOUT_COALESCE_MS = 50; -const BROWSER_LAYOUT_IDLE_TIMEOUT_MS = 150; - -interface BrowserLayoutRect { - top: number; - left: number; - width: number; - height: number; -} class BrowserRuntimeRegistryImpl { private entries = new Map(); @@ -60,10 +48,6 @@ class BrowserRuntimeRegistryImpl { private globalListenersInstalled = false; private windowDragPassthrough = false; private shellInteractionPassthrough = false; - private pendingLayoutEntries = new Set(); - private layoutFrameId: number | null = null; - private layoutTimerId: number | null = null; - private layoutIdleCallbackId: number | null = null; private getListeners(paneId: string): Set<() => void> { let set = this.listenersByPaneId.get(paneId); @@ -125,7 +109,7 @@ class BrowserRuntimeRegistryImpl { window.addEventListener("resize", () => { for (const entry of this.entries.values()) { - if (entry.placeholder) this.scheduleLayout(entry); + if (entry.placeholder) this.updateLayout(entry); } }); } @@ -159,104 +143,14 @@ class BrowserRuntimeRegistryImpl { } } - private scheduleLayout(entry: RegistryEntry) { - this.pendingLayoutEntries.add(entry); - this.scheduleDeferredLayoutFlush(); - } - - private scheduleDeferredLayoutFlush() { - if ( - this.layoutFrameId !== null || - this.layoutTimerId !== null || - this.layoutIdleCallbackId !== null - ) { - return; - } - - const requestFlushFrame = () => { - this.layoutTimerId = null; - this.layoutIdleCallbackId = null; - if (this.pendingLayoutEntries.size === 0) return; - this.scheduleImmediateLayoutFlush(); - }; - - if (typeof window.requestIdleCallback === "function") { - this.layoutIdleCallbackId = window.requestIdleCallback( - requestFlushFrame, - { - timeout: BROWSER_LAYOUT_IDLE_TIMEOUT_MS, - }, - ); - return; - } - - this.layoutTimerId = window.setTimeout( - requestFlushFrame, - BROWSER_LAYOUT_COALESCE_MS, - ); - } - - private scheduleImmediateLayoutFlush() { - if (this.layoutFrameId !== null) return; - if (typeof requestAnimationFrame !== "function") { - this.flushPendingLayouts(); - return; - } - this.layoutFrameId = requestAnimationFrame(() => { - this.flushPendingLayouts(); - }); - } - - private flushPendingLayouts() { - this.layoutFrameId = null; - const startedAt = - typeof performance === "undefined" ? 0 : performance.now(); - let processed = 0; - - for (const pendingEntry of Array.from(this.pendingLayoutEntries)) { - this.pendingLayoutEntries.delete(pendingEntry); - this.updateLayout(pendingEntry); - processed += 1; - - if (processed >= MAX_BROWSER_LAYOUTS_PER_FRAME) break; - if ( - startedAt > 0 && - performance.now() - startedAt >= BROWSER_LAYOUT_FRAME_BUDGET_MS - ) { - break; - } - } - - if (this.pendingLayoutEntries.size > 0) { - this.scheduleImmediateLayoutFlush(); - } - } - private updateLayout(entry: RegistryEntry) { - if (!entry.placeholder || !entry.visible) return; + if (!entry.placeholder) return; const rect = entry.placeholder.getBoundingClientRect(); - const nextRect = { - top: rect.top, - left: rect.left, - width: rect.width, - height: rect.height, - }; - const previousRect = entry.lastLayoutRect; const w = entry.webview; - if ( - !previousRect || - previousRect.top !== nextRect.top || - previousRect.left !== nextRect.left || - previousRect.width !== nextRect.width || - previousRect.height !== nextRect.height - ) { - w.style.top = `${nextRect.top}px`; - w.style.left = `${nextRect.left}px`; - w.style.width = `${nextRect.width}px`; - w.style.height = `${nextRect.height}px`; - entry.lastLayoutRect = nextRect; - } - w.style.visibility = "visible"; + w.style.top = `${rect.top}px`; + w.style.left = `${rect.left}px`; + w.style.width = `${rect.width}px`; + w.style.height = `${rect.height}px`; } private notify(paneId: string) { @@ -318,7 +212,6 @@ class BrowserRuntimeRegistryImpl { placeholder: null, resizeObserver: null, visible: false, - lastLayoutRect: null, }; const firePersist = () => { @@ -491,12 +384,13 @@ class BrowserRuntimeRegistryImpl { entry.resizeObserver?.disconnect(); const observer = new ResizeObserver(() => { - if (entry) this.scheduleLayout(entry); + if (entry) this.updateLayout(entry); }); observer.observe(placeholder); entry.resizeObserver = observer; - this.scheduleLayout(entry); + this.updateLayout(entry); + entry.webview.style.visibility = "visible"; this.applyPointerPassthrough(); } @@ -504,19 +398,16 @@ class BrowserRuntimeRegistryImpl { const entry = this.entries.get(paneId); if (!entry) return; entry.onPersist = null; - this.pendingLayoutEntries.delete(entry); entry.placeholder = null; entry.resizeObserver?.disconnect(); entry.resizeObserver = null; entry.visible = false; - entry.lastLayoutRect = null; entry.webview.style.visibility = "hidden"; } destroy(paneId: string): void { const entry = this.entries.get(paneId); if (!entry) return; - this.pendingLayoutEntries.delete(entry); entry.resizeObserver?.disconnect(); entry.detachHandlers(); entry.webview.remove(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index 5955300a5ae..4f8fa8c5c83 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -41,11 +41,7 @@ import type { PaneViewerData, TerminalPaneData, } from "../../types"; -import { - BrowserPane, - BrowserPaneToolbar, - browserRuntimeRegistry, -} from "./components/BrowserPane"; +import { BrowserPane, BrowserPaneToolbar } from "./components/BrowserPane"; import { ChatPane } from "./components/ChatPane"; import { ChatPaneTitle } from "./components/ChatPane/components/ChatPaneTitle"; import { CommentPane } from "./components/CommentPane"; @@ -420,9 +416,7 @@ export function usePaneRegistry({ renderToolbar: (ctx: RendererContext) => ( ), - onAfterClose: (pane) => { - browserRuntimeRegistry.destroy(pane.id); - }, + // Destruction handled by useGlobalBrowserLifecycle for now. contextMenuActions: (_ctx, defaults) => defaults.map((d) => d.key === "close-pane" ? { ...d, label: "Close Browser" } : d, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/useRendererStressWorkspaceBridge.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/useRendererStressWorkspaceBridge.ts index 3eb37cf5de1..44195fce473 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/useRendererStressWorkspaceBridge.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/useRendererStressWorkspaceBridge.ts @@ -10,7 +10,6 @@ import type { import { useEffect, useRef } from "react"; import { type TerminalRuntimeStressDebugInfo, - type TerminalWebglContextLossResult, terminalRuntimeRegistry, } from "renderer/lib/terminal/terminal-runtime-registry"; import type { StoreApi } from "zustand/vanilla"; @@ -81,7 +80,6 @@ interface RendererStressWorkspaceBridge { lines: number, payloadBytes: number, ) => Promise; - forceTerminalWebglContextLoss: () => TerminalWebglContextLossResult; getTerminalStressSummary: () => RendererStressTerminalSummary; releaseStressTerminalRuntimes: () => void; addRealTerminalTab: () => Promise; @@ -625,28 +623,6 @@ export function useRendererStressWorkspaceBridge({ byteLength: output.length, }; }, - forceTerminalWebglContextLoss: () => { - const result: TerminalWebglContextLossResult = { - terminalCount: 0, - canvasCount: 0, - webglContextCount: 0, - lostContextCount: 0, - unsupportedContextCount: 0, - }; - for (const ref of getStressTerminalRefs()) { - const partial = - terminalRuntimeRegistry.forceWebglContextLossForStress( - ref.terminalId, - ref.instanceId, - ); - result.terminalCount += partial.terminalCount; - result.canvasCount += partial.canvasCount; - result.webglContextCount += partial.webglContextCount; - result.lostContextCount += partial.lostContextCount; - result.unsupportedContextCount += partial.unsupportedContextCount; - } - return result; - }, getTerminalStressSummary: () => { const stressTerminalIds = stressTerminalIdsRef.current; const terminalRuntimes = terminalRuntimeRegistry diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index b0f45d802fb..25bd67ea8fa 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -27,7 +27,6 @@ import { useDefaultPaneActions } from "./hooks/useDefaultPaneActions"; import { useDirtyTabCloseGuard } from "./hooks/useDirtyTabCloseGuard"; import { usePaneRegistry } from "./hooks/usePaneRegistry"; import { renderBrowserTabIcon } from "./hooks/usePaneRegistry/components/BrowserPane"; -import { browserRuntimeRegistry } from "./hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry"; import { useRendererStressWorkspaceBridge } from "./hooks/useRendererStressWorkspaceBridge"; import { useV2PresetExecution } from "./hooks/useV2PresetExecution"; import { useV2TerminalLauncher } from "./hooks/useV2TerminalLauncher"; @@ -131,17 +130,6 @@ function V2WorkspaceContent({ } = useV2UserPreferences(); const showPresetsBar = v2UserPreferences.showPresetsBar; const { store } = useV2WorkspacePaneLayout(); - useEffect(() => { - return () => { - for (const tab of store.getState().tabs) { - for (const pane of Object.values(tab.panes)) { - if (pane.kind === "browser") { - browserRuntimeRegistry.detach(pane.id); - } - } - } - }; - }, [store]); useClearActivePaneAttention({ store }); const launcher = useV2TerminalLauncher(); const { matchedPresets, executePreset, resolvePresetCommands } = diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/FontSettingSection/hooks/useSystemFonts/useSystemFonts.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/FontSettingSection/hooks/useSystemFonts/useSystemFonts.ts index 612a5d8975a..633b928503e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/FontSettingSection/hooks/useSystemFonts/useSystemFonts.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/FontSettingSection/hooks/useSystemFonts/useSystemFonts.ts @@ -61,17 +61,6 @@ const KNOWN_MONO_SET = new Set([ ...WELL_KNOWN_NERD, ...REGISTERED_FONTS.map((f) => f.family), ]); -const FONT_DISCOVERY_BATCH_SIZE = 16; -const FONT_DISCOVERY_START_DELAY_MS = 1000; - -function yieldFontDiscoveryWork(): Promise { - if (typeof window.requestIdleCallback === "function") { - return new Promise((resolve) => { - window.requestIdleCallback(() => resolve(), { timeout: 100 }); - }); - } - return new Promise((resolve) => setTimeout(resolve, 0)); -} // Reuse a single canvas context for all font measurements let sharedCtx: CanvasRenderingContext2D | null = null; @@ -113,119 +102,94 @@ function classifyFont(family: string): FontCategory { return "other"; } -async function discoverSystemFonts(): Promise { +function isMonospaceByMeasurement(family: string): boolean { + const ctx = getCanvasCtx(); + if (!ctx) return false; + ctx.font = `16px "${family}"`; + const narrowWidth = ctx.measureText("iiiiii").width; + const wideWidth = ctx.measureText("MMMMMM").width; + return Math.abs(narrowWidth - wideWidth) < 1; +} + +function discoverSystemFonts(): FontInfo[] { const result: FontInfo[] = []; - let checked = 0; for (const family of WELL_KNOWN_NERD) { if (isFontAvailable(family)) { result.push({ family, category: "nerd" }); } - checked += 1; - if (checked % FONT_DISCOVERY_BATCH_SIZE === 0) { - await yieldFontDiscoveryWork(); - } } for (const family of WELL_KNOWN_MONO) { if (isFontAvailable(family)) { result.push({ family, category: "mono" }); } - checked += 1; - if (checked % FONT_DISCOVERY_BATCH_SIZE === 0) { - await yieldFontDiscoveryWork(); - } } return result; } let cachedFonts: FontInfo[] | null = null; -let fontDiscoveryPromise: Promise | null = null; -async function loadSystemFonts(): Promise { - if (cachedFonts) return cachedFonts; - if (fontDiscoveryPromise) return fontDiscoveryPromise; +export function useSystemFonts() { + const [fonts, setFonts] = useState(cachedFonts ?? []); + const [isLoading, setIsLoading] = useState(cachedFonts === null); - fontDiscoveryPromise = (async () => { - await document.fonts.ready; - await yieldFontDiscoveryWork(); + useEffect(() => { + if (cachedFonts) return; - const result: FontInfo[] = []; - const seen = new Set(); + let cancelled = false; - // Add registered @font-face fonts only if they loaded successfully. - for (const font of REGISTERED_FONTS) { - if (isFontAvailable(font.family)) { - result.push(font); - seen.add(font.family); - } - } + async function loadFonts() { + await document.fonts.ready; - for (const font of await discoverSystemFonts()) { - if (!seen.has(font.family)) { - seen.add(font.family); - result.push(font); - } - } + const result: FontInfo[] = []; + const seen = new Set(); - if (window.queryLocalFonts) { - try { - const fontData = await window.queryLocalFonts(); - let checked = 0; - for (const fd of fontData) { - if (seen.has(fd.family)) continue; - seen.add(fd.family); + // Add registered @font-face fonts only if they loaded successfully + for (const font of REGISTERED_FONTS) { + if (isFontAvailable(font.family)) { + result.push(font); + seen.add(font.family); + } + } - result.push({ family: fd.family, category: classifyFont(fd.family) }); + for (const font of discoverSystemFonts()) { + if (!seen.has(font.family)) { + seen.add(font.family); + result.push(font); + } + } - checked += 1; - if (checked % FONT_DISCOVERY_BATCH_SIZE === 0) { - await yieldFontDiscoveryWork(); + if (window.queryLocalFonts) { + try { + const fontData = await window.queryLocalFonts(); + for (const fd of fontData) { + if (seen.has(fd.family)) continue; + seen.add(fd.family); + + let category = classifyFont(fd.family); + if (category === "other" && isMonospaceByMeasurement(fd.family)) { + category = "mono"; + } + result.push({ family: fd.family, category }); } + } catch (err) { + console.warn("[useSystemFonts] queryLocalFonts failed:", err); } - } catch (err) { - console.warn("[useSystemFonts] queryLocalFonts failed:", err); } - } - result.sort((a, b) => a.family.localeCompare(b.family)); - cachedFonts = result; - return result; - })(); + result.sort((a, b) => a.family.localeCompare(b.family)); - try { - return await fontDiscoveryPromise; - } catch (error) { - fontDiscoveryPromise = null; - throw error; - } -} - -export function useSystemFonts() { - const [fonts, setFonts] = useState(cachedFonts ?? []); - const [isLoading, setIsLoading] = useState(cachedFonts === null); - - useEffect(() => { - if (cachedFonts) return; + cachedFonts = result; + if (!cancelled) { + setFonts(result); + setIsLoading(false); + } + } - let cancelled = false; - const timeoutId = window.setTimeout(() => { - void yieldFontDiscoveryWork() - .then(() => { - if (cancelled) return null; - return loadSystemFonts(); - }) - .then((result) => { - if (cancelled || !result) return; - setFonts(result); - setIsLoading(false); - }) - .catch((err) => { - if (!cancelled) setIsLoading(false); - console.warn("[useSystemFonts] Font loading failed:", err); - }); - }, FONT_DISCOVERY_START_DELAY_MS); + loadFonts().catch((err) => { + console.warn("[useSystemFonts] Font loading failed:", err); + }); return () => { cancelled = true; - window.clearTimeout(timeoutId); }; }, []); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/hooks/useSplitOrientation/useSplitOrientation.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/hooks/useSplitOrientation/useSplitOrientation.ts index 6b4eccc3b8c..a239d6e86fa 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/hooks/useSplitOrientation/useSplitOrientation.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/hooks/useSplitOrientation/useSplitOrientation.ts @@ -12,13 +12,14 @@ export function useSplitOrientation( const container = containerRef.current; if (!container) return; - const updateOrientation = ({ width, height }: DOMRectReadOnly) => { + const updateOrientation = () => { + const { width, height } = container.getBoundingClientRect(); setSplitOrientation(width >= height ? "vertical" : "horizontal"); }; - const resizeObserver = new ResizeObserver(([entry]) => { - if (entry) updateOrientation(entry.contentRect); - }); + updateOrientation(); + + const resizeObserver = new ResizeObserver(updateOrientation); resizeObserver.observe(container); return () => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index 61242a23609..bceb2c77739 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -4,12 +4,12 @@ import { FitAddon } from "@xterm/addon-fit"; import { LigaturesAddon } from "@xterm/addon-ligatures"; import { SearchAddon } from "@xterm/addon-search"; import { Unicode11Addon } from "@xterm/addon-unicode11"; +import { WebglAddon } from "@xterm/addon-webgl"; import type { ITheme } from "@xterm/xterm"; import { Terminal as XTerm } from "@xterm/xterm"; import type { DetectedLink } from "renderer/lib/terminal/links"; import { createTerminalImageAddon } from "renderer/lib/terminal/terminal-image-addon"; import { TerminalLinkManager } from "renderer/lib/terminal/terminal-link-manager"; -import { loadTerminalWebglAddon } from "renderer/lib/terminal/terminal-webgl-addon-controller"; import { electronTrpcClient as trpcClient } from "renderer/lib/trpc-client"; import { toXtermTheme } from "renderer/stores/theme/utils"; import { @@ -56,6 +56,9 @@ export function getDefaultTerminalBg(): string { return getDefaultTerminalTheme().background ?? "#151110"; } +// Once WebGL fails, skip it for all subsequent terminals (VS Code pattern). +let suggestedRendererType: "webgl" | "dom" | undefined; + export interface CreateTerminalOptions { /** * Workspace id used for worktree lookup during path stat/resolution. @@ -100,6 +103,9 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { const unicode11Addon = new Unicode11Addon(); const imageAddon = createTerminalImageAddon(); + let disposed = false; + let webglAddon: WebglAddon | null = null; + // Open into a detached wrapper div — not the live container. const wrapper = document.createElement("div"); wrapper.style.width = "100%"; @@ -118,7 +124,23 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { // Ligatures not supported by current font } - const webglAddon = loadTerminalWebglAddon(xterm); + // Defer WebGL to rAF to avoid racing xterm's post-open viewport sync. + const rafId = requestAnimationFrame(() => { + if (disposed || suggestedRendererType === "dom") return; + + try { + webglAddon = new WebglAddon(); + webglAddon.onContextLoss(() => { + webglAddon?.dispose(); + webglAddon = null; + xterm.refresh(0, xterm.rows - 1); + }); + xterm.loadAddon(webglAddon); + } catch { + suggestedRendererType = "dom"; + webglAddon = null; + } + }); const cleanupQuerySuppression = suppressQueryResponses(xterm); @@ -182,9 +204,14 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { wrapper, linkManager, cleanup: () => { + disposed = true; + cancelAnimationFrame(rafId); cleanupQuerySuppression(); linkManager.dispose(); - webglAddon.dispose(); + try { + webglAddon?.dispose(); + } catch {} + webglAddon = null; }, }; } diff --git a/apps/desktop/src/shared/workspace-run-definition.test.ts b/apps/desktop/src/shared/workspace-run-definition.test.ts index a093aedd3a9..117d1eb6120 100644 --- a/apps/desktop/src/shared/workspace-run-definition.test.ts +++ b/apps/desktop/src/shared/workspace-run-definition.test.ts @@ -84,64 +84,4 @@ describe("selectWorkspaceRunDefinition", () => { commands: ["npm run dev"], }); }); - - it("ignores non-string config commands instead of throwing", () => { - const definition = selectWorkspaceRunDefinition({ - projectId: "project-a", - configRunCommands: [ - { name: "Dev server", command: "bun dev" }, - "bun start", - ], - presets: [], - }); - - expect(definition).toEqual({ - source: "project-config", - projectId: "project-a", - commands: ["bun start"], - }); - }); - - it("falls back when all config commands are non-strings", () => { - const definition = selectWorkspaceRunDefinition({ - projectId: "project-a", - configRunCommands: [{ name: "Dev server", command: "bun dev" }], - presets: [ - { - id: "preset-global", - name: "Global dev", - commands: ["npm run dev"], - useAsWorkspaceRun: true, - }, - ], - }); - - expect(definition).toEqual({ - source: "terminal-preset", - presetId: "preset-global", - name: "Global dev", - commands: ["npm run dev"], - }); - }); - - it("ignores non-string preset commands instead of throwing", () => { - const definition = selectWorkspaceRunDefinition({ - projectId: "project-a", - presets: [ - { - id: "preset-global", - name: "Global dev", - commands: [{ name: "Dev server" }, "npm run dev"], - useAsWorkspaceRun: true, - }, - ], - }); - - expect(definition).toEqual({ - source: "terminal-preset", - presetId: "preset-global", - name: "Global dev", - commands: ["npm run dev"], - }); - }); }); diff --git a/apps/desktop/src/shared/workspace-run-definition.ts b/apps/desktop/src/shared/workspace-run-definition.ts index 19ba4ad7d86..16c1e44d045 100644 --- a/apps/desktop/src/shared/workspace-run-definition.ts +++ b/apps/desktop/src/shared/workspace-run-definition.ts @@ -21,17 +21,14 @@ export type WorkspaceRunDefinition = export interface WorkspaceRunPresetLike { id: string; name: string; - commands: unknown[]; + commands: string[]; cwd?: string; projectIds?: string[] | null; useAsWorkspaceRun?: boolean; } -function nonEmptyCommands(commands: readonly unknown[] | null | undefined) { - return (commands ?? []).filter( - (command): command is string => - typeof command === "string" && command.trim().length > 0, - ); +function nonEmptyCommands(commands: readonly string[] | null | undefined) { + return (commands ?? []).filter((command) => command.trim().length > 0); } function normalizeCwd(cwd: string | undefined): string | undefined { @@ -45,7 +42,7 @@ export function configRunToWorkspaceRun({ cwd, }: { projectId: string; - commands: readonly unknown[] | null | undefined; + commands: readonly string[] | null | undefined; cwd?: string; }): WorkspaceRunDefinition | null { const resolvedCommands = nonEmptyCommands(commands); @@ -80,7 +77,7 @@ export function selectWorkspaceRunDefinition({ configCwd, }: { presets: readonly WorkspaceRunPresetLike[]; - configRunCommands?: readonly unknown[] | null; + configRunCommands?: readonly string[] | null; projectId: string; configCwd?: string; }): WorkspaceRunDefinition | null { diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index 098ab18a113..41e8d331f56 100644 --- a/packages/host-service/src/app.ts +++ b/packages/host-service/src/app.ts @@ -215,7 +215,7 @@ export function createApp(options: CreateAppOptions): CreateAppResult { console.warn("[host-service] revokeAllSessions failed:", err); } try { - await pullRequestRuntime.stop(); + pullRequestRuntime.stop(); } catch (err) { console.warn("[host-service] pullRequestRuntime.stop failed:", err); } @@ -229,11 +229,6 @@ export function createApp(options: CreateAppOptions): CreateAppResult { } catch (err) { console.warn("[host-service] gitWatcher.close failed:", err); } - try { - await filesystem.close(); - } catch (err) { - console.warn("[host-service] filesystem.close failed:", err); - } if (ownsDb) { try { (db as unknown as { $client?: { close: () => void } }).$client?.close(); diff --git a/packages/host-service/src/runtime/pull-requests/pull-requests.test.ts b/packages/host-service/src/runtime/pull-requests/pull-requests.test.ts index c7ec18b33d2..68a73096f66 100644 --- a/packages/host-service/src/runtime/pull-requests/pull-requests.test.ts +++ b/packages/host-service/src/runtime/pull-requests/pull-requests.test.ts @@ -381,40 +381,3 @@ describe("PullRequestRuntimeManager direct checkout PR linking", () => { expect(state.workspace.pullRequestId).toBe("pr-existing"); }); }); - -describe("PullRequestRuntimeManager shutdown", () => { - test("stop waits for startup background work before returning", async () => { - const manager = createManager(makeState("main")); - let releaseSync: () => void = () => { - throw new Error("sync did not start"); - }; - let resolveStarted: () => void = () => {}; - const started = new Promise((resolve) => { - resolveStarted = resolve; - }); - let completed = false; - const internals = manager as unknown as { - syncWorkspaceBranches: () => Promise; - refreshEligibleProjects: () => Promise; - }; - internals.syncWorkspaceBranches = async () => { - resolveStarted(); - await new Promise((resolve) => { - releaseSync = resolve; - }); - completed = true; - }; - internals.refreshEligibleProjects = async () => {}; - - manager.start(); - await started; - - const stopPromise = manager.stop(); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(completed).toBe(false); - - releaseSync(); - await stopPromise; - expect(completed).toBe(true); - }); -}); diff --git a/packages/host-service/src/runtime/pull-requests/pull-requests.ts b/packages/host-service/src/runtime/pull-requests/pull-requests.ts index b043a33be49..37ee51783ee 100644 --- a/packages/host-service/src/runtime/pull-requests/pull-requests.ts +++ b/packages/host-service/src/runtime/pull-requests/pull-requests.ts @@ -271,12 +271,10 @@ export class PullRequestRuntimeManager { string, { running: Promise; rerunPending: boolean } >(); - private readonly backgroundTasks = new Set>(); private readonly pullRequestHeadCache = new Map< string, { promise: Promise; fetchedAt: number } >(); - private stopped = false; constructor(options: PullRequestRuntimeManagerOptions) { this.db = options.db; @@ -293,17 +291,12 @@ export class PullRequestRuntimeManager { this.unsubscribeFromGitWatcher ) return; - this.stopped = false; // One initial sweep so workspaces that existed before this manager // started have correct branch/sha/upstream rows even if no `.git/` // activity has happened since the last process boot. - this.runBackgroundTask("syncWorkspaceBranches", () => - this.syncWorkspaceBranches(), - ); - this.runBackgroundTask("refreshEligibleProjects", () => - this.refreshEligibleProjects(), - ); + void this.syncWorkspaceBranches(); + void this.refreshEligibleProjects(); // Steady-state: react to real `.git/` activity per workspace. Per-workspace // debounce lives in `GitWatcher` (300 ms), and concurrent project refreshes @@ -311,60 +304,25 @@ export class PullRequestRuntimeManager { // workspace so two debounce-separated bursts can't race their git reads // and have the slower one overwrite the newer snapshot. this.unsubscribeFromGitWatcher = this.gitWatcher.onChanged((event) => { - if (!this.stopped) void this.enqueueWorkspaceSync(event.workspaceId); + void this.enqueueWorkspaceSync(event.workspaceId); }); // Long-cadence safety net for `GitWatcher` overflow / error paths. this.safetyNetTimer = setInterval(() => { - this.runBackgroundTask("syncWorkspaceBranches", () => - this.syncWorkspaceBranches(), - ); + void this.syncWorkspaceBranches(); }, SAFETY_NET_INTERVAL_MS); this.projectRefreshTimer = setInterval(() => { - this.runBackgroundTask("refreshEligibleProjects", () => - this.refreshEligibleProjects(), - ); + void this.refreshEligibleProjects(); }, PROJECT_REFRESH_INTERVAL_MS); } - async stop(): Promise { - this.stopped = true; + stop() { if (this.safetyNetTimer) clearInterval(this.safetyNetTimer); if (this.projectRefreshTimer) clearInterval(this.projectRefreshTimer); this.unsubscribeFromGitWatcher?.(); this.safetyNetTimer = null; this.projectRefreshTimer = null; this.unsubscribeFromGitWatcher = null; - await this.drainInFlightWork(); - this.pullRequestHeadCache.clear(); - } - - private runBackgroundTask(label: string, task: () => Promise): void { - const promise = task() - .catch((error) => { - console.warn(`[host-service:pull-request-runtime] ${label} failed`, { - error, - }); - }) - .finally(() => { - this.backgroundTasks.delete(promise); - }); - this.backgroundTasks.add(promise); - } - - private async drainInFlightWork(): Promise { - for (let pass = 0; pass < 10; pass++) { - const tasks = [ - ...this.backgroundTasks, - ...Array.from( - this.workspaceSyncState.values(), - (state) => state.running, - ), - ...this.inFlightProjects.values(), - ]; - if (tasks.length === 0) return; - await Promise.allSettled(tasks); - } } async getPullRequestsByWorkspaces( @@ -492,7 +450,6 @@ export class PullRequestRuntimeManager { } private async syncWorkspaceBranches(): Promise { - if (this.stopped) return; // Route every workspace through the same per-workspace queue as the // watcher path, so a concurrent watcher-triggered sync can't race the // sweep's read+write and clobber the newer snapshot. enqueueWorkspaceSync @@ -504,13 +461,11 @@ export class PullRequestRuntimeManager { // original sweep's behavior. refreshProject inside each sync still // dedupes across workspaces in the same project via inFlightProjects. for (const row of ids) { - if (this.stopped) break; await this.enqueueWorkspaceSync(row.id); } } private enqueueWorkspaceSync(workspaceId: string): Promise { - if (this.stopped) return Promise.resolve(); // Coalesce: if a sync is already running for this workspace, just mark // "rerun pending" — there's no value in queuing N back-to-back syncs // when only the final state matters. At most one sync runs and one @@ -614,7 +569,6 @@ export class PullRequestRuntimeManager { } private async refreshEligibleProjects(): Promise { - if (this.stopped) return; const rows = this.db .select({ projectId: workspaces.projectId, diff --git a/packages/host-service/src/trpc/router/git/utils/config-write.ts b/packages/host-service/src/trpc/router/git/utils/config-write.ts index cb17d9c712e..a76a0f99c46 100644 --- a/packages/host-service/src/trpc/router/git/utils/config-write.ts +++ b/packages/host-service/src/trpc/router/git/utils/config-write.ts @@ -1,3 +1,5 @@ +import type { SimpleGit } from "simple-git"; + /** * Run a `git config` write with bounded retries on `.git/config.lock` * contention. @@ -13,12 +15,8 @@ * second writer just waits its turn instead of bubbling a confusing 500 * to the renderer. */ -interface GitConfigWriter { - raw(args: string[]): Promise; -} - export async function gitConfigWrite( - git: GitConfigWriter, + git: SimpleGit, args: string[], options: { retries?: number; baseDelayMs?: number } = {}, ): Promise { diff --git a/packages/host-service/src/trpc/router/project/utils/ensure-main-workspace.ts b/packages/host-service/src/trpc/router/project/utils/ensure-main-workspace.ts index 8a25c61fb79..122e2b668d5 100644 --- a/packages/host-service/src/trpc/router/project/utils/ensure-main-workspace.ts +++ b/packages/host-service/src/trpc/router/project/utils/ensure-main-workspace.ts @@ -8,12 +8,6 @@ export type EnsureMainWorkspaceContext = Pick< "api" | "db" | "git" | "organizationId" >; -const mainWorkspaceEnsuresInFlight = new Map>(); - -function mainWorkspaceEnsureKey(projectId: string, repoPath: string): string { - return `${projectId}\0${repoPath}`; -} - async function getCurrentBranchName( git: Awaited>, ): Promise { @@ -64,30 +58,6 @@ export async function ensureMainWorkspaceStrict( ctx: EnsureMainWorkspaceContext, projectId: string, repoPath: string, -): Promise<{ id: string }> { - const key = mainWorkspaceEnsureKey(projectId, repoPath); - const inFlight = mainWorkspaceEnsuresInFlight.get(key); - if (inFlight) return await inFlight; - - const promise = ensureMainWorkspaceStrictUncoalesced( - ctx, - projectId, - repoPath, - ); - mainWorkspaceEnsuresInFlight.set(key, promise); - try { - return await promise; - } finally { - if (mainWorkspaceEnsuresInFlight.get(key) === promise) { - mainWorkspaceEnsuresInFlight.delete(key); - } - } -} - -async function ensureMainWorkspaceStrictUncoalesced( - ctx: EnsureMainWorkspaceContext, - projectId: string, - repoPath: string, ): Promise<{ id: string }> { const git = await ctx.git(repoPath); const branch = await getCurrentBranchName(git); diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/adopt-existing-worktree.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/adopt-existing-worktree.ts index 95ad5def667..8b898047423 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/shared/adopt-existing-worktree.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/adopt-existing-worktree.ts @@ -295,7 +295,7 @@ async function recordBaseBranch( baseBranch: string | undefined, ): Promise { if (!baseBranch) return; - await gitConfigWrite(git, [ + await gitConfigWrite(git as Parameters[0], [ "config", `branch.${branch}.base`, baseBranch, diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/git-config.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/git-config.ts index 2bb9d6eb687..296722f4d56 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/shared/git-config.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/git-config.ts @@ -1,4 +1,3 @@ -import { gitConfigWrite } from "../../git/utils/config-write"; import type { GitClient } from "./types"; export async function enablePushAutoSetupRemote( @@ -6,14 +5,16 @@ export async function enablePushAutoSetupRemote( worktreePath: string, logPrefix: string, ): Promise { - await gitConfigWrite(git, [ - "-C", - worktreePath, - "config", - "--local", - "push.autoSetupRemote", - "true", - ]).catch((err) => { - console.warn(`${logPrefix} failed to set push.autoSetupRemote:`, err); - }); + await git + .raw([ + "-C", + worktreePath, + "config", + "--local", + "push.autoSetupRemote", + "true", + ]) + .catch((err) => { + console.warn(`${logPrefix} failed to set push.autoSetupRemote:`, err); + }); } diff --git a/packages/host-service/src/trpc/router/workspaces/workspaces.ts b/packages/host-service/src/trpc/router/workspaces/workspaces.ts index cfabd8dfc1a..323a2832dba 100644 --- a/packages/host-service/src/trpc/router/workspaces/workspaces.ts +++ b/packages/host-service/src/trpc/router/workspaces/workspaces.ts @@ -15,7 +15,6 @@ import { import type { HostServiceContext } from "../../../types"; import { protectedProcedure, router } from "../../index"; import { type AgentRunResult, runAgentInWorkspace } from "../agents"; -import { gitConfigWrite } from "../git/utils/config-write"; import { ensureMainWorkspace } from "../project/utils/ensure-main-workspace"; import { adoptExistingWorktree } from "../workspace-creation/shared/adopt-existing-worktree"; import { @@ -351,18 +350,20 @@ async function recordBaseBranchConfig(args: { branch: string; baseBranch: string; }): Promise { - await gitConfigWrite(args.git, [ - "-C", - args.worktreePath, - "config", - `branch.${args.branch}.base`, - args.baseBranch, - ]).catch((err) => { - console.warn( - `[workspaces.create] failed to record base branch ${args.baseBranch}:`, - err, - ); - }); + await args.git + .raw([ + "-C", + args.worktreePath, + "config", + `branch.${args.branch}.base`, + args.baseBranch, + ]) + .catch((err) => { + console.warn( + `[workspaces.create] failed to record base branch ${args.baseBranch}:`, + err, + ); + }); } /** @@ -908,19 +909,18 @@ export const workspacesRouter = router({ if (!plan.usedExistingBranch && plan.startPoint.kind !== "head") { const baseShortName = plan.startPoint.shortName; - await gitConfigWrite(git, [ - "-C", - worktreePath, - "config", - "--local", - `branch.${resolvedBranch}.base`, - baseShortName, - ]).catch((err) => { - console.warn( - `[workspaces.create] failed to record base branch ${baseShortName}:`, - err, - ); - }); + await git + .raw([ + "config", + `branch.${resolvedBranch}.base`, + baseShortName, + ]) + .catch((err) => { + console.warn( + `[workspaces.create] failed to record base branch ${baseShortName}:`, + err, + ); + }); } workspaceRow = await registerCloudAndLocal({ diff --git a/packages/host-service/test/integration/bug-hunt-3.integration.test.ts b/packages/host-service/test/integration/bug-hunt-3.integration.test.ts index baa0d778271..7ef51694722 100644 --- a/packages/host-service/test/integration/bug-hunt-3.integration.test.ts +++ b/packages/host-service/test/integration/bug-hunt-3.integration.test.ts @@ -238,7 +238,7 @@ describe("bug-hunt-3: more concurrency probes", () => { repo.dispose(); }); - test("parallel workspace.create calls for different branches retry .git/config lock contention", async () => { + test("BUG: parallel workspace.create calls for different branches can race on the same .git/config", async () => { host = await createTestHost({ apiOverrides: { "host.ensure.mutate": () => ({ machineId: "m1" }), @@ -261,29 +261,18 @@ describe("bug-hunt-3: more concurrency probes", () => { // Two different branches in parallel — they both call // `git worktree add` and `git branch..base` writes via // ensureMainWorkspace / inside the procedure. - const warnings: string[] = []; - const originalWarn = console.warn; - console.warn = (...args: unknown[]) => { - warnings.push(args.map(String).join(" ")); - originalWarn(...args); - }; - let results: PromiseSettledResult[] = []; - try { - results = await Promise.allSettled([ - host.trpc.workspaces.create.mutate({ - projectId, - name: "a", - branch: "feature/a", - }), - host.trpc.workspaces.create.mutate({ - projectId, - name: "b", - branch: "feature/b", - }), - ]); - } finally { - console.warn = originalWarn; - } + const results = await Promise.allSettled([ + host.trpc.workspaces.create.mutate({ + projectId, + name: "a", + branch: "feature/a", + }), + host.trpc.workspaces.create.mutate({ + projectId, + name: "b", + branch: "feature/b", + }), + ]); // Document current behavior. If both succeed, great — we have no // bug. If one fails with a config-lock or worktree-lock error, @@ -298,13 +287,5 @@ describe("bug-hunt-3: more concurrency probes", () => { // We currently expect this to be tolerated. If it starts failing, // flip to a `test.todo` documenting the regression. expect(failures.length).toBe(0); - expect( - warnings.some( - (warning) => - warning.includes("could not lock config file") || - warning.includes("failed to record base branch") || - warning.includes("failed to set push.autoSetupRemote"), - ), - ).toBe(false); }); }); diff --git a/packages/host-service/test/integration/workspace-cleanup.integration.test.ts b/packages/host-service/test/integration/workspace-cleanup.integration.test.ts index 390ea3b20c9..f8f4fccdeba 100644 --- a/packages/host-service/test/integration/workspace-cleanup.integration.test.ts +++ b/packages/host-service/test/integration/workspace-cleanup.integration.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; -import { existsSync, rmSync, writeFileSync } from "node:fs"; +import { rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { TRPCClientError } from "@trpc/client"; import { eq } from "drizzle-orm"; @@ -192,58 +192,6 @@ describe("workspaceCleanup.destroy integration", () => { expect(worktreeList).toContain(otherWorktreePath); }); - test("concurrent destroy of the same workspace rejects the second call and completes cleanup", async () => { - await scenario.dispose(); - - let releaseCloudDelete!: () => void; - const cloudDeleteStarted = Promise.withResolvers(); - const cloudDeleteRelease = new Promise((resolve) => { - releaseCloudDelete = resolve; - }); - scenario = await createFeatureWorktreeScenario({ - hostOptions: { - apiOverrides: { - "v2Workspace.getFromHost.query": cloudOk.workspaceGetFromHost({ - type: "feature", - }), - "v2Workspace.delete.mutate": async () => { - cloudDeleteStarted.resolve(); - await cloudDeleteRelease; - return { success: true }; - }, - }, - }, - }); - - const first = scenario.host.trpc.workspaceCleanup.destroy.mutate({ - workspaceId: scenario.featureWorkspaceId, - deleteBranch: true, - force: true, - }); - await cloudDeleteStarted.promise; - - await expect( - scenario.host.trpc.workspaceCleanup.destroy.mutate({ - workspaceId: scenario.featureWorkspaceId, - force: true, - }), - ).rejects.toThrow(/Deletion already in progress/); - - releaseCloudDelete(); - const result = await first; - expect(result.success).toBe(true); - expect(result.worktreeRemoved).toBe(true); - expect(result.branchDeleted).toBe(true); - expect(existsSync(scenario.worktreePath)).toBe(false); - - const remaining = scenario.host.db - .select() - .from(workspaces) - .where(eq(workspaces.id, scenario.featureWorkspaceId)) - .all(); - expect(remaining).toHaveLength(0); - }); - test("returns success when no local workspace row exists, still calls cloud delete", async () => { await scenario.dispose(); const fresh = await createBasicScenario({ diff --git a/packages/host-service/test/integration/workspace-create-delete.integration.test.ts b/packages/host-service/test/integration/workspace-create-delete.integration.test.ts index bd7f7d18e47..8c8db4824f7 100644 --- a/packages/host-service/test/integration/workspace-create-delete.integration.test.ts +++ b/packages/host-service/test/integration/workspace-create-delete.integration.test.ts @@ -287,84 +287,6 @@ describe("workspace.create + workspace.delete integration", () => { ).toBe(true); }); - test("parallel create() then destroy() churn leaves no duplicate rows or stale worktrees", async () => { - const scenario = await createProjectScenario({ - hostOptions: { - apiOverrides: { - ...cloudFlows.workspaceCreateOk(), - ...cloudFlows.workspaceDeleteOk(), - }, - }, - }); - dispose = scenario.dispose; - - const branches = ["feature/churn-a", "feature/churn-b", "feature/churn-c"]; - const createResults = await Promise.all( - branches.map((branch) => - scenario.host.trpc.workspaces.create.mutate({ - projectId: scenario.projectId, - name: branch, - branch, - }), - ), - ); - - const createdRows = createResults.map((result) => result.workspace); - expect(createdRows.map((row) => row.branch).sort()).toEqual( - branches.toSorted(), - ); - - const rowsAfterCreate = scenario.host.db.select().from(workspaces).all(); - const featureRows = rowsAfterCreate.filter((row) => - branches.includes(row.branch), - ); - const mainRows = rowsAfterCreate.filter( - (row) => row.worktreePath === scenario.repo.repoPath, - ); - expect(featureRows).toHaveLength(branches.length); - expect(mainRows).toHaveLength(1); - for (const row of featureRows) { - expect(existsSync(row.worktreePath)).toBe(true); - } - - const destroyResults = await Promise.all( - createdRows.map((row) => - scenario.host.trpc.workspaceCleanup.destroy.mutate({ - workspaceId: row.id, - deleteBranch: true, - force: true, - }), - ), - ); - expect(destroyResults.every((result) => result.success)).toBe(true); - - const rowsAfterDestroy = scenario.host.db.select().from(workspaces).all(); - expect( - rowsAfterDestroy.filter((row) => branches.includes(row.branch)), - ).toHaveLength(0); - expect( - rowsAfterDestroy.filter( - (row) => row.worktreePath === scenario.repo.repoPath, - ), - ).toHaveLength(1); - for (const row of featureRows) { - expect(existsSync(row.worktreePath)).toBe(false); - } - - const worktreeList = await scenario.repo.git.raw([ - "worktree", - "list", - "--porcelain", - ]); - for (const row of featureRows) { - expect(worktreeList).not.toContain(row.worktreePath); - } - const localBranches = await scenario.repo.git.branchLocal(); - for (const branch of branches) { - expect(localBranches.all).not.toContain(branch); - } - }); - test("delete() requires authentication", async () => { const scenario = await createBasicScenario(); dispose = scenario.dispose; diff --git a/packages/panes/src/react/components/Workspace/Workspace.tsx b/packages/panes/src/react/components/Workspace/Workspace.tsx index 807a4beeb83..370d9b668d3 100644 --- a/packages/panes/src/react/components/Workspace/Workspace.tsx +++ b/packages/panes/src/react/components/Workspace/Workspace.tsx @@ -40,11 +40,7 @@ export function Workspace({ } for (const [prevId, prevPane] of previousPanesRef.current) { if (!current.has(prevId)) { - try { - registry[prevPane.kind]?.onAfterClose?.(prevPane); - } catch (err) { - console.error("onAfterClose threw", err); - } + registry[prevPane.kind]?.onAfterClose?.(prevPane); } } previousPanesRef.current = current; diff --git a/packages/panes/src/react/components/Workspace/components/TabBar/TabBar.tsx b/packages/panes/src/react/components/Workspace/components/TabBar/TabBar.tsx index 0ffdcb8753d..4d3c117d869 100644 --- a/packages/panes/src/react/components/Workspace/components/TabBar/TabBar.tsx +++ b/packages/panes/src/react/components/Workspace/components/TabBar/TabBar.tsx @@ -4,14 +4,12 @@ import { DropdownMenuContent, DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; +import { OverflowFadeContainer } from "@superset/ui/overflow-fade-container"; import { PlusIcon } from "lucide-react"; import { + type ComponentProps, type ReactNode, - type UIEvent, useCallback, - useEffect, - useLayoutEffect, - useMemo, useRef, useState, } from "react"; @@ -20,7 +18,7 @@ import type { Tab } from "../../../../../types"; import type { PaneRegistry } from "../../../../types"; import { PANE_DRAG_TYPE } from "../Tab/components/Pane/components/PaneHeader"; import { TAB_DRAG_TYPE, TabItem } from "./components/TabItem"; -import { computeInsertIndex, getVisibleTabWindow, TAB_WIDTH } from "./utils"; +import { computeInsertIndex, TAB_WIDTH } from "./utils"; interface TabBarProps { tabs: Tab[]; @@ -89,12 +87,7 @@ export function TabBar({ renderTabAccessory, }: TabBarProps) { const tabsTrackRef = useRef(null); - const scrollContainerRef = useRef(null); - const scrollMetricsFrameRef = useRef(null); - const [scrollMetrics, setScrollMetrics] = useState({ - clientWidth: 0, - scrollLeft: 0, - }); + const [hasHorizontalOverflow, setHasHorizontalOverflow] = useState(false); const insertIndexRef = useRef(null); const [insertIndex, setInsertIndex] = useState(null); @@ -149,119 +142,6 @@ export function TabBar({ [tabs, onReorderTab, onMovePaneToNewTab], ); - const readScrollMetrics = useCallback(() => { - const node = scrollContainerRef.current; - if (!node) return; - const nextMetrics = { - clientWidth: node.clientWidth, - scrollLeft: node.scrollLeft, - }; - setScrollMetrics((currentMetrics) => - currentMetrics.clientWidth === nextMetrics.clientWidth && - currentMetrics.scrollLeft === nextMetrics.scrollLeft - ? currentMetrics - : nextMetrics, - ); - }, []); - - const scheduleScrollMetricsUpdate = useCallback(() => { - if (scrollMetricsFrameRef.current !== null) return; - if (typeof requestAnimationFrame !== "function") { - readScrollMetrics(); - return; - } - scrollMetricsFrameRef.current = requestAnimationFrame(() => { - scrollMetricsFrameRef.current = null; - readScrollMetrics(); - }); - }, [readScrollMetrics]); - - const handleScroll = useCallback( - (_event: UIEvent) => { - scheduleScrollMetricsUpdate(); - }, - [scheduleScrollMetricsUpdate], - ); - - useEffect( - () => () => { - if ( - scrollMetricsFrameRef.current !== null && - typeof cancelAnimationFrame === "function" - ) { - cancelAnimationFrame(scrollMetricsFrameRef.current); - scrollMetricsFrameRef.current = null; - } - }, - [], - ); - - useLayoutEffect(() => { - const node = scrollContainerRef.current; - if (!node) return; - - readScrollMetrics(); - const resizeObserver = new ResizeObserver(scheduleScrollMetricsUpdate); - resizeObserver.observe(node); - - return () => { - resizeObserver.disconnect(); - }; - }, [readScrollMetrics, scheduleScrollMetricsUpdate]); - - useLayoutEffect(() => { - readScrollMetrics(); - }, [readScrollMetrics]); - - const activeTabIndex = useMemo( - () => tabs.findIndex((tab) => tab.id === activeTabId), - [activeTabId, tabs], - ); - const totalTabsWidth = tabs.length * TAB_WIDTH; - const addTabButtonWidth = 40; - const hasHorizontalOverflow = - scrollMetrics.clientWidth > 0 && - totalTabsWidth + addTabButtonWidth > scrollMetrics.clientWidth + 1; - const inlineAddTabWidth = hasHorizontalOverflow ? 0 : addTabButtonWidth; - const tabsTrackWidth = totalTabsWidth + inlineAddTabWidth; - - useLayoutEffect(() => { - const node = scrollContainerRef.current; - const viewportWidth = scrollMetrics.clientWidth; - if (!node || activeTabIndex < 0 || viewportWidth <= 0) return; - - const tabLeft = activeTabIndex * TAB_WIDTH; - const tabRight = tabLeft + TAB_WIDTH; - const viewportLeft = scrollMetrics.scrollLeft; - const viewportRight = viewportLeft + viewportWidth; - let nextScrollLeft = viewportLeft; - - if (tabLeft < viewportLeft) { - nextScrollLeft = tabLeft; - } else if (tabRight > viewportRight) { - nextScrollLeft = tabRight - viewportWidth; - } - - if (nextScrollLeft === viewportLeft) return; - - const boundedScrollLeft = Math.max( - 0, - Math.min(nextScrollLeft, tabsTrackWidth - viewportWidth), - ); - node.scrollLeft = boundedScrollLeft; - setScrollMetrics((currentMetrics) => - currentMetrics.clientWidth === viewportWidth && - currentMetrics.scrollLeft === boundedScrollLeft - ? currentMetrics - : { clientWidth: viewportWidth, scrollLeft: boundedScrollLeft }, - ); - }, [ - activeTabIndex, - scrollMetrics.clientWidth, - scrollMetrics.scrollLeft, - tabsTrackWidth, - ]); - // Clear indicator when cursor leaves the tab bar if (!isOver && insertIndexRef.current !== null) { insertIndexRef.current = null; @@ -275,30 +155,15 @@ export function TabBar({ [connectDrop], ); - const setScrollContainerRef = useCallback((node: HTMLDivElement | null) => { - scrollContainerRef.current = node; + const handleOverflowChange = useCallback< + NonNullable< + ComponentProps["onOverflowChange"] + > + >((state) => { + setHasHorizontalOverflow(state.hasOverflowX); }, []); const insertLineLeft = insertIndex !== null ? insertIndex * TAB_WIDTH : null; - const visibleTabWindow = useMemo( - () => - getVisibleTabWindow({ - clientWidth: scrollMetrics.clientWidth, - scrollLeft: scrollMetrics.scrollLeft, - tabCount: tabs.length, - }), - [scrollMetrics.clientWidth, scrollMetrics.scrollLeft, tabs.length], - ); - const visibleTabs = useMemo( - () => - tabs - .slice(visibleTabWindow.start, visibleTabWindow.end) - .map((tab, offset) => ({ - index: visibleTabWindow.start + offset, - tab, - })), - [tabs, visibleTabWindow], - ); if (tabs.length === 0) { return ( @@ -324,27 +189,23 @@ export function TabBar({ ref={setRootRef} className="group/root-tabs flex h-10 min-w-0 shrink-0 items-stretch border-b border-border bg-background" > -
-
- {visibleTabs.map(({ tab, index }) => ( +
+ {tabs.map((tab, i) => (
onSelectTab(tab.id)} onClose={() => onCloseTab(tab.id)} @@ -363,15 +224,12 @@ export function TabBar({ /> )} {!hasHorizontalOverflow && ( -
+
)}
-
+ {hasHorizontalOverflow && (
diff --git a/packages/panes/src/react/components/Workspace/components/TabBar/components/TabItem/TabItem.tsx b/packages/panes/src/react/components/Workspace/components/TabBar/components/TabItem/TabItem.tsx index 8f64793493f..935b9431f42 100644 --- a/packages/panes/src/react/components/Workspace/components/TabBar/components/TabItem/TabItem.tsx +++ b/packages/panes/src/react/components/Workspace/components/TabBar/components/TabItem/TabItem.tsx @@ -6,10 +6,11 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "@superset/ui/context-menu"; +import { OverflowFadeText } from "@superset/ui/overflow-fade-text"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { PencilIcon, XIcon } from "lucide-react"; -import { memo, type ReactNode, useCallback, useRef, useState } from "react"; +import { type ReactNode, useCallback, useRef, useState } from "react"; import { useDrag, useDrop } from "react-dnd"; import type { Tab } from "../../../../../../../types"; import type { PaneRegistry } from "../../../../../../types"; @@ -34,7 +35,7 @@ interface TabItemProps { accessory?: ReactNode; } -function TabItemComponent({ +export function TabItem({ tab, tabs, registry, @@ -155,7 +156,9 @@ function TabItemComponent({ type="button" > {icon && {icon}} - {title} + + {title} + @@ -214,21 +217,3 @@ function TabItemComponent({ ); } - -function areTabItemPropsEqual( - previous: TabItemProps, - next: TabItemProps, -) { - return ( - previous.tab === next.tab && - previous.tabs === next.tabs && - previous.registry === next.registry && - previous.index === next.index && - previous.isActive === next.isActive - ); -} - -export const TabItem = memo( - TabItemComponent, - areTabItemPropsEqual, -) as typeof TabItemComponent; diff --git a/packages/panes/src/react/components/Workspace/components/TabBar/utils/index.ts b/packages/panes/src/react/components/Workspace/components/TabBar/utils/index.ts index 5537a977da1..a4f01d7c568 100644 --- a/packages/panes/src/react/components/Workspace/components/TabBar/utils/index.ts +++ b/packages/panes/src/react/components/Workspace/components/TabBar/utils/index.ts @@ -1,7 +1 @@ -export { - computeInsertIndex, - getVisibleTabWindow, - TAB_WIDTH, - TAB_WINDOW_OVERSCAN, - TAB_WINDOWING_THRESHOLD, -} from "./utils"; +export { computeInsertIndex, TAB_WIDTH } from "./utils"; diff --git a/packages/panes/src/react/components/Workspace/components/TabBar/utils/utils.test.ts b/packages/panes/src/react/components/Workspace/components/TabBar/utils/utils.test.ts deleted file mode 100644 index 31eb548e440..00000000000 --- a/packages/panes/src/react/components/Workspace/components/TabBar/utils/utils.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { getVisibleTabWindow, TAB_WIDTH } from "./utils"; - -describe("getVisibleTabWindow", () => { - it("renders all tabs below the windowing threshold", () => { - expect( - getVisibleTabWindow({ - clientWidth: TAB_WIDTH * 2, - scrollLeft: TAB_WIDTH * 3, - tabCount: 12, - }), - ).toEqual({ start: 0, end: 12 }); - }); - - it("limits large tab sets to the visible range plus overscan", () => { - expect( - getVisibleTabWindow({ - clientWidth: TAB_WIDTH * 3, - overscan: 2, - scrollLeft: TAB_WIDTH * 10, - tabCount: 80, - }), - ).toEqual({ start: 8, end: 15 }); - }); - - it("falls back to the initial window before layout has measured", () => { - expect( - getVisibleTabWindow({ - clientWidth: 0, - scrollLeft: 0, - tabCount: 80, - windowingThreshold: 24, - }), - ).toEqual({ start: 0, end: 24 }); - }); -}); diff --git a/packages/panes/src/react/components/Workspace/components/TabBar/utils/utils.ts b/packages/panes/src/react/components/Workspace/components/TabBar/utils/utils.ts index 6ca8324fdf6..d3cd75e1d07 100644 --- a/packages/panes/src/react/components/Workspace/components/TabBar/utils/utils.ts +++ b/packages/panes/src/react/components/Workspace/components/TabBar/utils/utils.ts @@ -1,43 +1,4 @@ export const TAB_WIDTH = 160; -export const TAB_WINDOWING_THRESHOLD = 12; -export const TAB_WINDOW_OVERSCAN = 4; - -interface VisibleTabWindowInput { - clientWidth: number; - overscan?: number; - scrollLeft: number; - tabCount: number; - windowingThreshold?: number; -} - -export function getVisibleTabWindow({ - clientWidth, - overscan = TAB_WINDOW_OVERSCAN, - scrollLeft, - tabCount, - windowingThreshold = TAB_WINDOWING_THRESHOLD, -}: VisibleTabWindowInput): { end: number; start: number } { - if (tabCount <= 0) { - return { start: 0, end: 0 }; - } - - if (tabCount <= windowingThreshold) { - return { start: 0, end: tabCount }; - } - - if (clientWidth <= 0) { - return { start: 0, end: Math.min(tabCount, windowingThreshold) }; - } - - const boundedScrollLeft = Math.max(0, scrollLeft); - const visibleStart = Math.floor(boundedScrollLeft / TAB_WIDTH); - const visibleEnd = Math.ceil((boundedScrollLeft + clientWidth) / TAB_WIDTH); - - return { - start: Math.max(0, visibleStart - overscan), - end: Math.min(tabCount, visibleEnd + overscan), - }; -} export function computeInsertIndex( clientX: number, diff --git a/packages/shared/src/host-info.ts b/packages/shared/src/host-info.ts index 268e24f5b3d..449162efb29 100644 --- a/packages/shared/src/host-info.ts +++ b/packages/shared/src/host-info.ts @@ -6,7 +6,6 @@ import { homedir, hostname, platform } from "node:os"; // Salt value preserved verbatim across the rename to keep existing host ids // stable for users already registered against the cloud. const APP_HOST_SALT = "superset-desktop-device-id-v1"; -const MACHINE_ID_COMMAND_TIMEOUT_MS = 1500; function getRawMachineId(): string { try { @@ -16,7 +15,7 @@ function getRawMachineId(): string { const output = execFileSync( "ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], - { encoding: "utf8", timeout: MACHINE_ID_COMMAND_TIMEOUT_MS }, + { encoding: "utf8" }, ); const match = output.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/); if (match?.[1]) return match[1]; @@ -35,7 +34,7 @@ function getRawMachineId(): string { "/v", "MachineGuid", ], - { encoding: "utf8", timeout: MACHINE_ID_COMMAND_TIMEOUT_MS }, + { encoding: "utf8" }, ); const match = output.match(/MachineGuid\s+REG_SZ\s+(\S+)/); if (match?.[1]) return match[1]; diff --git a/packages/ui/src/components/overflow-fade/OverflowFadeContainer/OverflowFadeContainer.tsx b/packages/ui/src/components/overflow-fade/OverflowFadeContainer/OverflowFadeContainer.tsx index 7aac864e912..3870c8e0a0a 100644 --- a/packages/ui/src/components/overflow-fade/OverflowFadeContainer/OverflowFadeContainer.tsx +++ b/packages/ui/src/components/overflow-fade/OverflowFadeContainer/OverflowFadeContainer.tsx @@ -28,11 +28,6 @@ interface OverflowFadeContainerProps extends ComponentProps<"div"> { * scrollers such as tabs; avoid on large or virtualized lists without profiling. */ observeChildren?: boolean; - /** - * Re-measure overflow when the caller knows scrollable content changed - * without relying on observing every child node. - */ - measureKey?: unknown; } export function OverflowFadeContainer({ @@ -41,7 +36,6 @@ export function OverflowFadeContainer({ fadeEdges = DEFAULT_FADE_EDGES, onOverflowChange, observeChildren = false, - measureKey, ...props }: OverflowFadeContainerProps) { const { @@ -52,7 +46,7 @@ export function OverflowFadeContainer({ canScrollRight, canScrollBottom, canScrollLeft, - } = useOverflowFade({ measureKey, observeChildren }); + } = useOverflowFade({ observeChildren }); const setRef = (node: HTMLDivElement | null) => { ref.current = node; diff --git a/packages/ui/src/hooks/use-overflow-fade.ts b/packages/ui/src/hooks/use-overflow-fade.ts index d38579b0c20..7e8dabf51dc 100644 --- a/packages/ui/src/hooks/use-overflow-fade.ts +++ b/packages/ui/src/hooks/use-overflow-fade.ts @@ -3,7 +3,6 @@ import { useCallback, useLayoutEffect, useRef, useState } from "react"; interface UseOverflowFadeOptions { - measureKey?: unknown; observeChildren?: boolean; observeParent?: boolean; } @@ -55,15 +54,13 @@ function areOverflowStatesEqual( } export function useOverflowFade({ - measureKey, observeChildren = false, observeParent = false, }: UseOverflowFadeOptions = {}) { const ref = useRef(null); - const frameIdRef = useRef(null); const [state, setState] = useState(INITIAL_STATE); - const measureOverflow = useCallback(() => { + const updateOverflow = useCallback(() => { const node = ref.current; if (!node) return; @@ -75,18 +72,6 @@ export function useOverflowFade({ ); }, []); - const updateOverflow = useCallback(() => { - if (frameIdRef.current !== null) return; - if (typeof requestAnimationFrame !== "function") { - measureOverflow(); - return; - } - frameIdRef.current = requestAnimationFrame(() => { - frameIdRef.current = null; - measureOverflow(); - }); - }, [measureOverflow]); - useLayoutEffect(() => { const node = ref.current; if (!node) return; @@ -121,10 +106,6 @@ export function useOverflowFade({ window.addEventListener("resize", updateOverflow); return () => { - if (frameIdRef.current !== null) { - cancelAnimationFrame(frameIdRef.current); - frameIdRef.current = null; - } resizeObserver.disconnect(); mutationObserver?.disconnect(); node.removeEventListener("scroll", updateOverflow); @@ -132,11 +113,6 @@ export function useOverflowFade({ }; }, [observeChildren, observeParent, updateOverflow]); - useLayoutEffect(() => { - void measureKey; - updateOverflow(); - }, [measureKey, updateOverflow]); - return { ref, ...state, diff --git a/turbo.jsonc b/turbo.jsonc index 30843360054..fcf516e98f3 100644 --- a/turbo.jsonc +++ b/turbo.jsonc @@ -25,7 +25,8 @@ "VERCEL", "VERCEL_ENV", "VERCEL_URL", - "npm_lifecycle_event" + "npm_lifecycle_event", + "SUPERSET_RENDERER_STRESS_CDP_PORT" ], "tasks": { "build": { From 89ec3858af33f2bca033f679799ef9c6b7a08eef Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 14 May 2026 13:41:01 -0700 Subject: [PATCH 09/16] Remove switch-time diff stat work --- .../DashboardSidebarWorkspaceItem.tsx | 7 +--- .../host-service/src/trpc/router/git/git.ts | 35 ++++--------------- .../test/integration/git.integration.test.ts | 4 +-- 3 files changed, 10 insertions(+), 36 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx index f40d01be103..8b7922ebcee 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useDiffStats } from "renderer/hooks/host-service/useDiffStats"; import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; import { useDeletingWorkspaces } from "renderer/routes/_authenticated/providers/DeletingWorkspacesProvider"; import { RenameBranchDialog } from "renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components"; @@ -70,10 +69,6 @@ export function DashboardSidebarWorkspaceItem({ branch, isMainWorkspace, }); - const diffStats = useDiffStats(id, { - enabled: !isCollapsed && !isPending && isActive, - }); - const { v2Workspaces: v2WorkspaceActions } = useOptimisticCollectionActions(); const [renameBranchTarget, setRenameBranchTarget] = useState( null, @@ -222,7 +217,7 @@ export function DashboardSidebarWorkspaceItem({ isRenaming={isRenaming} renameValue={renameValue} shortcutLabel={shortcutLabel} - diffStats={isPending ? null : diffStats} + diffStats={null} workspaceStatus={workspaceStatus} isInSection={isInSection} onClick={handleClick} diff --git a/packages/host-service/src/trpc/router/git/git.ts b/packages/host-service/src/trpc/router/git/git.ts index aab11db90fa..e285458ec1f 100644 --- a/packages/host-service/src/trpc/router/git/git.ts +++ b/packages/host-service/src/trpc/router/git/git.ts @@ -301,40 +301,19 @@ export const gitRouter = router({ const base = await resolveBaseComparison(git, input.baseBranch); const baseRef = base?.baseRef ?? "HEAD"; - const [againstBaseRaw, stagedRaw, unstagedRaw, untrackedRaw] = - await Promise.all([ - git - .raw(["diff", "--numstat", "-z", `${baseRef}...HEAD`]) - .catch(() => ""), - git.raw(["diff", "--numstat", "-z", "--cached"]).catch(() => ""), - git.raw(["diff", "--numstat", "-z"]).catch(() => ""), - git - .raw(["ls-files", "--others", "--exclude-standard", "-z"]) - .catch(() => ""), - ]); + const [againstBaseRaw, stagedRaw, unstagedRaw] = await Promise.all([ + git + .raw(["diff", "--numstat", "-z", `${baseRef}...HEAD`]) + .catch(() => ""), + git.raw(["diff", "--numstat", "-z", "--cached"]).catch(() => ""), + git.raw(["diff", "--numstat", "-z"]).catch(() => ""), + ]); const byPath = new Map(); applyNumstatToStatsMap(byPath, againstBaseRaw); applyNumstatToStatsMap(byPath, stagedRaw); applyNumstatToStatsMap(byPath, unstagedRaw); - const untrackedFiles: ChangedFile[] = untrackedRaw - .split("\0") - .filter((path) => path.length > 0) - .map((path) => ({ - path, - status: "untracked", - additions: 0, - deletions: 0, - })); - await countUntrackedFileLines(worktreePath, untrackedFiles); - for (const file of untrackedFiles) { - addDiffStats(byPath, file.path, { - additions: file.additions, - deletions: file.deletions, - }); - } - return sumDiffStats(byPath); }), diff --git a/packages/host-service/test/integration/git.integration.test.ts b/packages/host-service/test/integration/git.integration.test.ts index 5f1dc53c865..ff014bd3d1c 100644 --- a/packages/host-service/test/integration/git.integration.test.ts +++ b/packages/host-service/test/integration/git.integration.test.ts @@ -59,7 +59,7 @@ describe("git router integration", () => { ); }); - test("getDiffStats returns sidebar totals without full status payload", async () => { + test("getDiffStats returns tracked sidebar totals without full status payload", async () => { writeFileSync(join(scenario.repo.repoPath, "staged.txt"), "one\ntwo\n"); await scenario.repo.git.add("staged.txt"); writeFileSync(join(scenario.repo.repoPath, "mixed.txt"), "base\n"); @@ -77,7 +77,7 @@ describe("git router integration", () => { workspaceId: scenario.workspaceId, }); - expect(stats).toEqual({ additions: 8, deletions: 0 }); + expect(stats).toEqual({ additions: 5, deletions: 0 }); }); test("getBaseBranch returns null when not configured", async () => { From de350fbce9e19a96fbd2fdcc49066240d6c0cbee Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 14 May 2026 16:18:09 -0700 Subject: [PATCH 10/16] Stabilize rapid workspace switching --- apps/desktop/scripts/stress-renderer.ts | 25 +++- .../terminal-runtime-registry.test.ts | 31 +++- .../lib/terminal/terminal-runtime-registry.ts | 14 +- ...useDashboardSidebarWorkspaceItemActions.ts | 7 +- .../_dashboard/utils/workspace-navigation.ts | 121 +++++++++++++++- .../TerminalRemoteControlButton.tsx | 133 +++++++++--------- .../hooks/usePaneRegistry/usePaneRegistry.tsx | 10 +- .../v2-workspace/$workspaceId/page.tsx | 16 ++- 8 files changed, 260 insertions(+), 97 deletions(-) diff --git a/apps/desktop/scripts/stress-renderer.ts b/apps/desktop/scripts/stress-renderer.ts index 83d66255ed7..1ac4dc061dd 100644 --- a/apps/desktop/scripts/stress-renderer.ts +++ b/apps/desktop/scripts/stress-renderer.ts @@ -910,9 +910,29 @@ function rendererStress(options: { } }; + let preferredBridgeWorkspaceId: string | undefined; const findWorkspaceBridge = async (workspaceIds: string[]) => { + const mountedBridge = getBridge(); + if ( + mountedBridge && + (workspaceIds.length === 0 || + workspaceIds.includes(mountedBridge.workspaceId)) + ) { + return { + workspaceId: mountedBridge.workspaceId, + bridge: mountedBridge, + }; + } + + const orderedWorkspaceIds = Array.from( + new Set( + [preferredBridgeWorkspaceId, ...workspaceIds].filter( + (value): value is string => Boolean(value), + ), + ), + ); const failures: string[] = []; - for (const candidateWorkspaceId of workspaceIds) { + for (const candidateWorkspaceId of orderedWorkspaceIds) { await navigateTo( `/v2-workspace/${encodeURIComponent(candidateWorkspaceId)}/`, ); @@ -928,7 +948,7 @@ function rendererStress(options: { } } throw new Error( - `No V2 workspace stress bridge mounted. Tried ${workspaceIds.length} workspace route(s): ${failures.join( + `No V2 workspace stress bridge mounted. Tried ${orderedWorkspaceIds.length} workspace route(s): ${failures.join( "; ", )}`, ); @@ -1040,6 +1060,7 @@ function rendererStress(options: { const startedAt = performance.now(); const startMemory = stressWindow.performance.memory ?? null; const targets = getTargets(); + preferredBridgeWorkspaceId = getBridge()?.workspaceId; const requiredTargetCount = shouldRunWorkspaceSwitch ? 2 : 1; if (targets.length < requiredTargetCount) { throw new Error( diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts index b352d00b013..fefd6be8cef 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts @@ -86,7 +86,9 @@ function createFakeTerminal(): FakeTerminal { return { cols: 120, rows: 32, - write: mock((_data: string | Uint8Array) => {}), + write: mock((_data: string | Uint8Array, callback?: () => void) => { + callback?.(); + }), getSelection: mock(() => ""), clear: mock(() => {}), scrollToBottom: mock(() => {}), @@ -239,23 +241,40 @@ describe("terminalRuntimeRegistry", () => { ).toEqual([]); }); - it("accepts stress output without waiting for xterm write completion", async () => { + it("resolves stress output after xterm write completion", async () => { terminalRuntimeRegistry.mount( "terminal-1", createContainer(), appearance, "workspace-a", ); + const writeState: { flush: (() => void) | null } = { flush: null }; + createdRuntimes[0]?.terminal.write.mockImplementation( + (_data: string | Uint8Array, callback?: () => void) => { + writeState.flush = () => callback?.(); + }, + ); - const accepted = await terminalRuntimeRegistry.writeForStress( + const acceptedPromise = terminalRuntimeRegistry.writeForStress( "terminal-1", "large output", "workspace-a", ); + let resolved = false; + void acceptedPromise.then(() => { + resolved = true; + }); + await Promise.resolve(); + expect(resolved).toBe(false); + if (!writeState.flush) { + throw new Error("Expected xterm write callback to be captured"); + } + writeState.flush(); + const accepted = await acceptedPromise; expect(accepted).toBe(true); - expect(createdRuntimes[0]?.terminal.write).toHaveBeenCalledWith( - "large output", - ); + const writeCall = createdRuntimes[0]?.terminal.write.mock.calls[0]; + expect(writeCall?.[0]).toBe("large output"); + expect(typeof writeCall?.[1]).toBe("function"); }); }); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts index 7bb2351198a..497b83ddfad 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -343,13 +343,15 @@ class TerminalRuntimeRegistryImpl { ): Promise { const entry = this.getEntry(terminalId, instanceId); if (!entry?.runtime) return Promise.resolve(false); + const { terminal } = entry.runtime; - try { - entry.runtime.terminal.write(data); - return Promise.resolve(true); - } catch { - return Promise.resolve(false); - } + return new Promise((resolve) => { + try { + terminal.write(data, () => resolve(true)); + } catch { + resolve(false); + } + }); } getStressDebugInfo( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts index 2e7313d0984..2647133ddb0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts @@ -5,6 +5,7 @@ import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { electronTrpcClient } from "renderer/lib/trpc-client"; import { useDashboardSidebarSectionRename } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSectionRenameContext"; +import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; @@ -56,10 +57,8 @@ export function useDashboardSidebarWorkspaceItemActions({ const handleClick = () => { if (isRenaming) return; clearWorkspaceAttention(workspaceId); - navigate({ - to: "/v2-workspace/$workspaceId", - params: { workspaceId }, - }); + if (isActive) return; + void navigateToV2Workspace(workspaceId, navigate); }; const startRename = () => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts index a807561923b..5d9aa455027 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts @@ -17,6 +17,112 @@ export interface V2WorkspaceSearchParams { openUrlRequestId?: string; } +type V2WorkspaceNavigateRest = Omit< + NavigateOptions, + "to" | "params" | "search" +>; + +interface QueuedV2WorkspaceNavigation { + workspaceId: string; + navigate: UseNavigateResult; + rest: V2WorkspaceNavigateRest; + resolve: () => void; + reject: (error: unknown) => void; +} + +let queuedV2WorkspaceNavigation: QueuedV2WorkspaceNavigation | null = null; +let v2WorkspaceNavigationTimer: ReturnType | null = null; +let v2WorkspaceNavigationInFlight = false; +const WORKSPACE_SWITCH_COALESCE_MS = 25; + +function getCurrentV2WorkspaceId(): string | null { + if (typeof window === "undefined") return null; + const match = window.location.hash.match(/^#\/v2-workspace\/([^/?#]+)/); + if (!match?.[1]) return null; + return decodeURIComponent(match[1]); +} + +function hasSearchParams(search: V2WorkspaceSearchParams | undefined): boolean { + return search != null && Object.values(search).some((value) => value != null); +} + +function performV2WorkspaceNavigation( + workspaceId: string, + navigate: UseNavigateResult, + rest: V2WorkspaceNavigateRest, + search: V2WorkspaceSearchParams = {}, +): Promise { + const shouldReplaceWorkspaceSwitch = + rest.replace == null && + typeof window !== "undefined" && + window.location.hash.startsWith("#/v2-workspace/"); + + return navigate({ + to: "/v2-workspace/$workspaceId", + params: { workspaceId }, + search, + ...rest, + replace: shouldReplaceWorkspaceSwitch ? true : rest.replace, + }); +} + +function scheduleV2WorkspaceNavigationDrain() { + if (v2WorkspaceNavigationInFlight) { + return; + } + + if (v2WorkspaceNavigationTimer) { + clearTimeout(v2WorkspaceNavigationTimer); + } + v2WorkspaceNavigationTimer = setTimeout(() => { + void drainQueuedV2WorkspaceNavigation(); + }, WORKSPACE_SWITCH_COALESCE_MS); +} + +async function drainQueuedV2WorkspaceNavigation() { + v2WorkspaceNavigationTimer = null; + const next = queuedV2WorkspaceNavigation; + queuedV2WorkspaceNavigation = null; + if (!next) return; + + v2WorkspaceNavigationInFlight = true; + try { + if (getCurrentV2WorkspaceId() !== next.workspaceId) { + await performV2WorkspaceNavigation( + next.workspaceId, + next.navigate, + next.rest, + ); + } + next.resolve(); + } catch (error) { + next.reject(error); + } finally { + v2WorkspaceNavigationInFlight = false; + if (queuedV2WorkspaceNavigation) { + scheduleV2WorkspaceNavigationDrain(); + } + } +} + +function enqueueV2WorkspaceNavigation( + workspaceId: string, + navigate: UseNavigateResult, + rest: V2WorkspaceNavigateRest, +): Promise { + queuedV2WorkspaceNavigation?.resolve(); + return new Promise((resolve, reject) => { + queuedV2WorkspaceNavigation = { + workspaceId, + navigate, + rest, + resolve, + reject, + }; + scheduleV2WorkspaceNavigationDrain(); + }); +} + /** * Navigate to a workspace and update localStorage to remember it as the last viewed workspace. * This ensures the workspace will be restored when the app is reopened. @@ -53,10 +159,13 @@ export function navigateToV2Workspace( }, ): Promise { const { search, ...rest } = options ?? {}; - return navigate({ - to: "/v2-workspace/$workspaceId", - params: { workspaceId }, - search: search ?? {}, - ...rest, - }); + if (!hasSearchParams(search) && getCurrentV2WorkspaceId() === workspaceId) { + return Promise.resolve(); + } + + if (!hasSearchParams(search)) { + return enqueueV2WorkspaceNavigation(workspaceId, navigate, rest); + } + + return performV2WorkspaceNavigation(workspaceId, navigate, rest, search); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalRemoteControlButton/TerminalRemoteControlButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalRemoteControlButton/TerminalRemoteControlButton.tsx index 1ac334fe95a..60b1eb82719 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalRemoteControlButton/TerminalRemoteControlButton.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalRemoteControlButton/TerminalRemoteControlButton.tsx @@ -8,9 +8,10 @@ import { import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ExternalLink, Radio } from "lucide-react"; import { useFeatureFlagEnabled } from "posthog-js/react"; -import { useCallback, useEffect, useState } from "react"; +import { useMemo, useState } from "react"; import { apiTrpcClient } from "renderer/lib/api-trpc-client"; interface TerminalRemoteControlButtonProps { @@ -20,6 +21,7 @@ interface TerminalRemoteControlButtonProps { interface ActiveSession { sessionId: string; + terminalId: string; // `webUrl` is only available right after `create` — the cloud only // stores `token_hash`, so we cannot reconstruct the share URL when // hydrating from `listForWorkspace`. `null` means "session is live but @@ -30,6 +32,8 @@ interface ActiveSession { type Phase = "inactive" | "loading" | "creating" | "active" | "revoking"; const HYDRATE_REFRESH_MS = 30_000; +const remoteControlSessionsQueryKey = (workspaceId: string) => + ["remote-control-sessions", workspaceId] as const; export function TerminalRemoteControlButton({ workspaceId, @@ -40,69 +44,55 @@ export function TerminalRemoteControlButton({ const hasAccess = useFeatureFlagEnabled( FEATURE_FLAGS.WEB_REMOTE_CONTROL_ACCESS, ); - const [phase, setPhase] = useState("loading"); - const [active, setActive] = useState(null); - - const hydrate = useCallback( - async (signal?: AbortSignal): Promise => { - try { - const rows = await apiTrpcClient.remoteControl.listForWorkspace.query({ - workspaceId, - }); - if (signal?.aborted) return; - const now = Date.now(); - const live = rows.find( - (r) => - r.terminalId === terminalId && - r.status === "active" && - new Date(r.expiresAt).getTime() > now, - ); - if (live) { - setActive((prev) => ({ - sessionId: live.sessionId, - // Preserve a previously-captured webUrl if we still have - // it for the same session (e.g., we minted it ourselves - // in this component lifetime). - webUrl: - prev && prev.sessionId === live.sessionId ? prev.webUrl : null, - expiresAt: live.expiresAt, - })); - setPhase((prev) => - prev === "creating" || prev === "revoking" ? prev : "active", - ); - } else { - setActive(null); - setPhase((prev) => - prev === "creating" || prev === "revoking" ? prev : "inactive", - ); - } - } catch { - // Silent — background refresh; the user still has the optimistic - // state from the last successful action. - if (!signal?.aborted) { - setPhase((prev) => (prev === "loading" ? "inactive" : prev)); - } - } - }, - [workspaceId, terminalId], - ); + const queryClient = useQueryClient(); + const queryKey = remoteControlSessionsQueryKey(workspaceId); + const [actionPhase, setActionPhase] = useState("inactive"); + const [localActive, setLocalActive] = useState(null); + const sessionsQuery = useQuery({ + queryKey, + enabled: Boolean(hasAccess), + queryFn: () => + apiTrpcClient.remoteControl.listForWorkspace.query({ workspaceId }), + refetchInterval: HYDRATE_REFRESH_MS, + refetchOnWindowFocus: false, + staleTime: 5_000, + }); - useEffect(() => { - // Don't run the cloud hydrate poll when the user isn't in the cohort - // — that just wastes a tRPC call every 30s for a button that won't - // render anyway. - if (!hasAccess) return; - const ac = new AbortController(); - void hydrate(ac.signal); - const timer = setInterval( - () => void hydrate(ac.signal), - HYDRATE_REFRESH_MS, + const active = useMemo(() => { + const now = Date.now(); + const live = sessionsQuery.data?.find( + (row) => + row.terminalId === terminalId && + row.status === "active" && + new Date(row.expiresAt).getTime() > now, ); - return () => { - ac.abort(); - clearInterval(timer); - }; - }, [hydrate, hasAccess]); + if (live) { + return { + sessionId: live.sessionId, + terminalId: live.terminalId, + webUrl: + localActive?.sessionId === live.sessionId ? localActive.webUrl : null, + expiresAt: live.expiresAt, + }; + } + if ( + localActive && + localActive.terminalId === terminalId && + new Date(localActive.expiresAt).getTime() > now + ) { + return localActive; + } + return null; + }, [localActive, sessionsQuery.data, terminalId]); + + const phase: Phase = + actionPhase === "creating" || actionPhase === "revoking" + ? actionPhase + : sessionsQuery.isPending && !sessionsQuery.data + ? "loading" + : active + ? "active" + : "inactive"; if (!hasAccess) return null; @@ -118,22 +108,24 @@ export function TerminalRemoteControlButton({ } async function startShare() { - setPhase("creating"); + setActionPhase("creating"); try { const result = await apiTrpcClient.remoteControl.create.mutate({ workspaceId, terminalId, mode: "full", }); - setActive({ + setLocalActive({ sessionId: result.sessionId, + terminalId, webUrl: result.webUrl, expiresAt: result.expiresAt, }); - setPhase("active"); + setActionPhase("inactive"); + void queryClient.invalidateQueries({ queryKey }); void copyLink(result.webUrl); } catch (err) { - setPhase("inactive"); + setActionPhase("inactive"); toast.error( `Failed to start remote control: ${err instanceof Error ? err.message : String(err)}`, ); @@ -142,16 +134,17 @@ export function TerminalRemoteControlButton({ async function stopShare() { if (!active) return; - setPhase("revoking"); + setActionPhase("revoking"); try { await apiTrpcClient.remoteControl.revoke.mutate({ sessionId: active.sessionId, }); - setActive(null); - setPhase("inactive"); + setLocalActive(null); + setActionPhase("inactive"); + void queryClient.invalidateQueries({ queryKey }); toast.success("Remote control stopped"); } catch (err) { - setPhase("active"); + setActionPhase("inactive"); toast.error( `Failed to stop remote control: ${err instanceof Error ? err.message : String(err)}`, ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index 4f8fa8c5c83..5955300a5ae 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -41,7 +41,11 @@ import type { PaneViewerData, TerminalPaneData, } from "../../types"; -import { BrowserPane, BrowserPaneToolbar } from "./components/BrowserPane"; +import { + BrowserPane, + BrowserPaneToolbar, + browserRuntimeRegistry, +} from "./components/BrowserPane"; import { ChatPane } from "./components/ChatPane"; import { ChatPaneTitle } from "./components/ChatPane/components/ChatPaneTitle"; import { CommentPane } from "./components/CommentPane"; @@ -416,7 +420,9 @@ export function usePaneRegistry({ renderToolbar: (ctx: RendererContext) => ( ), - // Destruction handled by useGlobalBrowserLifecycle for now. + onAfterClose: (pane) => { + browserRuntimeRegistry.destroy(pane.id); + }, contextMenuActions: (_ctx, defaults) => defaults.map((d) => d.key === "close-pane" ? { ...d, label: "Close Browser" } : d, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 25bd67ea8fa..bfb7826399c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -26,7 +26,10 @@ import { useDefaultContextMenuActions } from "./hooks/useDefaultContextMenuActio import { useDefaultPaneActions } from "./hooks/useDefaultPaneActions"; import { useDirtyTabCloseGuard } from "./hooks/useDirtyTabCloseGuard"; import { usePaneRegistry } from "./hooks/usePaneRegistry"; -import { renderBrowserTabIcon } from "./hooks/usePaneRegistry/components/BrowserPane"; +import { + browserRuntimeRegistry, + renderBrowserTabIcon, +} from "./hooks/usePaneRegistry/components/BrowserPane"; import { useRendererStressWorkspaceBridge } from "./hooks/useRendererStressWorkspaceBridge"; import { useV2PresetExecution } from "./hooks/useV2PresetExecution"; import { useV2TerminalLauncher } from "./hooks/useV2TerminalLauncher"; @@ -130,6 +133,17 @@ function V2WorkspaceContent({ } = useV2UserPreferences(); const showPresetsBar = v2UserPreferences.showPresetsBar; const { store } = useV2WorkspacePaneLayout(); + useEffect(() => { + return () => { + for (const tab of store.getState().tabs) { + for (const pane of Object.values(tab.panes)) { + if (pane.kind === "browser") { + browserRuntimeRegistry.destroy(pane.id); + } + } + } + }; + }, [store]); useClearActivePaneAttention({ store }); const launcher = useV2TerminalLauncher(); const { matchedPresets, executePreset, resolvePresetCommands } = From 230c7c074ae1dee95fb9c510b3564805b6a516e8 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 14 May 2026 16:59:01 -0700 Subject: [PATCH 11/16] Cap retained browser runtimes --- .../BrowserPane/browserRuntimeRegistry.ts | 23 +++++++++++++++++++ .../v2-workspace/$workspaceId/page.tsx | 16 +------------ 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts index 31ebb1cc4a6..409ffec6599 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts @@ -27,6 +27,7 @@ interface RegistryEntry { placeholder: HTMLElement | null; resizeObserver: ResizeObserver | null; visible: boolean; + lastUsedAt: number; } const EMPTY_STATE: BrowserRuntimeState = Object.freeze({ @@ -40,6 +41,7 @@ const EMPTY_STATE: BrowserRuntimeState = Object.freeze({ }); const ROOT_CONTAINER_ID = "browser-runtime-root"; +const MAX_DETACHED_BROWSER_RUNTIMES = 8; class BrowserRuntimeRegistryImpl { private entries = new Map(); @@ -162,6 +164,7 @@ class BrowserRuntimeRegistryImpl { private setState(paneId: string, patch: Partial) { const entry = this.entries.get(paneId); if (!entry) return; + entry.lastUsedAt = Date.now(); let changed = false; for (const key in patch) { const k = key as keyof BrowserRuntimeState; @@ -212,6 +215,7 @@ class BrowserRuntimeRegistryImpl { placeholder: null, resizeObserver: null, visible: false, + lastUsedAt: Date.now(), }; const firePersist = () => { @@ -381,6 +385,7 @@ class BrowserRuntimeRegistryImpl { entry.onPersist = onPersist; entry.placeholder = placeholder; entry.visible = true; + entry.lastUsedAt = Date.now(); entry.resizeObserver?.disconnect(); const observer = new ResizeObserver(() => { @@ -392,17 +397,20 @@ class BrowserRuntimeRegistryImpl { this.updateLayout(entry); entry.webview.style.visibility = "visible"; this.applyPointerPassthrough(); + this.pruneDetachedRuntimes(); } detach(paneId: string): void { const entry = this.entries.get(paneId); if (!entry) return; + entry.lastUsedAt = Date.now(); entry.onPersist = null; entry.placeholder = null; entry.resizeObserver?.disconnect(); entry.resizeObserver = null; entry.visible = false; entry.webview.style.visibility = "hidden"; + this.pruneDetachedRuntimes(); } destroy(paneId: string): void { @@ -416,9 +424,21 @@ class BrowserRuntimeRegistryImpl { electronTrpcClient.browser.unregister.mutate({ paneId }).catch(() => {}); } + private pruneDetachedRuntimes(): void { + const detachedEntries = Array.from(this.entries.entries()) + .filter(([, entry]) => !entry.visible) + .sort(([, a], [, b]) => a.lastUsedAt - b.lastUsedAt); + while (detachedEntries.length > MAX_DETACHED_BROWSER_RUNTIMES) { + const [paneId] = detachedEntries.shift() ?? []; + if (!paneId) return; + this.destroy(paneId); + } + } + navigate(paneId: string, url: string): void { const entry = this.entries.get(paneId); if (!entry) return; + entry.lastUsedAt = Date.now(); entry.webview.loadURL(sanitizeUrl(url)).catch((err) => { console.error("[browserRuntimeRegistry] loadURL failed:", err); }); @@ -426,16 +446,19 @@ class BrowserRuntimeRegistryImpl { goBack(paneId: string): void { const entry = this.entries.get(paneId); + if (entry) entry.lastUsedAt = Date.now(); if (entry?.webview.canGoBack()) entry.webview.goBack(); } goForward(paneId: string): void { const entry = this.entries.get(paneId); + if (entry) entry.lastUsedAt = Date.now(); if (entry?.webview.canGoForward()) entry.webview.goForward(); } reload(paneId: string): void { const entry = this.entries.get(paneId); + if (entry) entry.lastUsedAt = Date.now(); entry?.webview.reload(); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index bfb7826399c..25bd67ea8fa 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -26,10 +26,7 @@ import { useDefaultContextMenuActions } from "./hooks/useDefaultContextMenuActio import { useDefaultPaneActions } from "./hooks/useDefaultPaneActions"; import { useDirtyTabCloseGuard } from "./hooks/useDirtyTabCloseGuard"; import { usePaneRegistry } from "./hooks/usePaneRegistry"; -import { - browserRuntimeRegistry, - renderBrowserTabIcon, -} from "./hooks/usePaneRegistry/components/BrowserPane"; +import { renderBrowserTabIcon } from "./hooks/usePaneRegistry/components/BrowserPane"; import { useRendererStressWorkspaceBridge } from "./hooks/useRendererStressWorkspaceBridge"; import { useV2PresetExecution } from "./hooks/useV2PresetExecution"; import { useV2TerminalLauncher } from "./hooks/useV2TerminalLauncher"; @@ -133,17 +130,6 @@ function V2WorkspaceContent({ } = useV2UserPreferences(); const showPresetsBar = v2UserPreferences.showPresetsBar; const { store } = useV2WorkspacePaneLayout(); - useEffect(() => { - return () => { - for (const tab of store.getState().tabs) { - for (const pane of Object.values(tab.panes)) { - if (pane.kind === "browser") { - browserRuntimeRegistry.destroy(pane.id); - } - } - } - }; - }, [store]); useClearActivePaneAttention({ store }); const launcher = useV2TerminalLauncher(); const { matchedPresets, executePreset, resolvePresetCommands } = From ced3bd99287a518b256070fa764ccdb764a9b0ee Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 15 May 2026 08:41:46 -0700 Subject: [PATCH 12/16] Document terminal WebGL recovery --- apps/desktop/docs/RENDERER_STRESS_QA.md | 3 + apps/desktop/docs/TERMINAL_WEBGL_RENDERING.md | 143 ++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 apps/desktop/docs/TERMINAL_WEBGL_RENDERING.md diff --git a/apps/desktop/docs/RENDERER_STRESS_QA.md b/apps/desktop/docs/RENDERER_STRESS_QA.md index 5a82826a62e..6afed217c5d 100644 --- a/apps/desktop/docs/RENDERER_STRESS_QA.md +++ b/apps/desktop/docs/RENDERER_STRESS_QA.md @@ -6,6 +6,9 @@ Use it before broad manual QA when a change may affect workspace switching, changes/diff panels, terminal rendering, tab/pane state, or renderer responsiveness. +For terminal WebGL context loss, blank panes, or glyph/font corruption, see +`apps/desktop/docs/TERMINAL_WEBGL_RENDERING.md`. + ## Safety - Run this only against a local dev `SUPERSET_HOME_DIR`. The fixture command diff --git a/apps/desktop/docs/TERMINAL_WEBGL_RENDERING.md b/apps/desktop/docs/TERMINAL_WEBGL_RENDERING.md new file mode 100644 index 00000000000..4cd99005531 --- /dev/null +++ b/apps/desktop/docs/TERMINAL_WEBGL_RENDERING.md @@ -0,0 +1,143 @@ +# Terminal WebGL Rendering and Glyph Corruption + +This note covers terminal rendering failures where the PTY stream is still +alive but the terminal pixels are wrong: blank panes after switching, smeared +glyphs, overlapping text, or rows that look like a corrupted font atlas. + +If copying text out of the affected terminal returns clean text, treat the +failure as a renderer/GPU issue first. The backend session, daemon stream, and +terminal buffer may still be correct. + +## What We Already Do + +- Electron raises Chromium's active WebGL context cap to `256` in + `apps/desktop/src/lib/electron-app/factories/app/setup.ts`. Each xterm WebGL + renderer owns a context, and parked terminal panes can otherwise exceed + Chromium's default cap of `16`, causing forced context eviction. +- On macOS, Electron starts with + `disable-backgrounding-occluded-windows` to avoid compositor throttling for + windows that are covered or backgrounded. +- Terminal WebGL loading is optional and deferred by one animation frame in + both terminal creation paths: + - `apps/desktop/src/renderer/lib/terminal/terminal-addons.ts` + - `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts` +- If `WebglAddon` fails to load, the renderer records + `suggestedRendererType = "dom"` and skips WebGL for later terminals in the + same renderer process. +- If WebGL reports context loss, the current code disposes that terminal's + `WebglAddon`, clears the addon reference, and refreshes the visible rows. + That gives xterm a chance to keep rendering without the lost WebGL context. +- The v2 terminal runtime parks xterm wrappers on detach instead of tearing + them down. Switching workspaces should reattach the same runtime instead of + replaying a large terminal history into a fresh renderer. +- Unicode 11 is activated before restoring serialized buffers. That prevents + CJK, emoji, and ZWJ-width decisions from being baked into replay with the + wrong width table. +- The image addon is bounded in + `apps/desktop/src/renderer/lib/terminal/terminal-image-addon.ts`: + `storageLimit: 16`, `pixelLimit: 1_048_576`, and 8 MB limits for kitty/IIP + payloads. This avoids one image-heavy terminal consuming unbounded renderer + memory. +- Terminal dispose is delayed by two animation frames after parking the wrapper + so pending xterm refresh work can drain before the DOM node disappears. + +## What The Font-Breaking Screenshot Suggests + +The screenshot with mixed, smeared glyphs looks more like a WebGL glyph atlas +or renderer-cache failure than a bad PTY stream. The terminal prompt and layout +still exist, but glyphs from previous cells appear to bleed through the current +row. + +Likely causes to check first: + +- WebGL context loss or forced context eviction. +- GPU texture atlas corruption after a context loss. +- Too many live terminal WebGL contexts while parked terminals remain alive. +- Font or ligature cache mismatch after appearance changes. +- xterm refresh work racing with a hidden, detached, or zero-sized terminal. + +## Current Gap + +Context loss does not currently promote the process-wide fallback to DOM. The +load-failure path sets `suggestedRendererType = "dom"`, but the context-loss +callback only disposes the current `WebglAddon` and refreshes the terminal. + +If a GPU/driver state is bad enough to lose or corrupt one terminal WebGL +context, later terminals may still try WebGL. That can keep reproducing blank +or font-corrupted terminals even though the fallback path exists. + +## Recommended Next Fixes + +1. Promote WebGL context loss to global DOM fallback in both terminal creation + paths. Set `suggestedRendererType = "dom"` inside `onContextLoss` before + disposing the addon and refreshing the terminal. This keeps the existing + behavior for the current terminal and prevents newly created terminals from + re-entering the same broken GPU path. +2. Add a developer kill switch for diagnosis, for example + `SUPERSET_TERMINAL_RENDERER=dom`. This should bypass WebGL loading without + changing terminal session behavior, replay, persistence, or pane ownership. +3. If corruption survives after WebGL disposal, rebuild only the xterm renderer + runtime from the serialized terminal buffer. Do not kill the backend PTY and + do not force a daemon replay on ordinary workspace switches. +4. Consider a terminal WebGL budget instead of raising the context cap further: + keep WebGL on visible terminals and use DOM for newly created terminals once + the live WebGL count is above a conservative limit. This preserves parked + terminal sessions without letting hidden panes own unlimited GPU contexts. +5. If the issue reproduces with DOM rendering, isolate font and ligature state: + disable `LigaturesAddon` behind the same diagnostic switch and verify whether + the copied terminal text still matches the corrupt pixels. + +Avoid papering over this with periodic full-terminal refreshes, larger replay +windows, or more switch-time terminal reconstruction. Those approaches make +workspace switching slower and risk reintroducing the long background replay +problem. + +## Diagnosis Checklist + +- Copy several corrupt lines from the terminal. If the clipboard text is clean, + investigate renderer/WebGL/font state before the PTY stream. +- Check DevTools console for WebGL context loss, WebAssembly memory errors from + `@xterm/addon-image`, or renderer long-task warnings. +- Confirm whether corruption appears only after workspace switches, only after + returning from background, or immediately after opening many terminals. +- Compare a run with WebGL enabled against a local patch that skips WebGL + loading. If DOM rendering is clean, the fix should stay in the addon/rendering + path, not the terminal transport path. +- Keep the desktop logs open during reproduction. Renderer crashes without a + JS error often still leave GPU, WebContents, or process-exit evidence there. + +## Stress Commands + +Use the general stress guide in +`apps/desktop/docs/RENDERER_STRESS_QA.md` for fixture setup and full runs. + +For targeted terminal rendering stress: + +```bash +SUPERSET_RENDERER_STRESS_CDP_PORT=9333 bun --cwd apps/desktop dev +``` + +Then, in another shell: + +```bash +bun --cwd apps/desktop stress:renderer -- \ + --port 9333 \ + --scenario terminal-heavy \ + --workspace-ids , \ + --terminal-iterations 200 \ + --terminal-tab-count 32 \ + --terminal-panes-per-tab 4 \ + --terminal-lines 80 \ + --terminal-payload-bytes 4096 \ + --interval-ms 0 \ + --settle-ms 1500 \ + --timeout-ms 300000 \ + --max-heartbeat-delay-ms 10000 \ + --max-long-task-ms 10000 \ + --progress-every 10 +``` + +For a narrow A/B test, run that command once normally and once with WebGL +loading locally disabled. A clean DOM run points at WebGL context/atlas +handling. A corrupt DOM run points at font metrics, ligatures, buffer restore, +or xterm sizing instead. From 98df68cdcb377722057a7d7900daac1794e371ab Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 15 May 2026 09:03:01 -0700 Subject: [PATCH 13/16] Bound terminal attach close reasons --- packages/host-service/src/terminal/terminal.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index 8774605a412..36587d93867 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -177,6 +177,7 @@ const MAX_BUFFER_BYTES = 64 * 1024; const SOCKET_OPEN = 1; const SOCKET_CLOSING = 2; const SOCKET_CLOSED = 3; +const TERMINAL_ATTACH_ERROR_CLOSE_REASON = "terminal-attach-error"; const DEFAULT_TERMINAL_COLS = 120; const DEFAULT_TERMINAL_ROWS = 32; const MIN_TERMINAL_COLS = 20; @@ -1531,7 +1532,9 @@ export function registerWorkspaceTerminalRoute({ const session = await resolveSessionForAttach(); if ("error" in session) { sendMessage(ws, { type: "error", message: session.error }); - ws.close(1011, session.error); + // WebSocket close reasons are limited to 123 bytes. + // The prior error frame carries the full diagnostic. + ws.close(1011, TERMINAL_ATTACH_ERROR_CLOSE_REASON); return; } if (ws.readyState !== SOCKET_OPEN) return; From d9ea846df14cf3d67d344979cdb0d1559f3b9e47 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 15 May 2026 10:16:12 -0700 Subject: [PATCH 14/16] Fallback terminal WebGL after context loss --- apps/desktop/docs/TERMINAL_WEBGL_RENDERING.md | 69 ++++++++--- apps/desktop/scripts/stress-renderer.ts | 26 ++++ .../renderer/lib/terminal/terminal-addons.ts | 10 +- .../lib/terminal/terminal-runtime-registry.ts | 59 +++++++++ .../useRendererStressWorkspaceBridge.ts | 114 +++++++++++++++++- .../TabsContent/Terminal/helpers.ts | 10 +- 6 files changed, 267 insertions(+), 21 deletions(-) diff --git a/apps/desktop/docs/TERMINAL_WEBGL_RENDERING.md b/apps/desktop/docs/TERMINAL_WEBGL_RENDERING.md index 4cd99005531..fe3725b7ba5 100644 --- a/apps/desktop/docs/TERMINAL_WEBGL_RENDERING.md +++ b/apps/desktop/docs/TERMINAL_WEBGL_RENDERING.md @@ -25,8 +25,11 @@ terminal buffer may still be correct. `suggestedRendererType = "dom"` and skips WebGL for later terminals in the same renderer process. - If WebGL reports context loss, the current code disposes that terminal's - `WebglAddon`, clears the addon reference, and refreshes the visible rows. - That gives xterm a chance to keep rendering without the lost WebGL context. + `WebglAddon`, clears the addon reference, promotes the renderer process to + DOM fallback for later terminals, and refreshes the visible rows immediately + and again on the next animation frame. That gives xterm a chance to keep + rendering without the lost WebGL context and avoids re-entering the same GPU + path for newly created terminals. - The v2 terminal runtime parks xterm wrappers on detach instead of tearing them down. Switching workspaces should reattach the same runtime instead of replaying a large terminal history into a fresh renderer. @@ -56,34 +59,38 @@ Likely causes to check first: - Font or ligature cache mismatch after appearance changes. - xterm refresh work racing with a hidden, detached, or zero-sized terminal. +Upstream xterm repros point at the same class of problem: + +- `xtermjs/xterm.js#5847` reports WebGL row ghosting and glyph substitution + under heavy true-color output. +- `xtermjs/xterm.js#4534` and `xtermjs/xterm.js#4484` cover texture-atlas + corruption under high color/atlas pressure. +- `xtermjs/xterm.js#4351` covers blurry glyphs during atlas page merging, and + `xtermjs/xterm.js#4480` covers TUI-driven WebGL render glitches. + ## Current Gap -Context loss does not currently promote the process-wide fallback to DOM. The -load-failure path sets `suggestedRendererType = "dom"`, but the context-loss -callback only disposes the current `WebglAddon` and refreshes the terminal. +The confirmed local reproduction is a transient white/blank frame during +forced WebGL context loss. Pure xterm atlas pressure did not produce stable +glyph corruption locally, but it did reach xterm's pending atlas-clear state. -If a GPU/driver state is bad enough to lose or corrupt one terminal WebGL -context, later terminals may still try WebGL. That can keep reproducing blank -or font-corrupted terminals even though the fallback path exists. +The remaining risk is that the browser can still show a one-frame blank while +the WebGL context loss event is being delivered. Once xterm calls our +context-loss handler, the terminal should fall back to DOM and repaint. ## Recommended Next Fixes -1. Promote WebGL context loss to global DOM fallback in both terminal creation - paths. Set `suggestedRendererType = "dom"` inside `onContextLoss` before - disposing the addon and refreshing the terminal. This keeps the existing - behavior for the current terminal and prevents newly created terminals from - re-entering the same broken GPU path. -2. Add a developer kill switch for diagnosis, for example +1. Add a developer kill switch for diagnosis, for example `SUPERSET_TERMINAL_RENDERER=dom`. This should bypass WebGL loading without changing terminal session behavior, replay, persistence, or pane ownership. -3. If corruption survives after WebGL disposal, rebuild only the xterm renderer +2. If corruption survives after WebGL disposal, rebuild only the xterm renderer runtime from the serialized terminal buffer. Do not kill the backend PTY and do not force a daemon replay on ordinary workspace switches. -4. Consider a terminal WebGL budget instead of raising the context cap further: +3. Consider a terminal WebGL budget instead of raising the context cap further: keep WebGL on visible terminals and use DOM for newly created terminals once the live WebGL count is above a conservative limit. This preserves parked terminal sessions without letting hidden panes own unlimited GPU contexts. -5. If the issue reproduces with DOM rendering, isolate font and ligature state: +4. If the issue reproduces with DOM rendering, isolate font and ligature state: disable `LigaturesAddon` behind the same diagnostic switch and verify whether the copied terminal text still matches the corrupt pixels. @@ -137,6 +144,34 @@ bun --cwd apps/desktop stress:renderer -- \ --progress-every 10 ``` +For the atlas/glyph corruption family, use `--terminal-output-mode cjk-sgr`. +This mode recreates the xterm demo issue pattern by writing many CJK glyphs +with varying SGR attributes, which forces WebGL texture-atlas churn: + +```bash +bun --cwd apps/desktop stress:renderer -- \ + --port 9333 \ + --scenario terminal-heavy \ + --terminal-iterations 1000 \ + --terminal-tab-count 8 \ + --terminal-panes-per-tab 1 \ + --terminal-lines 25 \ + --terminal-payload-bytes 0 \ + --terminal-output-mode cjk-sgr \ + --interval-ms 0 \ + --settle-ms 2000 \ + --timeout-ms 600000 \ + --max-heartbeat-delay-ms 10000 \ + --max-long-task-ms 10000 \ + --progress-every 100 \ + --json +``` + +In the result, inspect `terminalStressSummary.terminalRuntimes[].renderer`. +Multiple large atlas pages such as `8192` or `4096`, especially with +`pendingAtlasClear: true`, mean the run reached the upstream texture-atlas +pressure path even if screenshot corruption is subtle. + For a narrow A/B test, run that command once normally and once with WebGL loading locally disabled. A clean DOM run points at WebGL context/atlas handling. A corrupt DOM run points at font metrics, ligatures, buffer restore, diff --git a/apps/desktop/scripts/stress-renderer.ts b/apps/desktop/scripts/stress-renderer.ts index 1ac4dc061dd..b7477ea64c1 100644 --- a/apps/desktop/scripts/stress-renderer.ts +++ b/apps/desktop/scripts/stress-renderer.ts @@ -12,6 +12,7 @@ interface Args { terminalPanesPerTab: number; terminalLines: number; terminalPayloadBytes: number; + terminalOutputMode: TerminalOutputMode; includeTerminalAction: boolean; profileCpu: boolean; reactProbe: boolean; @@ -35,6 +36,12 @@ type StressScenario = | "workspace-switch" | "workspace-switch-heavy"; +type TerminalOutputMode = + | "atlas-rewrite" + | "cjk-sgr" + | "scrollback" + | "tui-churn"; + interface CdpTarget { type?: string; title?: string; @@ -168,6 +175,7 @@ function parseArgs(argv: string[]): Args { terminalPanesPerTab: 4, terminalLines: 40, terminalPayloadBytes: 1024, + terminalOutputMode: "scrollback", includeTerminalAction: false, profileCpu: false, reactProbe: false, @@ -247,6 +255,19 @@ function parseArgs(argv: string[]): Args { case "--terminal-payload-bytes": args.terminalPayloadBytes = readNumber(); break; + case "--terminal-output-mode": { + const mode = readValue(); + if ( + mode !== "atlas-rewrite" && + mode !== "cjk-sgr" && + mode !== "scrollback" && + mode !== "tui-churn" + ) { + throw new Error(`Invalid mode for ${arg}: ${mode}`); + } + args.terminalOutputMode = mode; + break; + } case "--include-terminal-action": args.includeTerminalAction = true; break; @@ -319,6 +340,7 @@ Options: Also controls generated tabs/panes for workspace-switch-heavy. --terminal-lines ANSI output lines per terminal write. Default: 40 --terminal-payload-bytes Repeated payload bytes per line. Default: 1024 + --terminal-output-mode scrollback, atlas-rewrite, tui-churn, or cjk-sgr. Default: scrollback --include-terminal-action Include one real backend terminal launch in heavy stress. Default: false --profile-cpu Capture a CDP CPU profile and print hottest JS frames --react-probe Capture React commit/component counts via React DevTools hook when available @@ -538,6 +560,7 @@ function rendererStress(options: { terminalPanesPerTab: number; terminalLines: number; terminalPayloadBytes: number; + terminalOutputMode: TerminalOutputMode; includeTerminalAction: boolean; reactProbe: boolean; progressEvery: number; @@ -843,6 +866,7 @@ function rendererStress(options: { index: number, lines: number, payloadBytes: number, + mode?: TerminalOutputMode, ) => Promise<{ terminalCount: number; writtenCount: number; @@ -1415,6 +1439,7 @@ function rendererStress(options: { index, options.terminalLines, options.terminalPayloadBytes, + options.terminalOutputMode, ), 15_000, "write-terminal-stress-output", @@ -1575,6 +1600,7 @@ async function main() { terminalPanesPerTab: args.terminalPanesPerTab, terminalLines: args.terminalLines, terminalPayloadBytes: args.terminalPayloadBytes, + terminalOutputMode: args.terminalOutputMode, includeTerminalAction: args.includeTerminalAction, reactProbe: args.reactProbe, progressEvery: args.progressEvery, diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts b/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts index 27c84492771..a622c0c9811 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts @@ -16,6 +16,10 @@ export interface LoadAddonsResult { // Once WebGL fails, skip it for all subsequent runtimes (VS Code pattern). let suggestedRendererType: "webgl" | "dom" | undefined; +function refreshTerminal(terminal: XTerm): void { + terminal.refresh(0, Math.max(0, terminal.rows - 1)); +} + /** * Load optional addons onto an already-opened terminal. Returns a cleanup * function and addon instances. WebGL is deferred to rAF to avoid @@ -49,9 +53,13 @@ export function loadAddons(terminal: XTerm): LoadAddonsResult { try { webglAddon = new WebglAddon(); webglAddon.onContextLoss(() => { + suggestedRendererType = "dom"; webglAddon?.dispose(); webglAddon = null; - terminal.refresh(0, terminal.rows - 1); + refreshTerminal(terminal); + requestAnimationFrame(() => { + if (!disposed) refreshTerminal(terminal); + }); }); terminal.loadAddon(webglAddon); } catch { diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts index 497b83ddfad..4cca3d0ce86 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -46,9 +46,67 @@ export interface TerminalRuntimeStressDebugInfo { cols: number | null; rows: number | null; canvasCount: number; + renderer: TerminalRendererStressDebugInfo | null; connectionState: ConnectionState; } +export interface TerminalRendererStressDebugInfo { + rendererType: string | null; + atlasPageCount: number | null; + atlasPageSizes: number[]; + atlasPageVersions: Array; + pendingAtlasClear: boolean | null; +} + +function getObjectProperty(source: unknown, key: string): unknown { + if (!source || (typeof source !== "object" && typeof source !== "function")) { + return undefined; + } + return (source as Record)[key]; +} + +function getNumberProperty(source: unknown, key: string): number | null { + const value = getObjectProperty(source, key); + return typeof value === "number" ? value : null; +} + +function getTerminalRenderer(terminal: TerminalRuntime["terminal"]): unknown { + const core = getObjectProperty(terminal, "_core"); + const renderService = getObjectProperty(core, "_renderService"); + const rendererHolder = getObjectProperty(renderService, "_renderer"); + return getObjectProperty(rendererHolder, "value") ?? rendererHolder; +} + +function getRendererStressDebugInfo( + terminal: TerminalRuntime["terminal"], +): TerminalRendererStressDebugInfo | null { + const renderer = getTerminalRenderer(terminal); + if (!renderer || typeof renderer !== "object") return null; + + const rendererConstructor = getObjectProperty(renderer, "constructor"); + const rendererType = + typeof getObjectProperty(rendererConstructor, "name") === "string" + ? (getObjectProperty(rendererConstructor, "name") as string) + : null; + const charAtlas = getObjectProperty(renderer, "_charAtlas"); + const rawPages = getObjectProperty(charAtlas, "pages"); + const pages = Array.isArray(rawPages) ? rawPages : []; + return { + rendererType, + atlasPageCount: Array.isArray(rawPages) ? rawPages.length : null, + atlasPageSizes: pages + .map((page) => + getNumberProperty(getObjectProperty(page, "canvas"), "width"), + ) + .filter((size): size is number => size !== null), + atlasPageVersions: pages.map((page) => getNumberProperty(page, "version")), + pendingAtlasClear: + typeof getObjectProperty(charAtlas, "_requestClearModel") === "boolean" + ? (getObjectProperty(charAtlas, "_requestClearModel") as boolean) + : null, + }; +} + class TerminalRuntimeRegistryImpl { private entries = new Map(); private entryKeysByTerminalId = new Map>(); @@ -373,6 +431,7 @@ class TerminalRuntimeRegistryImpl { canvasCount: runtime ? runtime.wrapper.querySelectorAll("canvas").length : 0, + renderer: runtime ? getRendererStressDebugInfo(runtime.terminal) : null, connectionState: entry.transport.connectionState, }; }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/useRendererStressWorkspaceBridge.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/useRendererStressWorkspaceBridge.ts index 44195fce473..0f7ff6b47ae 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/useRendererStressWorkspaceBridge.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/useRendererStressWorkspaceBridge.ts @@ -49,6 +49,12 @@ interface RendererStressTerminalWriteResult { byteLength: number; } +type RendererStressTerminalOutputMode = + | "atlas-rewrite" + | "cjk-sgr" + | "scrollback" + | "tui-churn"; + interface RendererStressWorkspaceBridge { workspaceId: string; projectId: string; @@ -79,6 +85,7 @@ interface RendererStressWorkspaceBridge { index: number, lines: number, payloadBytes: number, + mode?: RendererStressTerminalOutputMode, ) => Promise; getTerminalStressSummary: () => RendererStressTerminalSummary; releaseStressTerminalRuntimes: () => void; @@ -330,12 +337,110 @@ function makeTerminalStressOutput( index: number, lines: number, payloadBytes: number, + mode: RendererStressTerminalOutputMode = "scrollback", ): string { const boundedLines = Math.max(1, Math.min(500, Math.floor(lines))); const boundedPayloadBytes = Math.max( 0, Math.min(4096, Math.floor(payloadBytes)), ); + + if (mode === "atlas-rewrite") { + const text = + "!\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~ === !== <= >= -> => 红 node_modules"; + const red = (Math.floor(index / (128 * 128)) * 2) % 256; + const green = (Math.floor(index / 128) * 2) % 256; + const blue = (index * 2) % 256; + return [ + "\x1b[H\x1b[2J\x1b[mThis should be readable with white FG color.", + `r: ${red} g:${green} b:${blue}`, + `\x1b[38;2;${red};${green};${blue}m${text}\x1b[0m`, + ].join("\r\n"); + } + + if (mode === "tui-churn") { + const chunks: string[] = []; + if (index === 0) { + chunks.push("\x1b[?1049h\x1b[?25l\x1b[?7l"); + } + chunks.push("\x1b[H"); + for (let line = 0; line < boundedLines; line += 1) { + const red = (index * 13 + line * 17) % 256; + const green = (index * 23 + line * 19) % 256; + const blue = (index * 31 + line * 29) % 256; + const bgRed = (index * 7 + line * 11) % 256; + const bgGreen = (index * 5 + line * 13) % 256; + const bgBlue = (index * 3 + line * 17) % 256; + const prefix = `${String(index).padStart(5, "0")}:${String(line).padStart(3, "0")}`; + const body = + "█▓▒░ ╭─╮ ╰─╯ === !== <= >= -> => 红 node_modules diff --stat"; + chunks.push( + `\x1b[${line + 1};1H\x1b[38;2;${red};${green};${blue}m\x1b[48;2;${bgRed};${bgGreen};${bgBlue}m ${prefix} ${body} \x1b[0m\x1b[K`, + ); + } + return chunks.join(""); + } + + if (mode === "cjk-sgr") { + const randomSgrAttributes = [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "9", + "21", + "22", + "23", + "24", + "25", + "26", + "27", + "28", + "29", + "30", + "31", + "32", + "33", + "34", + "35", + "36", + "37", + "38", + "39", + "40", + "41", + "42", + "43", + "44", + "45", + "46", + "47", + "48", + "49", + ]; + const cjkStart = 0x4e00; + const cjkEnd = 0x9fcc; + const cjkCount = cjkEnd - cjkStart; + const charsPerWrite = Math.max(80, Math.min(2000, boundedLines * 80)); + const chunks = ["\r\n"]; + for (let offset = 0; offset < charsPerWrite; offset += 1) { + const codePoint = + cjkStart + ((index * charsPerWrite + offset) % cjkCount); + const sgr = + randomSgrAttributes[ + (index * 131 + offset * 17) % randomSgrAttributes.length + ]; + chunks.push(`\x1b[${sgr}m${String.fromCharCode(codePoint)}\x1b[0m`); + if (offset % 80 === 79) { + chunks.push("\r\n"); + } + } + return chunks.join(""); + } + const seed = `terminal-stress-${index}-`; const payload = boundedPayloadBytes > 0 @@ -603,9 +708,14 @@ export function useRendererStressWorkspaceBridge({ ), ); }, - writeTerminalStressOutput: async (index, lines, payloadBytes) => { + writeTerminalStressOutput: async (index, lines, payloadBytes, mode) => { const refs = getStressTerminalRefs(); - const output = makeTerminalStressOutput(index, lines, payloadBytes); + const output = makeTerminalStressOutput( + index, + lines, + payloadBytes, + mode, + ); const results = await Promise.all( refs.map((ref) => terminalRuntimeRegistry.writeForStress( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index bceb2c77739..297e540b0c1 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -59,6 +59,10 @@ export function getDefaultTerminalBg(): string { // Once WebGL fails, skip it for all subsequent terminals (VS Code pattern). let suggestedRendererType: "webgl" | "dom" | undefined; +function refreshTerminal(xterm: XTerm): void { + xterm.refresh(0, Math.max(0, xterm.rows - 1)); +} + export interface CreateTerminalOptions { /** * Workspace id used for worktree lookup during path stat/resolution. @@ -131,9 +135,13 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { try { webglAddon = new WebglAddon(); webglAddon.onContextLoss(() => { + suggestedRendererType = "dom"; webglAddon?.dispose(); webglAddon = null; - xterm.refresh(0, xterm.rows - 1); + refreshTerminal(xterm); + requestAnimationFrame(() => { + if (!disposed) refreshTerminal(xterm); + }); }); xterm.loadAddon(webglAddon); } catch { From b5dc410b142dd219cb288819a3b60e1446f8aab2 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 15 May 2026 10:48:56 -0700 Subject: [PATCH 15/16] Guard terminal WebGL atlas pressure --- apps/desktop/docs/TERMINAL_WEBGL_RENDERING.md | 71 ++-- apps/desktop/scripts/stress-renderer.ts | 390 +++++++++++++++++- .../renderer/lib/terminal/terminal-addons.ts | 37 +- .../lib/terminal/terminal-runtime-registry.ts | 22 + .../lib/terminal/terminal-webgl-addon.ts | 140 +++++++ .../useRendererStressWorkspaceBridge.ts | 37 ++ .../TabsContent/Terminal/helpers.ts | 38 +- 7 files changed, 639 insertions(+), 96 deletions(-) create mode 100644 apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon.ts diff --git a/apps/desktop/docs/TERMINAL_WEBGL_RENDERING.md b/apps/desktop/docs/TERMINAL_WEBGL_RENDERING.md index fe3725b7ba5..a7affafad68 100644 --- a/apps/desktop/docs/TERMINAL_WEBGL_RENDERING.md +++ b/apps/desktop/docs/TERMINAL_WEBGL_RENDERING.md @@ -17,10 +17,9 @@ terminal buffer may still be correct. - On macOS, Electron starts with `disable-backgrounding-occluded-windows` to avoid compositor throttling for windows that are covered or backgrounded. -- Terminal WebGL loading is optional and deferred by one animation frame in - both terminal creation paths: - - `apps/desktop/src/renderer/lib/terminal/terminal-addons.ts` - - `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts` +- Terminal WebGL loading is optional, centralized in + `apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon.ts`, and + deferred by one animation frame in both terminal creation paths. - If `WebglAddon` fails to load, the renderer records `suggestedRendererType = "dom"` and skips WebGL for later terminals in the same renderer process. @@ -30,6 +29,12 @@ terminal buffer may still be correct. and again on the next animation frame. That gives xterm a chance to keep rendering without the lost WebGL context and avoids re-entering the same GPU path for newly created terminals. +- If xterm's private WebGL atlas state reaches high pressure, the code clears + the atlas through `WebglAddon.clearTextureAtlas()` and refreshes the visible + rows. The guard currently trips when xterm has requested an atlas-model clear + or when any atlas page reaches at least 2048px wide/high. This is deliberately + renderer-local: it does not touch the PTY, replay, serialized buffer, pane + ownership, or terminal parking. - The v2 terminal runtime parks xterm wrappers on detach instead of tearing them down. Switching workspaces should reattach the same runtime instead of replaying a large terminal history into a fresh renderer. @@ -61,21 +66,27 @@ Likely causes to check first: Upstream xterm repros point at the same class of problem: -- `xtermjs/xterm.js#5847` reports WebGL row ghosting and glyph substitution - under heavy true-color output. +- `xtermjs/xterm.js#5847` + (https://github.com/xtermjs/xterm.js/issues/5847) is open and reports WebGL + row ghosting and glyph substitution under heavy true-color output. - `xtermjs/xterm.js#4534` and `xtermjs/xterm.js#4484` cover texture-atlas corruption under high color/atlas pressure. - `xtermjs/xterm.js#4351` covers blurry glyphs during atlas page merging, and `xtermjs/xterm.js#4480` covers TUI-driven WebGL render glitches. -## Current Gap +## Confirmed Reproduction -The confirmed local reproduction is a transient white/blank frame during -forced WebGL context loss. Pure xterm atlas pressure did not produce stable -glyph corruption locally, but it did reach xterm's pending atlas-clear state. +Stable WebGL glyph corruption reproduces locally with the renderer stress +harness by writing xterm's true-color atlas rewrite pattern, then comparing a +fixed sentinel frame before and after `clearTextureAtlas()`. -The remaining risk is that the browser can still show a one-frame blank while -the WebGL context loss event is being delivered. Once xterm calls our +Before the atlas guard, a 1-terminal run changed 241,147 pixels after clearing +the atlas. The saved before-clear screenshot had visible row ghosting and glyph +substitution, while the after-clear screenshot was clean. After the guard, the +same run completed with 0 changed pixels and WebGL still active. + +The remaining separate risk is that the browser can still show a one-frame +blank while a WebGL context-loss event is being delivered. Once xterm calls our context-loss handler, the terminal should fall back to DOM and repaint. ## Recommended Next Fixes @@ -144,33 +155,43 @@ bun --cwd apps/desktop stress:renderer -- \ --progress-every 10 ``` -For the atlas/glyph corruption family, use `--terminal-output-mode cjk-sgr`. -This mode recreates the xterm demo issue pattern by writing many CJK glyphs -with varying SGR attributes, which forces WebGL texture-atlas churn: +For the atlas/glyph corruption family, use `--terminal-output-mode +atlas-rewrite` plus `--terminal-corruption-check`. This recreates the upstream +xterm issue pattern by repeatedly rewriting the same rows with true-color SGR +attributes, then captures a deterministic sentinel frame before and after +`clearTextureAtlas()`: ```bash bun --cwd apps/desktop stress:renderer -- \ --port 9333 \ --scenario terminal-heavy \ - --terminal-iterations 1000 \ - --terminal-tab-count 8 \ + --terminal-iterations 1500 \ + --terminal-tab-count 1 \ --terminal-panes-per-tab 1 \ - --terminal-lines 25 \ + --terminal-lines 3 \ --terminal-payload-bytes 0 \ - --terminal-output-mode cjk-sgr \ + --terminal-output-mode atlas-rewrite \ + --terminal-corruption-check \ --interval-ms 0 \ - --settle-ms 2000 \ + --settle-ms 500 \ --timeout-ms 600000 \ --max-heartbeat-delay-ms 10000 \ --max-long-task-ms 10000 \ - --progress-every 100 \ + --progress-every 250 \ --json ``` -In the result, inspect `terminalStressSummary.terminalRuntimes[].renderer`. -Multiple large atlas pages such as `8192` or `4096`, especially with -`pendingAtlasClear: true`, mean the run reached the upstream texture-atlas -pressure path even if screenshot corruption is subtle. +The command writes before/after PNGs under +`apps/desktop/.tmp/renderer-terminal-corruption/` by default. In the JSON +result, inspect `terminalVisualCorruptionCheck.diff`. A non-zero diff above the +reported threshold means the visible pixels depended on stale atlas contents. +Also inspect `terminalStressSummary.terminalRuntimes[].renderer`: multiple atlas +pages, large `4096` pages, or `pendingAtlasClear: true` means the run reached +the upstream texture-atlas pressure path. + +`--terminal-output-mode cjk-sgr` is still useful as a broader atlas churn mode, +but it is harder to turn into a deterministic pixel detector than the +atlas-rewrite plus sentinel check. For a narrow A/B test, run that command once normally and once with WebGL loading locally disabled. A clean DOM run points at WebGL context/atlas diff --git a/apps/desktop/scripts/stress-renderer.ts b/apps/desktop/scripts/stress-renderer.ts index b7477ea64c1..b47342aa938 100644 --- a/apps/desktop/scripts/stress-renderer.ts +++ b/apps/desktop/scripts/stress-renderer.ts @@ -1,5 +1,8 @@ #!/usr/bin/env bun +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; + interface Args { host: string; port: number; @@ -13,6 +16,8 @@ interface Args { terminalLines: number; terminalPayloadBytes: number; terminalOutputMode: TerminalOutputMode; + terminalCorruptionCheck: boolean; + terminalCorruptionArtifactDir: string; includeTerminalAction: boolean; profileCpu: boolean; reactProbe: boolean; @@ -39,6 +44,7 @@ type StressScenario = type TerminalOutputMode = | "atlas-rewrite" | "cjk-sgr" + | "glyph-sentinel" | "scrollback" | "tui-churn"; @@ -84,6 +90,10 @@ interface RuntimeEvaluateResult { }; } +interface PageCaptureScreenshotResult { + data?: string; +} + interface CpuProfile { nodes: Array<{ id: number; @@ -135,6 +145,7 @@ interface RendererStressResult { terminalActionCounts: Record; terminalActionErrors: string[]; terminalStressSummary: unknown; + terminalVisualCorruptionCheck?: TerminalVisualCorruptionCheckResult | null; workspaceSummary: unknown; reactProbeSummary: unknown; durationMs: number; @@ -160,6 +171,48 @@ interface RendererStressResult { finalLocation: string; } +interface TerminalVisualCorruptionCheckResult { + available: boolean; + reason?: string; + clip?: { + x: number; + y: number; + width: number; + height: number; + scale: number; + }; + beforeClear?: TerminalVisualFrameInfo; + afterClear?: TerminalVisualFrameInfo; + diff?: TerminalVisualDiffSummary; + artifactPaths?: { + beforeClear: string; + afterClear: string; + }; + failed: boolean; +} + +interface TerminalVisualFrameInfo { + canvasCount: number; + rendererTypes: string[]; + atlasPageCounts: Array; + pendingAtlasClearCount: number; + stressTerminalRuntimeCount: number; + clearedAtlasCount?: number; +} + +interface TerminalVisualDiffSummary { + width: number; + height: number; + pixelCount: number; + changedPixels: number; + changedRatio: number; + maxChannelDelta: number; + meanChannelDelta: number; + changedRows: number; + changedColumns: number; + thresholdChangedPixels: number; +} + const DEFAULT_SELECTOR = "[data-renderer-stress-workspace-id]"; function parseArgs(argv: string[]): Args { @@ -176,6 +229,8 @@ function parseArgs(argv: string[]): Args { terminalLines: 40, terminalPayloadBytes: 1024, terminalOutputMode: "scrollback", + terminalCorruptionCheck: false, + terminalCorruptionArtifactDir: ".tmp/renderer-terminal-corruption", includeTerminalAction: false, profileCpu: false, reactProbe: false, @@ -260,6 +315,7 @@ function parseArgs(argv: string[]): Args { if ( mode !== "atlas-rewrite" && mode !== "cjk-sgr" && + mode !== "glyph-sentinel" && mode !== "scrollback" && mode !== "tui-churn" ) { @@ -268,6 +324,12 @@ function parseArgs(argv: string[]): Args { args.terminalOutputMode = mode; break; } + case "--terminal-corruption-check": + args.terminalCorruptionCheck = true; + break; + case "--terminal-corruption-artifact-dir": + args.terminalCorruptionArtifactDir = readValue(); + break; case "--include-terminal-action": args.includeTerminalAction = true; break; @@ -340,7 +402,11 @@ Options: Also controls generated tabs/panes for workspace-switch-heavy. --terminal-lines ANSI output lines per terminal write. Default: 40 --terminal-payload-bytes Repeated payload bytes per line. Default: 1024 - --terminal-output-mode scrollback, atlas-rewrite, tui-churn, or cjk-sgr. Default: scrollback + --terminal-output-mode scrollback, atlas-rewrite, tui-churn, cjk-sgr, or glyph-sentinel. Default: scrollback + --terminal-corruption-check After terminal-heavy stress, compare a fixed frame before/after clearTextureAtlas(). + --terminal-corruption-artifact-dir + Directory for before/after PNGs when --terminal-corruption-check is set. + Default: .tmp/renderer-terminal-corruption --include-terminal-action Include one real backend terminal launch in heavy stress. Default: false --profile-cpu Capture a CDP CPU profile and print hottest JS frames --react-probe Capture React commit/component counts via React DevTools hook when available @@ -550,6 +616,304 @@ async function getRendererTarget(args: Args): Promise { return target; } +async function evaluateInRenderer( + cdp: CdpClient, + expression: string, + timeoutMs: number, +): Promise { + const evaluation = await cdp.send( + "Runtime.evaluate", + { + expression, + awaitPromise: true, + returnByValue: true, + }, + timeoutMs, + ); + + if (evaluation.exceptionDetails) { + throw new Error( + evaluation.exceptionDetails.exception?.description ?? + evaluation.exceptionDetails.text ?? + "Renderer evaluation threw", + ); + } + + return evaluation.result?.value as T; +} + +function dataUrlToPngBuffer(dataUrl: string): Buffer { + const base64 = dataUrl.startsWith("data:") + ? dataUrl.slice(dataUrl.indexOf(",") + 1) + : dataUrl; + return Buffer.from(base64, "base64"); +} + +async function capturePngDataUrl( + cdp: CdpClient, + clip: TerminalVisualCorruptionCheckResult["clip"], + timeoutMs: number, +): Promise { + const result = await cdp.send( + "Page.captureScreenshot", + { + format: "png", + clip, + fromSurface: true, + captureBeyondViewport: false, + }, + timeoutMs, + ); + if (!result.data) throw new Error("Page.captureScreenshot returned no data"); + return `data:image/png;base64,${result.data}`; +} + +async function prepareTerminalVisualProbeFrame( + cdp: CdpClient, + timeoutMs: number, +): Promise< + | { + available: true; + clip: NonNullable; + frame: TerminalVisualFrameInfo; + } + | { available: false; reason: string } +> { + return evaluateInRenderer( + cdp, + `(async () => { + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); + const bridge = window.__SUPERSET_RENDERER_STRESS__; + if (!bridge) return { available: false, reason: "Renderer stress bridge is not mounted" }; + + bridge.switchTab(0); + await nextFrame(); + await nextFrame(); + await bridge.writeTerminalStressOutput(1_000_003, 36, 0, "glyph-sentinel"); + await nextFrame(); + await nextFrame(); + await sleep(250); + + const terminals = Array.from(document.querySelectorAll(".xterm")) + .map((element) => { + const rect = element.getBoundingClientRect(); + return { element, rect }; + }) + .filter(({ rect }) => rect.width > 20 && rect.height > 20); + if (terminals.length === 0) { + return { available: false, reason: "No visible xterm element found after sentinel write" }; + } + terminals.sort((left, right) => (right.rect.width * right.rect.height) - (left.rect.width * left.rect.height)); + const rect = terminals[0].rect; + const summary = bridge.getTerminalStressSummary(); + const runtimes = Array.isArray(summary?.terminalRuntimes) ? summary.terminalRuntimes : []; + const renderers = runtimes.map((runtime) => runtime.renderer).filter(Boolean); + return { + available: true, + clip: { + x: Math.max(0, Math.floor(rect.x)), + y: Math.max(0, Math.floor(rect.y)), + width: Math.max(1, Math.ceil(rect.width)), + height: Math.max(1, Math.ceil(rect.height)), + scale: 1 + }, + frame: { + canvasCount: terminals[0].element.querySelectorAll("canvas").length, + rendererTypes: Array.from(new Set(renderers.map((renderer) => renderer.rendererType).filter(Boolean))), + atlasPageCounts: renderers.map((renderer) => renderer.atlasPageCount ?? null), + pendingAtlasClearCount: renderers.filter((renderer) => renderer.pendingAtlasClear === true).length, + stressTerminalRuntimeCount: runtimes.length + } + }; + })()`, + timeoutMs, + ); +} + +async function clearTerminalVisualProbeAtlas( + cdp: CdpClient, + timeoutMs: number, +): Promise { + return evaluateInRenderer( + cdp, + `(async () => { + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); + const bridge = window.__SUPERSET_RENDERER_STRESS__; + if (!bridge) throw new Error("Renderer stress bridge is not mounted"); + const clearedAtlasCount = bridge.clearTerminalTextureAtlasesForStress(); + await nextFrame(); + await nextFrame(); + await sleep(250); + const summary = bridge.getTerminalStressSummary(); + const runtimes = Array.isArray(summary?.terminalRuntimes) ? summary.terminalRuntimes : []; + const renderers = runtimes.map((runtime) => runtime.renderer).filter(Boolean); + const terminal = document.querySelector(".xterm"); + return { + canvasCount: terminal ? terminal.querySelectorAll("canvas").length : 0, + rendererTypes: Array.from(new Set(renderers.map((renderer) => renderer.rendererType).filter(Boolean))), + atlasPageCounts: renderers.map((renderer) => renderer.atlasPageCount ?? null), + pendingAtlasClearCount: renderers.filter((renderer) => renderer.pendingAtlasClear === true).length, + stressTerminalRuntimeCount: runtimes.length, + clearedAtlasCount + }; + })()`, + timeoutMs, + ); +} + +async function compareScreenshotsInRenderer( + cdp: CdpClient, + beforeDataUrl: string, + afterDataUrl: string, + timeoutMs: number, +): Promise { + return evaluateInRenderer( + cdp, + `(async () => { + const beforeDataUrl = ${JSON.stringify(beforeDataUrl)}; + const afterDataUrl = ${JSON.stringify(afterDataUrl)}; + const loadImage = (src) => new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error("Failed to load screenshot")); + image.src = src; + }); + const [beforeImage, afterImage] = await Promise.all([ + loadImage(beforeDataUrl), + loadImage(afterDataUrl) + ]); + const width = Math.min(beforeImage.naturalWidth, afterImage.naturalWidth); + const height = Math.min(beforeImage.naturalHeight, afterImage.naturalHeight); + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d", { willReadFrequently: true }); + if (!ctx) throw new Error("Could not create 2d canvas context"); + ctx.drawImage(beforeImage, 0, 0, width, height); + const before = ctx.getImageData(0, 0, width, height).data; + ctx.clearRect(0, 0, width, height); + ctx.drawImage(afterImage, 0, 0, width, height); + const after = ctx.getImageData(0, 0, width, height).data; + + let changedPixels = 0; + let maxChannelDelta = 0; + let channelDeltaTotal = 0; + const changedRows = new Set(); + const changedColumns = new Set(); + for (let index = 0; index < before.length; index += 4) { + const dr = Math.abs(before[index] - after[index]); + const dg = Math.abs(before[index + 1] - after[index + 1]); + const db = Math.abs(before[index + 2] - after[index + 2]); + const da = Math.abs(before[index + 3] - after[index + 3]); + const delta = Math.max(dr, dg, db, da); + maxChannelDelta = Math.max(maxChannelDelta, delta); + channelDeltaTotal += dr + dg + db + da; + if (delta <= 8) continue; + const pixel = index / 4; + const x = pixel % width; + const y = Math.floor(pixel / width); + changedPixels += 1; + changedRows.add(y); + changedColumns.add(x); + } + const pixelCount = width * height; + const thresholdChangedPixels = Math.max(200, Math.floor(pixelCount * 0.001)); + return { + width, + height, + pixelCount, + changedPixels, + changedRatio: pixelCount > 0 ? changedPixels / pixelCount : 0, + maxChannelDelta, + meanChannelDelta: pixelCount > 0 ? channelDeltaTotal / (pixelCount * 4) : 0, + changedRows: changedRows.size, + changedColumns: changedColumns.size, + thresholdChangedPixels + }; + })()`, + timeoutMs, + ); +} + +async function restoreRendererStressBaseline( + cdp: CdpClient, + timeoutMs: number, +): Promise { + await evaluateInRenderer( + cdp, + `(() => { + window.__SUPERSET_RENDERER_STRESS__?.restoreBaseline(); + return true; + })()`, + timeoutMs, + ).catch(() => undefined); +} + +async function runTerminalVisualCorruptionCheck( + cdp: CdpClient, + args: Args, +): Promise { + const prepared = await prepareTerminalVisualProbeFrame(cdp, args.timeoutMs); + if (!prepared.available) { + await restoreRendererStressBaseline(cdp, args.timeoutMs); + return { available: false, reason: prepared.reason, failed: false }; + } + + let beforeDataUrl = ""; + let afterDataUrl = ""; + try { + beforeDataUrl = await capturePngDataUrl(cdp, prepared.clip, args.timeoutMs); + const afterClear = await clearTerminalVisualProbeAtlas(cdp, args.timeoutMs); + afterDataUrl = await capturePngDataUrl(cdp, prepared.clip, args.timeoutMs); + const diff = await compareScreenshotsInRenderer( + cdp, + beforeDataUrl, + afterDataUrl, + args.timeoutMs, + ); + const artifactPaths = await writeTerminalCorruptionArtifacts( + args.terminalCorruptionArtifactDir, + beforeDataUrl, + afterDataUrl, + ); + return { + available: true, + clip: prepared.clip, + beforeClear: prepared.frame, + afterClear, + diff, + artifactPaths, + failed: diff.changedPixels > diff.thresholdChangedPixels, + }; + } finally { + await restoreRendererStressBaseline(cdp, args.timeoutMs); + } +} + +async function writeTerminalCorruptionArtifacts( + artifactDir: string, + beforeDataUrl: string, + afterDataUrl: string, +): Promise { + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + await mkdir(artifactDir, { recursive: true }); + const beforeClear = join( + artifactDir, + `terminal-webgl-before-clear-${stamp}.png`, + ); + const afterClear = join( + artifactDir, + `terminal-webgl-after-clear-${stamp}.png`, + ); + await Promise.all([ + Bun.write(beforeClear, dataUrlToPngBuffer(beforeDataUrl)), + Bun.write(afterClear, dataUrlToPngBuffer(afterDataUrl)), + ]); + return { beforeClear, afterClear }; +} + function rendererStress(options: { scenario: StressScenario; iterations: number; @@ -561,6 +925,7 @@ function rendererStress(options: { terminalLines: number; terminalPayloadBytes: number; terminalOutputMode: TerminalOutputMode; + terminalCorruptionCheck: boolean; includeTerminalAction: boolean; reactProbe: boolean; progressEvery: number; @@ -873,6 +1238,7 @@ function rendererStress(options: { failedCount: number; byteLength: number; }>; + clearTerminalTextureAtlasesForStress: () => number; getTerminalStressSummary: () => unknown; releaseStressTerminalRuntimes: () => void; addRealTerminalTab: () => Promise; @@ -1474,9 +1840,11 @@ function rendererStress(options: { terminalStressSummary = activeBridge.getTerminalStressSummary(); } finally { - activeBridge.restoreBaseline(); - terminalActionCounts["restore-baseline"] = 1; - operationCount += 1; + if (!options.terminalCorruptionCheck) { + activeBridge.restoreBaseline(); + terminalActionCounts["restore-baseline"] = 1; + operationCount += 1; + } } } await sleep(options.settleMs); @@ -1601,6 +1969,7 @@ async function main() { terminalLines: args.terminalLines, terminalPayloadBytes: args.terminalPayloadBytes, terminalOutputMode: args.terminalOutputMode, + terminalCorruptionCheck: args.terminalCorruptionCheck, includeTerminalAction: args.includeTerminalAction, reactProbe: args.reactProbe, progressEvery: args.progressEvery, @@ -1633,6 +2002,9 @@ async function main() { } const summary = evaluation.result?.value as RendererStressResult; + const terminalVisualCorruptionCheck = args.terminalCorruptionCheck + ? await runTerminalVisualCorruptionCheck(cdp, args) + : null; const cdpMetrics = await cdp .send("Performance.getMetrics", {}, args.timeoutMs) .catch((error) => ({ error: String(error) })); @@ -1641,6 +2013,7 @@ async function main() { .catch((error) => ({ error: String(error) })); const output = { ...summary, + terminalVisualCorruptionCheck, cdpMetrics, cdpDomCounters: { start: startDomCounters, @@ -1676,6 +2049,15 @@ async function main() { )}ms exceeded ${args.maxLongTaskMs}ms`, ); } + if (terminalVisualCorruptionCheck?.failed) { + const changedPixels = + terminalVisualCorruptionCheck.diff?.changedPixels ?? 0; + const threshold = + terminalVisualCorruptionCheck.diff?.thresholdChangedPixels ?? 0; + failures.push( + `terminal visual corruption check changed ${changedPixels} pixel(s) after clearTextureAtlas(), threshold ${threshold}`, + ); + } if (args.json) { console.log(JSON.stringify({ ...output, failures }, null, 2)); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts b/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts index a622c0c9811..23e5cc1f29b 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts @@ -3,9 +3,9 @@ import { LigaturesAddon } from "@xterm/addon-ligatures"; import { ProgressAddon } from "@xterm/addon-progress"; import { SearchAddon } from "@xterm/addon-search"; import { Unicode11Addon } from "@xterm/addon-unicode11"; -import { WebglAddon } from "@xterm/addon-webgl"; import type { Terminal as XTerm } from "@xterm/xterm"; import { createTerminalImageAddon } from "./terminal-image-addon"; +import { scheduleWebglAddon } from "./terminal-webgl-addon"; export interface LoadAddonsResult { searchAddon: SearchAddon; @@ -13,13 +13,6 @@ export interface LoadAddonsResult { dispose: () => void; } -// Once WebGL fails, skip it for all subsequent runtimes (VS Code pattern). -let suggestedRendererType: "webgl" | "dom" | undefined; - -function refreshTerminal(terminal: XTerm): void { - terminal.refresh(0, Math.max(0, terminal.rows - 1)); -} - /** * Load optional addons onto an already-opened terminal. Returns a cleanup * function and addon instances. WebGL is deferred to rAF to avoid @@ -27,7 +20,6 @@ function refreshTerminal(terminal: XTerm): void { */ export function loadAddons(terminal: XTerm): LoadAddonsResult { let disposed = false; - let webglAddon: WebglAddon | null = null; terminal.loadAddon(new ClipboardAddon()); @@ -47,25 +39,8 @@ export function loadAddons(terminal: XTerm): LoadAddonsResult { terminal.loadAddon(new LigaturesAddon()); } catch {} - const rafId = requestAnimationFrame(() => { - if (disposed || suggestedRendererType === "dom") return; - - try { - webglAddon = new WebglAddon(); - webglAddon.onContextLoss(() => { - suggestedRendererType = "dom"; - webglAddon?.dispose(); - webglAddon = null; - refreshTerminal(terminal); - requestAnimationFrame(() => { - if (!disposed) refreshTerminal(terminal); - }); - }); - terminal.loadAddon(webglAddon); - } catch { - suggestedRendererType = "dom"; - webglAddon = null; - } + const disposeWebglAddon = scheduleWebglAddon(terminal, { + isDisposed: () => disposed, }); return { @@ -73,11 +48,7 @@ export function loadAddons(terminal: XTerm): LoadAddonsResult { progressAddon, dispose: () => { disposed = true; - cancelAnimationFrame(rafId); - try { - webglAddon?.dispose(); - } catch {} - webglAddon = null; + disposeWebglAddon(); }, }; } diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts index 4cca3d0ce86..836d9f12d8f 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -107,6 +107,22 @@ function getRendererStressDebugInfo( }; } +function clearRendererTextureAtlasForStress( + terminal: TerminalRuntime["terminal"], +): boolean { + const renderer = getTerminalRenderer(terminal); + const clearTextureAtlas = getObjectProperty(renderer, "clearTextureAtlas"); + if (typeof clearTextureAtlas !== "function") return false; + + try { + clearTextureAtlas.call(renderer); + terminal.refresh(0, Math.max(0, terminal.rows - 1)); + return true; + } catch { + return false; + } +} + class TerminalRuntimeRegistryImpl { private entries = new Map(); private entryKeysByTerminalId = new Map>(); @@ -412,6 +428,12 @@ class TerminalRuntimeRegistryImpl { }); } + clearTextureAtlasForStress(terminalId: string, instanceId?: string): boolean { + const entry = this.getEntry(terminalId, instanceId); + if (!entry?.runtime) return false; + return clearRendererTextureAtlasForStress(entry.runtime.terminal); + } + getStressDebugInfo( terminalId?: string, instanceId?: string, diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon.ts b/apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon.ts new file mode 100644 index 00000000000..66c3b44f965 --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon.ts @@ -0,0 +1,140 @@ +import { WebglAddon } from "@xterm/addon-webgl"; +import type { Terminal as XTerm } from "@xterm/xterm"; + +type Disposable = { dispose: () => void }; + +const ATLAS_GUARD_MIN_PAGE_SIZE = 2048; + +// Once WebGL fails, skip it for all subsequent runtimes in this renderer. +let suggestedRendererType: "webgl" | "dom" | undefined; + +function getObjectProperty(source: unknown, key: string): unknown { + if (!source || (typeof source !== "object" && typeof source !== "function")) { + return undefined; + } + return (source as Record)[key]; +} + +function getNumberProperty(source: unknown, key: string): number | null { + const value = getObjectProperty(source, key); + return typeof value === "number" ? value : null; +} + +function getBooleanProperty(source: unknown, key: string): boolean | null { + const value = getObjectProperty(source, key); + return typeof value === "boolean" ? value : null; +} + +function getTerminalRenderer(terminal: XTerm): unknown { + const core = getObjectProperty(terminal, "_core"); + const renderService = getObjectProperty(core, "_renderService"); + const rendererHolder = getObjectProperty(renderService, "_renderer"); + return getObjectProperty(rendererHolder, "value") ?? rendererHolder; +} + +function shouldClearTextureAtlas(terminal: XTerm): boolean { + const renderer = getTerminalRenderer(terminal); + const charAtlas = getObjectProperty(renderer, "_charAtlas"); + const pendingAtlasClear = + getBooleanProperty(charAtlas, "_requestClearModel") === true; + const rawPages = getObjectProperty(charAtlas, "pages"); + if (!Array.isArray(rawPages)) return false; + + const pageSizes = rawPages + .map((page) => + getNumberProperty(getObjectProperty(page, "canvas"), "width"), + ) + .filter((size): size is number => size !== null); + + return ( + pendingAtlasClear || + pageSizes.some((size) => size >= ATLAS_GUARD_MIN_PAGE_SIZE) + ); +} + +function refreshTerminal(terminal: XTerm): void { + terminal.refresh(0, Math.max(0, terminal.rows - 1)); +} + +export function scheduleWebglAddon( + terminal: XTerm, + options: { isDisposed?: () => boolean } = {}, +): () => void { + let disposed = false; + let webglAddon: WebglAddon | null = null; + let loadRafId: number | null = null; + let clearRafId: number | null = null; + const disposables: Disposable[] = []; + + const isDisposed = () => disposed || (options.isDisposed?.() ?? false); + + const cleanupWebgl = () => { + while (disposables.length > 0) { + try { + disposables.pop()?.dispose(); + } catch {} + } + try { + webglAddon?.dispose(); + } catch {} + webglAddon = null; + }; + + const clearAtlasIfNeeded = () => { + clearRafId = null; + if (isDisposed() || !webglAddon) return; + if (!shouldClearTextureAtlas(terminal)) return; + + try { + webglAddon.clearTextureAtlas(); + refreshTerminal(terminal); + requestAnimationFrame(() => { + if (!isDisposed()) refreshTerminal(terminal); + }); + } catch {} + }; + + const scheduleAtlasClear = () => { + if (isDisposed() || clearRafId !== null) return; + clearRafId = requestAnimationFrame(clearAtlasIfNeeded); + }; + + loadRafId = requestAnimationFrame(() => { + loadRafId = null; + if (isDisposed() || suggestedRendererType === "dom") return; + + try { + webglAddon = new WebglAddon(); + disposables.push( + webglAddon.onContextLoss(() => { + suggestedRendererType = "dom"; + cleanupWebgl(); + refreshTerminal(terminal); + requestAnimationFrame(() => { + if (!isDisposed()) refreshTerminal(terminal); + }); + }), + webglAddon.onAddTextureAtlasCanvas(scheduleAtlasClear), + webglAddon.onRemoveTextureAtlasCanvas(scheduleAtlasClear), + webglAddon.onChangeTextureAtlas(scheduleAtlasClear), + ); + terminal.loadAddon(webglAddon); + } catch { + suggestedRendererType = "dom"; + cleanupWebgl(); + } + }); + + return () => { + disposed = true; + if (loadRafId !== null) { + cancelAnimationFrame(loadRafId); + loadRafId = null; + } + if (clearRafId !== null) { + cancelAnimationFrame(clearRafId); + clearRafId = null; + } + cleanupWebgl(); + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/useRendererStressWorkspaceBridge.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/useRendererStressWorkspaceBridge.ts index 0f7ff6b47ae..72869cbd475 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/useRendererStressWorkspaceBridge.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/useRendererStressWorkspaceBridge.ts @@ -52,6 +52,7 @@ interface RendererStressTerminalWriteResult { type RendererStressTerminalOutputMode = | "atlas-rewrite" | "cjk-sgr" + | "glyph-sentinel" | "scrollback" | "tui-churn"; @@ -87,6 +88,7 @@ interface RendererStressWorkspaceBridge { payloadBytes: number, mode?: RendererStressTerminalOutputMode, ) => Promise; + clearTerminalTextureAtlasesForStress: () => number; getTerminalStressSummary: () => RendererStressTerminalSummary; releaseStressTerminalRuntimes: () => void; addRealTerminalTab: () => Promise; @@ -441,6 +443,26 @@ function makeTerminalStressOutput( return chunks.join(""); } + if (mode === "glyph-sentinel") { + const rows = Math.max(8, Math.min(80, boundedLines)); + const chunks = ["\x1b[?25l\x1b[H\x1b[2J"]; + const text = + "!\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~ === !== <= >= -> => 红 node_modules"; + for (let row = 0; row < rows; row += 1) { + const red = (row * 37 + 19) % 256; + const green = (row * 53 + 29) % 256; + const blue = (row * 71 + 43) % 256; + const bgRed = (row * 11 + 7) % 96; + const bgGreen = (row * 17 + 13) % 96; + const bgBlue = (row * 23 + 17) % 96; + chunks.push( + `\x1b[${row + 1};1H\x1b[38;2;${red};${green};${blue}m\x1b[48;2;${bgRed};${bgGreen};${bgBlue}m` + + ` sentinel-${String(row).padStart(2, "0")} ${text} \x1b[0m\x1b[K`, + ); + } + return chunks.join(""); + } + const seed = `terminal-stress-${index}-`; const payload = boundedPayloadBytes > 0 @@ -733,6 +755,21 @@ export function useRendererStressWorkspaceBridge({ byteLength: output.length, }; }, + clearTerminalTextureAtlasesForStress: () => { + const refs = getStressTerminalRefs(); + let clearedCount = 0; + for (const ref of refs) { + if ( + terminalRuntimeRegistry.clearTextureAtlasForStress( + ref.terminalId, + ref.instanceId, + ) + ) { + clearedCount += 1; + } + } + return clearedCount; + }, getTerminalStressSummary: () => { const stressTerminalIds = stressTerminalIdsRef.current; const terminalRuntimes = terminalRuntimeRegistry diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index 297e540b0c1..5dfaf693dd7 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -4,12 +4,12 @@ import { FitAddon } from "@xterm/addon-fit"; import { LigaturesAddon } from "@xterm/addon-ligatures"; import { SearchAddon } from "@xterm/addon-search"; import { Unicode11Addon } from "@xterm/addon-unicode11"; -import { WebglAddon } from "@xterm/addon-webgl"; import type { ITheme } from "@xterm/xterm"; import { Terminal as XTerm } from "@xterm/xterm"; import type { DetectedLink } from "renderer/lib/terminal/links"; import { createTerminalImageAddon } from "renderer/lib/terminal/terminal-image-addon"; import { TerminalLinkManager } from "renderer/lib/terminal/terminal-link-manager"; +import { scheduleWebglAddon } from "renderer/lib/terminal/terminal-webgl-addon"; import { electronTrpcClient as trpcClient } from "renderer/lib/trpc-client"; import { toXtermTheme } from "renderer/stores/theme/utils"; import { @@ -56,13 +56,6 @@ export function getDefaultTerminalBg(): string { return getDefaultTerminalTheme().background ?? "#151110"; } -// Once WebGL fails, skip it for all subsequent terminals (VS Code pattern). -let suggestedRendererType: "webgl" | "dom" | undefined; - -function refreshTerminal(xterm: XTerm): void { - xterm.refresh(0, Math.max(0, xterm.rows - 1)); -} - export interface CreateTerminalOptions { /** * Workspace id used for worktree lookup during path stat/resolution. @@ -108,7 +101,6 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { const imageAddon = createTerminalImageAddon(); let disposed = false; - let webglAddon: WebglAddon | null = null; // Open into a detached wrapper div — not the live container. const wrapper = document.createElement("div"); @@ -128,26 +120,8 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { // Ligatures not supported by current font } - // Defer WebGL to rAF to avoid racing xterm's post-open viewport sync. - const rafId = requestAnimationFrame(() => { - if (disposed || suggestedRendererType === "dom") return; - - try { - webglAddon = new WebglAddon(); - webglAddon.onContextLoss(() => { - suggestedRendererType = "dom"; - webglAddon?.dispose(); - webglAddon = null; - refreshTerminal(xterm); - requestAnimationFrame(() => { - if (!disposed) refreshTerminal(xterm); - }); - }); - xterm.loadAddon(webglAddon); - } catch { - suggestedRendererType = "dom"; - webglAddon = null; - } + const disposeWebglAddon = scheduleWebglAddon(xterm, { + isDisposed: () => disposed, }); const cleanupQuerySuppression = suppressQueryResponses(xterm); @@ -213,13 +187,9 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { linkManager, cleanup: () => { disposed = true; - cancelAnimationFrame(rafId); cleanupQuerySuppression(); linkManager.dispose(); - try { - webglAddon?.dispose(); - } catch {} - webglAddon = null; + disposeWebglAddon(); }, }; } From 3f7ee5e8ebc9189fa45ed1da0e41c8e0d54195f5 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 15 May 2026 18:17:39 -0700 Subject: [PATCH 16/16] Remove speculative renderer fixes --- apps/desktop/docs/RENDERER_STRESS_QA.md | 227 -- apps/desktop/docs/TERMINAL_WEBGL_RENDERING.md | 234 +- apps/desktop/docs/V2_LAUNCH_TEST_PLAN.md | 3 - apps/desktop/package.json | 4 +- .../prepare-renderer-stress-fixtures.ts | 876 ------- apps/desktop/scripts/stress-renderer.ts | 2084 ----------------- apps/desktop/src/main/index.ts | 18 - apps/desktop/src/main/lib/extensions/index.ts | 1 - .../main/lib/host-service-coordinator.test.ts | 5 - apps/desktop/src/renderer/index.tsx | 13 - apps/desktop/src/renderer/lib/posthog.test.ts | 24 - apps/desktop/src/renderer/lib/posthog.ts | 38 +- .../terminal-runtime-registry.test.ts | 280 --- .../lib/terminal/terminal-runtime-registry.ts | 148 -- .../renderer/lib/terminal/terminal-runtime.ts | 22 +- .../DashboardSidebarWorkspaceItem.tsx | 2 - .../DashboardSidebarExpandedWorkspaceRow.tsx | 24 +- ...useDashboardSidebarWorkspaceItemActions.ts | 7 +- .../useDashboardSidebarShortcuts.ts | 6 +- .../_dashboard/utils/workspace-navigation.ts | 121 +- .../BrowserPane/browserRuntimeRegistry.ts | 23 - .../TerminalRemoteControlButton.tsx | 133 +- .../useRendererStressWorkspaceBridge/index.ts | 1 - .../useRendererStressWorkspaceBridge.ts | 804 ------- .../v2-workspace/$workspaceId/page.tsx | 38 +- .../_dashboard/v2-workspace/layout.tsx | 7 +- .../V2WorkspaceRow/V2WorkspaceRow.tsx | 5 +- turbo.jsonc | 3 +- 28 files changed, 157 insertions(+), 4994 deletions(-) delete mode 100644 apps/desktop/docs/RENDERER_STRESS_QA.md delete mode 100644 apps/desktop/scripts/prepare-renderer-stress-fixtures.ts delete mode 100644 apps/desktop/scripts/stress-renderer.ts delete mode 100644 apps/desktop/src/renderer/lib/posthog.test.ts delete mode 100644 apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/useRendererStressWorkspaceBridge.ts diff --git a/apps/desktop/docs/RENDERER_STRESS_QA.md b/apps/desktop/docs/RENDERER_STRESS_QA.md deleted file mode 100644 index 6afed217c5d..00000000000 --- a/apps/desktop/docs/RENDERER_STRESS_QA.md +++ /dev/null @@ -1,227 +0,0 @@ -# Renderer Stress QA Fixtures and Instrumentation - -This is the repeatable process for local renderer QA with seeded V2 -workspaces, large dirty diffs, terminal stress, and renderer instrumentation. -Use it before broad manual QA when a change may -affect workspace switching, changes/diff panels, terminal rendering, tab/pane -state, or renderer responsiveness. - -For terminal WebGL context loss, blank panes, or glyph/font corruption, see -`apps/desktop/docs/TERMINAL_WEBGL_RENDERING.md`. - -## Safety - -- Run this only against a local dev `SUPERSET_HOME_DIR`. The fixture command - writes local TanStack and host-service SQLite rows. -- Do not point this at production or shared data. -- The fixture worktrees are disposable. By default they live at - `~/workplace/playground/superset-renderer-stress-fixtures`. -- If the desktop app is already open when fixtures are created, restart it - before running stress so the main process sees the seeded host DB state. - -## What The Fixture Command Seeds - -`bun --cwd apps/desktop stress:renderer:fixtures` creates: - -- A disposable git repo with many tracked files. -- Multiple git worktrees with large dirty changes, deletes, new files, and - renames. -- A V2 project row in the local persisted collections DB. -- V2 workspace rows for the generated worktrees. -- Matching host-service `projects` and `workspaces` rows in - `host//host.db`. - -The stress harness then drives those workspaces through the renderer using the -Chrome DevTools Protocol (CDP) and the in-app renderer stress bridge. - -## Prepare Fixture Workspaces - -For a normal large-diff run: - -```bash -bun --cwd apps/desktop stress:renderer:fixtures -- --json -``` - -For a smaller smoke fixture while iterating: - -```bash -bun --cwd apps/desktop stress:renderer:fixtures -- \ - --workspace-count 2 \ - --changed-files 20 \ - --lines-per-file 20 \ - --base-files 80 \ - --json -``` - -If you are running with `SKIP_ENV_VALIDATION=1`, the renderer may use the -mock auth organization id (`mock-org-id`). In that case, seed the fixture into -that org and pass a host id from an existing local host DB: - -```bash -bun --cwd apps/desktop stress:renderer:fixtures -- \ - --organization-id mock-org-id \ - --host-id \ - --workspace-count 2 \ - --changed-files 20 \ - --lines-per-file 20 \ - --base-files 80 \ - --json -``` - -If the fixture command reports `Host DB not found` for the mock org, copy an -existing local host DB into the mock org path before seeding: - -```bash -mkdir -p superset-dev-data/host/mock-org-id -sqlite3 superset-dev-data/host//host.db \ - ".backup 'superset-dev-data/host/mock-org-id/host.db'" -``` - -Keep the `workspaceIds` from the JSON output. Most targeted QA runs should pass -those ids explicitly so the harness exercises real seeded workspaces instead -of falling back to hash navigation. - -## Start The Desktop App With CDP - -Start the dev app in one shell: - -```bash -SKIP_ENV_VALIDATION=1 \ -SUPERSET_RENDERER_STRESS_CDP_PORT=9333 \ -bun --cwd apps/desktop dev -``` - -`SUPERSET_RENDERER_STRESS_CDP_PORT` opens the Electron renderer for CDP-based -stress automation. The main process also disables extension loading while this -variable is set so extension noise does not affect results. - -## Run The Stress Harness - -Run stress from another shell after the app has loaded: - -```bash -bun --cwd apps/desktop stress:renderer -- \ - --port 9333 \ - --scenario all \ - --workspace-ids , \ - --iterations 1000 \ - --route-iterations 240 \ - --heavy-iterations 500 \ - --timeout-ms 300000 -``` - -For the terminal-heavy flow we used in this PR: - -```bash -bun --cwd apps/desktop stress:renderer -- \ - --port 9333 \ - --scenario terminal-heavy \ - --workspace-ids , \ - --terminal-iterations 200 \ - --terminal-tab-count 32 \ - --terminal-panes-per-tab 4 \ - --terminal-lines 80 \ - --terminal-payload-bytes 4096 \ - --interval-ms 0 \ - --settle-ms 1500 \ - --timeout-ms 300000 \ - --max-heartbeat-delay-ms 10000 \ - --max-long-task-ms 10000 \ - --progress-every 10 -``` - -## Useful Scenarios - -- `workspace-switch`: repeatedly activates workspaces. -- `workspace-switch-heavy`: activates workspaces while generating synthetic - tabs and panes. -- `workspace-heavy`: runs mixed workspace, pane, browser, and diff actions. -- `route-sweep`: navigates renderer routes. -- `terminal-heavy`: creates synthetic terminal tabs/panes, writes large ANSI - payloads, and switches tabs. -- `all`: combines route, workspace, heavy, and terminal coverage. - -## Instrumentation Knobs - -Use these options when a run fails or feels slow: - -```bash ---profile-cpu ---react-probe ---json ---progress-every 10 ---max-heartbeat-delay-ms ---max-long-task-ms ---timeout-ms -``` - -What to look at: - -- `errorCount` and `errors`: uncaught renderer errors and unhandled rejections. -- `maxHeartbeatDelayMs`: event-loop stalls detected by the renderer heartbeat. -- `maxLongTaskDurationMs` and `longTasks`: browser long-task evidence. -- CPU profile output: hottest JS frames during the run. -- React probe output: commit/component counts when the React DevTools hook is - available. - -For strict automated runs, keep the default heartbeat and long-task thresholds. -For crash reproduction or exploratory QA, raise them so the harness can keep -running long enough to collect the actual renderer error. - -## Reproducing A Suspected Regression - -Use paired runs so the result identifies the failing subsystem instead of just -the commit range: - -1. Run the fixture command and record the generated workspace ids. -2. Start the app with CDP enabled. -3. Run the target stress scenario on the current branch. -4. Locally revert only the suspected fix with `git revert --no-commit ` - or restore only the relevant files. -5. Restart the app with CDP enabled. -6. Run the exact same stress command with the same workspace ids. -7. Restore the branch and rerun the command once more. - -In this PR, that process showed the renderer did not need a full process crash -to fail. The failing signal was an unhandled rejection: - -```text -RangeError: WebAssembly.instantiate(): Out of memory -``` - -The stack came from `@xterm/addon-image`, and it reproduced both with the -terminal WebGL changes reverted and restored. That isolated the issue to -per-terminal image decoder memory, not daemon update/attach behavior and not -terminal sizing. - -## Manual QA After Stress - -After an automated run passes, keep the app open and manually check: - -- Workspace switching between the seeded large-diff workspaces. -- Changes panel status, file list, file selection, diff render, and staging. -- Terminal tab switching, pane splitting, clear, search, and resize. -- New terminal creation immediately after attach. The terminal should size - correctly without waiting for a later layout event. -- Returning to an existing terminal. It should not perform a full replay just - because the user switched away and back. -- Navigation away from and back to the workspace route. -- Opening and closing enough terminal tabs/panes to verify terminal geometry - remains stable. - -## Cleanup - -The fixture command removes previous rows for the generated -`renderer-stress-large-diff` project before inserting fresh rows. To remove the -fixture files manually: - -```bash -rm -rf ~/workplace/playground/superset-renderer-stress-fixtures -``` - -If you copied a host DB into `mock-org-id` only for stress testing, remove it -when you want to return to the normal local auth org: - -```bash -rm -rf superset-dev-data/host/mock-org-id -``` diff --git a/apps/desktop/docs/TERMINAL_WEBGL_RENDERING.md b/apps/desktop/docs/TERMINAL_WEBGL_RENDERING.md index a7affafad68..718490242df 100644 --- a/apps/desktop/docs/TERMINAL_WEBGL_RENDERING.md +++ b/apps/desktop/docs/TERMINAL_WEBGL_RENDERING.md @@ -1,199 +1,35 @@ -# Terminal WebGL Rendering and Glyph Corruption - -This note covers terminal rendering failures where the PTY stream is still -alive but the terminal pixels are wrong: blank panes after switching, smeared -glyphs, overlapping text, or rows that look like a corrupted font atlas. - -If copying text out of the affected terminal returns clean text, treat the -failure as a renderer/GPU issue first. The backend session, daemon stream, and -terminal buffer may still be correct. - -## What We Already Do - -- Electron raises Chromium's active WebGL context cap to `256` in - `apps/desktop/src/lib/electron-app/factories/app/setup.ts`. Each xterm WebGL - renderer owns a context, and parked terminal panes can otherwise exceed - Chromium's default cap of `16`, causing forced context eviction. -- On macOS, Electron starts with - `disable-backgrounding-occluded-windows` to avoid compositor throttling for - windows that are covered or backgrounded. -- Terminal WebGL loading is optional, centralized in - `apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon.ts`, and - deferred by one animation frame in both terminal creation paths. -- If `WebglAddon` fails to load, the renderer records - `suggestedRendererType = "dom"` and skips WebGL for later terminals in the - same renderer process. -- If WebGL reports context loss, the current code disposes that terminal's - `WebglAddon`, clears the addon reference, promotes the renderer process to - DOM fallback for later terminals, and refreshes the visible rows immediately - and again on the next animation frame. That gives xterm a chance to keep - rendering without the lost WebGL context and avoids re-entering the same GPU - path for newly created terminals. -- If xterm's private WebGL atlas state reaches high pressure, the code clears - the atlas through `WebglAddon.clearTextureAtlas()` and refreshes the visible - rows. The guard currently trips when xterm has requested an atlas-model clear - or when any atlas page reaches at least 2048px wide/high. This is deliberately - renderer-local: it does not touch the PTY, replay, serialized buffer, pane - ownership, or terminal parking. -- The v2 terminal runtime parks xterm wrappers on detach instead of tearing - them down. Switching workspaces should reattach the same runtime instead of - replaying a large terminal history into a fresh renderer. -- Unicode 11 is activated before restoring serialized buffers. That prevents - CJK, emoji, and ZWJ-width decisions from being baked into replay with the - wrong width table. -- The image addon is bounded in - `apps/desktop/src/renderer/lib/terminal/terminal-image-addon.ts`: - `storageLimit: 16`, `pixelLimit: 1_048_576`, and 8 MB limits for kitty/IIP - payloads. This avoids one image-heavy terminal consuming unbounded renderer - memory. -- Terminal dispose is delayed by two animation frames after parking the wrapper - so pending xterm refresh work can drain before the DOM node disappears. - -## What The Font-Breaking Screenshot Suggests - -The screenshot with mixed, smeared glyphs looks more like a WebGL glyph atlas -or renderer-cache failure than a bad PTY stream. The terminal prompt and layout -still exist, but glyphs from previous cells appear to bleed through the current -row. - -Likely causes to check first: - -- WebGL context loss or forced context eviction. -- GPU texture atlas corruption after a context loss. -- Too many live terminal WebGL contexts while parked terminals remain alive. -- Font or ligature cache mismatch after appearance changes. -- xterm refresh work racing with a hidden, detached, or zero-sized terminal. - -Upstream xterm repros point at the same class of problem: - -- `xtermjs/xterm.js#5847` - (https://github.com/xtermjs/xterm.js/issues/5847) is open and reports WebGL - row ghosting and glyph substitution under heavy true-color output. -- `xtermjs/xterm.js#4534` and `xtermjs/xterm.js#4484` cover texture-atlas - corruption under high color/atlas pressure. -- `xtermjs/xterm.js#4351` covers blurry glyphs during atlas page merging, and - `xtermjs/xterm.js#4480` covers TUI-driven WebGL render glitches. - -## Confirmed Reproduction - -Stable WebGL glyph corruption reproduces locally with the renderer stress -harness by writing xterm's true-color atlas rewrite pattern, then comparing a -fixed sentinel frame before and after `clearTextureAtlas()`. - -Before the atlas guard, a 1-terminal run changed 241,147 pixels after clearing -the atlas. The saved before-clear screenshot had visible row ghosting and glyph -substitution, while the after-clear screenshot was clean. After the guard, the -same run completed with 0 changed pixels and WebGL still active. - -The remaining separate risk is that the browser can still show a one-frame -blank while a WebGL context-loss event is being delivered. Once xterm calls our -context-loss handler, the terminal should fall back to DOM and repaint. - -## Recommended Next Fixes - -1. Add a developer kill switch for diagnosis, for example - `SUPERSET_TERMINAL_RENDERER=dom`. This should bypass WebGL loading without - changing terminal session behavior, replay, persistence, or pane ownership. -2. If corruption survives after WebGL disposal, rebuild only the xterm renderer - runtime from the serialized terminal buffer. Do not kill the backend PTY and - do not force a daemon replay on ordinary workspace switches. -3. Consider a terminal WebGL budget instead of raising the context cap further: - keep WebGL on visible terminals and use DOM for newly created terminals once - the live WebGL count is above a conservative limit. This preserves parked - terminal sessions without letting hidden panes own unlimited GPU contexts. -4. If the issue reproduces with DOM rendering, isolate font and ligature state: - disable `LigaturesAddon` behind the same diagnostic switch and verify whether - the copied terminal text still matches the corrupt pixels. - -Avoid papering over this with periodic full-terminal refreshes, larger replay -windows, or more switch-time terminal reconstruction. Those approaches make -workspace switching slower and risk reintroducing the long background replay -problem. - -## Diagnosis Checklist - -- Copy several corrupt lines from the terminal. If the clipboard text is clean, - investigate renderer/WebGL/font state before the PTY stream. -- Check DevTools console for WebGL context loss, WebAssembly memory errors from - `@xterm/addon-image`, or renderer long-task warnings. -- Confirm whether corruption appears only after workspace switches, only after - returning from background, or immediately after opening many terminals. -- Compare a run with WebGL enabled against a local patch that skips WebGL - loading. If DOM rendering is clean, the fix should stay in the addon/rendering - path, not the terminal transport path. -- Keep the desktop logs open during reproduction. Renderer crashes without a - JS error often still leave GPU, WebContents, or process-exit evidence there. - -## Stress Commands - -Use the general stress guide in -`apps/desktop/docs/RENDERER_STRESS_QA.md` for fixture setup and full runs. - -For targeted terminal rendering stress: - -```bash -SUPERSET_RENDERER_STRESS_CDP_PORT=9333 bun --cwd apps/desktop dev -``` - -Then, in another shell: - -```bash -bun --cwd apps/desktop stress:renderer -- \ - --port 9333 \ - --scenario terminal-heavy \ - --workspace-ids , \ - --terminal-iterations 200 \ - --terminal-tab-count 32 \ - --terminal-panes-per-tab 4 \ - --terminal-lines 80 \ - --terminal-payload-bytes 4096 \ - --interval-ms 0 \ - --settle-ms 1500 \ - --timeout-ms 300000 \ - --max-heartbeat-delay-ms 10000 \ - --max-long-task-ms 10000 \ - --progress-every 10 -``` - -For the atlas/glyph corruption family, use `--terminal-output-mode -atlas-rewrite` plus `--terminal-corruption-check`. This recreates the upstream -xterm issue pattern by repeatedly rewriting the same rows with true-color SGR -attributes, then captures a deterministic sentinel frame before and after -`clearTextureAtlas()`: - -```bash -bun --cwd apps/desktop stress:renderer -- \ - --port 9333 \ - --scenario terminal-heavy \ - --terminal-iterations 1500 \ - --terminal-tab-count 1 \ - --terminal-panes-per-tab 1 \ - --terminal-lines 3 \ - --terminal-payload-bytes 0 \ - --terminal-output-mode atlas-rewrite \ - --terminal-corruption-check \ - --interval-ms 0 \ - --settle-ms 500 \ - --timeout-ms 600000 \ - --max-heartbeat-delay-ms 10000 \ - --max-long-task-ms 10000 \ - --progress-every 250 \ - --json -``` - -The command writes before/after PNGs under -`apps/desktop/.tmp/renderer-terminal-corruption/` by default. In the JSON -result, inspect `terminalVisualCorruptionCheck.diff`. A non-zero diff above the -reported threshold means the visible pixels depended on stale atlas contents. -Also inspect `terminalStressSummary.terminalRuntimes[].renderer`: multiple atlas -pages, large `4096` pages, or `pendingAtlasClear: true` means the run reached -the upstream texture-atlas pressure path. - -`--terminal-output-mode cjk-sgr` is still useful as a broader atlas churn mode, -but it is harder to turn into a deterministic pixel detector than the -atlas-rewrite plus sentinel check. - -For a narrow A/B test, run that command once normally and once with WebGL -loading locally disabled. A clean DOM run points at WebGL context/atlas -handling. A corrupt DOM run points at font metrics, ligatures, buffer restore, -or xterm sizing instead. +# Terminal WebGL Rendering + +This note covers the terminal rendering fixes in this PR. These are renderer +fixes only; they do not change PTY lifetime, replay, serialized terminal +buffers, pane ownership, or terminal parking. + +## Implemented Fixes + +- Terminal WebGL addon setup is centralized in + `apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon.ts`, so both + terminal creation paths use the same lifecycle. +- WebGL loading remains optional and delayed by one animation frame. +- If `WebglAddon` fails to load, later terminals in the same renderer process + skip WebGL and use xterm's non-WebGL renderer. +- If WebGL reports context loss, the current terminal disposes its WebGL addon, + refreshes visible rows immediately, refreshes again on the next animation + frame, and marks future terminals in that renderer process for DOM fallback. +- If xterm's WebGL atlas reaches pressure, the code calls the public + `WebglAddon.clearTextureAtlas()` API and refreshes visible rows. Detection is + limited to xterm renderer state: `_charAtlas._requestClearModel === true` or + an atlas page at least `2048px`. + +## Why This Fix Is Local + +Glyph corruption can happen while the copied terminal text and backend session +are still correct. That points at stale WebGL glyph atlas pixels rather than a +PTY stream or replay bug. Clearing the texture atlas forces xterm to rasterize +the visible glyphs again without rebuilding the terminal runtime or touching +the session. + +## Related Upstream Issue + +`xtermjs/xterm.js#5847` +(https://github.com/xtermjs/xterm.js/issues/5847) reports WebGL row ghosting +and glyph substitution under heavy true-color output. diff --git a/apps/desktop/docs/V2_LAUNCH_TEST_PLAN.md b/apps/desktop/docs/V2_LAUNCH_TEST_PLAN.md index a38a2917756..b95f581ecfe 100644 --- a/apps/desktop/docs/V2_LAUNCH_TEST_PLAN.md +++ b/apps/desktop/docs/V2_LAUNCH_TEST_PLAN.md @@ -6,9 +6,6 @@ Pair with `V2_LAUNCH_CONTEXT.md` for architectural background and ## Setup -For renderer stress fixtures, CDP instrumentation, terminal-heavy runs, and -large-diff workspace QA, see `RENDERER_STRESS_QA.md`. - 1. `bun dev` at the repo root. 2. Ensure your active org has V2 cloud enabled (or you're testing a V2 project). diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c9cd1fab44f..50936e60424 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -34,9 +34,7 @@ "generate:routes": "tsr generate", "pretypecheck": "bun run generate:icons && bun run generate:routes", "typecheck": "tsc --noEmit", - "test": "bun test", - "stress:renderer": "bun run scripts/stress-renderer.ts", - "stress:renderer:fixtures": "bun run scripts/prepare-renderer-stress-fixtures.ts" + "test": "bun test" }, "dependencies": { "@ai-sdk/anthropic": "3.0.64", diff --git a/apps/desktop/scripts/prepare-renderer-stress-fixtures.ts b/apps/desktop/scripts/prepare-renderer-stress-fixtures.ts deleted file mode 100644 index 1b46c5aed95..00000000000 --- a/apps/desktop/scripts/prepare-renderer-stress-fixtures.ts +++ /dev/null @@ -1,876 +0,0 @@ -#!/usr/bin/env bun - -import { Database } from "bun:sqlite"; -import { randomUUID } from "node:crypto"; -import { existsSync, readFileSync } from "node:fs"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { homedir, hostname } from "node:os"; -import { basename, join, resolve } from "node:path"; - -interface Args { - root: string; - supersetHome: string; - workspaceCount: number; - changedFiles: number; - linesPerFile: number; - baseFiles: number; - organizationId?: string; - hostId?: string; - json: boolean; - help: boolean; -} - -interface FixtureWorkspace { - id: string; - branch: string; - worktreePath: string; -} - -interface CollectionRow { - key: string; - value: string; - row_version: number; -} - -const repoRoot = resolve(import.meta.dirname, "../../.."); -const envPath = resolve(repoRoot, ".env"); -const fixtureSlug = "renderer-stress-large-diff"; -const fixtureProjectName = "Renderer Stress Large Diff"; - -function parseEnvFile(path: string): Record { - if (!existsSync(path)) return {}; - const text = readFileSync(path, "utf8"); - const values: Record = {}; - for (const rawLine of text.split(/\r?\n/)) { - const line = rawLine.trim(); - if (!line || line.startsWith("#")) continue; - const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line); - if (!match) continue; - const [, key, rawValue] = match; - values[key] = rawValue.trim().replace(/^["']|["']$/g, ""); - } - return values; -} - -const envFile = parseEnvFile(envPath); - -function defaultSupersetHome(): string { - return ( - envFile.SUPERSET_HOME_DIR ?? - process.env.SUPERSET_HOME_DIR ?? - resolve(repoRoot, "superset-dev-data") - ); -} - -function parseArgs(argv: string[]): Args { - const args: Args = { - root: join( - homedir(), - "workplace", - "playground", - "superset-renderer-stress-fixtures", - ), - supersetHome: defaultSupersetHome(), - workspaceCount: 8, - changedFiles: 220, - linesPerFile: 80, - baseFiles: 320, - organizationId: - envFile.SUPERSET_ORGANIZATION_ID ?? process.env.SUPERSET_ORGANIZATION_ID, - hostId: undefined, - json: false, - help: false, - }; - - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - const readValue = () => { - const value = argv[index + 1]; - if (!value) throw new Error(`Missing value for ${arg}`); - index += 1; - return value; - }; - const readNumber = () => { - const value = Number(readValue()); - if (!Number.isInteger(value) || value < 1) { - throw new Error(`Invalid positive integer for ${arg}`); - } - return value; - }; - - switch (arg) { - case "--help": - case "-h": - args.help = true; - break; - case "--root": - args.root = resolve(readValue()); - break; - case "--superset-home": - args.supersetHome = resolve(readValue()); - break; - case "--workspace-count": - args.workspaceCount = readNumber(); - break; - case "--changed-files": - args.changedFiles = readNumber(); - break; - case "--lines-per-file": - args.linesPerFile = readNumber(); - break; - case "--base-files": - args.baseFiles = readNumber(); - break; - case "--organization-id": - args.organizationId = readValue(); - break; - case "--host-id": - args.hostId = readValue(); - break; - case "--json": - args.json = true; - break; - default: - throw new Error(`Unknown argument: ${arg}`); - } - } - - return args; -} - -function usage(): string { - return `Prepare renderer stress fixture workspaces with large dirty git changes. - -This seeds both the persisted V2 collections and the host-service SQLite DB. -Run it before starting the desktop dev app, or restart the app after running it. - -Usage: - bun --cwd apps/desktop stress:renderer:fixtures - bun --cwd apps/desktop stress:renderer:fixtures -- --workspace-count 10 --changed-files 300 - -Options: - --root Fixture root. Default: ~/workplace/playground/superset-renderer-stress-fixtures - --superset-home Superset home dir. Default: SUPERSET_HOME_DIR, .env, or ./superset-dev-data - --workspace-count Number of workspaces to create. Default: 8 - --changed-files Dirty changed files per workspace. Default: 220 - --lines-per-file Lines appended to each modified file. Default: 80 - --base-files Tracked files in the base repo. Default: 320 - --organization-id Organization id. Default: inferred from persisted collections - --host-id Host machine id. Default: inferred from existing host DB rows - --json Print machine-readable fixture metadata -`; -} - -async function run( - command: string, - args: string[], - options: { cwd?: string } = {}, -): Promise { - const process = Bun.spawn([command, ...args], { - cwd: options.cwd, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, code] = await Promise.all([ - new Response(process.stdout).text(), - new Response(process.stderr).text(), - process.exited, - ]); - if (code !== 0) { - throw new Error( - `${command} ${args.join(" ")} failed with ${code}\n${stderr || stdout}`, - ); - } - return stdout.trim(); -} - -function quoteIdentifier(name: string): string { - if (!/^[A-Za-z0-9_]+$/.test(name)) { - throw new Error(`Unsafe SQLite identifier: ${name}`); - } - return `"${name}"`; -} - -function getCollectionTable(db: Database, collectionId: string): string { - const row = db - .query("select table_name from collection_registry where collection_id = ?") - .get(collectionId) as { table_name?: string } | null; - if (!row?.table_name) { - throw new Error(`Persisted collection not found: ${collectionId}`); - } - return row.table_name; -} - -function inferOrganizationId(db: Database, explicit?: string): string { - if (explicit) return explicit; - const row = db - .query( - "select collection_id from collection_registry where collection_id like 'v2_workspaces-%' limit 1", - ) - .get() as { collection_id?: string } | null; - const prefix = "v2_workspaces-"; - if (!row?.collection_id?.startsWith(prefix)) { - throw new Error( - "Could not infer organization id from persisted collections", - ); - } - return row.collection_id.slice(prefix.length); -} - -function getCollectionRows(db: Database, tableName: string): CollectionRow[] { - return db - .query( - `select key, value, row_version from ${quoteIdentifier(tableName)} order by row_version`, - ) - .all() as CollectionRow[]; -} - -function deleteCollectionKeys( - db: Database, - tableName: string, - keys: string[], -): void { - if (keys.length === 0) return; - const statement = db.query( - `delete from ${quoteIdentifier(tableName)} where key = ?`, - ); - const remove = db.transaction((items: string[]) => { - for (const key of items) statement.run(key); - }); - remove(keys); -} - -function getNextRowVersion(db: Database, collectionId: string): number { - const fromVersion = db - .query( - "select latest_row_version from collection_version where collection_id = ?", - ) - .get(collectionId) as { latest_row_version?: number } | null; - return (fromVersion?.latest_row_version ?? 0) + 1; -} - -function setCollectionVersion( - db: Database, - collectionId: string, - latestRowVersion: number, -): void { - db.query( - `insert into collection_version (collection_id, latest_row_version) - values (?, ?) - on conflict(collection_id) do update set latest_row_version = excluded.latest_row_version`, - ).run(collectionId, latestRowVersion); -} - -function insertCollectionValue( - db: Database, - tableName: string, - key: string, - value: unknown, - metadata: unknown, - rowVersion: number, -): void { - db.query( - `insert or replace into ${quoteIdentifier( - tableName, - )} (key, value, metadata, row_version) values (?, ?, ?, ?)`, - ).run(key, JSON.stringify(value), JSON.stringify(metadata), rowVersion); -} - -function parseCollectionValue(row: CollectionRow): T | null { - try { - return JSON.parse(row.value) as T; - } catch { - return null; - } -} - -function slugify(value: string): string { - return ( - value - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 60) || "renderer-stress" - ); -} - -function collectionKeyExists( - db: Database, - tableName: string, - key: string, -): boolean { - const row = db - .query(`select 1 as found from ${quoteIdentifier(tableName)} where key = ?`) - .get(key) as { found?: number } | null; - return Boolean(row?.found); -} - -function ensureOrganizationRow({ - tanstackDb, - organizationId, - now, -}: { - tanstackDb: Database; - organizationId: string; - now: string; -}): void { - const organizationCollectionId = "organizations"; - const organizationTable = getCollectionTable( - tanstackDb, - organizationCollectionId, - ); - const organizationKey = `s:${organizationId}`; - if (collectionKeyExists(tanstackDb, organizationTable, organizationKey)) { - return; - } - - const rowVersion = getNextRowVersion(tanstackDb, organizationCollectionId); - insertCollectionValue( - tanstackDb, - organizationTable, - organizationKey, - { - allowedDomains: [], - createdAt: now, - id: organizationId, - logo: null, - metadata: null, - name: `Renderer Stress ${organizationId}`, - slug: slugify(organizationId), - stripeCustomerId: null, - }, - { relation: ["auth", "organizations"], operation: "insert" }, - rowVersion, - ); - setCollectionVersion(tanstackDb, organizationCollectionId, rowVersion); -} - -function ensureHostRow({ - tanstackDb, - hostCollectionId, - hostTable, - organizationId, - hostId, - now, -}: { - tanstackDb: Database; - hostCollectionId: string; - hostTable: string; - organizationId: string; - hostId: string; - now: string; -}): void { - const hostKey = `s:${hostId}`; - if (collectionKeyExists(tanstackDb, hostTable, hostKey)) { - return; - } - - const rowVersion = getNextRowVersion(tanstackDb, hostCollectionId); - insertCollectionValue( - tanstackDb, - hostTable, - hostKey, - { - createdAt: now, - createdByUserId: null, - isOnline: true, - machineId: hostId, - name: hostname() || "Renderer Stress Host", - organizationId, - updatedAt: now, - }, - { relation: ["public", "v2_hosts"], operation: "insert" }, - rowVersion, - ); - setCollectionVersion(tanstackDb, hostCollectionId, rowVersion); -} - -function inferHostId({ - explicit, - tanstackDb, - hostDb, - workspaceTable, - hostTable, -}: { - explicit?: string; - tanstackDb: Database; - hostDb: Database; - workspaceTable: string; - hostTable: string; -}): string { - if (explicit) return explicit; - - const hostWorkspaceIds = new Set( - ( - hostDb.query("select id from workspaces").all() as Array<{ id: string }> - ).map((row) => row.id), - ); - - for (const row of getCollectionRows(tanstackDb, workspaceTable)) { - const workspace = parseCollectionValue<{ id: string; hostId?: string }>( - row, - ); - if ( - workspace?.id && - hostWorkspaceIds.has(workspace.id) && - workspace.hostId - ) { - return workspace.hostId; - } - } - - for (const row of getCollectionRows(tanstackDb, hostTable)) { - const host = parseCollectionValue<{ - machineId?: string; - isOnline?: boolean; - }>(row); - if (host?.machineId && host.isOnline !== false) { - return host.machineId; - } - } - - throw new Error("Could not infer host id; pass --host-id"); -} - -async function writeBaseRepo({ - repoPath, - baseFiles, - linesPerFile, -}: { - repoPath: string; - baseFiles: number; - linesPerFile: number; -}): Promise { - await mkdir(repoPath, { recursive: true }); - await run("git", ["init", "-b", "main"], { cwd: repoPath }); - await run("git", ["config", "user.name", "Renderer Stress"], { - cwd: repoPath, - }); - await run( - "git", - ["config", "user.email", "renderer-stress@example.invalid"], - { - cwd: repoPath, - }, - ); - - for (let index = 0; index < baseFiles; index += 1) { - const moduleId = String(index % 20).padStart(2, "0"); - const fileId = String(index).padStart(3, "0"); - const dir = join(repoPath, "src", `module-${moduleId}`); - await mkdir(dir, { recursive: true }); - const body = Array.from( - { length: Math.max(8, Math.floor(linesPerFile / 2)) }, - (_, line) => `export const value${line} = ${index + line};`, - ).join("\n"); - await writeFile(join(dir, `file-${fileId}.ts`), `${body}\n`); - } - - await writeFile( - join(repoPath, "README.md"), - `# Renderer Stress Fixture\n\nGenerated for large-diff renderer stress tests.\n`, - ); - await run("git", ["add", "."], { cwd: repoPath }); - await run("git", ["commit", "-m", "seed renderer stress fixture"], { - cwd: repoPath, - }); -} - -async function mutateWorktree({ - worktreePath, - workspaceIndex, - changedFiles, - linesPerFile, - baseFiles, -}: { - worktreePath: string; - workspaceIndex: number; - changedFiles: number; - linesPerFile: number; - baseFiles: number; -}): Promise { - for (let index = 0; index < changedFiles; index += 1) { - const fileIndex = (workspaceIndex * 37 + index) % baseFiles; - const moduleId = String(fileIndex % 20).padStart(2, "0"); - const fileId = String(fileIndex).padStart(3, "0"); - const filePath = join( - worktreePath, - "src", - `module-${moduleId}`, - `file-${fileId}.ts`, - ); - const body = Array.from( - { length: linesPerFile }, - (_, line) => - `export const stress${workspaceIndex}_${index}_${line} = ${workspaceIndex + index + line};`, - ).join("\n"); - await writeFile(filePath, `${body}\n`, { flag: "a" }); - } - - for ( - let index = 0; - index < Math.max(12, Math.floor(changedFiles / 8)); - index += 1 - ) { - const fileIndex = (workspaceIndex * 11 + index) % baseFiles; - const moduleId = String(fileIndex % 20).padStart(2, "0"); - const fileId = String(fileIndex).padStart(3, "0"); - await rm( - join(worktreePath, "src", `module-${moduleId}`, `file-${fileId}.ts`), - { - force: true, - }, - ); - } - - for ( - let index = 0; - index < Math.max(16, Math.floor(changedFiles / 6)); - index += 1 - ) { - const dir = join(worktreePath, "generated", `workspace-${workspaceIndex}`); - await mkdir(dir, { recursive: true }); - await writeFile( - join(dir, `new-file-${String(index).padStart(3, "0")}.ts`), - `export const generated = ${workspaceIndex * 1000 + index};\n`.repeat( - Math.max(12, Math.floor(linesPerFile / 2)), - ), - ); - } - - for ( - let index = 0; - index < Math.max(4, Math.floor(changedFiles / 50)); - index += 1 - ) { - const fileIndex = (workspaceIndex * 17 + index + 40) % baseFiles; - const moduleId = String(fileIndex % 20).padStart(2, "0"); - const fileId = String(fileIndex).padStart(3, "0"); - const oldPath = join("src", `module-${moduleId}`, `file-${fileId}.ts`); - const newDir = join("renamed", `workspace-${workspaceIndex}`); - await mkdir(join(worktreePath, newDir), { recursive: true }); - await run("git", ["mv", oldPath, join(newDir, `renamed-${fileId}.ts`)], { - cwd: worktreePath, - }).catch(() => undefined); - } - - await writeFile( - join(worktreePath, `large-single-file-${workspaceIndex}.txt`), - Array.from( - { length: linesPerFile * 20 }, - (_, line) => `workspace ${workspaceIndex} large line ${line}`, - ).join("\n"), - ); - - await run("git", ["add", "src", "renamed"], { cwd: worktreePath }).catch( - () => undefined, - ); -} - -async function createGitFixtures(args: Args): Promise<{ - projectId: string; - repoPath: string; - workspaces: FixtureWorkspace[]; -}> { - const repoPath = join(args.root, "large-diff-repo"); - const worktreesRoot = join(args.root, "worktrees"); - - await rm(repoPath, { recursive: true, force: true }); - await rm(worktreesRoot, { recursive: true, force: true }); - await mkdir(worktreesRoot, { recursive: true }); - await writeBaseRepo({ - repoPath, - baseFiles: Math.max(args.baseFiles, args.changedFiles + 40), - linesPerFile: args.linesPerFile, - }); - - const workspaces: FixtureWorkspace[] = []; - for (let index = 0; index < args.workspaceCount; index += 1) { - const branch = `renderer-stress/large-${String(index + 1).padStart(2, "0")}`; - const worktreePath = join( - worktreesRoot, - `large-${String(index + 1).padStart(2, "0")}`, - ); - await run("git", ["worktree", "add", "-b", branch, worktreePath, "main"], { - cwd: repoPath, - }); - await mutateWorktree({ - worktreePath, - workspaceIndex: index, - changedFiles: args.changedFiles, - linesPerFile: args.linesPerFile, - baseFiles: Math.max(args.baseFiles, args.changedFiles + 40), - }); - workspaces.push({ - id: randomUUID(), - branch, - worktreePath, - }); - } - - return { - projectId: randomUUID(), - repoPath, - workspaces, - }; -} - -function removePreviousFixtureRows({ - tanstackDb, - hostDb, - projectTable, - workspaceTable, - repoPath, -}: { - tanstackDb: Database; - hostDb: Database; - projectTable: string; - workspaceTable: string; - repoPath: string; -}): void { - const oldProjectIds = new Set(); - const projectKeysToDelete: string[] = []; - for (const row of getCollectionRows(tanstackDb, projectTable)) { - const project = parseCollectionValue<{ - id: string; - slug?: string; - repoCloneUrl?: string | null; - }>(row); - if ( - project?.id && - (project.slug === fixtureSlug || - project.repoCloneUrl === `file://${repoPath}`) - ) { - oldProjectIds.add(project.id); - projectKeysToDelete.push(row.key); - } - } - - const workspaceKeysToDelete: string[] = []; - for (const row of getCollectionRows(tanstackDb, workspaceTable)) { - const workspace = parseCollectionValue<{ - projectId?: string; - branch?: string; - }>(row); - if ( - workspace?.projectId && - (oldProjectIds.has(workspace.projectId) || - workspace.branch?.startsWith("renderer-stress/large-")) - ) { - workspaceKeysToDelete.push(row.key); - } - } - - deleteCollectionKeys(tanstackDb, workspaceTable, workspaceKeysToDelete); - deleteCollectionKeys(tanstackDb, projectTable, projectKeysToDelete); - - const hostProjectRows = hostDb - .query("select id from projects where repo_path = ?") - .all(repoPath) as Array<{ id: string }>; - for (const row of hostProjectRows) { - hostDb.query("delete from workspaces where project_id = ?").run(row.id); - hostDb.query("delete from projects where id = ?").run(row.id); - } -} - -async function seedDatabases({ - args, - projectId, - repoPath, - workspaces, -}: { - args: Args; - projectId: string; - repoPath: string; - workspaces: FixtureWorkspace[]; -}): Promise<{ organizationId: string; hostId: string }> { - const tanstackPath = join(args.supersetHome, "tanstack-db.sqlite"); - if (!existsSync(tanstackPath)) { - throw new Error(`TanStack DB not found: ${tanstackPath}`); - } - - const tanstackDb = new Database(tanstackPath); - const organizationId = inferOrganizationId(tanstackDb, args.organizationId); - const hostDbPath = join(args.supersetHome, "host", organizationId, "host.db"); - if (!existsSync(hostDbPath)) { - throw new Error(`Host DB not found: ${hostDbPath}`); - } - - const hostDb = new Database(hostDbPath); - const projectCollectionId = `v2_projects-${organizationId}`; - const workspaceCollectionId = `v2_workspaces-${organizationId}`; - const hostCollectionId = `v2_hosts-${organizationId}`; - const projectTable = getCollectionTable(tanstackDb, projectCollectionId); - const workspaceTable = getCollectionTable(tanstackDb, workspaceCollectionId); - const hostTable = getCollectionTable(tanstackDb, hostCollectionId); - const hostId = inferHostId({ - explicit: args.hostId, - tanstackDb, - hostDb, - workspaceTable, - hostTable, - }); - - const now = new Date().toISOString(); - ensureOrganizationRow({ tanstackDb, organizationId, now }); - ensureHostRow({ - tanstackDb, - hostCollectionId, - hostTable, - organizationId, - hostId, - now, - }); - - removePreviousFixtureRows({ - tanstackDb, - hostDb, - projectTable, - workspaceTable, - repoPath, - }); - - const projectVersion = getNextRowVersion(tanstackDb, projectCollectionId); - let workspaceVersion = getNextRowVersion(tanstackDb, workspaceCollectionId); - - insertCollectionValue( - tanstackDb, - projectTable, - `s:${projectId}`, - { - createdAt: now, - githubRepositoryId: null, - iconUrl: null, - id: projectId, - name: fixtureProjectName, - organizationId, - repoCloneUrl: `file://${repoPath}`, - slug: fixtureSlug, - updatedAt: now, - }, - { relation: ["public", "v2_projects"], operation: "insert" }, - projectVersion, - ); - - hostDb - .query( - `insert into projects - (id, repo_path, repo_provider, repo_owner, repo_name, repo_url, remote_name, created_at) - values (?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - projectId, - repoPath, - "local", - null, - basename(repoPath), - null, - null, - Date.now(), - ); - - for (const workspace of workspaces) { - const headSha = await run("git", ["rev-parse", "HEAD"], { - cwd: workspace.worktreePath, - }); - hostDb - .query( - `insert into workspaces - (id, project_id, worktree_path, branch, head_sha, created_at) - values (?, ?, ?, ?, ?, ?)`, - ) - .run( - workspace.id, - projectId, - workspace.worktreePath, - workspace.branch, - headSha, - Date.now(), - ); - - insertCollectionValue( - tanstackDb, - workspaceTable, - `s:${workspace.id}`, - { - branch: workspace.branch, - createdAt: now, - createdByUserId: null, - hostId, - id: workspace.id, - name: `large diff ${workspace.branch.split("/").at(-1)}`, - organizationId, - projectId, - taskId: null, - type: "worktree", - updatedAt: now, - }, - { relation: ["public", "v2_workspaces"], operation: "insert" }, - workspaceVersion, - ); - workspaceVersion += 1; - } - - setCollectionVersion(tanstackDb, projectCollectionId, projectVersion); - setCollectionVersion(tanstackDb, workspaceCollectionId, workspaceVersion - 1); - - tanstackDb.close(); - hostDb.close(); - return { organizationId, hostId }; -} - -async function main(): Promise { - const args = parseArgs(Bun.argv.slice(2)); - if (args.help) { - console.log(usage()); - return; - } - - const { projectId, repoPath, workspaces } = await createGitFixtures(args); - const { organizationId, hostId } = await seedDatabases({ - args, - projectId, - repoPath, - workspaces, - }); - - const result = { - organizationId, - hostId, - projectId, - repoPath, - workspaceIds: workspaces.map((workspace) => workspace.id), - workspaceCount: workspaces.length, - changedFilesPerWorkspace: args.changedFiles, - fixtureRoot: args.root, - stressCommand: `bun --cwd apps/desktop stress:renderer -- --scenario all --workspace-ids ${workspaces - .map((workspace) => workspace.id) - .join( - ",", - )} --iterations 1000 --route-iterations 240 --heavy-iterations 500 --timeout-ms 300000`, - }; - - if (args.json) { - console.log(JSON.stringify(result, null, 2)); - return; - } - - console.log("[stress:fixtures] prepared large-diff renderer fixtures"); - console.log(` org: ${organizationId}`); - console.log(` host: ${hostId}`); - console.log(` project: ${projectId}`); - console.log(` repo: ${repoPath}`); - console.log(` workspaces: ${workspaces.length}`); - console.log(` workspace ids: ${result.workspaceIds.join(",")}`); - console.log( - " restart the desktop dev app before running stress if it was open", - ); - console.log(` ${result.stressCommand}`); -} - -main().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); - console.error(""); - console.error(usage()); - process.exit(1); -}); diff --git a/apps/desktop/scripts/stress-renderer.ts b/apps/desktop/scripts/stress-renderer.ts deleted file mode 100644 index b47342aa938..00000000000 --- a/apps/desktop/scripts/stress-renderer.ts +++ /dev/null @@ -1,2084 +0,0 @@ -#!/usr/bin/env bun - -import { mkdir } from "node:fs/promises"; -import { join } from "node:path"; - -interface Args { - host: string; - port: number; - scenario: StressScenario; - iterations: number; - routeIterations: number; - heavyIterations: number; - terminalIterations: number; - terminalTabCount: number; - terminalPanesPerTab: number; - terminalLines: number; - terminalPayloadBytes: number; - terminalOutputMode: TerminalOutputMode; - terminalCorruptionCheck: boolean; - terminalCorruptionArtifactDir: string; - includeTerminalAction: boolean; - profileCpu: boolean; - reactProbe: boolean; - progressEvery: number; - intervalMs: number; - settleMs: number; - timeoutMs: number; - maxHeartbeatDelayMs: number; - maxLongTaskMs: number; - selector: string; - workspaceIds: string[]; - json: boolean; - help: boolean; -} - -type StressScenario = - | "all" - | "route-sweep" - | "terminal-heavy" - | "workspace-heavy" - | "workspace-switch" - | "workspace-switch-heavy"; - -type TerminalOutputMode = - | "atlas-rewrite" - | "cjk-sgr" - | "glyph-sentinel" - | "scrollback" - | "tui-churn"; - -interface CdpTarget { - type?: string; - title?: string; - url?: string; - webSocketDebuggerUrl?: string; -} - -interface CdpResponse { - id?: number; - result?: unknown; - error?: { - code: number; - message: string; - }; - method?: string; - params?: unknown; -} - -interface RuntimeConsoleApiCalledEvent { - type?: string; - args?: Array<{ - type?: string; - value?: unknown; - description?: string; - }>; -} - -interface RuntimeEvaluateResult { - result?: { - type?: string; - value?: unknown; - description?: string; - }; - exceptionDetails?: { - text?: string; - exception?: { - description?: string; - value?: unknown; - }; - }; -} - -interface PageCaptureScreenshotResult { - data?: string; -} - -interface CpuProfile { - nodes: Array<{ - id: number; - parent?: number; - children?: number[]; - callFrame: { - functionName?: string; - url?: string; - lineNumber?: number; - columnNumber?: number; - }; - hitCount?: number; - }>; - samples?: number[]; - timeDeltas?: number[]; -} - -interface CpuProfileResult { - profile?: CpuProfile; -} - -interface CpuProfileFrameSummary { - functionName: string; - url: string; - lineNumber: number; - columnNumber: number; - selfTimeMs: number; - sampleCount: number; - parentFunctionName?: string; - parentUrl?: string; - parentLineNumber?: number; - parentColumnNumber?: number; -} - -interface RendererStressResult { - scenario: StressScenario; - iterations: number; - operationCount: number; - targetCount: number; - activationModeCounts: Record; - routeCount: number; - routeIterations: number; - routesVisited: string[]; - heavyIterations: number; - heavyActionCounts: Record; - heavyActionErrors: string[]; - heavyActionCatalogue: string[]; - terminalIterations: number; - terminalActionCounts: Record; - terminalActionErrors: string[]; - terminalStressSummary: unknown; - terminalVisualCorruptionCheck?: TerminalVisualCorruptionCheckResult | null; - workspaceSummary: unknown; - reactProbeSummary: unknown; - durationMs: number; - maxHeartbeatDelayMs: number; - heartbeatDelaySamples: number[]; - maxLongTaskDurationMs: number; - longTaskCount: number; - longTasks: Array<{ - duration: number; - startTime: number; - name: string; - }>; - slowOperations: Array<{ - phase: string; - label: string; - durationMs: number; - index: number; - }>; - errorCount: number; - errors: string[]; - startMemory: unknown; - endMemory: unknown; - finalLocation: string; -} - -interface TerminalVisualCorruptionCheckResult { - available: boolean; - reason?: string; - clip?: { - x: number; - y: number; - width: number; - height: number; - scale: number; - }; - beforeClear?: TerminalVisualFrameInfo; - afterClear?: TerminalVisualFrameInfo; - diff?: TerminalVisualDiffSummary; - artifactPaths?: { - beforeClear: string; - afterClear: string; - }; - failed: boolean; -} - -interface TerminalVisualFrameInfo { - canvasCount: number; - rendererTypes: string[]; - atlasPageCounts: Array; - pendingAtlasClearCount: number; - stressTerminalRuntimeCount: number; - clearedAtlasCount?: number; -} - -interface TerminalVisualDiffSummary { - width: number; - height: number; - pixelCount: number; - changedPixels: number; - changedRatio: number; - maxChannelDelta: number; - meanChannelDelta: number; - changedRows: number; - changedColumns: number; - thresholdChangedPixels: number; -} - -const DEFAULT_SELECTOR = "[data-renderer-stress-workspace-id]"; - -function parseArgs(argv: string[]): Args { - const args: Args = { - host: "127.0.0.1", - port: Number(process.env.SUPERSET_RENDERER_STRESS_CDP_PORT ?? 9333), - scenario: "workspace-switch", - iterations: 500, - routeIterations: 0, - heavyIterations: 0, - terminalIterations: 0, - terminalTabCount: 24, - terminalPanesPerTab: 4, - terminalLines: 40, - terminalPayloadBytes: 1024, - terminalOutputMode: "scrollback", - terminalCorruptionCheck: false, - terminalCorruptionArtifactDir: ".tmp/renderer-terminal-corruption", - includeTerminalAction: false, - profileCpu: false, - reactProbe: false, - progressEvery: 100, - intervalMs: 0, - settleMs: 1000, - timeoutMs: 30_000, - maxHeartbeatDelayMs: 500, - maxLongTaskMs: 500, - selector: DEFAULT_SELECTOR, - workspaceIds: [], - json: false, - help: false, - }; - - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - const readValue = () => { - const value = argv[index + 1]; - if (!value) throw new Error(`Missing value for ${arg}`); - index += 1; - return value; - }; - const readNumber = () => { - const value = Number(readValue()); - if (!Number.isFinite(value)) throw new Error(`Invalid number for ${arg}`); - return value; - }; - - switch (arg) { - case "--help": - case "-h": - args.help = true; - break; - case "--host": - args.host = readValue(); - break; - case "--port": - args.port = readNumber(); - break; - case "--scenario": { - const scenario = readValue(); - if ( - scenario !== "all" && - scenario !== "route-sweep" && - scenario !== "terminal-heavy" && - scenario !== "workspace-heavy" && - scenario !== "workspace-switch" && - scenario !== "workspace-switch-heavy" - ) { - throw new Error(`Invalid scenario for ${arg}: ${scenario}`); - } - args.scenario = scenario; - break; - } - case "--iterations": - args.iterations = readNumber(); - break; - case "--route-iterations": - args.routeIterations = readNumber(); - break; - case "--heavy-iterations": - args.heavyIterations = readNumber(); - break; - case "--terminal-iterations": - args.terminalIterations = readNumber(); - break; - case "--terminal-tab-count": - args.terminalTabCount = readNumber(); - break; - case "--terminal-panes-per-tab": - args.terminalPanesPerTab = readNumber(); - break; - case "--terminal-lines": - args.terminalLines = readNumber(); - break; - case "--terminal-payload-bytes": - args.terminalPayloadBytes = readNumber(); - break; - case "--terminal-output-mode": { - const mode = readValue(); - if ( - mode !== "atlas-rewrite" && - mode !== "cjk-sgr" && - mode !== "glyph-sentinel" && - mode !== "scrollback" && - mode !== "tui-churn" - ) { - throw new Error(`Invalid mode for ${arg}: ${mode}`); - } - args.terminalOutputMode = mode; - break; - } - case "--terminal-corruption-check": - args.terminalCorruptionCheck = true; - break; - case "--terminal-corruption-artifact-dir": - args.terminalCorruptionArtifactDir = readValue(); - break; - case "--include-terminal-action": - args.includeTerminalAction = true; - break; - case "--profile-cpu": - args.profileCpu = true; - break; - case "--react-probe": - args.reactProbe = true; - break; - case "--progress-every": - args.progressEvery = readNumber(); - break; - case "--interval-ms": - args.intervalMs = readNumber(); - break; - case "--settle-ms": - args.settleMs = readNumber(); - break; - case "--timeout-ms": - args.timeoutMs = readNumber(); - break; - case "--max-heartbeat-delay-ms": - args.maxHeartbeatDelayMs = readNumber(); - break; - case "--max-long-task-ms": - args.maxLongTaskMs = readNumber(); - break; - case "--selector": - args.selector = readValue(); - break; - case "--workspace-ids": - args.workspaceIds = readValue() - .split(",") - .map((value) => value.trim()) - .filter(Boolean); - break; - case "--json": - args.json = true; - break; - default: - throw new Error(`Unknown argument: ${arg}`); - } - } - - return args; -} - -function usage() { - return `Renderer stress harness - -Start the desktop app with CDP enabled: - SUPERSET_RENDERER_STRESS_CDP_PORT=9333 bun --cwd apps/desktop dev - -Run the workspace switching stress test from another shell: - bun --cwd apps/desktop stress:renderer -- --port 9333 --iterations 1000 --interval-ms 0 - -Run route and workspace action stress: - bun --cwd apps/desktop stress:renderer -- --port 9333 --scenario all --iterations 1000 --route-iterations 200 --heavy-iterations 300 - -Options: - --port CDP port. Default: env SUPERSET_RENDERER_STRESS_CDP_PORT or 9333 - --host CDP host. Default: 127.0.0.1 - --scenario workspace-switch, workspace-switch-heavy, route-sweep, workspace-heavy, terminal-heavy, or all. Default: workspace-switch - --iterations Workspace activations. Default: 500 - --route-iterations Route navigations. Default: --iterations - --heavy-iterations Mixed pane/tab/browser/diff actions. Default: min(--iterations, 300) - --terminal-iterations Terminal tab switch/write cycles. Default: min(--iterations, 200) - --terminal-tab-count Synthetic terminal tabs. Default: 24 - --terminal-panes-per-tab Terminal panes per synthetic tab. Default: 4 - Also controls generated tabs/panes for workspace-switch-heavy. - --terminal-lines ANSI output lines per terminal write. Default: 40 - --terminal-payload-bytes Repeated payload bytes per line. Default: 1024 - --terminal-output-mode scrollback, atlas-rewrite, tui-churn, cjk-sgr, or glyph-sentinel. Default: scrollback - --terminal-corruption-check After terminal-heavy stress, compare a fixed frame before/after clearTextureAtlas(). - --terminal-corruption-artifact-dir - Directory for before/after PNGs when --terminal-corruption-check is set. - Default: .tmp/renderer-terminal-corruption - --include-terminal-action Include one real backend terminal launch in heavy stress. Default: false - --profile-cpu Capture a CDP CPU profile and print hottest JS frames - --react-probe Capture React commit/component counts via React DevTools hook when available - --progress-every Emit progress every n operations. Set 0 to disable. Default: 100 - --interval-ms Delay between activations. Default: 0 - --settle-ms Delay after the final activation. Default: 1000 - --timeout-ms CDP command timeout. Default: 30000 - --max-heartbeat-delay-ms Fail if event-loop heartbeat exceeds this. Default: 500 - --max-long-task-ms Fail if a renderer long task exceeds this. Default: 500 - --selector Workspace target selector. Default: ${DEFAULT_SELECTOR} - --workspace-ids Optional explicit workspace ids; falls back to hash navigation if needed - --json Print only JSON summary -`; -} - -function messageDataToString(data: unknown): string { - if (typeof data === "string") return data; - if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8"); - if (ArrayBuffer.isView(data)) { - return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString( - "utf8", - ); - } - return String(data); -} - -function summarizeCpuProfile( - profile: CpuProfile, - limit = 30, -): CpuProfileFrameSummary[] { - const nodesById = new Map(profile.nodes.map((node) => [node.id, node])); - const parentByNodeId = new Map(); - for (const node of profile.nodes) { - if (node.parent != null) parentByNodeId.set(node.id, node.parent); - for (const childId of node.children ?? []) { - parentByNodeId.set(childId, node.id); - } - } - const selfTimeByNodeId = new Map(); - const sampleCountByNodeId = new Map(); - const samples = profile.samples ?? []; - const timeDeltas = profile.timeDeltas ?? []; - - for (let index = 0; index < samples.length; index += 1) { - const nodeId = samples[index]; - sampleCountByNodeId.set(nodeId, (sampleCountByNodeId.get(nodeId) ?? 0) + 1); - selfTimeByNodeId.set( - nodeId, - (selfTimeByNodeId.get(nodeId) ?? 0) + (timeDeltas[index] ?? 0) / 1000, - ); - } - - return Array.from(nodesById.entries()) - .map(([nodeId, node]) => { - const callFrame = node.callFrame; - const parent = nodesById.get(parentByNodeId.get(nodeId) ?? -1); - const parentCallFrame = parent?.callFrame; - return { - functionName: callFrame.functionName || "(anonymous)", - url: callFrame.url || "", - lineNumber: callFrame.lineNumber ?? 0, - columnNumber: callFrame.columnNumber ?? 0, - selfTimeMs: selfTimeByNodeId.get(nodeId) ?? 0, - sampleCount: sampleCountByNodeId.get(nodeId) ?? node.hitCount ?? 0, - parentFunctionName: parentCallFrame?.functionName || undefined, - parentUrl: parentCallFrame?.url || undefined, - parentLineNumber: parentCallFrame?.lineNumber, - parentColumnNumber: parentCallFrame?.columnNumber, - }; - }) - .filter((frame) => frame.selfTimeMs > 0 || frame.sampleCount > 0) - .sort((left, right) => right.selfTimeMs - left.selfTimeMs) - .slice(0, limit); -} - -class CdpClient { - private nextId = 1; - private pending = new Map< - number, - { - resolve: (value: unknown) => void; - reject: (error: Error) => void; - timer: ReturnType; - } - >(); - private listeners = new Map void>>(); - - private constructor(private readonly ws: WebSocket) { - this.ws.addEventListener("message", (event) => { - this.onMessage(messageDataToString(event.data)); - }); - this.ws.addEventListener("close", () => { - this.rejectPending(new Error("CDP socket closed")); - }); - this.ws.addEventListener("error", () => { - this.rejectPending(new Error("CDP socket errored")); - }); - } - - static connect(url: string, timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - const ws = new WebSocket(url); - const timer = setTimeout(() => { - ws.close(); - reject(new Error(`Timed out connecting to ${url}`)); - }, timeoutMs); - ws.addEventListener("open", () => { - clearTimeout(timer); - resolve(new CdpClient(ws)); - }); - ws.addEventListener("error", () => { - clearTimeout(timer); - reject(new Error(`Failed to connect to ${url}`)); - }); - }); - } - - send( - method: string, - params: Record = {}, - timeoutMs = 10_000, - ): Promise { - const id = this.nextId; - this.nextId += 1; - const payload = JSON.stringify({ id, method, params }); - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.pending.delete(id); - reject(new Error(`CDP command timed out: ${method}`)); - }, timeoutMs); - this.pending.set(id, { - resolve: (value) => resolve(value as T), - reject, - timer, - }); - this.ws.send(payload); - }); - } - - close(): void { - this.ws.close(); - } - - on(method: string, listener: (params: unknown) => void): () => void { - const listeners = this.listeners.get(method) ?? new Set(); - listeners.add(listener); - this.listeners.set(method, listeners); - return () => { - listeners.delete(listener); - if (listeners.size === 0) { - this.listeners.delete(method); - } - }; - } - - private onMessage(raw: string): void { - const message = JSON.parse(raw) as CdpResponse; - if (typeof message.method === "string") { - const listeners = this.listeners.get(message.method); - if (listeners) { - for (const listener of listeners) { - listener(message.params); - } - } - if (typeof message.id !== "number") return; - } - if (typeof message.id !== "number") return; - const pending = this.pending.get(message.id); - if (!pending) return; - this.pending.delete(message.id); - clearTimeout(pending.timer); - if (message.error) { - pending.reject( - new Error(`CDP error ${message.error.code}: ${message.error.message}`), - ); - return; - } - pending.resolve(message.result); - } - - private rejectPending(error: Error): void { - for (const [id, pending] of this.pending) { - clearTimeout(pending.timer); - pending.reject(error); - this.pending.delete(id); - } - } -} - -async function getRendererTarget(args: Args): Promise { - const response = await fetch(`http://${args.host}:${args.port}/json/list`); - if (!response.ok) { - throw new Error( - `Failed to query CDP targets: ${response.status} ${response.statusText}`, - ); - } - const targets = (await response.json()) as CdpTarget[]; - const target = targets.find( - (candidate) => - candidate.webSocketDebuggerUrl && - candidate.type === "page" && - !candidate.url?.startsWith("devtools://"), - ); - if (!target?.webSocketDebuggerUrl) { - throw new Error("No renderer page target with a CDP socket was found"); - } - return target; -} - -async function evaluateInRenderer( - cdp: CdpClient, - expression: string, - timeoutMs: number, -): Promise { - const evaluation = await cdp.send( - "Runtime.evaluate", - { - expression, - awaitPromise: true, - returnByValue: true, - }, - timeoutMs, - ); - - if (evaluation.exceptionDetails) { - throw new Error( - evaluation.exceptionDetails.exception?.description ?? - evaluation.exceptionDetails.text ?? - "Renderer evaluation threw", - ); - } - - return evaluation.result?.value as T; -} - -function dataUrlToPngBuffer(dataUrl: string): Buffer { - const base64 = dataUrl.startsWith("data:") - ? dataUrl.slice(dataUrl.indexOf(",") + 1) - : dataUrl; - return Buffer.from(base64, "base64"); -} - -async function capturePngDataUrl( - cdp: CdpClient, - clip: TerminalVisualCorruptionCheckResult["clip"], - timeoutMs: number, -): Promise { - const result = await cdp.send( - "Page.captureScreenshot", - { - format: "png", - clip, - fromSurface: true, - captureBeyondViewport: false, - }, - timeoutMs, - ); - if (!result.data) throw new Error("Page.captureScreenshot returned no data"); - return `data:image/png;base64,${result.data}`; -} - -async function prepareTerminalVisualProbeFrame( - cdp: CdpClient, - timeoutMs: number, -): Promise< - | { - available: true; - clip: NonNullable; - frame: TerminalVisualFrameInfo; - } - | { available: false; reason: string } -> { - return evaluateInRenderer( - cdp, - `(async () => { - const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); - const bridge = window.__SUPERSET_RENDERER_STRESS__; - if (!bridge) return { available: false, reason: "Renderer stress bridge is not mounted" }; - - bridge.switchTab(0); - await nextFrame(); - await nextFrame(); - await bridge.writeTerminalStressOutput(1_000_003, 36, 0, "glyph-sentinel"); - await nextFrame(); - await nextFrame(); - await sleep(250); - - const terminals = Array.from(document.querySelectorAll(".xterm")) - .map((element) => { - const rect = element.getBoundingClientRect(); - return { element, rect }; - }) - .filter(({ rect }) => rect.width > 20 && rect.height > 20); - if (terminals.length === 0) { - return { available: false, reason: "No visible xterm element found after sentinel write" }; - } - terminals.sort((left, right) => (right.rect.width * right.rect.height) - (left.rect.width * left.rect.height)); - const rect = terminals[0].rect; - const summary = bridge.getTerminalStressSummary(); - const runtimes = Array.isArray(summary?.terminalRuntimes) ? summary.terminalRuntimes : []; - const renderers = runtimes.map((runtime) => runtime.renderer).filter(Boolean); - return { - available: true, - clip: { - x: Math.max(0, Math.floor(rect.x)), - y: Math.max(0, Math.floor(rect.y)), - width: Math.max(1, Math.ceil(rect.width)), - height: Math.max(1, Math.ceil(rect.height)), - scale: 1 - }, - frame: { - canvasCount: terminals[0].element.querySelectorAll("canvas").length, - rendererTypes: Array.from(new Set(renderers.map((renderer) => renderer.rendererType).filter(Boolean))), - atlasPageCounts: renderers.map((renderer) => renderer.atlasPageCount ?? null), - pendingAtlasClearCount: renderers.filter((renderer) => renderer.pendingAtlasClear === true).length, - stressTerminalRuntimeCount: runtimes.length - } - }; - })()`, - timeoutMs, - ); -} - -async function clearTerminalVisualProbeAtlas( - cdp: CdpClient, - timeoutMs: number, -): Promise { - return evaluateInRenderer( - cdp, - `(async () => { - const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); - const bridge = window.__SUPERSET_RENDERER_STRESS__; - if (!bridge) throw new Error("Renderer stress bridge is not mounted"); - const clearedAtlasCount = bridge.clearTerminalTextureAtlasesForStress(); - await nextFrame(); - await nextFrame(); - await sleep(250); - const summary = bridge.getTerminalStressSummary(); - const runtimes = Array.isArray(summary?.terminalRuntimes) ? summary.terminalRuntimes : []; - const renderers = runtimes.map((runtime) => runtime.renderer).filter(Boolean); - const terminal = document.querySelector(".xterm"); - return { - canvasCount: terminal ? terminal.querySelectorAll("canvas").length : 0, - rendererTypes: Array.from(new Set(renderers.map((renderer) => renderer.rendererType).filter(Boolean))), - atlasPageCounts: renderers.map((renderer) => renderer.atlasPageCount ?? null), - pendingAtlasClearCount: renderers.filter((renderer) => renderer.pendingAtlasClear === true).length, - stressTerminalRuntimeCount: runtimes.length, - clearedAtlasCount - }; - })()`, - timeoutMs, - ); -} - -async function compareScreenshotsInRenderer( - cdp: CdpClient, - beforeDataUrl: string, - afterDataUrl: string, - timeoutMs: number, -): Promise { - return evaluateInRenderer( - cdp, - `(async () => { - const beforeDataUrl = ${JSON.stringify(beforeDataUrl)}; - const afterDataUrl = ${JSON.stringify(afterDataUrl)}; - const loadImage = (src) => new Promise((resolve, reject) => { - const image = new Image(); - image.onload = () => resolve(image); - image.onerror = () => reject(new Error("Failed to load screenshot")); - image.src = src; - }); - const [beforeImage, afterImage] = await Promise.all([ - loadImage(beforeDataUrl), - loadImage(afterDataUrl) - ]); - const width = Math.min(beforeImage.naturalWidth, afterImage.naturalWidth); - const height = Math.min(beforeImage.naturalHeight, afterImage.naturalHeight); - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext("2d", { willReadFrequently: true }); - if (!ctx) throw new Error("Could not create 2d canvas context"); - ctx.drawImage(beforeImage, 0, 0, width, height); - const before = ctx.getImageData(0, 0, width, height).data; - ctx.clearRect(0, 0, width, height); - ctx.drawImage(afterImage, 0, 0, width, height); - const after = ctx.getImageData(0, 0, width, height).data; - - let changedPixels = 0; - let maxChannelDelta = 0; - let channelDeltaTotal = 0; - const changedRows = new Set(); - const changedColumns = new Set(); - for (let index = 0; index < before.length; index += 4) { - const dr = Math.abs(before[index] - after[index]); - const dg = Math.abs(before[index + 1] - after[index + 1]); - const db = Math.abs(before[index + 2] - after[index + 2]); - const da = Math.abs(before[index + 3] - after[index + 3]); - const delta = Math.max(dr, dg, db, da); - maxChannelDelta = Math.max(maxChannelDelta, delta); - channelDeltaTotal += dr + dg + db + da; - if (delta <= 8) continue; - const pixel = index / 4; - const x = pixel % width; - const y = Math.floor(pixel / width); - changedPixels += 1; - changedRows.add(y); - changedColumns.add(x); - } - const pixelCount = width * height; - const thresholdChangedPixels = Math.max(200, Math.floor(pixelCount * 0.001)); - return { - width, - height, - pixelCount, - changedPixels, - changedRatio: pixelCount > 0 ? changedPixels / pixelCount : 0, - maxChannelDelta, - meanChannelDelta: pixelCount > 0 ? channelDeltaTotal / (pixelCount * 4) : 0, - changedRows: changedRows.size, - changedColumns: changedColumns.size, - thresholdChangedPixels - }; - })()`, - timeoutMs, - ); -} - -async function restoreRendererStressBaseline( - cdp: CdpClient, - timeoutMs: number, -): Promise { - await evaluateInRenderer( - cdp, - `(() => { - window.__SUPERSET_RENDERER_STRESS__?.restoreBaseline(); - return true; - })()`, - timeoutMs, - ).catch(() => undefined); -} - -async function runTerminalVisualCorruptionCheck( - cdp: CdpClient, - args: Args, -): Promise { - const prepared = await prepareTerminalVisualProbeFrame(cdp, args.timeoutMs); - if (!prepared.available) { - await restoreRendererStressBaseline(cdp, args.timeoutMs); - return { available: false, reason: prepared.reason, failed: false }; - } - - let beforeDataUrl = ""; - let afterDataUrl = ""; - try { - beforeDataUrl = await capturePngDataUrl(cdp, prepared.clip, args.timeoutMs); - const afterClear = await clearTerminalVisualProbeAtlas(cdp, args.timeoutMs); - afterDataUrl = await capturePngDataUrl(cdp, prepared.clip, args.timeoutMs); - const diff = await compareScreenshotsInRenderer( - cdp, - beforeDataUrl, - afterDataUrl, - args.timeoutMs, - ); - const artifactPaths = await writeTerminalCorruptionArtifacts( - args.terminalCorruptionArtifactDir, - beforeDataUrl, - afterDataUrl, - ); - return { - available: true, - clip: prepared.clip, - beforeClear: prepared.frame, - afterClear, - diff, - artifactPaths, - failed: diff.changedPixels > diff.thresholdChangedPixels, - }; - } finally { - await restoreRendererStressBaseline(cdp, args.timeoutMs); - } -} - -async function writeTerminalCorruptionArtifacts( - artifactDir: string, - beforeDataUrl: string, - afterDataUrl: string, -): Promise { - const stamp = new Date().toISOString().replace(/[:.]/g, "-"); - await mkdir(artifactDir, { recursive: true }); - const beforeClear = join( - artifactDir, - `terminal-webgl-before-clear-${stamp}.png`, - ); - const afterClear = join( - artifactDir, - `terminal-webgl-after-clear-${stamp}.png`, - ); - await Promise.all([ - Bun.write(beforeClear, dataUrlToPngBuffer(beforeDataUrl)), - Bun.write(afterClear, dataUrlToPngBuffer(afterDataUrl)), - ]); - return { beforeClear, afterClear }; -} - -function rendererStress(options: { - scenario: StressScenario; - iterations: number; - routeIterations: number; - heavyIterations: number; - terminalIterations: number; - terminalTabCount: number; - terminalPanesPerTab: number; - terminalLines: number; - terminalPayloadBytes: number; - terminalOutputMode: TerminalOutputMode; - terminalCorruptionCheck: boolean; - includeTerminalAction: boolean; - reactProbe: boolean; - progressEvery: number; - intervalMs: number; - settleMs: number; - selector: string; - workspaceIds: string[]; -}): Promise { - type StressWindow = Window & { - performance: Performance & { - memory?: unknown; - }; - }; - - const stressWindow = window as StressWindow; - const sleep = (ms: number) => - new Promise((resolve) => setTimeout(resolve, ms)); - const nextFrame = () => - new Promise((resolve) => requestAnimationFrame(() => resolve())); - const cssEscape = - stressWindow.CSS?.escape ?? - ((value: string) => value.replace(/["\\]/g, "\\$&")); - const errors: string[] = []; - const longTasks: RendererStressResult["longTasks"] = []; - const heartbeatDelaySamples: number[] = []; - let maxHeartbeatDelayMs = 0; - let expectedHeartbeat = performance.now() + 50; - - const describeError = (error: unknown): string => { - if (error instanceof Error) { - return error.stack ?? error.message; - } - if (typeof error === "string") return error; - return String(error); - }; - - const onError = (event: ErrorEvent) => { - errors.push( - event.error - ? describeError(event.error) - : event.message || "unknown error", - ); - }; - const onUnhandledRejection = (event: PromiseRejectionEvent) => { - errors.push(`Unhandled rejection: ${describeError(event.reason)}`); - }; - const installReactProbe = () => { - if (!options.reactProbe) { - return { - getSummary: () => null, - cleanup: () => {}, - }; - } - - type ReactFiber = { - actualDuration?: number; - child?: ReactFiber | null; - elementType?: unknown; - flags?: number; - sibling?: ReactFiber | null; - type?: unknown; - }; - type ReactFiberRoot = { current?: ReactFiber | null }; - type ReactDevToolsHook = { - onCommitFiberRoot?: ( - rendererId: number, - root: ReactFiberRoot, - priorityLevel?: unknown, - didError?: boolean, - ) => unknown; - }; - type ReactProbeWindow = Window & { - __REACT_DEVTOOLS_GLOBAL_HOOK__?: ReactDevToolsHook; - }; - - const hook = (window as ReactProbeWindow).__REACT_DEVTOOLS_GLOBAL_HOOK__; - if (!hook || typeof hook.onCommitFiberRoot !== "function") { - return { - getSummary: () => ({ - available: false, - reason: "React DevTools global hook is not installed", - }), - cleanup: () => {}, - }; - } - - const previousOnCommitFiberRoot = hook.onCommitFiberRoot; - const componentStats = new Map< - string, - { - commitCount: number; - maxActualDurationMs: number; - totalActualDurationMs: number; - } - >(); - const recentCommits: Array<{ - componentCount: number; - durationMs: number; - fiberCount: number; - index: number; - totalActualDurationMs: number; - }> = []; - let commitCount = 0; - let maxCommitDurationMs = 0; - - const getDisplayName = (fiber: ReactFiber): string | null => { - const candidate = fiber.type ?? fiber.elementType; - if (typeof candidate === "string") return candidate; - if (typeof candidate === "function") { - const named = candidate as { displayName?: string; name?: string }; - return named.displayName ?? named.name ?? null; - } - if (candidate && typeof candidate === "object") { - const named = candidate as { - displayName?: string; - name?: string; - render?: { displayName?: string; name?: string }; - }; - return ( - named.displayName ?? - named.name ?? - named.render?.displayName ?? - named.render?.name ?? - null - ); - } - return null; - }; - - const visitFibers = ( - root: ReactFiber | null | undefined, - visit: (fiber: ReactFiber) => void, - ) => { - const stack: ReactFiber[] = []; - const seen = new Set(); - if (root) stack.push(root); - while (stack.length > 0 && seen.size < 20_000) { - const fiber = stack.pop(); - if (!fiber || seen.has(fiber)) continue; - seen.add(fiber); - visit(fiber); - if (fiber.sibling) stack.push(fiber.sibling); - if (fiber.child) stack.push(fiber.child); - } - return seen.size; - }; - - function onCommitFiberRoot( - this: unknown, - rendererId: number, - root: ReactFiberRoot, - priorityLevel?: unknown, - didError?: boolean, - ) { - const result = previousOnCommitFiberRoot.apply(this, [ - rendererId, - root, - priorityLevel, - didError, - ]); - const startedAt = performance.now(); - const committedComponents = new Set(); - let totalActualDurationMs = 0; - const fiberCount = visitFibers(root.current, (fiber) => { - const name = getDisplayName(fiber); - if (!name) return; - const actualDuration = - typeof fiber.actualDuration === "number" ? fiber.actualDuration : 0; - const flags = typeof fiber.flags === "number" ? fiber.flags : 0; - if (actualDuration <= 0 && flags === 0) return; - - committedComponents.add(name); - totalActualDurationMs += actualDuration; - const current = componentStats.get(name) ?? { - commitCount: 0, - maxActualDurationMs: 0, - totalActualDurationMs: 0, - }; - current.commitCount += 1; - current.totalActualDurationMs += actualDuration; - current.maxActualDurationMs = Math.max( - current.maxActualDurationMs, - actualDuration, - ); - componentStats.set(name, current); - }); - - commitCount += 1; - const durationMs = performance.now() - startedAt; - maxCommitDurationMs = Math.max(maxCommitDurationMs, durationMs); - recentCommits.push({ - componentCount: committedComponents.size, - durationMs, - fiberCount, - index: commitCount, - totalActualDurationMs, - }); - if (recentCommits.length > 50) recentCommits.shift(); - return result; - } - - hook.onCommitFiberRoot = onCommitFiberRoot; - - return { - getSummary: () => ({ - available: true, - commitCount, - maxCommitDurationMs, - recentCommits: recentCommits.slice(-10), - topComponents: Array.from(componentStats.entries()) - .map(([name, stats]) => ({ name, ...stats })) - .sort((left, right) => { - const durationDelta = - right.totalActualDurationMs - left.totalActualDurationMs; - if (durationDelta !== 0) return durationDelta; - return right.commitCount - left.commitCount; - }) - .slice(0, 30), - }), - cleanup: () => { - if (hook.onCommitFiberRoot === onCommitFiberRoot) { - hook.onCommitFiberRoot = previousOnCommitFiberRoot; - } - }, - }; - }; - - window.addEventListener("error", onError); - window.addEventListener("unhandledrejection", onUnhandledRejection); - const reactProbe = installReactProbe(); - - const heartbeat = setInterval(() => { - const now = performance.now(); - const delay = Math.max(0, now - expectedHeartbeat); - if (delay > maxHeartbeatDelayMs) maxHeartbeatDelayMs = delay; - if (delay > 50) heartbeatDelaySamples.push(delay); - expectedHeartbeat = now + 50; - }, 50); - - let longTaskObserver: PerformanceObserver | null = null; - try { - longTaskObserver = new PerformanceObserver((list) => { - for (const entry of list.getEntries()) { - longTasks.push({ - duration: entry.duration, - startTime: entry.startTime, - name: entry.name, - }); - } - }); - longTaskObserver.observe({ entryTypes: ["longtask"] }); - } catch { - longTaskObserver = null; - } - - const getTargets = () => { - if (options.workspaceIds.length > 0) return options.workspaceIds; - const ids = Array.from(document.querySelectorAll(options.selector)) - .map((element) => - element.getAttribute("data-renderer-stress-workspace-id"), - ) - .filter((value): value is string => !!value); - return Array.from(new Set(ids)); - }; - - const activateWorkspace = async (workspaceId: string) => { - const target = document.querySelector( - `${options.selector}[data-renderer-stress-workspace-id="${cssEscape( - workspaceId, - )}"]`, - ); - if (target) { - target.click(); - return "click"; - } - await navigateTo(`/v2-workspace/${encodeURIComponent(workspaceId)}/`); - return "navigate"; - }; - - type RendererStressBridge = { - workspaceId: string; - projectId: string; - captureBaseline: () => void; - restoreBaseline: () => void; - getSummary: () => unknown; - addTab: (kind: string, index: number, paneCount?: number) => void; - openPane: (kind: string, index: number) => void; - splitActivePane: (kind: string, index: number) => void; - switchTab: (index: number) => void; - closeActivePane: () => void; - closeOldestTab: (keepCount?: number) => void; - churnActivePaneData: (index: number) => void; - replaceWithGeneratedLayout: (tabCount: number, panesPerTab: number) => void; - replaceWithGeneratedTerminalLayout: ( - tabCount: number, - panesPerTab: number, - ) => void; - replaceWithGeneratedMixedLayout: ( - tabCount: number, - panesPerTab: number, - ) => void; - writeTerminalStressOutput: ( - index: number, - lines: number, - payloadBytes: number, - mode?: TerminalOutputMode, - ) => Promise<{ - terminalCount: number; - writtenCount: number; - failedCount: number; - byteLength: number; - }>; - clearTerminalTextureAtlasesForStress: () => number; - getTerminalStressSummary: () => unknown; - releaseStressTerminalRuntimes: () => void; - addRealTerminalTab: () => Promise; - showChangesSidebar: () => void; - }; - - type RendererStressWindow = Window & { - __SUPERSET_RENDERER_STRESS__?: RendererStressBridge; - __SUPERSET_RENDERER_STRESS_NAVIGATE__?: (path: string) => Promise; - }; - - const getBridge = () => - (window as RendererStressWindow).__SUPERSET_RENDERER_STRESS__ ?? null; - - const waitForBridge = async (workspaceId?: string, attempts = 250) => { - for (let attempt = 0; attempt < attempts; attempt += 1) { - const bridge = getBridge(); - if (bridge && (!workspaceId || bridge.workspaceId === workspaceId)) { - return bridge; - } - await sleep(20); - } - throw new Error( - workspaceId - ? `Renderer stress workspace bridge did not mount for ${workspaceId}` - : "Renderer stress workspace bridge did not mount", - ); - }; - - const navigateTo = async (path: string) => { - const stressNavigate = (window as RendererStressWindow) - .__SUPERSET_RENDERER_STRESS_NAVIGATE__; - if (stressNavigate) { - await stressNavigate(path); - } else { - window.location.hash = path; - } - await sleep(options.intervalMs); - }; - - const withTimeout = async ( - promise: Promise, - timeoutMs: number, - label: string, - ): Promise => { - let timer: ReturnType | null = null; - try { - return await Promise.race([ - promise, - new Promise((_, reject) => { - timer = setTimeout( - () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), - timeoutMs, - ); - }), - ]); - } finally { - if (timer) clearTimeout(timer); - } - }; - - let preferredBridgeWorkspaceId: string | undefined; - const findWorkspaceBridge = async (workspaceIds: string[]) => { - const mountedBridge = getBridge(); - if ( - mountedBridge && - (workspaceIds.length === 0 || - workspaceIds.includes(mountedBridge.workspaceId)) - ) { - return { - workspaceId: mountedBridge.workspaceId, - bridge: mountedBridge, - }; - } - - const orderedWorkspaceIds = Array.from( - new Set( - [preferredBridgeWorkspaceId, ...workspaceIds].filter( - (value): value is string => Boolean(value), - ), - ), - ); - const failures: string[] = []; - for (const candidateWorkspaceId of orderedWorkspaceIds) { - await navigateTo( - `/v2-workspace/${encodeURIComponent(candidateWorkspaceId)}/`, - ); - try { - const bridge = await waitForBridge(candidateWorkspaceId, 500); - return { workspaceId: candidateWorkspaceId, bridge }; - } catch (error) { - failures.push( - `${candidateWorkspaceId}: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - } - throw new Error( - `No V2 workspace stress bridge mounted. Tried ${orderedWorkspaceIds.length} workspace route(s): ${failures.join( - "; ", - )}`, - ); - }; - - const buildRoutePaths = ( - targets: string[], - metadata: { projectId?: string; workspaceId?: string }, - ) => { - const staticPaths = [ - "/", - "/v2-workspaces/", - "/workspaces/", - "/workspace/", - "/tasks/", - "/automations/", - "/settings/", - "/settings/account/", - "/settings/agents/", - "/settings/api-keys/", - "/settings/appearance/", - "/settings/behavior/", - "/settings/billing/", - "/settings/billing/plans/", - "/settings/experimental/", - "/settings/git/", - "/settings/hosts/", - "/settings/integrations/", - "/settings/keyboard/", - "/settings/links/", - "/settings/models/", - "/settings/organization/", - "/settings/permissions/", - "/settings/presets/", - "/settings/projects/", - "/settings/ringtones/", - "/settings/security/", - "/settings/teams/", - "/settings/terminal/", - "/setup/adopt-worktrees/", - "/setup/gh-cli/", - "/setup/permissions/", - "/setup/project/", - "/setup/providers/", - "/setup/providers/claude-code/", - "/setup/providers/claude-code/api-key/", - "/setup/providers/claude-code/custom/", - "/setup/providers/codex/", - "/setup/providers/codex/api-key/", - "/setup/providers/codex/custom/", - "/welcome/", - "/new-project/", - ]; - const dynamicPaths = targets.flatMap((workspaceId) => [ - `/v2-workspace/${encodeURIComponent(workspaceId)}/`, - `/workspace/${encodeURIComponent(workspaceId)}/`, - ]); - if (metadata.projectId) { - const projectId = encodeURIComponent(metadata.projectId); - dynamicPaths.push( - `/project/${projectId}/`, - `/settings/projects/${projectId}/`, - `/settings/project/${projectId}/cloud/`, - `/settings/project/${projectId}/cloud/secrets/`, - `/tasks/issue/1/?project=${projectId}`, - `/tasks/pr/1/?project=${projectId}`, - ); - } - return Array.from(new Set([...staticPaths, ...dynamicPaths])); - }; - - const heavyActionCatalogue = [ - "replace generated multi-tab/multi-pane workspace layout", - "open changes sidebar", - "create file tabs", - "create diff tabs", - "create browser tabs/webviews", - "create chat tabs", - "create comment tabs", - "split active pane with file/diff/browser/chat/comment panes", - "open panes through same-kind replacement path", - "rapid tab switching", - "active pane data churn", - "close active panes", - "close old tabs while preserving a warm set", - ...(options.includeTerminalAction - ? ["single real terminal tab launch"] - : []), - "synthetic terminal layout with parked xterm runtimes", - "high-volume terminal ANSI output", - "forced terminal WebGL context loss and fallback recovery", - "restore original pane layout", - ]; - - const shouldRunWorkspaceSwitch = - options.scenario === "workspace-switch" || - options.scenario === "workspace-switch-heavy" || - options.scenario === "all"; - const shouldPrepareHeavyWorkspaceSwitch = - options.scenario === "workspace-switch-heavy"; - const shouldRunRouteSweep = - options.scenario === "route-sweep" || options.scenario === "all"; - const shouldRunWorkspaceHeavy = - options.scenario === "workspace-heavy" || options.scenario === "all"; - const shouldRunTerminalHeavy = - options.scenario === "terminal-heavy" || options.scenario === "all"; - - return (async () => { - const startedAt = performance.now(); - const startMemory = stressWindow.performance.memory ?? null; - const targets = getTargets(); - preferredBridgeWorkspaceId = getBridge()?.workspaceId; - const requiredTargetCount = shouldRunWorkspaceSwitch ? 2 : 1; - if (targets.length < requiredTargetCount) { - throw new Error( - `Need at least ${requiredTargetCount} workspace target(s), found ${targets.length}. Open a workspace list/sidebar or pass --workspace-ids.`, - ); - } - - const activationModeCounts: Record = {}; - const routesVisited: string[] = []; - const heavyActionCounts: Record = {}; - const heavyActionErrors: string[] = []; - const terminalActionCounts: Record = {}; - const terminalActionErrors: string[] = []; - const slowOperations: RendererStressResult["slowOperations"] = []; - const heavyWorkspaceSwitchWorkspaceIds: string[] = []; - const heavyWorkspaceSwitchSummaries: unknown[] = []; - let operationCount = 0; - let workspaceSummary: unknown = null; - let terminalStressSummary: unknown = null; - let routeCount = 0; - const routeIterations = - options.routeIterations > 0 - ? options.routeIterations - : options.iterations; - const heavyIterations = - options.heavyIterations > 0 - ? options.heavyIterations - : Math.min(options.iterations, 300); - const terminalIterations = - options.terminalIterations > 0 - ? options.terminalIterations - : Math.min(options.iterations, 200); - const reportProgress = ( - phase: string, - index: number, - total: number, - force = false, - ) => { - if (options.progressEvery <= 0 && !force) return; - if (!force && index % options.progressEvery !== 0) return; - console.info( - "[stress:renderer:progress]", - JSON.stringify({ - phase, - index, - total, - operationCount, - elapsedMs: Math.round(performance.now() - startedAt), - }), - ); - }; - const recordOperationDuration = ( - phase: string, - label: string, - index: number, - startedAt: number, - ) => { - const durationMs = performance.now() - startedAt; - if (durationMs < 100) return; - slowOperations.push({ phase, label, index, durationMs }); - }; - const prepareHeavyWorkspaceSwitchTargets = async () => { - const tabCount = Math.max( - 1, - Math.min(40, Math.floor(options.terminalTabCount)), - ); - const panesPerTab = Math.max( - 1, - Math.min(8, Math.floor(options.terminalPanesPerTab)), - ); - - reportProgress("workspace-switch-heavy-prepare", 0, targets.length, true); - for (let index = 0; index < targets.length; index += 1) { - const target = targets[index]; - await navigateTo(`/v2-workspace/${encodeURIComponent(target)}/`); - const bridge = await waitForBridge(target, 500); - - let operationStartedAt = performance.now(); - bridge.captureBaseline(); - bridge.replaceWithGeneratedMixedLayout(tabCount, panesPerTab); - bridge.showChangesSidebar(); - recordOperationDuration( - "workspace-switch-heavy-prepare", - `replace-generated-mixed-layout:${target}`, - index, - operationStartedAt, - ); - heavyActionCounts["replace-generated-mixed-layout"] = - (heavyActionCounts["replace-generated-mixed-layout"] ?? 0) + 1; - operationCount += 1; - - operationStartedAt = performance.now(); - for (let tabIndex = 0; tabIndex < tabCount; tabIndex += 1) { - bridge.switchTab(tabIndex); - await nextFrame(); - } - recordOperationDuration( - "workspace-switch-heavy-prepare", - `warm-generated-tabs:${target}`, - index, - operationStartedAt, - ); - heavyActionCounts["warm-generated-tabs"] = - (heavyActionCounts["warm-generated-tabs"] ?? 0) + tabCount; - operationCount += tabCount; - - heavyWorkspaceSwitchWorkspaceIds.push(target); - heavyWorkspaceSwitchSummaries.push(bridge.getSummary()); - reportProgress( - "workspace-switch-heavy-prepare", - index + 1, - targets.length, - ); - } - }; - const restoreHeavyWorkspaceSwitchTargets = async () => { - for ( - let index = heavyWorkspaceSwitchWorkspaceIds.length - 1; - index >= 0; - index -= 1 - ) { - const target = heavyWorkspaceSwitchWorkspaceIds[index]; - const operationStartedAt = performance.now(); - await navigateTo(`/v2-workspace/${encodeURIComponent(target)}/`); - const bridge = await waitForBridge(target, 500); - bridge.restoreBaseline(); - recordOperationDuration( - "workspace-switch-heavy-restore", - target, - index, - operationStartedAt, - ); - } - }; - - if (shouldPrepareHeavyWorkspaceSwitch) { - await prepareHeavyWorkspaceSwitchTargets(); - workspaceSummary = { - preparedWorkspaceSummaries: heavyWorkspaceSwitchSummaries, - }; - } - - if (shouldRunWorkspaceSwitch) { - reportProgress("workspace-switch", 0, options.iterations, true); - for (let index = 0; index < options.iterations; index += 1) { - const target = targets[index % targets.length]; - const operationStartedAt = performance.now(); - const mode = await activateWorkspace(target); - recordOperationDuration( - "workspace-switch", - `${mode}:${target}`, - index, - operationStartedAt, - ); - activationModeCounts[mode] = (activationModeCounts[mode] ?? 0) + 1; - operationCount += 1; - reportProgress("workspace-switch", index + 1, options.iterations); - await sleep(options.intervalMs); - } - } - - if (shouldPrepareHeavyWorkspaceSwitch) { - await restoreHeavyWorkspaceSwitchTargets(); - } - - let workspaceId = targets[0]; - let metadata: { projectId?: string; workspaceId?: string } = {}; - if ( - shouldRunRouteSweep || - shouldRunWorkspaceHeavy || - shouldRunTerminalHeavy - ) { - const mounted = await findWorkspaceBridge(targets); - workspaceId = mounted.workspaceId; - const bridge = mounted.bridge; - metadata = { - projectId: bridge.projectId, - workspaceId: bridge.workspaceId, - }; - } - - if (shouldRunRouteSweep) { - const routePaths = buildRoutePaths(targets, metadata); - routeCount = routePaths.length; - reportProgress("route-sweep", 0, routeIterations, true); - for (let index = 0; index < routeIterations; index += 1) { - const routePath = routePaths[index % routePaths.length]; - routesVisited.push(routePath); - const operationStartedAt = performance.now(); - await navigateTo(routePath); - recordOperationDuration( - "route-sweep", - routePath, - index, - operationStartedAt, - ); - operationCount += 1; - reportProgress("route-sweep", index + 1, routeIterations); - } - await navigateTo(`/v2-workspace/${encodeURIComponent(workspaceId)}/`); - await waitForBridge(workspaceId); - } - - if (shouldRunWorkspaceHeavy) { - const activeBridge = await waitForBridge(workspaceId); - activeBridge.captureBaseline(); - let operationStartedAt = performance.now(); - activeBridge.replaceWithGeneratedLayout(12, 3); - recordOperationDuration( - "workspace-heavy", - "replace-generated-layout", - -2, - operationStartedAt, - ); - heavyActionCounts["replace-generated-layout"] = 1; - operationCount += 1; - operationStartedAt = performance.now(); - activeBridge.showChangesSidebar(); - recordOperationDuration( - "workspace-heavy", - "show-changes-sidebar", - -1, - operationStartedAt, - ); - heavyActionCounts["show-changes-sidebar"] = 1; - operationCount += 1; - reportProgress("workspace-heavy", 0, heavyIterations, true); - - const paneKinds = ["file", "diff", "browser", "chat", "comment"]; - for (let index = 0; index < heavyIterations; index += 1) { - const kind = paneKinds[index % paneKinds.length]; - const action = index % 12; - const operationStartedAt = performance.now(); - let actionLabel = "unknown"; - try { - if (options.includeTerminalAction && index === 0) { - actionLabel = "add-real-terminal-tab"; - await withTimeout( - activeBridge.addRealTerminalTab(), - 3000, - "add-real-terminal-tab", - ); - heavyActionCounts["add-real-terminal-tab"] = - (heavyActionCounts["add-real-terminal-tab"] ?? 0) + 1; - } else if (action === 0) { - actionLabel = `add-tab:${kind}`; - activeBridge.addTab(kind, index, (index % 4) + 1); - heavyActionCounts["add-tab"] = - (heavyActionCounts["add-tab"] ?? 0) + 1; - } else if (action === 1 || action === 2) { - actionLabel = `split-active-pane:${kind}`; - activeBridge.splitActivePane(kind, index); - heavyActionCounts["split-active-pane"] = - (heavyActionCounts["split-active-pane"] ?? 0) + 1; - } else if (action === 3) { - actionLabel = `open-pane:${kind}`; - activeBridge.openPane(kind, index); - heavyActionCounts["open-pane"] = - (heavyActionCounts["open-pane"] ?? 0) + 1; - } else if (action === 4 || action === 5) { - actionLabel = "switch-tab"; - activeBridge.switchTab(index); - heavyActionCounts["switch-tab"] = - (heavyActionCounts["switch-tab"] ?? 0) + 1; - } else if (action === 6 || action === 7) { - actionLabel = "churn-active-pane-data"; - activeBridge.churnActivePaneData(index); - heavyActionCounts["churn-active-pane-data"] = - (heavyActionCounts["churn-active-pane-data"] ?? 0) + 1; - } else if (action === 8) { - actionLabel = "close-active-pane"; - activeBridge.closeActivePane(); - heavyActionCounts["close-active-pane"] = - (heavyActionCounts["close-active-pane"] ?? 0) + 1; - } else if (action === 9) { - actionLabel = "close-oldest-tab"; - activeBridge.closeOldestTab(10); - heavyActionCounts["close-oldest-tab"] = - (heavyActionCounts["close-oldest-tab"] ?? 0) + 1; - } else { - actionLabel = `add-single-pane-tab:${kind}`; - activeBridge.addTab(kind, index); - heavyActionCounts["add-single-pane-tab"] = - (heavyActionCounts["add-single-pane-tab"] ?? 0) + 1; - } - } catch (error) { - heavyActionErrors.push( - `heavy action ${index} failed: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - recordOperationDuration( - "workspace-heavy", - actionLabel, - index, - operationStartedAt, - ); - operationCount += 1; - reportProgress("workspace-heavy", index + 1, heavyIterations); - await sleep(options.intervalMs); - } - workspaceSummary = activeBridge.getSummary(); - activeBridge.restoreBaseline(); - heavyActionCounts["restore-baseline"] = 1; - operationCount += 1; - } - - if (shouldRunTerminalHeavy) { - const activeBridge = await waitForBridge(workspaceId); - const terminalTabCount = Math.max( - 1, - Math.min(80, Math.floor(options.terminalTabCount)), - ); - const terminalPanesPerTab = Math.max( - 1, - Math.min(8, Math.floor(options.terminalPanesPerTab)), - ); - - activeBridge.captureBaseline(); - try { - let operationStartedAt = performance.now(); - activeBridge.replaceWithGeneratedTerminalLayout( - terminalTabCount, - terminalPanesPerTab, - ); - await nextFrame(); - recordOperationDuration( - "terminal-heavy", - "replace-generated-terminal-layout", - -1, - operationStartedAt, - ); - terminalActionCounts["replace-generated-terminal-layout"] = 1; - operationCount += 1; - reportProgress("terminal-heavy", 0, terminalIterations, true); - - for (let index = 0; index < terminalIterations; index += 1) { - operationStartedAt = performance.now(); - const actionLabel = "switch-write-terminal-output"; - try { - activeBridge.switchTab(index % terminalTabCount); - terminalActionCounts["switch-tab"] = - (terminalActionCounts["switch-tab"] ?? 0) + 1; - await nextFrame(); - if (options.intervalMs > 0) { - await sleep(options.intervalMs); - } - - const writeResult = await withTimeout( - activeBridge.writeTerminalStressOutput( - index, - options.terminalLines, - options.terminalPayloadBytes, - options.terminalOutputMode, - ), - 15_000, - "write-terminal-stress-output", - ); - terminalActionCounts["write-output"] = - (terminalActionCounts["write-output"] ?? 0) + 1; - if ( - index >= terminalTabCount && - writeResult.failedCount > 0 && - terminalActionErrors.length < 20 - ) { - terminalActionErrors.push( - `terminal write ${index} reached ${writeResult.writtenCount}/${writeResult.terminalCount} runtimes (${writeResult.byteLength} bytes)`, - ); - } - } catch (error) { - terminalActionErrors.push( - `terminal action ${index} failed: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - recordOperationDuration( - "terminal-heavy", - actionLabel, - index, - operationStartedAt, - ); - operationCount += 1; - reportProgress("terminal-heavy", index + 1, terminalIterations); - } - - terminalStressSummary = activeBridge.getTerminalStressSummary(); - } finally { - if (!options.terminalCorruptionCheck) { - activeBridge.restoreBaseline(); - terminalActionCounts["restore-baseline"] = 1; - operationCount += 1; - } - } - } - await sleep(options.settleMs); - - const durationMs = performance.now() - startedAt; - const maxLongTaskDurationMs = longTasks.reduce( - (max, task) => Math.max(max, task.duration), - 0, - ); - return { - scenario: options.scenario, - iterations: options.iterations, - operationCount, - targetCount: targets.length, - activationModeCounts, - routeCount, - routeIterations: shouldRunRouteSweep ? routeIterations : 0, - routesVisited: Array.from(new Set(routesVisited)), - heavyIterations: shouldRunWorkspaceHeavy ? heavyIterations : 0, - heavyActionCounts, - heavyActionErrors: heavyActionErrors.slice(0, 20), - heavyActionCatalogue, - terminalIterations: shouldRunTerminalHeavy ? terminalIterations : 0, - terminalActionCounts, - terminalActionErrors: terminalActionErrors.slice(0, 20), - terminalStressSummary, - workspaceSummary, - reactProbeSummary: reactProbe.getSummary(), - durationMs, - maxHeartbeatDelayMs, - heartbeatDelaySamples: heartbeatDelaySamples.slice(-20), - maxLongTaskDurationMs, - longTaskCount: longTasks.length, - longTasks: longTasks - .slice() - .sort((left, right) => right.duration - left.duration) - .slice(0, 10), - slowOperations: slowOperations - .slice() - .sort((left, right) => right.durationMs - left.durationMs) - .slice(0, 20), - errorCount: errors.length, - errors: errors.slice(0, 20), - startMemory, - endMemory: stressWindow.performance.memory ?? null, - finalLocation: window.location.href, - }; - })().finally(() => { - clearInterval(heartbeat); - reactProbe.cleanup(); - longTaskObserver?.disconnect(); - window.removeEventListener("error", onError); - window.removeEventListener("unhandledrejection", onUnhandledRejection); - }); -} - -async function main() { - const args = parseArgs(Bun.argv.slice(2)); - if (args.help) { - console.log(usage()); - return; - } - - const target = await getRendererTarget(args); - if (!args.json) { - console.log( - `[stress:renderer] attaching to ${target.title || target.url || "renderer"}`, - ); - } - const cdp = await CdpClient.connect( - target.webSocketDebuggerUrl ?? "", - args.timeoutMs, - ); - - try { - await cdp.send("Runtime.enable", {}, args.timeoutMs); - await cdp.send("Performance.enable", {}, args.timeoutMs); - const startDomCounters = await cdp - .send("Memory.getDOMCounters", {}, args.timeoutMs) - .catch((error) => ({ error: String(error) })); - if (args.profileCpu) { - await cdp.send("Profiler.enable", {}, args.timeoutMs); - await cdp.send("Profiler.setSamplingInterval", { interval: 1000 }); - await cdp.send("Profiler.start", {}, args.timeoutMs); - } - const removeConsoleListener = cdp.on( - "Runtime.consoleAPICalled", - (params) => { - if (args.json) return; - const event = params as RuntimeConsoleApiCalledEvent; - const firstArg = event.args?.[0]; - if (firstArg?.value !== "[stress:renderer:progress]") return; - const payload = event.args?.[1]?.value; - if (typeof payload !== "string") return; - try { - const progress = JSON.parse(payload) as { - phase?: string; - index?: number; - total?: number; - operationCount?: number; - elapsedMs?: number; - }; - console.log( - `[stress:renderer] ${progress.phase}: ${progress.index}/${progress.total} operations=${progress.operationCount} elapsed=${progress.elapsedMs}ms`, - ); - } catch { - console.log(`[stress:renderer] ${payload}`); - } - }, - ); - const evaluation = await cdp.send( - "Runtime.evaluate", - { - expression: `(${rendererStress.toString()})(${JSON.stringify({ - scenario: args.scenario, - iterations: args.iterations, - routeIterations: args.routeIterations, - heavyIterations: args.heavyIterations, - terminalIterations: args.terminalIterations, - terminalTabCount: args.terminalTabCount, - terminalPanesPerTab: args.terminalPanesPerTab, - terminalLines: args.terminalLines, - terminalPayloadBytes: args.terminalPayloadBytes, - terminalOutputMode: args.terminalOutputMode, - terminalCorruptionCheck: args.terminalCorruptionCheck, - includeTerminalAction: args.includeTerminalAction, - reactProbe: args.reactProbe, - progressEvery: args.progressEvery, - intervalMs: args.intervalMs, - settleMs: args.settleMs, - selector: args.selector, - workspaceIds: args.workspaceIds, - })})`, - awaitPromise: true, - returnByValue: true, - }, - args.timeoutMs, - ); - removeConsoleListener(); - const cpuProfileTopFrames = args.profileCpu - ? await cdp - .send("Profiler.stop", {}, args.timeoutMs) - .then((result) => - result.profile ? summarizeCpuProfile(result.profile) : [], - ) - .catch((error) => [{ error: String(error) }]) - : null; - - if (evaluation.exceptionDetails) { - throw new Error( - evaluation.exceptionDetails.exception?.description ?? - evaluation.exceptionDetails.text ?? - "Renderer stress script threw", - ); - } - - const summary = evaluation.result?.value as RendererStressResult; - const terminalVisualCorruptionCheck = args.terminalCorruptionCheck - ? await runTerminalVisualCorruptionCheck(cdp, args) - : null; - const cdpMetrics = await cdp - .send("Performance.getMetrics", {}, args.timeoutMs) - .catch((error) => ({ error: String(error) })); - const endDomCounters = await cdp - .send("Memory.getDOMCounters", {}, args.timeoutMs) - .catch((error) => ({ error: String(error) })); - const output = { - ...summary, - terminalVisualCorruptionCheck, - cdpMetrics, - cdpDomCounters: { - start: startDomCounters, - end: endDomCounters, - }, - cpuProfileTopFrames, - thresholds: { - maxHeartbeatDelayMs: args.maxHeartbeatDelayMs, - maxLongTaskMs: args.maxLongTaskMs, - }, - }; - - const failures: string[] = []; - if (summary.errorCount > 0) { - failures.push(`${summary.errorCount} renderer error(s) observed`); - } - if (summary.terminalActionErrors.length > 0) { - failures.push( - `${summary.terminalActionErrors.length} terminal stress error(s) observed`, - ); - } - if (summary.maxHeartbeatDelayMs > args.maxHeartbeatDelayMs) { - failures.push( - `heartbeat delay ${summary.maxHeartbeatDelayMs.toFixed( - 1, - )}ms exceeded ${args.maxHeartbeatDelayMs}ms`, - ); - } - if (summary.maxLongTaskDurationMs > args.maxLongTaskMs) { - failures.push( - `long task ${summary.maxLongTaskDurationMs.toFixed( - 1, - )}ms exceeded ${args.maxLongTaskMs}ms`, - ); - } - if (terminalVisualCorruptionCheck?.failed) { - const changedPixels = - terminalVisualCorruptionCheck.diff?.changedPixels ?? 0; - const threshold = - terminalVisualCorruptionCheck.diff?.thresholdChangedPixels ?? 0; - failures.push( - `terminal visual corruption check changed ${changedPixels} pixel(s) after clearTextureAtlas(), threshold ${threshold}`, - ); - } - - if (args.json) { - console.log(JSON.stringify({ ...output, failures }, null, 2)); - } else { - console.log(JSON.stringify(output, null, 2)); - if (failures.length > 0) { - console.error(`[stress:renderer] failed: ${failures.join("; ")}`); - } else { - console.log("[stress:renderer] passed"); - } - } - - if (failures.length > 0) process.exitCode = 1; - } finally { - cdp.close(); - } -} - -main().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); - console.error(""); - console.error(usage()); - process.exit(1); -}); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 75ea9c0eeb5..03879a6d9ea 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -54,24 +54,6 @@ import { MainWindow } from "./windows/main"; console.log("[main] Local database ready:", !!localDb); const IS_DEV = process.env.NODE_ENV === "development"; -const rendererStressCdpPort = - process.env.SUPERSET_RENDERER_STRESS_CDP_PORT?.trim(); - -if (IS_DEV && rendererStressCdpPort) { - if (/^\d+$/.test(rendererStressCdpPort)) { - app.commandLine.appendSwitch( - "remote-debugging-port", - rendererStressCdpPort, - ); - console.log( - `[main] Renderer stress CDP enabled on 127.0.0.1:${rendererStressCdpPort}`, - ); - } else { - console.warn( - `[main] Ignoring invalid SUPERSET_RENDERER_STRESS_CDP_PORT=${rendererStressCdpPort}`, - ); - } -} void applyShellEnvToProcess().catch((error) => { console.error("[main] Failed to apply shell environment:", error); diff --git a/apps/desktop/src/main/lib/extensions/index.ts b/apps/desktop/src/main/lib/extensions/index.ts index 0af5d81ba43..b49d48afbe9 100644 --- a/apps/desktop/src/main/lib/extensions/index.ts +++ b/apps/desktop/src/main/lib/extensions/index.ts @@ -169,7 +169,6 @@ function resolveWebviewExtensionPath(): string | null { export async function loadReactDevToolsExtension(): Promise { if (env.NODE_ENV !== "development") return; - if (process.env.SUPERSET_RENDERER_STRESS_CDP_PORT) return; const extensionPath = resolveReactDevToolsPath(); if (!extensionPath) { diff --git a/apps/desktop/src/main/lib/host-service-coordinator.test.ts b/apps/desktop/src/main/lib/host-service-coordinator.test.ts index cbe59424b37..d3feabd0ee4 100644 --- a/apps/desktop/src/main/lib/host-service-coordinator.test.ts +++ b/apps/desktop/src/main/lib/host-service-coordinator.test.ts @@ -70,11 +70,6 @@ mock.module("electron-log/main", () => ({ info: () => {}, warn: () => {}, error: () => {}, - transports: { - file: { - level: "info", - }, - }, }, })); diff --git a/apps/desktop/src/renderer/index.tsx b/apps/desktop/src/renderer/index.tsx index eeee7e80d08..3f951aa1b89 100644 --- a/apps/desktop/src/renderer/index.tsx +++ b/apps/desktop/src/renderer/index.tsx @@ -42,19 +42,6 @@ const handleDeepLink = (path: string) => { console.log("[deep-link] Navigating to:", path); router.navigate({ to: path }); }; - -declare global { - interface Window { - __SUPERSET_RENDERER_STRESS_NAVIGATE__?: (path: string) => Promise; - } -} - -if (process.env.NODE_ENV === "development") { - window.__SUPERSET_RENDERER_STRESS_NAVIGATE__ = async (path: string) => { - await router.navigate({ to: path }); - }; -} - const ipcRenderer = window.ipcRenderer as typeof window.ipcRenderer | undefined; if (ipcRenderer) { ipcRenderer.on("deep-link-navigate", handleDeepLink); diff --git a/apps/desktop/src/renderer/lib/posthog.test.ts b/apps/desktop/src/renderer/lib/posthog.test.ts deleted file mode 100644 index 686e2fc5146..00000000000 --- a/apps/desktop/src/renderer/lib/posthog.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { - buildPostHogInitConfig, - POSTHOG_SESSION_REPLAY_BLOCK_SELECTOR, -} from "./posthog"; - -describe("buildPostHogInitConfig", () => { - it("blocks terminal replay surfaces and disables canvas recording", () => { - const config = buildPostHogInitConfig(); - - expect(config.disable_session_recording).toBe(true); - expect(config.autocapture).toBe(false); - expect(config.disable_scroll_properties).toBe(true); - expect(POSTHOG_SESSION_REPLAY_BLOCK_SELECTOR).toContain(".xterm"); - expect(config.session_recording?.blockSelector).toBe( - POSTHOG_SESSION_REPLAY_BLOCK_SELECTOR, - ); - expect(config.session_recording?.captureCanvas).toEqual({ - recordCanvas: false, - canvasFps: 0, - canvasQuality: "0", - }); - }); -}); diff --git a/apps/desktop/src/renderer/lib/posthog.ts b/apps/desktop/src/renderer/lib/posthog.ts index ce574f66a81..180e2ca11d2 100644 --- a/apps/desktop/src/renderer/lib/posthog.ts +++ b/apps/desktop/src/renderer/lib/posthog.ts @@ -1,4 +1,3 @@ -import type { PostHogConfig } from "posthog-js/dist/module.full.no-external"; import posthogFull from "posthog-js/dist/module.full.no-external"; import type { PostHog } from "posthog-js/react"; import { env } from "../env.renderer"; @@ -6,49 +5,26 @@ import { env } from "../env.renderer"; // Cast to standard PostHog type for compatibility with posthog-js/react export const posthog = posthogFull as unknown as PostHog; -export const POSTHOG_SESSION_REPLAY_BLOCK_SELECTOR = [ - "[data-ph-no-capture]", - ".xterm", - ".xterm-screen", - ".xterm-viewport", - ".xterm-helper-textarea", -].join(", "); +export function initPostHog() { + if (!env.NEXT_PUBLIC_POSTHOG_KEY) { + console.log("[posthog] No key configured, skipping"); + return; + } -export function buildPostHogInitConfig(): Partial { - return { + posthogFull.init(env.NEXT_PUBLIC_POSTHOG_KEY, { api_host: env.NEXT_PUBLIC_POSTHOG_HOST, defaults: "2025-11-30", - autocapture: false, capture_pageview: false, capture_pageleave: false, capture_exceptions: true, - disable_scroll_properties: true, - disable_session_recording: true, person_profiles: "identified_only", persistence: "localStorage", debug: false, - session_recording: { - blockSelector: POSTHOG_SESSION_REPLAY_BLOCK_SELECTOR, - captureCanvas: { - recordCanvas: false, - canvasFps: 0, - canvasQuality: "0", - }, - }, loaded: (ph) => { ph.register({ app_name: "desktop", platform: window.navigator.platform, }); }, - }; -} - -export function initPostHog() { - if (!env.NEXT_PUBLIC_POSTHOG_KEY) { - console.log("[posthog] No key configured, skipping"); - return; - } - - posthogFull.init(env.NEXT_PUBLIC_POSTHOG_KEY, buildPostHogInitConfig()); + }); } diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts deleted file mode 100644 index fefd6be8cef..00000000000 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { - afterAll, - afterEach, - beforeEach, - describe, - expect, - it, - mock, -} from "bun:test"; -import type { TerminalAppearance } from "./appearance"; -import type { TerminalRuntime } from "./terminal-runtime"; - -type FakeTerminal = TerminalRuntime["terminal"] & { - write: ReturnType; -}; - -type FakeRuntime = TerminalRuntime & { - terminal: FakeTerminal; - wrapper: HTMLDivElement & { - _canvasList: HTMLCanvasElement[]; - connected: boolean; - }; -}; - -type MockedTerminalRuntimeModule = typeof import("./terminal-runtime"); - -const createdRuntimes: FakeRuntime[] = []; -const createRuntimeMock = mock( - ( - terminalId: string, - _appearance: TerminalAppearance, - _options?: { initialBuffer?: string }, - ): TerminalRuntime => { - const runtime = createFakeRuntime(terminalId); - createdRuntimes.push(runtime); - return runtime; - }, -); -const attachToContainerMock = mock( - ( - runtime: TerminalRuntime, - container: HTMLDivElement, - onResize?: () => void, - ) => { - runtime.container = container; - const wrapper = runtime.wrapper as FakeRuntime["wrapper"]; - wrapper.connected = false; - onResize?.(); - }, -); -const detachFromContainerMock = mock((runtime: TerminalRuntime) => { - runtime.container = null; - const wrapper = runtime.wrapper as FakeRuntime["wrapper"]; - wrapper.connected = true; -}); -const disposeRuntimeMock = mock((runtime: TerminalRuntime) => { - runtime.container = null; - const wrapper = runtime.wrapper as FakeRuntime["wrapper"]; - wrapper.connected = false; -}); -const updateRuntimeAppearanceMock = mock( - (_runtime: TerminalRuntime, _appearance: TerminalAppearance) => {}, -); - -mock.module( - "./terminal-runtime", - (): Partial => ({ - attachToContainer: attachToContainerMock, - createRuntime: createRuntimeMock, - detachFromContainer: detachFromContainerMock, - disposeRuntime: disposeRuntimeMock, - updateRuntimeAppearance: updateRuntimeAppearanceMock, - }), -); - -const { terminalRuntimeRegistry } = await import("./terminal-runtime-registry"); - -const appearance: TerminalAppearance = { - background: "#000000", - fontFamily: "Menlo", - fontSize: 13, - theme: {}, -}; - -function createFakeTerminal(): FakeTerminal { - return { - cols: 120, - rows: 32, - write: mock((_data: string | Uint8Array, callback?: () => void) => { - callback?.(); - }), - getSelection: mock(() => ""), - clear: mock(() => {}), - scrollToBottom: mock(() => {}), - paste: mock((_text: string) => {}), - options: { linkHandler: null }, - } as unknown as FakeTerminal; -} - -function createFakeRuntime(terminalId: string): FakeRuntime { - const wrapper = { - _canvasList: [] as HTMLCanvasElement[], - connected: false, - querySelectorAll: mock((_selector: string) => wrapper._canvasList), - } as unknown as FakeRuntime["wrapper"]; - Object.defineProperty(wrapper, "isConnected", { - get: () => wrapper.connected, - }); - - return { - terminalId, - terminal: createFakeTerminal(), - fitAddon: {} as TerminalRuntime["fitAddon"], - serializeAddon: { - serialize: mock(() => `serialized:${terminalId}`), - } as unknown as TerminalRuntime["serializeAddon"], - searchAddon: null, - progressAddon: null, - wrapper, - container: null, - resizeObserver: null, - _disposeResizeObserver: null, - lastCols: 120, - lastRows: 32, - _disposeAddons: null, - _disposeImagePasteFallback: null, - }; -} - -function createContainer(): HTMLDivElement { - return {} as HTMLDivElement; -} - -function clearRegistry() { - for (const terminalId of terminalRuntimeRegistry.getAllTerminalIds()) { - terminalRuntimeRegistry.dispose(terminalId); - } -} - -beforeEach(() => { - clearRegistry(); - for (const fn of [ - createRuntimeMock, - attachToContainerMock, - detachFromContainerMock, - disposeRuntimeMock, - updateRuntimeAppearanceMock, - ]) { - fn.mockClear(); - } - createdRuntimes.length = 0; -}); - -afterEach(() => { - clearRegistry(); -}); - -afterAll(() => { - clearRegistry(); - mock.restore(); -}); - -describe("terminalRuntimeRegistry", () => { - it("reuses the renderer runtime across detach and remount", () => { - const firstContainer = createContainer(); - const secondContainer = createContainer(); - - terminalRuntimeRegistry.mount( - "terminal-1", - firstContainer, - appearance, - "workspace-a", - ); - terminalRuntimeRegistry.detach("terminal-1", "workspace-a"); - terminalRuntimeRegistry.mount( - "terminal-1", - secondContainer, - appearance, - "workspace-a", - ); - - expect(createRuntimeMock).toHaveBeenCalledTimes(1); - expect(attachToContainerMock).toHaveBeenCalledTimes(2); - expect(detachFromContainerMock).toHaveBeenCalledTimes(1); - expect(updateRuntimeAppearanceMock).toHaveBeenCalledTimes(1); - expect(terminalRuntimeRegistry.getStressDebugInfo("terminal-1")).toEqual([ - expect.objectContaining({ - terminalId: "terminal-1", - instanceId: "workspace-a", - hasRuntime: true, - isAttached: true, - }), - ]); - }); - - it("releasing one terminal instance leaves sibling instances alive", () => { - terminalRuntimeRegistry.mount( - "terminal-1", - createContainer(), - appearance, - "workspace-a", - ); - terminalRuntimeRegistry.mount( - "terminal-1", - createContainer(), - appearance, - "workspace-b", - ); - - terminalRuntimeRegistry.release("terminal-1", "workspace-a"); - - expect(disposeRuntimeMock).toHaveBeenCalledTimes(1); - expect(terminalRuntimeRegistry.getStressDebugInfo("terminal-1")).toEqual([ - expect.objectContaining({ - terminalId: "terminal-1", - instanceId: "workspace-b", - hasRuntime: true, - }), - ]); - expect(terminalRuntimeRegistry.getAllTerminalIds()).toEqual( - new Set(["terminal-1"]), - ); - }); - - it("does not expand stress queries when only an instance id is provided", () => { - terminalRuntimeRegistry.mount( - "terminal-a", - createContainer(), - appearance, - "instance-a", - ); - terminalRuntimeRegistry.mount( - "terminal-b", - createContainer(), - appearance, - "instance-b", - ); - - expect( - terminalRuntimeRegistry.getStressDebugInfo(undefined, "instance-a"), - ).toEqual([]); - }); - - it("resolves stress output after xterm write completion", async () => { - terminalRuntimeRegistry.mount( - "terminal-1", - createContainer(), - appearance, - "workspace-a", - ); - const writeState: { flush: (() => void) | null } = { flush: null }; - createdRuntimes[0]?.terminal.write.mockImplementation( - (_data: string | Uint8Array, callback?: () => void) => { - writeState.flush = () => callback?.(); - }, - ); - - const acceptedPromise = terminalRuntimeRegistry.writeForStress( - "terminal-1", - "large output", - "workspace-a", - ); - let resolved = false; - void acceptedPromise.then(() => { - resolved = true; - }); - await Promise.resolve(); - - expect(resolved).toBe(false); - if (!writeState.flush) { - throw new Error("Expected xterm write callback to be captured"); - } - writeState.flush(); - const accepted = await acceptedPromise; - expect(accepted).toBe(true); - const writeCall = createdRuntimes[0]?.terminal.write.mock.calls[0]; - expect(writeCall?.[0]).toBe("large output"); - expect(typeof writeCall?.[1]).toBe("function"); - }); -}); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts index 836d9f12d8f..c9dad761712 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -37,92 +37,6 @@ interface RegistryEntry { pendingLinkHandlers: TerminalLinkHandlers | null; } -export interface TerminalRuntimeStressDebugInfo { - terminalId: string; - instanceId: string; - hasRuntime: boolean; - isAttached: boolean; - isParked: boolean; - cols: number | null; - rows: number | null; - canvasCount: number; - renderer: TerminalRendererStressDebugInfo | null; - connectionState: ConnectionState; -} - -export interface TerminalRendererStressDebugInfo { - rendererType: string | null; - atlasPageCount: number | null; - atlasPageSizes: number[]; - atlasPageVersions: Array; - pendingAtlasClear: boolean | null; -} - -function getObjectProperty(source: unknown, key: string): unknown { - if (!source || (typeof source !== "object" && typeof source !== "function")) { - return undefined; - } - return (source as Record)[key]; -} - -function getNumberProperty(source: unknown, key: string): number | null { - const value = getObjectProperty(source, key); - return typeof value === "number" ? value : null; -} - -function getTerminalRenderer(terminal: TerminalRuntime["terminal"]): unknown { - const core = getObjectProperty(terminal, "_core"); - const renderService = getObjectProperty(core, "_renderService"); - const rendererHolder = getObjectProperty(renderService, "_renderer"); - return getObjectProperty(rendererHolder, "value") ?? rendererHolder; -} - -function getRendererStressDebugInfo( - terminal: TerminalRuntime["terminal"], -): TerminalRendererStressDebugInfo | null { - const renderer = getTerminalRenderer(terminal); - if (!renderer || typeof renderer !== "object") return null; - - const rendererConstructor = getObjectProperty(renderer, "constructor"); - const rendererType = - typeof getObjectProperty(rendererConstructor, "name") === "string" - ? (getObjectProperty(rendererConstructor, "name") as string) - : null; - const charAtlas = getObjectProperty(renderer, "_charAtlas"); - const rawPages = getObjectProperty(charAtlas, "pages"); - const pages = Array.isArray(rawPages) ? rawPages : []; - return { - rendererType, - atlasPageCount: Array.isArray(rawPages) ? rawPages.length : null, - atlasPageSizes: pages - .map((page) => - getNumberProperty(getObjectProperty(page, "canvas"), "width"), - ) - .filter((size): size is number => size !== null), - atlasPageVersions: pages.map((page) => getNumberProperty(page, "version")), - pendingAtlasClear: - typeof getObjectProperty(charAtlas, "_requestClearModel") === "boolean" - ? (getObjectProperty(charAtlas, "_requestClearModel") as boolean) - : null, - }; -} - -function clearRendererTextureAtlasForStress( - terminal: TerminalRuntime["terminal"], -): boolean { - const renderer = getTerminalRenderer(terminal); - const clearTextureAtlas = getObjectProperty(renderer, "clearTextureAtlas"); - if (typeof clearTextureAtlas !== "function") return false; - - try { - clearTextureAtlas.call(renderer); - terminal.refresh(0, Math.max(0, terminal.rows - 1)); - return true; - } catch { - return false; - } -} - class TerminalRuntimeRegistryImpl { private entries = new Map(); private entryKeysByTerminalId = new Map>(); @@ -185,19 +99,6 @@ class TerminalRuntimeRegistryImpl { .filter((entry): entry is RegistryEntry => Boolean(entry)); } - private listEntries( - terminalId?: string, - instanceId?: string, - ): RegistryEntry[] { - if (terminalId && instanceId) { - const entry = this.getEntry(terminalId, instanceId); - return entry ? [entry] : []; - } - if (terminalId) return this.getEntries(terminalId); - if (instanceId) return []; - return Array.from(this.entries.values()); - } - private deleteEntry(entry: RegistryEntry) { const key = this.getEntryKey(entry.terminalId, entry.instanceId); this.entries.delete(key); @@ -410,55 +311,6 @@ class TerminalRuntimeRegistryImpl { sendInput(entry.transport, data); } - writeForStress( - terminalId: string, - data: string, - instanceId?: string, - ): Promise { - const entry = this.getEntry(terminalId, instanceId); - if (!entry?.runtime) return Promise.resolve(false); - const { terminal } = entry.runtime; - - return new Promise((resolve) => { - try { - terminal.write(data, () => resolve(true)); - } catch { - resolve(false); - } - }); - } - - clearTextureAtlasForStress(terminalId: string, instanceId?: string): boolean { - const entry = this.getEntry(terminalId, instanceId); - if (!entry?.runtime) return false; - return clearRendererTextureAtlasForStress(entry.runtime.terminal); - } - - getStressDebugInfo( - terminalId?: string, - instanceId?: string, - ): TerminalRuntimeStressDebugInfo[] { - return this.listEntries(terminalId, instanceId).map((entry) => { - const runtime = entry.runtime; - return { - terminalId: entry.terminalId, - instanceId: entry.instanceId, - hasRuntime: Boolean(runtime), - isAttached: Boolean(runtime?.container), - isParked: Boolean( - runtime && !runtime.container && runtime.wrapper.isConnected, - ), - cols: runtime?.terminal.cols ?? null, - rows: runtime?.terminal.rows ?? null, - canvasCount: runtime - ? runtime.wrapper.querySelectorAll("canvas").length - : 0, - renderer: runtime ? getRendererStressDebugInfo(runtime.terminal) : null, - connectionState: entry.transport.connectionState, - }; - }); - } - findNext(terminalId: string, query: string, instanceId?: string): boolean { const entry = this.getEntry(terminalId, instanceId); return entry?.runtime?.searchAddon?.findNext(query) ?? false; diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts index 84c0fe12f9d..fd85b46761d 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -187,25 +187,6 @@ function createResizeScheduler( return { observe, dispose }; } -function disposeTerminalAfterPendingRefresh(runtime: TerminalRuntime) { - getTerminalParkingContainer().appendChild(runtime.wrapper); - runtime.container = null; - - const dispose = () => { - runtime.wrapper.remove(); - runtime.terminal.dispose(); - }; - - if (typeof requestAnimationFrame !== "function") { - setTimeout(dispose, 0); - return; - } - - requestAnimationFrame(() => { - requestAnimationFrame(dispose); - }); -} - export function createRuntime( terminalId: string, appearance: TerminalAppearance, @@ -341,7 +322,8 @@ export function disposeRuntime( runtime._disposeResizeObserver = null; runtime.resizeObserver?.disconnect(); runtime.resizeObserver = null; - disposeTerminalAfterPendingRefresh(runtime); + runtime.wrapper.remove(); + runtime.terminal.dispose(); if (clearPersistedState) { clearPersistedBuffer(runtime.terminalId); clearPersistedDimensions(runtime.terminalId); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx index 8b7922ebcee..91fd2184f2e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx @@ -141,7 +141,6 @@ export function DashboardSidebarWorkspaceItem({ onClick={handleClick} creationStatus={creationStatus} pullRequestState={pullRequest?.state ?? null} - data-renderer-stress-workspace-id={id} aria-label={ creationStatus ? `Creating workspace: ${name}` : undefined } @@ -222,7 +221,6 @@ export function DashboardSidebarWorkspaceItem({ isInSection={isInSection} onClick={handleClick} onDoubleClick={isPending ? undefined : startRename} - data-renderer-stress-workspace-id={id} onRemoveFromSidebarClick={handleRemoveFromSidebar} onCloseWorkspaceClick={ isFailedInFlight diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx index 19df437c26d..2eda5ee39c1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx @@ -1,6 +1,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; -import { type ComponentPropsWithoutRef, forwardRef, useMemo } from "react"; +import { + type ComponentPropsWithoutRef, + forwardRef, + useEffect, + useMemo, + useRef, +} from "react"; import { HiMiniMinus, HiMiniXMark } from "react-icons/hi2"; import type { DiffStats } from "renderer/hooks/host-service/useDiffStats"; import { HotkeyLabel } from "renderer/hotkeys"; @@ -80,8 +86,18 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< creationStatus, } = workspace; const showsStandaloneActiveStripe = accentColor == null; + const localRef = useRef(null); const openUrl = electronTrpc.external.openUrl.useMutation(); + useEffect(() => { + if (isActive) { + localRef.current?.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }); + } + }, [isActive]); + const creationStatusText = useMemo( () => getCreationStatusText(creationStatus), [creationStatus], @@ -100,7 +116,11 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< role={onClick ? "button" : undefined} tabIndex={onClick ? 0 : undefined} aria-disabled={creationStatus ? true : undefined} - ref={ref} + ref={(node) => { + localRef.current = node; + if (typeof ref === "function") ref(node); + else if (ref) ref.current = node; + }} onClick={onClick} onKeyDown={(event) => { if (onClick && (event.key === "Enter" || event.key === " ")) { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts index 2647133ddb0..2e7313d0984 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts @@ -5,7 +5,6 @@ import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { electronTrpcClient } from "renderer/lib/trpc-client"; import { useDashboardSidebarSectionRename } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSectionRenameContext"; -import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; @@ -57,8 +56,10 @@ export function useDashboardSidebarWorkspaceItemActions({ const handleClick = () => { if (isRenaming) return; clearWorkspaceAttention(workspaceId); - if (isActive) return; - void navigateToV2Workspace(workspaceId, navigate); + navigate({ + to: "/v2-workspace/$workspaceId", + params: { workspaceId }, + }); }; const startRename = () => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts index 6e96aab93d0..82413df4232 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts @@ -108,7 +108,7 @@ export function useDashboardSidebarShortcuts( const workspace = flattenedWorkspaces[index]; if (workspace) { revealWorkspace(workspace.id); - void navigateToV2Workspace(workspace.id, navigate); + navigateToV2Workspace(workspace.id, navigate); } }, [flattenedWorkspaces, navigate, revealWorkspace], @@ -141,7 +141,7 @@ export function useDashboardSidebarShortcuts( const prevIndex = index <= 0 ? flattenedWorkspaces.length - 1 : index - 1; const target = flattenedWorkspaces[prevIndex]; revealWorkspace(target.id); - void navigateToV2Workspace(target.id, navigate); + navigateToV2Workspace(target.id, navigate); }); useHotkey("NEXT_WORKSPACE", () => { @@ -153,7 +153,7 @@ export function useDashboardSidebarShortcuts( const nextIndex = index >= flattenedWorkspaces.length - 1 ? 0 : index + 1; const target = flattenedWorkspaces[nextIndex]; revealWorkspace(target.id); - void navigateToV2Workspace(target.id, navigate); + navigateToV2Workspace(target.id, navigate); }); return workspaceShortcutLabels; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts index 5d9aa455027..a807561923b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts @@ -17,112 +17,6 @@ export interface V2WorkspaceSearchParams { openUrlRequestId?: string; } -type V2WorkspaceNavigateRest = Omit< - NavigateOptions, - "to" | "params" | "search" ->; - -interface QueuedV2WorkspaceNavigation { - workspaceId: string; - navigate: UseNavigateResult; - rest: V2WorkspaceNavigateRest; - resolve: () => void; - reject: (error: unknown) => void; -} - -let queuedV2WorkspaceNavigation: QueuedV2WorkspaceNavigation | null = null; -let v2WorkspaceNavigationTimer: ReturnType | null = null; -let v2WorkspaceNavigationInFlight = false; -const WORKSPACE_SWITCH_COALESCE_MS = 25; - -function getCurrentV2WorkspaceId(): string | null { - if (typeof window === "undefined") return null; - const match = window.location.hash.match(/^#\/v2-workspace\/([^/?#]+)/); - if (!match?.[1]) return null; - return decodeURIComponent(match[1]); -} - -function hasSearchParams(search: V2WorkspaceSearchParams | undefined): boolean { - return search != null && Object.values(search).some((value) => value != null); -} - -function performV2WorkspaceNavigation( - workspaceId: string, - navigate: UseNavigateResult, - rest: V2WorkspaceNavigateRest, - search: V2WorkspaceSearchParams = {}, -): Promise { - const shouldReplaceWorkspaceSwitch = - rest.replace == null && - typeof window !== "undefined" && - window.location.hash.startsWith("#/v2-workspace/"); - - return navigate({ - to: "/v2-workspace/$workspaceId", - params: { workspaceId }, - search, - ...rest, - replace: shouldReplaceWorkspaceSwitch ? true : rest.replace, - }); -} - -function scheduleV2WorkspaceNavigationDrain() { - if (v2WorkspaceNavigationInFlight) { - return; - } - - if (v2WorkspaceNavigationTimer) { - clearTimeout(v2WorkspaceNavigationTimer); - } - v2WorkspaceNavigationTimer = setTimeout(() => { - void drainQueuedV2WorkspaceNavigation(); - }, WORKSPACE_SWITCH_COALESCE_MS); -} - -async function drainQueuedV2WorkspaceNavigation() { - v2WorkspaceNavigationTimer = null; - const next = queuedV2WorkspaceNavigation; - queuedV2WorkspaceNavigation = null; - if (!next) return; - - v2WorkspaceNavigationInFlight = true; - try { - if (getCurrentV2WorkspaceId() !== next.workspaceId) { - await performV2WorkspaceNavigation( - next.workspaceId, - next.navigate, - next.rest, - ); - } - next.resolve(); - } catch (error) { - next.reject(error); - } finally { - v2WorkspaceNavigationInFlight = false; - if (queuedV2WorkspaceNavigation) { - scheduleV2WorkspaceNavigationDrain(); - } - } -} - -function enqueueV2WorkspaceNavigation( - workspaceId: string, - navigate: UseNavigateResult, - rest: V2WorkspaceNavigateRest, -): Promise { - queuedV2WorkspaceNavigation?.resolve(); - return new Promise((resolve, reject) => { - queuedV2WorkspaceNavigation = { - workspaceId, - navigate, - rest, - resolve, - reject, - }; - scheduleV2WorkspaceNavigationDrain(); - }); -} - /** * Navigate to a workspace and update localStorage to remember it as the last viewed workspace. * This ensures the workspace will be restored when the app is reopened. @@ -159,13 +53,10 @@ export function navigateToV2Workspace( }, ): Promise { const { search, ...rest } = options ?? {}; - if (!hasSearchParams(search) && getCurrentV2WorkspaceId() === workspaceId) { - return Promise.resolve(); - } - - if (!hasSearchParams(search)) { - return enqueueV2WorkspaceNavigation(workspaceId, navigate, rest); - } - - return performV2WorkspaceNavigation(workspaceId, navigate, rest, search); + return navigate({ + to: "/v2-workspace/$workspaceId", + params: { workspaceId }, + search: search ?? {}, + ...rest, + }); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts index 409ffec6599..31ebb1cc4a6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts @@ -27,7 +27,6 @@ interface RegistryEntry { placeholder: HTMLElement | null; resizeObserver: ResizeObserver | null; visible: boolean; - lastUsedAt: number; } const EMPTY_STATE: BrowserRuntimeState = Object.freeze({ @@ -41,7 +40,6 @@ const EMPTY_STATE: BrowserRuntimeState = Object.freeze({ }); const ROOT_CONTAINER_ID = "browser-runtime-root"; -const MAX_DETACHED_BROWSER_RUNTIMES = 8; class BrowserRuntimeRegistryImpl { private entries = new Map(); @@ -164,7 +162,6 @@ class BrowserRuntimeRegistryImpl { private setState(paneId: string, patch: Partial) { const entry = this.entries.get(paneId); if (!entry) return; - entry.lastUsedAt = Date.now(); let changed = false; for (const key in patch) { const k = key as keyof BrowserRuntimeState; @@ -215,7 +212,6 @@ class BrowserRuntimeRegistryImpl { placeholder: null, resizeObserver: null, visible: false, - lastUsedAt: Date.now(), }; const firePersist = () => { @@ -385,7 +381,6 @@ class BrowserRuntimeRegistryImpl { entry.onPersist = onPersist; entry.placeholder = placeholder; entry.visible = true; - entry.lastUsedAt = Date.now(); entry.resizeObserver?.disconnect(); const observer = new ResizeObserver(() => { @@ -397,20 +392,17 @@ class BrowserRuntimeRegistryImpl { this.updateLayout(entry); entry.webview.style.visibility = "visible"; this.applyPointerPassthrough(); - this.pruneDetachedRuntimes(); } detach(paneId: string): void { const entry = this.entries.get(paneId); if (!entry) return; - entry.lastUsedAt = Date.now(); entry.onPersist = null; entry.placeholder = null; entry.resizeObserver?.disconnect(); entry.resizeObserver = null; entry.visible = false; entry.webview.style.visibility = "hidden"; - this.pruneDetachedRuntimes(); } destroy(paneId: string): void { @@ -424,21 +416,9 @@ class BrowserRuntimeRegistryImpl { electronTrpcClient.browser.unregister.mutate({ paneId }).catch(() => {}); } - private pruneDetachedRuntimes(): void { - const detachedEntries = Array.from(this.entries.entries()) - .filter(([, entry]) => !entry.visible) - .sort(([, a], [, b]) => a.lastUsedAt - b.lastUsedAt); - while (detachedEntries.length > MAX_DETACHED_BROWSER_RUNTIMES) { - const [paneId] = detachedEntries.shift() ?? []; - if (!paneId) return; - this.destroy(paneId); - } - } - navigate(paneId: string, url: string): void { const entry = this.entries.get(paneId); if (!entry) return; - entry.lastUsedAt = Date.now(); entry.webview.loadURL(sanitizeUrl(url)).catch((err) => { console.error("[browserRuntimeRegistry] loadURL failed:", err); }); @@ -446,19 +426,16 @@ class BrowserRuntimeRegistryImpl { goBack(paneId: string): void { const entry = this.entries.get(paneId); - if (entry) entry.lastUsedAt = Date.now(); if (entry?.webview.canGoBack()) entry.webview.goBack(); } goForward(paneId: string): void { const entry = this.entries.get(paneId); - if (entry) entry.lastUsedAt = Date.now(); if (entry?.webview.canGoForward()) entry.webview.goForward(); } reload(paneId: string): void { const entry = this.entries.get(paneId); - if (entry) entry.lastUsedAt = Date.now(); entry?.webview.reload(); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalRemoteControlButton/TerminalRemoteControlButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalRemoteControlButton/TerminalRemoteControlButton.tsx index 60b1eb82719..1ac334fe95a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalRemoteControlButton/TerminalRemoteControlButton.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalRemoteControlButton/TerminalRemoteControlButton.tsx @@ -8,10 +8,9 @@ import { import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ExternalLink, Radio } from "lucide-react"; import { useFeatureFlagEnabled } from "posthog-js/react"; -import { useMemo, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { apiTrpcClient } from "renderer/lib/api-trpc-client"; interface TerminalRemoteControlButtonProps { @@ -21,7 +20,6 @@ interface TerminalRemoteControlButtonProps { interface ActiveSession { sessionId: string; - terminalId: string; // `webUrl` is only available right after `create` — the cloud only // stores `token_hash`, so we cannot reconstruct the share URL when // hydrating from `listForWorkspace`. `null` means "session is live but @@ -32,8 +30,6 @@ interface ActiveSession { type Phase = "inactive" | "loading" | "creating" | "active" | "revoking"; const HYDRATE_REFRESH_MS = 30_000; -const remoteControlSessionsQueryKey = (workspaceId: string) => - ["remote-control-sessions", workspaceId] as const; export function TerminalRemoteControlButton({ workspaceId, @@ -44,55 +40,69 @@ export function TerminalRemoteControlButton({ const hasAccess = useFeatureFlagEnabled( FEATURE_FLAGS.WEB_REMOTE_CONTROL_ACCESS, ); - const queryClient = useQueryClient(); - const queryKey = remoteControlSessionsQueryKey(workspaceId); - const [actionPhase, setActionPhase] = useState("inactive"); - const [localActive, setLocalActive] = useState(null); - const sessionsQuery = useQuery({ - queryKey, - enabled: Boolean(hasAccess), - queryFn: () => - apiTrpcClient.remoteControl.listForWorkspace.query({ workspaceId }), - refetchInterval: HYDRATE_REFRESH_MS, - refetchOnWindowFocus: false, - staleTime: 5_000, - }); + const [phase, setPhase] = useState("loading"); + const [active, setActive] = useState(null); - const active = useMemo(() => { - const now = Date.now(); - const live = sessionsQuery.data?.find( - (row) => - row.terminalId === terminalId && - row.status === "active" && - new Date(row.expiresAt).getTime() > now, - ); - if (live) { - return { - sessionId: live.sessionId, - terminalId: live.terminalId, - webUrl: - localActive?.sessionId === live.sessionId ? localActive.webUrl : null, - expiresAt: live.expiresAt, - }; - } - if ( - localActive && - localActive.terminalId === terminalId && - new Date(localActive.expiresAt).getTime() > now - ) { - return localActive; - } - return null; - }, [localActive, sessionsQuery.data, terminalId]); + const hydrate = useCallback( + async (signal?: AbortSignal): Promise => { + try { + const rows = await apiTrpcClient.remoteControl.listForWorkspace.query({ + workspaceId, + }); + if (signal?.aborted) return; + const now = Date.now(); + const live = rows.find( + (r) => + r.terminalId === terminalId && + r.status === "active" && + new Date(r.expiresAt).getTime() > now, + ); + if (live) { + setActive((prev) => ({ + sessionId: live.sessionId, + // Preserve a previously-captured webUrl if we still have + // it for the same session (e.g., we minted it ourselves + // in this component lifetime). + webUrl: + prev && prev.sessionId === live.sessionId ? prev.webUrl : null, + expiresAt: live.expiresAt, + })); + setPhase((prev) => + prev === "creating" || prev === "revoking" ? prev : "active", + ); + } else { + setActive(null); + setPhase((prev) => + prev === "creating" || prev === "revoking" ? prev : "inactive", + ); + } + } catch { + // Silent — background refresh; the user still has the optimistic + // state from the last successful action. + if (!signal?.aborted) { + setPhase((prev) => (prev === "loading" ? "inactive" : prev)); + } + } + }, + [workspaceId, terminalId], + ); - const phase: Phase = - actionPhase === "creating" || actionPhase === "revoking" - ? actionPhase - : sessionsQuery.isPending && !sessionsQuery.data - ? "loading" - : active - ? "active" - : "inactive"; + useEffect(() => { + // Don't run the cloud hydrate poll when the user isn't in the cohort + // — that just wastes a tRPC call every 30s for a button that won't + // render anyway. + if (!hasAccess) return; + const ac = new AbortController(); + void hydrate(ac.signal); + const timer = setInterval( + () => void hydrate(ac.signal), + HYDRATE_REFRESH_MS, + ); + return () => { + ac.abort(); + clearInterval(timer); + }; + }, [hydrate, hasAccess]); if (!hasAccess) return null; @@ -108,24 +118,22 @@ export function TerminalRemoteControlButton({ } async function startShare() { - setActionPhase("creating"); + setPhase("creating"); try { const result = await apiTrpcClient.remoteControl.create.mutate({ workspaceId, terminalId, mode: "full", }); - setLocalActive({ + setActive({ sessionId: result.sessionId, - terminalId, webUrl: result.webUrl, expiresAt: result.expiresAt, }); - setActionPhase("inactive"); - void queryClient.invalidateQueries({ queryKey }); + setPhase("active"); void copyLink(result.webUrl); } catch (err) { - setActionPhase("inactive"); + setPhase("inactive"); toast.error( `Failed to start remote control: ${err instanceof Error ? err.message : String(err)}`, ); @@ -134,17 +142,16 @@ export function TerminalRemoteControlButton({ async function stopShare() { if (!active) return; - setActionPhase("revoking"); + setPhase("revoking"); try { await apiTrpcClient.remoteControl.revoke.mutate({ sessionId: active.sessionId, }); - setLocalActive(null); - setActionPhase("inactive"); - void queryClient.invalidateQueries({ queryKey }); + setActive(null); + setPhase("inactive"); toast.success("Remote control stopped"); } catch (err) { - setActionPhase("inactive"); + setPhase("active"); toast.error( `Failed to stop remote control: ${err instanceof Error ? err.message : String(err)}`, ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/index.ts deleted file mode 100644 index 34745d466bd..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useRendererStressWorkspaceBridge } from "./useRendererStressWorkspaceBridge"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/useRendererStressWorkspaceBridge.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/useRendererStressWorkspaceBridge.ts deleted file mode 100644 index 72869cbd475..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/useRendererStressWorkspaceBridge.ts +++ /dev/null @@ -1,804 +0,0 @@ -import type { SelectV2Workspace } from "@superset/db/schema"; -import type { - CreatePaneInput, - LayoutNode, - Pane, - Tab, - WorkspaceState, - WorkspaceStore, -} from "@superset/panes"; -import { useEffect, useRef } from "react"; -import { - type TerminalRuntimeStressDebugInfo, - terminalRuntimeRegistry, -} from "renderer/lib/terminal/terminal-runtime-registry"; -import type { StoreApi } from "zustand/vanilla"; -import type { - BrowserPaneData, - ChatPaneData, - CommentPaneData, - DiffPaneData, - FilePaneData, - PaneViewerData, - TerminalPaneData, -} from "../../types"; - -type RendererStressPaneKind = "browser" | "chat" | "comment" | "diff" | "file"; -type RendererStressTabKind = RendererStressPaneKind | "terminal"; - -interface RendererStressSummary { - workspaceId: string; - projectId: string; - activeTabId: string | null; - tabCount: number; - paneCount: number; - paneKindCounts: Record; - filePathCount: number; -} - -interface RendererStressTerminalSummary extends RendererStressSummary { - stressTerminalIdCount: number; - terminalRuntimeCount: number; - terminalRuntimes: TerminalRuntimeStressDebugInfo[]; -} - -interface RendererStressTerminalWriteResult { - terminalCount: number; - writtenCount: number; - failedCount: number; - byteLength: number; -} - -type RendererStressTerminalOutputMode = - | "atlas-rewrite" - | "cjk-sgr" - | "glyph-sentinel" - | "scrollback" - | "tui-churn"; - -interface RendererStressWorkspaceBridge { - workspaceId: string; - projectId: string; - captureBaseline: () => void; - restoreBaseline: () => void; - getSummary: () => RendererStressSummary; - addTab: ( - kind: RendererStressTabKind, - index: number, - paneCount?: number, - ) => void; - openPane: (kind: RendererStressPaneKind, index: number) => void; - splitActivePane: (kind: RendererStressPaneKind, index: number) => void; - switchTab: (index: number) => void; - closeActivePane: () => void; - closeOldestTab: (keepCount?: number) => void; - churnActivePaneData: (index: number) => void; - replaceWithGeneratedLayout: (tabCount: number, panesPerTab: number) => void; - replaceWithGeneratedTerminalLayout: ( - tabCount: number, - panesPerTab: number, - ) => void; - replaceWithGeneratedMixedLayout: ( - tabCount: number, - panesPerTab: number, - ) => void; - writeTerminalStressOutput: ( - index: number, - lines: number, - payloadBytes: number, - mode?: RendererStressTerminalOutputMode, - ) => Promise; - clearTerminalTextureAtlasesForStress: () => number; - getTerminalStressSummary: () => RendererStressTerminalSummary; - releaseStressTerminalRuntimes: () => void; - addRealTerminalTab: () => Promise; - showChangesSidebar: () => void; -} - -declare global { - interface Window { - __SUPERSET_RENDERER_STRESS__?: RendererStressWorkspaceBridge; - __SUPERSET_RENDERER_STRESS_STATE__?: RendererStressGlobalState; - } -} - -const FALLBACK_FILE_ROOT = "/tmp/superset-renderer-stress"; - -interface RendererStressGlobalState { - baselinesByWorkspaceId: Record>; - terminalIdsByWorkspaceId: Record; -} - -function getRendererStressGlobalState(): RendererStressGlobalState { - window.__SUPERSET_RENDERER_STRESS_STATE__ ??= { - baselinesByWorkspaceId: {}, - terminalIdsByWorkspaceId: {}, - }; - return window.__SUPERSET_RENDERER_STRESS_STATE__; -} - -function cloneWorkspaceState( - state: WorkspaceState, -): WorkspaceState { - return JSON.parse(JSON.stringify(state)) as WorkspaceState; -} - -function getStoreStateSnapshot( - store: StoreApi>, -): WorkspaceState { - const state = store.getState(); - return cloneWorkspaceState({ - version: state.version, - tabs: state.tabs, - activeTabId: state.activeTabId, - }); -} - -function getFilePath( - filePaths: string[], - index: number, - worktreePath?: string | null, -): string { - if (filePaths.length > 0) { - return filePaths[index % filePaths.length]; - } - if (worktreePath) { - const moduleId = String(index % 20).padStart(2, "0"); - const fileId = String(index % 200).padStart(3, "0"); - return `${worktreePath}/src/module-${moduleId}/file-${fileId}.ts`; - } - return `${FALLBACK_FILE_ROOT}/fixture-${index % 25}.tsx`; -} - -function createStressTerminalId(workspaceId: string, index: number): string { - return `renderer-stress-terminal-${workspaceId}-${index}`; -} - -function createStressTerminalPane( - workspaceId: string, - index: number, - rememberStressTerminalId: (terminalId: string) => void, -): CreatePaneInput { - const terminalId = createStressTerminalId(workspaceId, index); - rememberStressTerminalId(terminalId); - return { - kind: "terminal", - data: { - terminalId, - } satisfies TerminalPaneData, - }; -} - -function buildStressLayoutNode( - paneIds: string[], - direction: "horizontal" | "vertical" = "vertical", -): LayoutNode { - if (paneIds.length === 1) { - const [paneId] = paneIds as [string]; - return { type: "pane", paneId }; - } - - const mid = Math.ceil(paneIds.length / 2); - const nextDirection = direction === "vertical" ? "horizontal" : "vertical"; - - return { - type: "split", - direction, - first: buildStressLayoutNode(paneIds.slice(0, mid), nextDirection), - second: buildStressLayoutNode(paneIds.slice(mid), nextDirection), - }; -} - -function buildGeneratedWorkspaceState( - workspaceId: string, - tabCount: number, - panesPerTab: number, - createPane: ( - tabIndex: number, - paneOffset: number, - ) => CreatePaneInput, -): WorkspaceState { - const createdAt = Date.now(); - const tabs: Tab[] = Array.from( - { length: tabCount }, - (_, tabIndex) => { - const panes: Record> = {}; - const paneIds: string[] = []; - - for (let paneOffset = 0; paneOffset < panesPerTab; paneOffset += 1) { - const input = createPane(tabIndex, paneOffset); - const paneId = - input.id ?? - `renderer-stress-pane-${workspaceId}-${tabIndex}-${paneOffset}`; - panes[paneId] = { - id: paneId, - kind: input.kind, - titleOverride: input.titleOverride, - pinned: input.pinned, - data: input.data, - }; - paneIds.push(paneId); - } - - return { - id: `renderer-stress-tab-${workspaceId}-${tabIndex}`, - createdAt: createdAt + tabIndex, - activePaneId: paneIds[0] ?? null, - layout: buildStressLayoutNode(paneIds), - panes, - }; - }, - ); - - return { - version: 1, - tabs, - activeTabId: tabs.at(-1)?.id ?? null, - }; -} - -function createStressPane( - kind: RendererStressPaneKind, - index: number, - filePaths: string[], - worktreePath?: string | null, -): CreatePaneInput { - const filePath = getFilePath(filePaths, index, worktreePath); - switch (kind) { - case "browser": - return { - kind, - data: { - url: index % 3 === 0 ? "about:blank" : "https://example.com/", - } satisfies BrowserPaneData, - }; - case "chat": - return { - kind, - data: { - sessionId: null, - launchConfig: - index % 2 === 0 - ? { initialPrompt: `renderer stress prompt ${index}` } - : null, - } satisfies ChatPaneData, - }; - case "comment": - return { - kind, - data: { - commentId: `renderer-stress-comment-${index}`, - authorLogin: "renderer-stress", - body: `Renderer stress comment ${index}`, - path: filePath, - line: (index % 200) + 1, - } satisfies CommentPaneData, - }; - case "diff": - return { - kind, - data: { - path: filePath, - collapsedFiles: index % 2 === 0 ? [] : [filePath], - expandedFiles: [filePath], - focusLine: (index % 200) + 1, - focusTick: Date.now(), - } satisfies DiffPaneData, - }; - case "file": - return { - kind, - data: { - filePath, - mode: index % 3 === 0 ? "preview" : "editor", - } satisfies FilePaneData, - }; - } -} - -function getNextPaneKind(index: number): RendererStressPaneKind { - const kinds: RendererStressPaneKind[] = [ - "file", - "diff", - "browser", - "chat", - "comment", - ]; - return kinds[index % kinds.length]; -} - -function isRendererStressPaneKind( - kind: string, -): kind is RendererStressPaneKind { - return ( - kind === "browser" || - kind === "chat" || - kind === "comment" || - kind === "diff" || - kind === "file" - ); -} - -function collectStressTerminalRefs( - state: WorkspaceState, - stressTerminalIds: Set, -): Array<{ terminalId: string; instanceId: string }> { - const refs: Array<{ terminalId: string; instanceId: string }> = []; - for (const tab of state.tabs) { - for (const pane of Object.values(tab.panes)) { - if (pane.kind !== "terminal") continue; - const terminalId = (pane.data as TerminalPaneData).terminalId; - if (!stressTerminalIds.has(terminalId)) continue; - refs.push({ terminalId, instanceId: pane.id }); - } - } - return refs; -} - -function makeTerminalStressOutput( - index: number, - lines: number, - payloadBytes: number, - mode: RendererStressTerminalOutputMode = "scrollback", -): string { - const boundedLines = Math.max(1, Math.min(500, Math.floor(lines))); - const boundedPayloadBytes = Math.max( - 0, - Math.min(4096, Math.floor(payloadBytes)), - ); - - if (mode === "atlas-rewrite") { - const text = - "!\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~ === !== <= >= -> => 红 node_modules"; - const red = (Math.floor(index / (128 * 128)) * 2) % 256; - const green = (Math.floor(index / 128) * 2) % 256; - const blue = (index * 2) % 256; - return [ - "\x1b[H\x1b[2J\x1b[mThis should be readable with white FG color.", - `r: ${red} g:${green} b:${blue}`, - `\x1b[38;2;${red};${green};${blue}m${text}\x1b[0m`, - ].join("\r\n"); - } - - if (mode === "tui-churn") { - const chunks: string[] = []; - if (index === 0) { - chunks.push("\x1b[?1049h\x1b[?25l\x1b[?7l"); - } - chunks.push("\x1b[H"); - for (let line = 0; line < boundedLines; line += 1) { - const red = (index * 13 + line * 17) % 256; - const green = (index * 23 + line * 19) % 256; - const blue = (index * 31 + line * 29) % 256; - const bgRed = (index * 7 + line * 11) % 256; - const bgGreen = (index * 5 + line * 13) % 256; - const bgBlue = (index * 3 + line * 17) % 256; - const prefix = `${String(index).padStart(5, "0")}:${String(line).padStart(3, "0")}`; - const body = - "█▓▒░ ╭─╮ ╰─╯ === !== <= >= -> => 红 node_modules diff --stat"; - chunks.push( - `\x1b[${line + 1};1H\x1b[38;2;${red};${green};${blue}m\x1b[48;2;${bgRed};${bgGreen};${bgBlue}m ${prefix} ${body} \x1b[0m\x1b[K`, - ); - } - return chunks.join(""); - } - - if (mode === "cjk-sgr") { - const randomSgrAttributes = [ - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "9", - "21", - "22", - "23", - "24", - "25", - "26", - "27", - "28", - "29", - "30", - "31", - "32", - "33", - "34", - "35", - "36", - "37", - "38", - "39", - "40", - "41", - "42", - "43", - "44", - "45", - "46", - "47", - "48", - "49", - ]; - const cjkStart = 0x4e00; - const cjkEnd = 0x9fcc; - const cjkCount = cjkEnd - cjkStart; - const charsPerWrite = Math.max(80, Math.min(2000, boundedLines * 80)); - const chunks = ["\r\n"]; - for (let offset = 0; offset < charsPerWrite; offset += 1) { - const codePoint = - cjkStart + ((index * charsPerWrite + offset) % cjkCount); - const sgr = - randomSgrAttributes[ - (index * 131 + offset * 17) % randomSgrAttributes.length - ]; - chunks.push(`\x1b[${sgr}m${String.fromCharCode(codePoint)}\x1b[0m`); - if (offset % 80 === 79) { - chunks.push("\r\n"); - } - } - return chunks.join(""); - } - - if (mode === "glyph-sentinel") { - const rows = Math.max(8, Math.min(80, boundedLines)); - const chunks = ["\x1b[?25l\x1b[H\x1b[2J"]; - const text = - "!\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~ === !== <= >= -> => 红 node_modules"; - for (let row = 0; row < rows; row += 1) { - const red = (row * 37 + 19) % 256; - const green = (row * 53 + 29) % 256; - const blue = (row * 71 + 43) % 256; - const bgRed = (row * 11 + 7) % 96; - const bgGreen = (row * 17 + 13) % 96; - const bgBlue = (row * 23 + 17) % 96; - chunks.push( - `\x1b[${row + 1};1H\x1b[38;2;${red};${green};${blue}m\x1b[48;2;${bgRed};${bgGreen};${bgBlue}m` + - ` sentinel-${String(row).padStart(2, "0")} ${text} \x1b[0m\x1b[K`, - ); - } - return chunks.join(""); - } - - const seed = `terminal-stress-${index}-`; - const payload = - boundedPayloadBytes > 0 - ? seed - .repeat(Math.ceil(boundedPayloadBytes / seed.length)) - .slice(0, boundedPayloadBytes) - : ""; - const chunks: string[] = []; - - for (let line = 0; line < boundedLines; line += 1) { - const color = (index + line) % 256; - const red = (index * 17 + line * 3) % 256; - const green = (index * 29 + line * 5) % 256; - const blue = (index * 31 + line * 7) % 256; - if (line % 25 === 0) { - chunks.push(`\x1b]0;renderer terminal stress ${index}.${line}\x07`); - } - chunks.push( - `\x1b[38;2;${red};${green};${blue}m\x1b[48;5;${color}m` + - `[${String(index).padStart(4, "0")}:${String(line).padStart(4, "0")}] ` + - `${payload}\x1b[0m\r\n`, - ); - } - - return chunks.join(""); -} - -function getSummary( - workspace: SelectV2Workspace, - store: StoreApi>, - filePaths: string[], -): RendererStressSummary { - const state = store.getState(); - const paneKindCounts: Record = {}; - let paneCount = 0; - for (const tab of state.tabs) { - for (const pane of Object.values(tab.panes)) { - paneCount += 1; - paneKindCounts[pane.kind] = (paneKindCounts[pane.kind] ?? 0) + 1; - } - } - return { - workspaceId: workspace.id, - projectId: workspace.projectId, - activeTabId: state.activeTabId, - tabCount: state.tabs.length, - paneCount, - paneKindCounts, - filePathCount: filePaths.length, - }; -} - -export function useRendererStressWorkspaceBridge({ - workspace, - store, - filePaths, - worktreePath, - addTerminalTab, - showChangesSidebar, -}: { - workspace: SelectV2Workspace; - store: StoreApi>; - filePaths: string[]; - worktreePath?: string | null; - addTerminalTab: () => Promise; - showChangesSidebar: () => void; -}) { - const baselineRef = useRef | null>(null); - const stressTerminalIdsRef = useRef>(new Set()); - - useEffect(() => { - if (process.env.NODE_ENV !== "development") return; - - const stressState = getRendererStressGlobalState(); - stressTerminalIdsRef.current = new Set( - stressState.terminalIdsByWorkspaceId[workspace.id] ?? [], - ); - const getBridgeSummary = () => getSummary(workspace, store, filePaths); - const rememberStressTerminalId = (terminalId: string) => { - stressTerminalIdsRef.current.add(terminalId); - stressState.terminalIdsByWorkspaceId[workspace.id] = Array.from( - stressTerminalIdsRef.current, - ); - }; - const releaseStressTerminalRuntimes = () => { - const terminalIds = new Set([ - ...stressTerminalIdsRef.current, - ...(stressState.terminalIdsByWorkspaceId[workspace.id] ?? []), - ]); - for (const terminalId of terminalIds) { - terminalRuntimeRegistry.release(terminalId); - } - stressTerminalIdsRef.current.clear(); - delete stressState.terminalIdsByWorkspaceId[workspace.id]; - }; - const getStressTerminalRefs = () => - collectStressTerminalRefs(store.getState(), stressTerminalIdsRef.current); - const bridge: RendererStressWorkspaceBridge = { - workspaceId: workspace.id, - projectId: workspace.projectId, - captureBaseline: () => { - const baseline = getStoreStateSnapshot(store); - baselineRef.current = baseline; - stressState.baselinesByWorkspaceId[workspace.id] = baseline; - }, - restoreBaseline: () => { - releaseStressTerminalRuntimes(); - const baseline = - stressState.baselinesByWorkspaceId[workspace.id] ?? - baselineRef.current; - if (!baseline) return; - store.getState().replaceState(cloneWorkspaceState(baseline)); - delete stressState.baselinesByWorkspaceId[workspace.id]; - }, - getSummary: getBridgeSummary, - addTab: (kind, index, paneCount = 1) => { - if (kind === "terminal") { - void addTerminalTab(); - return; - } - const count = Math.max(1, Math.min(8, Math.floor(paneCount))); - store.getState().addTab({ - panes: Array.from({ length: count }, (_, offset) => - createStressPane( - offset === 0 ? kind : getNextPaneKind(index + offset), - index + offset, - filePaths, - worktreePath, - ), - ) as [ - CreatePaneInput, - ...CreatePaneInput[], - ], - }); - }, - openPane: (kind, index) => { - store.getState().openPane({ - pane: createStressPane(kind, index, filePaths, worktreePath), - }); - }, - splitActivePane: (kind, index) => { - const state = store.getState(); - const active = state.getActivePane(); - if (!active) { - bridge.addTab(kind, index); - return; - } - state.splitPane({ - tabId: active.tabId, - paneId: active.pane.id, - position: index % 2 === 0 ? "right" : "bottom", - newPane: createStressPane(kind, index, filePaths, worktreePath), - }); - }, - switchTab: (index) => { - const state = store.getState(); - if (state.tabs.length === 0) return; - state.setActiveTab(state.tabs[index % state.tabs.length].id); - }, - closeActivePane: () => { - const state = store.getState(); - const active = state.getActivePane(); - if (!active) return; - state.closePane({ tabId: active.tabId, paneId: active.pane.id }); - }, - closeOldestTab: (keepCount = 4) => { - const state = store.getState(); - if (state.tabs.length <= keepCount) return; - state.removeTab(state.tabs[0].id); - }, - churnActivePaneData: (index) => { - const state = store.getState(); - const active = state.getActivePane(); - if (!active) { - bridge.addTab("file", index); - return; - } - if (!isRendererStressPaneKind(active.pane.kind)) { - bridge.addTab("file", index); - return; - } - const nextKind = active.pane.kind; - state.setPaneData({ - paneId: active.pane.id, - data: createStressPane(nextKind, index, filePaths, worktreePath).data, - }); - }, - replaceWithGeneratedLayout: (tabCount, panesPerTab) => { - releaseStressTerminalRuntimes(); - const boundedTabCount = Math.max(1, Math.min(50, Math.floor(tabCount))); - const boundedPaneCount = Math.max( - 1, - Math.min(8, Math.floor(panesPerTab)), - ); - store.getState().replaceState( - buildGeneratedWorkspaceState( - workspace.id, - boundedTabCount, - boundedPaneCount, - (tabIndex, paneOffset) => { - const paneIndex = tabIndex * 10 + paneOffset; - return createStressPane( - paneOffset === 0 - ? getNextPaneKind(tabIndex) - : getNextPaneKind(paneIndex), - paneIndex, - filePaths, - worktreePath, - ); - }, - ), - ); - }, - replaceWithGeneratedTerminalLayout: (tabCount, panesPerTab) => { - releaseStressTerminalRuntimes(); - const boundedTabCount = Math.max(1, Math.min(80, Math.floor(tabCount))); - const boundedPaneCount = Math.max( - 1, - Math.min(8, Math.floor(panesPerTab)), - ); - store - .getState() - .replaceState( - buildGeneratedWorkspaceState( - workspace.id, - boundedTabCount, - boundedPaneCount, - (tabIndex, paneOffset) => - createStressTerminalPane( - workspace.id, - tabIndex * boundedPaneCount + paneOffset, - rememberStressTerminalId, - ), - ), - ); - }, - replaceWithGeneratedMixedLayout: (tabCount, panesPerTab) => { - releaseStressTerminalRuntimes(); - const boundedTabCount = Math.max(1, Math.min(80, Math.floor(tabCount))); - const boundedPaneCount = Math.max( - 1, - Math.min(8, Math.floor(panesPerTab)), - ); - store.getState().replaceState( - buildGeneratedWorkspaceState( - workspace.id, - boundedTabCount, - boundedPaneCount, - (tabIndex, paneOffset) => { - const paneIndex = tabIndex * boundedPaneCount + paneOffset; - if (paneIndex % 4 === 0) { - return createStressTerminalPane( - workspace.id, - paneIndex, - rememberStressTerminalId, - ); - } - return createStressPane( - getNextPaneKind(paneIndex), - paneIndex, - filePaths, - worktreePath, - ); - }, - ), - ); - }, - writeTerminalStressOutput: async (index, lines, payloadBytes, mode) => { - const refs = getStressTerminalRefs(); - const output = makeTerminalStressOutput( - index, - lines, - payloadBytes, - mode, - ); - const results = await Promise.all( - refs.map((ref) => - terminalRuntimeRegistry.writeForStress( - ref.terminalId, - output, - ref.instanceId, - ), - ), - ); - const writtenCount = results.filter(Boolean).length; - return { - terminalCount: refs.length, - writtenCount, - failedCount: refs.length - writtenCount, - byteLength: output.length, - }; - }, - clearTerminalTextureAtlasesForStress: () => { - const refs = getStressTerminalRefs(); - let clearedCount = 0; - for (const ref of refs) { - if ( - terminalRuntimeRegistry.clearTextureAtlasForStress( - ref.terminalId, - ref.instanceId, - ) - ) { - clearedCount += 1; - } - } - return clearedCount; - }, - getTerminalStressSummary: () => { - const stressTerminalIds = stressTerminalIdsRef.current; - const terminalRuntimes = terminalRuntimeRegistry - .getStressDebugInfo() - .filter((runtime) => stressTerminalIds.has(runtime.terminalId)); - return { - ...getBridgeSummary(), - stressTerminalIdCount: stressTerminalIds.size, - terminalRuntimeCount: terminalRuntimes.length, - terminalRuntimes, - }; - }, - releaseStressTerminalRuntimes, - addRealTerminalTab: addTerminalTab, - showChangesSidebar, - }; - - window.__SUPERSET_RENDERER_STRESS__ = bridge; - return () => { - if (window.__SUPERSET_RENDERER_STRESS__ === bridge) { - delete window.__SUPERSET_RENDERER_STRESS__; - } - }; - }, [ - addTerminalTab, - filePaths, - showChangesSidebar, - store, - workspace, - worktreePath, - ]); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index e9f11553dc7..a03fcb819d8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -1,7 +1,7 @@ import { Workspace } from "@superset/panes"; import { workspaceTrpc } from "@superset/workspace-client"; import { createFileRoute } from "@tanstack/react-router"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { useQuickOpenStore } from "renderer/commandPalette/ui/QuickOpen/quickOpenStore"; import { useV2UserPreferences } from "renderer/hooks/useV2UserPreferences"; @@ -27,7 +27,6 @@ import { useDefaultPaneActions } from "./hooks/useDefaultPaneActions"; import { useDirtyTabCloseGuard } from "./hooks/useDirtyTabCloseGuard"; import { usePaneRegistry } from "./hooks/usePaneRegistry"; import { renderBrowserTabIcon } from "./hooks/usePaneRegistry/components/BrowserPane"; -import { useRendererStressWorkspaceBridge } from "./hooks/useRendererStressWorkspaceBridge"; import { useV2PresetExecution } from "./hooks/useV2PresetExecution"; import { useV2TerminalLauncher } from "./hooks/useV2TerminalLauncher"; import { useV2WorkspacePaneLayout } from "./hooks/useV2WorkspacePaneLayout"; @@ -98,18 +97,10 @@ function V2WorkspacePage() { ); } - return ( - - ); + return ; } -function V2WorkspaceContent({ - worktreePath, -}: { - worktreePath?: string | null; -}) { +function V2WorkspaceContent() { const { terminalId, chatSessionId, @@ -187,29 +178,6 @@ function V2WorkspaceContent({ openCommentPane, } = useWorkspacePaneOpeners({ store, launcher }); - const rendererStressFilePaths = useMemo( - () => - Array.from( - new Set([ - ...Array.from(openFilePaths), - ...recentFiles.map((file) => file.absolutePath), - ]), - ), - [openFilePaths, recentFiles], - ); - const showRendererStressChangesSidebar = useCallback(() => { - setRightSidebarOpen(true); - setRightSidebarTab("changes"); - }, [setRightSidebarOpen, setRightSidebarTab]); - useRendererStressWorkspaceBridge({ - workspace, - store, - filePaths: rendererStressFilePaths, - worktreePath, - addTerminalTab, - showChangesSidebar: showRendererStressChangesSidebar, - }); - const quickOpenOpen = useQuickOpenStore( (s) => s.open && s.target?.workspaceId === workspaceId, ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx index e7e40e9096f..05a8de21c94 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx @@ -56,7 +56,7 @@ function V2WorkspaceLayout() { const hostStatus = useRemoteHostStatus(workspace); - if (!workspaceId || !workspaces || (!isReady && !workspace)) { + if (!workspaceId || !isReady || !workspaces) { return
; } @@ -96,11 +96,8 @@ function V2WorkspaceLayout() { return
; } - // TanStack Router reuses the Outlet subtree across param-only transitions. - // A workspace switch must remount pane state instead of looking like every - // pane in the previous workspace was closed. return ( - + ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx index 6b8c08b0a77..75b7b933f2d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx @@ -57,9 +57,7 @@ export function V2WorkspaceRow({ !workspace.hostIsOnline && workspace.hostType !== "local-device"; const handleOpen = useCallback(() => { - const open = () => { - void navigateToV2Workspace(workspace.id, navigate); - }; + const open = () => navigateToV2Workspace(workspace.id, navigate); if (workspace.hostType === "local-device") { open(); return; @@ -157,7 +155,6 @@ export function V2WorkspaceRow({