diff --git a/apps/desktop/docs/TERMINAL_WEBGL_RENDERING.md b/apps/desktop/docs/TERMINAL_WEBGL_RENDERING.md new file mode 100644 index 00000000000..718490242df --- /dev/null +++ b/apps/desktop/docs/TERMINAL_WEBGL_RENDERING.md @@ -0,0 +1,35 @@ +# Terminal WebGL Rendering + +This note covers the terminal rendering fixes in this PR. These are renderer +fixes only; they do not change PTY lifetime, replay, serialized terminal +buffers, pane ownership, or terminal parking. + +## Implemented Fixes + +- Terminal WebGL addon setup is centralized in + `apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon.ts`, so both + terminal creation paths use the same lifecycle. +- WebGL loading remains optional and delayed by one animation frame. +- If `WebglAddon` fails to load, later terminals in the same renderer process + skip WebGL and use xterm's non-WebGL renderer. +- If WebGL reports context loss, the current terminal disposes its WebGL addon, + refreshes visible rows immediately, refreshes again on the next animation + frame, and marks future terminals in that renderer process for DOM fallback. +- If xterm's WebGL atlas reaches pressure, the code calls the public + `WebglAddon.clearTextureAtlas()` API and refreshes visible rows. Detection is + limited to xterm renderer state: `_charAtlas._requestClearModel === true` or + an atlas page at least `2048px`. + +## Why This Fix Is Local + +Glyph corruption can happen while the copied terminal text and backend session +are still correct. That points at stale WebGL glyph atlas pixels rather than a +PTY stream or replay bug. Clearing the texture atlas forces xterm to rasterize +the visible glyphs again without rebuilding the terminal runtime or touching +the session. + +## Related Upstream Issue + +`xtermjs/xterm.js#5847` +(https://github.com/xtermjs/xterm.js/issues/5847) reports WebGL row ghosting +and glyph substitution under heavy true-color output. diff --git a/apps/desktop/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/lib/terminal/terminal-addons.ts b/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts index 85faa897cdb..23e5cc1f29b 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts @@ -1,11 +1,11 @@ import { ClipboardAddon } from "@xterm/addon-clipboard"; -import { ImageAddon } from "@xterm/addon-image"; import { LigaturesAddon } from "@xterm/addon-ligatures"; import { ProgressAddon } from "@xterm/addon-progress"; import { SearchAddon } from "@xterm/addon-search"; import { Unicode11Addon } from "@xterm/addon-unicode11"; -import { WebglAddon } from "@xterm/addon-webgl"; import type { Terminal as XTerm } from "@xterm/xterm"; +import { createTerminalImageAddon } from "./terminal-image-addon"; +import { scheduleWebglAddon } from "./terminal-webgl-addon"; export interface LoadAddonsResult { searchAddon: SearchAddon; @@ -13,9 +13,6 @@ export interface LoadAddonsResult { dispose: () => void; } -// Once WebGL fails, skip it for all subsequent runtimes (VS Code pattern). -let suggestedRendererType: "webgl" | "dom" | undefined; - /** * Load optional addons onto an already-opened terminal. Returns a cleanup * function and addon instances. WebGL is deferred to rAF to avoid @@ -23,7 +20,6 @@ let suggestedRendererType: "webgl" | "dom" | undefined; */ export function loadAddons(terminal: XTerm): LoadAddonsResult { let disposed = false; - let webglAddon: WebglAddon | null = null; terminal.loadAddon(new ClipboardAddon()); @@ -31,7 +27,7 @@ export function loadAddons(terminal: XTerm): LoadAddonsResult { terminal.loadAddon(unicode11); terminal.unicode.activeVersion = "11"; - terminal.loadAddon(new ImageAddon()); + terminal.loadAddon(createTerminalImageAddon()); const searchAddon = new SearchAddon(); terminal.loadAddon(searchAddon); @@ -43,21 +39,8 @@ export function loadAddons(terminal: XTerm): LoadAddonsResult { terminal.loadAddon(new LigaturesAddon()); } catch {} - const rafId = requestAnimationFrame(() => { - if (disposed || suggestedRendererType === "dom") return; - - try { - webglAddon = new WebglAddon(); - webglAddon.onContextLoss(() => { - webglAddon?.dispose(); - webglAddon = null; - terminal.refresh(0, terminal.rows - 1); - }); - terminal.loadAddon(webglAddon); - } catch { - suggestedRendererType = "dom"; - webglAddon = null; - } + const disposeWebglAddon = scheduleWebglAddon(terminal, { + isDisposed: () => disposed, }); return { @@ -65,11 +48,7 @@ export function loadAddons(terminal: XTerm): LoadAddonsResult { progressAddon, dispose: () => { disposed = true; - cancelAnimationFrame(rafId); - try { - webglAddon?.dispose(); - } catch {} - webglAddon = null; + disposeWebglAddon(); }, }; } diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-image-addon.test.ts b/apps/desktop/src/renderer/lib/terminal/terminal-image-addon.test.ts new file mode 100644 index 00000000000..ca99e180ae1 --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-image-addon.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, mock } from "bun:test"; + +const imageAddonOptions: unknown[] = []; + +class FakeImageAddon { + constructor(options: unknown) { + imageAddonOptions.push(options); + } +} + +mock.module("@xterm/addon-image", () => ({ + ImageAddon: FakeImageAddon, +})); + +const { createTerminalImageAddon, TERMINAL_IMAGE_ADDON_OPTIONS } = await import( + "./terminal-image-addon" +); + +describe("createTerminalImageAddon", () => { + it("bounds per-terminal image decoder memory", () => { + imageAddonOptions.length = 0; + + const addon = createTerminalImageAddon(); + + expect(addon).toBeInstanceOf(FakeImageAddon); + expect(imageAddonOptions).toEqual([TERMINAL_IMAGE_ADDON_OPTIONS]); + expect(TERMINAL_IMAGE_ADDON_OPTIONS).toMatchObject({ + iipSupport: true, + kittySupport: true, + pixelLimit: 1_048_576, + sixelSupport: false, + storageLimit: 16, + }); + }); +}); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-image-addon.ts b/apps/desktop/src/renderer/lib/terminal/terminal-image-addon.ts new file mode 100644 index 00000000000..58986a834c1 --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-image-addon.ts @@ -0,0 +1,17 @@ +import { type IImageAddonOptions, ImageAddon } from "@xterm/addon-image"; + +export const TERMINAL_IMAGE_ADDON_OPTIONS = { + enableSizeReports: true, + iipSizeLimit: 8_000_000, + iipSupport: true, + kittySizeLimit: 8_000_000, + kittySupport: true, + pixelLimit: 1_048_576, + showPlaceholder: true, + sixelSupport: false, + storageLimit: 16, +} satisfies IImageAddonOptions; + +export function createTerminalImageAddon(): ImageAddon { + return new ImageAddon(TERMINAL_IMAGE_ADDON_OPTIONS); +} diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon.ts b/apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon.ts new file mode 100644 index 00000000000..66c3b44f965 --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-webgl-addon.ts @@ -0,0 +1,140 @@ +import { WebglAddon } from "@xterm/addon-webgl"; +import type { Terminal as XTerm } from "@xterm/xterm"; + +type Disposable = { dispose: () => void }; + +const ATLAS_GUARD_MIN_PAGE_SIZE = 2048; + +// Once WebGL fails, skip it for all subsequent runtimes in this renderer. +let suggestedRendererType: "webgl" | "dom" | undefined; + +function getObjectProperty(source: unknown, key: string): unknown { + if (!source || (typeof source !== "object" && typeof source !== "function")) { + return undefined; + } + return (source as Record)[key]; +} + +function getNumberProperty(source: unknown, key: string): number | null { + const value = getObjectProperty(source, key); + return typeof value === "number" ? value : null; +} + +function getBooleanProperty(source: unknown, key: string): boolean | null { + const value = getObjectProperty(source, key); + return typeof value === "boolean" ? value : null; +} + +function getTerminalRenderer(terminal: XTerm): unknown { + const core = getObjectProperty(terminal, "_core"); + const renderService = getObjectProperty(core, "_renderService"); + const rendererHolder = getObjectProperty(renderService, "_renderer"); + return getObjectProperty(rendererHolder, "value") ?? rendererHolder; +} + +function shouldClearTextureAtlas(terminal: XTerm): boolean { + const renderer = getTerminalRenderer(terminal); + const charAtlas = getObjectProperty(renderer, "_charAtlas"); + const pendingAtlasClear = + getBooleanProperty(charAtlas, "_requestClearModel") === true; + const rawPages = getObjectProperty(charAtlas, "pages"); + if (!Array.isArray(rawPages)) return false; + + const pageSizes = rawPages + .map((page) => + getNumberProperty(getObjectProperty(page, "canvas"), "width"), + ) + .filter((size): size is number => size !== null); + + return ( + pendingAtlasClear || + pageSizes.some((size) => size >= ATLAS_GUARD_MIN_PAGE_SIZE) + ); +} + +function refreshTerminal(terminal: XTerm): void { + terminal.refresh(0, Math.max(0, terminal.rows - 1)); +} + +export function scheduleWebglAddon( + terminal: XTerm, + options: { isDisposed?: () => boolean } = {}, +): () => void { + let disposed = false; + let webglAddon: WebglAddon | null = null; + let loadRafId: number | null = null; + let clearRafId: number | null = null; + const disposables: Disposable[] = []; + + const isDisposed = () => disposed || (options.isDisposed?.() ?? false); + + const cleanupWebgl = () => { + while (disposables.length > 0) { + try { + disposables.pop()?.dispose(); + } catch {} + } + try { + webglAddon?.dispose(); + } catch {} + webglAddon = null; + }; + + const clearAtlasIfNeeded = () => { + clearRafId = null; + if (isDisposed() || !webglAddon) return; + if (!shouldClearTextureAtlas(terminal)) return; + + try { + webglAddon.clearTextureAtlas(); + refreshTerminal(terminal); + requestAnimationFrame(() => { + if (!isDisposed()) refreshTerminal(terminal); + }); + } catch {} + }; + + const scheduleAtlasClear = () => { + if (isDisposed() || clearRafId !== null) return; + clearRafId = requestAnimationFrame(clearAtlasIfNeeded); + }; + + loadRafId = requestAnimationFrame(() => { + loadRafId = null; + if (isDisposed() || suggestedRendererType === "dom") return; + + try { + webglAddon = new WebglAddon(); + disposables.push( + webglAddon.onContextLoss(() => { + suggestedRendererType = "dom"; + cleanupWebgl(); + refreshTerminal(terminal); + requestAnimationFrame(() => { + if (!isDisposed()) refreshTerminal(terminal); + }); + }), + webglAddon.onAddTextureAtlasCanvas(scheduleAtlasClear), + webglAddon.onRemoveTextureAtlasCanvas(scheduleAtlasClear), + webglAddon.onChangeTextureAtlas(scheduleAtlasClear), + ); + terminal.loadAddon(webglAddon); + } catch { + suggestedRendererType = "dom"; + cleanupWebgl(); + } + }); + + return () => { + disposed = true; + if (loadRafId !== null) { + cancelAnimationFrame(loadRafId); + loadRafId = null; + } + if (clearRafId !== null) { + cancelAnimationFrame(clearRafId); + clearRafId = null; + } + cleanupWebgl(); + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/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/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx index 0f4a79be2bb..91fd2184f2e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useDiffStats } from "renderer/hooks/host-service/useDiffStats"; import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; import { useDeletingWorkspaces } from "renderer/routes/_authenticated/providers/DeletingWorkspacesProvider"; import { RenameBranchDialog } from "renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components"; @@ -40,7 +39,8 @@ export function DashboardSidebarWorkspaceItem({ pullRequest, } = workspace; const isMainWorkspace = workspace.type === "main"; - const diffStats = useDiffStats(id); + const isPending = !!creationStatus; + const isFailedInFlight = creationStatus === "failed"; const workspaceStatus = useV2WorkspaceNotificationStatus(id); const { cancelRename, @@ -69,7 +69,6 @@ export function DashboardSidebarWorkspaceItem({ branch, isMainWorkspace, }); - const { v2Workspaces: v2WorkspaceActions } = useOptimisticCollectionActions(); const [renameBranchTarget, setRenameBranchTarget] = useState( null, @@ -77,8 +76,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); @@ -219,7 +216,7 @@ export function DashboardSidebarWorkspaceItem({ isRenaming={isRenaming} renameValue={renameValue} shortcutLabel={shortcutLabel} - diffStats={isPending ? null : diffStats} + diffStats={null} workspaceStatus={workspaceStatus} isInSection={isInSection} onClick={handleClick} diff --git a/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 b69b67a3574..82c1dd00c08 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 @@ -42,7 +42,11 @@ import type { TerminalPaneData, } from "../../types"; import type { TerminalLauncher } from "../useV2TerminalLauncher"; -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"; @@ -423,7 +427,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/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..5dfaf693dd7 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -1,15 +1,15 @@ import { toast } from "@superset/ui/sonner"; import { ClipboardAddon } from "@xterm/addon-clipboard"; import { FitAddon } from "@xterm/addon-fit"; -import { ImageAddon } from "@xterm/addon-image"; import { LigaturesAddon } from "@xterm/addon-ligatures"; import { SearchAddon } from "@xterm/addon-search"; import { Unicode11Addon } from "@xterm/addon-unicode11"; -import { WebglAddon } from "@xterm/addon-webgl"; import type { ITheme } from "@xterm/xterm"; import { Terminal as XTerm } from "@xterm/xterm"; import type { DetectedLink } from "renderer/lib/terminal/links"; +import { createTerminalImageAddon } from "renderer/lib/terminal/terminal-image-addon"; import { TerminalLinkManager } from "renderer/lib/terminal/terminal-link-manager"; +import { scheduleWebglAddon } from "renderer/lib/terminal/terminal-webgl-addon"; import { electronTrpcClient as trpcClient } from "renderer/lib/trpc-client"; import { toXtermTheme } from "renderer/stores/theme/utils"; import { @@ -56,9 +56,6 @@ export function getDefaultTerminalBg(): string { return getDefaultTerminalTheme().background ?? "#151110"; } -// Once WebGL fails, skip it for all subsequent terminals (VS Code pattern). -let suggestedRendererType: "webgl" | "dom" | undefined; - export interface CreateTerminalOptions { /** * Workspace id used for worktree lookup during path stat/resolution. @@ -101,10 +98,9 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { const clipboardAddon = new ClipboardAddon(); const unicode11Addon = new Unicode11Addon(); - const imageAddon = new ImageAddon(); + const imageAddon = createTerminalImageAddon(); let disposed = false; - let webglAddon: WebglAddon | null = null; // Open into a detached wrapper div — not the live container. const wrapper = document.createElement("div"); @@ -124,22 +120,8 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { // Ligatures not supported by current font } - // Defer WebGL to rAF to avoid racing xterm's post-open viewport sync. - const rafId = requestAnimationFrame(() => { - if (disposed || suggestedRendererType === "dom") return; - - try { - webglAddon = new WebglAddon(); - webglAddon.onContextLoss(() => { - webglAddon?.dispose(); - webglAddon = null; - xterm.refresh(0, xterm.rows - 1); - }); - xterm.loadAddon(webglAddon); - } catch { - suggestedRendererType = "dom"; - webglAddon = null; - } + const disposeWebglAddon = scheduleWebglAddon(xterm, { + isDisposed: () => disposed, }); const cleanupQuerySuppression = suppressQueryResponses(xterm); @@ -205,13 +187,9 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { linkManager, cleanup: () => { disposed = true; - cancelAnimationFrame(rafId); cleanupQuerySuppression(); linkManager.dispose(); - try { - webglAddon?.dispose(); - } catch {} - webglAddon = null; + disposeWebglAddon(); }, }; } diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index 3e9559843f9..6601db7411c 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -177,6 +177,7 @@ const MAX_BUFFER_BYTES = 64 * 1024; const SOCKET_OPEN = 1; const SOCKET_CLOSING = 2; const SOCKET_CLOSED = 3; +const TERMINAL_ATTACH_ERROR_CLOSE_REASON = "terminal-attach-error"; const DEFAULT_TERMINAL_COLS = 120; const DEFAULT_TERMINAL_ROWS = 32; const MIN_TERMINAL_COLS = 20; @@ -1560,7 +1561,9 @@ export function registerWorkspaceTerminalRoute({ const session = await resolveSessionForAttach(); if ("error" in session) { sendMessage(ws, { type: "error", message: session.error }); - ws.close(1011, session.error); + // WebSocket close reasons are limited to 123 bytes. + // The prior error frame carries the full diagnostic. + ws.close(1011, TERMINAL_ATTACH_ERROR_CLOSE_REASON); return; } if (ws.readyState !== SOCKET_OPEN) return; diff --git a/packages/host-service/src/trpc/router/git/git.ts b/packages/host-service/src/trpc/router/git/git.ts index 047188fc440..e285458ec1f 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,36 @@ 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] = await Promise.all([ + git + .raw(["diff", "--numstat", "-z", `${baseRef}...HEAD`]) + .catch(() => ""), + git.raw(["diff", "--numstat", "-z", "--cached"]).catch(() => ""), + git.raw(["diff", "--numstat", "-z"]).catch(() => ""), + ]); + + const byPath = new Map(); + applyNumstatToStatsMap(byPath, againstBaseRaw); + applyNumstatToStatsMap(byPath, stagedRaw); + applyNumstatToStatsMap(byPath, unstagedRaw); + + return sumDiffStats(byPath); + }), + listCommits: queryProcedure .meta({ timeoutMs: 30_000 }) .input( 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 c41c878beec..dbbca1f6470 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 @@ -74,6 +74,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]; @@ -91,10 +116,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/test/integration/git.integration.test.ts b/packages/host-service/test/integration/git.integration.test.ts index 270347b3c4e..ff014bd3d1c 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 tracked sidebar totals without full status payload", async () => { + writeFileSync(join(scenario.repo.repoPath, "staged.txt"), "one\ntwo\n"); + await scenario.repo.git.add("staged.txt"); + writeFileSync(join(scenario.repo.repoPath, "mixed.txt"), "base\n"); + 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: 5, deletions: 0 }); + }); + test("getBaseBranch returns null when not configured", async () => { const result = await scenario.host.trpc.git.getBaseBranch.query({ workspaceId: scenario.workspaceId,