diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 971517aeb38..f40f9a31f1d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -34,7 +34,9 @@ "generate:routes": "tsr generate", "pretypecheck": "bun run generate:icons && bun run generate:routes", "typecheck": "tsc --noEmit", - "test": "bun test" + "test": "bun test", + "stress:renderer": "bun run scripts/stress-renderer.ts", + "stress:renderer:fixtures": "bun run scripts/prepare-renderer-stress-fixtures.ts" }, "dependencies": { "@ai-sdk/anthropic": "3.0.64", diff --git a/apps/desktop/scripts/prepare-renderer-stress-fixtures.ts b/apps/desktop/scripts/prepare-renderer-stress-fixtures.ts new file mode 100644 index 00000000000..1b46c5aed95 --- /dev/null +++ b/apps/desktop/scripts/prepare-renderer-stress-fixtures.ts @@ -0,0 +1,876 @@ +#!/usr/bin/env bun + +import { Database } from "bun:sqlite"; +import { randomUUID } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { homedir, hostname } from "node:os"; +import { basename, join, resolve } from "node:path"; + +interface Args { + root: string; + supersetHome: string; + workspaceCount: number; + changedFiles: number; + linesPerFile: number; + baseFiles: number; + organizationId?: string; + hostId?: string; + json: boolean; + help: boolean; +} + +interface FixtureWorkspace { + id: string; + branch: string; + worktreePath: string; +} + +interface CollectionRow { + key: string; + value: string; + row_version: number; +} + +const repoRoot = resolve(import.meta.dirname, "../../.."); +const envPath = resolve(repoRoot, ".env"); +const fixtureSlug = "renderer-stress-large-diff"; +const fixtureProjectName = "Renderer Stress Large Diff"; + +function parseEnvFile(path: string): Record { + if (!existsSync(path)) return {}; + const text = readFileSync(path, "utf8"); + const values: Record = {}; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line); + if (!match) continue; + const [, key, rawValue] = match; + values[key] = rawValue.trim().replace(/^["']|["']$/g, ""); + } + return values; +} + +const envFile = parseEnvFile(envPath); + +function defaultSupersetHome(): string { + return ( + envFile.SUPERSET_HOME_DIR ?? + process.env.SUPERSET_HOME_DIR ?? + resolve(repoRoot, "superset-dev-data") + ); +} + +function parseArgs(argv: string[]): Args { + const args: Args = { + root: join( + homedir(), + "workplace", + "playground", + "superset-renderer-stress-fixtures", + ), + supersetHome: defaultSupersetHome(), + workspaceCount: 8, + changedFiles: 220, + linesPerFile: 80, + baseFiles: 320, + organizationId: + envFile.SUPERSET_ORGANIZATION_ID ?? process.env.SUPERSET_ORGANIZATION_ID, + hostId: undefined, + json: false, + help: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const readValue = () => { + const value = argv[index + 1]; + if (!value) throw new Error(`Missing value for ${arg}`); + index += 1; + return value; + }; + const readNumber = () => { + const value = Number(readValue()); + if (!Number.isInteger(value) || value < 1) { + throw new Error(`Invalid positive integer for ${arg}`); + } + return value; + }; + + switch (arg) { + case "--help": + case "-h": + args.help = true; + break; + case "--root": + args.root = resolve(readValue()); + break; + case "--superset-home": + args.supersetHome = resolve(readValue()); + break; + case "--workspace-count": + args.workspaceCount = readNumber(); + break; + case "--changed-files": + args.changedFiles = readNumber(); + break; + case "--lines-per-file": + args.linesPerFile = readNumber(); + break; + case "--base-files": + args.baseFiles = readNumber(); + break; + case "--organization-id": + args.organizationId = readValue(); + break; + case "--host-id": + args.hostId = readValue(); + break; + case "--json": + args.json = true; + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + + return args; +} + +function usage(): string { + return `Prepare renderer stress fixture workspaces with large dirty git changes. + +This seeds both the persisted V2 collections and the host-service SQLite DB. +Run it before starting the desktop dev app, or restart the app after running it. + +Usage: + bun --cwd apps/desktop stress:renderer:fixtures + bun --cwd apps/desktop stress:renderer:fixtures -- --workspace-count 10 --changed-files 300 + +Options: + --root Fixture root. Default: ~/workplace/playground/superset-renderer-stress-fixtures + --superset-home Superset home dir. Default: SUPERSET_HOME_DIR, .env, or ./superset-dev-data + --workspace-count Number of workspaces to create. Default: 8 + --changed-files Dirty changed files per workspace. Default: 220 + --lines-per-file Lines appended to each modified file. Default: 80 + --base-files Tracked files in the base repo. Default: 320 + --organization-id Organization id. Default: inferred from persisted collections + --host-id Host machine id. Default: inferred from existing host DB rows + --json Print machine-readable fixture metadata +`; +} + +async function run( + command: string, + args: string[], + options: { cwd?: string } = {}, +): Promise { + const process = Bun.spawn([command, ...args], { + cwd: options.cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(process.stdout).text(), + new Response(process.stderr).text(), + process.exited, + ]); + if (code !== 0) { + throw new Error( + `${command} ${args.join(" ")} failed with ${code}\n${stderr || stdout}`, + ); + } + return stdout.trim(); +} + +function quoteIdentifier(name: string): string { + if (!/^[A-Za-z0-9_]+$/.test(name)) { + throw new Error(`Unsafe SQLite identifier: ${name}`); + } + return `"${name}"`; +} + +function getCollectionTable(db: Database, collectionId: string): string { + const row = db + .query("select table_name from collection_registry where collection_id = ?") + .get(collectionId) as { table_name?: string } | null; + if (!row?.table_name) { + throw new Error(`Persisted collection not found: ${collectionId}`); + } + return row.table_name; +} + +function inferOrganizationId(db: Database, explicit?: string): string { + if (explicit) return explicit; + const row = db + .query( + "select collection_id from collection_registry where collection_id like 'v2_workspaces-%' limit 1", + ) + .get() as { collection_id?: string } | null; + const prefix = "v2_workspaces-"; + if (!row?.collection_id?.startsWith(prefix)) { + throw new Error( + "Could not infer organization id from persisted collections", + ); + } + return row.collection_id.slice(prefix.length); +} + +function getCollectionRows(db: Database, tableName: string): CollectionRow[] { + return db + .query( + `select key, value, row_version from ${quoteIdentifier(tableName)} order by row_version`, + ) + .all() as CollectionRow[]; +} + +function deleteCollectionKeys( + db: Database, + tableName: string, + keys: string[], +): void { + if (keys.length === 0) return; + const statement = db.query( + `delete from ${quoteIdentifier(tableName)} where key = ?`, + ); + const remove = db.transaction((items: string[]) => { + for (const key of items) statement.run(key); + }); + remove(keys); +} + +function getNextRowVersion(db: Database, collectionId: string): number { + const fromVersion = db + .query( + "select latest_row_version from collection_version where collection_id = ?", + ) + .get(collectionId) as { latest_row_version?: number } | null; + return (fromVersion?.latest_row_version ?? 0) + 1; +} + +function setCollectionVersion( + db: Database, + collectionId: string, + latestRowVersion: number, +): void { + db.query( + `insert into collection_version (collection_id, latest_row_version) + values (?, ?) + on conflict(collection_id) do update set latest_row_version = excluded.latest_row_version`, + ).run(collectionId, latestRowVersion); +} + +function insertCollectionValue( + db: Database, + tableName: string, + key: string, + value: unknown, + metadata: unknown, + rowVersion: number, +): void { + db.query( + `insert or replace into ${quoteIdentifier( + tableName, + )} (key, value, metadata, row_version) values (?, ?, ?, ?)`, + ).run(key, JSON.stringify(value), JSON.stringify(metadata), rowVersion); +} + +function parseCollectionValue(row: CollectionRow): T | null { + try { + return JSON.parse(row.value) as T; + } catch { + return null; + } +} + +function slugify(value: string): string { + return ( + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60) || "renderer-stress" + ); +} + +function collectionKeyExists( + db: Database, + tableName: string, + key: string, +): boolean { + const row = db + .query(`select 1 as found from ${quoteIdentifier(tableName)} where key = ?`) + .get(key) as { found?: number } | null; + return Boolean(row?.found); +} + +function ensureOrganizationRow({ + tanstackDb, + organizationId, + now, +}: { + tanstackDb: Database; + organizationId: string; + now: string; +}): void { + const organizationCollectionId = "organizations"; + const organizationTable = getCollectionTable( + tanstackDb, + organizationCollectionId, + ); + const organizationKey = `s:${organizationId}`; + if (collectionKeyExists(tanstackDb, organizationTable, organizationKey)) { + return; + } + + const rowVersion = getNextRowVersion(tanstackDb, organizationCollectionId); + insertCollectionValue( + tanstackDb, + organizationTable, + organizationKey, + { + allowedDomains: [], + createdAt: now, + id: organizationId, + logo: null, + metadata: null, + name: `Renderer Stress ${organizationId}`, + slug: slugify(organizationId), + stripeCustomerId: null, + }, + { relation: ["auth", "organizations"], operation: "insert" }, + rowVersion, + ); + setCollectionVersion(tanstackDb, organizationCollectionId, rowVersion); +} + +function ensureHostRow({ + tanstackDb, + hostCollectionId, + hostTable, + organizationId, + hostId, + now, +}: { + tanstackDb: Database; + hostCollectionId: string; + hostTable: string; + organizationId: string; + hostId: string; + now: string; +}): void { + const hostKey = `s:${hostId}`; + if (collectionKeyExists(tanstackDb, hostTable, hostKey)) { + return; + } + + const rowVersion = getNextRowVersion(tanstackDb, hostCollectionId); + insertCollectionValue( + tanstackDb, + hostTable, + hostKey, + { + createdAt: now, + createdByUserId: null, + isOnline: true, + machineId: hostId, + name: hostname() || "Renderer Stress Host", + organizationId, + updatedAt: now, + }, + { relation: ["public", "v2_hosts"], operation: "insert" }, + rowVersion, + ); + setCollectionVersion(tanstackDb, hostCollectionId, rowVersion); +} + +function inferHostId({ + explicit, + tanstackDb, + hostDb, + workspaceTable, + hostTable, +}: { + explicit?: string; + tanstackDb: Database; + hostDb: Database; + workspaceTable: string; + hostTable: string; +}): string { + if (explicit) return explicit; + + const hostWorkspaceIds = new Set( + ( + hostDb.query("select id from workspaces").all() as Array<{ id: string }> + ).map((row) => row.id), + ); + + for (const row of getCollectionRows(tanstackDb, workspaceTable)) { + const workspace = parseCollectionValue<{ id: string; hostId?: string }>( + row, + ); + if ( + workspace?.id && + hostWorkspaceIds.has(workspace.id) && + workspace.hostId + ) { + return workspace.hostId; + } + } + + for (const row of getCollectionRows(tanstackDb, hostTable)) { + const host = parseCollectionValue<{ + machineId?: string; + isOnline?: boolean; + }>(row); + if (host?.machineId && host.isOnline !== false) { + return host.machineId; + } + } + + throw new Error("Could not infer host id; pass --host-id"); +} + +async function writeBaseRepo({ + repoPath, + baseFiles, + linesPerFile, +}: { + repoPath: string; + baseFiles: number; + linesPerFile: number; +}): Promise { + await mkdir(repoPath, { recursive: true }); + await run("git", ["init", "-b", "main"], { cwd: repoPath }); + await run("git", ["config", "user.name", "Renderer Stress"], { + cwd: repoPath, + }); + await run( + "git", + ["config", "user.email", "renderer-stress@example.invalid"], + { + cwd: repoPath, + }, + ); + + for (let index = 0; index < baseFiles; index += 1) { + const moduleId = String(index % 20).padStart(2, "0"); + const fileId = String(index).padStart(3, "0"); + const dir = join(repoPath, "src", `module-${moduleId}`); + await mkdir(dir, { recursive: true }); + const body = Array.from( + { length: Math.max(8, Math.floor(linesPerFile / 2)) }, + (_, line) => `export const value${line} = ${index + line};`, + ).join("\n"); + await writeFile(join(dir, `file-${fileId}.ts`), `${body}\n`); + } + + await writeFile( + join(repoPath, "README.md"), + `# Renderer Stress Fixture\n\nGenerated for large-diff renderer stress tests.\n`, + ); + await run("git", ["add", "."], { cwd: repoPath }); + await run("git", ["commit", "-m", "seed renderer stress fixture"], { + cwd: repoPath, + }); +} + +async function mutateWorktree({ + worktreePath, + workspaceIndex, + changedFiles, + linesPerFile, + baseFiles, +}: { + worktreePath: string; + workspaceIndex: number; + changedFiles: number; + linesPerFile: number; + baseFiles: number; +}): Promise { + for (let index = 0; index < changedFiles; index += 1) { + const fileIndex = (workspaceIndex * 37 + index) % baseFiles; + const moduleId = String(fileIndex % 20).padStart(2, "0"); + const fileId = String(fileIndex).padStart(3, "0"); + const filePath = join( + worktreePath, + "src", + `module-${moduleId}`, + `file-${fileId}.ts`, + ); + const body = Array.from( + { length: linesPerFile }, + (_, line) => + `export const stress${workspaceIndex}_${index}_${line} = ${workspaceIndex + index + line};`, + ).join("\n"); + await writeFile(filePath, `${body}\n`, { flag: "a" }); + } + + for ( + let index = 0; + index < Math.max(12, Math.floor(changedFiles / 8)); + index += 1 + ) { + const fileIndex = (workspaceIndex * 11 + index) % baseFiles; + const moduleId = String(fileIndex % 20).padStart(2, "0"); + const fileId = String(fileIndex).padStart(3, "0"); + await rm( + join(worktreePath, "src", `module-${moduleId}`, `file-${fileId}.ts`), + { + force: true, + }, + ); + } + + for ( + let index = 0; + index < Math.max(16, Math.floor(changedFiles / 6)); + index += 1 + ) { + const dir = join(worktreePath, "generated", `workspace-${workspaceIndex}`); + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, `new-file-${String(index).padStart(3, "0")}.ts`), + `export const generated = ${workspaceIndex * 1000 + index};\n`.repeat( + Math.max(12, Math.floor(linesPerFile / 2)), + ), + ); + } + + for ( + let index = 0; + index < Math.max(4, Math.floor(changedFiles / 50)); + index += 1 + ) { + const fileIndex = (workspaceIndex * 17 + index + 40) % baseFiles; + const moduleId = String(fileIndex % 20).padStart(2, "0"); + const fileId = String(fileIndex).padStart(3, "0"); + const oldPath = join("src", `module-${moduleId}`, `file-${fileId}.ts`); + const newDir = join("renamed", `workspace-${workspaceIndex}`); + await mkdir(join(worktreePath, newDir), { recursive: true }); + await run("git", ["mv", oldPath, join(newDir, `renamed-${fileId}.ts`)], { + cwd: worktreePath, + }).catch(() => undefined); + } + + await writeFile( + join(worktreePath, `large-single-file-${workspaceIndex}.txt`), + Array.from( + { length: linesPerFile * 20 }, + (_, line) => `workspace ${workspaceIndex} large line ${line}`, + ).join("\n"), + ); + + await run("git", ["add", "src", "renamed"], { cwd: worktreePath }).catch( + () => undefined, + ); +} + +async function createGitFixtures(args: Args): Promise<{ + projectId: string; + repoPath: string; + workspaces: FixtureWorkspace[]; +}> { + const repoPath = join(args.root, "large-diff-repo"); + const worktreesRoot = join(args.root, "worktrees"); + + await rm(repoPath, { recursive: true, force: true }); + await rm(worktreesRoot, { recursive: true, force: true }); + await mkdir(worktreesRoot, { recursive: true }); + await writeBaseRepo({ + repoPath, + baseFiles: Math.max(args.baseFiles, args.changedFiles + 40), + linesPerFile: args.linesPerFile, + }); + + const workspaces: FixtureWorkspace[] = []; + for (let index = 0; index < args.workspaceCount; index += 1) { + const branch = `renderer-stress/large-${String(index + 1).padStart(2, "0")}`; + const worktreePath = join( + worktreesRoot, + `large-${String(index + 1).padStart(2, "0")}`, + ); + await run("git", ["worktree", "add", "-b", branch, worktreePath, "main"], { + cwd: repoPath, + }); + await mutateWorktree({ + worktreePath, + workspaceIndex: index, + changedFiles: args.changedFiles, + linesPerFile: args.linesPerFile, + baseFiles: Math.max(args.baseFiles, args.changedFiles + 40), + }); + workspaces.push({ + id: randomUUID(), + branch, + worktreePath, + }); + } + + return { + projectId: randomUUID(), + repoPath, + workspaces, + }; +} + +function removePreviousFixtureRows({ + tanstackDb, + hostDb, + projectTable, + workspaceTable, + repoPath, +}: { + tanstackDb: Database; + hostDb: Database; + projectTable: string; + workspaceTable: string; + repoPath: string; +}): void { + const oldProjectIds = new Set(); + const projectKeysToDelete: string[] = []; + for (const row of getCollectionRows(tanstackDb, projectTable)) { + const project = parseCollectionValue<{ + id: string; + slug?: string; + repoCloneUrl?: string | null; + }>(row); + if ( + project?.id && + (project.slug === fixtureSlug || + project.repoCloneUrl === `file://${repoPath}`) + ) { + oldProjectIds.add(project.id); + projectKeysToDelete.push(row.key); + } + } + + const workspaceKeysToDelete: string[] = []; + for (const row of getCollectionRows(tanstackDb, workspaceTable)) { + const workspace = parseCollectionValue<{ + projectId?: string; + branch?: string; + }>(row); + if ( + workspace?.projectId && + (oldProjectIds.has(workspace.projectId) || + workspace.branch?.startsWith("renderer-stress/large-")) + ) { + workspaceKeysToDelete.push(row.key); + } + } + + deleteCollectionKeys(tanstackDb, workspaceTable, workspaceKeysToDelete); + deleteCollectionKeys(tanstackDb, projectTable, projectKeysToDelete); + + const hostProjectRows = hostDb + .query("select id from projects where repo_path = ?") + .all(repoPath) as Array<{ id: string }>; + for (const row of hostProjectRows) { + hostDb.query("delete from workspaces where project_id = ?").run(row.id); + hostDb.query("delete from projects where id = ?").run(row.id); + } +} + +async function seedDatabases({ + args, + projectId, + repoPath, + workspaces, +}: { + args: Args; + projectId: string; + repoPath: string; + workspaces: FixtureWorkspace[]; +}): Promise<{ organizationId: string; hostId: string }> { + const tanstackPath = join(args.supersetHome, "tanstack-db.sqlite"); + if (!existsSync(tanstackPath)) { + throw new Error(`TanStack DB not found: ${tanstackPath}`); + } + + const tanstackDb = new Database(tanstackPath); + const organizationId = inferOrganizationId(tanstackDb, args.organizationId); + const hostDbPath = join(args.supersetHome, "host", organizationId, "host.db"); + if (!existsSync(hostDbPath)) { + throw new Error(`Host DB not found: ${hostDbPath}`); + } + + const hostDb = new Database(hostDbPath); + const projectCollectionId = `v2_projects-${organizationId}`; + const workspaceCollectionId = `v2_workspaces-${organizationId}`; + const hostCollectionId = `v2_hosts-${organizationId}`; + const projectTable = getCollectionTable(tanstackDb, projectCollectionId); + const workspaceTable = getCollectionTable(tanstackDb, workspaceCollectionId); + const hostTable = getCollectionTable(tanstackDb, hostCollectionId); + const hostId = inferHostId({ + explicit: args.hostId, + tanstackDb, + hostDb, + workspaceTable, + hostTable, + }); + + const now = new Date().toISOString(); + ensureOrganizationRow({ tanstackDb, organizationId, now }); + ensureHostRow({ + tanstackDb, + hostCollectionId, + hostTable, + organizationId, + hostId, + now, + }); + + removePreviousFixtureRows({ + tanstackDb, + hostDb, + projectTable, + workspaceTable, + repoPath, + }); + + const projectVersion = getNextRowVersion(tanstackDb, projectCollectionId); + let workspaceVersion = getNextRowVersion(tanstackDb, workspaceCollectionId); + + insertCollectionValue( + tanstackDb, + projectTable, + `s:${projectId}`, + { + createdAt: now, + githubRepositoryId: null, + iconUrl: null, + id: projectId, + name: fixtureProjectName, + organizationId, + repoCloneUrl: `file://${repoPath}`, + slug: fixtureSlug, + updatedAt: now, + }, + { relation: ["public", "v2_projects"], operation: "insert" }, + projectVersion, + ); + + hostDb + .query( + `insert into projects + (id, repo_path, repo_provider, repo_owner, repo_name, repo_url, remote_name, created_at) + values (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + projectId, + repoPath, + "local", + null, + basename(repoPath), + null, + null, + Date.now(), + ); + + for (const workspace of workspaces) { + const headSha = await run("git", ["rev-parse", "HEAD"], { + cwd: workspace.worktreePath, + }); + hostDb + .query( + `insert into workspaces + (id, project_id, worktree_path, branch, head_sha, created_at) + values (?, ?, ?, ?, ?, ?)`, + ) + .run( + workspace.id, + projectId, + workspace.worktreePath, + workspace.branch, + headSha, + Date.now(), + ); + + insertCollectionValue( + tanstackDb, + workspaceTable, + `s:${workspace.id}`, + { + branch: workspace.branch, + createdAt: now, + createdByUserId: null, + hostId, + id: workspace.id, + name: `large diff ${workspace.branch.split("/").at(-1)}`, + organizationId, + projectId, + taskId: null, + type: "worktree", + updatedAt: now, + }, + { relation: ["public", "v2_workspaces"], operation: "insert" }, + workspaceVersion, + ); + workspaceVersion += 1; + } + + setCollectionVersion(tanstackDb, projectCollectionId, projectVersion); + setCollectionVersion(tanstackDb, workspaceCollectionId, workspaceVersion - 1); + + tanstackDb.close(); + hostDb.close(); + return { organizationId, hostId }; +} + +async function main(): Promise { + const args = parseArgs(Bun.argv.slice(2)); + if (args.help) { + console.log(usage()); + return; + } + + const { projectId, repoPath, workspaces } = await createGitFixtures(args); + const { organizationId, hostId } = await seedDatabases({ + args, + projectId, + repoPath, + workspaces, + }); + + const result = { + organizationId, + hostId, + projectId, + repoPath, + workspaceIds: workspaces.map((workspace) => workspace.id), + workspaceCount: workspaces.length, + changedFilesPerWorkspace: args.changedFiles, + fixtureRoot: args.root, + stressCommand: `bun --cwd apps/desktop stress:renderer -- --scenario all --workspace-ids ${workspaces + .map((workspace) => workspace.id) + .join( + ",", + )} --iterations 1000 --route-iterations 240 --heavy-iterations 500 --timeout-ms 300000`, + }; + + if (args.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + console.log("[stress:fixtures] prepared large-diff renderer fixtures"); + console.log(` org: ${organizationId}`); + console.log(` host: ${hostId}`); + console.log(` project: ${projectId}`); + console.log(` repo: ${repoPath}`); + console.log(` workspaces: ${workspaces.length}`); + console.log(` workspace ids: ${result.workspaceIds.join(",")}`); + console.log( + " restart the desktop dev app before running stress if it was open", + ); + console.log(` ${result.stressCommand}`); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + console.error(""); + console.error(usage()); + process.exit(1); +}); diff --git a/apps/desktop/scripts/stress-renderer.ts b/apps/desktop/scripts/stress-renderer.ts new file mode 100644 index 00000000000..47c3a7f36c5 --- /dev/null +++ b/apps/desktop/scripts/stress-renderer.ts @@ -0,0 +1,1709 @@ +#!/usr/bin/env bun + +interface Args { + host: string; + port: number; + scenario: StressScenario; + iterations: number; + routeIterations: number; + heavyIterations: number; + terminalIterations: number; + terminalTabCount: number; + terminalPanesPerTab: number; + terminalLines: number; + terminalPayloadBytes: number; + forceTerminalWebglLoss: boolean; + includeTerminalAction: boolean; + profileCpu: boolean; + reactProbe: boolean; + progressEvery: number; + intervalMs: number; + settleMs: number; + timeoutMs: number; + maxHeartbeatDelayMs: number; + maxLongTaskMs: number; + selector: string; + workspaceIds: string[]; + json: boolean; + help: boolean; +} + +type StressScenario = + | "all" + | "route-sweep" + | "terminal-heavy" + | "workspace-heavy" + | "workspace-switch" + | "workspace-switch-heavy"; + +interface CdpTarget { + type?: string; + title?: string; + url?: string; + webSocketDebuggerUrl?: string; +} + +interface CdpResponse { + id?: number; + result?: unknown; + error?: { + code: number; + message: string; + }; + method?: string; + params?: unknown; +} + +interface RuntimeConsoleApiCalledEvent { + type?: string; + args?: Array<{ + type?: string; + value?: unknown; + description?: string; + }>; +} + +interface RuntimeEvaluateResult { + result?: { + type?: string; + value?: unknown; + description?: string; + }; + exceptionDetails?: { + text?: string; + exception?: { + description?: string; + value?: unknown; + }; + }; +} + +interface CpuProfile { + nodes: Array<{ + id: number; + parent?: number; + children?: number[]; + callFrame: { + functionName?: string; + url?: string; + lineNumber?: number; + columnNumber?: number; + }; + hitCount?: number; + }>; + samples?: number[]; + timeDeltas?: number[]; +} + +interface CpuProfileResult { + profile?: CpuProfile; +} + +interface CpuProfileFrameSummary { + functionName: string; + url: string; + lineNumber: number; + columnNumber: number; + selfTimeMs: number; + sampleCount: number; + parentFunctionName?: string; + parentUrl?: string; + parentLineNumber?: number; + parentColumnNumber?: number; +} + +interface RendererStressResult { + scenario: StressScenario; + iterations: number; + operationCount: number; + targetCount: number; + activationModeCounts: Record; + routeCount: number; + routeIterations: number; + routesVisited: string[]; + heavyIterations: number; + heavyActionCounts: Record; + heavyActionErrors: string[]; + heavyActionCatalogue: string[]; + terminalIterations: number; + terminalActionCounts: Record; + terminalActionErrors: string[]; + terminalStressSummary: unknown; + terminalWebglContextLosses: unknown[]; + workspaceSummary: unknown; + reactProbeSummary: unknown; + durationMs: number; + maxHeartbeatDelayMs: number; + heartbeatDelaySamples: number[]; + maxLongTaskDurationMs: number; + longTaskCount: number; + longTasks: Array<{ + duration: number; + startTime: number; + name: string; + }>; + slowOperations: Array<{ + phase: string; + label: string; + durationMs: number; + index: number; + }>; + errorCount: number; + errors: string[]; + startMemory: unknown; + endMemory: unknown; + finalLocation: string; +} + +const DEFAULT_SELECTOR = "[data-renderer-stress-workspace-id]"; + +interface TerminalWebglContextLossRecord { + index: number; + terminalCount: number; + canvasCount: number; + webglContextCount: number; + lostContextCount: number; + unsupportedContextCount: number; +} + +function parseArgs(argv: string[]): Args { + const args: Args = { + host: "127.0.0.1", + port: Number(process.env.SUPERSET_RENDERER_STRESS_CDP_PORT ?? 9333), + scenario: "workspace-switch", + iterations: 500, + routeIterations: 0, + heavyIterations: 0, + terminalIterations: 0, + terminalTabCount: 24, + terminalPanesPerTab: 4, + terminalLines: 40, + terminalPayloadBytes: 1024, + forceTerminalWebglLoss: true, + includeTerminalAction: false, + profileCpu: false, + reactProbe: false, + progressEvery: 100, + intervalMs: 0, + settleMs: 1000, + timeoutMs: 30_000, + maxHeartbeatDelayMs: 500, + maxLongTaskMs: 500, + selector: DEFAULT_SELECTOR, + workspaceIds: [], + json: false, + help: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const readValue = () => { + const value = argv[index + 1]; + if (!value) throw new Error(`Missing value for ${arg}`); + index += 1; + return value; + }; + const readNumber = () => { + const value = Number(readValue()); + if (!Number.isFinite(value)) throw new Error(`Invalid number for ${arg}`); + return value; + }; + + switch (arg) { + case "--help": + case "-h": + args.help = true; + break; + case "--host": + args.host = readValue(); + break; + case "--port": + args.port = readNumber(); + break; + case "--scenario": { + const scenario = readValue(); + if ( + scenario !== "all" && + scenario !== "route-sweep" && + scenario !== "terminal-heavy" && + scenario !== "workspace-heavy" && + scenario !== "workspace-switch" && + scenario !== "workspace-switch-heavy" + ) { + throw new Error(`Invalid scenario for ${arg}: ${scenario}`); + } + args.scenario = scenario; + break; + } + case "--iterations": + args.iterations = readNumber(); + break; + case "--route-iterations": + args.routeIterations = readNumber(); + break; + case "--heavy-iterations": + args.heavyIterations = readNumber(); + break; + case "--terminal-iterations": + args.terminalIterations = readNumber(); + break; + case "--terminal-tab-count": + args.terminalTabCount = readNumber(); + break; + case "--terminal-panes-per-tab": + args.terminalPanesPerTab = readNumber(); + break; + case "--terminal-lines": + args.terminalLines = readNumber(); + break; + case "--terminal-payload-bytes": + args.terminalPayloadBytes = readNumber(); + break; + case "--no-terminal-webgl-loss": + args.forceTerminalWebglLoss = false; + break; + case "--include-terminal-action": + args.includeTerminalAction = true; + break; + case "--profile-cpu": + args.profileCpu = true; + break; + case "--react-probe": + args.reactProbe = true; + break; + case "--progress-every": + args.progressEvery = readNumber(); + break; + case "--interval-ms": + args.intervalMs = readNumber(); + break; + case "--settle-ms": + args.settleMs = readNumber(); + break; + case "--timeout-ms": + args.timeoutMs = readNumber(); + break; + case "--max-heartbeat-delay-ms": + args.maxHeartbeatDelayMs = readNumber(); + break; + case "--max-long-task-ms": + args.maxLongTaskMs = readNumber(); + break; + case "--selector": + args.selector = readValue(); + break; + case "--workspace-ids": + args.workspaceIds = readValue() + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + break; + case "--json": + args.json = true; + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + + return args; +} + +function usage() { + return `Renderer stress harness + +Start the desktop app with CDP enabled: + SUPERSET_RENDERER_STRESS_CDP_PORT=9333 bun --cwd apps/desktop dev + +Run the workspace switching stress test from another shell: + bun --cwd apps/desktop stress:renderer -- --port 9333 --iterations 1000 --interval-ms 0 + +Run route and workspace action stress: + bun --cwd apps/desktop stress:renderer -- --port 9333 --scenario all --iterations 1000 --route-iterations 200 --heavy-iterations 300 + +Options: + --port CDP port. Default: env SUPERSET_RENDERER_STRESS_CDP_PORT or 9333 + --host CDP host. Default: 127.0.0.1 + --scenario workspace-switch, workspace-switch-heavy, route-sweep, workspace-heavy, terminal-heavy, or all. Default: workspace-switch + --iterations Workspace activations. Default: 500 + --route-iterations Route navigations. Default: --iterations + --heavy-iterations Mixed pane/tab/browser/diff actions. Default: min(--iterations, 300) + --terminal-iterations Terminal tab switch/write/context-loss cycles. Default: min(--iterations, 200) + --terminal-tab-count Synthetic terminal tabs. Default: 24 + --terminal-panes-per-tab Terminal panes per synthetic tab. Default: 4 + Also controls generated tabs/panes for workspace-switch-heavy. + --terminal-lines ANSI output lines per terminal write. Default: 40 + --terminal-payload-bytes Repeated payload bytes per line. Default: 1024 + --no-terminal-webgl-loss Do not force WEBGL_lose_context during terminal stress + --include-terminal-action Include one real backend terminal launch in heavy stress. Default: false + --profile-cpu Capture a CDP CPU profile and print hottest JS frames + --react-probe Capture React commit/component counts via React DevTools hook when available + --progress-every Emit progress every n operations. Set 0 to disable. Default: 100 + --interval-ms Delay between activations. Default: 0 + --settle-ms Delay after the final activation. Default: 1000 + --timeout-ms CDP command timeout. Default: 30000 + --max-heartbeat-delay-ms Fail if event-loop heartbeat exceeds this. Default: 500 + --max-long-task-ms Fail if a renderer long task exceeds this. Default: 500 + --selector Workspace target selector. Default: ${DEFAULT_SELECTOR} + --workspace-ids Optional explicit workspace ids; falls back to hash navigation if needed + --json Print only JSON summary +`; +} + +function messageDataToString(data: unknown): string { + if (typeof data === "string") return data; + if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8"); + if (ArrayBuffer.isView(data)) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString( + "utf8", + ); + } + return String(data); +} + +function summarizeCpuProfile( + profile: CpuProfile, + limit = 30, +): CpuProfileFrameSummary[] { + const nodesById = new Map(profile.nodes.map((node) => [node.id, node])); + const parentByNodeId = new Map(); + for (const node of profile.nodes) { + if (node.parent != null) parentByNodeId.set(node.id, node.parent); + for (const childId of node.children ?? []) { + parentByNodeId.set(childId, node.id); + } + } + const selfTimeByNodeId = new Map(); + const sampleCountByNodeId = new Map(); + const samples = profile.samples ?? []; + const timeDeltas = profile.timeDeltas ?? []; + + for (let index = 0; index < samples.length; index += 1) { + const nodeId = samples[index]; + sampleCountByNodeId.set(nodeId, (sampleCountByNodeId.get(nodeId) ?? 0) + 1); + selfTimeByNodeId.set( + nodeId, + (selfTimeByNodeId.get(nodeId) ?? 0) + (timeDeltas[index] ?? 0) / 1000, + ); + } + + return Array.from(nodesById.entries()) + .map(([nodeId, node]) => { + const callFrame = node.callFrame; + const parent = nodesById.get(parentByNodeId.get(nodeId) ?? -1); + const parentCallFrame = parent?.callFrame; + return { + functionName: callFrame.functionName || "(anonymous)", + url: callFrame.url || "", + lineNumber: callFrame.lineNumber ?? 0, + columnNumber: callFrame.columnNumber ?? 0, + selfTimeMs: selfTimeByNodeId.get(nodeId) ?? 0, + sampleCount: sampleCountByNodeId.get(nodeId) ?? node.hitCount ?? 0, + parentFunctionName: parentCallFrame?.functionName || undefined, + parentUrl: parentCallFrame?.url || undefined, + parentLineNumber: parentCallFrame?.lineNumber, + parentColumnNumber: parentCallFrame?.columnNumber, + }; + }) + .filter((frame) => frame.selfTimeMs > 0 || frame.sampleCount > 0) + .sort((left, right) => right.selfTimeMs - left.selfTimeMs) + .slice(0, limit); +} + +class CdpClient { + private nextId = 1; + private pending = new Map< + number, + { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer: ReturnType; + } + >(); + private listeners = new Map void>>(); + + private constructor(private readonly ws: WebSocket) { + this.ws.addEventListener("message", (event) => { + this.onMessage(messageDataToString(event.data)); + }); + this.ws.addEventListener("close", () => { + this.rejectPending(new Error("CDP socket closed")); + }); + this.ws.addEventListener("error", () => { + this.rejectPending(new Error("CDP socket errored")); + }); + } + + static connect(url: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + const timer = setTimeout(() => { + ws.close(); + reject(new Error(`Timed out connecting to ${url}`)); + }, timeoutMs); + ws.addEventListener("open", () => { + clearTimeout(timer); + resolve(new CdpClient(ws)); + }); + ws.addEventListener("error", () => { + clearTimeout(timer); + reject(new Error(`Failed to connect to ${url}`)); + }); + }); + } + + send( + method: string, + params: Record = {}, + timeoutMs = 10_000, + ): Promise { + const id = this.nextId; + this.nextId += 1; + const payload = JSON.stringify({ id, method, params }); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`CDP command timed out: ${method}`)); + }, timeoutMs); + this.pending.set(id, { + resolve: (value) => resolve(value as T), + reject, + timer, + }); + this.ws.send(payload); + }); + } + + close(): void { + this.ws.close(); + } + + on(method: string, listener: (params: unknown) => void): () => void { + const listeners = this.listeners.get(method) ?? new Set(); + listeners.add(listener); + this.listeners.set(method, listeners); + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + this.listeners.delete(method); + } + }; + } + + private onMessage(raw: string): void { + const message = JSON.parse(raw) as CdpResponse; + if (typeof message.method === "string") { + const listeners = this.listeners.get(message.method); + if (listeners) { + for (const listener of listeners) { + listener(message.params); + } + } + if (typeof message.id !== "number") return; + } + if (typeof message.id !== "number") return; + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + clearTimeout(pending.timer); + if (message.error) { + pending.reject( + new Error(`CDP error ${message.error.code}: ${message.error.message}`), + ); + return; + } + pending.resolve(message.result); + } + + private rejectPending(error: Error): void { + for (const [id, pending] of this.pending) { + clearTimeout(pending.timer); + pending.reject(error); + this.pending.delete(id); + } + } +} + +async function getRendererTarget(args: Args): Promise { + const response = await fetch(`http://${args.host}:${args.port}/json/list`); + if (!response.ok) { + throw new Error( + `Failed to query CDP targets: ${response.status} ${response.statusText}`, + ); + } + const targets = (await response.json()) as CdpTarget[]; + const target = targets.find( + (candidate) => + candidate.webSocketDebuggerUrl && + candidate.type === "page" && + !candidate.url?.startsWith("devtools://"), + ); + if (!target?.webSocketDebuggerUrl) { + throw new Error("No renderer page target with a CDP socket was found"); + } + return target; +} + +function rendererStress(options: { + scenario: StressScenario; + iterations: number; + routeIterations: number; + heavyIterations: number; + terminalIterations: number; + terminalTabCount: number; + terminalPanesPerTab: number; + terminalLines: number; + terminalPayloadBytes: number; + forceTerminalWebglLoss: boolean; + includeTerminalAction: boolean; + reactProbe: boolean; + progressEvery: number; + intervalMs: number; + settleMs: number; + selector: string; + workspaceIds: string[]; +}): Promise { + const webglContextRecoverySettleMs = 3500; + type StressWindow = Window & { + performance: Performance & { + memory?: unknown; + }; + }; + + const stressWindow = window as StressWindow; + const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + const nextFrame = () => + new Promise((resolve) => requestAnimationFrame(() => resolve())); + const cssEscape = + stressWindow.CSS?.escape ?? + ((value: string) => value.replace(/["\\]/g, "\\$&")); + const errors: string[] = []; + const longTasks: RendererStressResult["longTasks"] = []; + const heartbeatDelaySamples: number[] = []; + let maxHeartbeatDelayMs = 0; + let expectedHeartbeat = performance.now() + 50; + + const describeError = (error: unknown): string => { + if (error instanceof Error) { + return error.stack ?? error.message; + } + if (typeof error === "string") return error; + return String(error); + }; + + const onError = (event: ErrorEvent) => { + errors.push( + event.error + ? describeError(event.error) + : event.message || "unknown error", + ); + }; + const onUnhandledRejection = (event: PromiseRejectionEvent) => { + errors.push(`Unhandled rejection: ${describeError(event.reason)}`); + }; + const installReactProbe = () => { + if (!options.reactProbe) { + return { + getSummary: () => null, + cleanup: () => {}, + }; + } + + type ReactFiber = { + actualDuration?: number; + child?: ReactFiber | null; + elementType?: unknown; + flags?: number; + sibling?: ReactFiber | null; + type?: unknown; + }; + type ReactFiberRoot = { current?: ReactFiber | null }; + type ReactDevToolsHook = { + onCommitFiberRoot?: ( + rendererId: number, + root: ReactFiberRoot, + priorityLevel?: unknown, + didError?: boolean, + ) => unknown; + }; + type ReactProbeWindow = Window & { + __REACT_DEVTOOLS_GLOBAL_HOOK__?: ReactDevToolsHook; + }; + + const hook = (window as ReactProbeWindow).__REACT_DEVTOOLS_GLOBAL_HOOK__; + if (!hook || typeof hook.onCommitFiberRoot !== "function") { + return { + getSummary: () => ({ + available: false, + reason: "React DevTools global hook is not installed", + }), + cleanup: () => {}, + }; + } + + const previousOnCommitFiberRoot = hook.onCommitFiberRoot; + const componentStats = new Map< + string, + { + commitCount: number; + maxActualDurationMs: number; + totalActualDurationMs: number; + } + >(); + const recentCommits: Array<{ + componentCount: number; + durationMs: number; + fiberCount: number; + index: number; + totalActualDurationMs: number; + }> = []; + let commitCount = 0; + let maxCommitDurationMs = 0; + + const getDisplayName = (fiber: ReactFiber): string | null => { + const candidate = fiber.type ?? fiber.elementType; + if (typeof candidate === "string") return candidate; + if (typeof candidate === "function") { + const named = candidate as { displayName?: string; name?: string }; + return named.displayName ?? named.name ?? null; + } + if (candidate && typeof candidate === "object") { + const named = candidate as { + displayName?: string; + name?: string; + render?: { displayName?: string; name?: string }; + }; + return ( + named.displayName ?? + named.name ?? + named.render?.displayName ?? + named.render?.name ?? + null + ); + } + return null; + }; + + const visitFibers = ( + root: ReactFiber | null | undefined, + visit: (fiber: ReactFiber) => void, + ) => { + const stack: ReactFiber[] = []; + const seen = new Set(); + if (root) stack.push(root); + while (stack.length > 0 && seen.size < 20_000) { + const fiber = stack.pop(); + if (!fiber || seen.has(fiber)) continue; + seen.add(fiber); + visit(fiber); + if (fiber.sibling) stack.push(fiber.sibling); + if (fiber.child) stack.push(fiber.child); + } + return seen.size; + }; + + function onCommitFiberRoot( + this: unknown, + rendererId: number, + root: ReactFiberRoot, + priorityLevel?: unknown, + didError?: boolean, + ) { + const result = previousOnCommitFiberRoot.apply(this, [ + rendererId, + root, + priorityLevel, + didError, + ]); + const startedAt = performance.now(); + const committedComponents = new Set(); + let totalActualDurationMs = 0; + const fiberCount = visitFibers(root.current, (fiber) => { + const name = getDisplayName(fiber); + if (!name) return; + const actualDuration = + typeof fiber.actualDuration === "number" ? fiber.actualDuration : 0; + const flags = typeof fiber.flags === "number" ? fiber.flags : 0; + if (actualDuration <= 0 && flags === 0) return; + + committedComponents.add(name); + totalActualDurationMs += actualDuration; + const current = componentStats.get(name) ?? { + commitCount: 0, + maxActualDurationMs: 0, + totalActualDurationMs: 0, + }; + current.commitCount += 1; + current.totalActualDurationMs += actualDuration; + current.maxActualDurationMs = Math.max( + current.maxActualDurationMs, + actualDuration, + ); + componentStats.set(name, current); + }); + + commitCount += 1; + const durationMs = performance.now() - startedAt; + maxCommitDurationMs = Math.max(maxCommitDurationMs, durationMs); + recentCommits.push({ + componentCount: committedComponents.size, + durationMs, + fiberCount, + index: commitCount, + totalActualDurationMs, + }); + if (recentCommits.length > 50) recentCommits.shift(); + return result; + } + + hook.onCommitFiberRoot = onCommitFiberRoot; + + return { + getSummary: () => ({ + available: true, + commitCount, + maxCommitDurationMs, + recentCommits: recentCommits.slice(-10), + topComponents: Array.from(componentStats.entries()) + .map(([name, stats]) => ({ name, ...stats })) + .sort((left, right) => { + const durationDelta = + right.totalActualDurationMs - left.totalActualDurationMs; + if (durationDelta !== 0) return durationDelta; + return right.commitCount - left.commitCount; + }) + .slice(0, 30), + }), + cleanup: () => { + if (hook.onCommitFiberRoot === onCommitFiberRoot) { + hook.onCommitFiberRoot = previousOnCommitFiberRoot; + } + }, + }; + }; + + window.addEventListener("error", onError); + window.addEventListener("unhandledrejection", onUnhandledRejection); + const reactProbe = installReactProbe(); + + const heartbeat = setInterval(() => { + const now = performance.now(); + const delay = Math.max(0, now - expectedHeartbeat); + if (delay > maxHeartbeatDelayMs) maxHeartbeatDelayMs = delay; + if (delay > 50) heartbeatDelaySamples.push(delay); + expectedHeartbeat = now + 50; + }, 50); + + let longTaskObserver: PerformanceObserver | null = null; + try { + longTaskObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + longTasks.push({ + duration: entry.duration, + startTime: entry.startTime, + name: entry.name, + }); + } + }); + longTaskObserver.observe({ entryTypes: ["longtask"] }); + } catch { + longTaskObserver = null; + } + + const getTargets = () => { + if (options.workspaceIds.length > 0) return options.workspaceIds; + const ids = Array.from(document.querySelectorAll(options.selector)) + .map((element) => + element.getAttribute("data-renderer-stress-workspace-id"), + ) + .filter((value): value is string => !!value); + return Array.from(new Set(ids)); + }; + + const activateWorkspace = async (workspaceId: string) => { + const target = document.querySelector( + `${options.selector}[data-renderer-stress-workspace-id="${cssEscape( + workspaceId, + )}"]`, + ); + if (target) { + target.click(); + return "click"; + } + await navigateTo(`/v2-workspace/${encodeURIComponent(workspaceId)}/`); + return "navigate"; + }; + + type RendererStressBridge = { + workspaceId: string; + projectId: string; + captureBaseline: () => void; + restoreBaseline: () => void; + getSummary: () => unknown; + addTab: (kind: string, index: number, paneCount?: number) => void; + openPane: (kind: string, index: number) => void; + splitActivePane: (kind: string, index: number) => void; + switchTab: (index: number) => void; + closeActivePane: () => void; + closeOldestTab: (keepCount?: number) => void; + churnActivePaneData: (index: number) => void; + replaceWithGeneratedLayout: (tabCount: number, panesPerTab: number) => void; + replaceWithGeneratedTerminalLayout: ( + tabCount: number, + panesPerTab: number, + ) => void; + replaceWithGeneratedMixedLayout: ( + tabCount: number, + panesPerTab: number, + ) => void; + writeTerminalStressOutput: ( + index: number, + lines: number, + payloadBytes: number, + ) => Promise<{ + terminalCount: number; + writtenCount: number; + failedCount: number; + byteLength: number; + }>; + forceTerminalWebglContextLoss: () => { + terminalCount: number; + canvasCount: number; + webglContextCount: number; + lostContextCount: number; + unsupportedContextCount: number; + }; + getTerminalStressSummary: () => unknown; + releaseStressTerminalRuntimes: () => void; + addRealTerminalTab: () => Promise; + showChangesSidebar: () => void; + }; + + type RendererStressWindow = Window & { + __SUPERSET_RENDERER_STRESS__?: RendererStressBridge; + __SUPERSET_RENDERER_STRESS_NAVIGATE__?: (path: string) => Promise; + }; + + const getBridge = () => + (window as RendererStressWindow).__SUPERSET_RENDERER_STRESS__ ?? null; + + const waitForBridge = async (workspaceId?: string, attempts = 250) => { + for (let attempt = 0; attempt < attempts; attempt += 1) { + const bridge = getBridge(); + if (bridge && (!workspaceId || bridge.workspaceId === workspaceId)) { + return bridge; + } + await sleep(20); + } + throw new Error( + workspaceId + ? `Renderer stress workspace bridge did not mount for ${workspaceId}` + : "Renderer stress workspace bridge did not mount", + ); + }; + + const navigateTo = async (path: string) => { + const stressNavigate = (window as RendererStressWindow) + .__SUPERSET_RENDERER_STRESS_NAVIGATE__; + if (stressNavigate) { + await stressNavigate(path); + } else { + window.location.hash = path; + } + await sleep(options.intervalMs); + }; + + const withTimeout = async ( + promise: Promise, + timeoutMs: number, + label: string, + ): Promise => { + let timer: ReturnType | null = null; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), + timeoutMs, + ); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + }; + + const findWorkspaceBridge = async (workspaceIds: string[]) => { + const failures: string[] = []; + for (const candidateWorkspaceId of workspaceIds) { + await navigateTo( + `/v2-workspace/${encodeURIComponent(candidateWorkspaceId)}/`, + ); + try { + const bridge = await waitForBridge(candidateWorkspaceId, 500); + return { workspaceId: candidateWorkspaceId, bridge }; + } catch (error) { + failures.push( + `${candidateWorkspaceId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + throw new Error( + `No V2 workspace stress bridge mounted. Tried ${workspaceIds.length} workspace route(s): ${failures.join( + "; ", + )}`, + ); + }; + + const buildRoutePaths = ( + targets: string[], + metadata: { projectId?: string; workspaceId?: string }, + ) => { + const staticPaths = [ + "/", + "/v2-workspaces/", + "/workspaces/", + "/workspace/", + "/tasks/", + "/automations/", + "/settings/", + "/settings/account/", + "/settings/agents/", + "/settings/api-keys/", + "/settings/appearance/", + "/settings/behavior/", + "/settings/billing/", + "/settings/billing/plans/", + "/settings/experimental/", + "/settings/git/", + "/settings/hosts/", + "/settings/integrations/", + "/settings/keyboard/", + "/settings/links/", + "/settings/models/", + "/settings/organization/", + "/settings/permissions/", + "/settings/presets/", + "/settings/projects/", + "/settings/ringtones/", + "/settings/security/", + "/settings/teams/", + "/settings/terminal/", + "/setup/adopt-worktrees/", + "/setup/gh-cli/", + "/setup/permissions/", + "/setup/project/", + "/setup/providers/", + "/setup/providers/claude-code/", + "/setup/providers/claude-code/api-key/", + "/setup/providers/claude-code/custom/", + "/setup/providers/codex/", + "/setup/providers/codex/api-key/", + "/setup/providers/codex/custom/", + "/welcome/", + "/new-project/", + ]; + const dynamicPaths = targets.flatMap((workspaceId) => [ + `/v2-workspace/${encodeURIComponent(workspaceId)}/`, + `/workspace/${encodeURIComponent(workspaceId)}/`, + ]); + if (metadata.projectId) { + const projectId = encodeURIComponent(metadata.projectId); + dynamicPaths.push( + `/project/${projectId}/`, + `/settings/projects/${projectId}/`, + `/settings/project/${projectId}/cloud/`, + `/settings/project/${projectId}/cloud/secrets/`, + `/tasks/issue/1/?project=${projectId}`, + `/tasks/pr/1/?project=${projectId}`, + ); + } + return Array.from(new Set([...staticPaths, ...dynamicPaths])); + }; + + const heavyActionCatalogue = [ + "replace generated multi-tab/multi-pane workspace layout", + "open changes sidebar", + "create file tabs", + "create diff tabs", + "create browser tabs/webviews", + "create chat tabs", + "create comment tabs", + "split active pane with file/diff/browser/chat/comment panes", + "open panes through same-kind replacement path", + "rapid tab switching", + "active pane data churn", + "close active panes", + "close old tabs while preserving a warm set", + ...(options.includeTerminalAction + ? ["single real terminal tab launch"] + : []), + "synthetic terminal layout with parked xterm runtimes", + "high-volume terminal ANSI output", + "forced terminal WebGL context loss and fallback recovery", + "restore original pane layout", + ]; + + const shouldRunWorkspaceSwitch = + options.scenario === "workspace-switch" || + options.scenario === "workspace-switch-heavy" || + options.scenario === "all"; + const shouldPrepareHeavyWorkspaceSwitch = + options.scenario === "workspace-switch-heavy"; + const shouldRunRouteSweep = + options.scenario === "route-sweep" || options.scenario === "all"; + const shouldRunWorkspaceHeavy = + options.scenario === "workspace-heavy" || options.scenario === "all"; + const shouldRunTerminalHeavy = + options.scenario === "terminal-heavy" || options.scenario === "all"; + + return (async () => { + const startedAt = performance.now(); + const startMemory = stressWindow.performance.memory ?? null; + const targets = getTargets(); + const requiredTargetCount = shouldRunWorkspaceSwitch ? 2 : 1; + if (targets.length < requiredTargetCount) { + throw new Error( + `Need at least ${requiredTargetCount} workspace target(s), found ${targets.length}. Open a workspace list/sidebar or pass --workspace-ids.`, + ); + } + + const activationModeCounts: Record = {}; + const routesVisited: string[] = []; + const heavyActionCounts: Record = {}; + const heavyActionErrors: string[] = []; + const terminalActionCounts: Record = {}; + const terminalActionErrors: string[] = []; + const terminalWebglContextLosses: TerminalWebglContextLossRecord[] = []; + const slowOperations: RendererStressResult["slowOperations"] = []; + const heavyWorkspaceSwitchWorkspaceIds: string[] = []; + const heavyWorkspaceSwitchSummaries: unknown[] = []; + let operationCount = 0; + let workspaceSummary: unknown = null; + let terminalStressSummary: unknown = null; + let routeCount = 0; + const routeIterations = + options.routeIterations > 0 + ? options.routeIterations + : options.iterations; + const heavyIterations = + options.heavyIterations > 0 + ? options.heavyIterations + : Math.min(options.iterations, 300); + const terminalIterations = + options.terminalIterations > 0 + ? options.terminalIterations + : Math.min(options.iterations, 200); + const reportProgress = ( + phase: string, + index: number, + total: number, + force = false, + ) => { + if (options.progressEvery <= 0 && !force) return; + if (!force && index % options.progressEvery !== 0) return; + console.info( + "[stress:renderer:progress]", + JSON.stringify({ + phase, + index, + total, + operationCount, + elapsedMs: Math.round(performance.now() - startedAt), + }), + ); + }; + const recordOperationDuration = ( + phase: string, + label: string, + index: number, + startedAt: number, + ) => { + const durationMs = performance.now() - startedAt; + if (durationMs < 100) return; + slowOperations.push({ phase, label, index, durationMs }); + }; + const prepareHeavyWorkspaceSwitchTargets = async () => { + const tabCount = Math.max( + 1, + Math.min(40, Math.floor(options.terminalTabCount)), + ); + const panesPerTab = Math.max( + 1, + Math.min(8, Math.floor(options.terminalPanesPerTab)), + ); + + reportProgress("workspace-switch-heavy-prepare", 0, targets.length, true); + for (let index = 0; index < targets.length; index += 1) { + const target = targets[index]; + await navigateTo(`/v2-workspace/${encodeURIComponent(target)}/`); + const bridge = await waitForBridge(target, 500); + + let operationStartedAt = performance.now(); + bridge.captureBaseline(); + bridge.replaceWithGeneratedMixedLayout(tabCount, panesPerTab); + bridge.showChangesSidebar(); + recordOperationDuration( + "workspace-switch-heavy-prepare", + `replace-generated-mixed-layout:${target}`, + index, + operationStartedAt, + ); + heavyActionCounts["replace-generated-mixed-layout"] = + (heavyActionCounts["replace-generated-mixed-layout"] ?? 0) + 1; + operationCount += 1; + + operationStartedAt = performance.now(); + for (let tabIndex = 0; tabIndex < tabCount; tabIndex += 1) { + bridge.switchTab(tabIndex); + await nextFrame(); + } + recordOperationDuration( + "workspace-switch-heavy-prepare", + `warm-generated-tabs:${target}`, + index, + operationStartedAt, + ); + heavyActionCounts["warm-generated-tabs"] = + (heavyActionCounts["warm-generated-tabs"] ?? 0) + tabCount; + operationCount += tabCount; + + heavyWorkspaceSwitchWorkspaceIds.push(target); + heavyWorkspaceSwitchSummaries.push(bridge.getSummary()); + reportProgress( + "workspace-switch-heavy-prepare", + index + 1, + targets.length, + ); + } + }; + const restoreHeavyWorkspaceSwitchTargets = async () => { + for ( + let index = heavyWorkspaceSwitchWorkspaceIds.length - 1; + index >= 0; + index -= 1 + ) { + const target = heavyWorkspaceSwitchWorkspaceIds[index]; + const operationStartedAt = performance.now(); + await navigateTo(`/v2-workspace/${encodeURIComponent(target)}/`); + const bridge = await waitForBridge(target, 500); + bridge.restoreBaseline(); + recordOperationDuration( + "workspace-switch-heavy-restore", + target, + index, + operationStartedAt, + ); + } + }; + + if (shouldPrepareHeavyWorkspaceSwitch) { + await prepareHeavyWorkspaceSwitchTargets(); + workspaceSummary = { + preparedWorkspaceSummaries: heavyWorkspaceSwitchSummaries, + }; + } + + if (shouldRunWorkspaceSwitch) { + reportProgress("workspace-switch", 0, options.iterations, true); + for (let index = 0; index < options.iterations; index += 1) { + const target = targets[index % targets.length]; + const operationStartedAt = performance.now(); + const mode = await activateWorkspace(target); + recordOperationDuration( + "workspace-switch", + `${mode}:${target}`, + index, + operationStartedAt, + ); + activationModeCounts[mode] = (activationModeCounts[mode] ?? 0) + 1; + operationCount += 1; + reportProgress("workspace-switch", index + 1, options.iterations); + await sleep(options.intervalMs); + } + } + + if (shouldPrepareHeavyWorkspaceSwitch) { + await restoreHeavyWorkspaceSwitchTargets(); + } + + let workspaceId = targets[0]; + let metadata: { projectId?: string; workspaceId?: string } = {}; + if ( + shouldRunRouteSweep || + shouldRunWorkspaceHeavy || + shouldRunTerminalHeavy + ) { + const mounted = await findWorkspaceBridge(targets); + workspaceId = mounted.workspaceId; + const bridge = mounted.bridge; + metadata = { + projectId: bridge.projectId, + workspaceId: bridge.workspaceId, + }; + } + + if (shouldRunRouteSweep) { + const routePaths = buildRoutePaths(targets, metadata); + routeCount = routePaths.length; + reportProgress("route-sweep", 0, routeIterations, true); + for (let index = 0; index < routeIterations; index += 1) { + const routePath = routePaths[index % routePaths.length]; + routesVisited.push(routePath); + const operationStartedAt = performance.now(); + await navigateTo(routePath); + recordOperationDuration( + "route-sweep", + routePath, + index, + operationStartedAt, + ); + operationCount += 1; + reportProgress("route-sweep", index + 1, routeIterations); + } + await navigateTo(`/v2-workspace/${encodeURIComponent(workspaceId)}/`); + await waitForBridge(workspaceId); + } + + if (shouldRunWorkspaceHeavy) { + const activeBridge = await waitForBridge(workspaceId); + activeBridge.captureBaseline(); + let operationStartedAt = performance.now(); + activeBridge.replaceWithGeneratedLayout(12, 3); + recordOperationDuration( + "workspace-heavy", + "replace-generated-layout", + -2, + operationStartedAt, + ); + heavyActionCounts["replace-generated-layout"] = 1; + operationCount += 1; + operationStartedAt = performance.now(); + activeBridge.showChangesSidebar(); + recordOperationDuration( + "workspace-heavy", + "show-changes-sidebar", + -1, + operationStartedAt, + ); + heavyActionCounts["show-changes-sidebar"] = 1; + operationCount += 1; + reportProgress("workspace-heavy", 0, heavyIterations, true); + + const paneKinds = ["file", "diff", "browser", "chat", "comment"]; + for (let index = 0; index < heavyIterations; index += 1) { + const kind = paneKinds[index % paneKinds.length]; + const action = index % 12; + const operationStartedAt = performance.now(); + let actionLabel = "unknown"; + try { + if (options.includeTerminalAction && index === 0) { + actionLabel = "add-real-terminal-tab"; + await withTimeout( + activeBridge.addRealTerminalTab(), + 3000, + "add-real-terminal-tab", + ); + heavyActionCounts["add-real-terminal-tab"] = + (heavyActionCounts["add-real-terminal-tab"] ?? 0) + 1; + } else if (action === 0) { + actionLabel = `add-tab:${kind}`; + activeBridge.addTab(kind, index, (index % 4) + 1); + heavyActionCounts["add-tab"] = + (heavyActionCounts["add-tab"] ?? 0) + 1; + } else if (action === 1 || action === 2) { + actionLabel = `split-active-pane:${kind}`; + activeBridge.splitActivePane(kind, index); + heavyActionCounts["split-active-pane"] = + (heavyActionCounts["split-active-pane"] ?? 0) + 1; + } else if (action === 3) { + actionLabel = `open-pane:${kind}`; + activeBridge.openPane(kind, index); + heavyActionCounts["open-pane"] = + (heavyActionCounts["open-pane"] ?? 0) + 1; + } else if (action === 4 || action === 5) { + actionLabel = "switch-tab"; + activeBridge.switchTab(index); + heavyActionCounts["switch-tab"] = + (heavyActionCounts["switch-tab"] ?? 0) + 1; + } else if (action === 6 || action === 7) { + actionLabel = "churn-active-pane-data"; + activeBridge.churnActivePaneData(index); + heavyActionCounts["churn-active-pane-data"] = + (heavyActionCounts["churn-active-pane-data"] ?? 0) + 1; + } else if (action === 8) { + actionLabel = "close-active-pane"; + activeBridge.closeActivePane(); + heavyActionCounts["close-active-pane"] = + (heavyActionCounts["close-active-pane"] ?? 0) + 1; + } else if (action === 9) { + actionLabel = "close-oldest-tab"; + activeBridge.closeOldestTab(10); + heavyActionCounts["close-oldest-tab"] = + (heavyActionCounts["close-oldest-tab"] ?? 0) + 1; + } else { + actionLabel = `add-single-pane-tab:${kind}`; + activeBridge.addTab(kind, index); + heavyActionCounts["add-single-pane-tab"] = + (heavyActionCounts["add-single-pane-tab"] ?? 0) + 1; + } + } catch (error) { + heavyActionErrors.push( + `heavy action ${index} failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + recordOperationDuration( + "workspace-heavy", + actionLabel, + index, + operationStartedAt, + ); + operationCount += 1; + reportProgress("workspace-heavy", index + 1, heavyIterations); + await sleep(options.intervalMs); + } + workspaceSummary = activeBridge.getSummary(); + activeBridge.restoreBaseline(); + heavyActionCounts["restore-baseline"] = 1; + operationCount += 1; + } + + if (shouldRunTerminalHeavy) { + const activeBridge = await waitForBridge(workspaceId); + const terminalTabCount = Math.max( + 1, + Math.min(80, Math.floor(options.terminalTabCount)), + ); + const terminalPanesPerTab = Math.max( + 1, + Math.min(8, Math.floor(options.terminalPanesPerTab)), + ); + const webglLossCadence = Math.max(4, Math.floor(terminalTabCount / 2)); + + activeBridge.captureBaseline(); + try { + let operationStartedAt = performance.now(); + activeBridge.replaceWithGeneratedTerminalLayout( + terminalTabCount, + terminalPanesPerTab, + ); + await nextFrame(); + recordOperationDuration( + "terminal-heavy", + "replace-generated-terminal-layout", + -1, + operationStartedAt, + ); + terminalActionCounts["replace-generated-terminal-layout"] = 1; + operationCount += 1; + reportProgress("terminal-heavy", 0, terminalIterations, true); + + for (let index = 0; index < terminalIterations; index += 1) { + operationStartedAt = performance.now(); + let actionLabel = "switch-write-terminal-output"; + try { + activeBridge.switchTab(index % terminalTabCount); + terminalActionCounts["switch-tab"] = + (terminalActionCounts["switch-tab"] ?? 0) + 1; + await nextFrame(); + if (options.intervalMs > 0) { + await sleep(options.intervalMs); + } + + const writeResult = await withTimeout( + activeBridge.writeTerminalStressOutput( + index, + options.terminalLines, + options.terminalPayloadBytes, + ), + 15_000, + "write-terminal-stress-output", + ); + terminalActionCounts["write-output"] = + (terminalActionCounts["write-output"] ?? 0) + 1; + if ( + index >= terminalTabCount && + writeResult.failedCount > 0 && + terminalActionErrors.length < 20 + ) { + terminalActionErrors.push( + `terminal write ${index} reached ${writeResult.writtenCount}/${writeResult.terminalCount} runtimes (${writeResult.byteLength} bytes)`, + ); + } + + if ( + options.forceTerminalWebglLoss && + index > 0 && + index % webglLossCadence === 0 + ) { + actionLabel = "switch-write-force-webgl-context-loss"; + const lossResult = activeBridge.forceTerminalWebglContextLoss(); + terminalWebglContextLosses.push({ + index, + ...lossResult, + }); + terminalActionCounts["force-webgl-context-loss"] = + (terminalActionCounts["force-webgl-context-loss"] ?? 0) + 1; + operationCount += 1; + } + } catch (error) { + terminalActionErrors.push( + `terminal action ${index} failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + recordOperationDuration( + "terminal-heavy", + actionLabel, + index, + operationStartedAt, + ); + operationCount += 1; + reportProgress("terminal-heavy", index + 1, terminalIterations); + } + + if ( + options.forceTerminalWebglLoss && + terminalWebglContextLosses.some( + (result) => result.lostContextCount > 0, + ) + ) { + await sleep(webglContextRecoverySettleMs); + } + + terminalStressSummary = activeBridge.getTerminalStressSummary(); + } finally { + activeBridge.restoreBaseline(); + terminalActionCounts["restore-baseline"] = 1; + operationCount += 1; + } + } + await sleep(options.settleMs); + + const durationMs = performance.now() - startedAt; + const maxLongTaskDurationMs = longTasks.reduce( + (max, task) => Math.max(max, task.duration), + 0, + ); + return { + scenario: options.scenario, + iterations: options.iterations, + operationCount, + targetCount: targets.length, + activationModeCounts, + routeCount, + routeIterations: shouldRunRouteSweep ? routeIterations : 0, + routesVisited: Array.from(new Set(routesVisited)), + heavyIterations: shouldRunWorkspaceHeavy ? heavyIterations : 0, + heavyActionCounts, + heavyActionErrors: heavyActionErrors.slice(0, 20), + heavyActionCatalogue, + terminalIterations: shouldRunTerminalHeavy ? terminalIterations : 0, + terminalActionCounts, + terminalActionErrors: terminalActionErrors.slice(0, 20), + terminalStressSummary, + terminalWebglContextLosses: terminalWebglContextLosses.slice(-20), + workspaceSummary, + reactProbeSummary: reactProbe.getSummary(), + durationMs, + maxHeartbeatDelayMs, + heartbeatDelaySamples: heartbeatDelaySamples.slice(-20), + maxLongTaskDurationMs, + longTaskCount: longTasks.length, + longTasks: longTasks + .slice() + .sort((left, right) => right.duration - left.duration) + .slice(0, 10), + slowOperations: slowOperations + .slice() + .sort((left, right) => right.durationMs - left.durationMs) + .slice(0, 20), + errorCount: errors.length, + errors: errors.slice(0, 20), + startMemory, + endMemory: stressWindow.performance.memory ?? null, + finalLocation: window.location.href, + }; + })().finally(() => { + clearInterval(heartbeat); + reactProbe.cleanup(); + longTaskObserver?.disconnect(); + window.removeEventListener("error", onError); + window.removeEventListener("unhandledrejection", onUnhandledRejection); + }); +} + +async function main() { + const args = parseArgs(Bun.argv.slice(2)); + if (args.help) { + console.log(usage()); + return; + } + + const target = await getRendererTarget(args); + if (!args.json) { + console.log( + `[stress:renderer] attaching to ${target.title || target.url || "renderer"}`, + ); + } + const cdp = await CdpClient.connect( + target.webSocketDebuggerUrl ?? "", + args.timeoutMs, + ); + + try { + await cdp.send("Runtime.enable", {}, args.timeoutMs); + await cdp.send("Performance.enable", {}, args.timeoutMs); + const startDomCounters = await cdp + .send("Memory.getDOMCounters", {}, args.timeoutMs) + .catch((error) => ({ error: String(error) })); + if (args.profileCpu) { + await cdp.send("Profiler.enable", {}, args.timeoutMs); + await cdp.send("Profiler.setSamplingInterval", { interval: 1000 }); + await cdp.send("Profiler.start", {}, args.timeoutMs); + } + const removeConsoleListener = cdp.on( + "Runtime.consoleAPICalled", + (params) => { + if (args.json) return; + const event = params as RuntimeConsoleApiCalledEvent; + const firstArg = event.args?.[0]; + if (firstArg?.value !== "[stress:renderer:progress]") return; + const payload = event.args?.[1]?.value; + if (typeof payload !== "string") return; + try { + const progress = JSON.parse(payload) as { + phase?: string; + index?: number; + total?: number; + operationCount?: number; + elapsedMs?: number; + }; + console.log( + `[stress:renderer] ${progress.phase}: ${progress.index}/${progress.total} operations=${progress.operationCount} elapsed=${progress.elapsedMs}ms`, + ); + } catch { + console.log(`[stress:renderer] ${payload}`); + } + }, + ); + const evaluation = await cdp.send( + "Runtime.evaluate", + { + expression: `(${rendererStress.toString()})(${JSON.stringify({ + scenario: args.scenario, + iterations: args.iterations, + routeIterations: args.routeIterations, + heavyIterations: args.heavyIterations, + terminalIterations: args.terminalIterations, + terminalTabCount: args.terminalTabCount, + terminalPanesPerTab: args.terminalPanesPerTab, + terminalLines: args.terminalLines, + terminalPayloadBytes: args.terminalPayloadBytes, + forceTerminalWebglLoss: args.forceTerminalWebglLoss, + includeTerminalAction: args.includeTerminalAction, + reactProbe: args.reactProbe, + progressEvery: args.progressEvery, + intervalMs: args.intervalMs, + settleMs: args.settleMs, + selector: args.selector, + workspaceIds: args.workspaceIds, + })})`, + awaitPromise: true, + returnByValue: true, + }, + args.timeoutMs, + ); + removeConsoleListener(); + const cpuProfileTopFrames = args.profileCpu + ? await cdp + .send("Profiler.stop", {}, args.timeoutMs) + .then((result) => + result.profile ? summarizeCpuProfile(result.profile) : [], + ) + .catch((error) => [{ error: String(error) }]) + : null; + + if (evaluation.exceptionDetails) { + throw new Error( + evaluation.exceptionDetails.exception?.description ?? + evaluation.exceptionDetails.text ?? + "Renderer stress script threw", + ); + } + + const summary = evaluation.result?.value as RendererStressResult; + const cdpMetrics = await cdp + .send("Performance.getMetrics", {}, args.timeoutMs) + .catch((error) => ({ error: String(error) })); + const endDomCounters = await cdp + .send("Memory.getDOMCounters", {}, args.timeoutMs) + .catch((error) => ({ error: String(error) })); + const output = { + ...summary, + cdpMetrics, + cdpDomCounters: { + start: startDomCounters, + end: endDomCounters, + }, + cpuProfileTopFrames, + thresholds: { + maxHeartbeatDelayMs: args.maxHeartbeatDelayMs, + maxLongTaskMs: args.maxLongTaskMs, + }, + }; + + const failures: string[] = []; + if (summary.errorCount > 0) { + failures.push(`${summary.errorCount} renderer error(s) observed`); + } + if (summary.terminalActionErrors.length > 0) { + failures.push( + `${summary.terminalActionErrors.length} terminal stress error(s) observed`, + ); + } + if (summary.maxHeartbeatDelayMs > args.maxHeartbeatDelayMs) { + failures.push( + `heartbeat delay ${summary.maxHeartbeatDelayMs.toFixed( + 1, + )}ms exceeded ${args.maxHeartbeatDelayMs}ms`, + ); + } + if (summary.maxLongTaskDurationMs > args.maxLongTaskMs) { + failures.push( + `long task ${summary.maxLongTaskDurationMs.toFixed( + 1, + )}ms exceeded ${args.maxLongTaskMs}ms`, + ); + } + + if (args.json) { + console.log(JSON.stringify({ ...output, failures }, null, 2)); + } else { + console.log(JSON.stringify(output, null, 2)); + if (failures.length > 0) { + console.error(`[stress:renderer] failed: ${failures.join("; ")}`); + } else { + console.log("[stress:renderer] passed"); + } + } + + if (failures.length > 0) process.exitCode = 1; + } finally { + cdp.close(); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + console.error(""); + console.error(usage()); + process.exit(1); +}); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index c4913ecdeb2..5a0c146f013 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -53,6 +53,24 @@ import { MainWindow } from "./windows/main"; console.log("[main] Local database ready:", !!localDb); const IS_DEV = process.env.NODE_ENV === "development"; +const rendererStressCdpPort = + process.env.SUPERSET_RENDERER_STRESS_CDP_PORT?.trim(); + +if (IS_DEV && rendererStressCdpPort) { + if (/^\d+$/.test(rendererStressCdpPort)) { + app.commandLine.appendSwitch( + "remote-debugging-port", + rendererStressCdpPort, + ); + console.log( + `[main] Renderer stress CDP enabled on 127.0.0.1:${rendererStressCdpPort}`, + ); + } else { + console.warn( + `[main] Ignoring invalid SUPERSET_RENDERER_STRESS_CDP_PORT=${rendererStressCdpPort}`, + ); + } +} void applyShellEnvToProcess().catch((error) => { console.error("[main] Failed to apply shell environment:", error); diff --git a/apps/desktop/src/main/lib/extensions/index.ts b/apps/desktop/src/main/lib/extensions/index.ts index b49d48afbe9..0af5d81ba43 100644 --- a/apps/desktop/src/main/lib/extensions/index.ts +++ b/apps/desktop/src/main/lib/extensions/index.ts @@ -169,6 +169,7 @@ function resolveWebviewExtensionPath(): string | null { export async function loadReactDevToolsExtension(): Promise { if (env.NODE_ENV !== "development") return; + if (process.env.SUPERSET_RENDERER_STRESS_CDP_PORT) return; const extensionPath = resolveReactDevToolsPath(); if (!extensionPath) { diff --git a/apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts b/apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts index 1fe9dcc5f39..2181b86dc0a 100644 --- a/apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts +++ b/apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts @@ -9,7 +9,15 @@ export interface DiffStats { deletions: number; } -export function useDiffStats(workspaceId: string): DiffStats | null { +interface UseDiffStatsOptions { + enabled?: boolean; +} + +export function useDiffStats( + workspaceId: string, + options: UseDiffStatsOptions = {}, +): DiffStats | null { + const { enabled = true } = options; const hostUrl = useWorkspaceHostUrl(workspaceId); const queryClient = useQueryClient(); const queryKey = useMemo( @@ -17,12 +25,12 @@ export function useDiffStats(workspaceId: string): DiffStats | null { [hostUrl, workspaceId], ); - const { data: status } = useQuery({ + const { data: stats } = useQuery({ queryKey, - enabled: Boolean(workspaceId) && Boolean(hostUrl), + enabled: enabled && Boolean(workspaceId) && Boolean(hostUrl), queryFn: () => { if (!hostUrl) return null; - return getHostServiceClientByUrl(hostUrl).git.getStatus.query({ + return getHostServiceClientByUrl(hostUrl).git.getDiffStats.query({ workspaceId, }); }, @@ -34,22 +42,7 @@ export function useDiffStats(workspaceId: string): DiffStats | null { void queryClient.invalidateQueries({ queryKey }); }, [queryClient, queryKey]); - useWorkspaceEvent("git:changed", workspaceId, invalidate); - - return useMemo(() => { - if (!status) return null; - - const byPath = new Map(); - for (const file of status.againstBase) byPath.set(file.path, file); - for (const file of status.staged) byPath.set(file.path, file); - for (const file of status.unstaged) byPath.set(file.path, file); + useWorkspaceEvent("git:changed", workspaceId, invalidate, enabled); - let additions = 0; - let deletions = 0; - for (const file of byPath.values()) { - additions += file.additions; - deletions += file.deletions; - } - return { additions, deletions }; - }, [status]); + return stats ?? null; } diff --git a/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts b/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts index 0596ef443b4..6fee440a84c 100644 --- a/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts +++ b/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts @@ -13,18 +13,21 @@ import { useWorkspaceEvent } from "../useWorkspaceEvent"; * both `.git/` metadata writes and worktree file edits — no client-side * debounce needed. */ -export function useGitStatus(workspaceId: string) { +export function useGitStatus(workspaceId: string, enabled = true) { const utils = workspaceTrpc.useUtils(); const baseBranchQuery = workspaceTrpc.git.getBaseBranch.useQuery( { workspaceId }, - { staleTime: Number.POSITIVE_INFINITY, enabled: Boolean(workspaceId) }, + { + staleTime: Number.POSITIVE_INFINITY, + enabled: enabled && Boolean(workspaceId), + }, ); const baseBranch = baseBranchQuery.data?.baseBranch ?? null; const query = workspaceTrpc.git.getStatus.useQuery( { workspaceId, baseBranch: baseBranch ?? undefined }, - { refetchOnWindowFocus: true, enabled: Boolean(workspaceId) }, + { refetchOnWindowFocus: true, enabled: enabled && Boolean(workspaceId) }, ); const invalidate = useCallback(() => { @@ -35,7 +38,7 @@ export function useGitStatus(workspaceId: string) { void utils.git.getBaseBranch.invalidate({ workspaceId }); }, [utils, workspaceId]); - useWorkspaceEvent("git:changed", workspaceId, invalidate); + useWorkspaceEvent("git:changed", workspaceId, invalidate, enabled); return query; } diff --git a/apps/desktop/src/renderer/index.tsx b/apps/desktop/src/renderer/index.tsx index 3f951aa1b89..eeee7e80d08 100644 --- a/apps/desktop/src/renderer/index.tsx +++ b/apps/desktop/src/renderer/index.tsx @@ -42,6 +42,19 @@ const handleDeepLink = (path: string) => { console.log("[deep-link] Navigating to:", path); router.navigate({ to: path }); }; + +declare global { + interface Window { + __SUPERSET_RENDERER_STRESS_NAVIGATE__?: (path: string) => Promise; + } +} + +if (process.env.NODE_ENV === "development") { + window.__SUPERSET_RENDERER_STRESS_NAVIGATE__ = async (path: string) => { + await router.navigate({ to: path }); + }; +} + const ipcRenderer = window.ipcRenderer as typeof window.ipcRenderer | undefined; if (ipcRenderer) { ipcRenderer.on("deep-link-navigate", handleDeepLink); diff --git a/apps/desktop/src/renderer/lib/posthog.test.ts b/apps/desktop/src/renderer/lib/posthog.test.ts new file mode 100644 index 00000000000..2d4405825f2 --- /dev/null +++ b/apps/desktop/src/renderer/lib/posthog.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "bun:test"; +import { + buildPostHogInitConfig, + POSTHOG_SESSION_REPLAY_BLOCK_SELECTOR, +} from "./posthog"; + +describe("buildPostHogInitConfig", () => { + it("blocks terminal replay surfaces and disables canvas recording", () => { + const config = buildPostHogInitConfig(); + + expect(config.disable_session_recording).toBe(true); + expect(config.autocapture).toBe(false); + expect(config.disable_scroll_properties).toBe(true); + expect(POSTHOG_SESSION_REPLAY_BLOCK_SELECTOR).toContain(".xterm"); + expect(POSTHOG_SESSION_REPLAY_BLOCK_SELECTOR).toContain( + "[data-terminal-webgl-canvas]", + ); + expect(config.session_recording?.blockSelector).toBe( + POSTHOG_SESSION_REPLAY_BLOCK_SELECTOR, + ); + expect(config.session_recording?.captureCanvas).toEqual({ + recordCanvas: false, + canvasFps: 0, + canvasQuality: "0", + }); + }); +}); diff --git a/apps/desktop/src/renderer/lib/posthog.ts b/apps/desktop/src/renderer/lib/posthog.ts index 180e2ca11d2..fa6946e8407 100644 --- a/apps/desktop/src/renderer/lib/posthog.ts +++ b/apps/desktop/src/renderer/lib/posthog.ts @@ -1,30 +1,61 @@ +import type { PostHogConfig } from "posthog-js/dist/module.full.no-external"; import posthogFull from "posthog-js/dist/module.full.no-external"; import type { PostHog } from "posthog-js/react"; import { env } from "../env.renderer"; +import { + TERMINAL_SESSION_REPLAY_BLOCK_ATTRIBUTE, + TERMINAL_SESSION_REPLAY_BLOCK_CLASS, +} from "./terminal/terminal-session-replay"; // Cast to standard PostHog type for compatibility with posthog-js/react export const posthog = posthogFull as unknown as PostHog; -export function initPostHog() { - if (!env.NEXT_PUBLIC_POSTHOG_KEY) { - console.log("[posthog] No key configured, skipping"); - return; - } +export const POSTHOG_SESSION_REPLAY_BLOCK_SELECTOR = [ + `.${TERMINAL_SESSION_REPLAY_BLOCK_CLASS}`, + `[${TERMINAL_SESSION_REPLAY_BLOCK_ATTRIBUTE}]`, + "[data-ph-no-capture]", + "[data-terminal-webgl-canvas]", + ".xterm", + ".xterm-screen", + ".xterm-viewport", + ".xterm-helper-textarea", +].join(", "); - posthogFull.init(env.NEXT_PUBLIC_POSTHOG_KEY, { +export function buildPostHogInitConfig(): Partial { + return { api_host: env.NEXT_PUBLIC_POSTHOG_HOST, defaults: "2025-11-30", + autocapture: false, capture_pageview: false, capture_pageleave: false, capture_exceptions: true, + disable_scroll_properties: true, + disable_session_recording: true, person_profiles: "identified_only", persistence: "localStorage", debug: false, + session_recording: { + blockSelector: POSTHOG_SESSION_REPLAY_BLOCK_SELECTOR, + captureCanvas: { + recordCanvas: false, + canvasFps: 0, + canvasQuality: "0", + }, + }, loaded: (ph) => { ph.register({ app_name: "desktop", platform: window.navigator.platform, }); }, - }); + }; +} + +export function initPostHog() { + if (!env.NEXT_PUBLIC_POSTHOG_KEY) { + console.log("[posthog] No key configured, skipping"); + return; + } + + posthogFull.init(env.NEXT_PUBLIC_POSTHOG_KEY, buildPostHogInitConfig()); } diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts b/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts index 85faa897cdb..82180a3609e 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts @@ -1,29 +1,28 @@ 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 { createTerminalImageAddonController } from "./terminal-image-addon-controller"; +import { createTerminalWebglAddonController } from "./terminal-webgl-addon-controller"; export interface LoadAddonsResult { searchAddon: SearchAddon; progressAddon: ProgressAddon; + enableImageAddon: () => void; + disableImageAddon: () => void; + enableWebglAddon: () => void; + disableWebglAddon: () => void; dispose: () => void; } -// Once WebGL fails, skip it for all subsequent runtimes (VS Code pattern). -let suggestedRendererType: "webgl" | "dom" | undefined; - /** * Load optional addons onto an already-opened terminal. Returns a cleanup - * function and addon instances. WebGL is deferred to rAF to avoid - * racing with xterm's post-open viewport sync. + * function and addon instances. */ export function loadAddons(terminal: XTerm): LoadAddonsResult { - let disposed = false; - let webglAddon: WebglAddon | null = null; + const imageAddonController = createTerminalImageAddonController(terminal); + const webglAddonController = createTerminalWebglAddonController(terminal); terminal.loadAddon(new ClipboardAddon()); @@ -31,45 +30,22 @@ export function loadAddons(terminal: XTerm): LoadAddonsResult { terminal.loadAddon(unicode11); terminal.unicode.activeVersion = "11"; - terminal.loadAddon(new ImageAddon()); - const searchAddon = new SearchAddon(); terminal.loadAddon(searchAddon); const progressAddon = new ProgressAddon(); terminal.loadAddon(progressAddon); - try { - terminal.loadAddon(new LigaturesAddon()); - } catch {} - - const rafId = requestAnimationFrame(() => { - if (disposed || suggestedRendererType === "dom") return; - - try { - webglAddon = new WebglAddon(); - webglAddon.onContextLoss(() => { - webglAddon?.dispose(); - webglAddon = null; - terminal.refresh(0, terminal.rows - 1); - }); - terminal.loadAddon(webglAddon); - } catch { - suggestedRendererType = "dom"; - webglAddon = null; - } - }); - return { searchAddon, progressAddon, + enableImageAddon: imageAddonController.enable, + disableImageAddon: imageAddonController.disable, + enableWebglAddon: webglAddonController.enable, + disableWebglAddon: webglAddonController.disable, dispose: () => { - disposed = true; - cancelAnimationFrame(rafId); - try { - webglAddon?.dispose(); - } catch {} - webglAddon = null; + imageAddonController.dispose(); + webglAddonController.dispose(); }, }; } diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-image-addon-controller.test.ts b/apps/desktop/src/renderer/lib/terminal/terminal-image-addon-controller.test.ts new file mode 100644 index 00000000000..2679dcfecdb --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-image-addon-controller.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import type { Terminal as XTerm } from "@xterm/xterm"; + +const constructedOptions: unknown[] = []; +const disposedAddons: FakeImageAddon[] = []; + +class FakeImageAddon { + constructor(options: unknown) { + constructedOptions.push(options); + } + + dispose() { + disposedAddons.push(this); + } +} + +mock.module("@xterm/addon-image", () => ({ + ImageAddon: FakeImageAddon, +})); + +const { IMAGE_PROTOCOL_ADDON_OPTIONS, createTerminalImageAddonController } = + await import("./terminal-image-addon-controller"); + +function makeTerminal(options: { throwOnLoad?: boolean } = {}) { + const loadedAddons: FakeImageAddon[] = []; + const loadAddon = mock((addon: FakeImageAddon) => { + loadedAddons.push(addon); + if (options.throwOnLoad) { + throw new Error("load failed"); + } + }); + + return { + loadedAddons, + terminal: { loadAddon } as unknown as XTerm, + loadAddon, + }; +} + +beforeEach(() => { + constructedOptions.length = 0; + disposedAddons.length = 0; +}); + +describe("IMAGE_PROTOCOL_ADDON_OPTIONS", () => { + it("does not enable eager-WASM image protocols", () => { + expect(IMAGE_PROTOCOL_ADDON_OPTIONS.sixelSupport).toBe(false); + expect(IMAGE_PROTOCOL_ADDON_OPTIONS.iipSupport).toBe(false); + expect(IMAGE_PROTOCOL_ADDON_OPTIONS.kittySupport).toBe(true); + }); +}); + +describe("createTerminalImageAddonController", () => { + it("loads the image addon only while enabled", () => { + const { terminal, loadAddon, loadedAddons } = makeTerminal(); + const controller = createTerminalImageAddonController(terminal); + + controller.enable(); + controller.enable(); + + expect(loadAddon).toHaveBeenCalledTimes(1); + expect(constructedOptions).toEqual([IMAGE_PROTOCOL_ADDON_OPTIONS]); + + controller.disable(); + controller.disable(); + + expect(disposedAddons).toEqual([loadedAddons[0]]); + }); + + it("disposes a partially loaded addon and stops retrying after load failure", () => { + const warn = mock(() => {}); + const previousWarn = console.warn; + console.warn = warn; + try { + const { terminal, loadAddon, loadedAddons } = makeTerminal({ + throwOnLoad: true, + }); + const controller = createTerminalImageAddonController(terminal); + + controller.enable(); + controller.enable(); + + expect(loadAddon).toHaveBeenCalledTimes(1); + expect(disposedAddons).toEqual([loadedAddons[0]]); + expect(warn).toHaveBeenCalledTimes(1); + } finally { + console.warn = previousWarn; + } + }); +}); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-image-addon-controller.ts b/apps/desktop/src/renderer/lib/terminal/terminal-image-addon-controller.ts new file mode 100644 index 00000000000..01fa9c43b39 --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-image-addon-controller.ts @@ -0,0 +1,68 @@ +import { ImageAddon } from "@xterm/addon-image"; +import type { Terminal as XTerm } from "@xterm/xterm"; + +type ImageAddonOptions = NonNullable< + ConstructorParameters[0] +>; + +export const IMAGE_PROTOCOL_ADDON_OPTIONS = { + enableSizeReports: true, + pixelLimit: 4 * 1024 * 1024, + storageLimit: 16, + showPlaceholder: true, + // These protocols allocate WASM decoders when the addon is activated. + // Keep retained/parked terminals cheap; Kitty allocates only per image stream. + sixelSupport: false, + sixelScrolling: true, + sixelPaletteLimit: 256, + sixelSizeLimit: 4 * 1024 * 1024, + iipSupport: false, + iipSizeLimit: 4 * 1024 * 1024, + kittySupport: true, + kittySizeLimit: 8 * 1024 * 1024, +} satisfies ImageAddonOptions; + +export interface TerminalImageAddonController { + enable: () => void; + disable: () => void; + dispose: () => void; +} + +export function createTerminalImageAddonController( + terminal: XTerm, +): TerminalImageAddonController { + let imageAddon: ImageAddon | null = null; + let loadFailed = false; + + const disable = () => { + const addon = imageAddon; + if (!addon) return; + imageAddon = null; + try { + addon.dispose(); + } catch {} + }; + + return { + enable: () => { + if (imageAddon || loadFailed) return; + + let nextAddon: ImageAddon | null = null; + try { + nextAddon = new ImageAddon(IMAGE_PROTOCOL_ADDON_OPTIONS); + terminal.loadAddon(nextAddon); + imageAddon = nextAddon; + } catch (error) { + loadFailed = true; + if (nextAddon) { + try { + nextAddon.dispose(); + } catch {} + } + console.warn("[Terminal] Disabled image protocol addon:", error); + } + }, + disable, + dispose: disable, + }; +} diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-parking.ts b/apps/desktop/src/renderer/lib/terminal/terminal-parking.ts index 86e57f5dc7a..c2f7b5135be 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-parking.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-parking.ts @@ -19,11 +19,14 @@ export function getTerminalParkingContainer(): HTMLDivElement { el.id = PARKING_CONTAINER_ID; el.setAttribute("inert", ""); el.setAttribute("aria-hidden", "true"); + el.style.display = "none"; el.style.position = "fixed"; el.style.left = "-9999px"; el.style.top = "-9999px"; - el.style.width = "100vw"; - el.style.height = "100vh"; + el.style.width = "0"; + el.style.height = "0"; + el.style.contain = "strict"; + el.style.setProperty("content-visibility", "hidden"); el.style.overflow = "hidden"; el.style.pointerEvents = "none"; document.body.appendChild(el); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts new file mode 100644 index 00000000000..692d75ff4e7 --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.test.ts @@ -0,0 +1,351 @@ +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, + ) => { + runtime.terminal.write(data, callback); + }, +); + +mock.module( + "./terminal-runtime", + (): Partial => ({ + attachToContainer: attachToContainerMock, + createRuntime: createRuntimeMock, + detachFromContainer: detachFromContainerMock, + disposeRuntime: disposeRuntimeMock, + focusRuntime: focusRuntimeMock, + 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, + _enableImageAddon: null, + _disableImageAddon: null, + _enableWebglAddon: null, + _disableWebglAddon: null, + _disposeAddons: null, + _disposeImagePasteFallback: null, + lastContainerWidth: 0, + lastContainerHeight: 0, + _outputQueue: [], + _outputEnqueued: false, + _outputQueuedBytes: 0, + }; +} + +function createContainer(): HTMLDivElement { + return {} as HTMLDivElement; +} + +function createCanvas(options: { + context?: WebGL2RenderingContext | null; +}): HTMLCanvasElement & { + getContext: ReturnType; +} { + const canvas = { + 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, + 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("does not probe unmarked canvases during stress WebGL loss", () => { + const unmarkedCanvas = 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 = [unmarkedCanvas]; + + const result = terminalRuntimeRegistry.forceWebglContextLossForStress( + "terminal-1", + "workspace-a", + ); + + expect(result).toEqual({ + terminalCount: 1, + canvasCount: 1, + webglContextCount: 0, + lostContextCount: 0, + unsupportedContextCount: 0, + }); + expect(unmarkedCanvas.getContext).not.toHaveBeenCalled(); + }); + + it("accepts stress output without waiting for xterm write completion", async () => { + terminalRuntimeRegistry.mount( + "terminal-1", + createContainer(), + appearance, + "workspace-a", + ); + + const accepted = await terminalRuntimeRegistry.writeForStress( + "terminal-1", + "large output", + "workspace-a", + ); + + expect(accepted).toBe(true); + expect(writeRuntimeOutputMock).toHaveBeenCalledWith( + createdRuntimes[0], + "large output", + ); + }); +}); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts index c9dad761712..47d1112a829 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -11,9 +11,12 @@ import { createRuntime, detachFromContainer, disposeRuntime, + focusRuntime, type TerminalRuntime, updateRuntimeAppearance, + writeRuntimeOutput, } from "./terminal-runtime"; +import { isTerminalWebglCanvas } from "./terminal-webgl-addon-controller"; import { type ConnectionState, clearLogs, @@ -37,6 +40,75 @@ interface RegistryEntry { pendingLinkHandlers: TerminalLinkHandlers | null; } +export interface TerminalWebglContextLossResult { + terminalCount: number; + canvasCount: number; + webglContextCount: number; + lostContextCount: number; + unsupportedContextCount: number; +} + +export interface TerminalRuntimeStressDebugInfo { + terminalId: string; + instanceId: string; + hasRuntime: boolean; + isAttached: boolean; + isParked: boolean; + cols: number | null; + rows: number | null; + canvasCount: number; + connectionState: ConnectionState; +} + +interface WebglLoseContextExtension { + loseContext: () => void; +} + +function getTerminalWebglContext( + canvas: HTMLCanvasElement, +): WebGL2RenderingContext | null { + if (!isTerminalWebglCanvas(canvas)) return null; + return canvas.getContext("webgl2") as WebGL2RenderingContext | null; +} + +function forceRuntimeWebglContextLoss( + runtime: TerminalRuntime, +): Omit { + const canvases = Array.from(runtime.wrapper.querySelectorAll("canvas")); + let webglContextCount = 0; + let lostContextCount = 0; + let unsupportedContextCount = 0; + + for (const canvas of canvases) { + const context = getTerminalWebglContext(canvas); + if (!context) continue; + + webglContextCount += 1; + const extension = context.getExtension( + "WEBGL_lose_context", + ) as WebglLoseContextExtension | null; + + if (!extension) { + unsupportedContextCount += 1; + continue; + } + + try { + extension.loseContext(); + lostContextCount += 1; + } catch { + unsupportedContextCount += 1; + } + } + + return { + canvasCount: canvases.length, + webglContextCount, + lostContextCount, + unsupportedContextCount, + }; +} + class TerminalRuntimeRegistryImpl { private entries = new Map(); private entryKeysByTerminalId = new Map>(); @@ -99,6 +171,19 @@ class TerminalRuntimeRegistryImpl { .filter((entry): entry is RegistryEntry => Boolean(entry)); } + private listEntries( + terminalId?: string, + instanceId?: string, + ): RegistryEntry[] { + if (terminalId && instanceId) { + const entry = this.getEntry(terminalId, instanceId); + return entry ? [entry] : []; + } + if (terminalId) return this.getEntries(terminalId); + if (instanceId) return []; + return Array.from(this.entries.values()); + } + private deleteEntry(entry: RegistryEntry) { const key = this.getEntryKey(entry.terminalId, entry.instanceId); this.entries.delete(key); @@ -174,7 +259,10 @@ class TerminalRuntimeRegistryImpl { connect(terminalId: string, wsUrl: string, instanceId = terminalId) { const entry = this.getEntry(terminalId, instanceId); if (!entry?.runtime) return; - connect(entry.transport, entry.runtime.terminal, wsUrl); + const { runtime } = entry; + connect(entry.transport, runtime.terminal, wsUrl, (data) => { + writeRuntimeOutput(runtime, data); + }); } /** @@ -194,7 +282,10 @@ class TerminalRuntimeRegistryImpl { if (!entry?.runtime) return; if (entry.transport.connectionState === "disconnected") return; if (entry.transport.currentUrl === wsUrl) return; - connect(entry.transport, entry.runtime.terminal, wsUrl); + const { runtime } = entry; + connect(entry.transport, runtime.terminal, wsUrl, (data) => { + writeRuntimeOutput(runtime, data); + }); } /** @@ -226,6 +317,13 @@ class TerminalRuntimeRegistryImpl { detachFromContainer(entry.runtime); } + focus(terminalId: string, instanceId = terminalId) { + const entry = this.getEntry(terminalId, instanceId); + if (!entry?.runtime) return; + + focusRuntime(entry.runtime); + } + updateAppearance( terminalId: string, appearance: TerminalAppearance, @@ -311,6 +409,87 @@ class TerminalRuntimeRegistryImpl { sendInput(entry.transport, data); } + writeForStress( + terminalId: string, + data: string, + instanceId?: string, + timeoutMs = 5000, + ): Promise { + const entry = this.getEntry(terminalId, instanceId); + if (!entry?.runtime) return Promise.resolve(false); + const { runtime } = entry; + + return new Promise((resolve) => { + let settled = false; + let timeoutId: ReturnType | null = null; + const settle = (value: boolean) => { + if (settled) return; + settled = true; + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + resolve(value); + }; + + timeoutId = setTimeout(() => settle(false), timeoutMs); + try { + writeRuntimeOutput(runtime, data); + queueMicrotask(() => settle(true)); + } catch { + settle(false); + } + }); + } + + forceWebglContextLossForStress( + terminalId?: string, + instanceId?: string, + ): TerminalWebglContextLossResult { + const result: TerminalWebglContextLossResult = { + terminalCount: 0, + canvasCount: 0, + webglContextCount: 0, + lostContextCount: 0, + unsupportedContextCount: 0, + }; + + for (const entry of this.listEntries(terminalId, instanceId)) { + if (!entry.runtime) continue; + result.terminalCount += 1; + const runtimeResult = forceRuntimeWebglContextLoss(entry.runtime); + result.canvasCount += runtimeResult.canvasCount; + result.webglContextCount += runtimeResult.webglContextCount; + result.lostContextCount += runtimeResult.lostContextCount; + result.unsupportedContextCount += runtimeResult.unsupportedContextCount; + } + + return result; + } + + getStressDebugInfo( + terminalId?: string, + instanceId?: string, + ): TerminalRuntimeStressDebugInfo[] { + return this.listEntries(terminalId, instanceId).map((entry) => { + const runtime = entry.runtime; + return { + terminalId: entry.terminalId, + instanceId: entry.instanceId, + hasRuntime: Boolean(runtime), + isAttached: Boolean(runtime?.container), + isParked: Boolean( + runtime && !runtime.container && runtime.wrapper.isConnected, + ), + cols: runtime?.terminal.cols ?? null, + rows: runtime?.terminal.rows ?? null, + canvasCount: runtime + ? runtime.wrapper.querySelectorAll("canvas").length + : 0, + connectionState: entry.transport.connectionState, + }; + }); + } + findNext(terminalId: string, query: string, instanceId?: string): boolean { const entry = this.getEntry(terminalId, instanceId); return entry?.runtime?.searchAddon?.findNext(query) ?? false; diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts index fd85b46761d..0797ebb76a6 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -9,6 +9,7 @@ import { loadAddons } from "./terminal-addons"; import { installImagePasteFallback } from "./terminal-image-paste-fallback"; import { installTerminalKeyEventHandler } from "./terminal-key-event-handler"; import { getTerminalParkingContainer } from "./terminal-parking"; +import { markTerminalSessionReplayBlocked } from "./terminal-session-replay"; const SERIALIZE_SCROLLBACK = 1000; const STORAGE_KEY_PREFIX = "terminal-buffer:"; @@ -16,6 +17,23 @@ 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; @@ -30,8 +48,148 @@ export interface TerminalRuntime { _disposeResizeObserver: (() => void) | null; lastCols: number; lastRows: number; + _enableImageAddon: (() => void) | null; + _disableImageAddon: (() => void) | null; + _enableWebglAddon: (() => void) | null; + _disableWebglAddon: (() => void) | null; _disposeAddons: (() => void) | null; _disposeImagePasteFallback: (() => void) | null; + lastContainerWidth: number; + lastContainerHeight: number; + _outputQueue: TerminalOutputQueueItem[]; + _outputEnqueued: boolean; + _outputQueuedBytes: number; +} + +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( @@ -116,13 +274,33 @@ function clearPersistedDimensions(terminalId: string) { } catch {} } -function hostIsVisible(container: HTMLDivElement | null): boolean { - if (!container) return false; - return container.clientWidth > 0 && container.clientHeight > 0; +function disposeTerminalAfterPendingRefresh(terminal: XTerm) { + const disposeTerminal = () => { + try { + terminal.dispose(); + } catch {} + }; + + if (typeof requestAnimationFrame !== "function") { + setTimeout(disposeTerminal, 0); + return; + } + + requestAnimationFrame(() => { + requestAnimationFrame(disposeTerminal); + }); +} + +function runtimeHasMeasuredHost(runtime: TerminalRuntime): boolean { + return Boolean( + runtime.container && + runtime.lastContainerWidth > 0 && + runtime.lastContainerHeight > 0, + ); } function measureAndResize(runtime: TerminalRuntime): boolean { - if (!hostIsVisible(runtime.container)) return false; + if (!runtimeHasMeasuredHost(runtime)) return false; const { terminal } = runtime; const buffer = terminal.buffer.active; const wasPinnedToBottom = buffer.viewportY >= buffer.baseY; @@ -153,38 +331,66 @@ function createResizeScheduler( onResize?: () => void, ): { observe: ResizeObserverCallback; + schedule: (delayMs?: number) => void; dispose: () => void; } { let timeoutId: ReturnType | null = null; + let rafId: number | null = null; + + const clearFrame = () => { + if (rafId === null) return; + cancelAnimationFrame(rafId); + rafId = null; + }; const dispose = () => { if (timeoutId !== null) { clearTimeout(timeoutId); timeoutId = null; } + clearFrame(); }; const run = () => { timeoutId = null; + rafId = null; const changed = measureAndResize(runtime); if (changed) onResize?.(); }; + const schedule = (delayMs = RESIZE_DEBOUNCE_MS) => { + dispose(); + timeoutId = setTimeout(() => { + timeoutId = null; + if (typeof requestAnimationFrame !== "function") { + run(); + return; + } + rafId = requestAnimationFrame(run); + }, delayMs); + }; + const observe: ResizeObserverCallback = (entries) => { - if ( - entries.some( - (entry) => - entry.contentRect.width <= 0 || entry.contentRect.height <= 0, - ) - ) { - dispose(); - return; + let changed = false; + for (const entry of entries) { + const { width, height } = entry.contentRect; + if (width <= 0 || height <= 0) { + dispose(); + return; + } + if ( + width !== runtime.lastContainerWidth || + height !== runtime.lastContainerHeight + ) { + changed = true; + runtime.lastContainerWidth = width; + runtime.lastContainerHeight = height; + } } - dispose(); - timeoutId = setTimeout(run, RESIZE_DEBOUNCE_MS); + if (changed) schedule(); }; - return { observe, dispose }; + return { observe, schedule, dispose }; } export function createRuntime( @@ -205,6 +411,7 @@ export function createRuntime( const wrapper = document.createElement("div"); wrapper.style.width = "100%"; wrapper.style.height = "100%"; + markTerminalSessionReplayBlocked(wrapper); terminal.open(wrapper); installTerminalKeyEventHandler(terminal); @@ -236,11 +443,39 @@ export function createRuntime( _disposeResizeObserver: null, lastCols: cols, lastRows: rows, + _enableImageAddon: addonsResult.enableImageAddon, + _disableImageAddon: addonsResult.disableImageAddon, + _enableWebglAddon: addonsResult.enableWebglAddon, + _disableWebglAddon: addonsResult.disableWebglAddon, _disposeAddons: addonsResult.dispose, _disposeImagePasteFallback: disposeImagePasteFallback, + lastContainerWidth: 0, + lastContainerHeight: 0, + _outputQueue: [], + _outputEnqueued: false, + _outputQueuedBytes: 0, }; } +export function writeRuntimeOutput( + runtime: TerminalRuntime, + data: TerminalOutputData, + callback?: () => void, +) { + const items = splitOutputData(data, callback); + 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, @@ -258,7 +493,7 @@ export function attachToContainer( runtime.container = container; container.appendChild(runtime.wrapper); - if (measureAndResize(runtime)) onResize?.(); + runtime._enableImageAddon?.(); runtime._disposeResizeObserver?.(); runtime._disposeResizeObserver = null; @@ -269,9 +504,37 @@ export function attachToContainer( runtime.resizeObserver = observer; runtime._disposeResizeObserver = scheduler.dispose; + if (runtime._outputQueue.length > 0 && !runtime._outputEnqueued) { + runtime._outputEnqueued = true; + runtimesWithQueuedOutput.add(runtime); + scheduleQueuedOutputFlush(); + } + runtime._enableWebglAddon?.(); +} + +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); @@ -279,6 +542,8 @@ export function detachFromContainer(runtime: TerminalRuntime) { runtime._disposeResizeObserver = null; runtime.resizeObserver?.disconnect(); runtime.resizeObserver = null; + runtime._disableImageAddon?.(); + runtime._disableWebglAddon?.(); // Park instead of .remove() so xterm survives the React unmount — // see getTerminalParkingContainer. getTerminalParkingContainer().appendChild(runtime.wrapper); @@ -299,7 +564,7 @@ export function updateRuntimeAppearance( if (fontChanged) { terminal.options.fontFamily = appearance.fontFamily; terminal.options.fontSize = appearance.fontSize; - if (hostIsVisible(runtime.container)) { + if (runtimeHasMeasuredHost(runtime)) { measureAndResize(runtime); } } @@ -318,14 +583,19 @@ export function disposeRuntime( runtime._disposeImagePasteFallback = null; runtime._disposeAddons?.(); runtime._disposeAddons = null; + runtime._enableImageAddon = null; + runtime._disableImageAddon = null; + runtime._enableWebglAddon = null; + runtime._disableWebglAddon = null; + clearQueuedRuntimeOutput(runtime); runtime._disposeResizeObserver?.(); runtime._disposeResizeObserver = null; runtime.resizeObserver?.disconnect(); runtime.resizeObserver = null; runtime.wrapper.remove(); - runtime.terminal.dispose(); if (clearPersistedState) { clearPersistedBuffer(runtime.terminalId); clearPersistedDimensions(runtime.terminalId); } + disposeTerminalAfterPendingRefresh(runtime.terminal); } diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-session-replay.ts b/apps/desktop/src/renderer/lib/terminal/terminal-session-replay.ts new file mode 100644 index 00000000000..42d67eac80e --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-session-replay.ts @@ -0,0 +1,17 @@ +export const TERMINAL_SESSION_REPLAY_BLOCK_CLASS = "ph-no-capture"; +export const TERMINAL_SESSION_REPLAY_BLOCK_ATTRIBUTE = + "data-terminal-replay-blocked"; + +type ReplayBlockedElement = Element & { + classList?: { + add?: (...tokens: string[]) => void; + }; + setAttribute?: (qualifiedName: string, value: string) => void; +}; + +export function markTerminalSessionReplayBlocked(element: Element): void { + const target = element as ReplayBlockedElement; + target.classList?.add?.(TERMINAL_SESSION_REPLAY_BLOCK_CLASS); + target.setAttribute?.("data-ph-no-capture", "true"); + target.setAttribute?.(TERMINAL_SESSION_REPLAY_BLOCK_ATTRIBUTE, "true"); +} diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon-controller.test.ts b/apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon-controller.test.ts new file mode 100644 index 00000000000..048437479e5 --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon-controller.test.ts @@ -0,0 +1,206 @@ +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 { + createTerminalWebglAddonController, + isTerminalWebglCanvas, + resetTerminalWebglAddonStateForTesting, +} = await import("./terminal-webgl-addon-controller"); + +const waitForWebglEnable = () => + new Promise((resolve) => setTimeout(resolve, 275)); + +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); + }), + }, + 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("createTerminalWebglAddonController", () => { + beforeEach(() => { + disposedAddons.length = 0; + resetTerminalWebglAddonStateForTesting(); + }); + + it("falls back to DOM immediately on terminal WebGL context loss", async () => { + const restoreAnimationFrames = installImmediateAnimationFrames(); + const previousInfo = console.info; + console.info = mock(() => {}); + + try { + const { + loadedAddons, + lossTarget, + terminal, + loadAddon, + refresh, + webglCanvas, + webglCanvasAttributes, + webglCanvasClasses, + } = createFakeTerminal(); + const controller = createTerminalWebglAddonController(terminal); + + controller.enable(); + await waitForWebglEnable(); + + 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("data-terminal-webgl-canvas")).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(console.info).toHaveBeenCalledTimes(1); + expect(disposedAddons).toEqual([loadedAddons[0]]); + expect(isTerminalWebglCanvas(webglCanvas)).toBe(false); + expect(refresh).toHaveBeenCalledWith(0, 9); + + controller.enable(); + + expect(loadAddon).toHaveBeenCalledTimes(1); + } finally { + console.info = previousInfo; + restoreAnimationFrames(); + } + }); + + it("keeps the global DOM fallback when a pending loss is disabled", async () => { + const restoreAnimationFrames = installImmediateAnimationFrames(); + const previousInfo = console.info; + console.info = mock(() => {}); + + try { + const { loadedAddons, lossTarget, terminal, loadAddon, webglCanvas } = + createFakeTerminal(); + const controller = createTerminalWebglAddonController(terminal); + + controller.enable(); + await waitForWebglEnable(); + + expect(loadAddon).toHaveBeenCalledTimes(1); + expect(isTerminalWebglCanvas(webglCanvas)).toBe(true); + + const lossEvent = new Event("webglcontextlost", { cancelable: true }); + lossTarget.dispatchEvent(lossEvent); + controller.disable(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(lossEvent.defaultPrevented).toBe(true); + expect(disposedAddons).toEqual([loadedAddons[0]]); + expect(isTerminalWebglCanvas(webglCanvas)).toBe(false); + + controller.enable(); + + expect(loadAddon).toHaveBeenCalledTimes(1); + } finally { + console.info = previousInfo; + restoreAnimationFrames(); + } + }); + + it("cancels pending WebGL setup when disabled before the stable attach window", async () => { + const restoreAnimationFrames = installImmediateAnimationFrames(); + + try { + const { terminal, loadAddon } = createFakeTerminal(); + const controller = createTerminalWebglAddonController(terminal); + + controller.enable(); + controller.disable(); + await waitForWebglEnable(); + + expect(loadAddon).toHaveBeenCalledTimes(0); + } finally { + restoreAnimationFrames(); + } + }); +}); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon-controller.ts b/apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon-controller.ts new file mode 100644 index 00000000000..7e461aa318f --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon-controller.ts @@ -0,0 +1,194 @@ +import { WebglAddon } from "@xterm/addon-webgl"; +import type { Terminal as XTerm } from "@xterm/xterm"; +import { markTerminalSessionReplayBlocked } from "./terminal-session-replay"; + +export interface TerminalWebglAddonController { + enable: () => void; + disable: () => void; + dispose: () => void; +} + +// Once WebGL fails, skip it for all subsequent runtimes (VS Code pattern). +let suggestedRendererType: "dom" | undefined; +const terminalWebglCanvases = new WeakSet(); +const WEBGL_ENABLE_STABILITY_DELAY_MS = 250; + +export function isTerminalWebglCanvas(canvas: HTMLCanvasElement): boolean { + return terminalWebglCanvases.has(canvas); +} + +export function resetTerminalWebglAddonStateForTesting(): void { + suggestedRendererType = undefined; +} + +function afterPendingXtermRefresh(callback: () => void): void { + if (typeof requestAnimationFrame !== "function") { + setTimeout(callback, 0); + return; + } + requestAnimationFrame(() => { + requestAnimationFrame(callback); + }); +} + +export function createTerminalWebglAddonController( + terminal: XTerm, +): TerminalWebglAddonController { + let disposed = false; + let webglAddon: WebglAddon | null = null; + let enableTimeoutId: ReturnType | null = null; + let enableRafId: number | null = null; + let fallbackTimeoutId: ReturnType | null = null; + let rootContextLossRemove: (() => void) | null = null; + let fallbackScheduled = false; + let markedWebglCanvases: HTMLCanvasElement[] = []; + + const clearEnableTimeout = () => { + if (enableTimeoutId === null) return; + clearTimeout(enableTimeoutId); + enableTimeoutId = null; + }; + + const clearEnableRaf = () => { + if (enableRafId === null) return; + cancelAnimationFrame(enableRafId); + enableRafId = null; + }; + + const removeContextLossListener = () => { + rootContextLossRemove?.(); + rootContextLossRemove = null; + }; + + const clearFallbackTimeout = () => { + if (fallbackTimeoutId === null) return; + clearTimeout(fallbackTimeoutId); + fallbackTimeoutId = null; + }; + + const unmarkWebglCanvases = () => { + for (const canvas of markedWebglCanvases) { + terminalWebglCanvases.delete(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) { + terminalWebglCanvases.add(canvas); + canvas.setAttribute("data-terminal-webgl-canvas", "true"); + markTerminalSessionReplayBlocked(canvas); + } + }; + + const repaint = () => { + try { + terminal.refresh(0, Math.max(0, terminal.rows - 1)); + } catch {} + }; + + const disposeAddon = (options: { markDomFallback: boolean }) => { + clearEnableTimeout(); + clearEnableRaf(); + 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"; + console.info("[terminal:webgl] context lost; falling back to DOM renderer"); + fallbackTimeoutId = setTimeout(() => { + fallbackTimeoutId = null; + disposeAddon({ markDomFallback: true }); + }, 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); + }; + }; + + return { + enable: () => { + if ( + disposed || + webglAddon || + enableTimeoutId !== null || + enableRafId !== null || + suggestedRendererType === "dom" + ) { + return; + } + + enableTimeoutId = setTimeout(() => { + enableTimeoutId = null; + if (disposed || webglAddon || suggestedRendererType === "dom") return; + + enableRafId = requestAnimationFrame(() => { + enableRafId = null; + if (disposed || webglAddon || suggestedRendererType === "dom") { + return; + } + + try { + const addon = new WebglAddon(); + addon.onContextLoss(fallbackToDom); + fallbackScheduled = false; + attachContextLossListener(); + terminal.loadAddon(addon); + webglAddon = addon; + markWebglCanvases(); + } catch { + console.warn( + "[terminal:webgl] failed to load; falling back to DOM renderer", + ); + suggestedRendererType = "dom"; + webglAddon = null; + removeContextLossListener(); + unmarkWebglCanvases(); + } + }); + }, WEBGL_ENABLE_STABILITY_DELAY_MS); + }, + disable: () => { + disposeAddon({ markDomFallback: false }); + }, + dispose: () => { + disposed = true; + disposeAddon({ markDomFallback: false }); + }, + }; +} diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts index a133122d71b..2b77fba0ba8 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts @@ -1,6 +1,10 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import type { Terminal as XTerm } from "@xterm/xterm"; -import { connect, createTransport } from "./terminal-ws-transport"; +import { + connect, + createTransport, + disposeTransport, +} from "./terminal-ws-transport"; type Listener = (event: { data?: unknown; @@ -66,21 +70,40 @@ const originalWebSocket = globalThis.WebSocket; function createMockTerminal( cols = 101, rows = 27, -): XTerm & { emitData(data: string): void } { - let onDataListener: ((data: string) => void) | null = null; +): XTerm & { + disposedInputListenerCount(): number; + emitData(data: string): void; +} { + const dataListeners: Array<{ + disposed: boolean; + listener: (data: string) => void; + }> = []; return { cols, rows, onData: (listener: (data: string) => void) => { - onDataListener = listener; - return { dispose() {} }; + const record = { disposed: false, listener }; + dataListeners.push(record); + return { + dispose() { + record.disposed = true; + }, + }; + }, + disposedInputListenerCount() { + return dataListeners.filter((record) => record.disposed).length; }, emitData(data: string) { - onDataListener?.(data); + for (const record of dataListeners) { + if (!record.disposed) record.listener(data); + } }, write() {}, writeln() {}, - } as unknown as XTerm & { emitData(data: string): void }; + } as unknown as XTerm & { + disposedInputListenerCount(): number; + emitData(data: string): void; + }; } beforeEach(() => { @@ -156,4 +179,66 @@ 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.titleListeners.size).toBe(0); + expect(transport.logListeners.size).toBe(0); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(titleListener).not.toHaveBeenCalled(); + expect(logListener).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts index 3daa24211ee..717bc4d81d9 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts @@ -4,6 +4,7 @@ import type { Terminal as XTerm } from "@xterm/xterm"; export type ConnectionState = "disconnected" | "connecting" | "open" | "closed"; export type TerminalLogLevel = "info" | "warn" | "error"; +export type TerminalOutputWriter = (data: Uint8Array) => void; export interface TerminalLogEntry { id: number; @@ -47,6 +48,8 @@ export interface TerminalTransport { _titleNotifyTimer: ReturnType | null; /** The xterm instance used for reconnection. */ _terminal: XTerm | null; + /** Writes PTY output into xterm, optionally with renderer-side backpressure. */ + _writeOutput: TerminalOutputWriter | null; /** Set when the server signals the session is done (PTY exit or fatal * attach error). Suppresses the auto-reconnect loop. */ _terminated: boolean; @@ -151,6 +154,7 @@ export function createTransport(): TerminalTransport { _titleNotifyTimer: null, _reconnectAttempt: 0, _terminal: null, + _writeOutput: null, _hasReceivedBytes: false, _terminated: false, }; @@ -175,7 +179,12 @@ function scheduleReconnect(transport: TerminalTransport) { transport.currentUrl && transport._terminal ) { - connect(transport, transport._terminal, transport.currentUrl); + connect( + transport, + transport._terminal, + transport.currentUrl, + transport._writeOutput ?? undefined, + ); } }, delay); } @@ -219,6 +228,7 @@ export function connect( transport: TerminalTransport, terminal: XTerm, wsUrl: string, + writeOutput?: TerminalOutputWriter, ) { // Idempotent: skip if already connected/connecting to the same endpoint. const isActive = @@ -234,6 +244,7 @@ export function connect( cancelReconnect(transport); transport.currentUrl = wsUrl; transport._terminal = terminal; + transport._writeOutput = writeOutput ?? ((data) => terminal.write(data)); transport._terminated = false; setConnectionState(transport, "connecting"); const actualUrl = transport._hasReceivedBytes @@ -307,7 +318,8 @@ function attachSocketListeners( // channel; renderer treats them identically). Pipe straight into // xterm without any decoding step. if (event.data instanceof ArrayBuffer) { - terminal.write(new Uint8Array(event.data)); + const data = new Uint8Array(event.data); + transport._writeOutput?.(data); transport._hasReceivedBytes = true; return; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx index ecbd1a2d622..c68897d1a3f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx @@ -28,6 +28,7 @@ import { V2AvailableBanner } from "renderer/components/V2AvailableBanner"; import { useHotkeyDisplay } from "renderer/hotkeys"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { useV2WorkspaceNavigationStore } from "renderer/stores/v2-workspace-navigation"; import { DashboardSidebarHeader } from "./components/DashboardSidebarHeader"; import { DashboardSidebarHelpMenu } from "./components/DashboardSidebarHelpMenu"; import { DashboardSidebarHoverCardOverlay } from "./components/DashboardSidebarHoverCardOverlay"; @@ -48,6 +49,7 @@ interface SortableProjectWrapperProps { project: DashboardSidebarProject; isCollapsed: boolean; isDraggingProject: boolean; + activeWorkspaceId: string | null; workspaceShortcutLabels: Map; onWorkspaceHover: (workspaceId: string) => void | Promise; onToggleCollapse: (projectId: string) => void; @@ -57,6 +59,7 @@ const SortableProjectWrapper = memo(function SortableProjectWrapper({ project, isCollapsed, isDraggingProject, + activeWorkspaceId, workspaceShortcutLabels, onWorkspaceHover, onToggleCollapse, @@ -83,6 +86,7 @@ const SortableProjectWrapper = memo(function SortableProjectWrapper({ project={project} isSidebarCollapsed={isCollapsed} isDraggingProject={isDraggingProject} + activeWorkspaceId={activeWorkspaceId} workspaceShortcutLabels={workspaceShortcutLabels} onWorkspaceHover={onWorkspaceHover} onToggleCollapse={onToggleCollapse} @@ -105,7 +109,11 @@ export function DashboardSidebar({ const isSettingsOpen = !!matchRoute({ to: "/settings", fuzzy: true }); const { activeHostUrl } = useLocalHostService(); const v2RouteMatch = matchRoute({ to: "/v2-workspace/$workspaceId" }); - const activeV2WorkspaceId = v2RouteMatch ? v2RouteMatch.workspaceId : null; + const routeV2WorkspaceId = v2RouteMatch ? v2RouteMatch.workspaceId : null; + const pendingV2WorkspaceId = useV2WorkspaceNavigationStore( + (state) => state.pendingWorkspaceId, + ); + const activeV2WorkspaceId = pendingV2WorkspaceId ?? routeV2WorkspaceId; const sensors = useSensors( useSensor(MouseSensor, { activationConstraint: { distance: 8 } }), @@ -204,6 +212,7 @@ export function DashboardSidebar({ project={project} isCollapsed={isCollapsed} isDraggingProject={activeProject != null} + activeWorkspaceId={activeV2WorkspaceId} workspaceShortcutLabels={workspaceShortcutLabels} onWorkspaceHover={refreshWorkspacePullRequest} onToggleCollapse={toggleProjectCollapsed} @@ -218,6 +227,7 @@ export function DashboardSidebar({ {}} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHoverCardOverlay/DashboardSidebarHoverCardOverlay.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHoverCardOverlay/DashboardSidebarHoverCardOverlay.tsx index 7d6cc1495f8..5cd5c977ca8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHoverCardOverlay/DashboardSidebarHoverCardOverlay.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHoverCardOverlay/DashboardSidebarHoverCardOverlay.tsx @@ -27,7 +27,7 @@ export function DashboardSidebarHoverCardOverlay({ virtualRef.current = anchorElement; const open = hoveredId !== null && payload !== null && !contextMenuOpen; - const diffStats = useDiffStats(hoveredId ?? ""); + const diffStats = useDiffStats(hoveredId ?? "", { enabled: open }); // Suppress the transform transition until Radix has placed the popover at // its real anchor — otherwise the initial jump from the off-screen measuring diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx index df2d8c24766..71bb6b5fe8e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx @@ -17,6 +17,7 @@ interface DashboardSidebarProjectSectionProps { project: DashboardSidebarProject; isSidebarCollapsed?: boolean; isDraggingProject?: boolean; + activeWorkspaceId: string | null; workspaceShortcutLabels: Map; onWorkspaceHover: (workspaceId: string) => void | Promise; onToggleCollapse: (projectId: string) => void; @@ -28,6 +29,7 @@ export function DashboardSidebarProjectSection({ project, isSidebarCollapsed = false, isDraggingProject = false, + activeWorkspaceId, workspaceShortcutLabels, onWorkspaceHover, onToggleCollapse, @@ -76,6 +78,7 @@ export function DashboardSidebarProjectSection({ isCollapsed={project.isCollapsed} totalWorkspaceCount={totalWorkspaceCount} workspaces={flattenedCollapsedWorkspaces} + activeWorkspaceId={activeWorkspaceId} workspaceShortcutLabels={workspaceShortcutLabels} onWorkspaceHover={onWorkspaceHover} onToggleCollapse={() => onToggleCollapse(project.id)} @@ -125,6 +128,7 @@ export function DashboardSidebarProjectSection({ projectId={project.id} isCollapsed={project.isCollapsed} projectChildren={project.children} + activeWorkspaceId={activeWorkspaceId} workspaceShortcutLabels={workspaceShortcutLabels} onWorkspaceHover={onWorkspaceHover} onDeleteSection={deleteSection} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarCollapsedProjectContent/DashboardSidebarCollapsedProjectContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarCollapsedProjectContent/DashboardSidebarCollapsedProjectContent.tsx index ac4479825dc..6ea47b4ce27 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarCollapsedProjectContent/DashboardSidebarCollapsedProjectContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarCollapsedProjectContent/DashboardSidebarCollapsedProjectContent.tsx @@ -13,6 +13,7 @@ interface DashboardSidebarCollapsedProjectContentProps isCollapsed: boolean; totalWorkspaceCount: number; workspaces: DashboardSidebarWorkspace[]; + activeWorkspaceId: string | null; workspaceShortcutLabels: Map; onWorkspaceHover: (workspaceId: string) => void | Promise; onToggleCollapse: () => void; @@ -29,6 +30,7 @@ export const DashboardSidebarCollapsedProjectContent = forwardRef< isCollapsed, totalWorkspaceCount, workspaces, + activeWorkspaceId, workspaceShortcutLabels, onWorkspaceHover, onToggleCollapse, @@ -82,9 +84,10 @@ export const DashboardSidebarCollapsedProjectContent = forwardRef< onWorkspaceHover(workspace.id)} + onWorkspaceHover={onWorkspaceHover} shortcutLabel={workspaceShortcutLabels.get(workspace.id)} isCollapsed + isActive={workspace.id === activeWorkspaceId} /> ))} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx index 955f8387875..f6b56786d70 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx @@ -16,6 +16,7 @@ interface DashboardSidebarExpandedProjectContentProps { projectId: string; isCollapsed: boolean; projectChildren: DashboardSidebarProjectChild[]; + activeWorkspaceId: string | null; workspaceShortcutLabels: Map; onWorkspaceHover: (workspaceId: string) => void | Promise; onDeleteSection: (sectionId: string) => void; @@ -27,6 +28,7 @@ export function DashboardSidebarExpandedProjectContent({ projectId, isCollapsed, projectChildren, + activeWorkspaceId, workspaceShortcutLabels, onWorkspaceHover, onDeleteSection, @@ -116,9 +118,8 @@ export function DashboardSidebarExpandedProjectContent({ activeId === id ? predictedColor : group?.color } isInSection={groupInfo.has(parsed.realId)} - onHoverCardOpen={() => - onWorkspaceHover(parsed.realId) - } + isActive={parsed.realId === activeWorkspaceId} + onWorkspaceHover={onWorkspaceHover} shortcutLabel={workspaceShortcutLabels.get( parsed.realId, )} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx index 0f4a79be2bb..8eb0b6c3c8b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useDiffStats } from "renderer/hooks/host-service/useDiffStats"; import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; import { useDeletingWorkspaces } from "renderer/routes/_authenticated/providers/DeletingWorkspacesProvider"; @@ -15,18 +15,20 @@ import { useDashboardSidebarWorkspaceItemActions } from "./hooks/useDashboardSid interface DashboardSidebarWorkspaceItemProps { workspace: DashboardSidebarWorkspace; - onHoverCardOpen?: () => void; + onWorkspaceHover?: (workspaceId: string) => void | Promise; shortcutLabel?: string; isCollapsed?: boolean; isInSection?: boolean; + isActive?: boolean; } -export function DashboardSidebarWorkspaceItem({ +function DashboardSidebarWorkspaceItemComponent({ workspace, - onHoverCardOpen, + onWorkspaceHover, shortcutLabel, isCollapsed = false, isInSection = false, + isActive = false, }: DashboardSidebarWorkspaceItemProps) { const { id, @@ -40,7 +42,11 @@ export function DashboardSidebarWorkspaceItem({ pullRequest, } = workspace; const isMainWorkspace = workspace.type === "main"; - const diffStats = useDiffStats(id); + const isPending = !!creationStatus; + const isFailedInFlight = creationStatus === "failed"; + const diffStats = useDiffStats(id, { + enabled: !isCollapsed && !isPending, + }); const workspaceStatus = useV2WorkspaceNotificationStatus(id); const { cancelRename, @@ -52,7 +58,6 @@ export function DashboardSidebarWorkspaceItem({ handleOpenInFinder, handleRemoveFromSidebar, handleToggleUnread, - isActive, isDeleteDialogOpen, isUnread, isRenaming, @@ -67,6 +72,7 @@ export function DashboardSidebarWorkspaceItem({ projectId, workspaceName: name, branch, + isActive, isMainWorkspace, }); @@ -77,8 +83,6 @@ export function DashboardSidebarWorkspaceItem({ const handleAfterBranchRename = (newBranchName: string) => { v2WorkspaceActions.updateWorkspace(id, { branch: newBranchName }); }; - const isPending = !!creationStatus; - const isFailedInFlight = creationStatus === "failed"; // Keep the delete dialog outside the hidden wrapper below — the destroy // flow reopens it into an error pane on conflict/teardown-failed. const isDeleting = useDeletingWorkspaces().isDeleting(id); @@ -111,8 +115,8 @@ export function DashboardSidebarWorkspaceItem({ const isHovered = hoverHoveredId === id; useEffect(() => { - if (isHovered && hostType === "local-device") onHoverCardOpen?.(); - }, [isHovered, hostType, onHoverCardOpen]); + if (isHovered && hostType === "local-device") onWorkspaceHover?.(id); + }, [isHovered, hostType, onWorkspaceHover, id]); useEffect(() => { if (!isHovered) return; hoverSyncIfHovered(id, hoverPayload); @@ -144,6 +148,7 @@ export function DashboardSidebarWorkspaceItem({ onClick={handleClick} creationStatus={creationStatus} pullRequestState={pullRequest?.state ?? null} + data-renderer-stress-workspace-id={id} aria-label={ creationStatus ? `Creating workspace: ${name}` : undefined } @@ -224,6 +229,7 @@ export function DashboardSidebarWorkspaceItem({ isInSection={isInSection} onClick={handleClick} onDoubleClick={isPending ? undefined : startRename} + data-renderer-stress-workspace-id={id} onRemoveFromSidebarClick={handleRemoveFromSidebar} onCloseWorkspaceClick={ isFailedInFlight @@ -291,3 +297,7 @@ export function DashboardSidebarWorkspaceItem({ ); } + +export const DashboardSidebarWorkspaceItem = memo( + DashboardSidebarWorkspaceItemComponent, +); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts index 2e7313d0984..35f07e405e1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts @@ -1,10 +1,11 @@ import { toast } from "@superset/ui/sonner"; -import { useMatchRoute, useNavigate } from "@tanstack/react-router"; +import { useNavigate } from "@tanstack/react-router"; import { useState } from "react"; import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { electronTrpcClient } from "renderer/lib/trpc-client"; import { useDashboardSidebarSectionRename } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSectionRenameContext"; +import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; @@ -19,6 +20,7 @@ interface UseDashboardSidebarWorkspaceItemActionsOptions { projectId: string; workspaceName: string; branch: string; + isActive: boolean; isMainWorkspace?: boolean; } @@ -27,10 +29,10 @@ export function useDashboardSidebarWorkspaceItemActions({ projectId, workspaceName, branch, + isActive, isMainWorkspace = false, }: UseDashboardSidebarWorkspaceItemActionsOptions) { const navigate = useNavigate(); - const matchRoute = useMatchRoute(); const { activeHostUrl } = useLocalHostService(); const { copyToClipboard } = useCopyToClipboard(); const { v2Workspaces: workspaceActions } = useOptimisticCollectionActions(); @@ -47,19 +49,11 @@ export function useDashboardSidebarWorkspaceItemActions({ const [renameValue, setRenameValue] = useState(workspaceName); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const isActive = !!matchRoute({ - to: "/v2-workspace/$workspaceId", - params: { workspaceId }, - fuzzy: true, - }); - const handleClick = () => { if (isRenaming) return; clearWorkspaceAttention(workspaceId); - navigate({ - to: "/v2-workspace/$workspaceId", - params: { workspaceId }, - }); + if (isActive) return; + void navigateToV2Workspace(workspaceId, navigate); }; const startRename = () => { @@ -171,7 +165,6 @@ export function useDashboardSidebarWorkspaceItemActions({ handleOpenInFinder, handleRemoveFromSidebar, handleToggleUnread, - isActive, isDeleteDialogOpen, isRenaming, isUnread, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx index 47cb8dfeba5..a47622cd858 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx @@ -1,5 +1,6 @@ import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; +import { memo } from "react"; import type { DashboardSidebarWorkspace } from "../../types"; import { DashboardSidebarWorkspaceItem } from "../DashboardSidebarWorkspaceItem"; @@ -8,17 +9,19 @@ interface SortableWorkspaceItemProps { workspace: DashboardSidebarWorkspace; accentColor?: string | null; isInSection?: boolean; - onHoverCardOpen?: () => void; + isActive?: boolean; + onWorkspaceHover?: (workspaceId: string) => void | Promise; shortcutLabel?: string; disabled?: boolean; } -export function SortableWorkspaceItem({ +export const SortableWorkspaceItem = memo(function SortableWorkspaceItem({ sortableId, workspace, accentColor, isInSection, - onHoverCardOpen, + isActive = false, + onWorkspaceHover, shortcutLabel, disabled, }: SortableWorkspaceItemProps) { @@ -45,10 +48,11 @@ export function SortableWorkspaceItem({ > ); -} +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts index 82413df4232..874d9d6a10d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts @@ -3,6 +3,7 @@ import { useCallback, useMemo, useRef } from "react"; import { useHotkey } from "renderer/hotkeys"; import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; +import { useV2WorkspaceNavigationStore } from "renderer/stores/v2-workspace-navigation"; import type { DashboardSidebarProject } from "../../types"; import { getProjectChildrenWorkspaces } from "../../utils/projectChildren"; @@ -108,7 +109,7 @@ export function useDashboardSidebarShortcuts( const workspace = flattenedWorkspaces[index]; if (workspace) { revealWorkspace(workspace.id); - navigateToV2Workspace(workspace.id, navigate); + void navigateToV2Workspace(workspace.id, navigate); } }, [flattenedWorkspaces, navigate, revealWorkspace], @@ -131,29 +132,33 @@ export function useDashboardSidebarShortcuts( }); const currentWorkspaceId = currentWorkspaceMatch !== false ? currentWorkspaceMatch.workspaceId : null; + const pendingWorkspaceId = useV2WorkspaceNavigationStore( + (state) => state.pendingWorkspaceId, + ); + const activeWorkspaceId = pendingWorkspaceId ?? currentWorkspaceId; useHotkey("PREV_WORKSPACE", () => { - if (!currentWorkspaceId || flattenedWorkspaces.length === 0) return; + if (!activeWorkspaceId || flattenedWorkspaces.length === 0) return; const index = flattenedWorkspaces.findIndex( - (w) => w.id === currentWorkspaceId, + (w) => w.id === activeWorkspaceId, ); if (index === -1) return; const prevIndex = index <= 0 ? flattenedWorkspaces.length - 1 : index - 1; const target = flattenedWorkspaces[prevIndex]; revealWorkspace(target.id); - navigateToV2Workspace(target.id, navigate); + void navigateToV2Workspace(target.id, navigate); }); useHotkey("NEXT_WORKSPACE", () => { - if (!currentWorkspaceId || flattenedWorkspaces.length === 0) return; + if (!activeWorkspaceId || flattenedWorkspaces.length === 0) return; const index = flattenedWorkspaces.findIndex( - (w) => w.id === currentWorkspaceId, + (w) => w.id === activeWorkspaceId, ); if (index === -1) return; const nextIndex = index >= flattenedWorkspaces.length - 1 ? 0 : index + 1; const target = flattenedWorkspaces[nextIndex]; revealWorkspace(target.id); - navigateToV2Workspace(target.id, navigate); + void navigateToV2Workspace(target.id, navigate); }); return workspaceShortcutLabels; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useHybridSearch/useHybridSearch.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useHybridSearch/useHybridSearch.ts index ec33397b0d5..953bb1a25ee 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useHybridSearch/useHybridSearch.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useHybridSearch/useHybridSearch.ts @@ -15,35 +15,42 @@ interface SearchResult { matchType: "exact" | "fuzzy"; } -export function useHybridSearch(tasks: T[]) { +export function useHybridSearch( + tasks: T[], + enabled = true, +) { const exactFuse = useMemo( () => - new Fuse(tasks, { - keys: [ - { name: "slug", weight: 2 }, - { name: "labels", weight: 1 }, - ], - threshold: 0, - includeScore: true, - ignoreLocation: true, - useExtendedSearch: false, - }), - [tasks], + enabled + ? new Fuse(tasks, { + keys: [ + { name: "slug", weight: 2 }, + { name: "labels", weight: 1 }, + ], + threshold: 0, + includeScore: true, + ignoreLocation: true, + useExtendedSearch: false, + }) + : null, + [tasks, enabled], ); const fuzzyFuse = useMemo( () => - new Fuse(tasks, { - keys: [ - { name: "title", weight: 2 }, - { name: "description", weight: 1 }, - ], - threshold: 0.3, - includeScore: true, - ignoreLocation: true, - useExtendedSearch: false, - }), - [tasks], + enabled + ? new Fuse(tasks, { + keys: [ + { name: "title", weight: 2 }, + { name: "description", weight: 1 }, + ], + threshold: 0.3, + includeScore: true, + ignoreLocation: true, + useExtendedSearch: false, + }) + : null, + [tasks, enabled], ); const search = useCallback( @@ -55,6 +62,7 @@ export function useHybridSearch(tasks: T[]) { matchType: "exact" as const, })); } + if (!exactFuse || !fuzzyFuse) return []; const exactMatches = exactFuse.search(query); const exactIds = new Set(exactMatches.map((m) => m.item.id)); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksData/useTasksData.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksData/useTasksData.tsx index e70ae23cdf9..2e5e6b17f6c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksData/useTasksData.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksData/useTasksData.tsx @@ -74,7 +74,8 @@ export function useTasksData({ .sort(compareTasks); }, [allData]); - const { search } = useHybridSearch(sortedData); + const hasSearchQuery = searchQuery.trim().length > 0; + const { search } = useHybridSearch(sortedData, hasSearchQuery); const searchedData = useMemo(() => { if (!searchQuery.trim()) { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.test.ts new file mode 100644 index 00000000000..19908f902f8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import type { UseNavigateResult } from "@tanstack/react-router"; +import { useV2WorkspaceNavigationStore } from "renderer/stores/v2-workspace-navigation"; +import { + navigateToV2Workspace, + resetV2WorkspaceNavigationStateForTesting, +} from "./workspace-navigation"; + +function createDeferred() { + let resolve!: () => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, reject, resolve }; +} + +describe("navigateToV2Workspace", () => { + beforeEach(() => { + resetV2WorkspaceNavigationStateForTesting(); + useV2WorkspaceNavigationStore.setState({ pendingWorkspaceId: null }); + }); + + it("starts the first plain workspace switch immediately", async () => { + const calls: unknown[] = []; + const firstNavigation = createDeferred(); + const navigate = ((options: unknown) => { + calls.push(options); + return firstNavigation.promise; + }) as UseNavigateResult; + + const promise = navigateToV2Workspace("workspace-a", navigate); + + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + to: "/v2-workspace/$workspaceId", + params: { workspaceId: "workspace-a" }, + search: {}, + }); + expect(useV2WorkspaceNavigationStore.getState().pendingWorkspaceId).toBe( + "workspace-a", + ); + + firstNavigation.resolve(); + await promise; + expect( + useV2WorkspaceNavigationStore.getState().pendingWorkspaceId, + ).toBeNull(); + }); + + it("coalesces plain switches while a route transition is in flight", async () => { + const calls: unknown[] = []; + const firstNavigation = createDeferred(); + const queuedNavigation = createDeferred(); + const navigate = ((options: unknown) => { + calls.push(options); + return calls.length === 1 + ? firstNavigation.promise + : queuedNavigation.promise; + }) as UseNavigateResult; + + const first = navigateToV2Workspace("workspace-a", navigate); + const second = navigateToV2Workspace("workspace-b", navigate); + const third = navigateToV2Workspace("workspace-c", navigate); + + expect(calls).toHaveLength(1); + expect(useV2WorkspaceNavigationStore.getState().pendingWorkspaceId).toBe( + "workspace-c", + ); + + firstNavigation.resolve(); + await first; + await Promise.resolve(); + + expect(calls).toHaveLength(2); + expect(calls[1]).toMatchObject({ + to: "/v2-workspace/$workspaceId", + params: { workspaceId: "workspace-c" }, + search: {}, + }); + + queuedNavigation.resolve(); + await Promise.all([second, third]); + expect( + useV2WorkspaceNavigationStore.getState().pendingWorkspaceId, + ).toBeNull(); + }); + + it("does not delay focused workspace navigation requests", async () => { + const calls: unknown[] = []; + const navigate = ((options: unknown) => { + calls.push(options); + return Promise.resolve(); + }) as UseNavigateResult; + + await navigateToV2Workspace("workspace-a", navigate, { + search: { terminalId: "terminal-a" }, + }); + + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + to: "/v2-workspace/$workspaceId", + params: { workspaceId: "workspace-a" }, + search: { terminalId: "terminal-a" }, + }); + }); + + it("cancels a queued plain switch when a focused request arrives", async () => { + const calls: unknown[] = []; + const firstNavigation = createDeferred(); + const focusedNavigation = createDeferred(); + const navigate = ((options: unknown) => { + calls.push(options); + return calls.length === 1 + ? firstNavigation.promise + : focusedNavigation.promise; + }) as UseNavigateResult; + + const first = navigateToV2Workspace("workspace-a", navigate); + const pendingPlainSwitch = navigateToV2Workspace("workspace-b", navigate); + const focusedSwitch = navigateToV2Workspace("workspace-c", navigate, { + search: { terminalId: "terminal-b" }, + }); + await pendingPlainSwitch; + + expect(calls).toHaveLength(2); + expect(calls[1]).toMatchObject({ + to: "/v2-workspace/$workspaceId", + params: { workspaceId: "workspace-c" }, + search: { terminalId: "terminal-b" }, + }); + + firstNavigation.resolve(); + focusedNavigation.resolve(); + await Promise.all([first, focusedSwitch]); + expect( + useV2WorkspaceNavigationStore.getState().pendingWorkspaceId, + ).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts index a807561923b..8bdd49b005d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts @@ -2,6 +2,10 @@ import type { NavigateOptions, UseNavigateResult, } from "@tanstack/react-router"; +import { + clearPendingV2WorkspaceNavigation, + setPendingV2WorkspaceNavigation, +} from "renderer/stores/v2-workspace-navigation"; export interface WorkspaceSearchParams { tabId?: string; @@ -17,6 +21,118 @@ export interface V2WorkspaceSearchParams { openUrlRequestId?: string; } +interface V2WorkspaceNavigationRequest { + workspaceId: string; + navigate: UseNavigateResult; + search: V2WorkspaceSearchParams; + options: Omit; +} + +interface QueuedV2WorkspaceNavigation extends V2WorkspaceNavigationRequest { + waiters: Array<{ + resolve: () => void; + reject: (error: unknown) => void; + }>; +} + +let inFlightV2WorkspaceNavigation: Promise | null = null; +let queuedV2WorkspaceNavigation: QueuedV2WorkspaceNavigation | null = null; + +export function resetV2WorkspaceNavigationStateForTesting(): void { + inFlightV2WorkspaceNavigation = null; + queuedV2WorkspaceNavigation = null; +} + +function observeNavigationFailure( + promise: Promise, + context: string, +): Promise { + void promise.catch((error) => { + console.warn(`[workspace-navigation] ${context} failed`, error); + }); + return promise; +} + +function hasSearchParams(search: V2WorkspaceSearchParams): boolean { + return Object.values(search).some((value) => value !== undefined); +} + +function runV2WorkspaceNavigation({ + workspaceId, + navigate, + search, + options, +}: V2WorkspaceNavigationRequest): Promise { + return navigate({ + to: "/v2-workspace/$workspaceId", + params: { workspaceId }, + search, + ...options, + }); +} + +function completeWaiters( + waiters: QueuedV2WorkspaceNavigation["waiters"], + result: { status: "resolved" } | { status: "rejected"; error: unknown }, +): void { + for (const waiter of waiters) { + if (result.status === "resolved") { + waiter.resolve(); + } else { + waiter.reject(result.error); + } + } +} + +function drainQueuedV2WorkspaceNavigation(): void { + if (inFlightV2WorkspaceNavigation || !queuedV2WorkspaceNavigation) return; + + const navigation = queuedV2WorkspaceNavigation; + queuedV2WorkspaceNavigation = null; + const promise = startV2WorkspaceNavigation(navigation); + promise.then( + () => completeWaiters(navigation.waiters, { status: "resolved" }), + (error) => + completeWaiters(navigation.waiters, { status: "rejected", error }), + ); +} + +function startV2WorkspaceNavigation( + request: V2WorkspaceNavigationRequest, +): Promise { + const promise = runV2WorkspaceNavigation(request); + inFlightV2WorkspaceNavigation = promise; + void promise.then( + () => { + if (inFlightV2WorkspaceNavigation !== promise) return; + inFlightV2WorkspaceNavigation = null; + drainQueuedV2WorkspaceNavigation(); + }, + () => { + if (inFlightV2WorkspaceNavigation !== promise) return; + inFlightV2WorkspaceNavigation = null; + drainQueuedV2WorkspaceNavigation(); + }, + ); + return promise; +} + +function queueV2WorkspaceNavigation( + request: V2WorkspaceNavigationRequest, +): Promise { + return new Promise((resolve, reject) => { + const waiters = queuedV2WorkspaceNavigation?.waiters ?? []; + waiters.push({ resolve, reject }); + queuedV2WorkspaceNavigation = { ...request, waiters }; + }); +} + +function cancelQueuedV2WorkspaceNavigation(): void { + const navigation = queuedV2WorkspaceNavigation; + queuedV2WorkspaceNavigation = null; + completeWaiters(navigation?.waiters ?? [], { status: "resolved" }); +} + /** * Navigate to a workspace and update localStorage to remember it as the last viewed workspace. * This ensures the workspace will be restored when the app is reopened. @@ -34,12 +150,15 @@ export function navigateToWorkspace( ): Promise { const { search, ...rest } = options ?? {}; localStorage.setItem("lastViewedWorkspaceId", workspaceId); - return navigate({ - to: "/workspace/$workspaceId", - params: { workspaceId }, - search: search ?? {}, - ...rest, - }); + return observeNavigationFailure( + navigate({ + to: "/workspace/$workspaceId", + params: { workspaceId }, + search: search ?? {}, + ...rest, + }), + `navigate to workspace ${workspaceId}`, + ); } /** @@ -53,10 +172,38 @@ export function navigateToV2Workspace( }, ): Promise { const { search, ...rest } = options ?? {}; - return navigate({ - to: "/v2-workspace/$workspaceId", - params: { workspaceId }, - search: search ?? {}, - ...rest, - }); + const resolvedSearch = search ?? {}; + const isPlainWorkspaceSwitch = + !rest.replace && !hasSearchParams(resolvedSearch); + + setPendingV2WorkspaceNavigation(workspaceId); + + if (!isPlainWorkspaceSwitch) { + cancelQueuedV2WorkspaceNavigation(); + } + + const promise = + isPlainWorkspaceSwitch && inFlightV2WorkspaceNavigation + ? queueV2WorkspaceNavigation({ + workspaceId, + navigate, + search: resolvedSearch, + options: rest, + }) + : startV2WorkspaceNavigation({ + workspaceId, + navigate, + search: resolvedSearch, + options: rest, + }); + + void promise.then( + () => clearPendingV2WorkspaceNavigation(workspaceId), + () => clearPendingV2WorkspaceNavigation(workspaceId), + ); + + return observeNavigationFailure( + promise, + `navigate to v2 workspace ${workspaceId}`, + ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx index d7972188da9..d79047a8b5e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -115,11 +115,18 @@ export function WorkspaceSidebar({ return () => ro.disconnect(); }, []); - const gitStatus = useGitStatus(workspaceId); + const isFilesTabActive = activeTab === "files"; + const isChangesTabActive = activeTab === "changes"; + const isReviewTabActive = activeTab === "review"; + const gitStatus = useGitStatus( + workspaceId, + isFilesTabActive || isChangesTabActive, + ); const changesTabDef = useChangesTab({ workspaceId, gitStatus, + enabled: isChangesTabActive, selectedFilePath, onSelectFile: onSelectDiffFile, onOpenFile: onSelectFile, @@ -131,6 +138,7 @@ export function WorkspaceSidebar({ const reviewTab = useReviewTab({ workspaceId, + enabled: isReviewTabActive, onOpenComment, onOpenInDiff: onSelectDiffFile ? (path, line, openInNewTab) => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx index cba18a18de1..61be82f48b8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx @@ -82,7 +82,10 @@ export const ChangesFileList = memo(function ChangesFileList({ } return ( -
+
{GROUP_ORDER.map((key) => { const groupFiles = grouped[key]; if (groupFiles.length === 0) return null; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx index e525781f33d..aa804845031 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx @@ -1,3 +1,4 @@ +import { defaultRangeExtractor, useVirtualizer } from "@tanstack/react-virtual"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ChangesetFile } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; import type { FoldSignal } from "../../ChangesFileList"; @@ -6,6 +7,8 @@ import { FolderHeader } from "./components/FolderHeader"; const ROOT_FOLDER_KEY = ""; const ROOT_FOLDER_LABEL = "Root Path"; +const ESTIMATED_ROW_HEIGHT = 24; +const OVERSCAN = 8; interface ChangesFoldersViewProps { files: ChangesetFile[]; @@ -23,6 +26,10 @@ interface FolderGroup { files: ChangesetFile[]; } +type GroupedRow = + | { kind: "folder"; key: string; group: FolderGroup } + | { kind: "file"; key: string; file: ChangesetFile }; + /** * Render a flat list of changed files grouped by their immediate parent * folder (one level deep — v1's "grouped" mode, not the full tree). @@ -45,6 +52,7 @@ export const ChangesFoldersView = memo(function ChangesFoldersView({ onOpenFile, onOpenInEditor, }: ChangesFoldersViewProps) { + const listRef = useRef(null); const groups = useMemo(() => groupFilesByFolder(files), [files]); const [closedFolders, setClosedFolders] = useState>(new Set()); @@ -72,26 +80,73 @@ export const ChangesFoldersView = memo(function ChangesFoldersView({ ); }, [foldSignal, groups]); + const rows = useMemo(() => { + const nextRows: GroupedRow[] = []; + for (const group of groups) { + nextRows.push({ + kind: "folder", + key: `folder:${group.folderPath || "__root__"}`, + group, + }); + if (closedFolders.has(group.folderPath)) continue; + for (const file of group.files) { + nextRows.push({ + kind: "file", + key: `file:${file.source.kind}:${file.path}`, + file, + }); + } + } + return nextRows; + }, [closedFolders, groups]); + + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => + listRef.current?.closest( + "[data-changes-file-list-scroll]", + ) as HTMLElement | null, + estimateSize: () => ESTIMATED_ROW_HEIGHT, + getItemKey: (index) => rows[index]?.key ?? index, + rangeExtractor: defaultRangeExtractor, + overscan: OVERSCAN, + scrollMargin: listRef.current?.offsetTop ?? 0, + }); + + const items = virtualizer.getVirtualItems(); + return ( -
- {groups.map((group) => { - const isRoot = group.folderPath === ROOT_FOLDER_KEY; - const isOpen = !closedFolders.has(group.folderPath); - // `folderPath` ("" for the root group) is already the unique - // per-group discriminator — `groupFilesByFolder` keys a Map by it. - return ( -
- toggleFolder(group.folderPath)} - /> - {isOpen && - group.files.map((file) => ( +
+
+ {items.map((virtualRow) => { + const row = rows[virtualRow.index]; + if (!row) return null; + return ( +
+ {row.kind === "folder" ? ( + toggleFolder(row.group.folderPath)} + /> + ) : ( - ))} -
- ); - })} + )} +
+ ); + })} +
); }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesSection/ChangesSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesSection/ChangesSection.tsx index f4ad7d1ecce..4277aa6dd05 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesSection/ChangesSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesSection/ChangesSection.tsx @@ -1,14 +1,9 @@ -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@superset/ui/collapsible"; import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { workspaceTrpc } from "@superset/workspace-client"; import { ChevronRight, Minus, Plus } from "lucide-react"; -import { type ReactNode, useState } from "react"; +import { type ReactNode, useId, useState } from "react"; import { LuUndo2 } from "react-icons/lu"; import { DiscardConfirmDialog } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/DiscardConfirmDialog"; @@ -31,6 +26,7 @@ export function ChangesSection({ }: ChangesSectionProps) { const [open, setOpen] = useState(defaultOpen); const [showConfirm, setShowConfirm] = useState(false); + const contentId = useId(); const utils = workspaceTrpc.useUtils(); const invalidate = () => { @@ -112,9 +108,15 @@ export function ChangesSection({ const StagingToggleIcon = isUnstaged ? Plus : Minus; return ( - +
- + {stagingActions && (
@@ -161,7 +163,7 @@ export function ChangesSection({
)}
- {children} + {open &&
{children}
} {stagingActions && ( )} - +
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx index 54c63d00187..36cfe295f98 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx @@ -23,6 +23,7 @@ export type { ChangesFilter, ChangesViewMode }; interface UseChangesTabParams { workspaceId: string; gitStatus: ReturnType; + enabled?: boolean; /** Absolute path of the file whose diff/preview is currently open. */ selectedFilePath?: string; onSelectFile?: (path: string, openInNewTab?: boolean) => void; @@ -32,6 +33,7 @@ interface UseChangesTabParams { export function useChangesTab({ workspaceId, gitStatus: status, + enabled = true, selectedFilePath, onSelectFile, onOpenFile, @@ -47,18 +49,21 @@ export function useChangesTab({ const baseBranchQuery = workspaceTrpc.git.getBaseBranch.useQuery( { workspaceId }, - { staleTime: Number.POSITIVE_INFINITY }, + { staleTime: Number.POSITIVE_INFINITY, enabled }, ); const baseBranch = baseBranchQuery.data?.baseBranch ?? null; - const ref = useSidebarDiffRef(workspaceId); - const { files, isLoading } = useChangeset({ workspaceId, ref }); + const ref = useSidebarDiffRef(workspaceId, enabled); + const { files, isLoading } = useChangeset({ workspaceId, ref, enabled }); - const workspaceQuery = workspaceTrpc.workspace.get.useQuery({ - id: workspaceId, - }); + const workspaceQuery = workspaceTrpc.workspace.get.useQuery( + { + id: workspaceId, + }, + { enabled }, + ); const worktreePath = workspaceQuery.data?.worktreePath; - const openInExternalEditor = useOpenInExternalEditor(workspaceId); + const openInExternalEditor = useOpenInExternalEditor(workspaceId, enabled); const handleOpenInEditor = useCallback( (relativePath: string) => { @@ -106,12 +111,12 @@ export function useChangesTab({ const commits = workspaceTrpc.git.listCommits.useQuery( { workspaceId, baseBranch: baseBranch ?? undefined }, - { refetchOnWindowFocus: true }, + { enabled, refetchOnWindowFocus: true }, ); const branches = workspaceTrpc.git.listBranches.useQuery( { workspaceId }, - { refetchInterval: 30_000, refetchOnWindowFocus: true }, + { enabled, refetchInterval: 30_000, refetchOnWindowFocus: true }, ); const renameBranchMutation = workspaceTrpc.git.renameBranch.useMutation(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/useReviewTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/useReviewTab.tsx index 0f5a034cb37..3d9d896865d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/useReviewTab.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/useReviewTab.tsx @@ -17,19 +17,21 @@ type V2ThreadsData = RouterOutputs["git"]["getPullRequestThreads"]; interface UseReviewTabParams { workspaceId: string; + enabled?: boolean; onOpenComment?: (comment: CommentPaneData) => void; onOpenInDiff?: (path: string, line?: number, openInNewTab?: boolean) => void; } export function useReviewTab({ workspaceId, + enabled = true, onOpenComment, onOpenInDiff, }: UseReviewTabParams): SidebarTabDefinition { const prQuery = workspaceTrpc.git.getPullRequest.useQuery( { workspaceId }, { - enabled: !!workspaceId, + enabled: enabled && !!workspaceId, refetchInterval: 10_000, refetchOnWindowFocus: true, staleTime: 10_000, @@ -40,7 +42,7 @@ export function useReviewTab({ const threadsQuery = workspaceTrpc.git.getPullRequestThreads.useQuery( { workspaceId }, { - enabled: !!workspaceId && hasPR, + enabled: enabled && !!workspaceId && hasPR, refetchInterval: 30_000, refetchOnWindowFocus: true, }, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/useChangeset.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/useChangeset.ts index bfb7e9f02b7..7893a675c54 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/useChangeset.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/useChangeset.ts @@ -7,6 +7,7 @@ import type { ChangesetFile, DiffRef } from "./types"; interface UseChangesetArgs { workspaceId: string; ref: DiffRef; + enabled?: boolean; } interface UseChangesetResult { @@ -19,6 +20,7 @@ interface UseChangesetResult { export function useChangeset({ workspaceId, ref, + enabled = true, }: UseChangesetArgs): UseChangesetResult { const utils = workspaceTrpc.useUtils(); @@ -29,7 +31,7 @@ export function useChangeset({ baseBranch: ref.kind === "against-base" ? (ref.baseBranch ?? undefined) : undefined, }, - { enabled: needsStatus, staleTime: Number.POSITIVE_INFINITY }, + { enabled: enabled && needsStatus, staleTime: Number.POSITIVE_INFINITY }, ); const commitQuery = workspaceTrpc.git.getCommitFiles.useQuery( @@ -41,7 +43,7 @@ export function useChangeset({ } : { workspaceId, commitHash: "" }, { - enabled: ref.kind === "commit", + enabled: enabled && ref.kind === "commit", staleTime: Number.POSITIVE_INFINITY, }, ); @@ -59,7 +61,7 @@ export function useChangeset({ void utils.git.getDiff.invalidate({ workspaceId }); } }, - needsStatus, + enabled && needsStatus, ); const files = useMemo(() => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/useOpenInExternalEditor.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/useOpenInExternalEditor.ts index f394b0a1a2b..c3d6768e530 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/useOpenInExternalEditor.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/useOpenInExternalEditor.ts @@ -13,7 +13,7 @@ export interface OpenInExternalEditorOptions { column?: number; } -export function useOpenInExternalEditor(workspaceId: string) { +export function useOpenInExternalEditor(workspaceId: string, enabled = true) { const collections = useCollections(); const { machineId } = useLocalHostService(); const { data: workspaceRows = [] } = useLiveQuery( @@ -34,9 +34,12 @@ export function useOpenInExternalEditor(workspaceId: string) { // can't look this up on its own (v2 projects aren't in the v1 localDb). const { app: v2PreferredApp } = useV2ProjectDefaultApp(projectId); - const workspaceQuery = workspaceTrpc.workspace.get.useQuery({ - id: workspaceId, - }); + const workspaceQuery = workspaceTrpc.workspace.get.useQuery( + { + id: workspaceId, + }, + { enabled }, + ); const worktreePath = workspaceQuery.data?.worktreePath ?? undefined; return useCallback( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts index 31ebb1cc4a6..52bcd3ac0f2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts @@ -27,6 +27,7 @@ interface RegistryEntry { placeholder: HTMLElement | null; resizeObserver: ResizeObserver | null; visible: boolean; + lastLayoutRect: BrowserLayoutRect | null; } const EMPTY_STATE: BrowserRuntimeState = Object.freeze({ @@ -40,6 +41,17 @@ const EMPTY_STATE: BrowserRuntimeState = Object.freeze({ }); const ROOT_CONTAINER_ID = "browser-runtime-root"; +const MAX_BROWSER_LAYOUTS_PER_FRAME = 4; +const BROWSER_LAYOUT_FRAME_BUDGET_MS = 8; +const BROWSER_LAYOUT_COALESCE_MS = 50; +const BROWSER_LAYOUT_IDLE_TIMEOUT_MS = 150; + +interface BrowserLayoutRect { + top: number; + left: number; + width: number; + height: number; +} class BrowserRuntimeRegistryImpl { private entries = new Map(); @@ -48,6 +60,10 @@ class BrowserRuntimeRegistryImpl { private globalListenersInstalled = false; private windowDragPassthrough = false; private shellInteractionPassthrough = false; + private pendingLayoutEntries = new Set(); + private layoutFrameId: number | null = null; + private layoutTimerId: number | null = null; + private layoutIdleCallbackId: number | null = null; private getListeners(paneId: string): Set<() => void> { let set = this.listenersByPaneId.get(paneId); @@ -109,7 +125,7 @@ class BrowserRuntimeRegistryImpl { window.addEventListener("resize", () => { for (const entry of this.entries.values()) { - if (entry.placeholder) this.updateLayout(entry); + if (entry.placeholder) this.scheduleLayout(entry); } }); } @@ -143,14 +159,104 @@ class BrowserRuntimeRegistryImpl { } } + private scheduleLayout(entry: RegistryEntry) { + this.pendingLayoutEntries.add(entry); + this.scheduleDeferredLayoutFlush(); + } + + private scheduleDeferredLayoutFlush() { + if ( + this.layoutFrameId !== null || + this.layoutTimerId !== null || + this.layoutIdleCallbackId !== null + ) { + return; + } + + const requestFlushFrame = () => { + this.layoutTimerId = null; + this.layoutIdleCallbackId = null; + if (this.pendingLayoutEntries.size === 0) return; + this.scheduleImmediateLayoutFlush(); + }; + + if (typeof window.requestIdleCallback === "function") { + this.layoutIdleCallbackId = window.requestIdleCallback( + requestFlushFrame, + { + timeout: BROWSER_LAYOUT_IDLE_TIMEOUT_MS, + }, + ); + return; + } + + this.layoutTimerId = window.setTimeout( + requestFlushFrame, + BROWSER_LAYOUT_COALESCE_MS, + ); + } + + private scheduleImmediateLayoutFlush() { + if (this.layoutFrameId !== null) return; + if (typeof requestAnimationFrame !== "function") { + this.flushPendingLayouts(); + return; + } + this.layoutFrameId = requestAnimationFrame(() => { + this.flushPendingLayouts(); + }); + } + + private flushPendingLayouts() { + this.layoutFrameId = null; + const startedAt = + typeof performance === "undefined" ? 0 : performance.now(); + let processed = 0; + + for (const pendingEntry of Array.from(this.pendingLayoutEntries)) { + this.pendingLayoutEntries.delete(pendingEntry); + this.updateLayout(pendingEntry); + processed += 1; + + if (processed >= MAX_BROWSER_LAYOUTS_PER_FRAME) break; + if ( + startedAt > 0 && + performance.now() - startedAt >= BROWSER_LAYOUT_FRAME_BUDGET_MS + ) { + break; + } + } + + if (this.pendingLayoutEntries.size > 0) { + this.scheduleImmediateLayoutFlush(); + } + } + private updateLayout(entry: RegistryEntry) { - if (!entry.placeholder) return; + if (!entry.placeholder || !entry.visible) return; const rect = entry.placeholder.getBoundingClientRect(); + const nextRect = { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + }; + const previousRect = entry.lastLayoutRect; const w = entry.webview; - w.style.top = `${rect.top}px`; - w.style.left = `${rect.left}px`; - w.style.width = `${rect.width}px`; - w.style.height = `${rect.height}px`; + if ( + !previousRect || + previousRect.top !== nextRect.top || + previousRect.left !== nextRect.left || + previousRect.width !== nextRect.width || + previousRect.height !== nextRect.height + ) { + w.style.top = `${nextRect.top}px`; + w.style.left = `${nextRect.left}px`; + w.style.width = `${nextRect.width}px`; + w.style.height = `${nextRect.height}px`; + entry.lastLayoutRect = nextRect; + } + w.style.visibility = "visible"; } private notify(paneId: string) { @@ -212,6 +318,7 @@ class BrowserRuntimeRegistryImpl { placeholder: null, resizeObserver: null, visible: false, + lastLayoutRect: null, }; const firePersist = () => { @@ -384,13 +491,12 @@ class BrowserRuntimeRegistryImpl { entry.resizeObserver?.disconnect(); const observer = new ResizeObserver(() => { - if (entry) this.updateLayout(entry); + if (entry) this.scheduleLayout(entry); }); observer.observe(placeholder); entry.resizeObserver = observer; - this.updateLayout(entry); - entry.webview.style.visibility = "visible"; + this.scheduleLayout(entry); this.applyPointerPassthrough(); } @@ -398,16 +504,19 @@ class BrowserRuntimeRegistryImpl { const entry = this.entries.get(paneId); if (!entry) return; entry.onPersist = null; + this.pendingLayoutEntries.delete(entry); entry.placeholder = null; entry.resizeObserver?.disconnect(); entry.resizeObserver = null; entry.visible = false; + entry.lastLayoutRect = null; entry.webview.style.visibility = "hidden"; } destroy(paneId: string): void { const entry = this.entries.get(paneId); if (!entry) return; + this.pendingLayoutEntries.delete(entry); entry.resizeObserver?.disconnect(); entry.detachHandlers(); entry.webview.remove(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index 7ccc6122c1d..d5ce97474a4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -3,6 +3,7 @@ import { cn } from "@superset/ui/utils"; import { workspaceTrpc } from "@superset/workspace-client"; import "@xterm/xterm/css/xterm.css"; import { + memo, useCallback, useEffect, useMemo, @@ -48,7 +49,7 @@ interface TerminalPaneProps { onRevealPath: (path: string, options?: { isDirectory?: boolean }) => void; } -export function TerminalPane({ +function TerminalPaneComponent({ ctx, workspaceId, onOpenFile, @@ -153,6 +154,11 @@ export function TerminalPane({ }; }, [terminalId, terminalInstanceId]); + useEffect(() => { + if (!ctx.isActive) return; + terminalRuntimeRegistry.focus(terminalId, terminalInstanceId); + }, [ctx.isActive, terminalId, terminalInstanceId]); + const lastInvalidatedOpenSessionRef = useRef(null); useEffect(() => { const invalidateSessionsAfterSocketOpen = () => { @@ -438,6 +444,18 @@ export function TerminalPane({ ); } +export const TerminalPane = memo( + TerminalPaneComponent, + (prev, next) => + prev.workspaceId === next.workspaceId && + prev.onOpenFile === next.onOpenFile && + prev.onRevealPath === next.onRevealPath && + prev.ctx.store === next.ctx.store && + prev.ctx.isActive === next.ctx.isActive && + prev.ctx.pane.id === next.ctx.pane.id && + prev.ctx.pane.data === next.ctx.pane.data, +); + // Compute "what would clicking right now do?" for the live link tooltip. // Folders use the hardcoded folderIntent rule; files/urls go through the // settings-driven policies. Returns null when no modifier is held or the diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index 4f8fa8c5c83..5955300a5ae 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -41,7 +41,11 @@ import type { PaneViewerData, TerminalPaneData, } from "../../types"; -import { BrowserPane, BrowserPaneToolbar } from "./components/BrowserPane"; +import { + BrowserPane, + BrowserPaneToolbar, + browserRuntimeRegistry, +} from "./components/BrowserPane"; import { ChatPane } from "./components/ChatPane"; import { ChatPaneTitle } from "./components/ChatPane/components/ChatPaneTitle"; import { CommentPane } from "./components/CommentPane"; @@ -416,7 +420,9 @@ export function usePaneRegistry({ renderToolbar: (ctx: RendererContext) => ( ), - // Destruction handled by useGlobalBrowserLifecycle for now. + onAfterClose: (pane) => { + browserRuntimeRegistry.destroy(pane.id); + }, contextMenuActions: (_ctx, defaults) => defaults.map((d) => d.key === "close-pane" ? { ...d, label: "Close Browser" } : d, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/index.ts new file mode 100644 index 00000000000..34745d466bd --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/index.ts @@ -0,0 +1 @@ +export { useRendererStressWorkspaceBridge } from "./useRendererStressWorkspaceBridge"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/useRendererStressWorkspaceBridge.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/useRendererStressWorkspaceBridge.ts new file mode 100644 index 00000000000..54315cb73c1 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRendererStressWorkspaceBridge/useRendererStressWorkspaceBridge.ts @@ -0,0 +1,679 @@ +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; + } + const nextKind = isRendererStressPaneKind(active.pane.kind) + ? active.pane.kind + : "file"; + state.setPaneData({ + paneId: active.pane.id, + data: createStressPane(nextKind, index, filePaths, worktreePath).data, + }); + }, + replaceWithGeneratedLayout: (tabCount, panesPerTab) => { + releaseStressTerminalRuntimes(); + const boundedTabCount = Math.max(1, Math.min(50, Math.floor(tabCount))); + const boundedPaneCount = Math.max( + 1, + Math.min(8, Math.floor(panesPerTab)), + ); + store.getState().replaceState( + buildGeneratedWorkspaceState( + workspace.id, + boundedTabCount, + boundedPaneCount, + (tabIndex, paneOffset) => { + const paneIndex = tabIndex * 10 + paneOffset; + return createStressPane( + paneOffset === 0 + ? getNextPaneKind(tabIndex) + : getNextPaneKind(paneIndex), + paneIndex, + filePaths, + worktreePath, + ); + }, + ), + ); + }, + replaceWithGeneratedTerminalLayout: (tabCount, panesPerTab) => { + releaseStressTerminalRuntimes(); + const boundedTabCount = Math.max(1, Math.min(80, Math.floor(tabCount))); + const boundedPaneCount = Math.max( + 1, + Math.min(8, Math.floor(panesPerTab)), + ); + store + .getState() + .replaceState( + buildGeneratedWorkspaceState( + workspace.id, + boundedTabCount, + boundedPaneCount, + (tabIndex, paneOffset) => + createStressTerminalPane( + workspace.id, + tabIndex * boundedPaneCount + paneOffset, + rememberStressTerminalId, + ), + ), + ); + }, + replaceWithGeneratedMixedLayout: (tabCount, panesPerTab) => { + releaseStressTerminalRuntimes(); + const boundedTabCount = Math.max(1, Math.min(80, Math.floor(tabCount))); + const boundedPaneCount = Math.max( + 1, + Math.min(8, Math.floor(panesPerTab)), + ); + store.getState().replaceState( + buildGeneratedWorkspaceState( + workspace.id, + boundedTabCount, + boundedPaneCount, + (tabIndex, paneOffset) => { + const paneIndex = tabIndex * boundedPaneCount + paneOffset; + if (paneIndex % 4 === 0) { + return createStressTerminalPane( + workspace.id, + paneIndex, + rememberStressTerminalId, + ); + } + return createStressPane( + getNextPaneKind(paneIndex), + paneIndex, + filePaths, + worktreePath, + ); + }, + ), + ); + }, + writeTerminalStressOutput: async (index, lines, payloadBytes) => { + const refs = getStressTerminalRefs(); + const output = makeTerminalStressOutput(index, lines, payloadBytes); + const results = await Promise.all( + refs.map((ref) => + terminalRuntimeRegistry.writeForStress( + ref.terminalId, + output, + ref.instanceId, + ), + ), + ); + const writtenCount = results.filter(Boolean).length; + return { + terminalCount: refs.length, + writtenCount, + failedCount: refs.length - writtenCount, + byteLength: output.length, + }; + }, + forceTerminalWebglContextLoss: () => { + const result: TerminalWebglContextLossResult = { + terminalCount: 0, + canvasCount: 0, + webglContextCount: 0, + lostContextCount: 0, + unsupportedContextCount: 0, + }; + for (const ref of getStressTerminalRefs()) { + const partial = + terminalRuntimeRegistry.forceWebglContextLossForStress( + ref.terminalId, + ref.instanceId, + ); + result.terminalCount += partial.terminalCount; + result.canvasCount += partial.canvasCount; + result.webglContextCount += partial.webglContextCount; + result.lostContextCount += partial.lostContextCount; + result.unsupportedContextCount += partial.unsupportedContextCount; + } + return result; + }, + getTerminalStressSummary: () => { + const stressTerminalIds = stressTerminalIdsRef.current; + const terminalRuntimes = terminalRuntimeRegistry + .getStressDebugInfo() + .filter((runtime) => stressTerminalIds.has(runtime.terminalId)); + return { + ...getBridgeSummary(), + stressTerminalIdCount: stressTerminalIds.size, + terminalRuntimeCount: terminalRuntimes.length, + terminalRuntimes, + }; + }, + releaseStressTerminalRuntimes, + addRealTerminalTab: addTerminalTab, + showChangesSidebar, + }; + + window.__SUPERSET_RENDERER_STRESS__ = bridge; + return () => { + if (window.__SUPERSET_RENDERER_STRESS__ === bridge) { + delete window.__SUPERSET_RENDERER_STRESS__; + } + }; + }, [ + addTerminalTab, + filePaths, + showChangesSidebar, + store, + workspace, + worktreePath, + ]); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/useSidebarDiffRef.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/useSidebarDiffRef.ts index cbf779c25b7..77c459db6e8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/useSidebarDiffRef.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/useSidebarDiffRef.ts @@ -5,7 +5,10 @@ import { useMemo } from "react"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { DiffRef } from "../useChangeset/types"; -export function useSidebarDiffRef(workspaceId: string): DiffRef { +export function useSidebarDiffRef( + workspaceId: string, + enabled = true, +): DiffRef { const collections = useCollections(); const { data: rows = [] } = useLiveQuery( (query) => @@ -19,7 +22,7 @@ export function useSidebarDiffRef(workspaceId: string): DiffRef { const baseBranchQuery = workspaceTrpc.git.getBaseBranch.useQuery( { workspaceId }, - { staleTime: Number.POSITIVE_INFINITY }, + { staleTime: Number.POSITIVE_INFINITY, enabled }, ); const baseBranch = baseBranchQuery.data?.baseBranch ?? null; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index ad4b778776b..3bcdc0df6b1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -1,7 +1,7 @@ import { Workspace } from "@superset/panes"; import { workspaceTrpc } from "@superset/workspace-client"; import { createFileRoute } from "@tanstack/react-router"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { useQuickOpenStore } from "renderer/commandPalette/ui/QuickOpen/quickOpenStore"; import { useV2UserPreferences } from "renderer/hooks/useV2UserPreferences"; @@ -27,6 +27,8 @@ import { useDefaultPaneActions } from "./hooks/useDefaultPaneActions"; import { useDirtyTabCloseGuard } from "./hooks/useDirtyTabCloseGuard"; import { usePaneRegistry } from "./hooks/usePaneRegistry"; import { renderBrowserTabIcon } from "./hooks/usePaneRegistry/components/BrowserPane"; +import { browserRuntimeRegistry } from "./hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry"; +import { useRendererStressWorkspaceBridge } from "./hooks/useRendererStressWorkspaceBridge"; import { useV2PresetExecution } from "./hooks/useV2PresetExecution"; import { useV2TerminalLauncher } from "./hooks/useV2TerminalLauncher"; import { useV2WorkspacePaneLayout } from "./hooks/useV2WorkspacePaneLayout"; @@ -97,10 +99,18 @@ function V2WorkspacePage() { ); } - return ; + return ( + + ); } -function V2WorkspaceContent() { +function V2WorkspaceContent({ + worktreePath, +}: { + worktreePath?: string | null; +}) { const { terminalId, chatSessionId, @@ -121,6 +131,17 @@ function V2WorkspaceContent() { } = useV2UserPreferences(); const showPresetsBar = v2UserPreferences.showPresetsBar; const { store } = useV2WorkspacePaneLayout(); + useEffect(() => { + return () => { + for (const tab of store.getState().tabs) { + for (const pane of Object.values(tab.panes)) { + if (pane.kind === "browser") { + browserRuntimeRegistry.detach(pane.id); + } + } + } + }; + }, [store]); useClearActivePaneAttention({ store }); const launcher = useV2TerminalLauncher(); const { matchedPresets, executePreset, resolvePresetCommands } = @@ -176,6 +197,29 @@ function V2WorkspaceContent() { openCommentPane, } = useWorkspacePaneOpeners({ store, launcher }); + const rendererStressFilePaths = useMemo( + () => + Array.from( + new Set([ + ...Array.from(openFilePaths), + ...recentFiles.map((file) => file.absolutePath), + ]), + ), + [openFilePaths, recentFiles], + ); + const showRendererStressChangesSidebar = useCallback(() => { + setRightSidebarOpen(true); + setRightSidebarTab("changes"); + }, [setRightSidebarOpen, setRightSidebarTab]); + useRendererStressWorkspaceBridge({ + workspace, + store, + filePaths: rendererStressFilePaths, + worktreePath, + addTerminalTab, + showChangesSidebar: showRendererStressChangesSidebar, + }); + const quickOpenOpen = useQuickOpenStore( (s) => s.open && s.target?.workspaceId === workspaceId, ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx index 05a8de21c94..e7e40e9096f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx @@ -56,7 +56,7 @@ function V2WorkspaceLayout() { const hostStatus = useRemoteHostStatus(workspace); - if (!workspaceId || !isReady || !workspaces) { + if (!workspaceId || !workspaces || (!isReady && !workspace)) { return
; } @@ -96,8 +96,11 @@ function V2WorkspaceLayout() { return
; } + // TanStack Router reuses the Outlet subtree across param-only transitions. + // A workspace switch must remount pane state instead of looking like every + // pane in the previous workspace was closed. return ( - + ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx index 0adb0f7ce65..d1c05cbecc4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx @@ -27,6 +27,7 @@ import { useV2WorkspacesFilterStore, } from "renderer/routes/_authenticated/_dashboard/v2-workspaces/stores/v2WorkspacesFilterStore"; import { useV2ProjectLocalMetaStore } from "renderer/stores/v2-project-local-meta"; +import { useV2WorkspaceNavigationStore } from "renderer/stores/v2-workspace-navigation"; import { V2WorkspaceProjectIcon } from "../V2WorkspaceProjectIcon"; import { SortableHeader } from "./components/SortableHeader"; import { V2WorkspaceRow } from "./components/V2WorkspaceRow"; @@ -131,6 +132,10 @@ export function V2WorkspacesList({ workspaces }: V2WorkspacesListProps) { }); const currentWorkspaceId = currentWorkspaceMatch !== false ? currentWorkspaceMatch.workspaceId : null; + const pendingWorkspaceId = useV2WorkspaceNavigationStore( + (state) => state.pendingWorkspaceId, + ); + const activeWorkspaceId = pendingWorkspaceId ?? currentWorkspaceId; const searchQuery = useV2WorkspacesFilterStore((state) => state.searchQuery); const deviceFilter = useV2WorkspacesFilterStore( @@ -265,7 +270,7 @@ export function V2WorkspacesList({ workspaces }: V2WorkspacesListProps) { ))}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx index 75b7b933f2d..6b8c08b0a77 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx @@ -57,7 +57,9 @@ export function V2WorkspaceRow({ !workspace.hostIsOnline && workspace.hostType !== "local-device"; const handleOpen = useCallback(() => { - const open = () => navigateToV2Workspace(workspace.id, navigate); + const open = () => { + void navigateToV2Workspace(workspace.id, navigate); + }; if (workspace.hostType === "local-device") { open(); return; @@ -155,6 +157,7 @@ export function V2WorkspaceRow({
f.family), ]); +const FONT_DISCOVERY_BATCH_SIZE = 16; +const FONT_DISCOVERY_START_DELAY_MS = 1000; + +function yieldFontDiscoveryWork(): Promise { + if (typeof window.requestIdleCallback === "function") { + return new Promise((resolve) => { + window.requestIdleCallback(() => resolve(), { timeout: 100 }); + }); + } + return new Promise((resolve) => setTimeout(resolve, 0)); +} // Reuse a single canvas context for all font measurements let sharedCtx: CanvasRenderingContext2D | null = null; @@ -102,94 +113,119 @@ function classifyFont(family: string): FontCategory { return "other"; } -function isMonospaceByMeasurement(family: string): boolean { - const ctx = getCanvasCtx(); - if (!ctx) return false; - ctx.font = `16px "${family}"`; - const narrowWidth = ctx.measureText("iiiiii").width; - const wideWidth = ctx.measureText("MMMMMM").width; - return Math.abs(narrowWidth - wideWidth) < 1; -} - -function discoverSystemFonts(): FontInfo[] { +async function discoverSystemFonts(): Promise { const result: FontInfo[] = []; + let checked = 0; for (const family of WELL_KNOWN_NERD) { if (isFontAvailable(family)) { result.push({ family, category: "nerd" }); } + checked += 1; + if (checked % FONT_DISCOVERY_BATCH_SIZE === 0) { + await yieldFontDiscoveryWork(); + } } for (const family of WELL_KNOWN_MONO) { if (isFontAvailable(family)) { result.push({ family, category: "mono" }); } + checked += 1; + if (checked % FONT_DISCOVERY_BATCH_SIZE === 0) { + await yieldFontDiscoveryWork(); + } } return result; } let cachedFonts: FontInfo[] | null = null; +let fontDiscoveryPromise: Promise | null = null; -export function useSystemFonts() { - const [fonts, setFonts] = useState(cachedFonts ?? []); - const [isLoading, setIsLoading] = useState(cachedFonts === null); +async function loadSystemFonts(): Promise { + if (cachedFonts) return cachedFonts; + if (fontDiscoveryPromise) return fontDiscoveryPromise; - useEffect(() => { - if (cachedFonts) return; + fontDiscoveryPromise = (async () => { + await document.fonts.ready; + await yieldFontDiscoveryWork(); - let cancelled = false; - - async function loadFonts() { - await document.fonts.ready; + const result: FontInfo[] = []; + const seen = new Set(); - const result: FontInfo[] = []; - const seen = new Set(); - - // Add registered @font-face fonts only if they loaded successfully - for (const font of REGISTERED_FONTS) { - if (isFontAvailable(font.family)) { - result.push(font); - seen.add(font.family); - } + // Add registered @font-face fonts only if they loaded successfully. + for (const font of REGISTERED_FONTS) { + if (isFontAvailable(font.family)) { + result.push(font); + seen.add(font.family); } + } - for (const font of discoverSystemFonts()) { - if (!seen.has(font.family)) { - seen.add(font.family); - result.push(font); - } + for (const font of await discoverSystemFonts()) { + if (!seen.has(font.family)) { + seen.add(font.family); + result.push(font); } + } + + if (window.queryLocalFonts) { + try { + const fontData = await window.queryLocalFonts(); + let checked = 0; + for (const fd of fontData) { + if (seen.has(fd.family)) continue; + seen.add(fd.family); + + result.push({ family: fd.family, category: classifyFont(fd.family) }); - if (window.queryLocalFonts) { - try { - const fontData = await window.queryLocalFonts(); - for (const fd of fontData) { - if (seen.has(fd.family)) continue; - seen.add(fd.family); - - let category = classifyFont(fd.family); - if (category === "other" && isMonospaceByMeasurement(fd.family)) { - category = "mono"; - } - result.push({ family: fd.family, category }); + checked += 1; + if (checked % FONT_DISCOVERY_BATCH_SIZE === 0) { + await yieldFontDiscoveryWork(); } - } catch (err) { - console.warn("[useSystemFonts] queryLocalFonts failed:", err); } + } catch (err) { + console.warn("[useSystemFonts] queryLocalFonts failed:", err); } + } - result.sort((a, b) => a.family.localeCompare(b.family)); + result.sort((a, b) => a.family.localeCompare(b.family)); + cachedFonts = result; + return result; + })(); - cachedFonts = result; - if (!cancelled) { - setFonts(result); - setIsLoading(false); - } - } + try { + return await fontDiscoveryPromise; + } catch (error) { + fontDiscoveryPromise = null; + throw error; + } +} - loadFonts().catch((err) => { - console.warn("[useSystemFonts] Font loading failed:", err); - }); +export function useSystemFonts() { + const [fonts, setFonts] = useState(cachedFonts ?? []); + const [isLoading, setIsLoading] = useState(cachedFonts === null); + + useEffect(() => { + if (cachedFonts) return; + + let cancelled = false; + const timeoutId = window.setTimeout(() => { + void yieldFontDiscoveryWork() + .then(() => { + if (cancelled) return null; + return loadSystemFonts(); + }) + .then((result) => { + if (cancelled || !result) return; + setFonts(result); + setIsLoading(false); + }) + .catch((err) => { + if (!cancelled) setIsLoading(false); + console.warn("[useSystemFonts] Font loading failed:", err); + }); + }, FONT_DISCOVERY_START_DELAY_MS); return () => { cancelled = true; + window.clearTimeout(timeoutId); }; }, []); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/hooks/useSplitOrientation/useSplitOrientation.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/hooks/useSplitOrientation/useSplitOrientation.ts index a239d6e86fa..6b4eccc3b8c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/hooks/useSplitOrientation/useSplitOrientation.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/hooks/useSplitOrientation/useSplitOrientation.ts @@ -12,14 +12,13 @@ export function useSplitOrientation( const container = containerRef.current; if (!container) return; - const updateOrientation = () => { - const { width, height } = container.getBoundingClientRect(); + const updateOrientation = ({ width, height }: DOMRectReadOnly) => { setSplitOrientation(width >= height ? "vertical" : "horizontal"); }; - updateOrientation(); - - const resizeObserver = new ResizeObserver(updateOrientation); + const resizeObserver = new ResizeObserver(([entry]) => { + if (entry) updateOrientation(entry.contentRect); + }); resizeObserver.observe(container); return () => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index c6f2d1c3571..eac5fbe42e8 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,14 @@ 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 { createTerminalImageAddonController } from "renderer/lib/terminal/terminal-image-addon-controller"; import { TerminalLinkManager } from "renderer/lib/terminal/terminal-link-manager"; +import { createTerminalWebglAddonController } from "renderer/lib/terminal/terminal-webgl-addon-controller"; import { electronTrpcClient as trpcClient } from "renderer/lib/trpc-client"; import { toXtermTheme } from "renderer/stores/theme/utils"; import { @@ -56,9 +55,6 @@ export function getDefaultTerminalBg(): string { return getDefaultTerminalTheme().background ?? "#151110"; } -// Once WebGL fails, skip it for all subsequent terminals (VS Code pattern). -let suggestedRendererType: "webgl" | "dom" | undefined; - export interface CreateTerminalOptions { /** * Workspace id used for worktree lookup during path stat/resolution. @@ -84,6 +80,10 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { searchAddon: SearchAddon; wrapper: HTMLDivElement; linkManager: TerminalLinkManager; + enableImageAddon: () => void; + disableImageAddon: () => void; + enableWebglAddon: () => void; + disableWebglAddon: () => void; cleanup: () => void; } { const { @@ -101,10 +101,8 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { const clipboardAddon = new ClipboardAddon(); const unicode11Addon = new Unicode11Addon(); - const imageAddon = new ImageAddon(); - - let disposed = false; - let webglAddon: WebglAddon | null = null; + const imageAddonController = createTerminalImageAddonController(xterm); + const webglAddonController = createTerminalWebglAddonController(xterm); // Open into a detached wrapper div — not the live container. const wrapper = document.createElement("div"); @@ -116,31 +114,6 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { xterm.loadAddon(searchAddon); xterm.loadAddon(clipboardAddon); xterm.loadAddon(unicode11Addon); - xterm.loadAddon(imageAddon); - - try { - xterm.loadAddon(new LigaturesAddon()); - } catch { - // Ligatures not supported by current font - } - - // Defer WebGL to rAF to avoid racing xterm's post-open viewport sync. - const rafId = requestAnimationFrame(() => { - if (disposed || suggestedRendererType === "dom") return; - - try { - webglAddon = new WebglAddon(); - webglAddon.onContextLoss(() => { - webglAddon?.dispose(); - webglAddon = null; - xterm.refresh(0, xterm.rows - 1); - }); - xterm.loadAddon(webglAddon); - } catch { - suggestedRendererType = "dom"; - webglAddon = null; - } - }); const cleanupQuerySuppression = suppressQueryResponses(xterm); @@ -203,15 +176,15 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { searchAddon, wrapper, linkManager, + enableImageAddon: imageAddonController.enable, + disableImageAddon: imageAddonController.disable, + enableWebglAddon: webglAddonController.enable, + disableWebglAddon: webglAddonController.disable, cleanup: () => { - disposed = true; - cancelAnimationFrame(rafId); + imageAddonController.dispose(); + webglAddonController.dispose(); cleanupQuerySuppression(); linkManager.dispose(); - try { - webglAddon?.dispose(); - } catch {} - webglAddon = null; }, }; } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts index 242b9b21202..1a0e01f73f6 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts @@ -24,6 +24,10 @@ export interface CachedTerminal { wrapper: HTMLDivElement; /** Disposes renderer RAF, query suppression, GPU renderer, etc. */ cleanupCreation: () => void; + enableImageAddon: () => void; + disableImageAddon: () => void; + enableWebglAddon: () => void; + disableWebglAddon: () => void; /** Last known dimensions — used to skip no-op resize events. */ lastCols: number; lastRows: number; @@ -111,8 +115,17 @@ export function getOrCreate( console.log(`[v1-terminal-cache] Creating new terminal: ${paneId}`); } - const { xterm, fitAddon, searchAddon, wrapper, cleanup } = - createTerminalInWrapper(options); + const { + xterm, + fitAddon, + searchAddon, + wrapper, + enableImageAddon, + disableImageAddon, + enableWebglAddon, + disableWebglAddon, + cleanup, + } = createTerminalInWrapper(options); const entry: CachedTerminal = { xterm, @@ -120,6 +133,10 @@ export function getOrCreate( searchAddon, wrapper, cleanupCreation: cleanup, + enableImageAddon, + disableImageAddon, + enableWebglAddon, + disableWebglAddon, subscription: null, streamReady: false, pendingStreamEvents: [], @@ -148,8 +165,10 @@ export function attachToContainer( entry.container = container; container.appendChild(entry.wrapper); + entry.enableImageAddon(); fitAndRefresh(entry); + entry.enableWebglAddon(); // Manage ResizeObserver lifecycle in the cache, not in React. entry.resizeObserver?.disconnect(); @@ -172,6 +191,8 @@ export function detachFromContainer(paneId: string): void { entry.resizeObserver?.disconnect(); entry.resizeObserver = null; entry.container = null; + entry.disableImageAddon(); + entry.disableWebglAddon(); // Park instead of .remove() so xterm survives the React unmount — // see getTerminalParkingContainer. getTerminalParkingContainer().appendChild(entry.wrapper); diff --git a/apps/desktop/src/renderer/stores/v2-workspace-navigation.ts b/apps/desktop/src/renderer/stores/v2-workspace-navigation.ts new file mode 100644 index 00000000000..00560af0a59 --- /dev/null +++ b/apps/desktop/src/renderer/stores/v2-workspace-navigation.ts @@ -0,0 +1,29 @@ +import { create } from "zustand"; + +interface V2WorkspaceNavigationState { + pendingWorkspaceId: string | null; + setPendingWorkspaceId: (workspaceId: string) => void; + clearPendingWorkspaceId: (workspaceId: string) => void; +} + +export const useV2WorkspaceNavigationStore = create( + (set) => ({ + pendingWorkspaceId: null, + setPendingWorkspaceId: (workspaceId) => + set({ pendingWorkspaceId: workspaceId }), + clearPendingWorkspaceId: (workspaceId) => + set((state) => + state.pendingWorkspaceId === workspaceId + ? { pendingWorkspaceId: null } + : state, + ), + }), +); + +export function setPendingV2WorkspaceNavigation(workspaceId: string): void { + useV2WorkspaceNavigationStore.getState().setPendingWorkspaceId(workspaceId); +} + +export function clearPendingV2WorkspaceNavigation(workspaceId: string): void { + useV2WorkspaceNavigationStore.getState().clearPendingWorkspaceId(workspaceId); +} diff --git a/apps/desktop/src/shared/workspace-run-definition.test.ts b/apps/desktop/src/shared/workspace-run-definition.test.ts index 117d1eb6120..a093aedd3a9 100644 --- a/apps/desktop/src/shared/workspace-run-definition.test.ts +++ b/apps/desktop/src/shared/workspace-run-definition.test.ts @@ -84,4 +84,64 @@ describe("selectWorkspaceRunDefinition", () => { commands: ["npm run dev"], }); }); + + it("ignores non-string config commands instead of throwing", () => { + const definition = selectWorkspaceRunDefinition({ + projectId: "project-a", + configRunCommands: [ + { name: "Dev server", command: "bun dev" }, + "bun start", + ], + presets: [], + }); + + expect(definition).toEqual({ + source: "project-config", + projectId: "project-a", + commands: ["bun start"], + }); + }); + + it("falls back when all config commands are non-strings", () => { + const definition = selectWorkspaceRunDefinition({ + projectId: "project-a", + configRunCommands: [{ name: "Dev server", command: "bun dev" }], + presets: [ + { + id: "preset-global", + name: "Global dev", + commands: ["npm run dev"], + useAsWorkspaceRun: true, + }, + ], + }); + + expect(definition).toEqual({ + source: "terminal-preset", + presetId: "preset-global", + name: "Global dev", + commands: ["npm run dev"], + }); + }); + + it("ignores non-string preset commands instead of throwing", () => { + const definition = selectWorkspaceRunDefinition({ + projectId: "project-a", + presets: [ + { + id: "preset-global", + name: "Global dev", + commands: [{ name: "Dev server" }, "npm run dev"], + useAsWorkspaceRun: true, + }, + ], + }); + + expect(definition).toEqual({ + source: "terminal-preset", + presetId: "preset-global", + name: "Global dev", + commands: ["npm run dev"], + }); + }); }); diff --git a/apps/desktop/src/shared/workspace-run-definition.ts b/apps/desktop/src/shared/workspace-run-definition.ts index 16c1e44d045..19ba4ad7d86 100644 --- a/apps/desktop/src/shared/workspace-run-definition.ts +++ b/apps/desktop/src/shared/workspace-run-definition.ts @@ -21,14 +21,17 @@ export type WorkspaceRunDefinition = export interface WorkspaceRunPresetLike { id: string; name: string; - commands: string[]; + commands: unknown[]; cwd?: string; projectIds?: string[] | null; useAsWorkspaceRun?: boolean; } -function nonEmptyCommands(commands: readonly string[] | null | undefined) { - return (commands ?? []).filter((command) => command.trim().length > 0); +function nonEmptyCommands(commands: readonly unknown[] | null | undefined) { + return (commands ?? []).filter( + (command): command is string => + typeof command === "string" && command.trim().length > 0, + ); } function normalizeCwd(cwd: string | undefined): string | undefined { @@ -42,7 +45,7 @@ export function configRunToWorkspaceRun({ cwd, }: { projectId: string; - commands: readonly string[] | null | undefined; + commands: readonly unknown[] | null | undefined; cwd?: string; }): WorkspaceRunDefinition | null { const resolvedCommands = nonEmptyCommands(commands); @@ -77,7 +80,7 @@ export function selectWorkspaceRunDefinition({ configCwd, }: { presets: readonly WorkspaceRunPresetLike[]; - configRunCommands?: readonly string[] | null; + configRunCommands?: readonly unknown[] | null; projectId: string; configCwd?: string; }): WorkspaceRunDefinition | null { diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index 41e8d331f56..098ab18a113 100644 --- a/packages/host-service/src/app.ts +++ b/packages/host-service/src/app.ts @@ -215,7 +215,7 @@ export function createApp(options: CreateAppOptions): CreateAppResult { console.warn("[host-service] revokeAllSessions failed:", err); } try { - pullRequestRuntime.stop(); + await pullRequestRuntime.stop(); } catch (err) { console.warn("[host-service] pullRequestRuntime.stop failed:", err); } @@ -229,6 +229,11 @@ export function createApp(options: CreateAppOptions): CreateAppResult { } catch (err) { console.warn("[host-service] gitWatcher.close failed:", err); } + try { + await filesystem.close(); + } catch (err) { + console.warn("[host-service] filesystem.close failed:", err); + } if (ownsDb) { try { (db as unknown as { $client?: { close: () => void } }).$client?.close(); diff --git a/packages/host-service/src/runtime/pull-requests/pull-requests.test.ts b/packages/host-service/src/runtime/pull-requests/pull-requests.test.ts index 68a73096f66..c7ec18b33d2 100644 --- a/packages/host-service/src/runtime/pull-requests/pull-requests.test.ts +++ b/packages/host-service/src/runtime/pull-requests/pull-requests.test.ts @@ -381,3 +381,40 @@ describe("PullRequestRuntimeManager direct checkout PR linking", () => { expect(state.workspace.pullRequestId).toBe("pr-existing"); }); }); + +describe("PullRequestRuntimeManager shutdown", () => { + test("stop waits for startup background work before returning", async () => { + const manager = createManager(makeState("main")); + let releaseSync: () => void = () => { + throw new Error("sync did not start"); + }; + let resolveStarted: () => void = () => {}; + const started = new Promise((resolve) => { + resolveStarted = resolve; + }); + let completed = false; + const internals = manager as unknown as { + syncWorkspaceBranches: () => Promise; + refreshEligibleProjects: () => Promise; + }; + internals.syncWorkspaceBranches = async () => { + resolveStarted(); + await new Promise((resolve) => { + releaseSync = resolve; + }); + completed = true; + }; + internals.refreshEligibleProjects = async () => {}; + + manager.start(); + await started; + + const stopPromise = manager.stop(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(completed).toBe(false); + + releaseSync(); + await stopPromise; + expect(completed).toBe(true); + }); +}); diff --git a/packages/host-service/src/runtime/pull-requests/pull-requests.ts b/packages/host-service/src/runtime/pull-requests/pull-requests.ts index 37ee51783ee..b043a33be49 100644 --- a/packages/host-service/src/runtime/pull-requests/pull-requests.ts +++ b/packages/host-service/src/runtime/pull-requests/pull-requests.ts @@ -271,10 +271,12 @@ export class PullRequestRuntimeManager { string, { running: Promise; rerunPending: boolean } >(); + private readonly backgroundTasks = new Set>(); private readonly pullRequestHeadCache = new Map< string, { promise: Promise; fetchedAt: number } >(); + private stopped = false; constructor(options: PullRequestRuntimeManagerOptions) { this.db = options.db; @@ -291,12 +293,17 @@ export class PullRequestRuntimeManager { this.unsubscribeFromGitWatcher ) return; + this.stopped = false; // One initial sweep so workspaces that existed before this manager // started have correct branch/sha/upstream rows even if no `.git/` // activity has happened since the last process boot. - void this.syncWorkspaceBranches(); - void this.refreshEligibleProjects(); + this.runBackgroundTask("syncWorkspaceBranches", () => + this.syncWorkspaceBranches(), + ); + this.runBackgroundTask("refreshEligibleProjects", () => + this.refreshEligibleProjects(), + ); // Steady-state: react to real `.git/` activity per workspace. Per-workspace // debounce lives in `GitWatcher` (300 ms), and concurrent project refreshes @@ -304,25 +311,60 @@ export class PullRequestRuntimeManager { // workspace so two debounce-separated bursts can't race their git reads // and have the slower one overwrite the newer snapshot. this.unsubscribeFromGitWatcher = this.gitWatcher.onChanged((event) => { - void this.enqueueWorkspaceSync(event.workspaceId); + if (!this.stopped) void this.enqueueWorkspaceSync(event.workspaceId); }); // Long-cadence safety net for `GitWatcher` overflow / error paths. this.safetyNetTimer = setInterval(() => { - void this.syncWorkspaceBranches(); + this.runBackgroundTask("syncWorkspaceBranches", () => + this.syncWorkspaceBranches(), + ); }, SAFETY_NET_INTERVAL_MS); this.projectRefreshTimer = setInterval(() => { - void this.refreshEligibleProjects(); + this.runBackgroundTask("refreshEligibleProjects", () => + this.refreshEligibleProjects(), + ); }, PROJECT_REFRESH_INTERVAL_MS); } - stop() { + async stop(): Promise { + this.stopped = true; if (this.safetyNetTimer) clearInterval(this.safetyNetTimer); if (this.projectRefreshTimer) clearInterval(this.projectRefreshTimer); this.unsubscribeFromGitWatcher?.(); this.safetyNetTimer = null; this.projectRefreshTimer = null; this.unsubscribeFromGitWatcher = null; + await this.drainInFlightWork(); + this.pullRequestHeadCache.clear(); + } + + private runBackgroundTask(label: string, task: () => Promise): void { + const promise = task() + .catch((error) => { + console.warn(`[host-service:pull-request-runtime] ${label} failed`, { + error, + }); + }) + .finally(() => { + this.backgroundTasks.delete(promise); + }); + this.backgroundTasks.add(promise); + } + + private async drainInFlightWork(): Promise { + for (let pass = 0; pass < 10; pass++) { + const tasks = [ + ...this.backgroundTasks, + ...Array.from( + this.workspaceSyncState.values(), + (state) => state.running, + ), + ...this.inFlightProjects.values(), + ]; + if (tasks.length === 0) return; + await Promise.allSettled(tasks); + } } async getPullRequestsByWorkspaces( @@ -450,6 +492,7 @@ export class PullRequestRuntimeManager { } private async syncWorkspaceBranches(): Promise { + if (this.stopped) return; // Route every workspace through the same per-workspace queue as the // watcher path, so a concurrent watcher-triggered sync can't race the // sweep's read+write and clobber the newer snapshot. enqueueWorkspaceSync @@ -461,11 +504,13 @@ export class PullRequestRuntimeManager { // original sweep's behavior. refreshProject inside each sync still // dedupes across workspaces in the same project via inFlightProjects. for (const row of ids) { + if (this.stopped) break; await this.enqueueWorkspaceSync(row.id); } } private enqueueWorkspaceSync(workspaceId: string): Promise { + if (this.stopped) return Promise.resolve(); // Coalesce: if a sync is already running for this workspace, just mark // "rerun pending" — there's no value in queuing N back-to-back syncs // when only the final state matters. At most one sync runs and one @@ -569,6 +614,7 @@ export class PullRequestRuntimeManager { } private async refreshEligibleProjects(): Promise { + if (this.stopped) return; const rows = this.db .select({ projectId: workspaces.projectId, diff --git a/packages/host-service/src/trpc/router/git/git.ts b/packages/host-service/src/trpc/router/git/git.ts index 047188fc440..aab11db90fa 100644 --- a/packages/host-service/src/trpc/router/git/git.ts +++ b/packages/host-service/src/trpc/router/git/git.ts @@ -27,6 +27,7 @@ import { getDefaultBranchName, mapGitStatus, parseNumstat, + parseNumstatRecords, resolveBaseComparison, } from "./utils/git-helpers"; import { @@ -58,6 +59,45 @@ function assertSafeRelativePath(filePath: string): void { } } +interface DiffStats { + additions: number; + deletions: number; +} + +function addDiffStats( + byPath: Map, + path: string, + stats: DiffStats, +): void { + const existing = byPath.get(path); + byPath.set(path, { + additions: (existing?.additions ?? 0) + stats.additions, + deletions: (existing?.deletions ?? 0) + stats.deletions, + }); +} + +function applyNumstatToStatsMap( + byPath: Map, + raw: string, +): void { + for (const record of parseNumstatRecords(raw)) { + addDiffStats(byPath, record.path, { + additions: record.additions, + deletions: record.deletions, + }); + } +} + +function sumDiffStats(byPath: Map): DiffStats { + let additions = 0; + let deletions = 0; + for (const file of byPath.values()) { + additions += file.additions; + deletions += file.deletions; + } + return { additions, deletions }; +} + export const gitRouter = router({ listBranches: queryProcedure .input(z.object({ workspaceId: z.string() })) @@ -247,6 +287,57 @@ export const gitRouter = router({ }; }), + getDiffStats: queryProcedure + .meta({ timeoutMs: 10_000 }) + .input( + z.object({ + workspaceId: z.string(), + baseBranch: z.string().optional(), + }), + ) + .query(async ({ ctx, input }) => { + const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const git = await ctx.git(worktreePath); + const base = await resolveBaseComparison(git, input.baseBranch); + const baseRef = base?.baseRef ?? "HEAD"; + + const [againstBaseRaw, stagedRaw, unstagedRaw, untrackedRaw] = + await Promise.all([ + git + .raw(["diff", "--numstat", "-z", `${baseRef}...HEAD`]) + .catch(() => ""), + git.raw(["diff", "--numstat", "-z", "--cached"]).catch(() => ""), + git.raw(["diff", "--numstat", "-z"]).catch(() => ""), + git + .raw(["ls-files", "--others", "--exclude-standard", "-z"]) + .catch(() => ""), + ]); + + const byPath = new Map(); + applyNumstatToStatsMap(byPath, againstBaseRaw); + applyNumstatToStatsMap(byPath, stagedRaw); + applyNumstatToStatsMap(byPath, unstagedRaw); + + const untrackedFiles: ChangedFile[] = untrackedRaw + .split("\0") + .filter((path) => path.length > 0) + .map((path) => ({ + path, + status: "untracked", + additions: 0, + deletions: 0, + })); + await countUntrackedFileLines(worktreePath, untrackedFiles); + for (const file of untrackedFiles) { + addDiffStats(byPath, file.path, { + additions: file.additions, + deletions: file.deletions, + }); + } + + return sumDiffStats(byPath); + }), + listCommits: queryProcedure .meta({ timeoutMs: 30_000 }) .input( diff --git a/packages/host-service/src/trpc/router/git/utils/config-write.ts b/packages/host-service/src/trpc/router/git/utils/config-write.ts index a76a0f99c46..cb17d9c712e 100644 --- a/packages/host-service/src/trpc/router/git/utils/config-write.ts +++ b/packages/host-service/src/trpc/router/git/utils/config-write.ts @@ -1,5 +1,3 @@ -import type { SimpleGit } from "simple-git"; - /** * Run a `git config` write with bounded retries on `.git/config.lock` * contention. @@ -15,8 +13,12 @@ import type { SimpleGit } from "simple-git"; * second writer just waits its turn instead of bubbling a confusing 500 * to the renderer. */ +interface GitConfigWriter { + raw(args: string[]): Promise; +} + export async function gitConfigWrite( - git: SimpleGit, + git: GitConfigWriter, args: string[], options: { retries?: number; baseDelayMs?: number } = {}, ): Promise { diff --git a/packages/host-service/src/trpc/router/git/utils/git-helpers.test.ts b/packages/host-service/src/trpc/router/git/utils/git-helpers.test.ts index 4ce2ecc6b12..92f5d488880 100644 --- a/packages/host-service/src/trpc/router/git/utils/git-helpers.test.ts +++ b/packages/host-service/src/trpc/router/git/utils/git-helpers.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { parseNameStatus, parseNumstat } from "./git-helpers"; +import { + parseNameStatus, + parseNumstat, + parseNumstatRecords, +} from "./git-helpers"; describe("parseNumstat", () => { test("regular file entry", () => { @@ -80,6 +84,39 @@ describe("parseNumstat", () => { }); }); +describe("parseNumstatRecords", () => { + test("returns one record per regular file", () => { + const raw = "5\t2\tsrc/foo.ts\x003\t0\tsrc/bar.ts\x00"; + expect(parseNumstatRecords(raw)).toEqual([ + { path: "src/foo.ts", additions: 5, deletions: 2 }, + { path: "src/bar.ts", additions: 3, deletions: 0 }, + ]); + }); + + test("rename is represented once by destination path", () => { + const raw = "4\t3\t\x00src/old.ts\x00src/new.ts\x00"; + expect(parseNumstatRecords(raw)).toEqual([ + { + path: "src/new.ts", + oldPath: "src/old.ts", + additions: 4, + deletions: 3, + }, + ]); + }); + + test("omits empty oldPath for malformed rename records", () => { + const raw = "4\t3\t\x00\x00src/new.ts\x00"; + expect(parseNumstatRecords(raw)).toEqual([ + { + path: "src/new.ts", + additions: 4, + deletions: 3, + }, + ]); + }); +}); + describe("parseNameStatus", () => { test("regular modification", () => { const raw = "M\x00src/foo.ts\x00"; diff --git a/packages/host-service/src/trpc/router/git/utils/git-helpers.ts b/packages/host-service/src/trpc/router/git/utils/git-helpers.ts index f0b0dc833a9..6eb8968a42c 100644 --- a/packages/host-service/src/trpc/router/git/utils/git-helpers.ts +++ b/packages/host-service/src/trpc/router/git/utils/git-helpers.ts @@ -73,6 +73,31 @@ export function parseNumstat( raw: string, ): Map { const result = new Map(); + for (const record of parseNumstatRecords(raw)) { + const stats = { + additions: record.additions, + deletions: record.deletions, + }; + result.set(record.path, stats); + if (record.oldPath) result.set(record.oldPath, stats); + } + return result; +} + +export interface NumstatRecord { + path: string; + oldPath?: string; + additions: number; + deletions: number; +} + +/** + * Parse `git diff --numstat -z` into one record per changed file. Unlike + * `parseNumstat`, renamed files are returned once under the destination path + * so callers that sum totals do not double-count the old and new names. + */ +export function parseNumstatRecords(raw: string): NumstatRecord[] { + const result: NumstatRecord[] = []; const entries = raw.split("\0"); for (let i = 0; i < entries.length; i++) { const entry = entries[i]; @@ -90,10 +115,15 @@ export function parseNumstat( if (pathMaybe === "") { const oldPath = entries[++i] ?? ""; const newPath = entries[++i] ?? ""; - if (newPath) result.set(newPath, stats); - if (oldPath) result.set(oldPath, stats); + if (newPath) { + result.push({ + path: newPath, + ...(oldPath ? { oldPath } : {}), + ...stats, + }); + } } else { - result.set(pathMaybe, stats); + result.push({ path: pathMaybe, ...stats }); } } return result; diff --git a/packages/host-service/src/trpc/router/project/utils/ensure-main-workspace.ts b/packages/host-service/src/trpc/router/project/utils/ensure-main-workspace.ts index 122e2b668d5..8a25c61fb79 100644 --- a/packages/host-service/src/trpc/router/project/utils/ensure-main-workspace.ts +++ b/packages/host-service/src/trpc/router/project/utils/ensure-main-workspace.ts @@ -8,6 +8,12 @@ export type EnsureMainWorkspaceContext = Pick< "api" | "db" | "git" | "organizationId" >; +const mainWorkspaceEnsuresInFlight = new Map>(); + +function mainWorkspaceEnsureKey(projectId: string, repoPath: string): string { + return `${projectId}\0${repoPath}`; +} + async function getCurrentBranchName( git: Awaited>, ): Promise { @@ -58,6 +64,30 @@ export async function ensureMainWorkspaceStrict( ctx: EnsureMainWorkspaceContext, projectId: string, repoPath: string, +): Promise<{ id: string }> { + const key = mainWorkspaceEnsureKey(projectId, repoPath); + const inFlight = mainWorkspaceEnsuresInFlight.get(key); + if (inFlight) return await inFlight; + + const promise = ensureMainWorkspaceStrictUncoalesced( + ctx, + projectId, + repoPath, + ); + mainWorkspaceEnsuresInFlight.set(key, promise); + try { + return await promise; + } finally { + if (mainWorkspaceEnsuresInFlight.get(key) === promise) { + mainWorkspaceEnsuresInFlight.delete(key); + } + } +} + +async function ensureMainWorkspaceStrictUncoalesced( + ctx: EnsureMainWorkspaceContext, + projectId: string, + repoPath: string, ): Promise<{ id: string }> { const git = await ctx.git(repoPath); const branch = await getCurrentBranchName(git); diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/adopt-existing-worktree.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/adopt-existing-worktree.ts index 8b898047423..95ad5def667 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/shared/adopt-existing-worktree.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/adopt-existing-worktree.ts @@ -295,7 +295,7 @@ async function recordBaseBranch( baseBranch: string | undefined, ): Promise { if (!baseBranch) return; - await gitConfigWrite(git as Parameters[0], [ + await gitConfigWrite(git, [ "config", `branch.${branch}.base`, baseBranch, diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/git-config.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/git-config.ts index 296722f4d56..2bb9d6eb687 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/shared/git-config.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/git-config.ts @@ -1,3 +1,4 @@ +import { gitConfigWrite } from "../../git/utils/config-write"; import type { GitClient } from "./types"; export async function enablePushAutoSetupRemote( @@ -5,16 +6,14 @@ export async function enablePushAutoSetupRemote( worktreePath: string, logPrefix: string, ): Promise { - await git - .raw([ - "-C", - worktreePath, - "config", - "--local", - "push.autoSetupRemote", - "true", - ]) - .catch((err) => { - console.warn(`${logPrefix} failed to set push.autoSetupRemote:`, err); - }); + await gitConfigWrite(git, [ + "-C", + worktreePath, + "config", + "--local", + "push.autoSetupRemote", + "true", + ]).catch((err) => { + console.warn(`${logPrefix} failed to set push.autoSetupRemote:`, err); + }); } diff --git a/packages/host-service/src/trpc/router/workspaces/workspaces.ts b/packages/host-service/src/trpc/router/workspaces/workspaces.ts index 323a2832dba..cfabd8dfc1a 100644 --- a/packages/host-service/src/trpc/router/workspaces/workspaces.ts +++ b/packages/host-service/src/trpc/router/workspaces/workspaces.ts @@ -15,6 +15,7 @@ import { import type { HostServiceContext } from "../../../types"; import { protectedProcedure, router } from "../../index"; import { type AgentRunResult, runAgentInWorkspace } from "../agents"; +import { gitConfigWrite } from "../git/utils/config-write"; import { ensureMainWorkspace } from "../project/utils/ensure-main-workspace"; import { adoptExistingWorktree } from "../workspace-creation/shared/adopt-existing-worktree"; import { @@ -350,20 +351,18 @@ async function recordBaseBranchConfig(args: { branch: string; baseBranch: string; }): Promise { - await args.git - .raw([ - "-C", - args.worktreePath, - "config", - `branch.${args.branch}.base`, - args.baseBranch, - ]) - .catch((err) => { - console.warn( - `[workspaces.create] failed to record base branch ${args.baseBranch}:`, - err, - ); - }); + await gitConfigWrite(args.git, [ + "-C", + args.worktreePath, + "config", + `branch.${args.branch}.base`, + args.baseBranch, + ]).catch((err) => { + console.warn( + `[workspaces.create] failed to record base branch ${args.baseBranch}:`, + err, + ); + }); } /** @@ -909,18 +908,19 @@ export const workspacesRouter = router({ if (!plan.usedExistingBranch && plan.startPoint.kind !== "head") { const baseShortName = plan.startPoint.shortName; - await git - .raw([ - "config", - `branch.${resolvedBranch}.base`, - baseShortName, - ]) - .catch((err) => { - console.warn( - `[workspaces.create] failed to record base branch ${baseShortName}:`, - err, - ); - }); + await gitConfigWrite(git, [ + "-C", + worktreePath, + "config", + "--local", + `branch.${resolvedBranch}.base`, + baseShortName, + ]).catch((err) => { + console.warn( + `[workspaces.create] failed to record base branch ${baseShortName}:`, + err, + ); + }); } workspaceRow = await registerCloudAndLocal({ diff --git a/packages/host-service/test/integration/bug-hunt-3.integration.test.ts b/packages/host-service/test/integration/bug-hunt-3.integration.test.ts index 7ef51694722..baa0d778271 100644 --- a/packages/host-service/test/integration/bug-hunt-3.integration.test.ts +++ b/packages/host-service/test/integration/bug-hunt-3.integration.test.ts @@ -238,7 +238,7 @@ describe("bug-hunt-3: more concurrency probes", () => { repo.dispose(); }); - test("BUG: parallel workspace.create calls for different branches can race on the same .git/config", async () => { + test("parallel workspace.create calls for different branches retry .git/config lock contention", async () => { host = await createTestHost({ apiOverrides: { "host.ensure.mutate": () => ({ machineId: "m1" }), @@ -261,18 +261,29 @@ describe("bug-hunt-3: more concurrency probes", () => { // Two different branches in parallel — they both call // `git worktree add` and `git branch..base` writes via // ensureMainWorkspace / inside the procedure. - const results = await Promise.allSettled([ - host.trpc.workspaces.create.mutate({ - projectId, - name: "a", - branch: "feature/a", - }), - host.trpc.workspaces.create.mutate({ - projectId, - name: "b", - branch: "feature/b", - }), - ]); + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(args.map(String).join(" ")); + originalWarn(...args); + }; + let results: PromiseSettledResult[] = []; + try { + results = await Promise.allSettled([ + host.trpc.workspaces.create.mutate({ + projectId, + name: "a", + branch: "feature/a", + }), + host.trpc.workspaces.create.mutate({ + projectId, + name: "b", + branch: "feature/b", + }), + ]); + } finally { + console.warn = originalWarn; + } // Document current behavior. If both succeed, great — we have no // bug. If one fails with a config-lock or worktree-lock error, @@ -287,5 +298,13 @@ describe("bug-hunt-3: more concurrency probes", () => { // We currently expect this to be tolerated. If it starts failing, // flip to a `test.todo` documenting the regression. expect(failures.length).toBe(0); + expect( + warnings.some( + (warning) => + warning.includes("could not lock config file") || + warning.includes("failed to record base branch") || + warning.includes("failed to set push.autoSetupRemote"), + ), + ).toBe(false); }); }); diff --git a/packages/host-service/test/integration/git.integration.test.ts b/packages/host-service/test/integration/git.integration.test.ts index 270347b3c4e..5f1dc53c865 100644 --- a/packages/host-service/test/integration/git.integration.test.ts +++ b/packages/host-service/test/integration/git.integration.test.ts @@ -59,6 +59,27 @@ describe("git router integration", () => { ); }); + test("getDiffStats returns sidebar totals without full status payload", async () => { + writeFileSync(join(scenario.repo.repoPath, "staged.txt"), "one\ntwo\n"); + await scenario.repo.git.add("staged.txt"); + writeFileSync(join(scenario.repo.repoPath, "mixed.txt"), "base\n"); + await scenario.repo.git.add("mixed.txt"); + writeFileSync( + join(scenario.repo.repoPath, "mixed.txt"), + "base\nunstaged one\nunstaged two\n", + ); + writeFileSync( + join(scenario.repo.repoPath, "untracked.txt"), + "alpha\nbeta\ngamma\n", + ); + + const stats = await scenario.host.trpc.git.getDiffStats.query({ + workspaceId: scenario.workspaceId, + }); + + expect(stats).toEqual({ additions: 8, deletions: 0 }); + }); + test("getBaseBranch returns null when not configured", async () => { const result = await scenario.host.trpc.git.getBaseBranch.query({ workspaceId: scenario.workspaceId, diff --git a/packages/host-service/test/integration/workspace-cleanup.integration.test.ts b/packages/host-service/test/integration/workspace-cleanup.integration.test.ts index f8f4fccdeba..390ea3b20c9 100644 --- a/packages/host-service/test/integration/workspace-cleanup.integration.test.ts +++ b/packages/host-service/test/integration/workspace-cleanup.integration.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; -import { rmSync, writeFileSync } from "node:fs"; +import { existsSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { TRPCClientError } from "@trpc/client"; import { eq } from "drizzle-orm"; @@ -192,6 +192,58 @@ describe("workspaceCleanup.destroy integration", () => { expect(worktreeList).toContain(otherWorktreePath); }); + test("concurrent destroy of the same workspace rejects the second call and completes cleanup", async () => { + await scenario.dispose(); + + let releaseCloudDelete!: () => void; + const cloudDeleteStarted = Promise.withResolvers(); + const cloudDeleteRelease = new Promise((resolve) => { + releaseCloudDelete = resolve; + }); + scenario = await createFeatureWorktreeScenario({ + hostOptions: { + apiOverrides: { + "v2Workspace.getFromHost.query": cloudOk.workspaceGetFromHost({ + type: "feature", + }), + "v2Workspace.delete.mutate": async () => { + cloudDeleteStarted.resolve(); + await cloudDeleteRelease; + return { success: true }; + }, + }, + }, + }); + + const first = scenario.host.trpc.workspaceCleanup.destroy.mutate({ + workspaceId: scenario.featureWorkspaceId, + deleteBranch: true, + force: true, + }); + await cloudDeleteStarted.promise; + + await expect( + scenario.host.trpc.workspaceCleanup.destroy.mutate({ + workspaceId: scenario.featureWorkspaceId, + force: true, + }), + ).rejects.toThrow(/Deletion already in progress/); + + releaseCloudDelete(); + const result = await first; + expect(result.success).toBe(true); + expect(result.worktreeRemoved).toBe(true); + expect(result.branchDeleted).toBe(true); + expect(existsSync(scenario.worktreePath)).toBe(false); + + const remaining = scenario.host.db + .select() + .from(workspaces) + .where(eq(workspaces.id, scenario.featureWorkspaceId)) + .all(); + expect(remaining).toHaveLength(0); + }); + test("returns success when no local workspace row exists, still calls cloud delete", async () => { await scenario.dispose(); const fresh = await createBasicScenario({ diff --git a/packages/host-service/test/integration/workspace-create-delete.integration.test.ts b/packages/host-service/test/integration/workspace-create-delete.integration.test.ts index 8c8db4824f7..bd7f7d18e47 100644 --- a/packages/host-service/test/integration/workspace-create-delete.integration.test.ts +++ b/packages/host-service/test/integration/workspace-create-delete.integration.test.ts @@ -287,6 +287,84 @@ describe("workspace.create + workspace.delete integration", () => { ).toBe(true); }); + test("parallel create() then destroy() churn leaves no duplicate rows or stale worktrees", async () => { + const scenario = await createProjectScenario({ + hostOptions: { + apiOverrides: { + ...cloudFlows.workspaceCreateOk(), + ...cloudFlows.workspaceDeleteOk(), + }, + }, + }); + dispose = scenario.dispose; + + const branches = ["feature/churn-a", "feature/churn-b", "feature/churn-c"]; + const createResults = await Promise.all( + branches.map((branch) => + scenario.host.trpc.workspaces.create.mutate({ + projectId: scenario.projectId, + name: branch, + branch, + }), + ), + ); + + const createdRows = createResults.map((result) => result.workspace); + expect(createdRows.map((row) => row.branch).sort()).toEqual( + branches.toSorted(), + ); + + const rowsAfterCreate = scenario.host.db.select().from(workspaces).all(); + const featureRows = rowsAfterCreate.filter((row) => + branches.includes(row.branch), + ); + const mainRows = rowsAfterCreate.filter( + (row) => row.worktreePath === scenario.repo.repoPath, + ); + expect(featureRows).toHaveLength(branches.length); + expect(mainRows).toHaveLength(1); + for (const row of featureRows) { + expect(existsSync(row.worktreePath)).toBe(true); + } + + const destroyResults = await Promise.all( + createdRows.map((row) => + scenario.host.trpc.workspaceCleanup.destroy.mutate({ + workspaceId: row.id, + deleteBranch: true, + force: true, + }), + ), + ); + expect(destroyResults.every((result) => result.success)).toBe(true); + + const rowsAfterDestroy = scenario.host.db.select().from(workspaces).all(); + expect( + rowsAfterDestroy.filter((row) => branches.includes(row.branch)), + ).toHaveLength(0); + expect( + rowsAfterDestroy.filter( + (row) => row.worktreePath === scenario.repo.repoPath, + ), + ).toHaveLength(1); + for (const row of featureRows) { + expect(existsSync(row.worktreePath)).toBe(false); + } + + const worktreeList = await scenario.repo.git.raw([ + "worktree", + "list", + "--porcelain", + ]); + for (const row of featureRows) { + expect(worktreeList).not.toContain(row.worktreePath); + } + const localBranches = await scenario.repo.git.branchLocal(); + for (const branch of branches) { + expect(localBranches.all).not.toContain(branch); + } + }); + test("delete() requires authentication", async () => { const scenario = await createBasicScenario(); dispose = scenario.dispose; diff --git a/packages/panes/src/react/components/Workspace/Workspace.tsx b/packages/panes/src/react/components/Workspace/Workspace.tsx index 370d9b668d3..807a4beeb83 100644 --- a/packages/panes/src/react/components/Workspace/Workspace.tsx +++ b/packages/panes/src/react/components/Workspace/Workspace.tsx @@ -40,7 +40,11 @@ export function Workspace({ } for (const [prevId, prevPane] of previousPanesRef.current) { if (!current.has(prevId)) { - registry[prevPane.kind]?.onAfterClose?.(prevPane); + try { + registry[prevPane.kind]?.onAfterClose?.(prevPane); + } catch (err) { + console.error("onAfterClose threw", err); + } } } previousPanesRef.current = current; diff --git a/packages/panes/src/react/components/Workspace/components/TabBar/TabBar.tsx b/packages/panes/src/react/components/Workspace/components/TabBar/TabBar.tsx index 4d3c117d869..0ffdcb8753d 100644 --- a/packages/panes/src/react/components/Workspace/components/TabBar/TabBar.tsx +++ b/packages/panes/src/react/components/Workspace/components/TabBar/TabBar.tsx @@ -4,12 +4,14 @@ import { DropdownMenuContent, DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; -import { OverflowFadeContainer } from "@superset/ui/overflow-fade-container"; import { PlusIcon } from "lucide-react"; import { - type ComponentProps, type ReactNode, + type UIEvent, useCallback, + useEffect, + useLayoutEffect, + useMemo, useRef, useState, } from "react"; @@ -18,7 +20,7 @@ import type { Tab } from "../../../../../types"; import type { PaneRegistry } from "../../../../types"; import { PANE_DRAG_TYPE } from "../Tab/components/Pane/components/PaneHeader"; import { TAB_DRAG_TYPE, TabItem } from "./components/TabItem"; -import { computeInsertIndex, TAB_WIDTH } from "./utils"; +import { computeInsertIndex, getVisibleTabWindow, TAB_WIDTH } from "./utils"; interface TabBarProps { tabs: Tab[]; @@ -87,7 +89,12 @@ export function TabBar({ renderTabAccessory, }: TabBarProps) { const tabsTrackRef = useRef(null); - const [hasHorizontalOverflow, setHasHorizontalOverflow] = useState(false); + const scrollContainerRef = useRef(null); + const scrollMetricsFrameRef = useRef(null); + const [scrollMetrics, setScrollMetrics] = useState({ + clientWidth: 0, + scrollLeft: 0, + }); const insertIndexRef = useRef(null); const [insertIndex, setInsertIndex] = useState(null); @@ -142,6 +149,119 @@ export function TabBar({ [tabs, onReorderTab, onMovePaneToNewTab], ); + const readScrollMetrics = useCallback(() => { + const node = scrollContainerRef.current; + if (!node) return; + const nextMetrics = { + clientWidth: node.clientWidth, + scrollLeft: node.scrollLeft, + }; + setScrollMetrics((currentMetrics) => + currentMetrics.clientWidth === nextMetrics.clientWidth && + currentMetrics.scrollLeft === nextMetrics.scrollLeft + ? currentMetrics + : nextMetrics, + ); + }, []); + + const scheduleScrollMetricsUpdate = useCallback(() => { + if (scrollMetricsFrameRef.current !== null) return; + if (typeof requestAnimationFrame !== "function") { + readScrollMetrics(); + return; + } + scrollMetricsFrameRef.current = requestAnimationFrame(() => { + scrollMetricsFrameRef.current = null; + readScrollMetrics(); + }); + }, [readScrollMetrics]); + + const handleScroll = useCallback( + (_event: UIEvent) => { + scheduleScrollMetricsUpdate(); + }, + [scheduleScrollMetricsUpdate], + ); + + useEffect( + () => () => { + if ( + scrollMetricsFrameRef.current !== null && + typeof cancelAnimationFrame === "function" + ) { + cancelAnimationFrame(scrollMetricsFrameRef.current); + scrollMetricsFrameRef.current = null; + } + }, + [], + ); + + useLayoutEffect(() => { + const node = scrollContainerRef.current; + if (!node) return; + + readScrollMetrics(); + const resizeObserver = new ResizeObserver(scheduleScrollMetricsUpdate); + resizeObserver.observe(node); + + return () => { + resizeObserver.disconnect(); + }; + }, [readScrollMetrics, scheduleScrollMetricsUpdate]); + + useLayoutEffect(() => { + readScrollMetrics(); + }, [readScrollMetrics]); + + const activeTabIndex = useMemo( + () => tabs.findIndex((tab) => tab.id === activeTabId), + [activeTabId, tabs], + ); + const totalTabsWidth = tabs.length * TAB_WIDTH; + const addTabButtonWidth = 40; + const hasHorizontalOverflow = + scrollMetrics.clientWidth > 0 && + totalTabsWidth + addTabButtonWidth > scrollMetrics.clientWidth + 1; + const inlineAddTabWidth = hasHorizontalOverflow ? 0 : addTabButtonWidth; + const tabsTrackWidth = totalTabsWidth + inlineAddTabWidth; + + useLayoutEffect(() => { + const node = scrollContainerRef.current; + const viewportWidth = scrollMetrics.clientWidth; + if (!node || activeTabIndex < 0 || viewportWidth <= 0) return; + + const tabLeft = activeTabIndex * TAB_WIDTH; + const tabRight = tabLeft + TAB_WIDTH; + const viewportLeft = scrollMetrics.scrollLeft; + const viewportRight = viewportLeft + viewportWidth; + let nextScrollLeft = viewportLeft; + + if (tabLeft < viewportLeft) { + nextScrollLeft = tabLeft; + } else if (tabRight > viewportRight) { + nextScrollLeft = tabRight - viewportWidth; + } + + if (nextScrollLeft === viewportLeft) return; + + const boundedScrollLeft = Math.max( + 0, + Math.min(nextScrollLeft, tabsTrackWidth - viewportWidth), + ); + node.scrollLeft = boundedScrollLeft; + setScrollMetrics((currentMetrics) => + currentMetrics.clientWidth === viewportWidth && + currentMetrics.scrollLeft === boundedScrollLeft + ? currentMetrics + : { clientWidth: viewportWidth, scrollLeft: boundedScrollLeft }, + ); + }, [ + activeTabIndex, + scrollMetrics.clientWidth, + scrollMetrics.scrollLeft, + tabsTrackWidth, + ]); + // Clear indicator when cursor leaves the tab bar if (!isOver && insertIndexRef.current !== null) { insertIndexRef.current = null; @@ -155,15 +275,30 @@ export function TabBar({ [connectDrop], ); - const handleOverflowChange = useCallback< - NonNullable< - ComponentProps["onOverflowChange"] - > - >((state) => { - setHasHorizontalOverflow(state.hasOverflowX); + const setScrollContainerRef = useCallback((node: HTMLDivElement | null) => { + scrollContainerRef.current = node; }, []); const insertLineLeft = insertIndex !== null ? insertIndex * TAB_WIDTH : null; + const visibleTabWindow = useMemo( + () => + getVisibleTabWindow({ + clientWidth: scrollMetrics.clientWidth, + scrollLeft: scrollMetrics.scrollLeft, + tabCount: tabs.length, + }), + [scrollMetrics.clientWidth, scrollMetrics.scrollLeft, tabs.length], + ); + const visibleTabs = useMemo( + () => + tabs + .slice(visibleTabWindow.start, visibleTabWindow.end) + .map((tab, offset) => ({ + index: visibleTabWindow.start + offset, + tab, + })), + [tabs, visibleTabWindow], + ); if (tabs.length === 0) { return ( @@ -189,23 +324,27 @@ export function TabBar({ ref={setRootRef} className="group/root-tabs flex h-10 min-w-0 shrink-0 items-stretch border-b border-border bg-background" > - -
- {tabs.map((tab, i) => ( +
+ {visibleTabs.map(({ tab, index }) => (
onSelectTab(tab.id)} onClose={() => onCloseTab(tab.id)} @@ -224,12 +363,15 @@ export function TabBar({ /> )} {!hasHorizontalOverflow && ( -
+
)}
- +
{hasHorizontalOverflow && (
diff --git a/packages/panes/src/react/components/Workspace/components/TabBar/components/TabItem/TabItem.tsx b/packages/panes/src/react/components/Workspace/components/TabBar/components/TabItem/TabItem.tsx index 935b9431f42..8f64793493f 100644 --- a/packages/panes/src/react/components/Workspace/components/TabBar/components/TabItem/TabItem.tsx +++ b/packages/panes/src/react/components/Workspace/components/TabBar/components/TabItem/TabItem.tsx @@ -6,11 +6,10 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "@superset/ui/context-menu"; -import { OverflowFadeText } from "@superset/ui/overflow-fade-text"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { PencilIcon, XIcon } from "lucide-react"; -import { type ReactNode, useCallback, useRef, useState } from "react"; +import { memo, type ReactNode, useCallback, useRef, useState } from "react"; import { useDrag, useDrop } from "react-dnd"; import type { Tab } from "../../../../../../../types"; import type { PaneRegistry } from "../../../../../../types"; @@ -35,7 +34,7 @@ interface TabItemProps { accessory?: ReactNode; } -export function TabItem({ +function TabItemComponent({ tab, tabs, registry, @@ -156,9 +155,7 @@ export function TabItem({ type="button" > {icon && {icon}} - - {title} - + {title} @@ -217,3 +214,21 @@ export function TabItem({ ); } + +function areTabItemPropsEqual( + previous: TabItemProps, + next: TabItemProps, +) { + return ( + previous.tab === next.tab && + previous.tabs === next.tabs && + previous.registry === next.registry && + previous.index === next.index && + previous.isActive === next.isActive + ); +} + +export const TabItem = memo( + TabItemComponent, + areTabItemPropsEqual, +) as typeof TabItemComponent; diff --git a/packages/panes/src/react/components/Workspace/components/TabBar/utils/index.ts b/packages/panes/src/react/components/Workspace/components/TabBar/utils/index.ts index a4f01d7c568..5537a977da1 100644 --- a/packages/panes/src/react/components/Workspace/components/TabBar/utils/index.ts +++ b/packages/panes/src/react/components/Workspace/components/TabBar/utils/index.ts @@ -1 +1,7 @@ -export { computeInsertIndex, TAB_WIDTH } from "./utils"; +export { + computeInsertIndex, + getVisibleTabWindow, + TAB_WIDTH, + TAB_WINDOW_OVERSCAN, + TAB_WINDOWING_THRESHOLD, +} from "./utils"; diff --git a/packages/panes/src/react/components/Workspace/components/TabBar/utils/utils.test.ts b/packages/panes/src/react/components/Workspace/components/TabBar/utils/utils.test.ts new file mode 100644 index 00000000000..31eb548e440 --- /dev/null +++ b/packages/panes/src/react/components/Workspace/components/TabBar/utils/utils.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "bun:test"; +import { getVisibleTabWindow, TAB_WIDTH } from "./utils"; + +describe("getVisibleTabWindow", () => { + it("renders all tabs below the windowing threshold", () => { + expect( + getVisibleTabWindow({ + clientWidth: TAB_WIDTH * 2, + scrollLeft: TAB_WIDTH * 3, + tabCount: 12, + }), + ).toEqual({ start: 0, end: 12 }); + }); + + it("limits large tab sets to the visible range plus overscan", () => { + expect( + getVisibleTabWindow({ + clientWidth: TAB_WIDTH * 3, + overscan: 2, + scrollLeft: TAB_WIDTH * 10, + tabCount: 80, + }), + ).toEqual({ start: 8, end: 15 }); + }); + + it("falls back to the initial window before layout has measured", () => { + expect( + getVisibleTabWindow({ + clientWidth: 0, + scrollLeft: 0, + tabCount: 80, + windowingThreshold: 24, + }), + ).toEqual({ start: 0, end: 24 }); + }); +}); diff --git a/packages/panes/src/react/components/Workspace/components/TabBar/utils/utils.ts b/packages/panes/src/react/components/Workspace/components/TabBar/utils/utils.ts index d3cd75e1d07..6ca8324fdf6 100644 --- a/packages/panes/src/react/components/Workspace/components/TabBar/utils/utils.ts +++ b/packages/panes/src/react/components/Workspace/components/TabBar/utils/utils.ts @@ -1,4 +1,43 @@ export const TAB_WIDTH = 160; +export const TAB_WINDOWING_THRESHOLD = 12; +export const TAB_WINDOW_OVERSCAN = 4; + +interface VisibleTabWindowInput { + clientWidth: number; + overscan?: number; + scrollLeft: number; + tabCount: number; + windowingThreshold?: number; +} + +export function getVisibleTabWindow({ + clientWidth, + overscan = TAB_WINDOW_OVERSCAN, + scrollLeft, + tabCount, + windowingThreshold = TAB_WINDOWING_THRESHOLD, +}: VisibleTabWindowInput): { end: number; start: number } { + if (tabCount <= 0) { + return { start: 0, end: 0 }; + } + + if (tabCount <= windowingThreshold) { + return { start: 0, end: tabCount }; + } + + if (clientWidth <= 0) { + return { start: 0, end: Math.min(tabCount, windowingThreshold) }; + } + + const boundedScrollLeft = Math.max(0, scrollLeft); + const visibleStart = Math.floor(boundedScrollLeft / TAB_WIDTH); + const visibleEnd = Math.ceil((boundedScrollLeft + clientWidth) / TAB_WIDTH); + + return { + start: Math.max(0, visibleStart - overscan), + end: Math.min(tabCount, visibleEnd + overscan), + }; +} export function computeInsertIndex( clientX: number, diff --git a/packages/shared/src/host-info.ts b/packages/shared/src/host-info.ts index 449162efb29..268e24f5b3d 100644 --- a/packages/shared/src/host-info.ts +++ b/packages/shared/src/host-info.ts @@ -6,6 +6,7 @@ import { homedir, hostname, platform } from "node:os"; // Salt value preserved verbatim across the rename to keep existing host ids // stable for users already registered against the cloud. const APP_HOST_SALT = "superset-desktop-device-id-v1"; +const MACHINE_ID_COMMAND_TIMEOUT_MS = 1500; function getRawMachineId(): string { try { @@ -15,7 +16,7 @@ function getRawMachineId(): string { const output = execFileSync( "ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], - { encoding: "utf8" }, + { encoding: "utf8", timeout: MACHINE_ID_COMMAND_TIMEOUT_MS }, ); const match = output.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/); if (match?.[1]) return match[1]; @@ -34,7 +35,7 @@ function getRawMachineId(): string { "/v", "MachineGuid", ], - { encoding: "utf8" }, + { encoding: "utf8", timeout: MACHINE_ID_COMMAND_TIMEOUT_MS }, ); const match = output.match(/MachineGuid\s+REG_SZ\s+(\S+)/); if (match?.[1]) return match[1]; diff --git a/packages/ui/src/components/overflow-fade/OverflowFadeContainer/OverflowFadeContainer.tsx b/packages/ui/src/components/overflow-fade/OverflowFadeContainer/OverflowFadeContainer.tsx index 3870c8e0a0a..7aac864e912 100644 --- a/packages/ui/src/components/overflow-fade/OverflowFadeContainer/OverflowFadeContainer.tsx +++ b/packages/ui/src/components/overflow-fade/OverflowFadeContainer/OverflowFadeContainer.tsx @@ -28,6 +28,11 @@ interface OverflowFadeContainerProps extends ComponentProps<"div"> { * scrollers such as tabs; avoid on large or virtualized lists without profiling. */ observeChildren?: boolean; + /** + * Re-measure overflow when the caller knows scrollable content changed + * without relying on observing every child node. + */ + measureKey?: unknown; } export function OverflowFadeContainer({ @@ -36,6 +41,7 @@ export function OverflowFadeContainer({ fadeEdges = DEFAULT_FADE_EDGES, onOverflowChange, observeChildren = false, + measureKey, ...props }: OverflowFadeContainerProps) { const { @@ -46,7 +52,7 @@ export function OverflowFadeContainer({ canScrollRight, canScrollBottom, canScrollLeft, - } = useOverflowFade({ observeChildren }); + } = useOverflowFade({ measureKey, observeChildren }); const setRef = (node: HTMLDivElement | null) => { ref.current = node; diff --git a/packages/ui/src/hooks/use-overflow-fade.ts b/packages/ui/src/hooks/use-overflow-fade.ts index 7e8dabf51dc..d38579b0c20 100644 --- a/packages/ui/src/hooks/use-overflow-fade.ts +++ b/packages/ui/src/hooks/use-overflow-fade.ts @@ -3,6 +3,7 @@ import { useCallback, useLayoutEffect, useRef, useState } from "react"; interface UseOverflowFadeOptions { + measureKey?: unknown; observeChildren?: boolean; observeParent?: boolean; } @@ -54,13 +55,15 @@ function areOverflowStatesEqual( } export function useOverflowFade({ + measureKey, observeChildren = false, observeParent = false, }: UseOverflowFadeOptions = {}) { const ref = useRef(null); + const frameIdRef = useRef(null); const [state, setState] = useState(INITIAL_STATE); - const updateOverflow = useCallback(() => { + const measureOverflow = useCallback(() => { const node = ref.current; if (!node) return; @@ -72,6 +75,18 @@ export function useOverflowFade({ ); }, []); + const updateOverflow = useCallback(() => { + if (frameIdRef.current !== null) return; + if (typeof requestAnimationFrame !== "function") { + measureOverflow(); + return; + } + frameIdRef.current = requestAnimationFrame(() => { + frameIdRef.current = null; + measureOverflow(); + }); + }, [measureOverflow]); + useLayoutEffect(() => { const node = ref.current; if (!node) return; @@ -106,6 +121,10 @@ export function useOverflowFade({ window.addEventListener("resize", updateOverflow); return () => { + if (frameIdRef.current !== null) { + cancelAnimationFrame(frameIdRef.current); + frameIdRef.current = null; + } resizeObserver.disconnect(); mutationObserver?.disconnect(); node.removeEventListener("scroll", updateOverflow); @@ -113,6 +132,11 @@ export function useOverflowFade({ }; }, [observeChildren, observeParent, updateOverflow]); + useLayoutEffect(() => { + void measureKey; + updateOverflow(); + }, [measureKey, updateOverflow]); + return { ref, ...state,