diff --git a/src/renderer/src/lib/typing-latency/diagnostic-summary.test.ts b/src/renderer/src/lib/typing-latency/diagnostic-summary.test.ts index abd5b024005..5e0e9a00fbb 100644 --- a/src/renderer/src/lib/typing-latency/diagnostic-summary.test.ts +++ b/src/renderer/src/lib/typing-latency/diagnostic-summary.test.ts @@ -59,23 +59,47 @@ describe('summarizeWorktreeNesting', () => { }) it('normalizes windows separators, case, and trailing slashes', () => { - expect(summarizeWorktreeNesting(worktreesAtPaths('C:\\Repo\\', 'c:/repo/Nested'))).toEqual({ + expect( + summarizeWorktreeNesting(worktreesAtPaths('C:\\Repo\\', 'c:/repo/Nested'), 'win32') + ).toEqual({ maxDepth: 1, nestedWorktrees: 1 }) }) - it('preserves case-distinct POSIX and SSH worktree paths', () => { + it('preserves case-distinct remote POSIX worktree paths', () => { expect( summarizeWorktreeNesting( worktreesAtPaths('/srv/Repo', '/srv/repo/nested').map((worktree) => ({ ...worktree, hostId: 'ssh:openclaw' - })) + })), + 'darwin' ) ).toEqual({ maxDepth: 0, nestedWorktrees: 0 }) }) + it('folds case-only local POSIX twins on macOS but not on Linux', () => { + const worktrees = worktreesAtPaths('/Users/ada/Repo', '/Users/ada/repo/nested') + expect(summarizeWorktreeNesting(worktrees, 'darwin')).toEqual({ + maxDepth: 1, + nestedWorktrees: 1 + }) + expect(summarizeWorktreeNesting(worktrees, 'linux')).toEqual({ + maxDepth: 0, + nestedWorktrees: 0 + }) + }) + + it('treats an absent hostId as the local host', () => { + expect( + summarizeWorktreeNesting( + [{ path: '/srv/repo' }, { path: '/srv/repo/nested', hostId: 'local' }], + 'linux' + ) + ).toEqual({ maxDepth: 1, nestedWorktrees: 1 }) + }) + it('does not infer nesting across execution hosts', () => { expect( summarizeWorktreeNesting([ diff --git a/src/renderer/src/lib/typing-latency/diagnostic-summary.ts b/src/renderer/src/lib/typing-latency/diagnostic-summary.ts index 3f60e5a7e02..5cb96d684d9 100644 --- a/src/renderer/src/lib/typing-latency/diagnostic-summary.ts +++ b/src/renderer/src/lib/typing-latency/diagnostic-summary.ts @@ -6,7 +6,12 @@ * Kept separate from the DOM/store wiring in diagnostic.ts so * the arithmetic is unit-testable without an xterm or a live store. */ -import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' +import { + isWindowsAbsolutePathLike, + normalizeRuntimePathForComparison +} from '../../../../shared/cross-platform-path' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { getRendererAppPlatform } from '../renderer-app-platform' export type LatencyPercentiles = { count: number @@ -55,17 +60,39 @@ export type WorktreeNestingCensus = { type WorktreeLike = { path?: string | null; hostId?: string | null } +/** + * Case-only path twins are one directory on a case-insensitive filesystem, so the + * census must fold them or the nesting they form goes uncounted. Windows drive/UNC + * keys are already folded by `normalizeRuntimePathForComparison`; a POSIX key folds + * only for the local host, where the client platform really does report the + * filesystem (default macOS APFS is case-insensitive). Remote POSIX paths stay + * byte-exact — the execution host owns that fact and we cannot see it from here. + */ +function worktreeNestingKey(rawPath: string, foldPosixCase: boolean): string { + const normalized = normalizeRuntimePathForComparison(rawPath) + return foldPosixCase && !isWindowsAbsolutePathLike(rawPath.normalize('NFC')) + ? normalized.toLowerCase() + : normalized +} + export function summarizeWorktreeNesting( - worktrees: readonly WorktreeLike[] + worktrees: readonly WorktreeLike[], + localPlatform: NodeJS.Platform = getRendererAppPlatform() ): WorktreeNestingCensus { - const pathsByHost = new Map>() + const pathsByHost = new Map>() for (const worktree of worktrees) { if (typeof worktree.path !== 'string' || worktree.path.length === 0) { continue } - const hostId = worktree.hostId ?? null + // An absent hostId means the local host, so it must not partition away from 'local'. + const hostId = worktree.hostId ?? LOCAL_EXECUTION_HOST_ID const paths = pathsByHost.get(hostId) ?? new Set() - paths.add(normalizeRuntimePathForComparison(worktree.path)) + paths.add( + worktreeNestingKey( + worktree.path, + hostId === LOCAL_EXECUTION_HOST_ID && localPlatform === 'darwin' + ) + ) pathsByHost.set(hostId, paths) } let maxDepth = 0 @@ -211,6 +238,7 @@ export function summarizeTypingScaleCensus(input: { mountedAgentRowCount: number | null storeListenerCount: number | null focusedPane: FocusedPaneCensus | null + localPlatform?: NodeJS.Platform }): TypingScaleCensus { const state = input.state const settings = state?.settings ?? null @@ -221,7 +249,7 @@ export function summarizeTypingScaleCensus(input: { appVersion: input.appVersion, repos: state?.worktreesByRepo ? Object.keys(state.worktreesByRepo).length : 0, worktrees: worktrees.length, - worktreeNesting: summarizeWorktreeNesting(worktrees), + worktreeNesting: summarizeWorktreeNesting(worktrees, input.localPlatform), tabs: { terminal: sumArrayLengths(state?.tabsByWorktree), unified: sumArrayLengths(state?.unifiedTabsByWorktree)