diff --git a/apps/desktop/docs/RENDERER_STRESS_QA.md b/apps/desktop/docs/RENDERER_STRESS_QA.md deleted file mode 100644 index 1d742bfee27..00000000000 --- a/apps/desktop/docs/RENDERER_STRESS_QA.md +++ /dev/null @@ -1,230 +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, 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 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 f40f9a31f1d..971517aeb38 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 47c3a7f36c5..00000000000 --- a/apps/desktop/scripts/stress-renderer.ts +++ /dev/null @@ -1,1709 +0,0 @@ -#!/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 5a0c146f013..c4913ecdeb2 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -53,24 +53,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/renderer/hooks/host-service/useDiffStats/useDiffStats.ts b/apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts index 2181b86dc0a..1fe9dcc5f39 100644 --- a/apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts +++ b/apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts @@ -9,15 +9,7 @@ export interface DiffStats { deletions: number; } -interface UseDiffStatsOptions { - enabled?: boolean; -} - -export function useDiffStats( - workspaceId: string, - options: UseDiffStatsOptions = {}, -): DiffStats | null { - const { enabled = true } = options; +export function useDiffStats(workspaceId: string): DiffStats | null { const hostUrl = useWorkspaceHostUrl(workspaceId); const queryClient = useQueryClient(); const queryKey = useMemo( @@ -25,12 +17,12 @@ export function useDiffStats( [hostUrl, workspaceId], ); - const { data: stats } = useQuery({ + const { data: status } = useQuery({ queryKey, - enabled: enabled && Boolean(workspaceId) && Boolean(hostUrl), + enabled: Boolean(workspaceId) && Boolean(hostUrl), queryFn: () => { if (!hostUrl) return null; - return getHostServiceClientByUrl(hostUrl).git.getDiffStats.query({ + return getHostServiceClientByUrl(hostUrl).git.getStatus.query({ workspaceId, }); }, @@ -42,7 +34,22 @@ export function useDiffStats( void queryClient.invalidateQueries({ queryKey }); }, [queryClient, queryKey]); - useWorkspaceEvent("git:changed", workspaceId, invalidate, enabled); + 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); - return stats ?? null; + let additions = 0; + let deletions = 0; + for (const file of byPath.values()) { + additions += file.additions; + deletions += file.deletions; + } + return { additions, deletions }; + }, [status]); } 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/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 2d4405825f2..00000000000 --- a/apps/desktop/src/renderer/lib/posthog.test.ts +++ /dev/null @@ -1,27 +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(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 fa6946e8407..180e2ca11d2 100644 --- a/apps/desktop/src/renderer/lib/posthog.ts +++ b/apps/desktop/src/renderer/lib/posthog.ts @@ -1,61 +1,30 @@ -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", - ".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-addons.ts b/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts index a8cc2fa946c..85faa897cdb 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,19 +13,25 @@ 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(); terminal.loadAddon(unicode11); terminal.unicode.activeVersion = "11"; - terminal.loadAddon(createTerminalImageAddon()); + terminal.loadAddon(new ImageAddon()); const searchAddon = new SearchAddon(); terminal.loadAddon(searchAddon); @@ -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-image-addon.test.ts b/apps/desktop/src/renderer/lib/terminal/terminal-image-addon.test.ts deleted file mode 100644 index ca99e180ae1..00000000000 --- a/apps/desktop/src/renderer/lib/terminal/terminal-image-addon.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 58986a834c1..00000000000 --- a/apps/desktop/src/renderer/lib/terminal/terminal-image-addon.ts +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 00c1fea6c97..00000000000 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts +++ /dev/null @@ -1,393 +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"] & { - 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("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 fd0c857ae40..c9dad761712 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -11,13 +11,9 @@ import { createRuntime, detachFromContainer, disposeRuntime, - focusRuntime, - shouldReplayTerminalRuntime, type TerminalRuntime, updateRuntimeAppearance, - writeRuntimeOutput, } from "./terminal-runtime"; -import { isTerminalWebglCanvas } from "./terminal-webgl-canvas-registry"; import { type ConnectionState, clearLogs, @@ -41,75 +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; - 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>(); @@ -172,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); @@ -260,18 +174,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); } /** @@ -291,18 +194,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); } /** @@ -334,13 +226,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, @@ -426,87 +311,6 @@ 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.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts index acd323b316e..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,23 +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; -const MAX_PARKED_OUTPUT_QUEUE_BYTES = 1024 * 1024; - -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; @@ -50,141 +32,6 @@ 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) { - runtime._outputEnqueued = false; - 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, -) { - runtime._outputQueuedBytes += item.byteLength; - runtime._outputQueue.push(item); - if (!runtime.container) { - item.callback?.(); - item.callback = undefined; - while ( - runtime._outputQueuedBytes > MAX_PARKED_OUTPUT_QUEUE_BYTES && - runtime._outputQueue.length > 1 - ) { - const dropped = runtime._outputQueue.shift(); - if (!dropped) break; - runtime._outputQueuedBytes = Math.max( - 0, - runtime._outputQueuedBytes - dropped.byteLength, - ); - dropped.callback?.(); - } - return; - } - if (!runtime._outputEnqueued) { - runtime._outputEnqueued = true; - runtimesWithQueuedOutput.add(runtime); - } - scheduleQueuedOutputFlush(); -} - -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( @@ -225,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) { @@ -273,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; @@ -379,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); @@ -387,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( @@ -417,39 +238,9 @@ 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 items = splitOutputData(data, callback); - if (items.some((item) => item.byteLength > 0)) { - runtime.hasBufferedContent = true; - } - if ( - runtime.container && - 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, @@ -467,7 +258,6 @@ export function attachToContainer( runtime.container = container; container.appendChild(runtime.wrapper); - if (measureAndResize(runtime)) onResize?.(); runtime._disposeResizeObserver?.(); @@ -479,36 +269,9 @@ 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); @@ -555,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-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/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/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/DashboardSidebarHoverCardOverlay/DashboardSidebarHoverCardOverlay.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHoverCardOverlay/DashboardSidebarHoverCardOverlay.tsx index 5cd5c977ca8..7d6cc1495f8 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 ?? "", { enabled: open }); + const diffStats = useDiffStats(hoveredId ?? ""); // 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 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 8eb0b6c3c8b..0f4a79be2bb 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, @@ -42,11 +40,7 @@ function DashboardSidebarWorkspaceItemComponent({ pullRequest, } = workspace; const isMainWorkspace = workspace.type === "main"; - const isPending = !!creationStatus; - const isFailedInFlight = creationStatus === "failed"; - const diffStats = useDiffStats(id, { - enabled: !isCollapsed && !isPending, - }); + const diffStats = useDiffStats(id); const workspaceStatus = useV2WorkspaceNotificationStatus(id); const { cancelRename, @@ -58,6 +52,7 @@ function DashboardSidebarWorkspaceItemComponent({ handleOpenInFinder, handleRemoveFromSidebar, handleToggleUnread, + isActive, isDeleteDialogOpen, isUnread, isRenaming, @@ -72,7 +67,6 @@ function DashboardSidebarWorkspaceItemComponent({ projectId, workspaceName: name, branch, - isActive, isMainWorkspace, }); @@ -83,6 +77,8 @@ function DashboardSidebarWorkspaceItemComponent({ 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); @@ -115,8 +111,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); @@ -148,7 +144,6 @@ function DashboardSidebarWorkspaceItemComponent({ onClick={handleClick} creationStatus={creationStatus} pullRequestState={pullRequest?.state ?? null} - data-renderer-stress-workspace-id={id} aria-label={ creationStatus ? `Creating workspace: ${name}` : undefined } @@ -229,7 +224,6 @@ function DashboardSidebarWorkspaceItemComponent({ isInSection={isInSection} onClick={handleClick} onDoubleClick={isPending ? undefined : startRename} - data-renderer-stress-workspace-id={id} onRemoveFromSidebarClick={handleRemoveFromSidebar} onCloseWorkspaceClick={ isFailedInFlight @@ -297,7 +291,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..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 @@ -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"; @@ -109,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], @@ -132,33 +131,29 @@ 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; const target = flattenedWorkspaces[prevIndex]; revealWorkspace(target.id); - void navigateToV2Workspace(target.id, navigate); + navigateToV2Workspace(target.id, navigate); }); 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; 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/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/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-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/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/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/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/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 3eb37cf5de1..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/useRendererStressWorkspaceBridge.ts +++ /dev/null @@ -1,681 +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, - 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 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; 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..5a4a06fa7f9 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,8 +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"; import { useV2WorkspacePaneLayout } from "./hooks/useV2WorkspacePaneLayout"; @@ -99,18 +97,10 @@ function V2WorkspacePage() { ); } - return ( - - ); + return ; } -function V2WorkspaceContent({ - worktreePath, -}: { - worktreePath?: string | null; -}) { +function V2WorkspaceContent() { const { terminalId, chatSessionId, @@ -131,17 +121,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 } = @@ -198,29 +177,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/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/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({
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..c6f2d1c3571 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,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. @@ -98,7 +101,10 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { const clipboardAddon = new ClipboardAddon(); const unicode11Addon = new Unicode11Addon(); - const imageAddon = createTerminalImageAddon(); + const imageAddon = new ImageAddon(); + + let disposed = false; + let webglAddon: WebglAddon | null = null; // Open into a detached wrapper div — not the live container. const wrapper = document.createElement("div"); @@ -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/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); -} 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/git.ts b/packages/host-service/src/trpc/router/git/git.ts index aab11db90fa..047188fc440 100644 --- a/packages/host-service/src/trpc/router/git/git.ts +++ b/packages/host-service/src/trpc/router/git/git.ts @@ -27,7 +27,6 @@ import { getDefaultBranchName, mapGitStatus, parseNumstat, - parseNumstatRecords, resolveBaseComparison, } from "./utils/git-helpers"; import { @@ -59,45 +58,6 @@ 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() })) @@ -287,57 +247,6 @@ 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 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/git/utils/git-helpers.test.ts b/packages/host-service/src/trpc/router/git/utils/git-helpers.test.ts index 92f5d488880..4ce2ecc6b12 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,9 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { - parseNameStatus, - parseNumstat, - parseNumstatRecords, -} from "./git-helpers"; +import { parseNameStatus, parseNumstat } from "./git-helpers"; describe("parseNumstat", () => { test("regular file entry", () => { @@ -84,39 +80,6 @@ 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 6eb8968a42c..f0b0dc833a9 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,31 +73,6 @@ 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]; @@ -115,15 +90,10 @@ export function parseNumstatRecords(raw: string): NumstatRecord[] { if (pathMaybe === "") { const oldPath = entries[++i] ?? ""; const newPath = entries[++i] ?? ""; - if (newPath) { - result.push({ - path: newPath, - ...(oldPath ? { oldPath } : {}), - ...stats, - }); - } + if (newPath) result.set(newPath, stats); + if (oldPath) result.set(oldPath, stats); } else { - result.push({ path: pathMaybe, ...stats }); + result.set(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 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/git.integration.test.ts b/packages/host-service/test/integration/git.integration.test.ts index 5f1dc53c865..270347b3c4e 100644 --- a/packages/host-service/test/integration/git.integration.test.ts +++ b/packages/host-service/test/integration/git.integration.test.ts @@ -59,27 +59,6 @@ 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 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,