diff --git a/.oxlintrc.json b/.oxlintrc.json index 4cdb4f2c0e2..0f679f17d52 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -150,10 +150,5 @@ } } ], - "ignorePatterns": [ - "**/node_modules", - "**/dist", - "**/out", - "tests/e2e/.cross-version-checkouts" - ] + "ignorePatterns": ["**/node_modules", "**/dist", "**/out", "tests/e2e/.cross-version-checkouts"] } diff --git a/config/scripts/relay-watcher-fault-harness.mjs b/config/scripts/relay-watcher-fault-harness.mjs index 12e36f72490..b27a1f945d9 100644 --- a/config/scripts/relay-watcher-fault-harness.mjs +++ b/config/scripts/relay-watcher-fault-harness.mjs @@ -109,9 +109,7 @@ function waitForStdoutSentinel(proc, protocol, stderr) { settled = true proc.stdout.off('data', onData) rejectPromise( - new Error( - `process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}` - ) + new Error(`process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}`) ) } proc.stdout.on('data', onData) @@ -162,11 +160,7 @@ function createRelayClient(entryPath, args, env, protocol) { }) const waitForMessage = (startIndex, predicate, label) => - pollUntil( - () => messages.slice(startIndex).find(predicate), - label, - streams.stderr - ) + pollUntil(() => messages.slice(startIndex).find(predicate), label, streams.stderr) const request = async (method, params = {}) => { const id = nextSequence++ @@ -317,8 +311,10 @@ async function main() { ) await relay.request('fs.watch', { rootPath: watchRoot }) - const firstWatcherPid = await waitForWatcherPid(pidFile, undefined, () => - `${daemonStreams.stderr()}\n${relay.stderr()}` + const firstWatcherPid = await waitForWatcherPid( + pidFile, + undefined, + () => `${daemonStreams.stderr()}\n${relay.stderr()}` ) const beforePath = join(watchRoot, 'before.txt') startIndex = relay.messageCount() @@ -330,8 +326,10 @@ async function main() { const faultSignal = process.platform === 'win32' ? 'SIGTERM' : 'SIGSEGV' startIndex = relay.messageCount() process.kill(firstWatcherPid, faultSignal) - const replacementWatcherPid = await waitForWatcherPid(pidFile, firstWatcherPid, () => - `${daemonStreams.stderr()}\n${relay.stderr()}` + const replacementWatcherPid = await waitForWatcherPid( + pidFile, + firstWatcherPid, + () => `${daemonStreams.stderr()}\n${relay.stderr()}` ) await relay.waitForNotification(startIndex, 'fs.changed', (params) => Array.isArray(params.events) diff --git a/mobile/app.json b/mobile/app.json index 53654335486..87b50446486 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.37", + "version": "0.0.41", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", @@ -75,7 +75,7 @@ "allowBackup": false, "permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"], "package": "com.stably.orca.mobile", - "versionCode": 10 + "versionCode": 11 }, "plugins": [ "expo-router", diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index c5e4175acc0..755d6605501 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -4986,6 +4986,7 @@ export default function SessionScreen() { { ]) }) + it('orders numbered names naturally, matching the desktop explorer', () => { + const cache: DirectoryCache = { + '': { entries: [entry('100 - b.txt'), entry('9 - c.txt'), entry('99 - a.txt')] } + } + + expect(flattenDirectoryCache(cache, new Set()).map((row) => row.id)).toEqual([ + 'file:9 - c.txt', + 'file:99 - a.txt', + 'file:100 - b.txt' + ]) + }) + it('mirrors desktop default browse exclusions while keeping dotfiles visible', () => { const cache: DirectoryCache = { '': { diff --git a/mobile/src/files/file-tree.ts b/mobile/src/files/file-tree.ts index a9283cfa457..71f26ca155b 100644 --- a/mobile/src/files/file-tree.ts +++ b/mobile/src/files/file-tree.ts @@ -1,5 +1,6 @@ // Pure tree projection for the mobile file explorer. Mobile mirrors desktop // browse semantics by flattening cached files.readDir results as folders open. +import { compareFileNames } from '../../../src/shared/file-name-sort' export type MobileDirEntry = { name: string @@ -118,7 +119,7 @@ function compareDirectoryEntries(a: MobileDirEntry, b: MobileDirEntry): number { if (a.isDirectory !== b.isDirectory) { return a.isDirectory ? -1 : 1 } - return a.name.localeCompare(b.name) + return compareFileNames(a.name, b.name) } export function shouldIncludeMobileFileExplorerEntry(entry: MobileDirEntry): boolean { diff --git a/mobile/src/session/MobileNativeChatAsk.tsx b/mobile/src/session/MobileNativeChatAsk.tsx index 86fcb0dc4a2..68258c22820 100644 --- a/mobile/src/session/MobileNativeChatAsk.tsx +++ b/mobile/src/session/MobileNativeChatAsk.tsx @@ -187,7 +187,7 @@ export function MobileNativeChatAsk({ prompt, onAnswer, onCancel }: Props): Reac disabled={!canAdvance} > - {isLast ? 'Send answer' : 'Next'} + {isLast ? 'Submit' : 'Next'} diff --git a/mobile/src/session/MobileTerminalLiveInputStatus.tsx b/mobile/src/session/MobileTerminalLiveInputStatus.tsx index dc135c3eb60..a27b09f07d2 100644 --- a/mobile/src/session/MobileTerminalLiveInputStatus.tsx +++ b/mobile/src/session/MobileTerminalLiveInputStatus.tsx @@ -10,11 +10,13 @@ type DictationStatus = { type MobileTerminalLiveInputStatusProps = { readonly dictation: DictationStatus readonly isAttaching: boolean + readonly liveInputText: string } export function MobileTerminalLiveInputStatus({ dictation, - isAttaching + isAttaching, + liveInputText }: MobileTerminalLiveInputStatusProps) { const title = dictation.isRecording ? 'Listening' @@ -31,14 +33,14 @@ export function MobileTerminalLiveInputStatus({ ? 'Preparing microphone' : isAttaching ? 'Uploading image to host' - : 'Tap to show keyboard' + : liveInputText || 'Tap to show keyboard' return ( {title} - + {detail} diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts new file mode 100644 index 00000000000..a817b47b3ee --- /dev/null +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -0,0 +1,64 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react' +import type { detectAgentPermission } from './mobile-native-chat-permission' +import type { parseAgentQuestion } from './mobile-native-chat-question' +import type { AskAnswerSelection, AskPrompt, parseAskFromStatus } from './mobile-native-chat-ask' +import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts' +import type { useMobileNativeChatSession } from './use-mobile-native-chat-session' +import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers' + +export type MobileNativeChatController = { + /** Whether a tab's effective view is chat (per-tab override, else the default). */ + isTabChatView: (tabId: string) => boolean + toggleTabChatView: (tabId: string) => void + showNativeChat: boolean + showNativeChatRef: MutableRefObject + /** Resolved agent for the active chat tab (names the empty-state copy). */ + nativeChatAgent: string | null + chatComposerText: string + setChatComposerText: Dispatch> + chatPending: MobileNativeChatPendingMessage[] + chatImagePreviewsByMessageId: Record + nativeChatSession: ReturnType + nativeChatAgentWorking: boolean + nativeChatStreamingText?: string + /** Agent mid-turn, regardless of whether chat is the visible view. */ + nativeChatStreamLive: boolean + /** Host/workspace/tab/session scope for stateful streaming suppression. */ + nativeChatStreamScopeKey: string + nativeChatPermission: ReturnType + nativeChatQuestion: ReturnType + /** The pending ask, already null while dismissed (dismissal lives here so it + * survives the chat-view subtree unmounting on a view toggle). */ + nativeChatAsk: ReturnType + /** Stable key for the current ask card (keys the card component). */ + nativeChatAskKey: string | null + /** Hide the current ask until a genuinely different question arrives. */ + dismissNativeChatAsk: () => void + handleNativeChatAnswerAsk: ( + prompt: AskPrompt, + selections: AskAnswerSelection[] + ) => Promise + handleNativeChatCancelAsk: () => Promise + handleNativeChatRespondPermission: (text: string) => Promise + handleNativeChatStop: () => void + nativeChatFilePaths: string[] + loadNativeChatFiles: (query: string) => void + handleNativeChatQuestionAnswer: (text: string) => Promise + handleNativeChatSend: (text: string, images?: string[]) => Promise + /** Outcome-preserving send: callers that pasted terminal input beforehand + * (image sends) must see 'unknown' to heal a possibly-orphaned paste. Such a + * caller passes its own `deadline` so the paste it already spent and this text + * body share one budget instead of holding the composer for two. */ + handleNativeChatSendWithOutcome: ( + text: string, + images?: string[], + deadline?: number + ) => Promise + /** Launch-context text still parked on the agent's TUI input line, or null. + * Image sends read it to size their leading clear (one Ctrl+U per line). */ + readSeededLaunchDraft: () => string | null + /** Model/session-option pickers for the composer, or null when the active + * agent has no session-option catalog. */ + nativeChatSessionOptions: MobileNativeChatSessionOptionPickersProps | null +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 5ed98925e9c..46012dadbe4 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -1,100 +1,29 @@ -import { - useCallback, - useLayoutEffect, - useRef, - type Dispatch, - type MutableRefObject, - type SetStateAction -} from 'react' +import { useCallback, useLayoutEffect, useRef, type MutableRefObject } from 'react' +import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' import { useMobileSessionViewMode } from './use-mobile-session-view-mode' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' -import { - parseAskFromStatus, - type AskAnswerSelection, - type AskPrompt -} from './mobile-native-chat-ask' import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility' -import { detectAgentPermission } from './mobile-native-chat-permission' -import { parseAgentQuestion } from './mobile-native-chat-question' import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send' -import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-ask' -import { - useMobileNativeChatDrafts, - type MobileNativeChatPendingMessage -} from './use-mobile-native-chat-drafts' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search' import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send' import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' import { useMobileNativeChatSession } from './use-mobile-native-chat-session' import { useMobileNativeChatSessionOptions } from './use-mobile-native-chat-session-options' -import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers' import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts' import { useMobileNativeChatStop } from './use-mobile-native-chat-stop' import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes' import { useThrottledLatestValue } from './use-throttled-latest-value' +import type { MobileNativeChatController } from './mobile-native-chat-controller-contract' + +export type { MobileNativeChatController } from './mobile-native-chat-controller-contract' const NATIVE_CHAT_STREAM_THROTTLE_MS = 50 -export type MobileNativeChatController = { - /** Whether a tab's effective view is chat (per-tab override, else the default). */ - isTabChatView: (tabId: string) => boolean - toggleTabChatView: (tabId: string) => void - showNativeChat: boolean - showNativeChatRef: MutableRefObject - /** Resolved agent for the active chat tab (names the empty-state copy). */ - nativeChatAgent: string | null - chatComposerText: string - setChatComposerText: Dispatch> - chatPending: MobileNativeChatPendingMessage[] - chatImagePreviewsByMessageId: Record - nativeChatSession: ReturnType - nativeChatAgentWorking: boolean - nativeChatStreamingText?: string - /** Agent mid-turn, regardless of whether chat is the visible view. */ - nativeChatStreamLive: boolean - /** Host/workspace/tab/session scope for stateful streaming suppression. */ - nativeChatStreamScopeKey: string - nativeChatPermission: ReturnType - nativeChatQuestion: ReturnType - /** The pending ask, already null while dismissed (dismissal lives here so it - * survives the chat-view subtree unmounting on a view toggle). */ - nativeChatAsk: ReturnType - /** Stable key for the current ask card (keys the card component). */ - nativeChatAskKey: string | null - /** Hide the current ask until a genuinely different question arrives. */ - dismissNativeChatAsk: () => void - handleNativeChatAnswerAsk: ( - prompt: AskPrompt, - selections: AskAnswerSelection[] - ) => Promise - handleNativeChatCancelAsk: () => Promise - handleNativeChatRespondPermission: (text: string) => Promise - handleNativeChatStop: () => void - nativeChatFilePaths: string[] - loadNativeChatFiles: (query: string) => void - handleNativeChatQuestionAnswer: (text: string) => Promise - handleNativeChatSend: (text: string, images?: string[]) => Promise - /** Outcome-preserving send: callers that pasted terminal input beforehand - * (image sends) must see 'unknown' to heal a possibly-orphaned paste. Such a - * caller passes its own `deadline` so the paste it already spent and this text - * body share one budget instead of holding the composer for two. */ - handleNativeChatSendWithOutcome: ( - text: string, - images?: string[], - deadline?: number - ) => Promise - /** Launch-context text still parked on the agent's TUI input line, or null. - * Image sends read it to size their leading clear (one Ctrl+U per line). */ - readSeededLaunchDraft: () => string | null - /** Model/session-option pickers for the composer, or null when the active - * agent has no session-option catalog. */ - nativeChatSessionOptions: MobileNativeChatSessionOptionPickersProps | null -} - /** Owns mobile native-chat state and teardown outside the already dense session * route. The route remains responsible only for choosing and rendering the view. */ export function useMobileNativeChatController(args: { @@ -153,6 +82,7 @@ export function useMobileNativeChatController(args: { const nativeChatSession = useMobileNativeChatSession({ client, + sourceIdentity: encodeNativeChatTranscriptIdentity([hostId, worktreeId]), agent: activeChatResolution?.agent ?? null, sessionId: activeChatSessionId, transcriptPath: activeChatResolution?.transcriptPath ?? null @@ -203,7 +133,8 @@ export function useMobileNativeChatController(args: { } = useMobileNativeChatPrompts({ enabled: activeChatResolution != null, status: nativeChatStatus, - messages: nativeChatSession.messages + messages: nativeChatSession.messages, + transcriptLoading: nativeChatSession.transcriptLoading }) // A never-read transcript cannot prove that a dismissed prompt cleared. const nativeChatTranscriptSettled = diff --git a/mobile/src/session/use-mobile-native-chat-prompts.test.ts b/mobile/src/session/use-mobile-native-chat-prompts.test.ts index f8a1d2a61e6..905b92155e6 100644 --- a/mobile/src/session/use-mobile-native-chat-prompts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-prompts.test.ts @@ -15,14 +15,16 @@ const ASK = JSON.stringify({ function promptsFor( status: Partial | null, - messages: NativeChatMessage[] = [] + messages: NativeChatMessage[] = [], + transcriptLoading = false ): ReturnType { let captured: ReturnType | undefined function Probe(): null { captured = useMobileNativeChatPrompts({ enabled: true, status: status as AgentStatusEntry | null, - messages + messages, + transcriptLoading }) return null } @@ -118,6 +120,18 @@ describe('useMobileNativeChatPrompts ask state gate', () => { expect(promptsFor(null, askMessages).ask).not.toBeNull() }) + it('withholds retained transcript asks while the replacement read is unsettled', () => { + const prompts = promptsFor({ state: 'done' }, askMessages, true) + expect(prompts.ask).toBeNull() + expect(prompts.detectedAsk).toBeNull() + }) + + it('keeps a paused live status ask authoritative while the read is unsettled', () => { + const prompts = promptsFor({ state: 'waiting', interactivePrompt: ASK }, askMessages, true) + expect(prompts.ask).toMatchObject({ questions: [{ question: 'Which path?' }] }) + expect(prompts.detectedAsk).not.toBeNull() + }) + it('does not leak a paused-out sticky status prompt through the transcript fallback', () => { // The post-answer window: the status still carries the prompt while flipping // to `working`, and the transcript's tool-result row has not landed yet, so diff --git a/mobile/src/session/use-mobile-native-chat-prompts.ts b/mobile/src/session/use-mobile-native-chat-prompts.ts index 717bd505aea..61b831dafd2 100644 --- a/mobile/src/session/use-mobile-native-chat-prompts.ts +++ b/mobile/src/session/use-mobile-native-chat-prompts.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react' import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { extractPendingAsk, parseAskFromStatus } from './mobile-native-chat-ask' +import { parseAskFromStatus, resolveNativeChatAsk } from './mobile-native-chat-ask' import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission' import { parseAgentQuestion } from './mobile-native-chat-question' @@ -17,8 +17,11 @@ export function useMobileNativeChatPrompts(args: { enabled: boolean status: AgentStatusEntry | null | undefined messages: readonly NativeChatMessage[] + /** True while `messages` is an unsettled read (including the cached list held + * across a reconnect). Required: an ask derived from it may already be answered. */ + transcriptLoading: boolean }): MobileNativeChatPrompts { - const { enabled, status, messages } = args + const { enabled, status, messages, transcriptLoading } = args const blocked = status?.state === 'waiting' || status?.state === 'blocked' // Both permission paths sit inside the paused gate: an approval envelope can // outlive its answer (the host keeps it sticky), so only a waiting/blocked @@ -38,11 +41,16 @@ export function useMobileNativeChatPrompts(args: { () => parseAskFromStatus(status?.interactivePrompt, status?.toolName), [status?.interactivePrompt, status?.toolName] ) - const askFromMessages = useMemo( - () => (askFromStatus ? null : extractPendingAsk(messages)), - [askFromStatus, messages] + const resolvedAsk = useMemo( + () => + resolveNativeChatAsk({ + liveAsk: askFromStatus, + messages, + transcriptSettled: !transcriptLoading + }), + [askFromStatus, transcriptLoading, messages] ) - + const askFromMessages = askFromStatus ? null : resolvedAsk const detectedAsk = askFromStatus ?? askFromMessages return { diff --git a/mobile/src/session/use-mobile-native-chat-session.test.ts b/mobile/src/session/use-mobile-native-chat-session.test.ts index 844c311b827..8c685d00202 100644 --- a/mobile/src/session/use-mobile-native-chat-session.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -36,6 +36,7 @@ describe('useMobileNativeChatSession', () => { function Harness({ client }: { client: RpcClient | null }): null { state = useMobileNativeChatSession({ client, + sourceIdentity: 'host-a\0workspace-a', agent: 'claude', sessionId: 'session', transcriptPath: null @@ -456,14 +457,17 @@ describe('useMobileNativeChatSession transcriptLoading', () => { function Harness({ client, sessionId, - agent = 'claude' + agent = 'claude', + sourceIdentity = 'host-a\0workspace-a' }: { client: RpcClient | null sessionId: string | null agent?: string | null + sourceIdentity?: string }): null { const session = useMobileNativeChatSession({ client, + sourceIdentity, agent, sessionId, transcriptPath: null @@ -506,7 +510,8 @@ describe('useMobileNativeChatSession transcriptLoading', () => { it('re-reads instead of resurfacing a settled read when the same identity returns', async () => { // Leaving chat view nulls the agent, then returning restores the identity a // settled read already matched — but its list was cleared, so trusting it - // would report 'ready' over an empty transcript. + // would report 'ready' over an empty transcript. The last settled list for + // this identity keeps rendering while the re-read is in flight. const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false }) return () => {} @@ -524,12 +529,17 @@ describe('useMobileNativeChatSession transcriptLoading', () => { renderer?.update(createElement(Harness, { client, sessionId: 'session-a', agent: 'claude' })) ) - expect(renders[0]).toMatchObject({ status: 'loading', transcriptLoading: true, ids: [] }) + expect(renders[0]).toMatchObject({ + status: 'loading', + transcriptLoading: true, + ids: ['a-1'] + }) }) - it('re-reads instead of resurfacing a settled read after a reconnect', async () => { - // A reconnect swaps the client without moving the identity; the effect - // re-subscribes and clears the list, so the old outcome must not stand. + it('keeps the last settled list rendered while a swapped client re-reads', async () => { + // A manual-retry reconnect swaps the client without moving the identity; the + // old outcome must not stand ('loading', not 'ready'), but the cached + // transcript keeps rendering instead of collapsing to a full-screen spinner. const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false }) return () => {} @@ -538,13 +548,61 @@ describe('useMobileNativeChatSession transcriptLoading', () => { await mountAt(client, 'session-a') expect(renders.at(-1)).toMatchObject({ status: 'ready' }) - const reconnected = { subscribe: vi.fn(() => () => {}) } as unknown as RpcClient + let emitFresh: (frame: unknown) => void = () => {} + const reconnected = { + subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => { + emitFresh = onData + return () => {} + }) + } as unknown as RpcClient renders.length = 0 await act(async () => renderer?.update(createElement(Harness, { client: reconnected, sessionId: 'session-a' })) ) - expect(renders[0]).toMatchObject({ status: 'loading', transcriptLoading: true, ids: [] }) + expect(renders[0]).toMatchObject({ + status: 'loading', + transcriptLoading: true, + ids: ['a-1'] + }) + // Every commit of the window, not just the first: the re-subscribe lands a + // commit after it, so clearing the cache there blanks the transcript the + // user actually sees while leaving a first-frame assertion green. + expect([...new Set(renders.map((entry) => entry.ids.join(',')))]).toEqual(['a-1']) + + // The fresh client's snapshot supersedes the held list. + await act(async () => + emitFresh({ type: 'snapshot', messages: [message('a-1'), message('a-2')], hasMore: false }) + ) + expect(renders.at(-1)).toMatchObject({ + status: 'ready', + transcriptLoading: false, + ids: ['a-1', 'a-2'] + }) + }) + + it('never holds a cached list across a host/workspace source change', async () => { + const firstClient = { + subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => { + onData({ type: 'snapshot', messages: [message('source-a')], hasMore: false }) + return () => {} + }) + } as unknown as RpcClient + await mountAt(firstClient, 'session-a') + + const secondClient = { subscribe: vi.fn(() => () => {}) } as unknown as RpcClient + renders.length = 0 + await act(async () => + renderer?.update( + createElement(Harness, { + client: secondClient, + sessionId: 'session-a', + sourceIdentity: 'host-b\0workspace-b' + }) + ) + ) + + expect(renders[0]).toMatchObject({ status: 'loading', ids: [] }) }) it('never hands out the previous session’s messages under the new session id', async () => { diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index 56db7def584..6305d0418d9 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -1,4 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { + createNativeChatTranscriptRetention, + encodeNativeChatTranscriptIdentity +} from '../../../src/shared/native-chat-transcript-retention' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { buildNativeChatSubscriptionId } from '../../../src/shared/native-chat-stream-unsubscribe' import type { RpcClient } from '../transport/rpc-client' @@ -28,9 +32,6 @@ export type MobileNativeChatSession = { loadEarlier: () => void } -// Stable empty reference so a not-yet-current read doesn't churn consumers. -const EMPTY_MESSAGES: NativeChatMessage[] = [] - // Small first page for a fast first paint; grows by a page as the user scrolls. const INITIAL_LIMIT = 40 const PAGE = 60 @@ -45,13 +46,20 @@ type ReadSessionResult = * an ordered tail); live appends merge by id so order stays stable. */ export function useMobileNativeChatSession(args: { client: RpcClient | null + /** Stable host/workspace source; unlike `client`, it survives manual reconnect. */ + sourceIdentity: string agent: string | null sessionId: string | null transcriptPath: string | null }): MobileNativeChatSession { - const { client, agent, sessionId, transcriptPath } = args + const { client, sourceIdentity, agent, sessionId, transcriptPath } = args const [messages, setMessages] = useState([]) - const identity = `${agent ?? ''}\0${sessionId ?? ''}\0${transcriptPath ?? ''}` + const identity = encodeNativeChatTranscriptIdentity([ + sourceIdentity, + agent, + sessionId, + transcriptPath + ]) // Pre-read status is a pure function of the props, so derive it rather than // letting the effect write it a commit later. const initialStatus: MobileNativeChatStatus = @@ -95,6 +103,13 @@ export function useMobileNativeChatSession(args: { // Whether this subscription already delivered its base snapshot; later // snapshots on the same subscription are reconnect replays, not fresh bases. const snapshotSeenRef = useRef(false) + const transcriptRetentionRef = useRef(createNativeChatTranscriptRetention()) + const settledReady = settled?.status === 'ready' + useEffect(() => { + if (settledReady) { + transcriptRetentionRef.current.capture(identity, messages) + } + }, [identity, messages, settledReady]) // Replace the base list (read results are an ordered tail). Resets the merger // cache so the index is rebuilt once over the new base. @@ -258,10 +273,17 @@ export function useMobileNativeChatSession(args: { })() }, [client, agent, sessionId, transcriptPath, hasMore, setList]) + const visibleMessages = transcriptRetentionRef.current.visible({ + identity, + messages, + settled: settledReady, + loading: status === 'loading' + }) + return { // Withheld until the settled read belongs to this identity: the effect that // clears the previous tab's list is passive, so `messages` lags a commit. - messages: settled ? messages : EMPTY_MESSAGES, + messages: visibleMessages, status, transcriptLoading: status === 'loading', error, diff --git a/mobile/src/source-control/mobile-git-status.test.ts b/mobile/src/source-control/mobile-git-status.test.ts index a9e2b554f91..007fb317ce9 100644 --- a/mobile/src/source-control/mobile-git-status.test.ts +++ b/mobile/src/source-control/mobile-git-status.test.ts @@ -29,11 +29,8 @@ describe('mobile source control status helpers', () => { it('builds sections in the mobile source control order', () => { const sections = buildMobileSourceControlSections(entries) - expect(sections.map((section) => section.title)).toEqual([ - 'Changes', - 'Untracked Files', - 'Staged Changes' - ]) + expect(sections.map((section) => section.title)).toEqual(['Changes', 'Staged Changes']) + expect(sections[0]?.data.map((entry) => entry.path)).toEqual(['a.ts', 'new.ts']) }) it('computes actionable path sets', () => { diff --git a/mobile/src/source-control/mobile-git-status.ts b/mobile/src/source-control/mobile-git-status.ts index 65fcf61e211..2d7b0795918 100644 --- a/mobile/src/source-control/mobile-git-status.ts +++ b/mobile/src/source-control/mobile-git-status.ts @@ -19,7 +19,7 @@ export type MobileSourceControlSection = { unstaged: 'Changes', @@ -59,7 +59,13 @@ export function buildMobileSourceControlSections ({ area, title: AREA_TITLES[area], - data: entries.filter((entry) => entry.area === area).sort(compareGitStatusEntries) + data: entries + .filter((entry) => + area === 'unstaged' + ? entry.area === 'unstaged' || entry.area === 'untracked' + : entry.area === area + ) + .sort(compareGitStatusEntries) })).filter((section) => section.data.length > 0) } diff --git a/mobile/src/terminal/terminal-live-input-affordance.test.ts b/mobile/src/terminal/terminal-live-input-affordance.test.ts index 0125cb053e6..ce360091ce2 100644 --- a/mobile/src/terminal/terminal-live-input-affordance.test.ts +++ b/mobile/src/terminal/terminal-live-input-affordance.test.ts @@ -39,6 +39,7 @@ describe('terminal live input affordance', () => { expect(block).toContain('pressed && styles.liveInputFocusTargetPressed') expect(block).toContain('!canSend && styles.liveInputFocusTargetDisabled') expect(block).toContain('showSoftInputOnFocus') + expect(block).toContain('liveInputText={liveInputCapture}') expect(sessionRouteSource).toContain('useTerminalLiveInputFocus({') expect(sessionRouteSource).toContain('return resetLiveInputFocus') expect(liveInputFocusSource).toContain('focusTerminalLiveInputTarget(inputRef.current') @@ -52,6 +53,8 @@ describe('terminal live input affordance', () => { it('makes the live keyboard target visible instead of status-only chrome', () => { expect(liveInputStatusSource).toContain("'Tap to show keyboard'") + expect(liveInputStatusSource).toContain("liveInputText || 'Tap to show keyboard'") + expect(liveInputStatusSource).toContain('ellipsizeMode="head"') expect(commandInputStylesSource).toContain('backgroundColor: colors.bgRaised') expect(commandInputStylesSource).toContain('borderWidth: 1') expect(commandInputStylesSource).toContain('liveInputFocusTargetPressed') diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.test.ts b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts index 88918c00681..a77d4315c29 100644 --- a/mobile/src/terminal/terminal-live-pending-flush-state.test.ts +++ b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts @@ -1,15 +1,16 @@ import { describe, expect, it } from 'vitest' import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' import { + cancelTerminalLivePendingFlush, + createTerminalLivePendingFlushState, queueTerminalLiveMirrorSend, - waitForTerminalLivePendingFlush, - type TerminalLivePendingFlushState + waitForTerminalLivePendingFlush } from './terminal-live-pending-flush-state' describe('terminal live pending flush state', () => { it('Given no in-flight flush When waiting for the barrier Then allows control input', async () => { // Given - const state: TerminalLivePendingFlushState = { current: null } + const state = createTerminalLivePendingFlushState() // When / Then await expect(waitForTerminalLivePendingFlush(state)).resolves.toBe(true) @@ -22,7 +23,8 @@ describe('terminal live pending flush state', () => { const flushPromise = new Promise((resolve) => { resolveFlush = resolve }) - const state: TerminalLivePendingFlushState = { current: flushPromise } + const state = createTerminalLivePendingFlushState() + state.current = flushPromise // When const controlSend = sendTerminalLiveControlAfterPendingFlush( @@ -48,7 +50,8 @@ describe('terminal live pending flush state', () => { const flushPromise = new Promise((resolve) => { resolveFlush = resolve }) - const state: TerminalLivePendingFlushState = { current: flushPromise } + const state = createTerminalLivePendingFlushState() + state.current = flushPromise // When const controlSend = sendTerminalLiveControlAfterPendingFlush( @@ -67,17 +70,45 @@ describe('terminal live pending flush state', () => { }) describe('terminal live mirror send queue', () => { + it('Given high RTT When more input queues Then pending bytes share one follow-up send', async () => { + // Given + const state = createTerminalLivePendingFlushState() + const payloads: string[] = [] + let resolveFirstSend: (value: boolean) => void = () => {} + const sender = async (_handle: string, payload: string): Promise => { + payloads.push(payload) + if (payloads.length === 1) { + return new Promise((resolve) => { + resolveFirstSend = resolve + }) + } + return true + } + + // When + const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'a', sender) + const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'b', sender) + const third = queueTerminalLiveMirrorSend(state, 'terminal-1', 'c', sender) + await Promise.resolve() + + // Then + expect(payloads).toEqual(['a']) + resolveFirstSend(true) + await expect(Promise.all([first, second, third])).resolves.toEqual([true, true, true]) + expect(payloads).toEqual(['a', 'bc']) + }) + it('Given a failed previous send When a mirror send queues Then it still runs in order', async () => { // Given - const state: TerminalLivePendingFlushState = { current: null } + const state = createTerminalLivePendingFlushState() const order: string[] = [] - const first = queueTerminalLiveMirrorSend(state, async () => { + const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'first', async () => { order.push('first') return false }) // When - const second = queueTerminalLiveMirrorSend(state, async () => { + const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'second', async () => { order.push('second') return true }) @@ -90,13 +121,13 @@ describe('terminal live mirror send queue', () => { it('Given a throwing send When a mirror send queues Then the promise resolves false and the chain continues', async () => { // Given - const state: TerminalLivePendingFlushState = { current: null } - const first = queueTerminalLiveMirrorSend(state, async () => { + const state = createTerminalLivePendingFlushState() + const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'first', async () => { throw new Error('boom') }) // When - const second = queueTerminalLiveMirrorSend(state, async () => true) + const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'second', async () => true) // Then await expect(first).resolves.toBe(false) @@ -105,13 +136,33 @@ describe('terminal live mirror send queue', () => { it('Given a settled mirror send When it was the newest Then the state resets to null', async () => { // Given - const state: TerminalLivePendingFlushState = { current: null } + const state = createTerminalLivePendingFlushState() // When - await queueTerminalLiveMirrorSend(state, async () => true) + await queueTerminalLiveMirrorSend(state, 'terminal-1', 'payload', async () => true) await Promise.resolve() // Then expect(state.current).toBeNull() }) + + it('Given queued input When the queue is cancelled Then unsent input is dropped', async () => { + // Given + const state = createTerminalLivePendingFlushState() + let resolveSend: (value: boolean) => void = () => {} + const sender = async (): Promise => + new Promise((resolve) => { + resolveSend = resolve + }) + const active = queueTerminalLiveMirrorSend(state, 'terminal-1', 'a', sender) + const pending = queueTerminalLiveMirrorSend(state, 'terminal-1', 'b', sender) + + // When + cancelTerminalLivePendingFlush(state) + + // Then + await expect(Promise.all([active, pending])).resolves.toEqual([false, false]) + expect(state.current).toBeNull() + resolveSend(true) + }) }) diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.ts b/mobile/src/terminal/terminal-live-pending-flush-state.ts index 405198964f3..fe0b4927f5a 100644 --- a/mobile/src/terminal/terminal-live-pending-flush-state.ts +++ b/mobile/src/terminal/terminal-live-pending-flush-state.ts @@ -1,5 +1,30 @@ +type TerminalLiveMirrorSender = (handle: string, payload: string) => Promise + +type TerminalLivePendingRequest = { + readonly resolve: (sent: boolean) => void +} + +type TerminalLivePendingBatch = { + readonly handle: string + payload: string + readonly requests: TerminalLivePendingRequest[] + readonly sender: TerminalLiveMirrorSender +} + export type TerminalLivePendingFlushState = { current: Promise | null + activeRequests: TerminalLivePendingRequest[] + generation: number + pendingBatches: TerminalLivePendingBatch[] +} + +export function createTerminalLivePendingFlushState(): TerminalLivePendingFlushState { + return { + current: null, + activeRequests: [], + generation: 0, + pendingBatches: [] + } } export function waitForTerminalLivePendingFlush( @@ -8,26 +33,76 @@ export function waitForTerminalLivePendingFlush( return state.current ?? Promise.resolve(true) } -// Why: mirror payloads are erase/append deltas against the PTY echo. A skipped -// delta desyncs every later diff, so this chain runs each send even when the -// previous one failed. state.current should never reject; the catch keeps a -// future raw assignment from skipping a delta. +export function cancelTerminalLivePendingFlush(state: TerminalLivePendingFlushState): void { + state.generation += 1 + const requests = [ + ...state.activeRequests, + ...state.pendingBatches.flatMap((batch) => batch.requests) + ] + state.activeRequests = [] + state.pendingBatches = [] + state.current = null + requests.forEach(({ resolve }) => resolve(false)) +} + +async function drainTerminalLiveMirrorSends( + state: TerminalLivePendingFlushState, + generation: number +): Promise { + let allSent = true + while (state.generation === generation) { + const batch = state.pendingBatches.shift() + if (!batch) { + state.current = null + return allSent + } + + state.activeRequests = batch.requests + const sent = await batch.sender(batch.handle, batch.payload).catch(() => false) + if (state.generation !== generation) { + return false + } + + state.activeRequests = [] + batch.requests.forEach(({ resolve }) => resolve(sent)) + allSent &&= sent + } + return false +} + +// Mirror deltas are ordered PTY bytes; batching pending bytes avoids one RTT per keystroke. export function queueTerminalLiveMirrorSend( state: TerminalLivePendingFlushState, - sendMirrorPayload: () => Promise + handle: string, + payload: string, + sender: TerminalLiveMirrorSender ): Promise { - const previousSend = state.current - const sendPromise = (async () => { - if (previousSend) { - await previousSend.catch(() => false) - } - return sendMirrorPayload() - })().catch(() => false) - state.current = sendPromise - void sendPromise.then(() => { - if (state.current === sendPromise) { - state.current = null - } + let resolveRequest: (sent: boolean) => void = () => {} + const request = new Promise((resolve) => { + resolveRequest = resolve }) - return sendPromise + const pendingTail = state.pendingBatches.at(-1) + if (pendingTail?.handle === handle && pendingTail.sender === sender) { + pendingTail.payload += payload + pendingTail.requests.push({ resolve: resolveRequest }) + } else { + state.pendingBatches.push({ + handle, + payload, + requests: [{ resolve: resolveRequest }], + sender + }) + } + + if (!state.current) { + const generation = state.generation + const drain = drainTerminalLiveMirrorSends(state, generation).catch(() => false) + state.current = drain + void drain.then(() => { + if (state.current === drain) { + state.current = null + } + }) + } + return request } diff --git a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts index f6239312947..05617442142 100644 --- a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts +++ b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts @@ -7,6 +7,8 @@ import { TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS } from './terminal-live-hangul-mirror' import { + cancelTerminalLivePendingFlush, + createTerminalLivePendingFlushState, queueTerminalLiveMirrorSend, waitForTerminalLivePendingFlush } from './terminal-live-pending-flush-state' @@ -39,7 +41,7 @@ export function useTerminalLivePendingInputFlush({ setLiveInputCapture }: TerminalLivePendingInputFlushOptions): TerminalLivePendingInputFlush { const heldCommitTimerRef = useRef | null>(null) - const pendingLiveInputFlushRef = useRef | null>(null) + const pendingLiveInputFlushRef = useRef(createTerminalLivePendingFlushState()) const heldLiveInputTextRef = useRef('') const sentLiveInputTextRef = useRef('') const pendingLiveInputHandleRef = useRef(null) @@ -56,6 +58,7 @@ export function useTerminalLivePendingInputFlush({ const resetMirrorState = useCallback(() => { clearHeldCommitTimer() + cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current) heldLiveInputTextRef.current = '' sentLiveInputTextRef.current = '' pendingLiveInputHandleRef.current = null @@ -68,9 +71,15 @@ export function useTerminalLivePendingInputFlush({ }, [liveInputRef, resetMirrorState, setLiveInputCapture]) const waitForPendingLiveInputFlush = useCallback(async (): Promise => { - return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef) + return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef.current) }, []) + const sendQueuedMirrorPayload = useCallback( + (handle: string, payload: string): Promise => + sendLiveTerminalInputRef.current(handle, payload), + [sendLiveTerminalInputRef] + ) + const runMirrorStep = useCallback( async (handle: string, fieldText: string, commitHeld: boolean): Promise => { if ( @@ -107,8 +116,11 @@ export function useTerminalLivePendingInputFlush({ if (payload.length === 0) { return waitForPendingLiveInputFlush() } - return queueTerminalLiveMirrorSend(pendingLiveInputFlushRef, () => - sendLiveTerminalInputRef.current(handle, payload) + return queueTerminalLiveMirrorSend( + pendingLiveInputFlushRef.current, + handle, + payload, + sendQueuedMirrorPayload ) }, [ @@ -117,7 +129,7 @@ export function useTerminalLivePendingInputFlush({ clearHeldCommitTimer, liveInputTerminalHandlesRef, resetMirrorState, - sendLiveTerminalInputRef, + sendQueuedMirrorPayload, waitForPendingLiveInputFlush ] ) @@ -164,7 +176,7 @@ export function useTerminalLivePendingInputFlush({ heldLiveInputTextRef.current = '' sentLiveInputTextRef.current = '' pendingLiveInputHandleRef.current = null - pendingLiveInputFlushRef.current = null + cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current) } }, []) diff --git a/mobile/src/transport/mobile-relay-pairing-recovery.test.ts b/mobile/src/transport/mobile-relay-pairing-recovery.test.ts index 41d53189880..96d71b37680 100644 --- a/mobile/src/transport/mobile-relay-pairing-recovery.test.ts +++ b/mobile/src/transport/mobile-relay-pairing-recovery.test.ts @@ -220,4 +220,53 @@ describe('mobile relay pairing recovery', () => { expect(saved.metadata.authorizationMode).toBe('authenticated-direct') expect(deps.updateJournal).toHaveBeenCalledTimes(2) }) + // Why: a journal stranded by a relay outage used to block every later pairing + // with "recovery pending" forever, because recovery only ever deferred. + it('abandons a journal once its invite expired and no credential can reconcile', async () => { + const saved = journal() + const unreachable = client(async () => { + throw new Error('relay unreachable') + }) + const deps = { + ...dependencies({ journal: saved, connectRelay: vi.fn(() => unreachable) }), + now: () => saved.metadata.relay.inviteExpiresAt + 10 * 60 * 1000 + 1 + } + + await expect(recoverMobileRelayPairing(deps)).resolves.toBe('abandoned') + expect(deps.clearJournal).toHaveBeenCalledWith(saved.metadata.journalId) + }) + + it('keeps a just-expired journal so a brief outage cannot discard it', async () => { + const saved = journal() + const unreachable = client(async () => { + throw new Error('relay unreachable') + }) + const deps = { + ...dependencies({ journal: saved, connectRelay: vi.fn(() => unreachable) }), + now: () => saved.metadata.relay.inviteExpiresAt + 1 + } + + await expect(recoverMobileRelayPairing(deps)).resolves.toBe('deferred') + expect(deps.clearJournal).not.toHaveBeenCalled() + }) + + // Why: a committed install whose local persistence failed is the one case the + // journal must survive — it is the only record left to retry the write from. + it('keeps a journal when the server committed but the local write failed', async () => { + const saved = journal() + const directInstalled = installed(saved, 'authenticated-direct') + const committed = client(async () => + response(endpoints(saved, { state: 'committed', result: directInstalled })) + ) + const deps = { + ...dependencies({ journal: saved, connectRelay: vi.fn(() => committed) }), + now: () => saved.metadata.relay.inviteExpiresAt + 10 * 60 * 1000 + 1, + writeCredentialBundle: vi.fn(async () => { + throw new Error('keychain unavailable') + }) + } + + await expect(recoverMobileRelayPairing(deps)).resolves.toBe('deferred') + expect(deps.clearJournal).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/transport/mobile-relay-pairing-recovery.ts b/mobile/src/transport/mobile-relay-pairing-recovery.ts index 0d5ba7e5e07..26dc08620de 100644 --- a/mobile/src/transport/mobile-relay-pairing-recovery.ts +++ b/mobile/src/transport/mobile-relay-pairing-recovery.ts @@ -27,7 +27,7 @@ import { import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate' import type { HostProfile, RpcResponse } from './types' -export type MobileRelayPairingRecoveryResult = 'none' | 'recovered' | 'deferred' +export type MobileRelayPairingRecoveryResult = 'none' | 'recovered' | 'deferred' | 'abandoned' type RecoveryDependencies = { loadJournal: typeof loadMobileRelayPairingJournal @@ -57,6 +57,10 @@ const defaultDependencies: RecoveryDependencies = { platform: Platform.OS } +// One full invite lifetime past expiry, so a momentary outage never discards a +// journal that a later launch could still reconcile. +const ABANDON_GRACE_MS = 10 * 60 * 1000 + let recoveryPromise: Promise | null = null export function recoverMobileRelayPairing( @@ -96,6 +100,10 @@ async function runRecovery( } const credentials = recoveryCredentials(journal, bundle, dependencies.now()) + // Why: publishCommitted runs inside the catch below, so a failed local write + // of an authoritatively committed install must not look like "nothing to + // reconcile" — that journal is the only record left to retry the write from. + let observedCommitted = false for (const credential of credentials) { let client: PairingCandidateClient | null = null try { @@ -113,6 +121,7 @@ async function runRecovery( }) const endpoints = await getRecoveryStatus(client, journal, credential.kind) if (endpoints.installStatus?.state === 'committed') { + observedCommitted = true await publishCommitted(journal, endpoints, dependencies) return 'recovered' } @@ -128,6 +137,7 @@ async function runRecovery( ) const reconciled = await getRecoveryStatus(client, journal, 'invite') assertCommitted(reconciled, installed) + observedCommitted = true await publishCommitted(journal, reconciled, dependencies) return 'recovered' } @@ -138,6 +148,19 @@ async function runRecovery( client?.close() } } + // Why: past invite expiry no credential can still establish what happened, so + // retaining the journal cannot reconcile anything — it only fails every later + // pairing with "recovery pending" forever. Re-pairing mints a fresh device and + // any uncommitted server-side install expires on its own. The extra invite + // lifetime of slack keeps a brief relay outage from discarding a journal whose + // resume credential would have reconciled it on the next launch. + if ( + !observedCommitted && + journal.metadata.relay.inviteExpiresAt + ABANDON_GRACE_MS <= dependencies.now() + ) { + await dependencies.clearJournal(journal.metadata.journalId).catch(() => {}) + return 'abandoned' + } return 'deferred' } diff --git a/package.json b/package.json index cbc7862f8fd..d800838281b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.169-rc.0", + "version": "1.4.174-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 8d3a52fa823..f802fecb7e7 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -105,7 +105,6 @@ function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings { openLinksInAppPreferencePrompted: false, rightSidebarOpenByDefault: true, sourceControlViewMode: 'list', - sourceControlGroupOrder: 'changes-first', sourceControlCompareAgainstUpstream: false, showTitlebarAppName: true, showTasksButton: true, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 47fd0c67850..79bd2ea7bbe 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -97,7 +97,6 @@ function createSettings(overrides: Partial = {}): GlobalSettings openLinksInAppPreferencePrompted: false, rightSidebarOpenByDefault: true, sourceControlViewMode: 'list', - sourceControlGroupOrder: 'changes-first', sourceControlCompareAgainstUpstream: false, showTitlebarAppName: true, showTasksButton: true, diff --git a/src/main/git/git-status-read-lease-owner.ts b/src/main/git/git-status-read-lease-owner.ts index a0012da7d3a..eeae5eedf95 100644 --- a/src/main/git/git-status-read-lease-owner.ts +++ b/src/main/git/git-status-read-lease-owner.ts @@ -1,101 +1,3 @@ -type StatusReadEntry = { - controller: AbortController - promise: Promise - liveLeases: number - settled: boolean -} - -function getAbortReason(signal: AbortSignal): unknown { - try { - signal.throwIfAborted() - } catch (error) { - return error - } - return new DOMException('This operation was aborted', 'AbortError') -} - -export class GitStatusReadLeaseOwner { - private readonly entries = new Map>() - - lease( - key: string, - signal: AbortSignal | undefined, - load: (sharedSignal: AbortSignal) => Promise - ): Promise { - if (signal?.aborted) { - return Promise.reject(getAbortReason(signal)) - } - - let entry = this.entries.get(key) - if (!entry) { - const controller = new AbortController() - const promise = load(controller.signal) - const createdEntry = { controller, promise, liveLeases: 0, settled: false } - entry = createdEntry - this.entries.set(key, createdEntry) - void promise.then( - () => this.settle(key, createdEntry), - () => this.settle(key, createdEntry) - ) - } - - entry.liveLeases += 1 - return this.createLease(key, entry, signal) - } - - invalidate(): void { - this.entries.clear() - } - - private createLease( - key: string, - entry: StatusReadEntry, - signal: AbortSignal | undefined - ): Promise { - return new Promise((resolve, reject) => { - let active = true - const release = (abortReason?: unknown): boolean => { - if (!active) { - return false - } - active = false - signal?.removeEventListener('abort', onAbort) - entry.liveLeases -= 1 - if (abortReason !== undefined && entry.liveLeases === 0 && !entry.settled) { - if (this.entries.get(key) === entry) { - this.entries.delete(key) - } - entry.controller.abort(abortReason) - } - return true - } - const onAbort = (): void => { - const reason = getAbortReason(signal!) - if (release(reason)) { - reject(reason) - } - } - - signal?.addEventListener('abort', onAbort, { once: true }) - void entry.promise.then( - (value) => { - if (release()) { - resolve(value) - } - }, - (error: unknown) => { - if (release()) { - reject(error) - } - } - ) - }) - } - - private settle(key: string, entry: StatusReadEntry): void { - entry.settled = true - if (this.entries.get(key) === entry) { - this.entries.delete(key) - } - } -} +// Why: the class moved to src/shared so the relay host can coalesce the same +// reads; this re-export keeps main's import path (and its tests) stable. +export { GitStatusReadLeaseOwner } from '../../shared/git-status-read-lease-owner' diff --git a/src/main/git/status-branch-line-total-exec-contract.test.ts b/src/main/git/status-branch-line-total-exec-contract.test.ts new file mode 100644 index 00000000000..13ece36daec --- /dev/null +++ b/src/main/git/status-branch-line-total-exec-contract.test.ts @@ -0,0 +1,330 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + buildGitBranchLineTotalDiffArgs, + invalidateGitBranchLineTotalInFlight +} from '../../shared/git-branch-line-total' + +import type * as BranchLineTotal from '../../shared/git-branch-line-total' +import type * as GitRunner from './runner' + +// Why: real git, spied argv. Every exec is recorded so the performance contract +// ("no ranged diff unless asked", "one exec for concurrent callers") can be +// asserted on the actual command list rather than on a stubbed return value. +const { gitExecCalls, execHooks, coalescerJoins } = vi.hoisted(() => ({ + gitExecCalls: [] as string[][], + execHooks: { beforeExec: undefined as undefined | ((args: string[]) => Promise | void) }, + coalescerJoins: { count: 0, onJoin: undefined as undefined | (() => void) } +})) + +vi.mock('../../shared/git-branch-line-total', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + computeGitBranchLineTotal: ( + input: Parameters[0] + ) => { + // The lease is taken synchronously inside, so counting after the call + // means a join is observable the instant it has happened. + const total = actual.computeGitBranchLineTotal(input) + coalescerJoins.count += 1 + coalescerJoins.onJoin?.() + return total + } + } +}) + +vi.mock('./runner', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + gitExecFileAsync: async (...args: Parameters) => { + gitExecCalls.push(args[0]) + await execHooks.beforeExec?.(args[0]) + return actual.gitExecFileAsync(...args) + }, + gitExecFileAsyncBuffer: async ( + ...args: Parameters + ) => { + gitExecCalls.push(args[0]) + return actual.gitExecFileAsyncBuffer(...args) + }, + gitStreamStdout: async (...args: Parameters) => { + gitExecCalls.push(args[0]) + return actual.gitStreamStdout(...args) + } + } +}) + +import { + clearEffectiveUpstreamStatusCacheForTests, + clearSubmodulePathsCacheForTests, + getStatus, + invalidateGitReadCaches +} from './status' + +const BOGUS_MERGE_BASE = 'deadbeef'.repeat(5) +const tempRoots: string[] = [] + +function git(repo: string, args: string[]): string { + return execFileSync('git', args, { + cwd: repo, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }).trim() +} + +async function createFixtureRepo(): Promise<{ repo: string; mergeBase: string }> { + const root = await mkdtemp(path.join(tmpdir(), 'orca-branch-line-total-exec-')) + tempRoots.push(root) + const repo = path.join(root, 'repo') + execFileSync('git', ['init', '-q', repo]) + git(repo, ['config', 'user.email', 'test@example.com']) + git(repo, ['config', 'user.name', 'Test User']) + git(repo, ['config', 'commit.gpgSign', 'false']) + await write(repo, 'f.txt', 'a\nb\n') + git(repo, ['add', '-A']) + git(repo, ['commit', '-q', '-m', 'base']) + await write(repo, 'f.txt', 'a\nb\nc\n') + return { repo, mergeBase: git(repo, ['rev-parse', 'HEAD']) } +} + +async function write(repo: string, relativePath: string, contents: string): Promise { + const target = path.join(repo, relativePath) + await mkdir(path.dirname(target), { recursive: true }) + await writeFile(target, contents) +} + +/** The `mergeBase → worktree` diff, distinguished from the per-area numstats by its rev + `--`. */ +function rangedDiffCalls(): string[][] { + return gitExecCalls.filter((args) => args.includes('--numstat') && args.at(-1) === '--') +} + +function numstatCalls(): string[][] { + return gitExecCalls.filter((args) => args.includes('--numstat')) +} + +/** Resolves once `count` status passes have entered the branch-total coalescer. */ +function waitForCoalescerJoins(count: number): Promise { + if (coalescerJoins.count >= count) { + return Promise.resolve() + } + return new Promise((resolve) => { + coalescerJoins.onJoin = () => { + if (coalescerJoins.count >= count) { + coalescerJoins.onJoin = undefined + resolve() + } + } + }) +} + +function resetGitReadCaches(): void { + clearEffectiveUpstreamStatusCacheForTests() + clearSubmodulePathsCacheForTests() + invalidateGitReadCaches() + invalidateGitBranchLineTotalInFlight() +} + +beforeEach(() => { + gitExecCalls.length = 0 + execHooks.beforeExec = undefined + coalescerJoins.count = 0 + coalescerJoins.onJoin = undefined + resetGitReadCaches() +}) + +afterEach(async () => { + execHooks.beforeExec = undefined + coalescerJoins.onJoin = undefined + resetGitReadCaches() + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('branch line total completeness', () => { + it('omits the total when the status listing hit its limit', async () => { + const { repo, mergeBase } = await createFixtureRepo() + await write(repo, 'u1.txt', 'a\n') + await write(repo, 'u2.txt', 'b\n') + await write(repo, 'u3.txt', 'c\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase, limit: 1 }) + + expect(result.didHitLimit).toBe(true) + expect(result.branchLineTotal).toBeUndefined() + expect(rangedDiffCalls()).toEqual([]) + }) + + it('omits the total when the ranged numstat fails, rather than reporting zero', async () => { + const { repo } = await createFixtureRepo() + + const result = await getStatus(repo, { branchLineTotalMergeBase: BOGUS_MERGE_BASE }) + + expect(result.entries.length).toBeGreaterThan(0) + expect(result.branchLineTotal).toBeUndefined() + // The diff really was attempted, so this is a failure path and not a gate. + expect(rangedDiffCalls()).toHaveLength(1) + }) + + it('omits the total for a directory that is not a git worktree', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'orca-branch-line-total-folder-')) + tempRoots.push(root) + + const result = await getStatus(root, { branchLineTotalMergeBase: BOGUS_MERGE_BASE }) + + expect(result.branchLineTotal).toBeUndefined() + expect(rangedDiffCalls()).toEqual([]) + }) + + it('rejects with an AbortError when the pass is cancelled mid-diff', async () => { + const { repo, mergeBase } = await createFixtureRepo() + const controller = new AbortController() + execHooks.beforeExec = (args) => { + if (args.includes('--numstat') && args.at(-1) === '--') { + controller.abort() + } + } + + await expect( + getStatus(repo, { branchLineTotalMergeBase: mergeBase, signal: controller.signal }) + ).rejects.toMatchObject({ name: 'AbortError' }) + // The pass had already streamed status and reached the ranged diff, so this + // is a cancellation in flight rather than a pre-flight rejection. + expect(rangedDiffCalls()).toHaveLength(1) + }) +}) + +describe('branch line total merge-base gate', () => { + it('runs no ranged diff and returns no total without a merge base', async () => { + const { repo } = await createFixtureRepo() + + const result = await getStatus(repo) + + expect(result.branchLineTotal).toBeUndefined() + expect(rangedDiffCalls()).toEqual([]) + }) + + it.each(['--upload-pack=x', 'HEAD', '', 'origin/main', '-M', 'A1B2C3D'])( + 'never spawns a ranged diff for a merge base of %j', + async (branchLineTotalMergeBase) => { + const { repo } = await createFixtureRepo() + + const result = await getStatus(repo, { branchLineTotalMergeBase }) + + expect(result.branchLineTotal).toBeUndefined() + expect(rangedDiffCalls()).toEqual([]) + expect(gitExecCalls).not.toContainEqual( + buildGitBranchLineTotalDiffArgs(branchLineTotalMergeBase) + ) + } + ) + + it('never leaks a flag-shaped merge base into any git argv', async () => { + const { repo } = await createFixtureRepo() + + await getStatus(repo, { branchLineTotalMergeBase: '--upload-pack=/tmp/evil' }) + + for (const args of gitExecCalls) { + expect(args).not.toContain('--upload-pack=/tmp/evil') + } + }) +}) + +describe('branch line total exec budget', () => { + it('costs exactly one extra exec versus the same pass with the chip hidden', async () => { + const { repo, mergeBase } = await createFixtureRepo() + + await getStatus(repo) + const withoutChip = gitExecCalls.map((args) => args.join(' ')) + + gitExecCalls.length = 0 + resetGitReadCaches() + await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + const withChip = gitExecCalls.map((args) => args.join(' ')) + + expect(withChip.filter((args) => !args.endsWith(`${mergeBase} --`))).toEqual(withoutChip) + expect(withChip).toHaveLength(withoutChip.length + 1) + }) + + it('shares one ranged diff between two concurrent identical status passes', async () => { + const { repo, mergeBase } = await createFixtureRepo() + + const [first, second] = await Promise.all([ + getStatus(repo, { branchLineTotalMergeBase: mergeBase }), + getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + ]) + + expect(first.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + expect(second.branchLineTotal).toEqual(first.branchLineTotal) + expect(rangedDiffCalls()).toHaveLength(1) + }) + + it('shares one ranged diff across status passes the status lease cannot merge', async () => { + const { repo, mergeBase } = await createFixtureRepo() + // Why: `limit` is part of the status read key, so these two passes each run + // their own `git status`; only the branch-total coalescer can merge the diff. + // Hold the first diff exactly until the second pass has taken the lease: a + // fixed sleep would either release too early or, on a slow machine, outrun + // GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS and publish without an exact total. + execHooks.beforeExec = async (args) => { + if (args.includes('--numstat') && args.at(-1) === '--') { + await waitForCoalescerJoins(2) + } + } + + const [first, second] = await Promise.all([ + getStatus(repo, { branchLineTotalMergeBase: mergeBase, limit: 0 }), + getStatus(repo, { branchLineTotalMergeBase: mergeBase, limit: 4096 }) + ]) + + expect(first.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + expect(second.branchLineTotal).toEqual(first.branchLineTotal) + // Proves the saving came from the branch-total coalescer: both passes really + // ran their own `git status`, so the status lease merged nothing. + expect(gitExecCalls.filter((args) => args.includes('status'))).toHaveLength(2) + expect(rangedDiffCalls()).toHaveLength(1) + }) + + it('reuses the cached total when the pass reuses cached line stats', async () => { + const { repo, mergeBase } = await createFixtureRepo() + + const first = await getStatus(repo, { + branchLineTotalMergeBase: mergeBase, + reuseLineStats: true + }) + expect(rangedDiffCalls()).toHaveLength(1) + expect(numstatCalls()).toHaveLength(2) + + gitExecCalls.length = 0 + invalidateGitBranchLineTotalInFlight() + const second = await getStatus(repo, { + branchLineTotalMergeBase: mergeBase, + reuseLineStats: true + }) + + expect(second.branchLineTotal).toEqual(first.branchLineTotal) + expect(second.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + expect(numstatCalls()).toEqual([]) + }) + + it('recomputes rather than reusing a total measured against another merge base', async () => { + const { repo, mergeBase } = await createFixtureRepo() + git(repo, ['add', '-A']) + git(repo, ['commit', '-q', '-m', 'second']) + const laterMergeBase = git(repo, ['rev-parse', 'HEAD']) + + await getStatus(repo, { branchLineTotalMergeBase: mergeBase, reuseLineStats: true }) + gitExecCalls.length = 0 + invalidateGitBranchLineTotalInFlight() + const result = await getStatus(repo, { + branchLineTotalMergeBase: laterMergeBase, + reuseLineStats: true + }) + + expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase: laterMergeBase }) + expect(rangedDiffCalls()).toHaveLength(1) + }) +}) diff --git a/src/main/git/status-branch-line-total-real-git.test.ts b/src/main/git/status-branch-line-total-real-git.test.ts new file mode 100644 index 00000000000..80426c76e65 --- /dev/null +++ b/src/main/git/status-branch-line-total-real-git.test.ts @@ -0,0 +1,203 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { invalidateGitBranchLineTotalInFlight } from '../../shared/git-branch-line-total' +import { getStatus, invalidateGitReadCaches } from './status' + +const tempRoots: string[] = [] + +function git(repo: string, args: string[]): string { + return execFileSync('git', args, { + cwd: repo, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }).trim() +} + +async function createFixtureRepo(): Promise { + const root = await mkdtemp(path.join(tmpdir(), 'orca-branch-line-total-')) + tempRoots.push(root) + const repo = path.join(root, 'repo') + execFileSync('git', ['init', '-q', repo]) + git(repo, ['config', 'user.email', 'test@example.com']) + git(repo, ['config', 'user.name', 'Test User']) + git(repo, ['config', 'commit.gpgSign', 'false']) + return repo +} + +async function write(repo: string, relativePath: string, contents: string | Buffer): Promise { + const target = path.join(repo, relativePath) + await mkdir(path.dirname(target), { recursive: true }) + await writeFile(target, contents) +} + +/** The forked-from commit the chip measures against, as branch compare would resolve it. */ +function commitAll(repo: string, message: string): string { + git(repo, ['add', '-A']) + git(repo, ['commit', '-q', '-m', message]) + return git(repo, ['rev-parse', 'HEAD']) +} + +/** What a plain `git diff ` reports, i.e. the number the chip must not disagree with. */ +function rangedDiffTotal(repo: string, mergeBase: string): { added: number; removed: number } { + let added = 0 + let removed = 0 + for (const line of git(repo, ['diff', '--numstat', '-M', mergeBase, '--']).split(/\r?\n/)) { + if (!line) { + continue + } + const [rawAdded, rawRemoved] = line.split('\t') + added += rawAdded === '-' ? 0 : Number.parseInt(rawAdded ?? '0', 10) + removed += rawRemoved === '-' ? 0 : Number.parseInt(rawRemoved ?? '0', 10) + } + return { added, removed } +} + +beforeEach(() => { + invalidateGitReadCaches() + invalidateGitBranchLineTotalInFlight() +}) + +afterEach(async () => { + invalidateGitReadCaches() + invalidateGitBranchLineTotalInFlight() + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('branch line total against a real repository', () => { + it('measures a partially staged line once, not once per status area', async () => { + const repo = await createFixtureRepo() + await write(repo, 'f.txt', 'line1\n') + const mergeBase = commitAll(repo, 'base') + // Stage "+foo", then edit that same line to "bar" without staging it: the + // per-area rows read +1/-0 staged and +1/-1 unstaged, summing to +2/-1. + await write(repo, 'f.txt', 'line1\nfoo\n') + git(repo, ['add', 'f.txt']) + await write(repo, 'f.txt', 'line1\nbar\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 }) + expect(result.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + expect(result.entries.map((entry) => [entry.area, entry.added, entry.removed])).toEqual([ + ['staged', 1, 0], + ['unstaged', 1, 1] + ]) + }) + + it('nets a line added in a branch commit and deleted in the working tree to zero', async () => { + const repo = await createFixtureRepo() + await write(repo, 'f.txt', 'a\n') + const mergeBase = commitAll(repo, 'base') + await write(repo, 'f.txt', 'a\nb\n') + commitAll(repo, 'add b') + await write(repo, 'f.txt', 'a\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase }) + }) + + it('keeps the total across a commit and updates it on the next status call after an edit', async () => { + const repo = await createFixtureRepo() + await write(repo, 'f.txt', 'a\n') + const mergeBase = commitAll(repo, 'base') + await write(repo, 'f.txt', 'a\nb\nc\n') + + const beforeCommit = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + expect(beforeCommit.branchLineTotal).toEqual({ added: 2, removed: 0, mergeBase }) + + commitAll(repo, 'commit the edit') + const afterCommit = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + expect(afterCommit.branchLineTotal).toEqual({ added: 2, removed: 0, mergeBase }) + + await write(repo, 'f.txt', 'a\nb\nc\nd\n') + const afterSecondEdit = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + expect(afterSecondEdit.branchLineTotal).toEqual({ added: 3, removed: 0, mergeBase }) + }) + + it('counts a rename across the commit boundary once, using the post-rename path', async () => { + const repo = await createFixtureRepo() + await write(repo, 'old.txt', 'a\nb\nc\nd\ne\n') + const mergeBase = commitAll(repo, 'base') + git(repo, ['mv', 'old.txt', 'new.txt']) + commitAll(repo, 'rename') + await write(repo, 'new.txt', 'a\nb\nc\nd\ne\nf\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 }) + expect(result.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + }) + + it('counts an untracked-only branch from the untracked file contents', async () => { + const repo = await createFixtureRepo() + await write(repo, 'kept.txt', 'a\n') + const mergeBase = commitAll(repo, 'base') + await write(repo, 'src/new.ts', 'one\ntwo\nthree\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(result.entries.map((entry) => entry.area)).toEqual(['untracked']) + expect(result.branchLineTotal).toEqual({ added: 3, removed: 0, mergeBase }) + }) + + it('excludes a binary-only change, matching numstat reporting it as "-"', async () => { + const repo = await createFixtureRepo() + await write(repo, 'blob.bin', Buffer.from([0, 1, 2, 3, 10, 65, 66])) + const mergeBase = commitAll(repo, 'base') + await write(repo, 'blob.bin', Buffer.from([0, 9, 10, 11, 12, 67, 68, 69, 70])) + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(git(repo, ['diff', '--numstat', mergeBase, '--'])).toMatch(/^-\t-\t/) + expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase }) + }) + + it('contributes nothing for a pure rename', async () => { + const repo = await createFixtureRepo() + await write(repo, 'f.txt', 'a\nb\nc\nd\n') + const mergeBase = commitAll(repo, 'base') + git(repo, ['mv', 'f.txt', 'g.txt']) + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase }) + }) + + it('includes untracked additions alongside tracked range changes', async () => { + const repo = await createFixtureRepo() + await write(repo, 'f.txt', 'a\nb\n') + const mergeBase = commitAll(repo, 'base') + await write(repo, 'f.txt', 'a\nb\nc\n') + commitAll(repo, 'append c') + await write(repo, 'untracked.txt', 'u1\nu2\nu3\nu4\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 }) + expect(result.branchLineTotal).toEqual({ added: 5, removed: 0, mergeBase }) + }) + + // Documented deviation from a pure `git diff`: the untracked half is added on + // top of the tracked range, so a deleted-then-recreated path is counted twice. + // Locked in deliberately — changing it must be a deliberate spec change. + it('counts both the deletion and the recreated untracked file for the same path', async () => { + const repo = await createFixtureRepo() + await write(repo, 'gone.txt', 'a\nb\nc\n') + const mergeBase = commitAll(repo, 'base') + git(repo, ['rm', '-q', 'gone.txt']) + commitAll(repo, 'delete gone.txt') + await write(repo, 'gone.txt', 'n1\nn2\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(result.entries.map((entry) => [entry.area, entry.path])).toEqual([ + ['untracked', 'gone.txt'] + ]) + expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 0, removed: 3 }) + expect(result.branchLineTotal).toEqual({ added: 2, removed: 3, mergeBase }) + }) +}) diff --git a/src/main/git/status-branch-line-total-relay-parity.test.ts b/src/main/git/status-branch-line-total-relay-parity.test.ts new file mode 100644 index 00000000000..e9f5240728c --- /dev/null +++ b/src/main/git/status-branch-line-total-relay-parity.test.ts @@ -0,0 +1,172 @@ +/** + * Main and relay must publish the same `branchLineTotal` for the same fixture + * repo. Both call sites + * share `src/shared/git-branch-line-total.ts`; this is the test that catches one + * of them wiring it up differently — different flags, a different untracked + * source, a different completeness gate. + */ +import { execFile, execFileSync } from 'node:child_process' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import * as path from 'node:path' +import { promisify } from 'node:util' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { getStatus } from './status' +import type { GitExec } from '../../relay/git-handler-ops' +import { getStatusOp } from '../../relay/git-handler-status-ops' +import type { RelayGitStreamExec } from '../../relay/git-stdout-stream' +import { invalidateGitBranchLineTotalInFlight } from '../../shared/git-branch-line-total' +import { clearGitStatusLineStatsCache } from '../../shared/git-status-line-stats-cache' + +const execFileAsync = promisify(execFile) + +// Fork point → working tree for the fixture below: +// tracked.txt +2 (branch commit only) +// partial.txt +1 (staged +2 then one of those lines removed unstaged) +// flip.txt 0 (added in the branch commit, removed again in the worktree) +// moved.txt 0 (pure rename) +// fresh.txt +3 (untracked) +const EXPECTED_TOTAL = { added: 6, removed: 0 } +// Summing the per-area status rows instead would give this — the wrong answer +// the shared module exists to avoid. +const AREA_ROW_SUM = { added: 5, removed: 2 } + +const relayGit: GitExec = async (args, cwd, opts) => { + const { stdout, stderr } = await execFileAsync('git', args, { + cwd, + encoding: 'utf8', + ...(opts?.signal ? { signal: opts.signal } : {}), + ...(opts?.timeout ? { timeout: opts.timeout } : {}) + }) + return { stdout, stderr } +} + +const relayStreamGit: RelayGitStreamExec = async (args, cwd, options) => { + const { stdout } = await relayGit(args, cwd, { + disableOptionalLocks: options.disableOptionalLocks, + signal: options.signal + }) + return { stoppedEarly: options.onStdout(stdout) === true } +} + +function runFixtureGit(repo: string, args: string[]): string { + return execFileSync( + 'git', + [ + '-c', + 'user.email=test@test.com', + '-c', + 'user.name=Test', + '-c', + 'commit.gpgSign=false', + ...args + ], + { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } + ).trim() +} + +/** Returns the merge-base OID the chip is measured against. */ +async function seedParityFixture(repo: string): Promise { + execFileSync('git', ['init', '-q', repo], { stdio: 'pipe' }) + await writeFile(path.join(repo, 'tracked.txt'), 'a\nb\n') + await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\n') + await writeFile(path.join(repo, 'renamed.txt'), 'stable\n') + await writeFile(path.join(repo, 'flip.txt'), 'p\n') + runFixtureGit(repo, ['add', '.']) + runFixtureGit(repo, ['commit', '-m', 'base']) + const mergeBase = runFixtureGit(repo, ['rev-parse', 'HEAD']) + + runFixtureGit(repo, ['checkout', '-q', '-b', 'feature']) + await writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\nd\n') + await writeFile(path.join(repo, 'flip.txt'), 'p\nq\n') + runFixtureGit(repo, ['add', '-A']) + runFixtureGit(repo, ['commit', '-m', 'branch commit']) + + runFixtureGit(repo, ['mv', 'renamed.txt', 'moved.txt']) + // Staged and unstaged hunks land on the same added lines, so an area sum + // double-counts them. + await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\nfoo\nbaz\n') + runFixtureGit(repo, ['add', 'partial.txt']) + await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\nfoo\n') + // The branch commit's line, taken back out in the worktree: net zero. + await writeFile(path.join(repo, 'flip.txt'), 'p\n') + await writeFile(path.join(repo, 'fresh.txt'), 'n1\nn2\nn3\n') + return mergeBase +} + +function sumAreaRows(entries: readonly { added?: number; removed?: number }[]): { + added: number + removed: number +} { + let added = 0 + let removed = 0 + for (const entry of entries) { + added += entry.added ?? 0 + removed += entry.removed ?? 0 + } + return { added, removed } +} + +describe('branch line total parity between main and relay', () => { + let repo: string + + beforeEach(async () => { + clearGitStatusLineStatsCache() + invalidateGitBranchLineTotalInFlight() + repo = await mkdtemp(path.join(tmpdir(), 'branch-line-total-parity-')) + }) + + afterEach(async () => { + clearGitStatusLineStatsCache() + invalidateGitBranchLineTotalInFlight() + await rm(repo, { recursive: true, force: true }) + }) + + it('produces identical totals for the same fixture repo', async () => { + const mergeBase = await seedParityFixture(repo) + + const mainStatus = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + clearGitStatusLineStatsCache() + const relayStatus = await getStatusOp(relayGit, relayStreamGit, { + worktreePath: repo, + branchLineTotalMergeBase: mergeBase + }) + + expect(mainStatus.branchLineTotal).toEqual({ ...EXPECTED_TOTAL, mergeBase }) + expect(relayStatus.branchLineTotal).toEqual(mainStatus.branchLineTotal) + // Both sides model the same worktree, so a parity pass on a mismatched + // entry list would be meaningless. + expect(relayStatus.entries.map((entry) => entry.path).sort()).toEqual( + mainStatus.entries.map((entry) => entry.path).sort() + ) + }) + + it('agrees on a number no per-area row sum could produce', async () => { + const mergeBase = await seedParityFixture(repo) + + const mainStatus = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + clearGitStatusLineStatsCache() + const relayStatus = await getStatusOp(relayGit, relayStreamGit, { + worktreePath: repo, + branchLineTotalMergeBase: mergeBase + }) + + expect(sumAreaRows(mainStatus.entries)).toEqual(AREA_ROW_SUM) + expect(sumAreaRows(relayStatus.entries as { added?: number; removed?: number }[])).toEqual( + AREA_ROW_SUM + ) + expect(mainStatus.branchLineTotal).not.toMatchObject(AREA_ROW_SUM) + expect(relayStatus.branchLineTotal).toEqual(mainStatus.branchLineTotal) + }) + + it('omits the total on both sides when no merge base is requested', async () => { + await seedParityFixture(repo) + + const mainStatus = await getStatus(repo) + clearGitStatusLineStatsCache() + const relayStatus = await getStatusOp(relayGit, relayStreamGit, { worktreePath: repo }) + + expect(Object.hasOwn(mainStatus, 'branchLineTotal')).toBe(false) + expect(Object.hasOwn(relayStatus, 'branchLineTotal')).toBe(false) + }) +}) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 120c84869d2..c03d9307112 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -54,6 +54,13 @@ import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' import { GitStatusReadLeaseOwner } from './git-status-read-lease-owner' import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output' +import { + computeGitBranchLineTotal, + invalidateGitBranchLineTotalInFlight, + readGitBranchLineTotalMergeBaseParam, + GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS, + type GitBranchLineTotal +} from '../../shared/git-branch-line-total' import { beginGitStatusLineStatsCacheWrite, clearGitStatusLineStatsCache, @@ -95,10 +102,12 @@ const gitDiffReadDedupe = new InFlightPromiseDedupe() const effectiveUpstreamStatusWriteGeneration = new Map() const statusReadLeaseOwner = new GitStatusReadLeaseOwner() -// Why: clear both diff and status in-flight caches; clearing only diff would let getStatus() join a pre-mutation read. +// Why: clear every in-flight git read cache; clearing only some would let a post-mutation +// getStatus() join a pre-mutation read and publish it as current. export function invalidateGitReadCaches(): void { gitDiffReadDedupe.clear() statusReadLeaseOwner.invalidate() + invalidateGitBranchLineTotalInFlight() clearGitStatusLineStatsCache() clearSubmodulePathsCache() resolvedUpstreamNameCache.clear() @@ -195,6 +204,9 @@ export function getEffectiveUpstreamStatusGenerationCountForTests(): number { export type GetStatusOptions = GitRuntimeOptions & { includeIgnored?: boolean reuseLineStats?: boolean + /** Merge-base OID the caller wants the branch line total measured against; + * omitted means the chip is hidden, so no ranged diff runs at all. */ + branchLineTotalMergeBase?: string /** * Max changed-file entries before git is stopped and the result is marked * `didHitLimit`. Defaults to DEFAULT_GIT_STATUS_LIMIT; 0 disables the cap. @@ -232,6 +244,9 @@ function getStatusReadKey(worktreePath: string, options: GetStatusOptions): stri options.wslDistro ?? '', options.includeIgnored === true, options.reuseLineStats === true, + // Why: the result carries a total only for callers who asked, and only for + // this fork point, so a shared lease must never serve one to the other. + options.branchLineTotalMergeBase ?? '', options.bypassEffectiveUpstreamNegativeCache === true, limit, // Why: this changes which entries survive, so it must not share a cache slot. @@ -371,16 +386,25 @@ async function runGetStatus( } // Why: line counts run only for areas with entries (clean tree = 0 calls); skip past the limit to avoid numstat over a huge set. + let branchLineTotal: GitBranchLineTotal | undefined if (!didHitLimit) { - await reuseOrRecomputeGitStatusLineStats({ + const branchLineTotalInput = createBranchLineTotalInput( + worktreePath, + entries, + options, + statusSucceeded + ) + const lineStats = await reuseOrRecomputeGitStatusLineStats({ cacheKey: lineStatsCacheKey, head, entries, writeToken: lineStatsWriteToken, reuse: options.reuseLineStats === true, isAborted: () => options.signal?.aborted === true, - recompute: () => attachLineStats(worktreePath, entries, options) + recompute: () => attachLineStats(worktreePath, entries, options), + ...(branchLineTotalInput ? { branchLineTotal: branchLineTotalInput } : {}) }) + branchLineTotal = lineStats.branchLineTotal } else { clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken) } @@ -398,6 +422,7 @@ async function runGetStatus( head, branch, ...(options.includeIgnored ? { ignoredPaths: parser.ignoredPaths } : {}), + ...(branchLineTotal ? { branchLineTotal } : {}), ...(didHitLimit ? { didHitLimit: true, statusLength: parser.statusLength } : {}), ...(statusSucceeded ? { @@ -416,6 +441,43 @@ async function runGetStatus( } } +/** Undefined — and therefore zero extra work — unless the caller asked for a total we can know exact. */ +function createBranchLineTotalInput( + worktreePath: string, + entries: GitStatusEntry[], + options: GetStatusOptions, + statusSucceeded: boolean +): { mergeBase: string; compute: () => Promise } | undefined { + const mergeBase = readGitBranchLineTotalMergeBaseParam(options.branchLineTotalMergeBase) + // Why: a failed status scan leaves the untracked list untrustworthy, so the + // total would silently under-count rather than be absent. + if (mergeBase === undefined || !statusSucceeded) { + return undefined + } + return { + mergeBase, + compute: () => + computeGitBranchLineTotal({ + worktreePath, + // Why: the same path can be a different filesystem per WSL distro. + hostKey: options.wslDistro ?? 'native', + mergeBase, + untrackedPaths: entries + .filter((entry) => entry.area === 'untracked') + .map((entry) => entry.path), + runDiffNumstat: (args, signal) => + gitExecFileAsync(args, { + ...gitOptionsForWorktree(worktreePath, options), + // Why: after the spread, so the shared lease signal wins over this caller's own. + signal, + env: gitOptionalLocksDisabledEnv(), + timeout: GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS + }).then((result) => result.stdout), + ...(options.signal ? { signal: options.signal } : {}) + }) + } +} + function getStatusLineStatsCacheKey(worktreePath: string, options: GitRuntimeOptions = {}): string { // Why: identical paths can map to different WSL-distro filesystems, so key stats by Git's execution host. return `${options.wslDistro ?? 'native'}\0${worktreePath}` diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index d1c2ee312d0..ad68010cbb7 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -366,6 +366,30 @@ describe('registerFilesystemHandlers', () => { lstatMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) }) + it('re-sorts SSH provider listings directories-first in natural order', async () => { + // Why: the remote relay may be an older build that still sorts lexicographically. + getSshFilesystemProviderMock.mockReturnValueOnce({ + readDir: vi.fn().mockResolvedValue([ + { name: '100 - b.txt', isDirectory: false, isSymlink: false }, + { name: '9 - c.txt', isDirectory: false, isSymlink: false }, + { name: '10 - dir', isDirectory: true, isSymlink: false }, + { name: '99 - a.txt', isDirectory: false, isSymlink: false } + ]) + }) + registerFilesystemHandlers(store as never) + + const result = (await handlers.get('fs:readDir')!(null, { + dirPath: '/remote/repo', + connectionId: 'ssh-1' + })) as { name: string }[] + expect(result.map((e) => e.name)).toEqual([ + '10 - dir', + '9 - c.txt', + '99 - a.txt', + '100 - b.txt' + ]) + }) + it('returns an actionable reconnect error when the SSH filesystem provider is unavailable', async () => { registerFilesystemHandlers(store as never) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index f3c91088d0a..4ca9547b77c 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -30,6 +30,7 @@ import type { } from '../../shared/types' import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' import type { SshMutationExpectation } from '../../shared/ssh-types' +import { sortDirEntries } from '../../shared/file-name-sort' import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' import { buildRgArgs, @@ -540,7 +541,9 @@ export function registerFilesystemHandlers( if (args.connectionId) { throwSite = 'ssh-provider' const provider = requireSshFilesystemProvider(args.connectionId) - return await provider.readDir(args.dirPath) + // Why: re-sort locally — the remote relay may be an older build with + // lexicographic ordering. + return sortDirEntries(await provider.readDir(args.dirPath)) } throwSite = 'authorize' const dirPath = await resolveAuthorizedPath(args.dirPath, store) @@ -555,12 +558,7 @@ export function registerFilesystemHandlers( isSymlink: entry.isSymbolicLink() })) ) - return mapped.sort((a, b) => { - if (a.isDirectory !== b.isDirectory) { - return a.isDirectory ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) + return sortDirEntries(mapped) } catch (error: unknown) { recordCrashBreadcrumb( 'fs_readdir_error', @@ -1127,6 +1125,7 @@ export function registerFilesystemHandlers( includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean reuseLineStats?: boolean + branchLineTotalMergeBase?: string requestToken?: string } ): Promise => { @@ -1134,6 +1133,9 @@ export function registerFilesystemHandlers( const options = { includeIgnored: args.includeIgnored ?? false, ...(args.reuseLineStats === true ? { reuseLineStats: true } : {}), + ...(args.branchLineTotalMergeBase === undefined + ? {} + : { branchLineTotalMergeBase: args.branchLineTotalMergeBase }), ...(args.bypassEffectiveUpstreamNegativeCache === true ? { bypassEffectiveUpstreamNegativeCache: true } : {}), diff --git a/src/main/ipc/ssh-browse.ts b/src/main/ipc/ssh-browse.ts index 2356de614b0..f2a779ee412 100644 --- a/src/main/ipc/ssh-browse.ts +++ b/src/main/ipc/ssh-browse.ts @@ -3,6 +3,7 @@ import type { SshConnectionManager } from '../ssh/ssh-connection-manager' import type { SshExecOptions } from '../ssh/ssh-connection-utils' import { powerShellCommand, powerShellLiteral } from '../ssh/ssh-remote-powershell' import type { FilesystemPathFlavor } from '../../shared/types' +import { sortDirEntries } from '../../shared/file-name-sort' export type RemoteDirEntry = { name: string @@ -220,13 +221,8 @@ async function runBrowseCommand( } } - // Sort: directories first, then alphabetical - entries.sort((a, b) => { - if (a.isDirectory !== b.isDirectory) { - return a.isDirectory ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) + // Sort: directories first, then natural name order (matches the Explorer) + sortDirEntries(entries) resolveOnce({ entries, resolvedPath, pathFlavor }) } diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index c83eece63b6..f5925e1acc1 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import type { CreateWorktreeResult, GitWorktreeInfo, Repo, Worktree } from '../../shared/types' import type { ProviderRequestId } from '../../shared/detected-worktree-provider-contract' -import { toSshExecutionHostId } from '../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host' import * as localWorktreeFilesystem from '../local-worktree-filesystem' const ORIGINAL_PLATFORM = process.platform @@ -2589,6 +2589,139 @@ describe('registerWorktreeHandlers', () => { }) }) + it('lists every persisted SSH worktree without accessing the live provider', async () => { + const sshHostId = toSshExecutionHostId('target-a') + const sshRepo = { + id: 'repo-1', + path: '/remote/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + const metaById = { + 'repo-1::/remote/repo': makeWorktreeMeta({ + displayName: 'main', + hostId: sshHostId + }), + 'repo-1::/remote/queued': makeWorktreeMeta({ + displayName: 'queued', + hostId: sshHostId + }), + 'repo-1::/remote/other-host': makeWorktreeMeta({ + displayName: 'other host', + hostId: toSshExecutionHostId('target-b') + }) + } + store.getRepos.mockReturnValue([sshRepo]) + store.getProjectHostSetups.mockReturnValue([]) + store.getAllWorktreeMeta.mockReturnValue(metaById) + store.getWorktreeMeta.mockImplementation((worktreeId: string) => metaById[worktreeId]) + + const result = await handlers['worktrees:listKnownForExecutionHost'](null, { + repoId: sshRepo.id, + executionHostId: sshHostId + }) + + expect(result).toMatchObject({ + status: 'complete', + repoId: sshRepo.id, + executionHostId: sshHostId, + result: { + repoId: sshRepo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: [ + expect.objectContaining({ path: '/remote/repo', isMainWorktree: true }), + expect.objectContaining({ path: '/remote/queued', isMainWorktree: false }) + ] + } + }) + expect(getSshGitProviderMock).not.toHaveBeenCalled() + expect(listWorktreesMock).not.toHaveBeenCalled() + }) + + it('lists SSH folder workspaces at the folder path, not the instance-suffixed id', async () => { + const sshHostId = toSshExecutionHostId('target-a') + const folderRepo = { + id: 'repo-1', + path: '/remote/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a', + kind: 'folder' as const + } + const rootId = `${folderRepo.id}::${folderRepo.path}` + const instanceId = `${rootId}::workspace:11111111-2222-3333-4444-555555555555` + const metaById = { + [rootId]: makeWorktreeMeta({ displayName: 'root', hostId: sshHostId }), + [instanceId]: makeWorktreeMeta({ + displayName: 'second workspace', + hostId: sshHostId, + instanceId: '11111111-2222-3333-4444-555555555555' + }) + } + store.getRepos.mockReturnValue([folderRepo]) + store.getProjectHostSetups.mockReturnValue([]) + store.getAllWorktreeMeta.mockReturnValue(metaById) + store.getWorktreeMeta.mockImplementation((worktreeId: string) => metaById[worktreeId]) + + const result = (await handlers['worktrees:listKnownForExecutionHost'](null, { + repoId: folderRepo.id, + executionHostId: sshHostId + })) as { status: string; result: { authoritative: boolean; worktrees: Worktree[] } } + + expect(result.status).toBe('complete') + expect(result.result.authoritative).toBe(false) + // Why: the git-worktree synthesizer would read the "::workspace:" tail as a directory. + expect(result.result.worktrees).toEqual([ + expect.objectContaining({ id: rootId, path: folderRepo.path, isMainWorktree: true }), + expect.objectContaining({ id: instanceId, path: folderRepo.path, isMainWorktree: false }) + ]) + expect(getSshGitProviderMock).not.toHaveBeenCalled() + }) + + it('rejects metadata-only reads for non-SSH execution hosts', async () => { + const result = await handlers['worktrees:listKnownForExecutionHost'](null, { + repoId: 'repo-1', + executionHostId: LOCAL_EXECUTION_HOST_ID + }) + + expect(result).toEqual({ + status: 'rejected', + repoId: 'repo-1', + executionHostId: LOCAL_EXECUTION_HOST_ID + }) + expect(store.getRepos).not.toHaveBeenCalled() + }) + + it('rejects ambiguous metadata-only SSH owners', async () => { + const sshHostId = toSshExecutionHostId('target-a') + const sshRepo = { + id: 'repo-1', + path: '/remote/repo-a', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + store.getRepos.mockReturnValue([sshRepo, { ...sshRepo, path: '/remote/repo-b' }]) + + const result = await handlers['worktrees:listKnownForExecutionHost'](null, { + repoId: sshRepo.id, + executionHostId: sshHostId + }) + + expect(result).toEqual({ + status: 'rejected', + repoId: sshRepo.id, + executionHostId: sshHostId + }) + expect(store.getAllWorktreeMeta).not.toHaveBeenCalled() + expect(getSshGitProviderMock).not.toHaveBeenCalled() + }) + it('fails closed for duplicate exact owners and ambiguous legacy repo IDs', async () => { const sshRepo = { id: 'shared-repo', diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index c8dceec6bed..098e1e8191f 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -49,7 +49,9 @@ import { import { PROVIDER_REQUEST_ID_MAX_UTF8_BYTES, type DirectSshDetectedWorktreeRequest, + type HostQualifiedKnownWorktreeResult, type HostQualifiedDetectedWorktreeResult, + type ListKnownWorktreesForExecutionHostArgs, type ListDetectedWorktreesArgs, type ProviderRequestId } from '../../shared/detected-worktree-provider-contract' @@ -229,7 +231,8 @@ import { import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, - getRepoIdFromWorktreeId + getRepoIdFromWorktreeId, + getWorktreePathBasenameFromId } from '../../shared/worktree-id' import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' import { @@ -804,6 +807,17 @@ function createSshWorktreeMetaIndex(entries: [string, WorktreeMeta][]): SshWorkt return index } +// Why: scopes parseWorktreeId to one repo's keys. The entry list itself is still materialized for the whole +// store, so this is cheaper per call than the unfiltered index, not free. +function createSshWorktreeMetaIndexForRepo( + allMeta: Record, + repoId: string +): SshWorktreeMetaIndex { + return createSshWorktreeMetaIndex( + Object.entries(allMeta).filter(([worktreeId]) => getRepoIdFromWorktreeId(worktreeId) === repoId) + ) +} + function synthesizeSshGitWorktree(repo: Repo, path: string, meta: WorktreeMeta): GitWorktreeInfo { return { path, @@ -842,10 +856,15 @@ function listDisconnectedSshWorktrees( if (Object.keys(ownershipUpdates).length > 0) { store.setWorktreeMeta(candidate.id, ownershipUpdates) } + // Why: synthesized rows carry no branch, so the title would fall through to the DESKTOP's basename() + // applied to a REMOTE path — a Windows remote then renders its whole C:\... path as the name. Rows must + // stay per-directory (repo.displayName would title every row identically), so use the separator-agnostic + // basename instead. const worktree = mergeWorktree( repo.id, synthesizeSshGitWorktree(repo, candidate.path, meta), - meta + meta, + getWorktreePathBasenameFromId(candidate.id) ?? undefined ) byWorktreeId.delete(worktree.id) byWorktreeId.set(worktree.id, worktree) @@ -1826,6 +1845,7 @@ export function registerWorktreeHandlers( ipcMain.removeHandler('worktrees:listAll') ipcMain.removeHandler('worktrees:list') ipcMain.removeHandler('worktrees:listDetected') + ipcMain.removeHandler('worktrees:listKnownForExecutionHost') ipcMain.removeHandler('worktrees:cancelListDetected') ipcMain.removeHandler('worktrees:create') ipcMain.removeHandler('worktrees:prefetchCreateBase') @@ -1970,6 +1990,66 @@ export function registerWorktreeHandlers( } }) + ipcMain.handle( + 'worktrees:listKnownForExecutionHost', + (_event, args: ListKnownWorktreesForExecutionHostArgs): HostQualifiedKnownWorktreeResult => { + // Why: a malformed invoke must fail closed as `rejected`, not throw out of the handler. `ssh:` is inert — + // it owns no repo, so every guard below still rejects it. + const requestedRepoId = args?.repoId ?? '' + const requestedExecutionHostId = args?.executionHostId ?? 'ssh:' + const rejected = (): HostQualifiedKnownWorktreeResult => ({ + status: 'rejected', + repoId: requestedRepoId, + executionHostId: requestedExecutionHostId + }) + const parsedHost = parseExecutionHostId(requestedExecutionHostId) + if (parsedHost?.kind !== 'ssh') { + return rejected() + } + // Why: findExactRepoOwner repeats this same all-candidates-owned check, and getRepos() re-hydrates the + // whole catalog, so a separate pass here is pure cost. + const repo = findExactRepoOwner(store, requestedRepoId, requestedExecutionHostId) + if (!repo || repo.connectionId !== parsedHost.targetId) { + return rejected() + } + const complete = (worktrees: DetectedWorktree[]): HostQualifiedKnownWorktreeResult => ({ + status: 'complete', + repoId: repo.id, + executionHostId: requestedExecutionHostId, + result: { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees + } + }) + // Why: folder workspace ids carry an instance suffix the git-worktree synthesizer would read as a directory; build them the way every other listing does. + if (isFolderRepo(repo)) { + const folderWorkspaceIds = Object.keys(store.getAllWorktreeMeta()).filter((worktreeId) => + isFolderWorkspaceIdForRepo(repo, worktreeId) + ) + return hasConflictingStoredWorktreeOwner(store, repo, folderWorkspaceIds) + ? rejected() + : complete( + // Why: match the authoritative folder listing; without lineage these rows render flat and then + // reshuffle once the real scan lands. + projectResolvedWorktreeLineage( + buildFolderDetectedWorktrees(store, repo), + store.getAllWorktreeLineage?.() ?? {} + ) + ) + } + const metaIndex = createSshWorktreeMetaIndexForRepo(store.getAllWorktreeMeta(), repo.id) + return complete( + buildDisconnectedDetectedWorktrees( + store, + repo, + listDisconnectedSshWorktrees(store, repo, metaIndex) + ) + ) + } + ) + ipcMain.handle( 'worktrees:listDetected', async ( diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 81faa5f6297..24cc6e512cc 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -676,7 +676,6 @@ describe('Store', () => { const settings = store.getSettings() expect(settings.branchPrefix).toBe('git-username') expect(settings.refreshLocalBaseRefOnWorktreeCreate).toBe(false) - expect(settings.sourceControlGroupOrder).toBe('changes-first') expect(settings.theme).toBe('system') expect(settings.appIcon).toBe('classic') expect(settings.appFontFamily).toBe('Geist') @@ -2784,21 +2783,6 @@ describe('Store', () => { expect(store.getSettings().terminalShortcutPolicy).toBe('orca-first') }) - it('normalizes malformed source control group order on load', async () => { - writeDataFile({ - schemaVersion: 1, - repos: [], - worktreeMeta: {}, - settings: { sourceControlGroupOrder: 'tracked-first' }, - ui: {}, - githubCache: { pr: {}, issue: {} }, - workspaceSession: {} - }) - - const store = await createStore() - expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first') - }) - it('repairs drifted task provider defaults on load', async () => { writeDataFile({ schemaVersion: 1, @@ -5817,15 +5801,21 @@ describe('Store', () => { expect(store.getSettings().sourceControlViewMode).toBe('tree') }) - it('updateSettings persists sourceControlGroupOrder as a user setting', async () => { + it('drops retired Source Control order preferences', async () => { + writeDataFile({ + ...getDefaultPersistedState(testState.dir), + settings: { + ...getDefaultPersistedState(testState.dir).settings, + sourceControlGroupOrder: 'changes-first', + sourceControlHierarchyDefaultedV2: true + } + } as never) + const store = await createStore() - expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first') - - store.updateSettings({ sourceControlGroupOrder: 'staged-first' }) - expect(store.getSettings().sourceControlGroupOrder).toBe('staged-first') - - store.updateSettings({ sourceControlGroupOrder: 'tracked-first' as never }) - expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first') + store.flush() + const persisted = readDataFile() as { settings?: Record } + expect(persisted.settings).not.toHaveProperty('sourceControlGroupOrder') + expect(persisted.settings).not.toHaveProperty('sourceControlHierarchyDefaultedV2') }) it('updateSettings normalizes terminal shortcut policy', async () => { @@ -5883,18 +5873,15 @@ describe('Store', () => { const store = await createStore() expect(store.getSettings().sourceControlViewMode).toBe('list') - expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first') - - store.updateSettings({ sourceControlViewMode: 'tree', sourceControlGroupOrder: 'staged-first' }) + store.updateSettings({ sourceControlViewMode: 'tree' }) store.flush() const persisted = readDataFile() as { - settings?: { sourceControlGroupOrder?: string; sourceControlViewMode?: string } + settings?: { sourceControlViewMode?: string } workspaceSession?: typeof workspaceSession worktreeMeta?: Record } expect(persisted.settings?.sourceControlViewMode).toBe('tree') - expect(persisted.settings?.sourceControlGroupOrder).toBe('staged-first') expect(persisted.workspaceSession).toEqual({ ...getDefaultWorkspaceSession(), ...workspaceSession @@ -5906,13 +5893,9 @@ describe('Store', () => { expect(collectPropertyPaths(persisted, 'sourceControlViewMode')).toEqual([ 'settings.sourceControlViewMode' ]) - expect(collectPropertyPaths(persisted, 'sourceControlGroupOrder')).toEqual([ - 'settings.sourceControlGroupOrder' - ]) const reloaded = await createStore() expect(reloaded.getSettings().sourceControlViewMode).toBe('tree') - expect(reloaded.getSettings().sourceControlGroupOrder).toBe('staged-first') expect(reloaded.getWorkspaceSession().activeWorktreeId).toBe('repo1::/worktree-a') }) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 2afccf7e0ae..af82bcbdd89 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -182,7 +182,6 @@ import { } from '../shared/mobile-pairing-custom-address' import { normalizeOpenInApplications } from '../shared/open-in-applications' import { normalizeTerminalShortcutPolicy } from '../shared/keybindings' -import { normalizeSourceControlGroupOrder } from '../shared/source-control-group-order' import { normalizeAppIconId } from '../shared/app-icon' import { normalizeTerminalCustomThemes } from '../shared/terminal-custom-themes' import { @@ -650,12 +649,22 @@ function readLegacyTerminalScrollbackSettings(settings: unknown): LegacyTerminal : {} } -function stripLegacyTerminalScrollbackBytes( +function stripRetiredSettingsFields( settings: Partial | undefined ): Partial { - const { terminalScrollbackBytes: _legacyScrollbackBytes, ...rest } = (settings ?? - {}) as Partial & { terminalScrollbackBytes?: unknown } + const { + terminalScrollbackBytes: _legacyScrollbackBytes, + sourceControlGroupOrder: _sourceControlGroupOrder, + sourceControlHierarchyDefaultedV2: _sourceControlHierarchyDefaultedV2, + ...rest + } = (settings ?? {}) as Partial & { + terminalScrollbackBytes?: unknown + sourceControlGroupOrder?: unknown + sourceControlHierarchyDefaultedV2?: unknown + } void _legacyScrollbackBytes + void _sourceControlGroupOrder + void _sourceControlHierarchyDefaultedV2 return rest } @@ -3316,15 +3325,6 @@ export class Store { ) { this.loadNeedsSave = true } - const normalizedSourceControlGroupOrder = normalizeSourceControlGroupOrder( - parsed.settings?.sourceControlGroupOrder - ) - if ( - parsed.settings?.sourceControlGroupOrder !== undefined && - parsed.settings.sourceControlGroupOrder !== normalizedSourceControlGroupOrder - ) { - this.loadNeedsSave = true - } result = { ...defaults, ...parsed, @@ -3346,7 +3346,7 @@ export class Store { settings: { ...defaults.settings, // Why (#7977): keep persisted experimentalNewWorktreeCardStyle:true — v1.4.130's onboarding auto-wrote it as a plain boolean, so it's indistinguishable from a real opt-in; only the default changed. - ...stripLegacyTerminalScrollbackBytes(parsed.settings), + ...stripRetiredSettingsFields(parsed.settings), prBotAuthorOverrides: normalizePRBotAuthorOverrides( parsed.settings?.prBotAuthorOverrides ), @@ -3420,7 +3420,6 @@ export class Store { }), notifications: normalizeNotificationSettings(parsed.settings?.notifications), sourceControlAi: migratedSourceControlAi, - sourceControlGroupOrder: normalizedSourceControlGroupOrder, // Why: rollback builds still read commitMessageAi, so refresh the legacy projection from sourceControlAi for compat. commitMessageAi: projectSourceControlAiToLegacyCommitMessageAi( migratedSourceControlAi, @@ -5691,7 +5690,7 @@ export class Store { updates: Partial, options: { notifyListeners?: boolean; originWebContentsId?: number } = {} ): GlobalSettings { - const sanitizedUpdates = stripLegacyTerminalScrollbackBytes(updates) + const sanitizedUpdates = stripRetiredSettingsFields(updates) // Why: coerce to boolean here (not the IPC edge) so every write path is covered and a truthy non-bool can't persist as "tray-minimize on". if ('minimizeToTrayOnClose' in updates) { sanitizedUpdates.minimizeToTrayOnClose = updates.minimizeToTrayOnClose === true @@ -5759,11 +5758,6 @@ export class Store { updates.terminalShortcutPolicy ) } - if ('sourceControlGroupOrder' in updates) { - sanitizedUpdates.sourceControlGroupOrder = normalizeSourceControlGroupOrder( - updates.sourceControlGroupOrder - ) - } if ('appIcon' in updates) { sanitizedUpdates.appIcon = normalizeAppIconId(updates.appIcon) } diff --git a/src/main/providers/git-provider-status-options.ts b/src/main/providers/git-provider-status-options.ts index 3d03ca6a269..a8924924f22 100644 --- a/src/main/providers/git-provider-status-options.ts +++ b/src/main/providers/git-provider-status-options.ts @@ -3,5 +3,7 @@ export type GitProviderStatusOptions = { includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean reuseLineStats?: boolean + /** Merge-base OID to measure the branch line total against; omit to skip the work. */ + branchLineTotalMergeBase?: string signal?: AbortSignal } diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 2ec2fbbfc6f..e4c306529ee 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -129,11 +129,16 @@ export class SshGitProvider implements IGitProvider { ? { bypassEffectiveUpstreamNegativeCache: true } : {} const lineStatsReuseArgs = options?.reuseLineStats ? { reuseLineStats: true } : {} + const branchLineTotalArgs = + options?.branchLineTotalMergeBase === undefined + ? {} + : { branchLineTotalMergeBase: options.branchLineTotalMergeBase } const request = { worktreePath, ...includeIgnoredArgs, ...upstreamCacheBypassArgs, - ...lineStatsReuseArgs + ...lineStatsReuseArgs, + ...branchLineTotalArgs } return (await (options?.signal ? this.mux.request('git.status', request, { signal: options.signal }) diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 5cefa574248..68000f1b297 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -38,6 +38,7 @@ import { resolveRuntimePath } from '../../shared/cross-platform-path' import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' +import { sortDirEntries } from '../../shared/file-name-sort' import type { RuntimeFileListResult, RuntimeFileOpenResult, @@ -1292,7 +1293,9 @@ export class RuntimeFileCommands { if (!provider) { throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) } - return provider.readDir(target.path) + // Why: re-sort locally — the remote relay may be an older build with + // lexicographic ordering. + return sortDirEntries(await provider.readDir(target.path)) } const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) @@ -1307,12 +1310,7 @@ export class RuntimeFileCommands { } }) ) - return mapped.sort((a, b) => { - if (a.isDirectory !== b.isDirectory) { - return a.isDirectory ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) + return sortDirEntries(mapped) } async watchFileExplorer( diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 0454246661c..432711bc6cc 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -13948,6 +13948,212 @@ describe('OrcaRuntimeService', () => { ) }) + // Why (#10333): `orca serve` publishes a ready graph under + // HEADLESS_RUNTIME_WINDOW_ID with no BrowserWindow behind it, so every + // focus-requested create used to fall through to getAuthoritativeWindow(). + const wireHeadlessServeRuntime = (): OrcaRuntimeService => { + const runtime = new OrcaRuntimeService(store) + electronMocks.BrowserWindow.fromId.mockReturnValue(null as never) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + return runtime + } + + it('spawns focus-requested CLI terminal creates in background on headless serve', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-focus-headless' }) + const runtime = wireHeadlessServeRuntime() + const revealTerminalSession = vi.fn() + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + resumeSleepingAgents: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + } as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + // `orca terminal create --worktree --command "echo test" --focus` + await expect( + runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'echo test', + focus: true + }) + ).resolves.toMatchObject({ + worktreeId: TEST_WORKTREE_ID, + handle: expect.stringMatching(/^term_/) + }) + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'echo test', + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID, + persistHostSessionBinding: true + }) + ) + // Why: degrading the create must not silently drop the focus request — the + // host still asks whatever surface exists to activate the new pane. + expect(revealTerminalSession).toHaveBeenCalledWith( + TEST_WORKTREE_ID, + expect.objectContaining({ activate: true, presentation: 'focused' }) + ) + }) + + it('spawns presentation:focused terminal creates in background on headless serve', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-presentation-headless' }) + const runtime = wireHeadlessServeRuntime() + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + // Paired desktop `+` button: clients send presentation:'focused', not focus. + await expect( + runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'codex', + presentation: 'focused' + }) + ).resolves.toMatchObject({ + worktreeId: TEST_WORKTREE_ID, + surface: 'background', + handle: expect.stringMatching(/^term_/) + }) + expect(spawn).toHaveBeenCalled() + }) + + it('spawns focused agent-session creates in background on headless serve', async () => { + // Why: RPC only downgrades `focused` for clients that report a clientKind + // (#10193), so loopback/CLI callers still reach the runtime asking for focus. + const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent-headless' }) + const runtime = wireHeadlessServeRuntime() + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await expect( + runtime.createAgentSession({ + clientOperationId: `${Date.now()}-0123456789abcdef0123456789abcdef`, + worktree: `path:${TEST_WORKTREE_PATH}`, + agent: 'codex', + prompt: 'hello', + presentation: 'focused' + }) + ).resolves.toMatchObject({ + disposition: 'created', + terminal: expect.objectContaining({ + worktreeId: TEST_WORKTREE_ID, + handle: expect.stringMatching(/^term_/) + }) + }) + expect(spawn).toHaveBeenCalled() + }) + + it('activates the paired mobile session tab for a degraded focused headless create', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-focus-publish' }) + const runtime = wireHeadlessServeRuntime() + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + const created = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'echo test', + focus: true + }) + + // Why: with no renderer notifier on a serve host, the session-tab publish is + // the only channel a paired client learns about the terminal on — a degraded + // focused create must still land there selected, or focus is silently lost. + const tabs = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + const published = tabs.tabs.find( + (tab) => tab.type === 'terminal' && tab.parentTabId === created.tabId + ) + expect(published).toBeDefined() + expect(published?.isActive).toBe(true) + expect(tabs.activeTabId).toBe(published?.id) + }) + + it('keeps focus-requested terminal creates on the renderer when a window exists', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-should-not-spawn' }) + const webContents = { send: vi.fn() } + const send = vi.fn((_channel: string, payload: { requestId: string }) => { + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-focused', + worktreeId: TEST_WORKTREE_ID, + title: 'Focused Terminal', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-focused', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-focused', + paneTitle: null + } + ] + }) + ipcMain.emit( + 'terminal:tabCreateReply', + { sender: webContents }, + { requestId: payload.requestId, tabId: 'tab-focused', title: 'Focused Terminal' } + ) + }) + webContents.send = send + const runtime = new OrcaRuntimeService(store) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents + }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await expect( + runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + command: 'echo test', + focus: true + }) + ).resolves.toMatchObject({ + tabId: 'tab-focused', + worktreeId: TEST_WORKTREE_ID, + surface: 'visible' + }) + expect(send).toHaveBeenCalledWith( + 'terminal:requestTabCreate', + expect.objectContaining({ activate: true, presentation: 'focused' }) + ) + expect(spawn).not.toHaveBeenCalled() + }) + it('accepts renderer-backed terminal create replies only from the target renderer', async () => { const webContents = { send: vi.fn() } const send = vi.fn((_channel: string, payload: { requestId: string }) => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 5e23498f370..1a2aed7a74b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -10,6 +10,7 @@ import { normalizeTerminalTitle } from '../../shared/agent-detection' import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail' +import { sortDirEntries } from '../../shared/file-name-sort' import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing' import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extraction' import { parseFileUriPathParts } from '../daemon/osc7-file-uri' @@ -10762,10 +10763,7 @@ export class OrcaRuntimeService { // A spawn published (or admission pending) this generation already // attaches the provider stream; a replacement under a reused id must not // read as the discovered never-attached session it replaced. - if ( - this.spawnPublishedPtys.has(ptyId) || - this.pendingPtyRegistrationIncarnations.has(ptyId) - ) { + if (this.spawnPublishedPtys.has(ptyId) || this.pendingPtyRegistrationIncarnations.has(ptyId)) { return false } // SSH panes have their own lease/reattach machinery. @@ -18253,12 +18251,7 @@ export class OrcaRuntimeService { isDirectory: entry.isDirectory(), isSymlink: entry.isSymbolicLink() })) - mapped.sort((a, b) => { - if (a.isDirectory !== b.isDirectory) { - return a.isDirectory ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) + sortDirEntries(mapped) return { resolvedPath: dirPath, entries: mapped, @@ -25061,9 +25054,12 @@ export class OrcaRuntimeService { (Boolean(opts.agentSessionClaim) || (!requiresRendererFocus && opts.rendererBacked !== true) || // Why: `orca serve` exposes the local runtime without a renderer - // window. Renderer-backed Codex terminals are preferred for the app, - // but headless CLI users still need a usable terminal handle. - (opts.rendererBacked === true && rendererWindow === null)) + // window. Renderer-backed and focus-requested creates are preferred on + // the renderer, but with no window a background spawn is the only + // usable path — otherwise getAuthoritativeWindow() below throws and the + // caller gets no terminal at all (#10333). Focus is not lost: the + // spawned pane is still published and revealed with `activate`. + availableAuthoritativeWindow === null) if (shouldCreateInBackground) { if (!this.ptyController?.spawn) { diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts index 7e13e584c42..0fe273108a5 100644 --- a/src/main/runtime/rpc/methods/git-params.ts +++ b/src/main/runtime/rpc/methods/git-params.ts @@ -10,7 +10,9 @@ export const WorktreeSelector = z.object({ export const GitStatusParams = WorktreeSelector.extend({ includeIgnored: z.boolean().optional(), bypassEffectiveUpstreamNegativeCache: z.boolean().optional(), - reuseLineStats: z.boolean().optional() + reuseLineStats: z.boolean().optional(), + // Shape is re-validated host-side before it reaches a git argv. + branchLineTotalMergeBase: z.string().optional() }) export const GitCheckIgnored = WorktreeSelector.extend({ diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index c4ccdf071b6..c30e80f4338 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -93,6 +93,7 @@ export const GIT_METHODS: RpcMethod[] = [ params.includeIgnored === undefined && params.bypassEffectiveUpstreamNegativeCache === undefined && params.reuseLineStats === undefined && + params.branchLineTotalMergeBase === undefined && signal === undefined ? undefined : { @@ -103,6 +104,9 @@ export const GIT_METHODS: RpcMethod[] = [ ? { bypassEffectiveUpstreamNegativeCache: true } : {}), ...(params.reuseLineStats === true ? { reuseLineStats: true } : {}), + ...(params.branchLineTotalMergeBase === undefined + ? {} + : { branchLineTotalMergeBase: params.branchLineTotalMergeBase }), ...(signal ? { signal } : {}) } return options === undefined diff --git a/src/main/runtime/rpc/remote-runtime-server-heartbeat-missed-probe-tolerance.test.ts b/src/main/runtime/rpc/remote-runtime-server-heartbeat-missed-probe-tolerance.test.ts new file mode 100644 index 00000000000..665d94b0f41 --- /dev/null +++ b/src/main/runtime/rpc/remote-runtime-server-heartbeat-missed-probe-tolerance.test.ts @@ -0,0 +1,167 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { WebSocket } from 'ws' +import { RemoteRuntimeServerHeartbeat } from './remote-runtime-server-heartbeat' + +afterEach(() => { + vi.useRealTimers() +}) + +const INTERVAL_MS = 100 + +function makeSocket(): WebSocket { + return { ping: vi.fn(), terminate: vi.fn() } as unknown as WebSocket +} + +describe('RemoteRuntimeServerHeartbeat missed-probe tolerance', () => { + it('keeps a client whose pong is merely delayed past one probe window', async () => { + vi.useFakeTimers() + let now = 1_000 + const socket = makeSocket() + const heartbeat = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now) + heartbeat.noteAlive(socket) + heartbeat.start(() => [socket]) // probe #1 + heartbeat.noteAlive(socket) // pongs probe #1 + + // A transient blackhole: probe #2 goes out and its pong is stuck in the network. + now += INTERVAL_MS + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + expect(socket.ping).toHaveBeenCalledTimes(2) + expect(socket.terminate).not.toHaveBeenCalled() + + // The path recovers and the delayed pong lands right after the next sweep would have run. + now += INTERVAL_MS + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + heartbeat.noteAlive(socket) + + // One unanswered probe is not evidence the peer is gone: it must still be connected, + // and it must have been re-probed rather than reaped. + expect(socket.terminate).not.toHaveBeenCalled() + expect(socket.ping).toHaveBeenCalledTimes(3) + heartbeat.stop() + }) + + it('reaps only after consecutive probes go unanswered, counted not timed', async () => { + vi.useFakeTimers() + let now = 1_000 + const socket = makeSocket() + const heartbeat = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now) + heartbeat.noteAlive(socket) + heartbeat.start(() => [socket]) + heartbeat.noteAlive(socket) + + const missesBeforeReap: number[] = [] + for (let probe = 0; probe < 6; probe += 1) { + now += INTERVAL_MS + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + missesBeforeReap.push( + (socket.terminate as unknown as ReturnType).mock.calls.length + ) + } + + // Silence must be tolerated for more than a single probe, and must eventually end the socket. + expect(missesBeforeReap[0]).toBe(0) + expect(missesBeforeReap[1]).toBe(0) + expect(missesBeforeReap.at(-1)).toBeGreaterThan(0) + heartbeat.stop() + }) + + it('lets any proof of life reset accumulated misses', async () => { + vi.useFakeTimers() + let now = 1_000 + const socket = makeSocket() + const heartbeat = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now) + heartbeat.noteAlive(socket) + heartbeat.start(() => [socket]) + heartbeat.noteAlive(socket) + + // Two silent probes, then a single inbound frame, repeated well past any fixed budget. + for (let cycle = 0; cycle < 5; cycle += 1) { + now += INTERVAL_MS + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + now += INTERVAL_MS + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + heartbeat.noteAlive(socket) + } + + expect(socket.terminate).not.toHaveBeenCalled() + heartbeat.stop() + }) + + it('charges no miss for a paused tick but keeps the ones already banked', async () => { + vi.useFakeTimers() + let now = 1_000 + const socket = makeSocket() + const heartbeat = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now) + heartbeat.noteAlive(socket) + heartbeat.start(() => [socket]) + heartbeat.noteAlive(socket) + + const sweep = async (): Promise => { + now += INTERVAL_MS + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + } + const reaped = (candidate: WebSocket): boolean => + (candidate.terminate as unknown as ReturnType).mock.calls.length > 0 + + // Discover the budget rather than hardcoding it: the first sweep consumes the pong above, so + // every sweep after it is an unanswered probe. + await sweep() + let toleratedMisses = 0 + // Bounded so a heartbeat that never reaps fails here instead of hanging out to the test timeout. + while (!reaped(socket) && toleratedMisses < 20) { + await sweep() + toleratedMisses += 1 + } + expect(reaped(socket)).toBe(true) + expect(toleratedMisses).toBeGreaterThan(1) + heartbeat.stop() + + // Replay on a fresh socket: bank misses to one short of the limit, then suspend. The stalled tick + // must charge nothing — the client had no chance to answer it — but it must not forgive the misses + // already banked, or a host that stalls periodically could shelter a dead socket indefinitely. + const resumed = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now) + const resumedSocket = makeSocket() + resumed.noteAlive(resumedSocket) + resumed.start(() => [resumedSocket]) + resumed.noteAlive(resumedSocket) + await sweep() + for (let miss = 0; miss < toleratedMisses - 1; miss += 1) { + await sweep() + } + expect(reaped(resumedSocket)).toBe(false) + + now += 3_600_000 + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + // The suspend itself costs the socket nothing. + expect(reaped(resumedSocket)).toBe(false) + + // But the pre-suspend evidence survives, so the very next unanswered probe completes the budget. + await sweep() + expect(reaped(resumedSocket)).toBe(true) + resumed.stop() + }) + + // Why: pre-fix the pardon cleared banked misses, so a host stalling once every few ticks reset the + // budget forever and a dead socket was never reaped — the connection leak the reaper exists to stop. + it('still reaps a dead socket when the host stalls repeatedly', async () => { + vi.useFakeTimers() + let now = 1_000 + const socket = makeSocket() + const heartbeat = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now) + heartbeat.noteAlive(socket) + heartbeat.start(() => [socket]) + heartbeat.noteAlive(socket) + + const reaped = (): boolean => + (socket.terminate as unknown as ReturnType).mock.calls.length > 0 + + // Stall every third tick — the worst case the old pardon could not survive. + for (let tick = 0; tick < 30 && !reaped(); tick += 1) { + now += tick % 3 === 2 ? INTERVAL_MS * 2 : INTERVAL_MS + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + } + + expect(reaped()).toBe(true) + heartbeat.stop() + }) +}) diff --git a/src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts b/src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts index a34b43c3c8b..e1a58d23587 100644 --- a/src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts +++ b/src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts @@ -7,12 +7,13 @@ afterEach(() => { }) describe('RemoteRuntimeServerHeartbeat', () => { - it('still reaps a client that misses a probe while another remains alive', async () => { + it('still reaps a persistently silent client while another remains alive', async () => { vi.useFakeTimers() let now = 1_000 const responsiveSocket = { ping: vi.fn(), terminate: vi.fn() } as unknown as WebSocket const deadSocket = { ping: vi.fn(), terminate: vi.fn() } as unknown as WebSocket - const heartbeat = new RemoteRuntimeServerHeartbeat(100, () => now) + // Limit of 2 keeps the sweep count readable; production uses the module default. + const heartbeat = new RemoteRuntimeServerHeartbeat(100, () => now, 128, 2) heartbeat.noteAlive(responsiveSocket) heartbeat.noteAlive(deadSocket) // start() probes immediately: both are pinged now (probe #1) and cleared to await a pong. @@ -20,12 +21,20 @@ describe('RemoteRuntimeServerHeartbeat', () => { // Only the responsive socket pongs the immediate probe. heartbeat.noteAlive(responsiveSocket) + // Miss #1: not yet evidence, so the silent socket is re-probed rather than reaped. + now += 100 + await vi.advanceTimersByTimeAsync(100) + heartbeat.noteAlive(responsiveSocket) + expect(deadSocket.ping).toHaveBeenCalledTimes(2) + expect(deadSocket.terminate).not.toHaveBeenCalled() + + // Miss #2 reaches the limit: consecutive silence is evidence. now += 100 await vi.advanceTimersByTimeAsync(100) - expect(responsiveSocket.ping).toHaveBeenCalledTimes(2) + expect(responsiveSocket.ping).toHaveBeenCalledTimes(3) expect(responsiveSocket.terminate).not.toHaveBeenCalled() - expect(deadSocket.ping).toHaveBeenCalledTimes(1) + expect(deadSocket.ping).toHaveBeenCalledTimes(2) expect(deadSocket.terminate).toHaveBeenCalledTimes(1) heartbeat.stop() }) @@ -34,7 +43,8 @@ describe('RemoteRuntimeServerHeartbeat', () => { vi.useFakeTimers() let now = 1_000 const socket = { ping: vi.fn(), terminate: vi.fn() } as unknown as WebSocket - const heartbeat = new RemoteRuntimeServerHeartbeat(100, () => now) + // Limit of 1 isolates the resume grant: without it the very next sweep would reap. + const heartbeat = new RemoteRuntimeServerHeartbeat(100, () => now, 128, 1) heartbeat.noteAlive(socket) // start() probes immediately (ping #1); the socket pongs it. heartbeat.start(() => [socket]) diff --git a/src/main/runtime/rpc/remote-runtime-server-heartbeat.ts b/src/main/runtime/rpc/remote-runtime-server-heartbeat.ts index 3b5bb9760df..6134259c51f 100644 --- a/src/main/runtime/rpc/remote-runtime-server-heartbeat.ts +++ b/src/main/runtime/rpc/remote-runtime-server-heartbeat.ts @@ -1,16 +1,27 @@ import type { WebSocket } from 'ws' +// Why: one unanswered probe is UNKNOWN, not death — a cellular/Tailscale blackhole or a stalled TCP +// retransmit routinely swallows a single pong from a peer that is still there (STA-3320). Only a run of +// consecutive unanswered probes is evidence. Three matches the web client's own 45s liveness budget +// (25s idle + 20s probe grace), so both ends of a paired session give up on roughly the same evidence. +const MISSED_PROBE_LIMIT = 3 + export class RemoteRuntimeServerHeartbeat { private timer: ReturnType | null = null private lastTickAt: number | null = null private readonly alive = new WeakSet() + // Why: consecutive probes a socket has left unanswered. Absent = zero, so live sockets cost nothing. + private readonly missedProbes = new WeakMap() constructor( private readonly intervalMs: number, private readonly now: () => number = Date.now, - private readonly warningClientCount = 128 + private readonly warningClientCount = 128, + private readonly missedProbeLimit = MISSED_PROBE_LIMIT ) {} + // Why: hot path — every inbound frame calls this. The sweep clears the miss count when it observes + // the flag, so proof of life stays a single WeakSet write. noteAlive(socket: WebSocket): void { this.alive.add(socket) } @@ -41,22 +52,32 @@ export class RemoteRuntimeServerHeartbeat { const tickAt = this.now() const elapsedMs = tickAt - (this.lastTickAt ?? tickAt) this.lastTickAt = tickAt - const resumedFromPause = elapsedMs < 0 || elapsedMs > this.intervalMs * 1.5 + // Why: a delayed tick cannot infer that clients missed a probe they had no chance to answer, so + // this sweep must not charge one. It must not *forgive* either: clearing banked misses lets a host + // that stalls once every few ticks protect a dead socket forever, which is the connection leak the + // reaper exists to prevent. Skipping the charge is enough — a live client clears its own count by + // answering the probe still sent below. + const stalledTick = elapsedMs < 0 || elapsedMs > this.intervalMs * 1.5 let reaped = 0 let clientCount = 0 for (const socket of clients) { clientCount += 1 - if (resumedFromPause) { - // Why: a delayed server tick cannot infer that clients missed a probe they had no chance to answer. - this.alive.add(socket) + if (this.alive.has(socket)) { + this.alive.delete(socket) + this.missedProbes.delete(socket) + } else if (!stalledTick) { + const missed = (this.missedProbes.get(socket) ?? 0) + 1 + if (missed >= this.missedProbeLimit) { + this.missedProbes.delete(socket) + socket.terminate() + reaped += 1 + continue + } + this.missedProbes.set(socket, missed) } - if (!this.alive.has(socket)) { - socket.terminate() - reaped += 1 - continue - } - this.alive.delete(socket) try { + // Why: re-probe every sweep, including missed ones, so a path that just recovered can prove + // itself on the next tick instead of waiting out the rest of the budget. socket.ping() } catch { // Why: a mid-teardown socket is finalized by its close/error listener. diff --git a/src/main/runtime/rpc/ws-transport-accept-order.test.ts b/src/main/runtime/rpc/ws-transport-accept-order.test.ts index 4f56a2da53e..20ae28f0f0e 100644 --- a/src/main/runtime/rpc/ws-transport-accept-order.test.ts +++ b/src/main/runtime/rpc/ws-transport-accept-order.test.ts @@ -83,7 +83,7 @@ describe('WebSocketTransport accepted socket ordering', () => { ).toEqual([0, 0, 0, 0]) }) - it('reaps an unresponsive accepted socket by the first interval deadline', async () => { + it('reaps an unresponsive accepted socket once consecutive probes go unanswered', async () => { vi.useFakeTimers() const events: string[] = [] let socket: WebSocket @@ -99,19 +99,22 @@ describe('WebSocketTransport accepted socket ordering', () => { socket.once('open', () => events.push('open')) socket.emit('open') lifecycle.handleConnection(socket) - await vi.advanceTimersByTimeAsync(99) + // A single silent interval is not evidence: the socket is re-probed, not reaped. + await vi.advanceTimersByTimeAsync(100) + expect(socket.terminate).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(199) expect(socket.terminate).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(1) expect(socket.terminate).toHaveBeenCalledTimes(1) - expect(events).toEqual(['open', 'ping', 'close']) + expect(events).toEqual(['open', 'ping', 'ping', 'ping', 'close']) expect(lifecycle.heartbeatConnections.size).toBe(0) expect(lifecycle.heartbeat.timer).toBeNull() expect(vi.getTimerCount()).toBe(0) }) - it('keeps later sockets on the shared cadence and reaps them within two intervals', async () => { + it('keeps later sockets on the shared cadence and reaps them on the shared budget', async () => { vi.useFakeTimers() vi.setSystemTime(0) const firstPingTimes: number[] = [] @@ -150,13 +153,15 @@ describe('WebSocketTransport accepted socket ordering', () => { expect(laterPingTimes).toEqual([100]) expect(laterSocket.terminate).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(99) + // Re-probed on every sweep while its misses bank, so a recovered path can answer immediately. + await vi.advanceTimersByTimeAsync(299) + expect(laterPingTimes).toEqual([100, 200, 300]) expect(laterSocket.terminate).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(1) expect(laterSocket.terminate).toHaveBeenCalledTimes(1) - expect(laterReapTimes).toEqual([200]) - expect(firstPingTimes).toEqual([0, 100, 200]) + expect(laterReapTimes).toEqual([400]) + expect(firstPingTimes).toEqual([0, 100, 200, 300, 400]) expect( ['pong', 'message', 'close', 'error'].map((event) => laterSocket.listenerCount(event)) ).toEqual([0, 0, 0, 0]) diff --git a/src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts b/src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts new file mode 100644 index 00000000000..a8a5eb9c4de --- /dev/null +++ b/src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts @@ -0,0 +1,83 @@ +// Real ws server + real ws client over loopback: a live, well-behaved peer whose pong is swallowed by +// a transient blackhole must not be terminated (STA-3320). +import { afterEach, describe, expect, it, vi } from 'vitest' +import WebSocket from 'ws' +import { WebSocketTransport } from './ws-transport' + +const PROBE_INTERVAL_MS = 150 + +// Why: the heartbeat calls now() exactly once per sweep, so a per-call step makes elapsed exactly one +// interval every sweep — real scheduler jitter can never be mistaken for a suspended loop, and no +// assertion in this file depends on wall time. +function createStepClock(stepMs: number): () => number { + let calls = 0 + return () => 1_000_000 + calls++ * stepMs +} + +describe('WebSocketTransport under transient packet loss', () => { + const transports: WebSocketTransport[] = [] + const sockets: WebSocket[] = [] + + afterEach(async () => { + for (const socket of sockets) { + socket.removeAllListeners() + socket.terminate() + } + sockets.length = 0 + await Promise.all(transports.map((t) => t.stop().catch(() => {}))) + transports.length = 0 + }) + + async function startTransport(): Promise { + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: 0, + heartbeatIntervalMs: PROBE_INTERVAL_MS, + heartbeatNow: createStepClock(PROBE_INTERVAL_MS), + // Why: this test is about liveness, not the auth handshake; keep the pre-auth reaper out of it. + preAuthTimeoutMs: 600_000 + }) + transports.push(transport) + await transport.start() + return transport + } + + it('keeps a live client whose pong is swallowed for a single probe', async () => { + const transport = await startTransport() + // autoPong:false hands us the protocol-level pong so we can drop exactly one, the way a + // cellular/Tailscale blackhole drops exactly the frames in flight during the stall. + const client = new WebSocket(`ws://127.0.0.1:${transport.resolvedPort}`, { autoPong: false }) + sockets.push(client) + + let probesReceived = 0 + const pongedProbes: number[] = [] + const swallowedProbe = 2 + let closed = false + client.on('ping', () => { + probesReceived += 1 + if (probesReceived === swallowedProbe) { + return + } + pongedProbes.push(probesReceived) + client.pong() + }) + client.on('close', () => { + closed = true + }) + await new Promise((resolve, reject) => { + client.once('open', resolve) + client.once('error', reject) + }) + + // Bounded by counted probe events, never by elapsed time. + await vi.waitFor(() => expect(closed || probesReceived >= swallowedProbe + 2).toBe(true), { + timeout: 15_000, + interval: 10 + }) + + expect({ closed, probesReceived }).toEqual({ closed: false, probesReceived: 4 }) + // The peer was demonstrably alive across the blackhole: it answered the probes on both sides of it. + expect(pongedProbes).toEqual([1, 3, 4]) + expect(client.readyState).toBe(WebSocket.OPEN) + }) +}) diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index 1444813fd65..ec58f8697ae 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -21,7 +21,7 @@ type WebSocketMessageHandler = { ): void }['bivarianceHack'] -// Why: mobile clients background-suspend sockets with no TCP FIN, leaving half-opens that otherwise only the OS keepalive (~2h) reaps; a 15s ping/pong sweep bounds that to ~30s (clients auto-pong per RFC 6455). +// Why: mobile clients background-suspend sockets with no TCP FIN, leaving half-opens that otherwise only the OS keepalive (~2h) reaps; a 15s ping/pong sweep bounds that to ~60s (clients auto-pong per RFC 6455), since a reap needs consecutive unanswered probes rather than one (STA-3320). const HEARTBEAT_INTERVAL_MS = 15_000 export type WebSocketTransportOptions = { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 0dc78393f28..53bdb0e7349 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -30,8 +30,10 @@ import type { ReadClipboardTextOptions } from '../shared/clipboard-text' import type { AppIdentity } from '../shared/app-identity' import type { ReleaseChannel } from '../shared/release-channel' import type { + HostQualifiedKnownWorktreeResult, HostQualifiedDetectedWorktreeResult, LegacyDetectedWorktreeRequest, + ListKnownWorktreesForExecutionHostArgs, ListDetectedWorktreesArgs, ProviderRequestId } from '../shared/detected-worktree-provider-contract' @@ -1399,6 +1401,9 @@ export type PreloadApi = { ): Promise (args: LegacyDetectedWorktreeRequest): Promise } + listKnownForExecutionHost?: ( + args: ListKnownWorktreesForExecutionHostArgs + ) => Promise cancelListDetected?: (args: { providerRequestId: ProviderRequestId }) => Promise listAll: () => Promise create: (args: CreateWorktreeArgs) => Promise @@ -2898,6 +2903,8 @@ export type PreloadApi = { includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean reuseLineStats?: boolean + /** Merge-base OID to measure the branch line total against; omit to skip the work. */ + branchLineTotalMergeBase?: string requestToken?: string }) => Promise cancelStatus: (args: { requestToken: string }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 36e26870868..cf602b3f1cc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -766,6 +766,9 @@ const api = { listDetected: (args) => ipcRenderer.invoke('worktrees:listDetected', args), + listKnownForExecutionHost: (args) => + ipcRenderer.invoke('worktrees:listKnownForExecutionHost', args), + cancelListDetected: (args) => ipcRenderer.invoke('worktrees:cancelListDetected', args), listAll: () => ipcRenderer.invoke('worktrees:listAll'), @@ -3230,6 +3233,7 @@ const api = { includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean reuseLineStats?: boolean + branchLineTotalMergeBase?: string requestToken?: string }): Promise => ipcRenderer.invoke('git:status', args), cancelStatus: (args: { requestToken: string }): Promise => diff --git a/src/preload/ssh-authority-forwarding.test.ts b/src/preload/ssh-authority-forwarding.test.ts index 7a81df602d2..04f13430801 100644 --- a/src/preload/ssh-authority-forwarding.test.ts +++ b/src/preload/ssh-authority-forwarding.test.ts @@ -68,6 +68,16 @@ describe('native preload SSH authority forwarding', () => { >() }) + it('forwards host-qualified metadata-only worktree reads', async () => { + await import('./index') + const api = exposeInMainWorld.mock.calls.find(([name]) => name === 'api')?.[1] as PreloadApi + const args = { repoId: 'repo-1', executionHostId: 'ssh:ssh-1' as const } + + await api.worktrees.listKnownForExecutionHost?.(args) + + expect(invoke).toHaveBeenCalledWith('worktrees:listKnownForExecutionHost', args) + }) + it('forwards full-pair get and push states without cloning away authority', async () => { const state: SshConnectionState = { targetId: 'ssh-1', diff --git a/src/relay/fs-handler.test.ts b/src/relay/fs-handler.test.ts index 23dd28c35cb..9dc4812ef98 100644 --- a/src/relay/fs-handler.test.ts +++ b/src/relay/fs-handler.test.ts @@ -164,19 +164,23 @@ describe('FsHandler', () => { await expect(dispatcher.callRequest('fs.tempDir')).resolves.toBe(tmpdir()) }) - it('readDir returns sorted entries with directories first', async () => { + it('readDir returns entries directories-first in natural name order', async () => { mkdirSync(path.join(tmpDir, 'subdir')) - writeFileSync(path.join(tmpDir, 'file.txt'), 'hello') - writeFileSync(path.join(tmpDir, 'aaa.txt'), 'world') + for (const name of ['file.txt', '100 - b.txt', '99 - a.txt', '9 - c.txt']) { + writeFileSync(path.join(tmpDir, name), 'x') + } const result = (await dispatcher.callRequest('fs.readDir', { dirPath: tmpDir })) as { name: string isDirectory: boolean }[] - expect(result[0].name).toBe('subdir') - expect(result[0].isDirectory).toBe(true) - expect(result.find((e) => e.name === 'file.txt')).toBeDefined() - expect(result.find((e) => e.name === 'aaa.txt')).toBeDefined() + expect(result[0]).toMatchObject({ name: 'subdir', isDirectory: true }) + expect(result.slice(1).map((e) => e.name)).toEqual([ + '9 - c.txt', + '99 - a.txt', + '100 - b.txt', + 'file.txt' + ]) }) it('readDir reports symlinked directories as directories', async () => { diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index 9c146af1450..d07e2bf397f 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -5,6 +5,7 @@ import { execFile } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { RelayDispatcher, RequestContext } from './dispatcher' +import { sortDirEntries } from '../shared/file-name-sort' import type { RelayContext } from './context' // Why: RelayContext is accepted in the constructor for protocol back-compat // (see docs/relay-fs-allowlist-removal.md), but no longer consulted on FS ops. @@ -148,12 +149,7 @@ export class FsHandler { isSymlink: entry.isSymbolicLink() })) ) - return mapped.sort((a, b) => { - if (a.isDirectory !== b.isDirectory) { - return a.isDirectory ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) + return sortDirEntries(mapped) } private async readFile(params: Record) { diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index 880e690ee54..60ea5913139 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -24,6 +24,11 @@ import { clearGitStatusLineStatsCacheKey, reuseOrRecomputeGitStatusLineStats } from '../shared/git-status-line-stats-cache' +import { + readGitBranchLineTotalMergeBaseParam, + type GitBranchLineTotal +} from '../shared/git-branch-line-total' +import { buildBranchLineTotalInput } from './git-status-branch-line-total' export async function resolveGitDir(worktreePath: string): Promise { const dotGitPath = path.join(worktreePath, '.git') @@ -74,11 +79,16 @@ export async function getStatusOp( ignoredPaths?: string[] didHitLimit?: boolean statusLength?: number + branchLineTotal?: GitBranchLineTotal }> { const worktreePath = params.worktreePath as string const lineStatsCacheKey = `relay\0${worktreePath}` const lineStatsWriteToken = beginGitStatusLineStatsCacheWrite(lineStatsCacheKey) const includeIgnored = params.includeIgnored === true + // Why: untrusted RPC input spliced into a git argv — only an OID shape may pass. + const branchLineTotalMergeBase = readGitBranchLineTotalMergeBaseParam( + params.branchLineTotalMergeBase + ) // Why: reject NaN/negative limits — NaN would silently disable capping, negatives would over-truncate. const limit = resolveGitStatusLimit(params.limit) const conflictOperation = await detectConflictOperation(worktreePath) @@ -89,6 +99,8 @@ export async function getStatusOp( let ignoredPaths: string[] = [] let didHitLimit = false let statusLength = 0 + let statusSucceeded = false + let branchLineTotal: GitBranchLineTotal | undefined try { // Why: core.quotePath=false keeps non-ASCII filenames as raw UTF-8 instead of octal escapes that render as gibberish. @@ -118,6 +130,7 @@ export async function getStatusOp( ignoredPaths = parser.ignoredPaths statusLength = parser.statusLength didHitLimit = stoppedEarly + statusSucceeded = true const { upstreamName, upstreamAheadBehind } = parser.branch upstreamStatus = upstreamName ? { @@ -173,15 +186,26 @@ export async function getStatusOp( // Why: skip numstat after the limit to avoid reintroducing its cost. if (!didHitLimit) { - await reuseOrRecomputeGitStatusLineStats({ + const branchLineTotalInput = buildBranchLineTotalInput( + git, + worktreePath, + entries, + // Why: a failed scan leaves the untracked list untrustworthy, so the total + // would under-count — omit it rather than publish a confident wrong number. + statusSucceeded ? branchLineTotalMergeBase : undefined, + options.signal + ) + // Why: passed in so the ranged diff runs alongside the per-area numstats, not after them. + ;({ branchLineTotal } = await reuseOrRecomputeGitStatusLineStats({ cacheKey: lineStatsCacheKey, head, entries, writeToken: lineStatsWriteToken, reuse: params.reuseLineStats === true, isAborted: () => options.signal?.aborted === true, - recompute: () => attachLineStats(git, worktreePath, entries, options.signal) - }) + recompute: () => attachLineStats(git, worktreePath, entries, options.signal), + ...(branchLineTotalInput ? { branchLineTotal: branchLineTotalInput } : {}) + })) } else { clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken) } @@ -200,7 +224,8 @@ export async function getStatusOp( branch, upstreamStatus, ...(includeIgnored ? { ignoredPaths } : {}), - ...(didHitLimit ? { didHitLimit: true, statusLength } : {}) + ...(didHitLimit ? { didHitLimit: true, statusLength } : {}), + ...(branchLineTotal ? { branchLineTotal } : {}) } } diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 6674500c6f9..0abc9f5d5be 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -89,6 +89,7 @@ import { GitResponseStreamRegistry } from './git-response-stream' import { GIT_RESPONSE_STREAM_THRESHOLD } from './protocol' import { endSubprocessStdin } from '../shared/subprocess-stdin-write' import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache' +import { invalidateGitBranchLineTotalInFlight } from '../shared/git-branch-line-total' import { streamRelayGitStdout } from './git-stdout-stream' const execFileAsync = promisify(execFile) @@ -298,6 +299,7 @@ export class GitHandler { private clearGitMutationReadCaches(): void { this.gitDiffReadDedupe.clear() + invalidateGitBranchLineTotalInFlight() clearGitStatusLineStatsCache() clearSubmodulePathsCache(this.submodulePathsCache) } diff --git a/src/relay/git-status-branch-line-total.test.ts b/src/relay/git-status-branch-line-total.test.ts new file mode 100644 index 00000000000..ceb04351e07 --- /dev/null +++ b/src/relay/git-status-branch-line-total.test.ts @@ -0,0 +1,475 @@ +/** + * Relay-side contract for the status pass's branch line total: the chip is an + * exact number or absent, and costs nothing when nobody asked for it. + */ +import { execFile, execFileSync } from 'node:child_process' +import { mkdtempSync } from 'node:fs' +import * as fs from 'node:fs/promises' +import { tmpdir } from 'node:os' +import * as path from 'node:path' +import { promisify } from 'node:util' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { invalidateGitBranchLineTotalInFlight } from '../shared/git-branch-line-total' +import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache' +import type { GitExec } from './git-handler-ops' +import { getStatusOp } from './git-handler-status-ops' +import type { RelayGitStreamExec } from './git-stdout-stream' +import { clearNoEffectiveUpstreamStatusCache } from './git-status-upstream-negative-cache' + +type GitCall = Parameters + +const execFileAsync = promisify(execFile) +const MERGE_BASE = '0123456789abcdef0123456789abcdef01234567' +const OTHER_MERGE_BASE = 'fedcba9876543210fedcba9876543210fedcba98' +// Untracked additions ride on top of the ranged diff, so the fixture needs a real file. +const UNTRACKED_FILE = 'notes.md' +const UNTRACKED_LINES = 4 +const STATUS_OUTPUT = [ + '# branch.oid 1111111111111111111111111111111111111111', + '# branch.head (detached)', + '1 .M N... 100644 100644 100644 aaaa aaaa src/a.ts', + `? ${UNTRACKED_FILE}` +].join('\n') + +function isRangedNumstat(args: string[]): boolean { + return args.includes('diff') && args.includes('--numstat') && args.includes('-z') +} + +function rangedDiffCalls(calls: readonly GitCall[]): string[][] { + return calls.map(([args]) => args).filter((args) => isRangedNumstat(args)) +} + +function streamGitFromCapture(git: GitExec): RelayGitStreamExec { + return async (args, cwd, options) => { + const { stdout } = await git(args, cwd, { + disableOptionalLocks: options.disableOptionalLocks, + signal: options.signal + }) + return { stoppedEarly: options.onStdout(stdout) === true } + } +} + +/** Mock host; the fixture-repo cases below run real git instead. */ +function createMockGit(overrides: { + status?: string + ranged?: () => Promise<{ stdout: string; stderr: string }> + areaNumstat?: string +}) { + return vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: overrides.status ?? STATUS_OUTPUT, stderr: '' } + } + if (isRangedNumstat(args)) { + return overrides.ranged ? overrides.ranged() : { stdout: '12\t5\tsrc/a.ts\n', stderr: '' } + } + if (args.includes('diff')) { + return { stdout: overrides.areaNumstat ?? '3\t2\tsrc/a.ts\n', stderr: '' } + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) +} + +const realGitExec: GitExec = async (args, cwd, opts) => { + const { stdout, stderr } = await execFileAsync('git', args, { + cwd, + encoding: 'utf8', + ...(opts?.signal ? { signal: opts.signal } : {}), + ...(opts?.timeout ? { timeout: opts.timeout } : {}) + }) + return { stdout, stderr } +} + +function runFixtureGit(repo: string, args: string[]): string { + return execFileSync( + 'git', + [ + '-c', + 'user.email=test@test.com', + '-c', + 'user.name=Test', + '-c', + 'commit.gpgSign=false', + ...args + ], + { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } + ).trim() +} + +/** + * Fork point → working tree: +3 tracked (2 committed, 1 unstaged), -2 from the + * file the branch deleted, +4 untracked. The pure rename contributes nothing. + */ +async function seedBranchFixture(repo: string): Promise { + await fs.rm(path.join(repo, UNTRACKED_FILE), { force: true }) + execFileSync('git', ['init', '-q', repo], { stdio: 'pipe' }) + await fs.writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\n') + await fs.writeFile(path.join(repo, 'doomed.txt'), 'x\ny\n') + await fs.writeFile(path.join(repo, 'old-name.txt'), 'stable\n') + runFixtureGit(repo, ['add', '.']) + runFixtureGit(repo, ['commit', '-m', 'base']) + const mergeBase = runFixtureGit(repo, ['rev-parse', 'HEAD']) + + runFixtureGit(repo, ['checkout', '-q', '-b', 'feature']) + await fs.writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\nd\ne\n') + await fs.rm(path.join(repo, 'doomed.txt')) + runFixtureGit(repo, ['add', '-A']) + runFixtureGit(repo, ['commit', '-m', 'branch commit']) + + runFixtureGit(repo, ['mv', 'old-name.txt', 'new-name.txt']) + await fs.writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\nd\ne\nf\n') + await fs.writeFile(path.join(repo, UNTRACKED_FILE), 'n1\nn2\nn3\nn4\n') + return mergeBase +} + +describe('getStatusOp branch line total', () => { + let tmpDir: string + + beforeEach(async () => { + clearNoEffectiveUpstreamStatusCache() + clearGitStatusLineStatsCache() + invalidateGitBranchLineTotalInFlight() + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-branch-line-total-')) + await fs.writeFile(path.join(tmpDir, UNTRACKED_FILE), 'n1\nn2\nn3\nn4\n') + }) + + afterEach(async () => { + clearNoEffectiveUpstreamStatusCache() + clearGitStatusLineStatsCache() + invalidateGitBranchLineTotalInFlight() + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it('runs no ranged diff and returns no total when the merge base param is absent', async () => { + const git = createMockGit({}) + + const result = await getStatusOp(git, streamGitFromCapture(git), { worktreePath: tmpDir }) + + expect(rangedDiffCalls(git.mock.calls)).toEqual([]) + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(result.branchLineTotal).toBeUndefined() + // The per-area numstats the CHANGES rows need still ran, so this is a + // zero-cost omission rather than a disabled status pass. + expect(result.entries).toContainEqual( + expect.objectContaining({ path: 'src/a.ts', added: 3, removed: 2 }) + ) + }) + + it('sums the ranged diff and untracked additions against a real fixture repo', async () => { + const mergeBase = await seedBranchFixture(tmpDir) + const git = vi.fn(realGitExec) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: mergeBase + }) + + expect(result.branchLineTotal).toEqual({ + added: 3 + UNTRACKED_LINES, + removed: 2, + mergeBase + }) + expect(rangedDiffCalls(git.mock.calls)).toEqual([ + ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', mergeBase, '--'] + ]) + }) + + it('omits the total when the status listing hit its limit', async () => { + const manyEntries = Array.from( + { length: 6 }, + (_, index) => `1 A. N... 100644 100644 100644 000000 111111 generated-${index}.txt` + ).join('\n') + const git = createMockGit({ status: manyEntries }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + limit: 3, + branchLineTotalMergeBase: MERGE_BASE + }) + + expect(result.didHitLimit).toBe(true) + // The untracked list is truncated, so any total would silently under-count. + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(rangedDiffCalls(git.mock.calls)).toEqual([]) + }) + + it('omits the total, never zero, when the ranged diff fails', async () => { + const git = createMockGit({ + ranged: () => Promise.reject(new Error('fatal: bad object 0123456789abcdef')) + }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(result.branchLineTotal).toBeUndefined() + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + // A failed ranged diff must not take the per-file rows down with it. + expect(result.entries).toContainEqual(expect.objectContaining({ path: 'src/a.ts', added: 3 })) + }) + + it('omits the total, never zero, for a well-formed but unknown merge-base oid', async () => { + await seedBranchFixture(tmpDir) + const git = vi.fn(realGitExec) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef' + }) + + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(result.entries.length).toBeGreaterThan(0) + }) + + it('omits the total when the ranged diff exceeds its time budget', async () => { + const git = createMockGit({ + ranged: () => { + const error: Error & { killed?: boolean } = new Error('spawn git ETIMEDOUT') + error.killed = true + return Promise.reject(error) + } + }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + // The budget is handed to the subprocess rather than raced on a wall clock. + const rangedOptions = git.mock.calls.find(([args]) => isRangedNumstat(args))?.[2] + expect(rangedOptions).toMatchObject({ disableOptionalLocks: true, timeout: expect.any(Number) }) + }) + + it('omits the total when the status scan itself failed', async () => { + const git = vi.fn(async (args) => { + if (args.includes('status')) { + throw new Error('fatal: not a git repository') + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + + expect(result.entries).toEqual([]) + // A failed scan yields an empty untracked list, which would read as "no + // untracked additions" rather than "unknown". + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(rangedDiffCalls(git.mock.calls)).toEqual([]) + }) + + // Relay params are an untyped bag, so the merge base must be proven to be an + // object name before it can be spliced into an argv. + it.each([ + ['a flag-shaped value', '--upload-pack=x'], + ['a rev name', 'HEAD'], + ['a ref path', 'refs/heads/main'], + ['a range', `${MERGE_BASE}..HEAD`], + ['a number', 123], + ['null', null], + ['an empty string', ''] + ])('rejects %s before it reaches a git argv', async (_label, value) => { + const git = createMockGit({}) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: value + }) + + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(rangedDiffCalls(git.mock.calls)).toEqual([]) + for (const [args] of git.mock.calls) { + expect(args).not.toContain(String(value)) + } + }) + + it('answers a rejected merge base with the byte-identical no-merge-base response', async () => { + const gitWithout = createMockGit({}) + const withoutParam = await getStatusOp(gitWithout, streamGitFromCapture(gitWithout), { + worktreePath: tmpDir + }) + clearGitStatusLineStatsCache() + const gitRejected = createMockGit({}) + const rejectedParam = await getStatusOp(gitRejected, streamGitFromCapture(gitRejected), { + worktreePath: tmpDir, + branchLineTotalMergeBase: '--upload-pack=x' + }) + + expect(JSON.stringify(rejectedParam)).toBe(JSON.stringify(withoutParam)) + expect(gitRejected.mock.calls.map(([args]) => args)).toEqual( + gitWithout.mock.calls.map(([args]) => args) + ) + }) + + it('rejects an aborted scan instead of resolving a partial total', async () => { + const controller = new AbortController() + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: STATUS_OUTPUT, stderr: '' } + } + if (isRangedNumstat(args)) { + controller.abort() + const error = new Error('The operation was aborted.') + error.name = 'AbortError' + throw error + } + if (args.includes('diff')) { + return { stdout: '3\t2\tsrc/a.ts\n', stderr: '' } + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) + + await expect( + getStatusOp( + git, + streamGitFromCapture(git), + { worktreePath: tmpDir, branchLineTotalMergeBase: MERGE_BASE }, + { signal: controller.signal } + ) + ).rejects.toThrow(/abort/i) + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + }) + + it('reuses the cached total instead of re-running the ranged diff', async () => { + const git = createMockGit({}) + + const first = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + const reused = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE, + reuseLineStats: true + }) + + expect(first.branchLineTotal).toEqual({ + added: 12 + UNTRACKED_LINES, + removed: 5, + mergeBase: MERGE_BASE + }) + expect(reused.branchLineTotal).toEqual(first.branchLineTotal) + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + }) + + it('coalesces concurrent status passes onto one ranged diff', async () => { + // The renderer's own in-flight refs only dedupe one renderer; a second + // window or an fs-watcher burst must not run the ranged diff twice. + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: STATUS_OUTPUT, stderr: '' } + } + if (isRangedNumstat(args)) { + await new Promise((resolve) => setTimeout(resolve, 20)) + return { stdout: '12\t5\tsrc/a.ts\n', stderr: '' } + } + if (args.includes('diff')) { + return { stdout: '3\t2\tsrc/a.ts\n', stderr: '' } + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) + + const [first, second] = await Promise.all([ + getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }), + getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + ]) + + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + expect(first.branchLineTotal).toEqual(second.branchLineTotal) + expect(first.branchLineTotal).toEqual({ + added: 12 + UNTRACKED_LINES, + removed: 5, + mergeBase: MERGE_BASE + }) + }) + + it('recomputes rather than reusing a total measured against another fork point', async () => { + const git = createMockGit({}) + + await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + const moved = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: OTHER_MERGE_BASE, + reuseLineStats: true + }) + + expect(moved.branchLineTotal).toEqual({ + added: 12 + UNTRACKED_LINES, + removed: 5, + mergeBase: OTHER_MERGE_BASE + }) + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(2) + }) + + // Rule 1 of docs/reference/remote-wire-compatibility.md: a new optional field + // is safe only while every reader survives its absence. + describe('wire compatibility', () => { + it('drops the key from the wire payload so a reader sees undefined, not 0 or NaN', async () => { + const git = createMockGit({ ranged: () => Promise.reject(new Error('fatal: bad object')) }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + // An old server's payload and a new server's "not known exact" payload are + // the same thing on the wire: no key at all. + const overTheWire = JSON.parse(JSON.stringify(result)) as { + branchLineTotal?: { added: number; removed: number } + } + + expect(Object.hasOwn(overTheWire, 'branchLineTotal')).toBe(false) + expect(overTheWire.branchLineTotal).toBeUndefined() + expect(overTheWire.branchLineTotal?.added).toBeUndefined() + expect(overTheWire.branchLineTotal?.removed).toBeUndefined() + }) + + it('keeps the new request param out of the status argv an old server would run', async () => { + const git = createMockGit({}) + + await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + + const statusArgs = git.mock.calls + .map(([args]) => args) + .filter((args) => args.includes('status')) + expect(statusArgs).toHaveLength(1) + expect(statusArgs[0]).not.toContain(MERGE_BASE) + }) + + it('publishes finite counts when the ranged diff reports only binaries', async () => { + const git = createMockGit({ + ranged: () => Promise.resolve({ stdout: '-\t-\tlogo.png\n', stderr: '' }) + }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + + expect(result.branchLineTotal).toEqual({ + added: UNTRACKED_LINES, + removed: 0, + mergeBase: MERGE_BASE + }) + expect(Number.isFinite(result.branchLineTotal?.added)).toBe(true) + expect(Number.isFinite(result.branchLineTotal?.removed)).toBe(true) + }) + }) +}) diff --git a/src/relay/git-status-branch-line-total.ts b/src/relay/git-status-branch-line-total.ts new file mode 100644 index 00000000000..c2fe6556a0e --- /dev/null +++ b/src/relay/git-status-branch-line-total.ts @@ -0,0 +1,44 @@ +/** + * Relay-side wiring for the status pass's branch line total. + * Why: separate file so git-handler-status-ops.ts stays under oxlint max-lines (300). + */ +import { + computeGitBranchLineTotal, + GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS, + type GitBranchLineTotal +} from '../shared/git-branch-line-total' +import type { GitExec } from './git-handler-ops' + +/** Undefined when no valid merge base was requested, which keeps the ranged diff entirely off. */ +export function buildBranchLineTotalInput( + git: GitExec, + worktreePath: string, + entries: Record[], + mergeBase: string | undefined, + signal?: AbortSignal +): { mergeBase: string; compute: () => Promise } | undefined { + if (!mergeBase) { + return undefined + } + return { + mergeBase, + compute: () => + computeGitBranchLineTotal({ + worktreePath, + // Matches the `relay\0` line-stats cache-key convention. + hostKey: 'relay', + mergeBase, + untrackedPaths: entries + .filter((entry) => entry.area === 'untracked') + .map((entry) => entry.path as string), + runDiffNumstat: (args, diffSignal) => + git(args, worktreePath, { + // Why: a working-tree diff must not take index.lock away from terminal Git. + disableOptionalLocks: true, + signal: diffSignal, + timeout: GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS + }).then(({ stdout }) => stdout), + ...(signal ? { signal } : {}) + }) + } +} diff --git a/src/renderer/src/components/editor/CombinedDiffFileTree.tsx b/src/renderer/src/components/editor/CombinedDiffFileTree.tsx index fc59c2dd75c..dd6f7b99b93 100644 --- a/src/renderer/src/components/editor/CombinedDiffFileTree.tsx +++ b/src/renderer/src/components/editor/CombinedDiffFileTree.tsx @@ -10,7 +10,7 @@ import { compactSourceControlTree, flattenSourceControlTree } from '@/components/right-sidebar/source-control-tree' -import type { GitBranchChangeEntry, GitStagingArea, GitStatusEntry } from '../../../../shared/types' +import type { GitBranchChangeEntry, GitStagingArea } from '../../../../shared/types' import { getEntryExtension, getFilteredCombinedDiffFileTreeEntries, @@ -22,6 +22,11 @@ import { import { CombinedDiffFileTreeRow, type CombinedDiffTreeNode } from './combined-diff-file-tree-row' import { useCombinedDiffFileTreeResize } from './use-combined-diff-file-tree-resize' import { translate } from '@/i18n/i18n' +import { + mergeUntrackedIntoChanges, + SOURCE_CONTROL_GROUP_ORDER, + type SourceControlEntryGroups +} from '@/components/right-sidebar/source-control-section-order' export { createCombinedDiffSectionIndexMap, @@ -30,7 +35,6 @@ export { handleCombinedDiffFileTreeNavigation } from './combined-diff-file-tree-model' -const UNCOMMITTED_AREA_ORDER: readonly GitStagingArea[] = ['unstaged', 'staged', 'untracked'] const UNCOMMITTED_AREA_LABELS: Record = { unstaged: 'Changes', staged: 'Staged Changes', @@ -39,26 +43,35 @@ const UNCOMMITTED_AREA_LABELS: Record = { function buildUncommittedRows( entries: readonly CombinedDiffFileTreeEntry[], - collapsedDirectoryKeys: ReadonlySet + collapsedDirectoryKeys: ReadonlySet, + areaOrder: readonly GitStagingArea[] ): { area: GitStagingArea; label: string; rows: CombinedDiffTreeNode[] }[] { - return UNCOMMITTED_AREA_ORDER.map((area) => { - const areaEntries = entries.filter( - (entry): entry is GitStatusEntry => isGitStatusEntry(entry) && entry.area === area - ) - if (areaEntries.length === 0) { - return null + const groups: SourceControlEntryGroups = { staged: [], unstaged: [], untracked: [] } + for (const entry of entries) { + if (isGitStatusEntry(entry)) { + groups[entry.area].push(entry) } + } + const displayGroups = mergeUntrackedIntoChanges(groups) - const roots = compactSourceControlTree(buildGitStatusSourceControlTree(area, areaEntries)) - return { - area, - label: UNCOMMITTED_AREA_LABELS[area], - rows: flattenSourceControlTree(roots, collapsedDirectoryKeys) as CombinedDiffTreeNode[] - } - }).filter( - (group): group is { area: GitStagingArea; label: string; rows: CombinedDiffTreeNode[] } => - Boolean(group) - ) + return areaOrder + .map((area) => { + const areaEntries = displayGroups[area] + if (areaEntries.length === 0) { + return null + } + + const roots = compactSourceControlTree(buildGitStatusSourceControlTree(area, areaEntries)) + return { + area, + label: UNCOMMITTED_AREA_LABELS[area], + rows: flattenSourceControlTree(roots, collapsedDirectoryKeys) as CombinedDiffTreeNode[] + } + }) + .filter( + (group): group is { area: GitStagingArea; label: string; rows: CombinedDiffTreeNode[] } => + Boolean(group) + ) } function buildBranchRows( @@ -153,7 +166,7 @@ export function CombinedDiffFileTree({ const uncommittedGroups = React.useMemo( () => mode === 'all' || mode === 'uncommitted' - ? buildUncommittedRows(filteredEntries, collapsedDirectoryKeys) + ? buildUncommittedRows(filteredEntries, collapsedDirectoryKeys, SOURCE_CONTROL_GROUP_ORDER) : [], [collapsedDirectoryKeys, filteredEntries, mode] ) diff --git a/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx b/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx index 6914f9b3aba..792799376ea 100644 --- a/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx +++ b/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx @@ -13,7 +13,10 @@ export function GitHubMarkdownComposerEditorPane({ const scrollContainerRef = useRef(null) return ( -
+
{ function cardElement( canSend = true, messages?: readonly NativeChatMessage[], - onShowingQuestionChange?: (showing: boolean) => void + onShowingQuestionChange?: (showing: boolean) => void, + transcriptSettled = true ): React.JSX.Element { return ( { @@ -122,7 +124,7 @@ describe('NativeChatInteractiveCard answer lifecycle', () => { chooseSpacesAndSubmit() expect(screen.getByText('Tabs or spaces?')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'Send answer' })) + fireEvent.click(screen.getByRole('button', { name: 'Submit' })) expect(mocks.sendAnswer).toHaveBeenCalledTimes(2) }) @@ -205,7 +207,7 @@ describe('NativeChatInteractiveCard answer lifecycle', () => { chooseSpacesAndSubmit() act(() => settleDelivery?.(false)) - expect(screen.getByRole('button', { name: 'Send answer' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'Submit' })).toBeEnabled() expect(screen.getByText('Tabs or spaces?')).toBeInTheDocument() }) }) @@ -233,6 +235,12 @@ describe('NativeChatInteractiveCard transcript fallback', () => { expect(onShowingQuestionChange).toHaveBeenCalledWith(true) }) + it('withholds a retained transcript ask while its replacement read is unsettled', () => { + render(cardElement(true, [askCallMessage('Stale transcript question?')], undefined, false)) + + expect(screen.queryByText('Stale transcript question?')).not.toBeInTheDocument() + }) + it('prefers live status over the transcript when both carry a prompt', () => { storeState.agentStatusByPaneKey['tab-1:leaf-1'].interactivePrompt = INITIAL_PROMPT render(cardElement(true, [askCallMessage('Stale transcript question?')])) diff --git a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx index c02df5c13e8..0b3e110c0d5 100644 --- a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '../../store' -import { extractPendingAsk } from '../../../../shared/native-chat-ask' +import { resolveNativeChatAsk } from '../../../../shared/native-chat-ask' import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { parseInteractivePrompt } from './native-chat-interactive-prompt' import { nativeChatCardDismissKey } from './native-chat-dismiss-key' @@ -33,6 +33,7 @@ export function NativeChatInteractiveCard({ send, canSend, messages, + transcriptSettled, onShowingQuestionChange, answerInputRef }: { @@ -42,6 +43,7 @@ export function NativeChatInteractiveCard({ /** Transcript to fall back on when live status carries no prompt. Pass the * command-boundary-trimmed messages so an ask abandoned via `/clear` stays gone. */ messages?: readonly NativeChatMessage[] + transcriptSettled: boolean /** Reports whether a question card is on screen so the view can replace the * composer with it (the card's free-text row is the answer input). */ onShowingQuestionChange?: (showing: boolean) => void @@ -59,12 +61,16 @@ export function NativeChatInteractiveCard({ const card = useMemo(() => { const statusCard = parseInteractivePrompt(interactivePrompt, interactiveToolName ?? undefined) - if (statusCard || !messages) { + if (statusCard?.kind === 'approval') { return statusCard } - const prompt = extractPendingAsk(messages) + const prompt = resolveNativeChatAsk({ + liveAsk: statusCard?.prompt ?? null, + messages: messages ?? [], + transcriptSettled: transcriptSettled && messages != null + }) return prompt ? { kind: 'question' as const, prompt } : null - }, [interactivePrompt, interactiveToolName, messages]) + }, [interactivePrompt, interactiveToolName, messages, transcriptSettled]) const cardKey = useMemo(() => nativeChatCardDismissKey(card), [card]) const [dismissedKey, setDismissedKey] = useState(null) // A question answer is a paced multi-step write (body→Enter per question); keep diff --git a/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx index 3e2a8b27716..26805ed2f06 100644 --- a/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx @@ -75,7 +75,7 @@ describe('NativeChatQuestionCard', () => { render(tabsOrSpaces, onAnswer) clickOption('Spaces') - clickAction('Send answer') + clickAction('Submit') expect(onAnswer).toHaveBeenCalledWith([{ indices: [1], other: '' }]) }) @@ -97,7 +97,7 @@ describe('NativeChatQuestionCard', () => { clickOption('Cherry') clickOption('Apple') - clickAction('Send answer') + clickAction('Submit') expect(onAnswer).toHaveBeenCalledWith([{ indices: [0, 2], other: '' }]) }) @@ -118,7 +118,7 @@ describe('NativeChatQuestionCard', () => { ) clickOptionAt(1) - clickAction('Send answer') + clickAction('Submit') expect(onAnswer).toHaveBeenCalledWith([{ indices: [1], other: '' }]) }) @@ -133,7 +133,7 @@ describe('NativeChatQuestionCard', () => { setter.call(input, 'four spaces') input.dispatchEvent(new Event('input', { bubbles: true })) }) - clickAction('Send answer') + clickAction('Submit') expect(onAnswer).toHaveBeenCalledWith([{ indices: [], other: 'four spaces' }]) }) diff --git a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx index d5241e23bdf..adabecf67b8 100644 --- a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx @@ -209,7 +209,7 @@ export function NativeChatQuestionCard({ disabled={isSubmitting} onClick={() => confirm()} className={cn( - 'w-24 shrink-0 rounded-md px-3 py-1 text-xs font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-50', + 'shrink-0 whitespace-nowrap rounded-md px-3 py-1 text-xs font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-50', currentAnswered ? 'bg-primary text-primary-foreground hover:bg-primary/90' : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground' @@ -219,7 +219,7 @@ export function NativeChatQuestionCard({ ? translate('components.native-chat.question.sending', 'Sending…') : currentAnswered ? isLast - ? translate('components.native-chat.question.send', 'Send answer') + ? translate('components.native-chat.question.send', 'Submit') : translate('components.native-chat.question.next', 'Next') : translate('components.native-chat.question.skip', 'Skip')} diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index 33a8ddecbcc..2fb458166ec 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -3,7 +3,7 @@ import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '../../store' import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-adoption' import type { NativeChatSession } from '../../../../shared/native-chat-types' -import { useNativeChatLiveSession } from './use-native-chat-live-session' +import { useNativeChatRetainedSession } from './use-native-chat-retained-session' import { selectNativeChatViewState } from './native-chat-view-state' import { NativeChatMessageList } from './NativeChatMessageList' import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatComposer' @@ -131,7 +131,7 @@ function NativeChatResolvedView({ const runtimeEnvironmentId = useAppStore((s) => selectNativeChatRuntimeEnvironmentId(s, terminalTabId) ) - const session = useNativeChatLiveSession({ + const session = useNativeChatRetainedSession({ paneKey, agent, sessionId, @@ -430,6 +430,7 @@ function NativeChatResolvedView({ send={interactiveSend} canSend={canSend} messages={sessionAfterCommandBoundaries.messages} + transcriptSettled={session.readPhase === 'ready'} onShowingQuestionChange={setQuestionActive} answerInputRef={questionAnswerInputRef} /> diff --git a/src/renderer/src/components/native-chat/use-native-chat-retained-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-retained-session.test.ts new file mode 100644 index 00000000000..17b5801b05a --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-retained-session.test.ts @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { + NativeChatLiveSession, + UseNativeChatLiveSessionArgs +} from './use-native-chat-live-session' + +const { liveSession } = vi.hoisted(() => ({ liveSession: vi.fn() })) + +vi.mock('./use-native-chat-live-session', () => ({ useNativeChatLiveSession: liveSession })) + +import { useNativeChatRetainedSession } from './use-native-chat-retained-session' + +const ARGS: UseNativeChatLiveSessionArgs = { + paneKey: 'tab:leaf', + agent: 'claude', + sessionId: 'session', + runtimeEnvironmentId: 'owner-a' +} + +function message(id: string): NativeChatMessage { + return { id, role: 'assistant', blocks: [], timestamp: 0, source: 'transcript' } +} + +function session( + readPhase: NativeChatLiveSession['readPhase'], + messages: NativeChatMessage[], + sessionId: string | null = 'session' +): NativeChatLiveSession { + return { + agent: 'claude', + sessionId, + messages, + status: readPhase === 'loading' ? 'loading' : 'ready', + hasMore: false, + loadingEarlier: false, + loadEarlier: vi.fn(), + readPhase + } +} + +describe('useNativeChatRetainedSession', () => { + let root: Root | null = null + let latest: NativeChatLiveSession | null = null + + function Probe(props: UseNativeChatLiveSessionArgs): null { + latest = useNativeChatRetainedSession(props) + return null + } + + async function render(props: UseNativeChatLiveSessionArgs): Promise { + if (!root) { + root = createRoot(document.createElement('div')) + } + await act(async () => root?.render(createElement(Probe, props))) + } + + afterEach(() => { + act(() => root?.unmount()) + root = null + latest = null + liveSession.mockReset() + }) + + it('keeps settled messages during a same-identity rebind', async () => { + liveSession.mockReturnValue(session('ready', [message('settled')])) + await render(ARGS) + + liveSession.mockReturnValue(session('loading', [])) + await render(ARGS) + + expect(latest?.readPhase).toBe('loading') + expect(latest?.messages.map((entry) => entry.id)).toEqual(['settled']) + }) + + it('misses retained messages when the source owner changes', async () => { + liveSession.mockReturnValue(session('ready', [message('owner-a')])) + await render(ARGS) + + liveSession.mockReturnValue(session('loading', [])) + await render({ ...ARGS, runtimeEnvironmentId: 'owner-b' }) + + expect(latest?.readPhase).toBe('loading') + expect(latest?.messages).toEqual([]) + }) + + it('does not overwrite retention with the session-less view', async () => { + liveSession.mockReturnValue(session('ready', [message('settled')])) + await render(ARGS) + + liveSession.mockReturnValue(session('ready', [], null)) + await render({ ...ARGS, sessionId: null }) + liveSession.mockReturnValue(session('loading', [])) + await render(ARGS) + + expect(latest?.messages.map((entry) => entry.id)).toEqual(['settled']) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-retained-session.ts b/src/renderer/src/components/native-chat/use-native-chat-retained-session.ts new file mode 100644 index 00000000000..e10ccecf5aa --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-retained-session.ts @@ -0,0 +1,53 @@ +import { useEffect, useRef } from 'react' +import { + createNativeChatTranscriptRetention, + encodeNativeChatTranscriptIdentity +} from '../../../../shared/native-chat-transcript-retention' +import { + useNativeChatLiveSession, + type NativeChatLiveSession, + type UseNativeChatLiveSessionArgs +} from './use-native-chat-live-session' + +/** Keeps one committed conversation visible while its exact source rebinds. */ +export function useNativeChatRetainedSession( + args: UseNativeChatLiveSessionArgs +): NativeChatLiveSession { + const session = useNativeChatLiveSession(args) + const identity = encodeNativeChatTranscriptIdentity([ + args.paneKey, + args.runtimeEnvironmentId ?? null, + args.agent, + args.sessionId, + args.transcriptPath ?? null + ]) + const activeIdentityRef = useRef(identity) + const retentionRef = useRef(createNativeChatTranscriptRetention()) + const sessionMatchesIdentity = activeIdentityRef.current === identity + const readPhase = sessionMatchesIdentity ? session.readPhase : 'loading' + + useEffect(() => { + activeIdentityRef.current = identity + }, [identity]) + useEffect(() => { + if (sessionMatchesIdentity && args.sessionId !== null && session.readPhase === 'ready') { + retentionRef.current.capture(identity, session.messages) + } + }, [args.sessionId, identity, session.messages, session.readPhase, sessionMatchesIdentity]) + + const messages = retentionRef.current.visible({ + identity, + messages: session.messages, + settled: readPhase === 'ready', + loading: readPhase === 'loading' + }) + if (messages === session.messages && readPhase === session.readPhase) { + return session + } + return { + ...session, + messages, + readPhase, + ...(sessionMatchesIdentity ? {} : { status: 'loading' as const, error: undefined }) + } +} diff --git a/src/renderer/src/components/quick-open-search.test.ts b/src/renderer/src/components/quick-open-search.test.ts index b38f2b1c44a..be15b7ee45c 100644 --- a/src/renderer/src/components/quick-open-search.test.ts +++ b/src/renderer/src/components/quick-open-search.test.ts @@ -9,11 +9,33 @@ import { } from './quick-open-search' describe('quick-open-search', () => { - it('returns the first 50 paths with score 0 for an empty query', () => { - const files = Array.from({ length: 75 }, (_, index) => `src/file-${index}.ts`) + it('orders numbered paths naturally for empty queries and fuzzy-score ties', () => { + const files = prepareQuickOpenFiles([ + 'songs/100 - b.txt', + 'songs/9 - c.txt', + 'songs/99 - a.txt' + ]) + + expect(rankQuickOpenFiles('', files).map((item) => item.path)).toEqual([ + 'songs/9 - c.txt', + 'songs/99 - a.txt', + 'songs/100 - b.txt' + ]) + expect(rankQuickOpenFiles('songs', files).map((item) => item.path)).toEqual([ + 'songs/9 - c.txt', + 'songs/99 - a.txt', + 'songs/100 - b.txt' + ]) + }) + + it('returns the first 50 naturally sorted paths with score 0 for an empty query', () => { + const files = Array.from({ length: 75 }, (_, index) => `src/file-${74 - index}.ts`) expect(rankQuickOpenFiles('', prepareQuickOpenFiles(files))).toEqual( - files.slice(0, QUICK_OPEN_RESULT_LIMIT).map((path) => ({ path, score: 0 })) + Array.from({ length: QUICK_OPEN_RESULT_LIMIT }, (_, index) => ({ + path: `src/file-${index}.ts`, + score: 0 + })) ) }) @@ -35,8 +57,8 @@ describe('quick-open-search', () => { ).toEqual(['src/components/Button.tsx', 'button-area/deep/path/file.tsx']) }) - it('keeps first-seen order for tie-heavy results at the limit boundary', () => { - const files = Array.from({ length: 10 }, (_, index) => `src/path-${index}.bin`) + it('uses natural order for tie-heavy results at the limit boundary', () => { + const files = Array.from({ length: 10 }, (_, index) => `src/path-${9 - index}.bin`) expect(rankQuickOpenFiles('s', prepareQuickOpenFiles(files), 4)).toEqual([ { path: 'src/path-0.bin', score: 0 }, @@ -85,9 +107,9 @@ describe('quick-open-search', () => { it('indexes normalized relative paths without changing path semantics', () => { const files = [ 'src/renderer/src/components/QuickOpen.tsx', + 'legacy\\provider\\raw-path.ts', 'packages/windows-origin/src/App.tsx', - 'single-file.ts', - 'legacy\\provider\\raw-path.ts' + 'single-file.ts' ] expect(prepareQuickOpenFiles(files)).toEqual([ @@ -97,22 +119,22 @@ describe('quick-open-search', () => { lowerFilename: 'quickopen.tsx', inputIndex: 0 }, + { + path: 'legacy\\provider\\raw-path.ts', + lowerPath: 'legacy/provider/raw-path.ts', + lowerFilename: 'raw-path.ts', + inputIndex: 1 + }, { path: 'packages/windows-origin/src/App.tsx', lowerPath: 'packages/windows-origin/src/app.tsx', lowerFilename: 'app.tsx', - inputIndex: 1 + inputIndex: 2 }, { path: 'single-file.ts', lowerPath: 'single-file.ts', lowerFilename: 'single-file.ts', - inputIndex: 2 - }, - { - path: 'legacy\\provider\\raw-path.ts', - lowerPath: 'legacy/provider/raw-path.ts', - lowerFilename: 'raw-path.ts', inputIndex: 3 } ]) diff --git a/src/renderer/src/components/quick-open-search.ts b/src/renderer/src/components/quick-open-search.ts index 5413c151193..db13042c8fa 100644 --- a/src/renderer/src/components/quick-open-search.ts +++ b/src/renderer/src/components/quick-open-search.ts @@ -1,4 +1,5 @@ import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' +import { compareFileNames } from '../../../shared/file-name-sort' export const QUICK_OPEN_RESULT_LIMIT = 50 export const QUICK_OPEN_QUERY_MAX_BYTES = 2 * 1024 @@ -51,7 +52,11 @@ export function rankQuickOpenFiles( // still naturally type backslashes in path queries. const normalizedQuery = query.trim().replace(/\\/g, '/').toLowerCase() if (!normalizedQuery) { - return files.slice(0, limit).map((file) => ({ path: file.path, score: 0 })) + const results: QuickOpenRankedResult[] = [] + for (const file of files) { + retainTopResult(results, { path: file.path, score: 0, inputIndex: file.inputIndex }, limit) + } + return finalizeResults(results) } const results: QuickOpenRankedResult[] = [] @@ -61,10 +66,10 @@ export function rankQuickOpenFiles( continue } - insertTopResult(results, { path: file.path, score, inputIndex: file.inputIndex }, limit) + retainTopResult(results, { path: file.path, score, inputIndex: file.inputIndex }, limit) } - return results.map(({ path, score }) => ({ path, score })) + return finalizeResults(results) } function fuzzyMatchIndexedFile(query: string, file: QuickOpenIndexedFile): number { @@ -106,42 +111,63 @@ type QuickOpenRankedResult = QuickOpenSearchResult & { inputIndex: number } -function insertTopResult( - results: QuickOpenRankedResult[], +function retainTopResult( + heap: QuickOpenRankedResult[], candidate: QuickOpenRankedResult, limit: number ): void { - const worst = results.at(-1) - if (results.length === limit && worst && compareRankedResult(candidate, worst) >= 0) { + if (heap.length === limit && compareRankedResult(candidate, heap[0]) >= 0) { return } - const insertAt = findInsertionIndex(results, candidate) - results.splice(insertAt, 0, candidate) - if (results.length > limit) { - results.pop() + if (heap.length < limit) { + heap.push(candidate) + siftResultUp(heap, heap.length - 1) + return + } + + heap[0] = candidate + siftResultDown(heap) +} + +function siftResultUp(heap: QuickOpenRankedResult[], startIndex: number): void { + let index = startIndex + while (index > 0) { + const parentIndex = Math.floor((index - 1) / 2) + if (compareRankedResult(heap[index], heap[parentIndex]) <= 0) { + return + } + ;[heap[index], heap[parentIndex]] = [heap[parentIndex], heap[index]] + index = parentIndex } } -function findInsertionIndex( - results: readonly QuickOpenRankedResult[], - candidate: QuickOpenRankedResult -): number { - let low = 0 - let high = results.length - - while (low < high) { - const mid = Math.floor((low + high) / 2) - if (compareRankedResult(candidate, results[mid]) < 0) { - high = mid - } else { - low = mid + 1 +function siftResultDown(heap: QuickOpenRankedResult[]): void { + let index = 0 + while (true) { + const leftIndex = index * 2 + 1 + if (leftIndex >= heap.length) { + return } + const rightIndex = leftIndex + 1 + const worseChildIndex = + rightIndex < heap.length && compareRankedResult(heap[rightIndex], heap[leftIndex]) > 0 + ? rightIndex + : leftIndex + if (compareRankedResult(heap[worseChildIndex], heap[index]) <= 0) { + return + } + ;[heap[index], heap[worseChildIndex]] = [heap[worseChildIndex], heap[index]] + index = worseChildIndex } +} - return low +function finalizeResults(results: QuickOpenRankedResult[]): QuickOpenSearchResult[] { + return results + .sort(compareRankedResult) + .map(({ path, score }): QuickOpenSearchResult => ({ path, score })) } function compareRankedResult(a: QuickOpenRankedResult, b: QuickOpenRankedResult): number { - return a.score - b.score || a.inputIndex - b.inputIndex + return a.score - b.score || compareFileNames(a.path, b.path) || a.inputIndex - b.inputIndex } diff --git a/src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx new file mode 100644 index 00000000000..c9a7f24beb2 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx @@ -0,0 +1,340 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { GitBranchCompareSummary } from '../../../../shared/types' +import { + clearBranchLineTotalRequestGateForTests, + getBranchLineTotalMergeBase +} from './branch-line-total-request-gate' +import SourceControl from './SourceControl' + +const MERGE_BASE = '1f3c0d9a5b6e7f8091a2b3c4d5e6f708192a3b4c' + +const mocks = vi.hoisted(() => { + const activeRepo = { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: 0, + kind: 'git' as string + } + const activeWorktree = { + id: 'wt-1', + repoId: 'repo-1', + path: '/repo/wt', + head: 'head-1', + branch: 'refs/heads/feature/line-total', + isBare: false, + isMainWorktree: false, + displayName: 'feature/line-total', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + } + return { + activeRepo, + activeWorktree, + state: {} as Record + } +}) + +vi.mock('@/store', () => { + const useAppStore = Object.assign( + (selector?: (state: Record) => unknown) => + selector ? selector(mocks.state) : mocks.state, + { + getState: () => mocks.state + } + ) + return { useAppStore } +}) + +vi.mock('@/store/selectors', () => ({ + useActiveWorktree: () => mocks.activeWorktree, + useRepoById: (repoId: string | null) => + repoId === mocks.activeRepo.id ? mocks.activeRepo : null, + useWorktreeMap: () => new Map([[mocks.activeWorktree.id, mocks.activeWorktree]]) +})) + +vi.mock('@/components/confirmation-dialog-context', () => ({ + useConfirmationDialog: () => vi.fn().mockResolvedValue(true) +})) + +vi.mock('./git-status-refresh', () => ({ + refreshGitStatusForWorktree: vi.fn().mockResolvedValue(undefined), + refreshGitStatusForWorktreeStrict: vi.fn().mockResolvedValue(undefined) +})) + +function noopAsync(value: unknown = undefined): () => Promise { + return vi.fn().mockResolvedValue(value) +} + +const readySummary: GitBranchCompareSummary = { + baseRef: 'refs/remotes/origin/main', + baseOid: 'base-oid', + compareRef: 'feature/line-total', + headOid: 'head-1', + mergeBase: MERGE_BASE, + changedFiles: 2, + commitsAhead: 1, + status: 'ready' +} + +function resetState(overrides: Partial> = {}): void { + vi.clearAllMocks() + mocks.activeRepo.kind = 'git' + mocks.state = { + activeWorktreeId: mocks.activeWorktree.id, + activeGroupIdByWorktree: { [mocks.activeWorktree.id]: 'group-1' }, + groupsByWorktree: { [mocks.activeWorktree.id]: [{ id: 'group-1', activeTabId: null }] }, + repos: [mocks.activeRepo], + worktreesByRepo: { [mocks.activeRepo.id]: [mocks.activeWorktree] }, + rightSidebarOpen: true, + rightSidebarTab: 'source-control', + gitStatusByWorktree: { [mocks.activeWorktree.id]: [] }, + gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] }, + gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: readySummary }, + gitBranchLineTotalByWorktree: {}, + gitConflictOperationByWorktree: {}, + remoteStatusesByWorktree: {}, + isRemoteOperationActive: false, + inFlightRemoteOpKind: null, + settings: null, + hostedReviewCache: {}, + prCache: {}, + commitMessageGenerationRecords: {}, + pullRequestGenerationRecords: {}, + openFiles: [], + activeFileIdByWorktree: {}, + activeTabTypeByWorktree: {}, + getDiffComments: vi.fn(() => []), + updateSettings: noopAsync(), + openSettingsTarget: vi.fn(), + openSettingsPage: vi.fn(), + fetchHostedReviewForBranch: noopAsync(), + getHostedReviewCreationEligibility: noopAsync(null), + createHostedReview: noopAsync({ ok: false, error: 'not available' }), + updateWorktreeMeta: noopAsync(), + fetchPRForBranch: noopAsync(), + enqueueGitHubPRRefresh: vi.fn(), + updateRepo: noopAsync(), + setGitStatus: vi.fn(), + updateWorktreeGitIdentity: vi.fn(), + beginGitBranchCompareRequest: vi.fn(() => 'request-key'), + setGitBranchCompareResult: vi.fn(), + clearGitBranchCompare: vi.fn(), + fetchUpstreamStatus: noopAsync(), + setUpstreamStatus: vi.fn(), + pushBranch: noopAsync(), + pullBranch: noopAsync(), + fastForwardBranch: noopAsync(), + syncBranch: noopAsync(), + rebaseFromBase: noopAsync(), + fetchBranch: noopAsync(), + revealInExplorer: vi.fn(), + trackConflictPath: vi.fn(), + openDiff: vi.fn(), + openFile: vi.fn(), + setEditorViewMode: vi.fn(), + setMarkdownViewMode: vi.fn(), + setPendingEditorReveal: vi.fn(), + openConflictFile: vi.fn(), + openConflictReview: vi.fn(), + openBranchDiff: vi.fn(), + createEmptySplitGroup: vi.fn(() => 'group-2'), + openAllDiffs: vi.fn(), + openBranchAllDiffs: vi.fn(), + openCommitAllDiffs: vi.fn(), + deleteDiffComment: noopAsync(true), + clearDiffComments: noopAsync(true), + clearDiffCommentsForFile: noopAsync(true), + setScrollToDiffCommentId: vi.fn(), + setRightSidebarOpen: vi.fn(), + setRightSidebarTab: vi.fn(), + allocateCommitMessageGenerationRequestId: vi.fn(() => 'commit-generation-1'), + setCommitMessageGenerationRecord: vi.fn(), + updateCommitMessageGenerationRecord: vi.fn(), + pruneCommitMessageGenerationRecords: vi.fn(), + allocatePullRequestGenerationRequestId: vi.fn(() => 'pr-generation-1'), + setPullRequestGenerationRecord: vi.fn(), + updatePullRequestGenerationRecord: vi.fn(), + prunePullRequestGenerationRecords: vi.fn(), + ...overrides + } +} + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + clearBranchLineTotalRequestGateForTests() + resetState() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + clearBranchLineTotalRequestGateForTests() +}) + +function renderSourceControl(): void { + act(() => { + root.render( + + + + ) + }) +} + +function chip(): HTMLElement | null { + return container.querySelector('[data-testid="source-control-branch-line-total"]') +} + +describe('SourceControl branch line total request gate', () => { + it('asks for a total when the panel is visible and compare is ready', () => { + renderSourceControl() + + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBe(MERGE_BASE) + }) + + it('asks for nothing while the sidebar is closed', () => { + resetState({ rightSidebarOpen: false }) + renderSourceControl() + + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined() + }) + + it('asks for nothing while another right-sidebar tab is showing', () => { + resetState({ rightSidebarTab: 'checks' }) + renderSourceControl() + + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined() + }) + + it('asks for nothing until branch compare is ready', () => { + resetState({ + gitBranchCompareSummaryByWorktree: { + [mocks.activeWorktree.id]: { ...readySummary, status: 'loading' } + } + }) + renderSourceControl() + + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined() + }) + + it('asks for nothing when compare has no merge base', () => { + resetState({ + gitBranchCompareSummaryByWorktree: { + [mocks.activeWorktree.id]: { ...readySummary, status: 'invalid-base' } + } + }) + renderSourceControl() + + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined() + }) + + it('asks for nothing in a folder workspace', () => { + resetState() + mocks.activeRepo.kind = 'folder' + renderSourceControl() + + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined() + }) + + it('releases the gate when the panel unmounts', () => { + renderSourceControl() + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBe(MERGE_BASE) + + act(() => root.render({null})) + + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined() + }) +}) + +describe('SourceControl branch line total chip', () => { + it('renders a total measured against the current fork point', () => { + resetState({ + gitBranchLineTotalByWorktree: { + [mocks.activeWorktree.id]: { added: 8259, removed: 670, mergeBase: MERGE_BASE } + } + }) + renderSourceControl() + + expect(chip()?.getAttribute('aria-label')).toBe('8259 additions, 670 deletions') + // Grouping is pinned to the app locale (`en`), not the runner's host locale. + expect(chip()?.textContent).toBe('+8,259-670') + }) + + it('drops a total whose fork point has since moved', () => { + // Why: status and branch compare refresh on different cadences, so a total + // can outlive the merge base it measured. Hidden beats a stale number. + resetState({ + gitBranchLineTotalByWorktree: { + [mocks.activeWorktree.id]: { added: 8259, removed: 670, mergeBase: 'stale-merge-base' } + } + }) + renderSourceControl() + + expect(chip()).toBeNull() + }) + + it('drops a published total while branch compare has no ready summary', () => { + resetState({ + gitBranchCompareSummaryByWorktree: { + [mocks.activeWorktree.id]: { ...readySummary, status: 'loading', mergeBase: '' } + }, + gitBranchLineTotalByWorktree: { + [mocks.activeWorktree.id]: { added: 8259, removed: 670, mergeBase: MERGE_BASE } + } + }) + renderSourceControl() + + expect(chip()).toBeNull() + }) + + it('renders nothing when no total was published', () => { + renderSourceControl() + + expect(chip()).toBeNull() + }) + + it('renders nothing for an exact zero total', () => { + resetState({ + gitBranchLineTotalByWorktree: { + [mocks.activeWorktree.id]: { added: 0, removed: 0, mergeBase: MERGE_BASE } + } + }) + renderSourceControl() + + expect(chip()).toBeNull() + }) + + it('omits the zero half of a one-sided total', () => { + resetState({ + gitBranchLineTotalByWorktree: { + [mocks.activeWorktree.id]: { added: 42, removed: 0, mergeBase: MERGE_BASE } + } + }) + renderSourceControl() + + expect(chip()?.textContent).toBe('+42') + expect(chip()?.getAttribute('aria-label')).toBe('42 additions') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx index 8624d25fae2..d3125ee86c7 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx @@ -104,6 +104,7 @@ function resetState(overrides: Partial> = {}): void { gitStatusByWorktree: { [mocks.activeWorktree.id]: [] }, gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] }, gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: null }, + gitBranchLineTotalByWorktree: {}, gitConflictOperationByWorktree: {}, remoteStatusesByWorktree: {}, isRemoteOperationActive: false, diff --git a/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx index 22bc4954b20..13dc2479894 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx @@ -156,6 +156,7 @@ function resetState(overrides: Partial> = {}): void { gitStatusByWorktree: { [mocks.activeWorktree.id]: [] }, gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] }, gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: null }, + gitBranchLineTotalByWorktree: {}, gitConflictOperationByWorktree: {}, remoteStatusesByWorktree: {}, isRemoteOperationActive: false, diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 68b55f9bda7..13c94afebe4 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -111,8 +111,9 @@ import { useSourceControlSubmoduleStatus } from './useSourceControlSubmoduleStat import { buildSourceControlDisplaySections, getSourceControlSectionViewAction, - resolveSourceControlGroupOrder, + mergeUntrackedIntoChanges, SOURCE_CONTROL_AREAS, + SOURCE_CONTROL_GROUP_ORDER, type SourceControlDisplaySectionId, type SourceControlEntryGroups, type SourceControlSectionArea @@ -124,8 +125,8 @@ import { buildActiveOpenRowKeys } from './source-control-active-open-file-keys' import { + filterAndSortSourceControlPathEntries, filterSourceControlGroupedPathEntries, - filterSourceControlPathEntries, getSourceControlFileFilterState } from './source-control-file-filter' import { getCommitMessageTextareaRows } from './source-control-commit-message-rows' @@ -296,6 +297,7 @@ import { } from './source-control-hosted-review-push-target' import { buildSourceControlManualReviewUrlFromContext } from './source-control-manual-review-url' import { parseRemoteRepo } from './source-control-remote-repo' +import { setBranchLineTotalMergeBase } from './branch-line-total-request-gate' export { HostedReviewHeaderLink } from './hosted-review-header-chrome' import { createRunningCommitMessageGenerationRecord, @@ -643,6 +645,11 @@ type SourceControlDirectoryActionPaths = { stagePaths: string[] unstagePaths: string[] discardPaths: string[] + discardHasUntracked: boolean +} + +function discardAllAreaFilter(area: DiscardAllArea): DiscardAllArea | readonly DiscardAllArea[] { + return area === 'unstaged' ? (['unstaged', 'untracked'] as const) : area } function getSourceControlDirectoryActionPaths( @@ -654,8 +661,9 @@ function getSourceControlDirectoryActionPaths( unstagePaths: getUnstageAllPaths(entries), discardPaths: node.area === 'unstaged' || node.area === 'untracked' - ? getDiscardAllPaths(entries, node.area) - : [] + ? getDiscardAllPaths(entries, discardAllAreaFilter(node.area)) + : [], + discardHasUntracked: entries.some((entry) => entry.area === 'untracked') } } @@ -835,6 +843,15 @@ function SourceControlInner(): React.JSX.Element { const branchSummary = useAppStore((s) => activeWorktreeId ? (s.gitBranchCompareSummaryByWorktree[activeWorktreeId] ?? null) : null ) + const publishedBranchLineTotal = useAppStore((s) => + activeWorktreeId ? (s.gitBranchLineTotalByWorktree?.[activeWorktreeId] ?? null) : null + ) + // Why: status and branch compare refresh on different cadences, so a total can + // outlive the fork point it measured. Drop it rather than render a stale number. + const branchLineTotal = + publishedBranchLineTotal && publishedBranchLineTotal.mergeBase === branchSummary?.mergeBase + ? publishedBranchLineTotal + : null const conflictOperation = useAppStore((s) => activeWorktreeId ? (s.gitConflictOperationByWorktree[activeWorktreeId] ?? 'unknown') : 'unknown' ) @@ -1050,7 +1067,6 @@ function SourceControlInner(): React.JSX.Element { settings?.sourceControlViewMode ) const sourceControlViewMode = persistedSourceControlViewMode - const sourceControlGroupOrder = resolveSourceControlGroupOrder(settings?.sourceControlGroupOrder) const [collapsedTreeDirs, setCollapsedTreeDirs] = useState>(new Set()) const [baseRefDialogOpen, setBaseRefDialogOpen] = useState(false) const [pendingDiscard, setPendingDiscard] = useState(null) @@ -1252,6 +1268,22 @@ function SourceControlInner(): React.JSX.Element { // Why: the sidebar stays mounted when closed, so gate polling on tab AND open or branchCompare/PR fetch would run with no visible consumer. const isBranchVisible = rightSidebarTab === 'source-control' && rightSidebarOpen + // Why: the merge base IS the request gate — no OID on the status request means + // the host runs no ranged diff, so a hidden chip costs a background worktree nothing. + const requestedBranchLineTotalMergeBase = + isBranchVisible && !isFolder && branchSummary?.status === 'ready' + ? branchSummary.mergeBase + : null + useEffect(() => { + if (!activeWorktreeId) { + return + } + setBranchLineTotalMergeBase(activeWorktreeId, requestedBranchLineTotalMergeBase) + return () => { + setBranchLineTotalMergeBase(activeWorktreeId, null) + } + }, [activeWorktreeId, requestedBranchLineTotalMergeBase]) + const refreshActiveGitStatus = useCallback( async (signal?: AbortSignal): Promise => { if (!activeWorktreeId || !worktreePath || isFolder) { @@ -1770,13 +1802,19 @@ function SourceControlInner(): React.JSX.Element { [fileFilterState, grouped] ) + const mergedGrouped = useMemo(() => mergeUntrackedIntoChanges(grouped), [grouped]) + const mergedFilteredGrouped = useMemo( + () => mergeUntrackedIntoChanges(filteredGrouped), + [filteredGrouped] + ) + const displaySections = useMemo( - () => buildSourceControlDisplaySections(filteredGrouped, sourceControlGroupOrder), - [filteredGrouped, sourceControlGroupOrder] + () => buildSourceControlDisplaySections(mergedFilteredGrouped, SOURCE_CONTROL_GROUP_ORDER), + [mergedFilteredGrouped] ) const unfilteredDisplaySections = useMemo( - () => buildSourceControlDisplaySections(grouped, sourceControlGroupOrder), - [grouped, sourceControlGroupOrder] + () => buildSourceControlDisplaySections(mergedGrouped, SOURCE_CONTROL_GROUP_ORDER), + [mergedGrouped] ) const unfilteredDisplaySectionsById = useMemo( () => new Map(unfilteredDisplaySections.map((section) => [section.id, section])), @@ -1784,7 +1822,7 @@ function SourceControlInner(): React.JSX.Element { ) const filteredBranchEntries = useMemo( - () => filterSourceControlPathEntries(branchEntries, fileFilterState), + () => filterAndSortSourceControlPathEntries(branchEntries, fileFilterState), [branchEntries, fileFilterState] ) @@ -5374,7 +5412,9 @@ function SourceControlInner(): React.JSX.Element { if (!worktreePath || !activeWorktreeId || isExecutingBulk) { return } - const paths = confirmedPaths ? [...confirmedPaths] : getDiscardAllPaths(grouped[area], area) + const paths = confirmedPaths + ? [...confirmedPaths] + : getDiscardAllPaths(mergedGrouped[area], discardAllAreaFilter(area)) if (paths.length === 0) { return } @@ -5446,7 +5486,7 @@ function SourceControlInner(): React.JSX.Element { activeRepoSettings, worktreePath, activeWorktreeId, - grouped, + mergedGrouped, isExecutingBulk, clearSelection, discardMany, @@ -5460,13 +5500,21 @@ function SourceControlInner(): React.JSX.Element { if (!worktreePath || !activeWorktreeId || isExecutingBulk) { return } - const paths = confirmedPaths ? [...confirmedPaths] : getDiscardAllPaths(grouped[area], area) + const paths = confirmedPaths + ? [...confirmedPaths] + : getDiscardAllPaths(mergedGrouped[area], discardAllAreaFilter(area)) if (paths.length === 0) { return } - setPendingDiscard({ kind: 'area', area, paths }) + setPendingDiscard({ + kind: 'area', + area, + paths, + hasUntracked: + area === 'unstaged' && mergedGrouped.unstaged.some((entry) => entry.area === 'untracked') + }) }, - [activeWorktreeId, grouped, isExecutingBulk, worktreePath] + [activeWorktreeId, isExecutingBulk, mergedGrouped, worktreePath] ) const requestDiscardEntry = useCallback( @@ -5549,6 +5597,7 @@ function SourceControlInner(): React.JSX.Element { diffCommentCount={diffCommentCount} onExpandNotes={() => setDiffCommentsExpanded(true)} branchSummary={branchSummary} + branchLineTotal={branchLineTotal} compareBaseRef={compareBaseRef} headDisplay={gitIdentityDisplay} upstreamStatus={remoteStatus} @@ -5887,7 +5936,8 @@ function SourceControlInner(): React.JSX.Element { .filter(isStageableStatusEntry) .map((entry) => entry.path) const unstageAllPaths = getUnstageAllPaths(actionItems) - const discardAllPaths = getDiscardAllPaths(actionItems, area) + const discardAllPaths = getDiscardAllPaths(actionItems, discardAllAreaFilter(area)) + const discardHasUntracked = actionItems.some((entry) => entry.area === 'untracked') const canStageAll = !normalizedFilter && stageAllPaths.length > 0 const canUnstageAll = !normalizedFilter && unstageAllPaths.length > 0 const canRevertAll = !normalizedFilter && discardAllPaths.length > 0 @@ -5910,7 +5960,7 @@ function SourceControlInner(): React.JSX.Element {
{canRevertAll && ( { event.stopPropagation() @@ -6028,11 +6083,12 @@ function SourceControlInner(): React.JSX.Element { isExecutingBulk={isExecutingBulk} isCollapsed={collapsedTreeDirs.has(node.key)} onToggle={() => toggleTreeDir(node.key)} - onRequestDiscardPaths={(discardArea, paths) => + onRequestDiscardPaths={(discardArea, paths, hasUntracked) => setPendingDiscard({ kind: 'area', area: discardArea, - paths + paths, + hasUntracked }) } onStagePaths={handleStageAllPaths} @@ -7671,7 +7727,11 @@ function SourceControlTreeDirectoryRow({ isExecutingBulk: boolean isCollapsed: boolean onToggle: () => void - onRequestDiscardPaths: (area: DiscardAllArea, paths: readonly string[]) => void + onRequestDiscardPaths: ( + area: DiscardAllArea, + paths: readonly string[], + hasUntracked: boolean + ) => void onStagePaths: (paths: readonly string[]) => Promise onUnstagePaths: (paths: readonly string[]) => Promise }): React.JSX.Element { @@ -7710,21 +7770,30 @@ function SourceControlTreeDirectoryRow({
{canDiscard && ( { event.stopPropagation() - onRequestDiscardPaths(node.area, actionPaths.discardPaths) + onRequestDiscardPaths( + node.area, + actionPaths.discardPaths, + actionPaths.discardHasUntracked + ) }} disabled={isExecutingBulk} /> diff --git a/src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx index 7e1d4c7d2ec..5ff61b17d43 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx @@ -116,6 +116,7 @@ function resetState(overrides: Partial> = {}): void { gitStatusByWorktree: { [mocks.activeWorktree.id]: [] }, gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] }, gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: null }, + gitBranchLineTotalByWorktree: {}, gitConflictOperationByWorktree: {}, remoteStatusesByWorktree: {}, isRemoteOperationActive: false, diff --git a/src/renderer/src/components/right-sidebar/branch-line-total-request-gate.test.ts b/src/renderer/src/components/right-sidebar/branch-line-total-request-gate.test.ts new file mode 100644 index 00000000000..600a3cf8fc7 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/branch-line-total-request-gate.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + clearBranchLineTotalRequestGateForTests, + getBranchLineTotalMergeBase, + setBranchLineTotalMergeBase +} from './branch-line-total-request-gate' + +describe('branch line total request gate', () => { + beforeEach(() => { + clearBranchLineTotalRequestGateForTests() + }) + + it('reports no merge base for a worktree that never opened the chip', () => { + expect(getBranchLineTotalMergeBase('wt-never-gated')).toBeUndefined() + }) + + it('stores the merge base a visible chip asked for', () => { + setBranchLineTotalMergeBase('wt-1', 'merge-base-1') + + expect(getBranchLineTotalMergeBase('wt-1')).toBe('merge-base-1') + }) + + it('replaces the merge base when the fork point moves', () => { + setBranchLineTotalMergeBase('wt-1', 'merge-base-1') + setBranchLineTotalMergeBase('wt-1', 'merge-base-2') + + expect(getBranchLineTotalMergeBase('wt-1')).toBe('merge-base-2') + }) + + it('deletes the entry when the chip is hidden', () => { + setBranchLineTotalMergeBase('wt-1', 'merge-base-1') + setBranchLineTotalMergeBase('wt-1', null) + + expect(getBranchLineTotalMergeBase('wt-1')).toBeUndefined() + }) + + it('treats an empty merge base as no gate rather than an empty request param', () => { + setBranchLineTotalMergeBase('wt-1', 'merge-base-1') + setBranchLineTotalMergeBase('wt-1', '') + + expect(getBranchLineTotalMergeBase('wt-1')).toBeUndefined() + }) + + it('keeps entries independent per worktree', () => { + setBranchLineTotalMergeBase('wt-1', 'merge-base-1') + setBranchLineTotalMergeBase('wt-2', 'merge-base-2') + setBranchLineTotalMergeBase('wt-1', null) + + expect(getBranchLineTotalMergeBase('wt-1')).toBeUndefined() + expect(getBranchLineTotalMergeBase('wt-2')).toBe('merge-base-2') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/branch-line-total-request-gate.ts b/src/renderer/src/components/right-sidebar/branch-line-total-request-gate.ts new file mode 100644 index 00000000000..c3fa7b98369 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/branch-line-total-request-gate.ts @@ -0,0 +1,23 @@ +// Why: several independent code paths call `git.status`; if only some carried +// the merge base the branch line-total chip would blank out between polls. One +// registry keeps every path asking for the same thing. +// Only the visible worktree's SourceControl is mounted and its effect cleanup +// deletes on every change, so this holds ~1 entry and needs no eviction. +const branchLineTotalMergeBaseByWorktree = new Map() + +/** `null` deletes the entry — that absence is the visibility gate (no merge base ⇒ no host cost). */ +export function setBranchLineTotalMergeBase(worktreeId: string, mergeBase: string | null): void { + if (!mergeBase) { + branchLineTotalMergeBaseByWorktree.delete(worktreeId) + return + } + branchLineTotalMergeBaseByWorktree.set(worktreeId, mergeBase) +} + +export function getBranchLineTotalMergeBase(worktreeId: string): string | undefined { + return branchLineTotalMergeBaseByWorktree.get(worktreeId) +} + +export function clearBranchLineTotalRequestGateForTests(): void { + branchLineTotalMergeBaseByWorktree.clear() +} diff --git a/src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts b/src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts index e808af6da27..d8f76c48f3f 100644 --- a/src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts +++ b/src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts @@ -30,6 +30,15 @@ describe('getDiscardAllPaths', () => { expect(getDiscardAllPaths(entries, 'untracked')).toEqual(['c.ts']) }) + it('accepts mixed Changes areas when untracked files are combined', () => { + const entries: GitStatusEntry[] = [ + entry({ path: 'changed.ts', area: 'unstaged' }), + entry({ path: 'new.ts', area: 'untracked', status: 'untracked' }), + entry({ path: 'ready.ts', area: 'staged' }) + ] + expect(getDiscardAllPaths(entries, ['unstaged', 'untracked'])).toEqual(['changed.ts', 'new.ts']) + }) + it('skips entries with an unresolved conflict', () => { const entries: GitStatusEntry[] = [ entry({ path: 'clean.ts', area: 'unstaged' }), diff --git a/src/renderer/src/components/right-sidebar/discard-all-sequence.ts b/src/renderer/src/components/right-sidebar/discard-all-sequence.ts index c52854fcd91..073ba321a2a 100644 --- a/src/renderer/src/components/right-sidebar/discard-all-sequence.ts +++ b/src/renderer/src/components/right-sidebar/discard-all-sequence.ts @@ -9,12 +9,13 @@ export type DiscardAllArea = 'staged' | 'unstaged' | 'untracked' */ export function getDiscardAllPaths( entries: readonly GitStatusEntry[], - area: DiscardAllArea + area: DiscardAllArea | readonly DiscardAllArea[] ): string[] { + const areas = Array.isArray(area) ? area : [area] return entries .filter( (entry) => - entry.area === area && + areas.includes(entry.area) && entry.conflictStatus !== 'unresolved' && entry.conflictStatus !== 'resolved_locally' ) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.test.ts new file mode 100644 index 00000000000..a0e40f2ef12 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest' + +const readRuntimeDirectory = vi.fn() +vi.mock('@/runtime/runtime-file-client', () => ({ + readRuntimeDirectory: (...args: unknown[]) => readRuntimeDirectory(...args) +})) +vi.mock('./file-explorer-operation-owner', () => ({ + getFileExplorerOperationOwner: () => ({ kind: 'local' }), + getFileExplorerOperationRoute: () => ({ settings: null, connectionId: null }), + getFileExplorerOwnerUnresolvedMessage: () => 'unresolved' +})) + +import { readFileExplorerDirectory } from './file-explorer-directory-listing' + +describe('readFileExplorerDirectory', () => { + it('re-sorts backend order — remote-runtime and paired-web routes return the host order verbatim', async () => { + readRuntimeDirectory.mockResolvedValueOnce([ + { name: '100 - b.txt', isDirectory: false, isSymlink: false }, + { name: '9 - c.txt', isDirectory: false, isSymlink: false }, + { name: '10 - dir', isDirectory: true, isSymlink: false }, + { name: '99 - a.txt', isDirectory: false, isSymlink: false } + ]) + + const { entries } = await readFileExplorerDirectory('wt-1', '/w', '/w/dir') + expect(entries.map((e) => e.name)).toEqual([ + '10 - dir', + '9 - c.txt', + '99 - a.txt', + '100 - b.txt' + ]) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts b/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts index 13742226c9a..eb8f7142846 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts @@ -1,5 +1,6 @@ import { joinPath, normalizeRelativePath } from '@/lib/path' import type { DirEntry } from '../../../../shared/types' +import { sortDirEntries } from '../../../../shared/file-name-sort' import { readRuntimeDirectory } from '@/runtime/runtime-file-client' import type { FileExplorerOperationOwner, TreeNode } from './file-explorer-types' import { shouldIncludeFileExplorerEntry } from './file-explorer-entries' @@ -56,5 +57,8 @@ export async function readFileExplorerDirectory( }, dirPath ) - return { entries, operationOwner } + // Why: remote-runtime and paired-web routes return the host's order verbatim, + // and an older host may still sort lexicographically; re-sorting an already + // sorted local listing is near-free (adaptive sort). + return { entries: sortDirEntries(entries), operationOwner } } diff --git a/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts b/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts index ba057328924..9070472ed0c 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts @@ -1,5 +1,6 @@ import { joinPath, normalizeRelativePath } from '@/lib/path' import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' +import { compareFileNames } from '../../../../shared/file-name-sort' import type { FileExplorerOperationOwner, TreeNode } from './file-explorer-types' import { createFileExplorerRowProjectionFromParts, @@ -232,7 +233,7 @@ function appendNameFilteredEntries( if (a.node.isDirectory !== b.node.isDirectory) { return a.node.isDirectory ? -1 : 1 } - return a.node.name.localeCompare(b.node.name) + return compareFileNames(a.node.name, b.node.name) }) for (const entry of sortedEntries) { visibleFlatRows.push(entry.node) diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts b/src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts new file mode 100644 index 00000000000..3437d8d227a --- /dev/null +++ b/src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts @@ -0,0 +1,224 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + clearBranchLineTotalRequestGateForTests, + setBranchLineTotalMergeBase +} from './branch-line-total-request-gate' +import { + clearGitStatusRefreshOrderingForTests, + refreshGitStatusForWorktree, + refreshGitStatusForWorktreeStrict, + type GitStatusRefreshDeps +} from './git-status-refresh' +import type { GitStatusResult } from '../../../../shared/types' + +const MERGE_BASE = '1f3c0d9a5b6e7f8091a2b3c4d5e6f708192a3b4c' + +function makeDeps(): GitStatusRefreshDeps { + return { + setGitStatus: vi.fn(), + updateWorktreeGitIdentity: vi.fn(), + setUpstreamStatus: vi.fn(), + fetchUpstreamStatus: vi.fn().mockResolvedValue(null) + } +} + +function stubGitStatus(): ReturnType { + const status: GitStatusResult = { + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } + } + const gitStatus = vi.fn().mockResolvedValue(status) + vi.stubGlobal('window', { + api: { + git: { + status: gitStatus, + cancelStatus: vi.fn().mockResolvedValue(undefined), + upstreamStatus: vi.fn().mockResolvedValue({ hasUpstream: false, ahead: 0, behind: 0 }) + } + } + }) + return gitStatus +} + +describe('branch line total request gate on git status refreshes', () => { + beforeEach(() => { + vi.unstubAllGlobals() + clearGitStatusRefreshOrderingForTests() + clearBranchLineTotalRequestGateForTests() + }) + + it('omits the merge base when no chip is asking for a total', async () => { + const gitStatus = stubGitStatus() + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-hidden', + worktreePath: '/repo', + connectionId: 'ssh-1', + deps: makeDeps() + }) + + // Why: no OID on the request is the whole performance contract — the host + // runs no ranged diff, so a background worktree costs nothing. + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: 'ssh-1' + }) + }) + + it('omits the merge base for a worktree whose chip was hidden again', async () => { + const gitStatus = stubGitStatus() + setBranchLineTotalMergeBase('wt-1', MERGE_BASE) + setBranchLineTotalMergeBase('wt-1', null) + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-1', + worktreePath: '/repo', + deps: makeDeps() + }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: undefined + }) + }) + + it('does not send one worktree gate on another worktree status pass', async () => { + const gitStatus = stubGitStatus() + setBranchLineTotalMergeBase('wt-visible', MERGE_BASE) + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-background', + worktreePath: '/other-repo', + deps: makeDeps() + }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/other-repo', + connectionId: undefined + }) + }) + + it('sends the merge base on a plain refresh that passes no request options', async () => { + const gitStatus = stubGitStatus() + setBranchLineTotalMergeBase('wt-1', MERGE_BASE) + + // Why: this path used to build no options object at all, so the gate was + // silently dropped on the most common (fs-watcher) refresh. + await refreshGitStatusForWorktree({ + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: 'ssh-1', + deps: makeDeps() + }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: 'ssh-1', + branchLineTotalMergeBase: MERGE_BASE + }) + }) + + it('sends the merge base alongside line-stat reuse', async () => { + const gitStatus = stubGitStatus() + setBranchLineTotalMergeBase('wt-1', MERGE_BASE) + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-1', + worktreePath: '/repo', + deps: makeDeps(), + request: { reuseLineStats: true } + }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: undefined, + reuseLineStats: true, + branchLineTotalMergeBase: MERGE_BASE + }) + }) + + it('sends the merge base on an abortable refresh', async () => { + const gitStatus = stubGitStatus() + setBranchLineTotalMergeBase('wt-1', MERGE_BASE) + const controller = new AbortController() + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-1', + worktreePath: '/repo', + deps: makeDeps(), + request: { signal: controller.signal } + }) + + expect(gitStatus).toHaveBeenCalledWith( + expect.objectContaining({ + worktreePath: '/repo', + branchLineTotalMergeBase: MERGE_BASE + }) + ) + }) + + it('re-reads the gate on every pass so a moved fork point is not sent twice', async () => { + const gitStatus = stubGitStatus() + setBranchLineTotalMergeBase('wt-1', MERGE_BASE) + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-1', + worktreePath: '/repo', + deps: makeDeps() + }) + setBranchLineTotalMergeBase('wt-1', 'rebased-merge-base') + await refreshGitStatusForWorktree({ + worktreeId: 'wt-1', + worktreePath: '/repo', + deps: makeDeps() + }) + + expect(gitStatus).toHaveBeenNthCalledWith(1, { + worktreePath: '/repo', + connectionId: undefined, + branchLineTotalMergeBase: MERGE_BASE + }) + expect(gitStatus).toHaveBeenNthCalledWith(2, { + worktreePath: '/repo', + connectionId: undefined, + branchLineTotalMergeBase: 'rebased-merge-base' + }) + }) + + it('omits the merge base from a strict refresh when no chip is visible', async () => { + const gitStatus = stubGitStatus() + + await refreshGitStatusForWorktreeStrict({ + worktreeId: 'wt-strict', + worktreePath: '/repo', + deps: { ...makeDeps(), fetchUpstreamStatus: undefined } + }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: undefined, + bypassEffectiveUpstreamNegativeCache: true + }) + }) + + // Why: a strict refresh is exactly the commit/push/sync that moves the number; + // skipping the gate there would blank the chip until the next automatic poll. + it('sends the merge base on a strict refresh', async () => { + const gitStatus = stubGitStatus() + setBranchLineTotalMergeBase('wt-strict', MERGE_BASE) + + await refreshGitStatusForWorktreeStrict({ + worktreeId: 'wt-strict', + worktreePath: '/repo', + deps: { ...makeDeps(), fetchUpstreamStatus: undefined } + }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: undefined, + bypassEffectiveUpstreamNegativeCache: true, + branchLineTotalMergeBase: MERGE_BASE + }) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.ts index 596a68f8df3..50f2cf068c7 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.ts @@ -1,4 +1,5 @@ import { getRuntimeGitStatus, getRuntimeGitUpstreamStatus } from '@/runtime/runtime-git-client' +import { getBranchLineTotalMergeBase } from './branch-line-total-request-gate' import { clearAutomaticPushTargetUpstreamStatusCache, getCachedAutomaticPushTargetUpstreamStatus, @@ -121,6 +122,9 @@ export async function refreshGitStatusForWorktree({ } }): Promise { const refreshOrder = beginAutomaticUpstreamRefresh(worktreeId) + // Why: every status path must carry the merge base, or the chip blanks out on + // whichever poll happened to omit it. + const branchLineTotalMergeBase = getBranchLineTotalMergeBase(worktreeId) try { const status = (await getRuntimeGitStatus( { @@ -129,12 +133,11 @@ export async function refreshGitStatusForWorktree({ worktreePath, connectionId }, - request - ? { - ...(request.reuseLineStats === true ? { reuseLineStats: true } : {}), - ...(request.signal ? { signal: request.signal } : {}) - } - : undefined + { + ...(request?.reuseLineStats === true ? { reuseLineStats: true } : {}), + ...(request?.signal ? { signal: request.signal } : {}), + ...(branchLineTotalMergeBase ? { branchLineTotalMergeBase } : {}) + } )) as GitStatusResult if (!claimAutomaticUpstreamRefreshApply(worktreeId, refreshOrder, request?.shouldApply)) { @@ -247,6 +250,7 @@ export async function refreshGitStatusForWorktreeStrict({ }): Promise<{ status: GitStatusResult; upstreamStatus: GitUpstreamStatus }> { beginStrictUpstreamRefresh(worktreeId) clearAutomaticPushTargetUpstreamStatusCache() + const strictBranchLineTotalMergeBase = getBranchLineTotalMergeBase(worktreeId) const status = (await getRuntimeGitStatus( { settings, @@ -257,7 +261,12 @@ export async function refreshGitStatusForWorktreeStrict({ { // Why: strict refreshes are user-triggered reconciliation and must not reuse // automatic polling's no-upstream backoff window. - bypassEffectiveUpstreamNegativeCache: true + bypassEffectiveUpstreamNegativeCache: true, + // Why: an absent total clears the chip, so a path that skipped the gate + // would blank it right after the commit or push that moved the number. + ...(strictBranchLineTotalMergeBase + ? { branchLineTotalMergeBase: strictBranchLineTotalMergeBase } + : {}) } )) as GitStatusResult diff --git a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx index 4a454224997..0172dab5dd0 100644 --- a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react' import { describe, expect, it, vi } from 'vitest' import { SourceControlBranchContextRow } from './source-control-branch-context-row' import type { GitBranchCompareSummary } from '../../../../shared/types' +import type { GitBranchLineTotal } from '../../../../shared/git-status-types' vi.mock('@/components/ui/tooltip', () => ({ Tooltip: ({ children }: { children: ReactNode }) => <>{children}, @@ -215,3 +216,144 @@ describe('SourceControlBranchContextRow', () => { expect(markup).toContain('aria-label="Open review page in browser"') }) }) + +function renderWithLineTotal( + branchLineTotal: GitBranchLineTotal | null | undefined, + summary: GitBranchCompareSummary | null = readySummary +): string { + return renderToStaticMarkup( + + ) +} + +describe('SourceControlBranchContextRow branch line total', () => { + it('renders both halves with grouped digits and a spoken label', () => { + const markup = renderWithLineTotal({ added: 8259, removed: 670, mergeBase: 'base' }) + + expect(markup).toContain('+8,259') + expect(markup).toContain('-670') + // Label reads raw digits; the grouped spans are decoration. + expect(markup).toContain('aria-label="8259 additions, 670 deletions"') + expect(markup).toContain('tabular-nums') + expect(markup).toContain('text-[color:var(--git-decoration-added)]') + expect(markup).toContain('text-[color:var(--git-decoration-deleted)]') + // Not clickable in v1 — the chip's scope differs from openBranchAllDiffs. + expect(markup).not.toContain(' ) : null, - matchesSettingsSearch(searchQuery, { - title: translate( - 'auto.components.settings.GitPane.sourceControlGroupOrderTitle', - 'Source Control Group Order' - ), - description: translate( - 'auto.components.settings.GitPane.sourceControlGroupOrderDescription', - 'Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.' - ), - keywords: SOURCE_CONTROL_GROUP_ORDER_KEYWORDS - }) ? ( - - ) : null, compareAgainstUpstreamMatchesSearch(searchQuery) ? ( [ ...translateSearchKeyword('auto.components.settings.git.search.035134fcd9', 'worktree') ] }, - { - title: translate( - 'auto.components.settings.git.search.sourceControlGroupOrderTitle', - 'Source Control Group Order' - ), - description: translate( - 'auto.components.settings.git.search.sourceControlGroupOrderDescription', - 'Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.' - ), - keywords: [ - ...translateSearchKeyword('auto.components.settings.git.search.groupOrder', 'group order'), - ...translateSearchKeyword( - 'auto.components.settings.git.search.changesFirst', - 'changes first' - ), - ...translateSearchKeyword('auto.components.settings.git.search.stagedFirst', 'staged first'), - ...translateSearchKeyword( - 'auto.components.settings.git.search.untrackedFirst', - 'untracked first' - ), - ...translateSearchKeyword( - 'auto.components.settings.git.search.sourceControl', - 'source control' - ), - ...translateSearchKeyword('auto.components.settings.git.search.gitChanges', 'git changes') - ] - }, { title: translate( 'auto.components.settings.git.search.compareAgainstUpstreamTitle', diff --git a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx index a219841b949..fef27ea71cc 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx @@ -60,7 +60,7 @@ vi.mock('@/components/ui/tooltip', () => ({ })) vi.mock('./use-worktree-activity-status', () => ({ - useWorktreeActivityStatus: () => 'inactive' + useWorktreeActivityStatus: () => 'active' })) vi.mock('./CacheTimer', () => ({ @@ -171,8 +171,8 @@ describe('WorktreeCard linked PR display', () => { ) - expect(markup).toContain('Inactive') - expect(markup).toContain('bg-neutral-500/40') + expect(markup).toContain('Active') + expect(markup).toContain('bg-emerald-500') expect(markup).not.toContain('PR: Open') expect(markup).not.toContain('Linked PR #456') }, 20_000) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx index 54322d38ffc..7456cce2faa 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx @@ -21,7 +21,6 @@ let tabsByWorktree: Record = {} let ptyIdsByTabId: Record = {} let browserTabsByWorktree: Record = {} let settings: Partial | null = null -let activityStatus = 'idle' let projectGroups: unknown[] = [] let workspaceDeleteModifierPressed = false let gitConflictOperationByWorktree: Record = {} @@ -61,7 +60,7 @@ vi.mock('@/components/ui/tooltip', () => ({ })) vi.mock('./use-worktree-activity-status', () => ({ - useWorktreeActivityStatus: () => activityStatus + useWorktreeActivityStatus: () => 'idle' })) vi.mock('./CacheTimer', () => ({ @@ -145,7 +144,6 @@ describe('WorktreeCard quick actions', () => { projectGroups = [] workspaceDeleteModifierPressed = false gitConflictOperationByWorktree = {} - activityStatus = 'idle' }) it('marks the unread toggle as a workspace-board-preserving action', () => { @@ -254,19 +252,6 @@ describe('WorktreeCard quick actions', () => { expect(markup).not.toContain('lucide-git-branch') }) - it('keeps the quiet status dot when the branch card property is off', () => { - settings = { experimentalNewWorktreeCardStyle: true } - worktreeCardProperties = ['status'] - activityStatus = 'inactive' - - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('bg-neutral-500/40') - expect(markup).not.toContain('lucide-git-branch') - }) - it('does not render a pending first-agent rename title badge', () => { const markup = renderToStaticMarkup(
) : null} diff --git a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx index 8f204527c9a..8911c1783c5 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx @@ -192,7 +192,7 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).not.toContain('PR checks: Failed') }) - it('keeps the active dot ahead of PR status when new card style is on', () => { + it('uses PR status instead of the quiet active dot when new card style is on', () => { const markup = renderToStaticMarkup( { /> ) - expect(markup).toContain('Active') + expect(markup).toContain('PR checks: Failed') expect(markup).toContain('inline-flex size-5 items-center justify-center') - expect(markup).toContain('bg-emerald-500') - expect(markup).not.toContain('PR checks: Failed') - }) - - it('keeps the emerald activity dot ahead of branch identity when new card style is on', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Active') - expect(markup).toContain('bg-emerald-500') - expect(markup).not.toContain('lucide-git-branch') + expect(markup).toContain('size-[13px] translate-x-px') + expect(markup).toContain('text-rose-500/85') + expect(markup).not.toContain('bg-emerald-500') }) it('uses the unified compact review glyph for GitLab MR status', () => { - mocks.status = 'inactive' const markup = renderToStaticMarkup( { expect(markup).not.toContain('lucide-git-merge') }) - it('keeps the done dot ahead of PR status when new card style is on', () => { + it('uses PR status instead of the quiet done dot when new card style is on', () => { mocks.status = 'done' const markup = renderToStaticMarkup( { /> ) - expect(markup).toContain('Done') - expect(markup).toContain('bg-emerald-500') - expect(markup).not.toContain('PR checks: Failed') + expect(markup).toContain('PR checks: Failed') + expect(markup).not.toContain('bg-emerald-500') }) it('uses PR status instead of the inactive dot when new card style is on', () => { @@ -298,8 +277,7 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).not.toContain('bg-neutral-500/40') }) - it('uses a branch icon with branch-only tooltip copy on quiet rows', () => { - mocks.status = 'inactive' + it('uses a branch icon with branch-only tooltip copy by default', () => { const markup = renderToStaticMarkup( { }) it('uses context-aware branch or folder path tooltip copy', () => { - mocks.status = 'inactive' const markup = renderToStaticMarkup( { }) it('overlays an unread badge on PR status when new card style is on', () => { - mocks.status = 'inactive' const markup = renderToStaticMarkup( { }) it('overlays an unread badge on the branch icon in new card style', () => { - mocks.status = 'inactive' const markup = renderToStaticMarkup( (['inactive']) +const QUIET_REVIEW_REPLACEABLE_STATUSES = new Set(['active', 'done', 'inactive']) // Why: a missing review display can also mean provider state is unavailable, // so the passive label names the identity cue without claiming no review exists. function getDefaultBranchIdentityLabel(): string { @@ -104,13 +103,16 @@ export function WorktreeCardStatusSlot({ const status = useWorktreeActivityStatus(worktreeId) const statusLabel = getWorktreeStatusLabel(status) || status const canShowReviewStatus = - newCardStyle && showStatus && prDisplay !== null && IDENTITY_REPLACEABLE_STATUSES.has(status) + newCardStyle && + showStatus && + prDisplay !== null && + QUIET_REVIEW_REPLACEABLE_STATUSES.has(status) const canShowBranchStatus = newCardStyle && showStatus && hasBranchIdentity && prDisplay === null && - IDENTITY_REPLACEABLE_STATUSES.has(status) + QUIET_REVIEW_REPLACEABLE_STATUSES.has(status) const passiveStatusLabel = canShowReviewStatus && prDisplay ? getReviewStatusTooltip(prDisplay) diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts index 959a8069db3..cd3cfd99999 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts @@ -542,6 +542,19 @@ describe('startParkedTerminalByteWatcher', () => { dispose() }) + // Why: retained gauges would inflate every later high-water profile. + it('dispose drops the processor from the pty side-effect census', async () => { + await import('./pty-side-effect-pending-census') + const { collectRendererMemoryProfileCounts } = await import('@/lib/renderer-memory-profile') + expect(collectRendererMemoryProfileCounts()['ptySideEffects.processors']).toBe(0) + + const { dispose } = await startWatcher() + expect(collectRendererMemoryProfileCounts()['ptySideEffects.processors']).toBe(1) + + dispose() + expect(collectRendererMemoryProfileCounts()['ptySideEffects.processors']).toBe(0) + }) + it('disposes the previous watcher when a new one starts for the same PTY', async () => { await startWatcher({ paneId: 1 }) const second = await startWatcher({ paneId: 2 }) diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts index faa9601cbfa..42dff141d34 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts @@ -303,6 +303,9 @@ export function startParkedTerminalByteWatcher( return } disposed = true + // Why first: each park/reveal cycle owns a distinct processor gauge, and the store/IPC + // teardown below must not be able to throw its way past the census drop. + processor?.disposePendingSideEffectGauge() // Why: unhide BEFORE the reveal remount registers pane handlers, so main resumes delivery and emits the restore marker the pane consumes. releaseHiddenDeliveryClaim?.() stopMode2031Responder?.() diff --git a/src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts b/src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts new file mode 100644 index 00000000000..eb9a5724d52 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.test.ts @@ -0,0 +1,130 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { collectRendererMemoryProfileCounts } from '@/lib/renderer-memory-profile' +import { registerPtySideEffectPendingGauge } from './pty-side-effect-pending-census' + +function ptySideEffectCounts(): { pending: number; retained: number; processors: number } { + const counts = collectRendererMemoryProfileCounts() + return { + pending: counts['ptySideEffects.pending'], + retained: counts['ptySideEffects.retained'], + processors: counts['ptySideEffects.processors'] + } +} + +function fixedGauge(pending: number, retained = pending): { dispose: () => void } { + // Why the local: the census holds gauges weakly, so a test gauge needs a strong owner too. + const gauge = { pending: () => pending, retained: () => retained } + return { dispose: registerPtySideEffectPendingGauge(gauge) } +} + +describe('pty side-effect pending census', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('sums registered gauges and drops disposed ones', () => { + const before = ptySideEffectCounts() + const a = fixedGauge(3, 5) + const b = fixedGauge(4, 4) + + let counts = ptySideEffectCounts() + expect(counts.pending).toBe(before.pending + 7) + expect(counts.retained).toBe(before.retained + 9) + expect(counts.processors).toBe(before.processors + 2) + + a.dispose() + counts = ptySideEffectCounts() + expect(counts.pending).toBe(before.pending + 4) + expect(counts.retained).toBe(before.retained + 4) + expect(counts.processors).toBe(before.processors + 1) + + b.dispose() + expect(ptySideEffectCounts()).toEqual(before) + }) + + it('ignores a repeated dispose instead of dropping another gauge', () => { + const before = ptySideEffectCounts() + const a = fixedGauge(3) + const b = fixedGauge(4) + + a.dispose() + a.dispose() + + expect(ptySideEffectCounts().processors).toBe(before.processors + 1) + b.dispose() + expect(ptySideEffectCounts()).toEqual(before) + }) + + it('stops tracking new gauges past the cap so a missed dispose cannot grow it', () => { + const before = ptySideEffectCounts() + const owners = Array.from({ length: 600 }, () => fixedGauge(1)) + try { + expect(ptySideEffectCounts().processors).toBe(512) + } finally { + for (const owner of owners) { + owner.dispose() + } + } + expect(ptySideEffectCounts()).toEqual(before) + }) + + it('tracks a live output processor queue through enqueue, drain, and dispose', async () => { + vi.useFakeTimers() + const { createPtyOutputProcessor } = await import('./pty-transport') + const before = ptySideEffectCounts() + const processor = createPtyOutputProcessor({ onTitleChange: vi.fn() }) + + expect(ptySideEffectCounts()).toEqual({ + pending: before.pending, + retained: before.retained, + processors: before.processors + 1 + }) + + processor.processData('\x1b]0;census-title\x07', { onData: vi.fn() }) + expect(ptySideEffectCounts().pending).toBe(before.pending + 1) + + await vi.runOnlyPendingTimersAsync() + expect(ptySideEffectCounts().pending).toBe(before.pending) + + processor.processData('\x1b]0;census-title-2\x07', { onData: vi.fn() }) + expect(ptySideEffectCounts().pending).toBe(before.pending + 1) + processor.clearAccumulatedState() + expect(ptySideEffectCounts().pending).toBe(before.pending) + + processor.disposePendingSideEffectGauge() + expect(ptySideEffectCounts()).toEqual(before) + }) + + // Why: a partly drained queue still holds every entry until compaction, so depth alone + // would understate the retained bytes this census exists to attribute. + it('reports drained-but-uncompacted entries as retained after a bounded drain', async () => { + vi.useFakeTimers() + const { createPtyOutputProcessor } = await import('./pty-transport') + const before = ptySideEffectCounts() + const processor = createPtyOutputProcessor({ onTitleChange: vi.fn() }) + + for (let i = 0; i < 100; i += 1) { + processor.processData(`\x1b]0;census-title-${i}\x07`, { onData: vi.fn() }) + } + expect(ptySideEffectCounts()).toEqual({ + pending: before.pending + 100, + retained: before.retained + 100, + processors: before.processors + 1 + }) + + // One drain applies MAX_PTY_SIDE_EFFECTS_PER_DRAIN entries but stays under the compaction threshold. + await vi.runOnlyPendingTimersAsync() + expect(ptySideEffectCounts().pending).toBe(before.pending + 36) + expect(ptySideEffectCounts().retained).toBe(before.retained + 100) + + processor.flushPendingSideEffects() + expect(ptySideEffectCounts()).toEqual({ + pending: before.pending, + retained: before.retained, + processors: before.processors + 1 + }) + + processor.disposePendingSideEffectGauge() + expect(ptySideEffectCounts()).toEqual(before) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.ts b/src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.ts new file mode 100644 index 00000000000..53f1f98c4e2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-side-effect-pending-census.ts @@ -0,0 +1,65 @@ +import { registerRendererMemoryProfileContributor } from '@/lib/renderer-memory-profile' + +/** Queue counts for one PTY output processor, read at heap-highwater time. */ +export type PtySideEffectGauge = { + /** Entries still awaiting apply; saturates at MAX_PENDING_PTY_SIDE_EFFECTS. */ + pending: () => number + /** Entries the queue array still holds — drained-but-uncompacted ones included, so this is the count that tracks retained bytes. */ + retained: () => number +} + +// Why weak: a diagnostic for leaks must not be able to cause one. The owning processor holds +// the only strong reference, so a teardown path that forgets to dispose strands an empty +// WeakRef here instead of pinning the processor's whole closure (callbacks, tracker, terminal). +const gaugeRefs = new Set>() + +// Why: bounds this module even if registrations outrun both disposal and collection. +const MAX_TRACKED_GAUGES = 512 + +function pruneCollectedGauges(): void { + for (const ref of gaugeRefs) { + if (ref.deref() === undefined) { + gaugeRefs.delete(ref) + } + } +} + +export function registerPtySideEffectPendingGauge(gauge: PtySideEffectGauge): () => void { + if (gaugeRefs.size >= MAX_TRACKED_GAUGES) { + pruneCollectedGauges() + } + if (gaugeRefs.size >= MAX_TRACKED_GAUGES) { + return () => undefined + } + gaugeRefs.add(new WeakRef(gauge)) + // Why delete by identity: this closure is handed to the processor, so closing over `gauge` + // is what makes the processor — never this module — the gauge's strong owner. + return () => { + for (const ref of gaugeRefs) { + if (ref.deref() === gauge) { + gaugeRefs.delete(ref) + return + } + } + } +} + +// Why processor count is the signal: each queue is capped at MAX_PENDING_PTY_SIDE_EFFECTS and +// evicts oldest-first, so one processor can never grow without bound. Aggregate growth means +// processors outliving their panes; queues parked at the cap mean a drain starved by throttling. +registerRendererMemoryProfileContributor('ptySideEffects', () => { + let pending = 0 + let retained = 0 + let processors = 0 + for (const ref of gaugeRefs) { + const gauge = ref.deref() + if (!gauge) { + gaugeRefs.delete(ref) + continue + } + pending += gauge.pending() + retained += gauge.retained() + processors += 1 + } + return { pending, retained, processors } +}) diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 3833468ed0d..8c82e7584a1 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -183,6 +183,9 @@ export type PtyTransport = { scrollbackRows?: number }) => Promise preserve?: () => void + /** Hand the live PTY to a successor without process teardown. Terminal for this instance: + * it also drops the transport's output processor from the pty side-effect memory census, + * so a reattached one would run untracked. Create a new transport instead. */ detach?: (options?: { preserveExitObserver?: boolean }) => void destroy?: () => void | Promise } diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index c9973b82311..72b340d2620 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -118,6 +118,43 @@ describe('createIpcPtyTransport', () => { expect(kill).not.toHaveBeenCalled() }) + // Why: retained gauges would inflate every later high-water profile. + it.each(['detach', 'destroy'] as const)( + 'drops its side-effect gauge from the census on %s', + async (teardown) => { + await import('./pty-side-effect-pending-census') + const { collectRendererMemoryProfileCounts } = await import('@/lib/renderer-memory-profile') + const { createIpcPtyTransport } = await import('./pty-transport') + expect(collectRendererMemoryProfileCounts()['ptySideEffects.processors']).toBe(0) + + const transport = createIpcPtyTransport({}) + await transport.connect({ url: '', callbacks: {} }) + expect(collectRendererMemoryProfileCounts()['ptySideEffects.processors']).toBe(1) + + transport[teardown]?.() + + expect(collectRendererMemoryProfileCounts()['ptySideEffects.processors']).toBe(0) + } + ) + + // Why: teardown that already failed is exactly when a stranded gauge would pin the processor. + it('drops its side-effect gauge even when destroy throws mid-disconnect', async () => { + await import('./pty-side-effect-pending-census') + const { collectRendererMemoryProfileCounts } = await import('@/lib/renderer-memory-profile') + const { createIpcPtyTransport } = await import('./pty-transport') + const kill = window.api.pty.kill as unknown as ReturnType + const transport = createIpcPtyTransport({}) + await transport.connect({ url: '', callbacks: {} }) + expect(collectRendererMemoryProfileCounts()['ptySideEffects.processors']).toBe(1) + + kill.mockImplementationOnce(() => { + throw new Error('ipc channel closed') + }) + + expect(() => transport.destroy?.()).toThrow('ipc channel closed') + expect(collectRendererMemoryProfileCounts()['ptySideEffects.processors']).toBe(0) + }) + it('retires an adopted PTY when recovery disconnects before a replacement spawn', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const spawn = window.api.pty.spawn as unknown as ReturnType diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index d612b13cc8f..95528b43070 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -43,6 +43,10 @@ import { type ProcessedAgentStatusChunk } from '../../../../shared/agent-status-osc' import { extractIpcErrorMessage } from '@/lib/ipc-error' +import { + registerPtySideEffectPendingGauge, + type PtySideEffectGauge +} from './pty-side-effect-pending-census' import { isTuiAgent } from '../../../../shared/tui-agent-config' // Re-export public API so existing consumers keep working. @@ -153,6 +157,7 @@ export function createPtyOutputProcessor({ flushPendingSideEffects: () => void resetBellDetector: () => void resetAgentStatusCarry: () => void + disposePendingSideEffectGauge: () => void } { const bellDetector = createBellDetector() // Why let: a model-restore marker drops bytes; recreating the parser stops a partial OSC-9999 carry from swallowing the next chunk's head. @@ -165,6 +170,12 @@ export function createPtyOutputProcessor({ let pendingSideEffects: PendingPtySideEffect[] = [] let pendingSideEffectIndex = 0 let pendingWorkingTitleSideEffects = 0 + // Why both counts: drained entries survive until compaction, so depth alone understates what the array retains. + const pendingSideEffectGauge: PtySideEffectGauge = { + pending: () => pendingSideEffects.length - pendingSideEffectIndex, + retained: () => pendingSideEffects.length + } + const disposePendingSideEffectGauge = registerPtySideEffectPendingGauge(pendingSideEffectGauge) const agentTracker = onAgentBecameIdle || onAgentBecameWorking || onAgentExited ? createAgentStatusTracker( @@ -507,7 +518,8 @@ export function createPtyOutputProcessor({ resetBellDetector: () => bellDetector.reset(), resetAgentStatusCarry: () => { processAgentStatusChunk = createAgentStatusOscProcessor() - } + }, + disposePendingSideEffectGauge } } @@ -989,6 +1001,9 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra }, detach(options) { + // Why first: the successor transport owns the PTY after detach, and nothing below may + // throw its way past the census drop — a stranded gauge outlives the transport. + outputProcessor.disposePendingSideEffectGauge() clearAccumulatedState() inputWriteQueue.clear() if (ptyId) { @@ -1086,7 +1101,13 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra destroy() { destroyed = true - this.disconnect() + // Why finally: disconnect runs pty.kill IPC and consumer onDisconnect callbacks; a throw + // there must not strand the gauge in the very path where teardown already went wrong. + try { + this.disconnect() + } finally { + outputProcessor.disposePendingSideEffectGauge() + } } } } diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index 1b82b9850ad..0292d0b1749 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -388,6 +388,27 @@ describe('createRemoteRuntimePtyTransport', () => { transport.destroy?.() }) + // Why: retained gauges would inflate every later high-water profile. + it.each(['detach', 'destroy'] as const)( + 'drops its side-effect gauge from the census on %s', + async (teardown) => { + await import('./pty-side-effect-pending-census') + const { collectRendererMemoryProfileCounts } = await import('@/lib/renderer-memory-profile') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + expect(collectRendererMemoryProfileCounts()['ptySideEffects.processors']).toBe(0) + + const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1' }) + transport.attach({ existingPtyId: 'remote:terminal-1', callbacks: {} }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + expect(collectRendererMemoryProfileCounts()['ptySideEffects.processors']).toBe(1) + + transport[teardown]?.() + + expect(collectRendererMemoryProfileCounts()['ptySideEffects.processors']).toBe(0) + transport.destroy?.() + } + ) + it('recovers when the first restored-terminal subscription attempt is offline', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index e0322101e59..d34bd384511 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -2291,6 +2291,9 @@ export function createRemoteRuntimePtyTransport( }, detach() { + // Why first: the successor transport owns the PTY after detach, and the batcher flushes + // below can throw past the census drop — a stranded gauge outlives the transport. + outputProcessor.disposePendingSideEffectGauge() lifecycleEpoch += 1 attachGeneration += 1 cancelTerminalCreateRetryWait() @@ -2516,7 +2519,13 @@ export function createRemoteRuntimePtyTransport( destroy() { destroyed = true setAttachmentUnavailable() - this.disconnect() + // Why finally: disconnect runs consumer onDisconnect/onPtyExit callbacks; a throw there + // must not strand the gauge in the very path where teardown already went wrong. + try { + this.disconnect() + } finally { + outputProcessor.disposePendingSideEffectGauge() + } recovery.dispose() inputBatcher.clear() viewportBatcher.clear() diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c4913683d8e..3f940076736 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6183,11 +6183,6 @@ "3d172725cc": "None", "1f32ba27a6": "Custom", "a182c5125e": "Git Username", - "sourceControlGroupOrderTitle": "Source Control Group Order", - "sourceControlGroupOrderDescription": "Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.", - "changesFirst": "Changes first", - "stagedFirst": "Staged first", - "untrackedFirst": "Untracked first", "compareAgainstUpstreamTitle": "Default Compare Base", "compareAgainstUpstreamDescription": "Choose which base Source Control uses by default for committed-change comparisons. Branch upstream follows the current branch automatically and falls back to the repository default branch when no upstream exists. You can still change the compare base per worktree from that worktree's Git panel. Pull Request and rebase targets don't change.", "compareBaseRepositoryDefault": "Repository default", @@ -8463,14 +8458,7 @@ "f83c8937c4": "branch naming", "5ecd91c5ef": "Prefix added to branch names when creating worktrees.", "68bd65fdb8": "Branch Prefix", - "sourceControlGroupOrderTitle": "Source Control Group Order", - "sourceControlGroupOrderDescription": "Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.", - "groupOrder": "group order", - "changesFirst": "changes first", - "stagedFirst": "staged first", - "untrackedFirst": "untracked first", "sourceControl": "source control", - "gitChanges": "git changes", "compareAgainstUpstreamTitle": "Default Compare Base", "compareAgainstUpstreamDescription": "Choose which base Source Control uses by default for committed-change comparisons. Branch upstream follows the current branch automatically and falls back to the repository default branch when no upstream exists. You can still change the compare base per worktree from that worktree's Git panel. Pull Request and rebase targets don't change.", "compareBase": "compare base", @@ -10805,6 +10793,8 @@ "a4e93c21d7": "Current branch: {{value0}}", "c7d4e2f801": "Change base ref: {{value0}}", "f3a1b8c204": "upstream", + "discardAllMixedUntracked": "Discard changes and delete untracked files", + "discardFolderMixedUntracked": "Discard changes and delete untracked files in folder", "createPrIntentEmptyGeneratedBody": "Generated review details did not include a description. Retry Create PR.", "createPrIntentGenerateDetailsFailed": "Could not generate review details. Retry Create PR." }, @@ -11230,7 +11220,10 @@ "40e9357b2a": "This will restore the file from HEAD and discard the deletion. This cannot be undone.", "5c0bdbc4cb": "Restore \"{{value0}}\"?", "d97bf697c9": "This will permanently delete this file. This cannot be undone.", - "96c772bee9": "Delete \"{{value0}}\"?" + "96c772bee9": "Delete \"{{value0}}\"?", + "mixedTitle": "Discard changes and delete untracked files?", + "mixedDescription": "Tracked changes will be reverted and untracked files will be permanently deleted. This cannot be undone.", + "mixedConfirm": "Delete and discard" }, "dialog": { "3bc61dc989": "Cancel", @@ -11299,6 +11292,17 @@ "8f9a0b1c2d": "Nothing to commit. Branch is up to date.", "h3i4j5k607": "Checking whether this branch can create a {{value0}}…" } + }, + "branch": { + "line": { + "total": { + "chip": { + "daa8e8e59b": "{{value0}} additions, {{value1}} deletions", + "8a9b97b666": "{{value0}} additions", + "52c366d88d": "{{value0}} deletions" + } + } + } } } }, @@ -14856,7 +14860,7 @@ "other": "Other…", "otherPlaceholder": "Type your answer", "cancel": "Cancel", - "send": "Send answer", + "send": "Submit", "next": "Next", "skip": "Skip", "sending": "Sending…" diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 68a9bcb8fbd..33b14ec6fa7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -5966,11 +5966,6 @@ "3d172725cc": "Ninguno", "1f32ba27a6": "Personalizado", "a182c5125e": "Usuario de Git", - "sourceControlGroupOrderTitle": "Orden de grupos de control de código fuente", - "sourceControlGroupOrderDescription": "Elige si Cambios, Cambios preparados o Archivos sin seguimiento aparecen primero en Control de código fuente.", - "changesFirst": "Cambios primero", - "stagedFirst": "Cambios preparados primero", - "untrackedFirst": "Archivos sin seguimiento primero", "compareAgainstUpstreamTitle": "Base de comparación predeterminada", "compareAgainstUpstreamDescription": "Elige qué base usa Control de código fuente de forma predeterminada para las comparaciones de cambios confirmados. El upstream de la rama sigue automáticamente la rama actual y recurre a la rama predeterminada del repositorio cuando no hay upstream. Aun así, puedes cambiar la base de comparación por worktree desde el panel Git de ese worktree. Los objetivos de Pull Request y rebase no cambian.", "compareBaseRepositoryDefault": "Predeterminada del repositorio", @@ -8236,14 +8231,7 @@ "f83c8937c4": "nombres de ramas", "5ecd91c5ef": "Prefijo agregado a los nombres de ramas al crear worktrees.", "68bd65fdb8": "Prefijo de rama", - "sourceControlGroupOrderTitle": "Orden de grupos de control de código fuente", - "sourceControlGroupOrderDescription": "Elige si Cambios, Cambios preparados o Archivos sin seguimiento aparecen primero en Control de código fuente.", - "groupOrder": "orden de grupos", - "changesFirst": "cambios primero", - "stagedFirst": "cambios preparados primero", - "untrackedFirst": "archivos sin seguimiento primero", "sourceControl": "control de código fuente", - "gitChanges": "cambios de git", "compareAgainstUpstreamTitle": "Base de comparación predeterminada", "compareAgainstUpstreamDescription": "Elige qué base usa Control de código fuente de forma predeterminada para las comparaciones de cambios confirmados. El upstream de la rama sigue automáticamente la rama actual y recurre a la rama predeterminada del repositorio cuando no hay upstream. Aun así, puedes cambiar la base de comparación por worktree desde el panel Git de ese worktree. Los objetivos de Pull Request y rebase no cambian.", "compareBase": "base de comparación", @@ -14526,7 +14514,7 @@ "other": "Otro…", "otherPlaceholder": "Escribe tu respuesta", "cancel": "Cancelar", - "send": "Enviar respuesta", + "send": "Enviar", "next": "Siguiente", "skip": "Omitir", "sending": "Enviando…" diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 9ef5a69cbae..24c94d5c823 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -5988,11 +5988,6 @@ "3d172725cc": "なし", "1f32ba27a6": "カスタム", "a182c5125e": "Gitのユーザー名", - "sourceControlGroupOrderTitle": "ソース管理のグループ順序", - "sourceControlGroupOrderDescription": "ソース管理で「変更」「ステージ済みの変更」「未追跡ファイル」のどれを先に表示するかを選択します。", - "changesFirst": "変更を先頭", - "stagedFirst": "ステージ済みを先頭", - "untrackedFirst": "未追跡を先頭", "compareAgainstUpstreamTitle": "既定の比較ベース", "compareAgainstUpstreamDescription": "ソース管理がコミット済み変更の比較に既定で使うベースを選択します。ブランチの上流は現在のブランチに自動的に追従し、上流がない場合はリポジトリの既定ブランチにフォールバックします。比較ベースは各 worktree の Git パネルから worktree ごとに変更できます。Pull Request やリベースの対象は変更されません。", "compareBaseRepositoryDefault": "リポジトリ既定", @@ -8258,14 +8253,7 @@ "f83c8937c4": "ブランチの命名", "5ecd91c5ef": "ワークツリーの作成時にブランチ名に追加されるプレフィックス。", "68bd65fdb8": "ブランチプレフィックス", - "sourceControlGroupOrderTitle": "ソース管理のグループ順序", - "sourceControlGroupOrderDescription": "ソース管理で「変更」「ステージ済みの変更」「未追跡ファイル」のどれを先に表示するかを選択します。", - "groupOrder": "グループ順序", - "changesFirst": "変更を先頭", - "stagedFirst": "ステージ済みを先頭", - "untrackedFirst": "未追跡を先頭", "sourceControl": "ソース管理", - "gitChanges": "git の変更", "compareAgainstUpstreamTitle": "既定の比較ベース", "compareAgainstUpstreamDescription": "ソース管理がコミット済み変更の比較に既定で使うベースを選択します。ブランチの上流は現在のブランチに自動的に追従し、上流がない場合はリポジトリの既定ブランチにフォールバックします。比較ベースは各 worktree の Git パネルから worktree ごとに変更できます。Pull Request やリベースの対象は変更されません。", "compareBase": "比較基準", @@ -14526,7 +14514,7 @@ "other": "他の…", "otherPlaceholder": "答えを入力してください", "cancel": "キャンセル", - "send": "回答を送信する", + "send": "送信", "next": "次へ", "skip": "スキップ", "sending": "送信中…" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d6c3e34fcd1..ead7ea624f4 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -5951,11 +5951,6 @@ "3d172725cc": "없음", "1f32ba27a6": "사용자 정의", "a182c5125e": "Git 사용자 이름", - "sourceControlGroupOrderTitle": "소스 제어 그룹 순서", - "sourceControlGroupOrderDescription": "소스 제어에서 변경 사항, 스테이징된 변경 사항 또는 추적되지 않는 파일 중 무엇을 먼저 표시할지 선택합니다.", - "changesFirst": "변경 사항 먼저", - "stagedFirst": "스테이징된 항목 먼저", - "untrackedFirst": "추적되지 않는 파일 먼저", "compareAgainstUpstreamTitle": "기본 비교 기준", "compareAgainstUpstreamDescription": "소스 제어가 커밋된 변경 사항 비교에 기본으로 사용할 기준을 선택합니다. 브랜치 업스트림은 현재 브랜치를 자동으로 따라가며, 업스트림이 없으면 저장소 기본 브랜치로 돌아갑니다. 비교 기준은 해당 worktree의 Git 패널에서 worktree별로 변경할 수 있습니다. Pull Request 및 리베이스 대상은 변경되지 않습니다.", "compareBaseRepositoryDefault": "저장소 기본값", @@ -8221,14 +8216,7 @@ "f83c8937c4": "브랜치 이름 지정", "5ecd91c5ef": "작업 트리를 생성할 때 브랜치 이름에 접두사가 추가됩니다.", "68bd65fdb8": "브랜치 접두사", - "sourceControlGroupOrderTitle": "소스 제어 그룹 순서", - "sourceControlGroupOrderDescription": "소스 제어에서 변경 사항, 스테이징된 변경 사항 또는 추적되지 않는 파일 중 무엇을 먼저 표시할지 선택합니다.", - "groupOrder": "그룹 순서", - "changesFirst": "변경 사항 먼저", - "stagedFirst": "스테이징된 항목 먼저", - "untrackedFirst": "추적되지 않는 파일 먼저", "sourceControl": "소스 제어", - "gitChanges": "git 변경 사항", "compareAgainstUpstreamTitle": "기본 비교 기준", "compareAgainstUpstreamDescription": "소스 제어가 커밋된 변경 사항 비교에 기본으로 사용할 기준을 선택합니다. 브랜치 업스트림은 현재 브랜치를 자동으로 따라가며, 업스트림이 없으면 저장소 기본 브랜치로 돌아갑니다. 비교 기준은 해당 worktree의 Git 패널에서 worktree별로 변경할 수 있습니다. Pull Request 및 리베이스 대상은 변경되지 않습니다.", "compareBase": "비교 기준", @@ -14537,7 +14525,7 @@ "other": "다른…", "otherPlaceholder": "답변을 입력하세요", "cancel": "취소", - "send": "답변 보내기", + "send": "제출", "next": "다음", "skip": "건너뛰기", "sending": "전송 중…" diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index c5f255fe4e9..b64faff2472 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -5963,11 +5963,6 @@ "3d172725cc": "没有任何", "1f32ba27a6": "自定义", "a182c5125e": "git用户名", - "sourceControlGroupOrderTitle": "源代码管理分组顺序", - "sourceControlGroupOrderDescription": "选择在源代码管理中优先显示“更改”、“已暂存更改”还是“未跟踪文件”。", - "changesFirst": "更改优先", - "stagedFirst": "已暂存优先", - "untrackedFirst": "未跟踪优先", "compareAgainstUpstreamTitle": "默认对比基准", "compareAgainstUpstreamDescription": "选择源代码管理在比较已提交变更时默认使用的基准。分支上游会自动跟随当前分支;如果没有上游,则回退到仓库默认分支。你仍然可以在对应 worktree 的 Git 面板中按 worktree 更改对比基准。Pull Request 和变基目标不会改变。", "compareBaseRepositoryDefault": "仓库默认", @@ -8233,14 +8228,7 @@ "f83c8937c4": "分支命名", "5ecd91c5ef": "创建工作树时添加到分支名称的前缀。", "68bd65fdb8": "分支前缀", - "sourceControlGroupOrderTitle": "源代码管理分组顺序", - "sourceControlGroupOrderDescription": "选择在源代码管理中优先显示“更改”、“已暂存更改”还是“未跟踪文件”。", - "groupOrder": "分组顺序", - "changesFirst": "更改优先", - "stagedFirst": "已暂存优先", - "untrackedFirst": "未跟踪优先", "sourceControl": "源代码管理", - "gitChanges": "git 更改", "compareAgainstUpstreamTitle": "默认对比基准", "compareAgainstUpstreamDescription": "选择源代码管理在比较已提交变更时默认使用的基准。分支上游会自动跟随当前分支;如果没有上游,则回退到仓库默认分支。你仍然可以在对应 worktree 的 Git 面板中按 worktree 更改对比基准。Pull Request 和变基目标不会改变。", "compareBase": "对比基准", @@ -14546,7 +14534,7 @@ "other": "其他…", "otherPlaceholder": "输入你的回答", "cancel": "取消", - "send": "发送回答", + "send": "提交", "next": "下一步", "skip": "跳过", "sending": "正在发送…" diff --git a/src/renderer/src/runtime/runtime-git-client-branch-line-total.test.ts b/src/renderer/src/runtime/runtime-git-client-branch-line-total.test.ts new file mode 100644 index 00000000000..07d2db533a9 --- /dev/null +++ b/src/renderer/src/runtime/runtime-git-client-branch-line-total.test.ts @@ -0,0 +1,133 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getRuntimeGitStatus } from './runtime-git-client' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from './runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' + +const MERGE_BASE = '1f3c0d9a5b6e7f8091a2b3c4d5e6f708192a3b4c' + +const gitStatus = vi.fn() +const gitCancelStatus = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + gitStatus.mockReset() + gitStatus.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + gitCancelStatus.mockReset() + gitCancelStatus.mockResolvedValue(undefined) + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { entries: [], conflictOperation: 'unknown' }, + _meta: { runtimeId: 'remote-runtime' } + }) + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + git: { status: gitStatus, cancelStatus: gitCancelStatus }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('branch line total merge base on git status requests', () => { + it('forwards the merge base to local git status only when the chip asked for it', async () => { + const context = { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo' + } + + await getRuntimeGitStatus(context, { branchLineTotalMergeBase: MERGE_BASE }) + await getRuntimeGitStatus(context, {}) + await getRuntimeGitStatus(context) + + expect(gitStatus).toHaveBeenNthCalledWith(1, { + worktreePath: '/repo', + connectionId: undefined, + branchLineTotalMergeBase: MERGE_BASE + }) + expect(gitStatus).toHaveBeenNthCalledWith(2, { + worktreePath: '/repo', + connectionId: undefined + }) + expect(gitStatus).toHaveBeenNthCalledWith(3, { + worktreePath: '/repo', + connectionId: undefined + }) + }) + + it('forwards the merge base through the active runtime environment', async () => { + await getRuntimeGitStatus( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { branchLineTotalMergeBase: MERGE_BASE } + ) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'git.status', + params: { worktree: 'id:wt-1', branchLineTotalMergeBase: MERGE_BASE }, + timeoutMs: 15_000 + }) + }) + + it('omits the merge base param entirely for a remote status with no visible chip', async () => { + // Why: Rule-1 wire safety — an old server must see the exact params it + // already understands, and a hidden chip must not cost a remote diff. + await getRuntimeGitStatus({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'git.status', + params: { worktree: 'id:wt-1' }, + timeoutMs: 15_000 + }) + }) + + it('keeps the merge base alongside reuse and cache-bypass flags', async () => { + await getRuntimeGitStatus( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { + reuseLineStats: true, + bypassEffectiveUpstreamNegativeCache: true, + branchLineTotalMergeBase: MERGE_BASE + } + ) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'git.status', + params: { + worktree: 'id:wt-1', + bypassEffectiveUpstreamNegativeCache: true, + reuseLineStats: true, + branchLineTotalMergeBase: MERGE_BASE + }, + timeoutMs: 15_000 + }) + }) +}) diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts index 0a820ad18c2..f4b5931d696 100644 --- a/src/renderer/src/runtime/runtime-git-client.ts +++ b/src/renderer/src/runtime/runtime-git-client.ts @@ -139,6 +139,7 @@ export async function getRuntimeGitStatus( includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean reuseLineStats?: boolean + branchLineTotalMergeBase?: string signal?: AbortSignal } ): Promise { @@ -148,6 +149,9 @@ export async function getRuntimeGitStatus( ? { bypassEffectiveUpstreamNegativeCache: true } : {} const lineStatsReuseArgs = options?.reuseLineStats ? { reuseLineStats: true } : {} + const branchLineTotalArgs = options?.branchLineTotalMergeBase + ? { branchLineTotalMergeBase: options.branchLineTotalMergeBase } + : {} if (target.kind === 'local' || !context.worktreeId) { return callLocalGitStatus( { @@ -155,7 +159,8 @@ export async function getRuntimeGitStatus( connectionId: context.connectionId, ...includeIgnoredArgs, ...upstreamCacheBypassArgs, - ...lineStatsReuseArgs + ...lineStatsReuseArgs, + ...branchLineTotalArgs }, options?.signal ) @@ -167,7 +172,8 @@ export async function getRuntimeGitStatus( worktree: toRuntimeWorktreeSelector(context.worktreeId), ...includeIgnoredArgs, ...upstreamCacheBypassArgs, - ...lineStatsReuseArgs + ...lineStatsReuseArgs, + ...branchLineTotalArgs }, { timeoutMs: 15_000, diff --git a/src/renderer/src/runtime/runtime-server-directory-browser.test.ts b/src/renderer/src/runtime/runtime-server-directory-browser.test.ts index 07740c37726..6c7579bc7a2 100644 --- a/src/renderer/src/runtime/runtime-server-directory-browser.test.ts +++ b/src/renderer/src/runtime/runtime-server-directory-browser.test.ts @@ -29,7 +29,12 @@ beforeEach(() => { result: { resolvedPath: '/home/me', pathFlavor: 'posix', - entries: [{ name: 'repo', isDirectory: true, isSymlink: false }] + entries: [ + { name: '100 - file.txt', isDirectory: false, isSymlink: false }, + { name: '9 - file.txt', isDirectory: false, isSymlink: false }, + { name: '10 - repo', isDirectory: true, isSymlink: false }, + { name: '99 - file.txt', isDirectory: false, isSymlink: false } + ] }, _meta: { runtimeId: 'remote-runtime' } }) @@ -48,7 +53,12 @@ describe('runtime server directory browser', () => { await expect(browseRuntimeServerDirectory('env-1', '~')).resolves.toEqual({ resolvedPath: '/home/me', pathFlavor: 'posix', - entries: [{ name: 'repo', isDirectory: true, isSymlink: false }] + entries: [ + { name: '10 - repo', isDirectory: true, isSymlink: false }, + { name: '9 - file.txt', isDirectory: false, isSymlink: false }, + { name: '99 - file.txt', isDirectory: false, isSymlink: false }, + { name: '100 - file.txt', isDirectory: false, isSymlink: false } + ] }) expect(runtimeEnvironmentCall).toHaveBeenLastCalledWith({ diff --git a/src/renderer/src/runtime/runtime-server-directory-browser.ts b/src/renderer/src/runtime/runtime-server-directory-browser.ts index 5d7c0a13ade..b28e7755f16 100644 --- a/src/renderer/src/runtime/runtime-server-directory-browser.ts +++ b/src/renderer/src/runtime/runtime-server-directory-browser.ts @@ -1,4 +1,5 @@ import type { DirEntry, FilesystemPathFlavor } from '../../../shared/types' +import { sortDirEntries } from '../../../shared/file-name-sort' import { callRuntimeRpc } from './runtime-rpc-client' export type RuntimeServerDirectoryListing = { @@ -11,10 +12,11 @@ export async function browseRuntimeServerDirectory( environmentId: string, path: string ): Promise { - return callRuntimeRpc( + const listing = await callRuntimeRpc( { kind: 'environment', environmentId }, 'files.browseServerDir', { path }, { timeoutMs: 15_000 } ) + return { ...listing, entries: sortDirEntries(listing.entries) } } diff --git a/src/renderer/src/store/slices/degraded-repo-hydration.test.ts b/src/renderer/src/store/slices/degraded-repo-hydration.test.ts index 93902bb9d49..48690c43f51 100644 --- a/src/renderer/src/store/slices/degraded-repo-hydration.test.ts +++ b/src/renderer/src/store/slices/degraded-repo-hydration.test.ts @@ -16,7 +16,7 @@ import { buildWorkspaceSessionPayload, shouldPersistWorkspaceSession } from '@/lib/workspace-session' -import { createTestStore, makeTab } from './store-test-helpers' +import { createTestStore, makeTab, makeWorktree } from './store-test-helpers' const WORKTREE_ID = 'repo1::/path/degraded' const TERMINAL_ID = 'terminal-degraded' @@ -199,6 +199,39 @@ it('keeps a terminal-free degraded workspace selected through hydration and pers expect(restored.activeTabType).toBe('browser') }) +it('keeps chrome when a non-authoritative fallback lists only some of the repo worktrees', () => { + const store = createTestStore() + // Why: an SSH metadata fallback can be non-empty yet partial (host-less metas are skipped on multi-owner + // repos, agent-scratch stays hidden). A partial list must not read as proof the missing worktree was deleted. + const sibling = makeWorktree({ + id: 'repo1::/path/sibling', + repoId: 'repo1', + path: '/path/sibling' + }) + store.setState({ + repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }], + worktreesByRepo: { repo1: [sibling] }, + detectedWorktreesByRepo: { + repo1: { + repoId: 'repo1', + authoritative: false, + source: 'metadata-fallback', + worktrees: [] + } + } + }) + const session = makeDegradedRepoSession() + store.getState().hydrateWorkspaceSession(session) + store.getState().hydrateTabsSession(session) + store.getState().hydrateEditorSession(session) + store.getState().hydrateBrowserSession(session) + + const state = store.getState() + expect(state.tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([TERMINAL_ID]) + expect(state.openFiles.map((file) => file.id)).toEqual([EDITOR_FILE_ID]) + expect(state.browserTabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([BROWSER_ID]) +}) + it('drops terminal-free chrome when an authoritative scan proves deletion', () => { const store = createTestStore() hydrateWithRepoScan(store, makeTerminalFreeDegradedRepoSession(), true) diff --git a/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts b/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts index 0fb4e42fd19..1a9693bdf5f 100644 --- a/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts +++ b/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts @@ -45,13 +45,21 @@ export function buildValidWorktreeIdsForSessionHydration( .map((worktree) => worktree.id) ) const knownRepoIds = new Set(catalog.repos.map((repo) => repo.id)) + const detectedWorktreesByRepo = catalog.detectedWorktreesByRepo ?? {} const repoIdsWithLoadedWorktrees = new Set( Object.entries(worktreesByRepo) - .filter(([, worktrees]) => worktrees.length > 0) + // Why (#1158): a metadata fallback can be non-empty yet partial (host-less metas are skipped on + // multi-owner repos, agent-scratch stays hidden), so it cannot prove deletion the way a real listing can. + // Only an explicitly non-authoritative result is disqualified; a repo with no detection entry at all + // still counts as loaded, as before. + .filter( + ([repoId, worktrees]) => + worktrees.length > 0 && detectedWorktreesByRepo[repoId]?.authoritative !== false + ) .map(([repoId]) => repoId) ) const repoIdsWithAuthoritativeDetectedWorktrees = new Set( - Object.entries(catalog.detectedWorktreesByRepo ?? {}) + Object.entries(detectedWorktreesByRepo) .filter(([, detected]) => detected?.authoritative) .map(([repoId]) => repoId) ) diff --git a/src/renderer/src/store/slices/editor-branch-line-total.test.ts b/src/renderer/src/store/slices/editor-branch-line-total.test.ts new file mode 100644 index 00000000000..c4f88540951 --- /dev/null +++ b/src/renderer/src/store/slices/editor-branch-line-total.test.ts @@ -0,0 +1,225 @@ +import { createStore, type StoreApi } from 'zustand/vanilla' +import { describe, expect, it, vi } from 'vitest' +import { createEditorSlice } from './editor' +import type { AppState } from '../types' +import type { GitStatusResult } from '../../../../shared/types' + +const MERGE_BASE = '1f3c0d9a5b6e7f8091a2b3c4d5e6f708192a3b4c' + +function createEditorStore(): StoreApi { + // Only the editor slice + activeWorktreeId are needed for these tests. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return createStore()((...args: any[]) => ({ + activeWorktreeId: 'wt-1', + tabsByWorktree: {}, + browserTabsByWorktree: {}, + activeBrowserTabId: null, + activeBrowserTabIdByWorktree: {}, + repos: [{ id: 'repo-1', path: '/repo' }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo' }] }, + folderWorkspaces: [], + projectGroups: [], + recordFeatureInteraction: vi.fn(), + ...createEditorSlice(...(args as Parameters)) + })) as unknown as StoreApi +} + +function status(overrides: Partial = {}): GitStatusResult { + return { + conflictOperation: 'unknown', + entries: [{ path: 'src/index.ts', status: 'modified', area: 'unstaged' }], + head: 'head-1', + ignoredPaths: [], + ...overrides + } +} + +describe('createEditorSlice branch line total', () => { + it('stores a total published with the status result', () => { + const store = createEditorStore() + + store + .getState() + .setGitStatus( + 'wt-1', + status({ branchLineTotal: { added: 8259, removed: 670, mergeBase: MERGE_BASE } }) + ) + + expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toEqual({ + added: 8259, + removed: 670, + mergeBase: MERGE_BASE + }) + }) + + it('stores an exact zero total rather than treating it as absent', () => { + const store = createEditorStore() + + store + .getState() + .setGitStatus( + 'wt-1', + status({ branchLineTotal: { added: 0, removed: 0, mergeBase: MERGE_BASE } }) + ) + + expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toEqual({ + added: 0, + removed: 0, + mergeBase: MERGE_BASE + }) + }) + + it('clears a stored total when a later status omits the field', () => { + const store = createEditorStore() + store + .getState() + .setGitStatus( + 'wt-1', + status({ branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE } }) + ) + + // Why: an omitted field means "not known exact" — an old server, a failed + // numstat, or a timeout. Keeping the old number renders a confident lie. + store.getState().setGitStatus('wt-1', status()) + + expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toBeUndefined() + }) + + it('clears a stored total when the listing hits the entry cap', () => { + const store = createEditorStore() + store + .getState() + .setGitStatus( + 'wt-1', + status({ branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE } }) + ) + + store.getState().setGitStatus( + 'wt-1', + status({ + entries: [{ path: 'generated/a.ts', status: 'untracked', area: 'untracked' }], + didHitLimit: true, + statusLength: 2 + }) + ) + + expect(store.getState().gitStatusHugeByWorktree['wt-1']).toEqual({ limit: 1 }) + expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toBeUndefined() + }) + + it('never substitutes zero or NaN for a total an old server never sent', () => { + const store = createEditorStore() + + store.getState().setGitStatus('wt-1', status()) + + const stored = store.getState().gitBranchLineTotalByWorktree['wt-1'] + expect(stored).toBeUndefined() + expect(stored ?? null).toBeNull() + }) + + it('keeps totals independent per worktree', () => { + const store = createEditorStore() + store + .getState() + .setGitStatus( + 'wt-1', + status({ branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE } }) + ) + store + .getState() + .setGitStatus( + 'wt-2', + status({ branchLineTotal: { added: 1, removed: 1, mergeBase: 'other' } }) + ) + + store.getState().setGitStatus('wt-1', status()) + + expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toBeUndefined() + expect(store.getState().gitBranchLineTotalByWorktree['wt-2']).toEqual({ + added: 1, + removed: 1, + mergeBase: 'other' + }) + }) + + it('replaces the total when the fork point moves', () => { + const store = createEditorStore() + store + .getState() + .setGitStatus( + 'wt-1', + status({ branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE } }) + ) + + store + .getState() + .setGitStatus( + 'wt-1', + status({ branchLineTotal: { added: 24, removed: 3, mergeBase: 'rebased' } }) + ) + + expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toEqual({ + added: 24, + removed: 3, + mergeBase: 'rebased' + }) + }) + + it('does not produce a new state object for an unchanged status tick', () => { + const store = createEditorStore() + const tick = status({ branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE } }) + store.getState().setGitStatus('wt-1', tick) + const before = store.getState() + const listener = vi.fn() + const unsubscribe = store.subscribe(listener) + + store.getState().setGitStatus('wt-1', { + ...tick, + entries: [...tick.entries], + branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE } + }) + unsubscribe() + + expect(store.getState()).toBe(before) + expect(listener).not.toHaveBeenCalled() + }) + + it('produces a new state object when only the total changed', () => { + const store = createEditorStore() + const tick = status({ branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE } }) + store.getState().setGitStatus('wt-1', tick) + const before = store.getState() + + store.getState().setGitStatus('wt-1', { + ...tick, + branchLineTotal: { added: 25, removed: 3, mergeBase: MERGE_BASE } + }) + + expect(store.getState()).not.toBe(before) + expect(store.getState().gitBranchLineTotalByWorktree['wt-1']).toEqual({ + added: 25, + removed: 3, + mergeBase: MERGE_BASE + }) + }) + + it('leaves other worktree entries referentially stable when one total changes', () => { + const store = createEditorStore() + store + .getState() + .setGitStatus( + 'wt-2', + status({ branchLineTotal: { added: 1, removed: 1, mergeBase: 'other' } }) + ) + const otherTotal = store.getState().gitBranchLineTotalByWorktree['wt-2'] + + store + .getState() + .setGitStatus( + 'wt-1', + status({ branchLineTotal: { added: 24, removed: 3, mergeBase: MERGE_BASE } }) + ) + + expect(store.getState().gitBranchLineTotalByWorktree['wt-2']).toBe(otherTotal) + }) +}) diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index bf17d61a3ea..e3123db9cd1 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -45,6 +45,7 @@ import type { WorkspaceSessionState, WorkspaceVisibleTabType } from '../../../../shared/types' +import type { GitBranchLineTotal } from '../../../../shared/git-status-types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { clampMarkdownTocPanelWidth } from '../../../../shared/markdown-toc-panel-width' import { @@ -629,6 +630,8 @@ export type EditorSlice = { gitStatusHeadByWorktree: Record // Why: set when status hit the entry limit; SCM shows "too many changes" and pauses polling. `{ limit }` when huge, else absent. gitStatusHugeByWorktree: Record + // Why: absent means "not known exact" (stale fork point, old server, capped listing); never fall back to a previous total. + gitBranchLineTotalByWorktree: Record gitIgnoredPathsByWorktree: Record gitConflictOperationByWorktree: Record trackedConflictPathsByWorktree: Record> @@ -4086,6 +4089,7 @@ export const createEditorSlice: StateCreator = (s gitStatusByWorktree: {}, gitStatusHeadByWorktree: {}, gitStatusHugeByWorktree: {}, + gitBranchLineTotalByWorktree: {}, gitIgnoredPathsByWorktree: {}, gitConflictOperationByWorktree: {}, trackedConflictPathsByWorktree: {}, @@ -4187,6 +4191,18 @@ export const createEditorSlice: StateCreator = (s const prevHuge = s.gitStatusHugeByWorktree[worktreeId] const nextHuge = status.didHitLimit ? { limit: nextEntries.length } : undefined const hugeUnchanged = (prevHuge?.limit ?? null) === (nextHuge?.limit ?? null) + + const prevBranchLineTotal = s.gitBranchLineTotalByWorktree[worktreeId] ?? null + // Why: an omitted field means "not known exact"; keeping the old total would render a confidently wrong chip. + const nextBranchLineTotal = status.branchLineTotal ?? null + const branchLineTotalUnchanged = + prevBranchLineTotal === nextBranchLineTotal || + (prevBranchLineTotal !== null && + nextBranchLineTotal !== null && + prevBranchLineTotal.added === nextBranchLineTotal.added && + prevBranchLineTotal.removed === nextBranchLineTotal.removed && + prevBranchLineTotal.mergeBase === nextBranchLineTotal.mergeBase) + const prevStatusHead = s.gitStatusHeadByWorktree[worktreeId] const nextStatusHead = getKnownGitHead(status.head) const statusHeadUnchanged = prevStatusHead === nextStatusHead @@ -4206,12 +4222,23 @@ export const createEditorSlice: StateCreator = (s operationUnchanged && ignoredUnchanged && hugeUnchanged && + branchLineTotalUnchanged && statusHeadUnchanged && !shouldInvalidateBranchCompare ) { return s } + const nextBranchLineTotalMap = branchLineTotalUnchanged + ? s.gitBranchLineTotalByWorktree + : nextBranchLineTotal + ? { ...s.gitBranchLineTotalByWorktree, [worktreeId]: nextBranchLineTotal } + : (() => { + const copy = { ...s.gitBranchLineTotalByWorktree } + delete copy[worktreeId] + return copy + })() + const nextHugeMap = hugeUnchanged ? s.gitStatusHugeByWorktree : nextHuge @@ -4244,6 +4271,7 @@ export const createEditorSlice: StateCreator = (s return { openFiles: nextOpenFiles, gitStatusHugeByWorktree: nextHugeMap, + gitBranchLineTotalByWorktree: nextBranchLineTotalMap, gitStatusHeadByWorktree: nextStatusHeadMap, gitStatusByWorktree: statusUnchanged ? s.gitStatusByWorktree diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 1d2b822dffd..72d099a2731 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -21,6 +21,8 @@ import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rp import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import type { HostQualifiedDetectedWorktreeResult, + HostQualifiedKnownWorktreeResult, + ListKnownWorktreesForExecutionHostArgs, ListDetectedWorktreesArgs } from '../../../../shared/detected-worktree-provider-contract' import type { DirectSshAuthority, SshProviderEpoch } from '../../../../shared/ssh-types' @@ -105,12 +107,17 @@ const listDetectedMock = vi.fn< return qualifyDetectedResult(args, result) }) +const listKnownForExecutionHostMock = vi.fn< + (args: ListKnownWorktreesForExecutionHostArgs) => Promise +>(async (args) => ({ status: 'rejected', ...args })) + const mockApi = { worktrees: { create: vi.fn(), prefetchCreateBase: vi.fn().mockResolvedValue(undefined), list: worktreeListMock, listDetected: listDetectedMock, + listKnownForExecutionHost: listKnownForExecutionHostMock, cancelListDetected: vi.fn().mockResolvedValue(undefined), listLineage: vi.fn().mockResolvedValue({}), remove: vi.fn().mockResolvedValue(undefined), @@ -153,6 +160,7 @@ import { createWorktreeSlice, getHostedReviewLinkMutationGenerationForTests, getHostedReviewLinkWorktreeAliasCountForTests, + resetAuthoritativelyRemovedWorktreeMemoryForTests, resetHostedReviewLinkMutationGenerationForTests } from './worktrees' import type { PendingWorktreeCreation } from '@/lib/pending-worktree-creation' @@ -439,6 +447,7 @@ describe('fetchWorktrees', () => { vi.clearAllMocks() resetRemoteRuntimeMocks() clearHugeRepoWarningDismissalsForTests() + resetAuthoritativelyRemovedWorktreeMemoryForTests() }) it('does not notify subscribers when the fetched payload is unchanged', async () => { @@ -1350,6 +1359,365 @@ describe('fetchWorktrees', () => { expect(store.getState().detectedWorktreesByRepo).toBe(detectedWorktreesByRepo) }) + it('shows persisted secondary worktrees while SSH is connecting', async () => { + const store = createTestStore() + const sshRepo = { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + const queued = makeWorktree({ + id: 'repo-ssh::/home/orca/queued', + repoId: 'repo-ssh', + path: '/home/orca/queued', + displayName: 'queued' + }) + const detected = makeDetectedResult('repo-ssh', [queued], { + authoritative: false, + source: 'metadata-fallback' + }) + listKnownForExecutionHostMock.mockResolvedValueOnce({ + status: 'complete', + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1', + result: detected + }) + store.setState({ + repos: [sshRepo], + sshConnectionStates: new Map([ + [ + 'ssh-1', + { + targetId: 'ssh-1', + status: 'connecting', + error: null, + reconnectAttempt: 0, + providerEpoch: null + } + ] + ]) + } as Partial) + + await expect(store.getState().fetchWorktrees(sshRepo.id)).resolves.toBe(false) + + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ + { ...queued, hostId: 'ssh:ssh-1' } + ]) + expect(listKnownForExecutionHostMock).toHaveBeenCalledWith({ + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1' + }) + expect(mockApi.worktrees.listDetected).not.toHaveBeenCalled() + }) + + it('adds metadata rows without replacing richer cached SSH worktrees', async () => { + const store = createTestStore() + const sshRepo = { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + const existing = makeWorktree({ + id: 'repo-ssh::/home/orca/existing', + repoId: 'repo-ssh', + path: '/home/orca/existing', + hostId: 'ssh:ssh-1', + head: 'live-head', + branch: 'refs/heads/live-branch' + }) + const metadataExisting = { ...existing, head: '', branch: '' } + const queued = makeWorktree({ + id: 'repo-ssh::/home/orca/queued', + repoId: 'repo-ssh', + path: '/home/orca/queued' + }) + listKnownForExecutionHostMock.mockResolvedValueOnce({ + status: 'complete', + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1', + result: makeDetectedResult('repo-ssh', [metadataExisting, queued], { + authoritative: false, + source: 'metadata-fallback' + }) + }) + store.setState({ + repos: [sshRepo], + sshConnectionStates: new Map(), + worktreesByRepo: { [sshRepo.id]: [existing] } + } as Partial) + + await store.getState().fetchWorktrees(sshRepo.id) + + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ + existing, + { ...queued, hostId: 'ssh:ssh-1' } + ]) + }) + + it('inserts metadata rows inside the SSH block instead of past sibling hosts', async () => { + const store = createTestStore() + const sshRepo = { + id: 'repo-shared', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + const localRepo = { ...sshRepo, path: '/local/repo', connectionId: undefined } + const sshExisting = makeWorktree({ + id: 'repo-shared::/home/orca/existing', + repoId: sshRepo.id, + path: '/home/orca/existing', + hostId: 'ssh:ssh-1' + }) + const localExisting = makeWorktree({ + id: 'repo-shared::/local/existing', + repoId: sshRepo.id, + path: '/local/existing', + hostId: LOCAL_EXECUTION_HOST_ID + }) + const queued = makeWorktree({ + id: 'repo-shared::/home/orca/queued', + repoId: sshRepo.id, + path: '/home/orca/queued' + }) + listKnownForExecutionHostMock.mockResolvedValueOnce({ + status: 'complete', + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1', + result: makeDetectedResult(sshRepo.id, [queued], { + authoritative: false, + source: 'metadata-fallback' + }) + }) + store.setState({ + repos: [sshRepo, localRepo], + sshConnectionStates: new Map(), + worktreesByRepo: { [sshRepo.id]: [sshExisting, localExisting] } + } as Partial) + + await store.getState().fetchWorktrees(sshRepo.id, { executionHostId: 'ssh:ssh-1' }) + + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ + sshExisting, + { ...queued, hostId: 'ssh:ssh-1' }, + localExisting + ]) + }) + + it('drops metadata rows when SSH authority lands during the read', async () => { + const store = createTestStore() + const sshRepo = { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + const live = makeWorktree({ + id: 'repo-ssh::/home/orca/live', + repoId: 'repo-ssh', + path: '/home/orca/live', + hostId: 'ssh:ssh-1' + }) + // Why: deleted on the host, so an authoritative scan already purged it; the late metadata write must not resurrect it. + const purged = makeWorktree({ + id: 'repo-ssh::/home/orca/purged', + repoId: 'repo-ssh', + path: '/home/orca/purged' + }) + listKnownForExecutionHostMock.mockImplementationOnce(async (args) => { + store.setState({ + sshConnectionStates: new Map([ + [ + TEST_SSH_AUTHORITY.targetId, + { + targetId: TEST_SSH_AUTHORITY.targetId, + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: TEST_SSH_AUTHORITY.providerEpoch, + connectionGeneration: TEST_SSH_AUTHORITY.connectionGeneration + } + ] + ]), + worktreesByRepo: { [sshRepo.id]: [live] } + } as Partial) + return { + status: 'complete', + repoId: args.repoId, + executionHostId: args.executionHostId, + result: makeDetectedResult(args.repoId, [live, purged], { + authoritative: false, + source: 'metadata-fallback' + }) + } + }) + store.setState({ + repos: [sshRepo], + sshConnectionStates: new Map() + } as Partial) + + await store.getState().fetchWorktrees(sshRepo.id) + + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live]) + }) + + it('replaces metadata rows once the authoritative SSH scan lands', async () => { + const store = createTestStore() + const sshRepo = { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + const live = makeWorktree({ + id: 'repo-ssh::/home/orca/live', + repoId: 'repo-ssh', + path: '/home/orca/live', + hostId: 'ssh:ssh-1' + }) + const stale = makeWorktree({ + id: 'repo-ssh::/home/orca/stale', + repoId: 'repo-ssh', + path: '/home/orca/stale' + }) + listKnownForExecutionHostMock.mockResolvedValueOnce({ + status: 'complete', + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1', + result: makeDetectedResult(sshRepo.id, [stale], { + authoritative: false, + source: 'metadata-fallback' + }) + }) + const connectedStates = createTestStore().getState().sshConnectionStates + store.setState({ repos: [sshRepo], sshConnectionStates: new Map() } as Partial) + + await store.getState().fetchWorktrees(sshRepo.id) + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ + { ...stale, hostId: 'ssh:ssh-1' } + ]) + + worktreeListMock.mockResolvedValueOnce([live]) + store.setState({ sshConnectionStates: connectedStates } as Partial) + await store.getState().fetchWorktrees(sshRepo.id) + + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live]) + }) + + it('keeps the repo detection entry authoritative while appending metadata rows', async () => { + const store = createTestStore() + const sshRepo = { + id: 'repo-shared', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + const localRepo = { ...sshRepo, path: '/local/repo', connectionId: undefined } + const scanned = makeWorktree({ + id: 'repo-shared::/local/scanned', + repoId: sshRepo.id, + path: '/local/scanned' + }) + const fromMetadata = makeWorktree({ + id: 'repo-shared::/home/orca/queued', + repoId: sshRepo.id, + path: '/home/orca/queued' + }) + const authoritative = makeDetectedResult(sshRepo.id, [scanned]) + listKnownForExecutionHostMock.mockResolvedValueOnce({ + status: 'complete', + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1', + result: makeDetectedResult(sshRepo.id, [fromMetadata], { + authoritative: false, + source: 'metadata-fallback' + }) + }) + store.setState({ + repos: [sshRepo, localRepo], + sshConnectionStates: new Map(), + detectedWorktreesByRepo: { [sshRepo.id]: authoritative } + } as Partial) + + await store.getState().fetchWorktrees(sshRepo.id, { executionHostId: 'ssh:ssh-1' }) + + const detected = store.getState().detectedWorktreesByRepo[sshRepo.id] + // Why: this entry is shared with the co-owning local host, so the fallback must not demote its scan. + expect(detected?.authoritative).toBe(true) + expect(detected?.source).toBe('git') + expect(detected?.worktrees.map((worktree) => worktree.id)).toEqual([ + scanned.id, + fromMetadata.id + ]) + }) + + it('does not resurrect worktrees an authoritative SSH scan already removed', async () => { + const store = createTestStore() + const sshRepo = { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + const live = makeWorktree({ + id: 'repo-ssh::/home/orca/live', + repoId: 'repo-ssh', + path: '/home/orca/live', + hostId: 'ssh:ssh-1' + }) + const deletedOnRemote = makeWorktree({ + id: 'repo-ssh::/home/orca/deleted', + repoId: 'repo-ssh', + path: '/home/orca/deleted' + }) + const metadataResult = () => ({ + status: 'complete' as const, + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1' as const, + result: makeDetectedResult(sshRepo.id, [deletedOnRemote], { + authoritative: false, + source: 'metadata-fallback' + }) + }) + const connectedStates = createTestStore().getState().sshConnectionStates + listKnownForExecutionHostMock.mockResolvedValueOnce(metadataResult()) + store.setState({ repos: [sshRepo], sshConnectionStates: new Map() } as Partial) + + await store.getState().fetchWorktrees(sshRepo.id) + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ + { ...deletedOnRemote, hostId: 'ssh:ssh-1' } + ]) + + // The host connects and an authoritative scan proves the worktree is gone. + worktreeListMock.mockResolvedValueOnce([live]) + store.setState({ sshConnectionStates: connectedStates } as Partial) + await store.getState().fetchWorktrees(sshRepo.id) + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live]) + + // The host drops again; persisted metadata still lists the deleted worktree. + listKnownForExecutionHostMock.mockResolvedValueOnce(metadataResult()) + store.setState({ sshConnectionStates: new Map() } as Partial) + await store.getState().fetchWorktrees(sshRepo.id) + + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live]) + }) + it('keeps worktree maps byte-identical for stale and malformed direct results', async () => { const store = createTestStore() const existing = makeWorktree({ @@ -5023,6 +5391,10 @@ describe('removeWorktree state cleanup', () => { 'repo1::/path/wt1': 'head-1', 'repo1::/path/wt2': 'head-2' }, + gitBranchLineTotalByWorktree: { + 'repo1::/path/wt1': { added: 24, removed: 3, mergeBase: 'base-1' }, + 'repo1::/path/wt2': { added: 1, removed: 0, mergeBase: 'base-2' } + }, gitIgnoredPathsByWorktree: { 'repo1::/path/wt1': ['dist/'], 'repo1::/path/wt2': ['coverage/'] @@ -5061,6 +5433,9 @@ describe('removeWorktree state cleanup', () => { expect(store.getState().gitStatusHeadByWorktree).toEqual({ 'repo1::/path/wt2': 'head-2' }) + expect(store.getState().gitBranchLineTotalByWorktree).toEqual({ + 'repo1::/path/wt2': { added: 1, removed: 0, mergeBase: 'base-2' } + }) expect(store.getState().gitIgnoredPathsByWorktree).toEqual({ 'repo1::/path/wt2': ['coverage/'] }) @@ -7536,6 +7911,63 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { addedAt: 0 } + it.each([false, true])( + 'hydrates connecting SSH worktrees with hydration purge completed=%s', + async (hasHydratedWorktreePurge) => { + const store = createTestStore() + const sshRepo = { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + const queued = makeWorktree({ + id: 'repo-ssh::/home/orca/queued', + repoId: 'repo-ssh', + path: '/home/orca/queued', + displayName: 'queued' + }) + listKnownForExecutionHostMock.mockResolvedValueOnce({ + status: 'complete', + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1', + result: makeDetectedResult(sshRepo.id, [queued], { + authoritative: false, + source: 'metadata-fallback' + }) + }) + store.setState({ + repos: [sshRepo], + hasHydratedWorktreePurge, + sshConnectionStates: new Map([ + [ + 'ssh-1', + { + targetId: 'ssh-1', + status: 'connecting', + error: null, + reconnectAttempt: 0, + providerEpoch: null + } + ] + ]) + } as Partial) + + await store.getState().fetchAllWorktrees() + + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ + { ...queued, hostId: 'ssh:ssh-1' } + ]) + expect(listKnownForExecutionHostMock).toHaveBeenCalledWith({ + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1' + }) + expect(mockApi.worktrees.listDetected).not.toHaveBeenCalled() + } + ) + it('preserves resolved inline legacy lineage when side-map hydration is absent', async () => { const store = createTestStore() const parent = makeWorktree({ @@ -8257,6 +8689,10 @@ describe('purgeWorktreeTerminalState direct (design §4.4)', () => { 'repoA::/a/wt1': 'head-1', 'repoA::/a/wt2': 'head-2' }, + gitBranchLineTotalByWorktree: { + 'repoA::/a/wt1': { added: 24, removed: 3, mergeBase: 'base-1' }, + 'repoA::/a/wt2': { added: 1, removed: 0, mergeBase: 'base-2' } + }, gitBranchCompareRequestStatusHeadByWorktree: { 'repoA::/a/wt1': 'head-1', 'repoA::/a/wt2': 'head-2' @@ -8300,6 +8736,9 @@ describe('purgeWorktreeTerminalState direct (design §4.4)', () => { expect(s.editorDrafts).toEqual({ 'file-99': 'other' }) expect(s.markdownFrontmatterVisible).toEqual({ 'file-99': true }) expect(s.gitStatusHeadByWorktree).toEqual({ 'repoA::/a/wt2': 'head-2' }) + expect(s.gitBranchLineTotalByWorktree).toEqual({ + 'repoA::/a/wt2': { added: 1, removed: 0, mergeBase: 'base-2' } + }) expect(s.gitBranchCompareRequestStatusHeadByWorktree).toEqual({ 'repoA::/a/wt2': 'head-2' }) @@ -8808,6 +9247,7 @@ describe('migrateWorktreeIdentity', () => { groupsByWorktree: { [OLD]: [{ id: 'group1', worktreeId: OLD }] }, gitStatusByWorktree: { [OLD]: [{ path: 'a.ts' }] }, gitStatusHeadByWorktree: { [OLD]: 'head-old' }, + gitBranchLineTotalByWorktree: { [OLD]: { added: 24, removed: 3, mergeBase: 'base-old' } }, gitBranchCompareRequestStatusHeadByWorktree: { [OLD]: 'head-old' }, lastVisitedAtByWorktreeId: { [OLD]: 123 }, defaultTerminalTabsAppliedByWorktreeId: { [OLD]: true }, @@ -8855,6 +9295,12 @@ describe('migrateWorktreeIdentity', () => { expect(s.groupsByWorktree[NEW]?.[0]?.worktreeId).toBe(NEW) expect(s.gitStatusByWorktree[NEW]).toEqual([{ path: 'a.ts' }]) expect(s.gitStatusHeadByWorktree[NEW]).toBe('head-old') + expect(s.gitBranchLineTotalByWorktree[OLD]).toBeUndefined() + expect(s.gitBranchLineTotalByWorktree[NEW]).toEqual({ + added: 24, + removed: 3, + mergeBase: 'base-old' + }) expect(s.gitBranchCompareRequestStatusHeadByWorktree[NEW]).toBe('head-old') expect(s.rightSidebarExplorerViewByWorktree[OLD]).toBeUndefined() expect(s.rightSidebarExplorerViewByWorktree[NEW]).toBe('search') diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 6cd073abbe1..3859cec22fc 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -121,6 +121,7 @@ import { import { getTerminalActivationSpawnSuppression } from './terminal-activation-spawn-suppression' import type { HostQualifiedDetectedWorktreeResult, + HostQualifiedKnownWorktreeResult, ListDetectedWorktreesArgs, ProviderRequestId, SshExecutionHostId @@ -2228,6 +2229,7 @@ const WORKTREE_ID_KEYED_MAP_KEYS = [ 'activeGroupIdByWorktree', 'gitStatusByWorktree', 'gitStatusHeadByWorktree', + 'gitBranchLineTotalByWorktree', 'gitIgnoredPathsByWorktree', 'gitConflictOperationByWorktree', 'trackedConflictPathsByWorktree', @@ -2695,6 +2697,7 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial { if ( !isCurrentDetectedWorktreeRefresh(s, args.refresh) || @@ -2948,6 +2953,10 @@ function mergeFetchedWorktrees( args.refresh.result, args.hostId ) + authoritativelyRemovedIds = removedIds + if (args.refresh.result.authoritative) { + authoritativelySeenIds = args.refresh.result.worktrees.map((worktree) => worktree.id) + } const worktreesChanged = !areWorktreesEqual(s.worktreesByRepo[args.repoId], mergedWorktrees) const detectedChanged = !areDetectedWorktreeResultsEqual( s.detectedWorktreesByRepo[args.repoId], @@ -2977,9 +2986,210 @@ function mergeFetchedWorktrees( ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) } }) + if (admitted) { + // Why: applied outside the updater so a repeated updater call cannot double-apply the removal memory. + forgetAuthoritativelyRemovedWorktrees(args.hostId, authoritativelySeenIds) + rememberAuthoritativelyRemovedWorktrees(args.hostId, authoritativelyRemovedIds) + } return admitted } +// Why: an authoritative scan is the only proof a remote worktree is gone, but SSH WorktreeMeta is exempt from +// gcStaleWorktreeMeta (persistence.ts:407,415), so without this memory the metadata fallback re-appends every +// deleted row on the next disconnect — forever. +const AUTHORITATIVE_REMOVAL_MEMORY_LIMIT = 512 +const authoritativelyRemovedWorktreeIdsByHost = new Map>() + +function rememberAuthoritativelyRemovedWorktrees( + hostId: ExecutionHostId, + worktreeIds: readonly string[] +): void { + if (worktreeIds.length === 0) { + return + } + const removed = authoritativelyRemovedWorktreeIdsByHost.get(hostId) ?? new Set() + for (const worktreeId of worktreeIds) { + // Why: re-insert so a re-removed id moves to the back of the insertion order the cap evicts from. + removed.delete(worktreeId) + removed.add(worktreeId) + } + for (const oldest of removed) { + if (removed.size <= AUTHORITATIVE_REMOVAL_MEMORY_LIMIT) { + break + } + removed.delete(oldest) + } + authoritativelyRemovedWorktreeIdsByHost.set(hostId, removed) +} + +// Why: a remote path can be recreated, so a scan that reports it again retracts the deletion verdict. +function forgetAuthoritativelyRemovedWorktrees( + hostId: ExecutionHostId, + worktreeIds: Iterable +): void { + const removed = authoritativelyRemovedWorktreeIdsByHost.get(hostId) + if (!removed) { + return + } + for (const worktreeId of worktreeIds) { + removed.delete(worktreeId) + } + if (removed.size === 0) { + authoritativelyRemovedWorktreeIdsByHost.delete(hostId) + } +} + +/** Test-only: module-level removal memory would otherwise leak across cases in one file. */ +export function resetAuthoritativelyRemovedWorktreeMemoryForTests(): void { + authoritativelyRemovedWorktreeIdsByHost.clear() +} + +function appendMissingWorktreesForHost< + T extends { id: string; hostId?: ExecutionHostId; runtimeOwnerEnvironmentId?: string } +>( + current: readonly T[] | undefined, + incoming: readonly T[], + hostId: ExecutionHostId, + options: WorktreeHostMatchOptions +): T[] { + const existing = current ?? [] + const existingHostIds = new Set( + existing + .filter((worktree) => worktreeMatchesHost(worktree, hostId, options)) + .map(({ id }) => id) + ) + const missing = incoming.filter((worktree) => !existingHostIds.has(worktree.id)) + if (missing.length === 0) { + return [...existing] + } + // Why: land inside the host's block like mergeWorktreesForHost does, else these rows sit past sibling hosts and visibly jump once the authoritative scan splices them back. + const lastHostIndex = existing.findLastIndex((worktree) => + worktreeMatchesHost(worktree, hostId, options) + ) + if (lastHostIndex === -1) { + return [...existing, ...missing] + } + return [...existing.slice(0, lastHostIndex + 1), ...missing, ...existing.slice(lastHostIndex + 1)] +} + +function isAdmittedKnownSshWorktreeResult( + result: HostQualifiedKnownWorktreeResult, + repoId: string, + executionHostId: SshExecutionHostId +): result is Extract { + return ( + result.status === 'complete' && + result.repoId === repoId && + result.executionHostId === executionHostId && + isDetectedWorktreeListResult(result.result) && + result.result.repoId === repoId && + result.result.authoritative === false + ) +} + +const inflightKnownSshWorktreeFetches = new Map< + string, + Promise +>() + +// Why: the authoritative path dedupes through listDetectedWorktreesForRepoCoalesced; without a matching guard +// the four refresh triggers can each issue this IPC and its merge for the same repo/host. +async function fetchKnownSshWorktreesForRepo( + set: Parameters>[0], + repoId: string, + executionHostId: SshExecutionHostId +): Promise { + const coalesceKey = `${repoId}${executionHostId}` + const inflight = inflightKnownSshWorktreeFetches.get(coalesceKey) + if (inflight) { + return await inflight + } + const request = runKnownSshWorktreeFetch(set, repoId, executionHostId).finally(() => { + inflightKnownSshWorktreeFetches.delete(coalesceKey) + }) + inflightKnownSshWorktreeFetches.set(coalesceKey, request) + return await request +} + +async function runKnownSshWorktreeFetch( + set: Parameters>[0], + repoId: string, + executionHostId: SshExecutionHostId +): Promise { + // Why: reads the local store only, so a runtime-hub session (whose repo ids live on the hub) always gets 'rejected' and keeps the pre-existing no-op. + const listKnown = window.api.worktrees.listKnownForExecutionHost + if (typeof listKnown !== 'function') { + return null + } + const result = await listKnown({ repoId, executionHostId }) + if (!isAdmittedKnownSshWorktreeResult(result, repoId, executionHostId)) { + return null + } + // Why: persisted SSH metadata outlives the remote worktree, so drop rows a completed scan already proved gone. + const suppressedIds = authoritativelyRemovedWorktreeIdsByHost.get(executionHostId) + const known = + suppressedIds && suppressedIds.size > 0 + ? { + ...result.result, + worktrees: result.result.worktrees.filter((worktree) => !suppressedIds.has(worktree.id)) + } + : result.result + let admitted = false + set((state) => { + // Why: the provider can connect during the await; authoritative rows already replaced this host, so appending stale metadata would resurrect purged worktrees. + if ( + getCurrentDirectSshAuthority(state, executionHostId) || + !repoHasExactlyOneExecutionHostOwner(state, repoId, executionHostId, false) + ) { + return state + } + admitted = true + const setup = getProjectHostSetupForRepoHost(state, repoId, executionHostId) + const matchOptions = worktreeHostMatchOptions(state, repoId, executionHostId) + const incomingDetected = known.worktrees.map((worktree) => + withRepoHostOwnership(worktree, executionHostId, setup) + ) + const priorDetected = state.detectedWorktreesByRepo[repoId] + // Why: only the rows are ours to merge. This entry is keyed by repo alone, so adopting the fallback's + // authoritative/source would demote a sibling host's completed scan and blank every authoritative-gated surface. + const detected = { + ...(priorDetected ?? known), + worktrees: appendMissingWorktreesForHost( + priorDetected?.worktrees, + incomingDetected, + executionHostId, + matchOptions + ) + } + const worktrees = appendMissingWorktreesForHost( + state.worktreesByRepo[repoId], + toVisibleWorktrees(known, executionHostId, setup), + executionHostId, + matchOptions + ) + const worktreesChanged = !areWorktreesEqual(state.worktreesByRepo[repoId], worktrees) + const detectedChanged = !areDetectedWorktreeResultsEqual( + state.detectedWorktreesByRepo[repoId], + detected + ) + if (!worktreesChanged && !detectedChanged) { + return state + } + return { + ...(worktreesChanged + ? { + worktreesByRepo: { ...state.worktreesByRepo, [repoId]: worktrees }, + sortEpoch: state.sortEpoch + 1 + } + : {}), + ...(detectedChanged + ? { detectedWorktreesByRepo: { ...state.detectedWorktreesByRepo, [repoId]: detected } } + : {}) + } + }) + return admitted ? known : null +} + export type DirectSshDetectedWorktreeRefresh = { waiterLeaseId: DetectedWorktreeRefreshLease['waiterLeaseId'] providerRequestId: ProviderRequestId @@ -3110,6 +3320,9 @@ export const createWorktreeSlice: StateCreator ? (getCurrentDirectSshAuthority(ownerState, hostId) ?? undefined) : undefined if (parsedHost?.kind === 'ssh' && !directSshAuthority) { + // Why: this function's contract is detected-only. The fallback runs for its store side effect, but + // callers keep seeing null as they did before the metadata path existed. + await fetchKnownSshWorktreesForRepo(set, repoId, parsedHost.id) return null } const refresh = await listDetectedWorktreesForRepoCoalesced( @@ -3208,6 +3421,11 @@ export const createWorktreeSlice: StateCreator ? (directCallerAuthority ?? getCurrentDirectSshAuthority(ownerState, hostId) ?? undefined) : undefined if (parsedHost?.kind === 'ssh' && !directSshAuthority) { + // Why: requireAuthoritative callers asked for authoritative-or-nothing, so writing non-authoritative + // rows as a side effect before returning false would silently weaken that contract. + if (!options?.requireAuthoritative) { + await fetchKnownSshWorktreesForRepo(set, repoId, parsedHost.id) + } return false } const refresh = await listDetectedWorktreesForRepoCoalesced(settings, repoId, { @@ -3272,6 +3490,7 @@ export const createWorktreeSlice: StateCreator ? (getCurrentDirectSshAuthority(requestStartedState, hostId) ?? undefined) : undefined if (parsedHost?.kind === 'ssh' && !directSshAuthority) { + await fetchKnownSshWorktreesForRepo(set, r.id, parsedHost.id) return } const refresh = await listDetectedWorktreesForRepoCoalesced(settings, r.id, { @@ -3323,6 +3542,7 @@ export const createWorktreeSlice: StateCreator ? (getCurrentDirectSshAuthority(requestStartedState, hostId) ?? undefined) : undefined if (parsedHost?.kind === 'ssh' && !directSshAuthority) { + await fetchKnownSshWorktreesForRepo(set, r.id, parsedHost.id) return { repoId: r.id, ok: false as const } } const refresh = await listDetectedWorktreesForRepoCoalesced( @@ -4080,6 +4300,11 @@ export const createWorktreeSlice: StateCreator // Why: invalidate stale probes once deletion is authoritative, so an old toast can't mutate a same-path replacement. forgetHugeRepoWarningDismissalsForWorktrees([worktreeId]) + // Why: forget-local is legal while the host is unreachable, so record the removal here too — otherwise an + // in-flight metadata read that snapshotted this row re-appends it, and disconnected polls never drop it. + if (hostId && parseExecutionHostId(hostId)?.kind === 'ssh') { + rememberAuthoritativelyRemovedWorktrees(hostId, [worktreeId]) + } const worktreeDisplayName = worktreeBeforeRemoval?.displayName?.trim() if (worktreeDisplayName) { @@ -4191,6 +4416,8 @@ export const createWorktreeSlice: StateCreator delete nextGitStatusByWorktree[worktreeId] const nextGitStatusHeadByWorktree = { ...s.gitStatusHeadByWorktree } delete nextGitStatusHeadByWorktree[worktreeId] + const nextGitBranchLineTotalByWorktree = { ...s.gitBranchLineTotalByWorktree } + delete nextGitBranchLineTotalByWorktree[worktreeId] const nextGitIgnoredPathsByWorktree = { ...s.gitIgnoredPathsByWorktree } delete nextGitIgnoredPathsByWorktree[worktreeId] const nextGitConflictOperationByWorktree = { ...s.gitConflictOperationByWorktree } @@ -4354,6 +4581,7 @@ export const createWorktreeSlice: StateCreator gitStatusHugeByWorktree: nextGitStatusHugeByWorktree, gitStatusByWorktree: nextGitStatusByWorktree, gitStatusHeadByWorktree: nextGitStatusHeadByWorktree, + gitBranchLineTotalByWorktree: nextGitBranchLineTotalByWorktree, gitIgnoredPathsByWorktree: nextGitIgnoredPathsByWorktree, gitConflictOperationByWorktree: nextGitConflictOperationByWorktree, trackedConflictPathsByWorktree: nextTrackedConflictPathsByWorktree, diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index a8aec38fc92..92dd209d047 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -3616,6 +3616,99 @@ describe('web git preload API', () => { { method: 'git.remoteCommitUrl', params: { worktree: 'id:wt-1', sha: TEST_COMMIT_OID } } ]) }) + + it('sends the branch line total merge base only when the chip asked for one', async () => { + const runtimeCalls: { method: string; params: unknown }[] = [] + const worktree = { + id: 'wt-1', + repoId: 'repo-1', + path: '/workspace/repo', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true, + displayName: 'repo', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + workspaceStatus: 'todo' + } + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params?: unknown): Promise> { + runtimeCalls.push({ method, params }) + if (method === 'repo.list') { + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: { repos: [{ id: 'repo-1' }] }, + _meta: { runtimeId: 'runtime-1' } + }) + } + if (method === 'worktree.detectedList') { + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: { repoId: 'repo-1', authoritative: true, worktrees: [worktree] }, + _meta: { runtimeId: 'runtime-1' } + }) + } + return Promise.resolve({ + id: `call-${runtimeCalls.length}`, + ok: true, + result: { entries: [], conflictOperation: 'unknown' }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await globals.window.api.git.status({ + worktreePath: '/workspace/repo', + branchLineTotalMergeBase: TEST_COMMIT_OID + }) + await globals.window.api.git.status({ worktreePath: '/workspace/repo' }) + + const statusCalls = runtimeCalls.filter((call) => call.method === 'git.status') + // Why: strict — `toEqual` would pass on a forwarded `branchLineTotalMergeBase: undefined`, + // which is exactly what the conditional spread must avoid sending. + expect(statusCalls).toStrictEqual([ + { + method: 'git.status', + params: { + worktree: 'id:wt-1', + includeIgnored: undefined, + bypassEffectiveUpstreamNegativeCache: undefined, + reuseLineStats: undefined, + branchLineTotalMergeBase: TEST_COMMIT_OID + } + }, + { + method: 'git.status', + params: { + worktree: 'id:wt-1', + includeIgnored: undefined, + bypassEffectiveUpstreamNegativeCache: undefined, + reuseLineStats: undefined + } + } + ]) + }) }) describe('web GitHub preload API', () => { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 55ad967929a..64239245158 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1979,6 +1979,7 @@ function createGitApi(): NonNullable['git']> { includeIgnored, bypassEffectiveUpstreamNegativeCache, reuseLineStats, + branchLineTotalMergeBase, requestToken }) => { const worktree = await resolveRuntimeWorktreeByPath(worktreePath) @@ -1986,7 +1987,8 @@ function createGitApi(): NonNullable['git']> { worktree: toRuntimeWorktreeSelector(worktree.id), includeIgnored, bypassEffectiveUpstreamNegativeCache, - reuseLineStats + reuseLineStats, + ...(branchLineTotalMergeBase ? { branchLineTotalMergeBase } : {}) } // Why: no token = nothing to cancel (pooled); a token routes via the subscription bridge so cancelStatus can abort. if (!requestToken) { diff --git a/src/renderer/src/web/web-runtime-client.test.ts b/src/renderer/src/web/web-runtime-client.test.ts index 85a5575e3bf..e9d510a6234 100644 --- a/src/renderer/src/web/web-runtime-client.test.ts +++ b/src/renderer/src/web/web-runtime-client.test.ts @@ -210,6 +210,8 @@ describe('WebRuntimeClient', () => { } timerWindow.setTimeout = setTimeout timerWindow.clearTimeout = clearTimeout + // Pin the one-sided reconnect jitter to zero so the redial lands on its exact backoff step. + const random = vi.spyOn(Math, 'random').mockReturnValue(0) const client = new WebRuntimeClient({ v: 2, endpoint: 'ws://127.0.0.1:6768', @@ -235,6 +237,7 @@ describe('WebRuntimeClient', () => { expect(replacementSocket.send).toHaveBeenCalledTimes(1) } finally { client.close() + random.mockRestore() vi.useRealTimers() } }) diff --git a/src/renderer/src/web/web-runtime-client.ts b/src/renderer/src/web/web-runtime-client.ts index 12507f6225f..3ed33e18887 100644 --- a/src/renderer/src/web/web-runtime-client.ts +++ b/src/renderer/src/web/web-runtime-client.ts @@ -16,6 +16,7 @@ import { } from './web-e2ee' import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import { createWebRuntimeUnauthorizedError } from './web-runtime-client-error' +import { withReconnectJitter } from '../../../shared/reconnect-jitter' type WebRuntimeConnectionState = | 'disconnected' @@ -641,8 +642,9 @@ export class WebRuntimeClient { if (this.reconnectTimer || this.intentionallyClosed) { return } - const delay = + const delay = withReconnectJitter( RECONNECT_DELAYS_MS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)] + ) this.reconnectAttempt += 1 this.reconnectTimer = window.setTimeout(() => { this.reconnectTimer = null diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index e8c55be052d..85780660b2a 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -23,9 +23,7 @@ describe('getDefaultSettings', () => { expect(getDefaultSettings('/tmp').sourceControlViewMode).toBe('list') }) - it('keeps Source Control changes first by default', () => { - expect(getDefaultSettings('/tmp').sourceControlGroupOrder).toBe('changes-first') - }) + it('uses a commit-oriented Source Control layout by default', () => {}) it('defaults mobile pairing to discovered network addresses', () => { expect(getDefaultSettings('/tmp').mobilePairingCustomAddress).toBeNull() diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 80fca73c828..33b927dccf1 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -29,7 +29,6 @@ import { DEFAULT_LEFT_SIDEBAR_TINT_COLOR, DEFAULT_LEFT_SIDEBAR_TINT_OPACITY } from './left-sidebar-appearance' -import { DEFAULT_SOURCE_CONTROL_GROUP_ORDER } from './source-control-group-order' import { DEFAULT_SETUP_AGENT_STARTUP_POLICY } from './setup-agent-startup-policy' import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from './terminal-scrollback-policy' import { DEFAULT_USAGE_PERCENTAGE_DISPLAY } from './usage-percentage-display' @@ -272,7 +271,6 @@ export function getDefaultSettings(homedir: string): GlobalSettings { rightSidebarOpenByDefault: true, showGitIgnoredFiles: true, sourceControlViewMode: 'list', - sourceControlGroupOrder: DEFAULT_SOURCE_CONTROL_GROUP_ORDER, sourceControlCompareAgainstUpstream: false, showTitlebarAppName: true, showTasksButton: true, diff --git a/src/shared/detected-worktree-provider-contract.ts b/src/shared/detected-worktree-provider-contract.ts index 4c5289db8fa..38b38ace37e 100644 --- a/src/shared/detected-worktree-provider-contract.ts +++ b/src/shared/detected-worktree-provider-contract.ts @@ -23,6 +23,24 @@ export type ListDetectedWorktreesArgs = | LocalDetectedWorktreeRequest | DirectSshDetectedWorktreeRequest +export type ListKnownWorktreesForExecutionHostArgs = { + repoId: string + executionHostId: SshExecutionHostId +} + +export type HostQualifiedKnownWorktreeResult = + | { + status: 'complete' + repoId: string + executionHostId: SshExecutionHostId + result: DetectedWorktreeListResult + } + | { + status: 'rejected' + repoId: string + executionHostId: SshExecutionHostId + } + export type AuthoritativeDetectedWorktreeHost = | { kind: 'local' diff --git a/src/shared/file-name-sort.test.ts b/src/shared/file-name-sort.test.ts new file mode 100644 index 00000000000..b9bd1c52473 --- /dev/null +++ b/src/shared/file-name-sort.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { compareFileNames, sortDirEntries } from './file-name-sort' + +describe('compareFileNames', () => { + it('sorts numeric name segments naturally instead of lexicographically', () => { + const names = [ + '1 - item.txt', + '100 - item.txt', + '2 - item.txt', + '200 - item.txt', + '409 - item.txt', + '41 - item.txt', + '410 - item.txt', + '9 - item.txt', + '99 - item.txt' + ] + expect([...names].sort(compareFileNames)).toEqual([ + '1 - item.txt', + '2 - item.txt', + '9 - item.txt', + '41 - item.txt', + '99 - item.txt', + '100 - item.txt', + '200 - item.txt', + '409 - item.txt', + '410 - item.txt' + ]) + }) + + it('sorts embedded numbers naturally within alphabetical order', () => { + expect(['b1.md', 'a10.md', 'a2.md', 'a1.md'].sort(compareFileNames)).toEqual([ + 'a1.md', + 'a2.md', + 'a10.md', + 'b1.md' + ]) + }) + + it('breaks numeric-collation ties deterministically instead of readdir order', () => { + // numeric collation ties "2" with "02"; the code-unit fallback keeps a total order. + expect(['2.txt', '02.txt'].sort(compareFileNames)).toEqual(['02.txt', '2.txt']) + expect(['02.txt', '2.txt'].sort(compareFileNames)).toEqual(['02.txt', '2.txt']) + expect(compareFileNames('a.txt', 'a.txt')).toBe(0) + }) +}) + +describe('sortDirEntries', () => { + it('keeps directories first, each group in natural order', () => { + const entries = [ + { name: '10 - notes', isDirectory: false }, + { name: '2 - src', isDirectory: true }, + { name: '9 - docs.txt', isDirectory: false }, + { name: '10 - assets', isDirectory: true } + ] + expect(sortDirEntries(entries).map((e) => e.name)).toEqual([ + '2 - src', + '10 - assets', + '9 - docs.txt', + '10 - notes' + ]) + }) +}) diff --git a/src/shared/file-name-sort.ts b/src/shared/file-name-sort.ts new file mode 100644 index 00000000000..0d2de903fd1 --- /dev/null +++ b/src/shared/file-name-sort.ts @@ -0,0 +1,30 @@ +// Why hoisted: localeCompare with an options object resolves a fresh ICU collator +// on every comparison (~14x this form). Numeric collation itself costs ~5x over +// bare localeCompare — accepted for the feature; don't "optimize" the hoist away. +// numeric: true orders "99 - a" before "100 - b", matching Finder/Explorer. +// Locale pinned to 'en' so local main, renderer, relay, and remote runtime hosts +// all produce one order regardless of each process's LANG (precedent: +// skill-freshness pins 'en' for canonical ordering). +export const fileNameCollator = new Intl.Collator('en', { numeric: true }) + +export function compareFileNames(a: string, b: string): number { + const primary = fileNameCollator.compare(a, b) + if (primary !== 0) { + return primary + } + // Why: numeric collation ties distinct names ("2" vs "02"); fall back to code + // units so sibling order stays a total order instead of readdir order. + return a < b ? -1 : a > b ? 1 : 0 +} + +/** Directories-first, then natural name order — the File Explorer listing contract. */ +export function sortDirEntries( + entries: T[] +): T[] { + return entries.sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1 + } + return compareFileNames(a.name, b.name) + }) +} diff --git a/src/shared/git-branch-line-total-soft-deadline.test.ts b/src/shared/git-branch-line-total-soft-deadline.test.ts new file mode 100644 index 00000000000..9ef6c6872d9 --- /dev/null +++ b/src/shared/git-branch-line-total-soft-deadline.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest' +import { + beginGitStatusLineStatsCacheWrite, + clearGitStatusLineStatsCache, + readCachedGitBranchLineTotal, + reuseOrRecomputeGitStatusLineStats +} from './git-status-line-stats-cache' + +const MERGE_BASE = 'a'.repeat(40) +const CACHE_KEY = 'native\0/repo' +const entries = [{ path: 'a.txt', area: 'unstaged', status: 'M', added: 1, removed: 0 }] + +async function runPass(diffDelayMs: number): Promise<{ elapsed: number; total: unknown }> { + const cacheKey = CACHE_KEY + const started = Date.now() + const result = await reuseOrRecomputeGitStatusLineStats({ + cacheKey, + head: 'head-1', + entries, + writeToken: beginGitStatusLineStatsCacheWrite(cacheKey), + reuse: false, + isAborted: () => false, + recompute: async () => true, + branchLineTotal: { + mergeBase: MERGE_BASE, + compute: () => + new Promise((resolve) => + setTimeout(() => resolve({ added: 5, removed: 5, mergeBase: MERGE_BASE }), diffDelayMs) + ) + } + }) + return { elapsed: Date.now() - started, total: result.branchLineTotal } +} + +describe('branch line total never blocks the status response', () => { + it('returns fast when the ranged diff is slow, then publishes it on the next pass', async () => { + clearGitStatusLineStatsCache() + const slow = await runPass(3000) + expect(slow.total).toBeUndefined() + expect(slow.elapsed).toBeLessThan(1200) + + // Why: poll the cache rather than run another pass — a second recompute + // stores, which retires the first pass's still-pending late-arrival token. + await vi.waitFor( + () => { + expect( + readCachedGitBranchLineTotal({ cacheKey: CACHE_KEY, mergeBase: MERGE_BASE }) + ).toEqual({ added: 5, removed: 5, mergeBase: MERGE_BASE }) + }, + { timeout: 15000, interval: 50 } + ) + const next = await runPass(3000) + expect(next.total).toEqual({ added: 5, removed: 5, mergeBase: MERGE_BASE }) + }, 20000) + + it('still returns the total inline when the diff is fast', async () => { + clearGitStatusLineStatsCache() + const fast = await runPass(10) + expect(fast.total).toEqual({ added: 5, removed: 5, mergeBase: MERGE_BASE }) + expect(fast.elapsed).toBeLessThan(400) + }) +}) diff --git a/src/shared/git-branch-line-total.test.ts b/src/shared/git-branch-line-total.test.ts new file mode 100644 index 00000000000..054c4fc6897 --- /dev/null +++ b/src/shared/git-branch-line-total.test.ts @@ -0,0 +1,434 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + buildGitBranchLineTotalDiffArgs, + invalidateGitBranchLineTotalInFlight, + computeGitBranchLineTotal, + GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS, + GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS, + isGitBranchLineTotalMergeBase, + readGitBranchLineTotalMergeBaseParam, + sumGitBranchLineTotal +} from './git-branch-line-total' +import type { GitLineStats } from './git-uncommitted-line-stats' + +const MERGE_BASE = 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4' +const OTHER_MERGE_BASE = '0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c' + +function statsMap(entries: Record): ReadonlyMap { + return new Map(Object.entries(entries)) +} + +function createAbortError(): Error { + const error = new Error('The operation was aborted.') + error.name = 'AbortError' + return error +} + +const tempRoots: string[] = [] + +async function createWorktreeDir(files: Record = {}): Promise { + const root = await mkdtemp(path.join(tmpdir(), 'orca-branch-line-total-')) + tempRoots.push(root) + for (const [relativePath, contents] of Object.entries(files)) { + await writeFile(path.join(root, relativePath), contents) + } + return root +} + +beforeEach(() => { + invalidateGitBranchLineTotalInFlight() +}) + +afterEach(async () => { + invalidateGitBranchLineTotalInFlight() + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('sumGitBranchLineTotal', () => { + it('counts binary entries as zero rather than NaN', () => { + expect( + sumGitBranchLineTotal({ + mergeBase: MERGE_BASE, + tracked: statsMap({ + 'blob.bin': { added: undefined, removed: undefined }, + 'src/a.ts': { added: 4, removed: 1 } + }), + untracked: statsMap({}) + }) + ).toEqual({ added: 4, removed: 1, mergeBase: MERGE_BASE }) + }) + + it('counts a half-binary entry on the side git did report', () => { + expect( + sumGitBranchLineTotal({ + mergeBase: MERGE_BASE, + tracked: statsMap({ 'odd.txt': { added: 3 } }), + untracked: statsMap({ 'new.bin': {} }) + }) + ).toEqual({ added: 3, removed: 0, mergeBase: MERGE_BASE }) + }) + + it('adds untracked additions on top of the tracked range and echoes the merge base', () => { + expect( + sumGitBranchLineTotal({ + mergeBase: MERGE_BASE, + tracked: statsMap({ 'src/a.ts': { added: 10, removed: 2 } }), + untracked: statsMap({ 'src/new.ts': { added: 7, removed: 0 }, 'src/also.ts': { added: 1 } }) + }) + ).toEqual({ added: 18, removed: 2, mergeBase: MERGE_BASE }) + }) + + it('returns an all-zero total for a pure rename rather than omitting it', () => { + expect( + sumGitBranchLineTotal({ + mergeBase: MERGE_BASE, + tracked: statsMap({ 'g.txt': { added: 0, removed: 0 } }), + untracked: statsMap({}) + }) + ).toEqual({ added: 0, removed: 0, mergeBase: MERGE_BASE }) + }) +}) + +describe('merge base param validation', () => { + it('accepts abbreviated and full object names', () => { + for (const value of ['abc1234', MERGE_BASE, 'f'.repeat(64)]) { + expect(isGitBranchLineTotalMergeBase(value)).toBe(true) + expect(readGitBranchLineTotalMergeBaseParam(value)).toBe(value) + } + }) + + it('rejects anything that is not an object name, notably flag-shaped input', () => { + const rejected: unknown[] = [ + '--upload-pack=x', + '-M', + 'HEAD', + '', + 'origin/main', + 'A1B2C3D', + 'abc123', + 'f'.repeat(65), + 'abc1234 --output=/tmp/x', + undefined, + null, + 42, + { toString: () => MERGE_BASE } + ] + for (const value of rejected) { + expect(isGitBranchLineTotalMergeBase(value)).toBe(false) + expect(readGitBranchLineTotalMergeBaseParam(value)).toBeUndefined() + } + }) +}) + +describe('buildGitBranchLineTotalDiffArgs', () => { + it('diffs the merge base against the working tree with -M only', () => { + expect(buildGitBranchLineTotalDiffArgs(MERGE_BASE)).toEqual([ + '-c', + 'core.quotePath=false', + 'diff', + '-z', + '--numstat', + '-M', + MERGE_BASE, + '--' + ]) + }) + + it('never asks for --cached or -C, which would change the measured range', () => { + const args = buildGitBranchLineTotalDiffArgs(MERGE_BASE) + expect(args).not.toContain('--cached') + expect(args).not.toContain('-C') + expect(args.at(-1)).toBe('--') + }) + + it('exposes a finite timeout budget so a huge branch cannot block the status response', () => { + expect(GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS).toBeGreaterThan(0) + expect(Number.isFinite(GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS)).toBe(true) + }) +}) + +describe('computeGitBranchLineTotal', () => { + it('never invokes git for a merge base that is not an object name', async () => { + const runDiffNumstat = vi.fn() + for (const mergeBase of ['--upload-pack=x', 'HEAD', '', 'origin/main']) { + await expect( + computeGitBranchLineTotal({ + worktreePath: '/repo', + hostKey: 'native', + mergeBase, + untrackedPaths: [], + runDiffNumstat + }) + ).resolves.toBeUndefined() + } + expect(runDiffNumstat).not.toHaveBeenCalled() + }) + + it('includes untracked additions the ranged diff cannot see', async () => { + const worktreePath = await createWorktreeDir({ 'new.txt': 'one\ntwo\nthree\n' }) + + await expect( + computeGitBranchLineTotal({ + worktreePath, + hostKey: 'native', + mergeBase: MERGE_BASE, + untrackedPaths: ['new.txt'], + runDiffNumstat: async () => '4\t1\tsrc/a.ts\0' + }) + ).resolves.toEqual({ added: 7, removed: 1, mergeBase: MERGE_BASE }) + }) + + it('omits the total when the ranged numstat fails instead of publishing untracked-only zeros', async () => { + const worktreePath = await createWorktreeDir({ 'new.txt': 'one\ntwo\n' }) + + await expect( + computeGitBranchLineTotal({ + worktreePath, + hostKey: 'native', + mergeBase: MERGE_BASE, + untrackedPaths: ['new.txt'], + runDiffNumstat: async () => { + throw new Error('fatal: bad object') + } + }) + ).resolves.toBeUndefined() + }) + + it('coalesces concurrent callers sharing a host, worktree and merge base into one exec', async () => { + let release = (): void => {} + const gate = new Promise((resolve) => { + release = resolve + }) + const runDiffNumstat = vi.fn(async () => { + await gate + return '4\t1\tsrc/a.ts\0' + }) + const input = { + worktreePath: '/repo', + hostKey: 'native', + mergeBase: MERGE_BASE, + untrackedPaths: [], + runDiffNumstat + } + + const both = Promise.all([computeGitBranchLineTotal(input), computeGitBranchLineTotal(input)]) + release() + + expect(await both).toEqual([ + { added: 4, removed: 1, mergeBase: MERGE_BASE }, + { added: 4, removed: 1, mergeBase: MERGE_BASE } + ]) + expect(runDiffNumstat).toHaveBeenCalledTimes(1) + }) + + it('keeps separate execs for a different merge base, worktree or host', async () => { + const runDiffNumstat = vi.fn(async () => '1\t0\tsrc/a.ts\0') + const base = { + worktreePath: '/repo', + hostKey: 'native', + mergeBase: MERGE_BASE, + untrackedPaths: [], + runDiffNumstat + } + + await Promise.all([ + computeGitBranchLineTotal(base), + computeGitBranchLineTotal({ ...base, mergeBase: OTHER_MERGE_BASE }), + computeGitBranchLineTotal({ ...base, worktreePath: '/other-repo' }), + computeGitBranchLineTotal({ ...base, hostKey: 'Ubuntu' }) + ]) + + expect(runDiffNumstat).toHaveBeenCalledTimes(4) + }) + + it('re-execs once the shared lease has settled', async () => { + const runDiffNumstat = vi.fn(async () => '1\t0\tsrc/a.ts\0') + const input = { + worktreePath: '/repo', + hostKey: 'native', + mergeBase: MERGE_BASE, + untrackedPaths: [], + runDiffNumstat + } + + await computeGitBranchLineTotal(input) + await computeGitBranchLineTotal(input) + + expect(runDiffNumstat).toHaveBeenCalledTimes(2) + }) + + it('rejects when the shared diff is aborted instead of resolving a partial total', async () => { + const controller = new AbortController() + const runDiffNumstat = vi.fn( + (_args: string[], signal: AbortSignal) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(createAbortError()), { once: true }) + }) + ) + + const pending = computeGitBranchLineTotal({ + worktreePath: '/repo', + hostKey: 'native', + mergeBase: MERGE_BASE, + untrackedPaths: [], + runDiffNumstat, + signal: controller.signal + }) + const assertion = expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + controller.abort() + await assertion + }) + + it('rejects immediately for a signal that is already aborted', async () => { + const runDiffNumstat = vi.fn(async () => '1\t0\tsrc/a.ts\0') + + await expect( + computeGitBranchLineTotal({ + worktreePath: '/repo', + hostKey: 'native', + mergeBase: MERGE_BASE, + untrackedPaths: [], + runDiffNumstat, + signal: AbortSignal.abort() + }) + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(runDiffNumstat).not.toHaveBeenCalled() + }) + + it('passes the built argv straight through to the runner', async () => { + const runDiffNumstat = vi.fn(async () => '') + + await computeGitBranchLineTotal({ + worktreePath: '/repo', + hostKey: 'native', + mergeBase: MERGE_BASE, + untrackedPaths: [], + runDiffNumstat + }) + + expect(runDiffNumstat).toHaveBeenCalledWith( + buildGitBranchLineTotalDiffArgs(MERGE_BASE), + expect.anything() + ) + }) + + it('invalidateGitBranchLineTotalInFlight stops a later pass joining a pre-mutation diff', async () => { + let release = (): void => {} + const gate = new Promise((resolve) => { + release = resolve + }) + const runDiffNumstat = vi.fn(async () => { + await gate + return '1\t0\tsrc/a.ts\0' + }) + const input = { + worktreePath: '/repo', + hostKey: 'native', + mergeBase: MERGE_BASE, + untrackedPaths: [], + runDiffNumstat + } + + const first = computeGitBranchLineTotal(input) + invalidateGitBranchLineTotalInFlight() + const second = computeGitBranchLineTotal(input) + release() + await Promise.all([first, second]) + + expect(runDiffNumstat).toHaveBeenCalledTimes(2) + }) +}) + +describe('computeGitBranchLineTotal ranged-diff cooldown', () => { + let nowMs = 0 + + function diffTaking(durationMs: number): () => Promise { + return vi.fn(async () => { + nowMs += durationMs + return '10\t2\tsrc/a.ts\0' + }) + } + + function inputFor( + runDiffNumstat: () => Promise, + worktreePath = '/repo' + ): Parameters[0] { + return { + worktreePath, + hostKey: 'native', + mergeBase: MERGE_BASE, + untrackedPaths: [], + runDiffNumstat + } + } + + const TOTAL = { added: 10, removed: 2, mergeBase: MERGE_BASE } + + beforeEach(() => { + nowMs = 0 + vi.spyOn(performance, 'now').mockImplementation(() => nowMs) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('skips a rerun for as long as the overrunning diff itself took', async () => { + const runDiffNumstat = diffTaking(900) + const input = inputFor(runDiffNumstat) + + await expect(computeGitBranchLineTotal(input)).resolves.toEqual(TOTAL) + await expect(computeGitBranchLineTotal(input)).resolves.toBeUndefined() + expect(runDiffNumstat).toHaveBeenCalledTimes(1) + + nowMs += 900 + await expect(computeGitBranchLineTotal(input)).resolves.toEqual(TOTAL) + expect(runDiffNumstat).toHaveBeenCalledTimes(2) + }) + + it('leaves a diff inside the soft deadline uncooled, so ordinary repos are untouched', async () => { + const runDiffNumstat = diffTaking(GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS - 1) + const input = inputFor(runDiffNumstat) + + await expect(computeGitBranchLineTotal(input)).resolves.toEqual(TOTAL) + await expect(computeGitBranchLineTotal(input)).resolves.toEqual(TOTAL) + expect(runDiffNumstat).toHaveBeenCalledTimes(2) + }) + + it('arms the cooldown on a failing diff too, since a timeout is the costliest outcome', async () => { + const runDiffNumstat = vi.fn(async () => { + nowMs += GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS + throw new Error('timed out') + }) + const input = inputFor(runDiffNumstat) + + await expect(computeGitBranchLineTotal(input)).resolves.toBeUndefined() + await expect(computeGitBranchLineTotal(input)).resolves.toBeUndefined() + expect(runDiffNumstat).toHaveBeenCalledTimes(1) + }) + + it('clears the cooldown on a git mutation, when a fresh total matters most', async () => { + const runDiffNumstat = diffTaking(900) + const input = inputFor(runDiffNumstat) + + await expect(computeGitBranchLineTotal(input)).resolves.toEqual(TOTAL) + invalidateGitBranchLineTotalInFlight() + await expect(computeGitBranchLineTotal(input)).resolves.toEqual(TOTAL) + expect(runDiffNumstat).toHaveBeenCalledTimes(2) + }) + + it('cools down per worktree, so one slow branch cannot mute another', async () => { + const slow = diffTaking(900) + const other = diffTaking(900) + + await expect(computeGitBranchLineTotal(inputFor(slow, '/repo'))).resolves.toEqual(TOTAL) + await expect(computeGitBranchLineTotal(inputFor(slow, '/repo'))).resolves.toBeUndefined() + await expect(computeGitBranchLineTotal(inputFor(other, '/other'))).resolves.toEqual(TOTAL) + + expect(slow).toHaveBeenCalledTimes(1) + expect(other).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/shared/git-branch-line-total.ts b/src/shared/git-branch-line-total.ts new file mode 100644 index 00000000000..270b2870488 --- /dev/null +++ b/src/shared/git-branch-line-total.ts @@ -0,0 +1,214 @@ +import { GitStatusReadLeaseOwner } from './git-status-read-lease-owner' +import type { GitBranchLineTotal } from './git-status-types' +import { + collectUntrackedAdditions, + parseNumstat, + type GitLineStats +} from './git-uncommitted-line-stats' + +export type { GitBranchLineTotal } + +// Why: a mergeBase→worktree diff on a very large branch over SSH is unbounded +// work. Past this budget the chip is dropped rather than the status response +// blocked — an absent total is the documented "not known exact" rendering. +export const GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS = 15_000 + +// Why: the hard timeout bounds the git process, not the wait. The status +// response carries the file list and staging state, so it must never sit behind +// a ranged diff — past this budget the pass publishes without the total and the +// diff keeps running to backfill the cache for the next pass. +export const GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS = 500 + +const BRANCH_LINE_TOTAL_PENDING = Symbol('branch-line-total-pending') + +/** + * Resolves with the total only if it lands inside the soft budget; otherwise + * resolves undefined and hands the still-running diff to `onLateArrival`. + */ +export async function settleGitBranchLineTotalWithinSoftDeadline(input: { + total: Promise + onLateArrival: (total: GitBranchLineTotal) => void + softDeadlineMs?: number +}): Promise { + let timer: ReturnType | undefined + const deadline = new Promise((resolve) => { + timer = setTimeout( + () => resolve(BRANCH_LINE_TOTAL_PENDING), + input.softDeadlineMs ?? GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS + ) + // Why: a pending status timer must not hold the process open at shutdown. + timer.unref?.() + }) + const settled = await Promise.race([input.total, deadline]) + clearTimeout(timer) + if (settled !== BRANCH_LINE_TOTAL_PENDING) { + return settled + } + void input.total.then((late) => { + if (late) { + input.onLateArrival(late) + } + }) + return undefined +} + +/** + * `git diff ` with no second rev and no `--cached` compares that + * commit to the working tree, so committed + staged + unstaged collapse into one + * correctly-deduplicated result. Summing the per-area status rows instead would + * double-count any line touched in two areas. + * + * `-M` only (not `-M -C`): matches the working-tree numstat the CHANGES rows are + * built from, and avoids `-C`'s cost on wide diffs. The trailing `--` keeps the + * OID parsed as a rev even if a path of the same name exists. + */ +export function buildGitBranchLineTotalDiffArgs(mergeBase: string): string[] { + return ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', mergeBase, '--'] +} + +/** + * The merge base reaches the host as untrusted RPC input and is spliced into a + * git argv. Only an object name shape is ever legitimate here, so anything else + * (notably a leading `-`) is rejected before it can act as a flag. + */ +export function isGitBranchLineTotalMergeBase(value: unknown): value is string { + return typeof value === 'string' && /^[0-9a-f]{7,64}$/.test(value) +} + +/** Reads the request param without trusting its type; undefined disables the work entirely. */ +export function readGitBranchLineTotalMergeBaseParam(value: unknown): string | undefined { + return isGitBranchLineTotalMergeBase(value) ? value : undefined +} + +export function sumGitBranchLineTotal(input: { + mergeBase: string + tracked: ReadonlyMap + untracked: ReadonlyMap +}): GitBranchLineTotal { + let added = 0 + let removed = 0 + for (const stats of input.tracked.values()) { + // Binary files parse to undefined in numstat and contribute nothing, matching + // the per-file rows. + added += stats.added ?? 0 + removed += stats.removed ?? 0 + } + for (const stats of input.untracked.values()) { + added += stats.added ?? 0 + removed += stats.removed ?? 0 + } + return { added, removed, mergeBase: input.mergeBase } +} + +// Why: one shared exec per (host, worktree, mergeBase). The renderer's own +// in-flight refs only dedupe a single renderer's calls; a second window or an +// fs-watcher burst would otherwise run the ranged diff twice concurrently. +const rangedNumstatLeaseOwner = new GitStatusReadLeaseOwner | null>() + +// Why: the soft deadline hides this diff's cost from the poller's duration-aware +// backoff, so an overrunning one would restart the moment it finished. Make it +// wait out its own measured cost; the cache carry-forward keeps the chip up. +const GIT_BRANCH_LINE_TOTAL_MAX_COOLDOWN_MS = 30_000 +const GIT_BRANCH_LINE_TOTAL_COOLDOWN_MAX_KEYS = 256 +const rangedNumstatCooldownUntilMs = new Map() + +const monotonicNowMs = (): number => performance.now() + +function isRangedNumstatCoolingDown(leaseKey: string, nowMs: number): boolean { + const readyAtMs = rangedNumstatCooldownUntilMs.get(leaseKey) + if (readyAtMs === undefined) { + return false + } + if (nowMs >= readyAtMs) { + rangedNumstatCooldownUntilMs.delete(leaseKey) + return false + } + return true +} + +function recordRangedNumstatDuration(leaseKey: string, durationMs: number, nowMs: number): void { + if (durationMs <= GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS) { + rangedNumstatCooldownUntilMs.delete(leaseKey) + return + } + rangedNumstatCooldownUntilMs.delete(leaseKey) + rangedNumstatCooldownUntilMs.set( + leaseKey, + nowMs + Math.min(durationMs, GIT_BRANCH_LINE_TOTAL_MAX_COOLDOWN_MS) + ) + while (rangedNumstatCooldownUntilMs.size > GIT_BRANCH_LINE_TOTAL_COOLDOWN_MAX_KEYS) { + const oldestKey = rangedNumstatCooldownUntilMs.keys().next().value + if (oldestKey === undefined) { + return + } + rangedNumstatCooldownUntilMs.delete(oldestKey) + } +} + +/** + * Call from every path that clears the other git read caches: without it a pass + * beginning after a discard/stash/checkout joins the pre-mutation lease. + */ +export function invalidateGitBranchLineTotalInFlight(): void { + rangedNumstatLeaseOwner.invalidate() + rangedNumstatCooldownUntilMs.clear() +} + +/** + * Computes the branch total, or undefined when it cannot be known exact. + * Rejects only on abort — every other failure hides the chip instead of + * publishing a partial number. + */ +export async function computeGitBranchLineTotal(input: { + worktreePath: string + /** Distinguishes hosts that can map the same path to different filesystems (WSL distro, relay). */ + hostKey: string + mergeBase: string + untrackedPaths: readonly string[] + runDiffNumstat: (args: string[], signal: AbortSignal) => Promise + signal?: AbortSignal +}): Promise { + if (!isGitBranchLineTotalMergeBase(input.mergeBase)) { + return undefined + } + const leaseKey = `${input.hostKey}\0${input.worktreePath}\0${input.mergeBase}` + // Safe before the lease: a cooldown is only armed once a diff settled, so there + // is never an in-flight one to join while it is active. + if (isRangedNumstatCoolingDown(leaseKey, monotonicNowMs())) { + return undefined + } + const [tracked, untracked] = await Promise.all([ + rangedNumstatLeaseOwner.lease(leaseKey, input.signal, async (sharedSignal) => { + const startedAtMs = monotonicNowMs() + try { + const stdout = await input.runDiffNumstat( + buildGitBranchLineTotalDiffArgs(input.mergeBase), + sharedSignal + ) + return parseNumstat(stdout) + } catch (error) { + // Why: an aborted pass must reject so a cancelled scan is never treated + // as completed. Everything else — a bad merge base, a timeout, a + // detached worktree — yields null so the field is omitted, not zeroed. + if (sharedSignal.aborted) { + throw error + } + return null + } finally { + // `finally`, not the success path: a timeout is the costliest outcome. + if (!sharedSignal.aborted) { + const settledAtMs = monotonicNowMs() + recordRangedNumstatDuration(leaseKey, settledAtMs - startedAtMs, settledAtMs) + } + } + }), + // Untracked files are invisible to a ranged diff but already render as + // CHANGES rows, so they are added on top. Stat-keyed caching inside makes + // this near-free when attachLineStats just read the same paths. + collectUntrackedAdditions(input.worktreePath, input.untrackedPaths, input.signal) + ]) + if (tracked === null) { + return undefined + } + return sumGitBranchLineTotal({ mergeBase: input.mergeBase, tracked, untracked }) +} diff --git a/src/shared/git-status-branch-line-total-cache.test.ts b/src/shared/git-status-branch-line-total-cache.test.ts new file mode 100644 index 00000000000..cd4220c7c02 --- /dev/null +++ b/src/shared/git-status-branch-line-total-cache.test.ts @@ -0,0 +1,236 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + applyCachedGitStatusLineStats, + beginGitStatusLineStatsCacheWrite, + clearGitStatusLineStatsCache, + readCachedGitBranchLineTotal, + reuseOrRecomputeGitStatusLineStats, + storeGitStatusLineStats +} from './git-status-line-stats-cache' +import type { GitBranchLineTotal } from './git-status-types' + +const CACHE_KEY = 'native\0/repo' +const MERGE_BASE = 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4' +const OTHER_MERGE_BASE = '0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c' +const TOTAL: GitBranchLineTotal = { added: 12, removed: 3, mergeBase: MERGE_BASE } + +function entries(added?: number): { path: string; status: string; area: string; added?: number }[] { + return [{ path: 'src/a.ts', status: 'modified', area: 'unstaged', ...(added ? { added } : {}) }] +} + +function seedSnapshot(branchLineTotal?: GitBranchLineTotal): void { + storeGitStatusLineStats({ + cacheKey: CACHE_KEY, + head: 'head-1', + entries: entries(3), + ...(branchLineTotal ? { branchLineTotal } : {}) + }) +} + +describe('branch line total inside the status line-stats cache', () => { + beforeEach(() => { + clearGitStatusLineStatsCache() + }) + + it('costs nothing when the caller did not ask for a total', async () => { + const recompute = vi.fn(async () => true) + + const result = await reuseOrRecomputeGitStatusLineStats({ + cacheKey: CACHE_KEY, + head: 'head-1', + entries: entries(), + writeToken: beginGitStatusLineStatsCacheWrite(CACHE_KEY), + reuse: false, + isAborted: () => false, + recompute + }) + + expect(result).toEqual({}) + expect(recompute).toHaveBeenCalledTimes(1) + }) + + it('reuses the cached total on a line-stats reuse hit without re-running the diff', async () => { + seedSnapshot(TOTAL) + const compute = vi.fn(async () => TOTAL) + const recompute = vi.fn(async () => true) + + const result = await reuseOrRecomputeGitStatusLineStats({ + cacheKey: CACHE_KEY, + head: 'head-1', + entries: entries(), + writeToken: beginGitStatusLineStatsCacheWrite(CACHE_KEY), + reuse: true, + isAborted: () => false, + recompute, + branchLineTotal: { mergeBase: MERGE_BASE, compute } + }) + + expect(result).toEqual({ branchLineTotal: TOTAL }) + expect(compute).not.toHaveBeenCalled() + expect(recompute).not.toHaveBeenCalled() + }) + + it('recomputes and backfills when the snapshot has no total for this fork point', async () => { + seedSnapshot({ added: 99, removed: 99, mergeBase: OTHER_MERGE_BASE }) + const compute = vi.fn(async () => TOTAL) + + const first = await reuseOrRecomputeGitStatusLineStats({ + cacheKey: CACHE_KEY, + head: 'head-1', + entries: entries(), + writeToken: beginGitStatusLineStatsCacheWrite(CACHE_KEY), + reuse: true, + isAborted: () => false, + recompute: async () => true, + branchLineTotal: { mergeBase: MERGE_BASE, compute } + }) + const second = await reuseOrRecomputeGitStatusLineStats({ + cacheKey: CACHE_KEY, + head: 'head-1', + entries: entries(), + writeToken: beginGitStatusLineStatsCacheWrite(CACHE_KEY), + reuse: true, + isAborted: () => false, + recompute: async () => true, + branchLineTotal: { mergeBase: MERGE_BASE, compute } + }) + + expect(first).toEqual({ branchLineTotal: TOTAL }) + expect(second).toEqual({ branchLineTotal: TOTAL }) + expect(compute).toHaveBeenCalledTimes(1) + expect(readCachedGitBranchLineTotal({ cacheKey: CACHE_KEY, mergeBase: MERGE_BASE })).toEqual( + TOTAL + ) + }) + + it('never serves a cached total that was measured against another fork point', () => { + seedSnapshot(TOTAL) + + expect( + readCachedGitBranchLineTotal({ cacheKey: CACHE_KEY, mergeBase: OTHER_MERGE_BASE }) + ).toBeUndefined() + }) + + it('starts the ranged diff before waiting on the per-area numstat', async () => { + const order: string[] = [] + let releaseRecompute = (): void => {} + const recomputeGate = new Promise((resolve) => { + releaseRecompute = resolve + }) + + const pending = reuseOrRecomputeGitStatusLineStats({ + cacheKey: CACHE_KEY, + head: 'head-1', + entries: entries(), + writeToken: beginGitStatusLineStatsCacheWrite(CACHE_KEY), + reuse: false, + isAborted: () => false, + recompute: async () => { + order.push('recompute') + await recomputeGate + return true + }, + branchLineTotal: { + mergeBase: MERGE_BASE, + compute: async () => { + order.push('compute') + return TOTAL + } + } + }) + releaseRecompute() + + expect(await pending).toEqual({ branchLineTotal: TOTAL }) + expect(order).toEqual(['compute', 'recompute']) + }) + + it('keeps the exact total when the per-area numstat pass failed', async () => { + const result = await reuseOrRecomputeGitStatusLineStats({ + cacheKey: CACHE_KEY, + head: 'head-1', + entries: entries(), + writeToken: beginGitStatusLineStatsCacheWrite(CACHE_KEY), + reuse: false, + isAborted: () => false, + recompute: async () => false, + branchLineTotal: { mergeBase: MERGE_BASE, compute: async () => TOTAL } + }) + + expect(result).toEqual({ branchLineTotal: TOTAL }) + // The incomplete pass stays uncacheable, so nothing was pinned for reuse. + expect( + applyCachedGitStatusLineStats({ cacheKey: CACHE_KEY, head: 'head-1', entries: entries() }) + ).toBe(false) + }) + + it('omits the field entirely when the total could not be known exact', async () => { + const result = await reuseOrRecomputeGitStatusLineStats({ + cacheKey: CACHE_KEY, + head: 'head-1', + entries: entries(4), + writeToken: beginGitStatusLineStatsCacheWrite(CACHE_KEY), + reuse: false, + isAborted: () => false, + recompute: async () => true, + branchLineTotal: { mergeBase: MERGE_BASE, compute: async () => undefined } + }) + + expect(result).toEqual({}) + expect('branchLineTotal' in result).toBe(false) + expect(readCachedGitBranchLineTotal({ cacheKey: CACHE_KEY, mergeBase: MERGE_BASE })).toBe( + undefined + ) + }) + + it('rejects instead of returning a partial total when the pass aborts mid-diff', async () => { + let aborted = false + + await expect( + reuseOrRecomputeGitStatusLineStats({ + cacheKey: CACHE_KEY, + head: 'head-1', + entries: entries(), + writeToken: beginGitStatusLineStatsCacheWrite(CACHE_KEY), + reuse: false, + isAborted: () => aborted, + recompute: async () => true, + branchLineTotal: { + mergeBase: MERGE_BASE, + compute: async () => { + aborted = true + const error = new Error('The operation was aborted.') + error.name = 'AbortError' + throw error + } + } + }) + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(readCachedGitBranchLineTotal({ cacheKey: CACHE_KEY, mergeBase: MERGE_BASE })).toBe( + undefined + ) + }) + + it('rejects a reuse hit whose backfill compute is aborted', async () => { + seedSnapshot() + let aborted = false + + await expect( + reuseOrRecomputeGitStatusLineStats({ + cacheKey: CACHE_KEY, + head: 'head-1', + entries: entries(), + writeToken: beginGitStatusLineStatsCacheWrite(CACHE_KEY), + reuse: true, + isAborted: () => aborted, + recompute: async () => true, + branchLineTotal: { + mergeBase: MERGE_BASE, + compute: async () => { + aborted = true + return TOTAL + } + } + }) + ).rejects.toMatchObject({ name: 'AbortError' }) + }) +}) diff --git a/src/shared/git-status-line-stats-cache.ts b/src/shared/git-status-line-stats-cache.ts index 9a3ccf59356..2ddbfaf96fc 100644 --- a/src/shared/git-status-line-stats-cache.ts +++ b/src/shared/git-status-line-stats-cache.ts @@ -1,3 +1,19 @@ +import { settleGitBranchLineTotalWithinSoftDeadline } from './git-branch-line-total' +import { + beginGitStatusLineStatsCacheWrite, + bumpGitStatusLineStatsKeyGeneration, + isWriteTokenCurrent, + markGitStatusLineStatsStored, + resetGitStatusLineStatsWriteGenerations, + type GitStatusLineStatsWriteToken +} from './git-status-line-stats-write-token' +import type { GitBranchLineTotal } from './git-status-types' + +export { + beginGitStatusLineStatsCacheWrite, + type GitStatusLineStatsWriteToken +} from './git-status-line-stats-write-token' + type GitStatusLineStatsEntry = { path?: unknown status?: unknown @@ -19,13 +35,10 @@ type CachedLineStats = { identity: string storedAt: number stats: { added?: number; removed?: number }[] -} - -export type GitStatusLineStatsWriteToken = { - cacheKey: string - globalGeneration: number - keyGeneration: number - beginSeq: number + // Why: the branch total is derived from the same tree snapshot as the entry + // stats, so it must share their reuse lifecycle — a poll that reuses line + // stats must reuse the total rather than re-running the ranged diff. + branchLineTotal?: GitBranchLineTotal } // Why: the TTL is the sole staleness backstop when file contents change while @@ -33,50 +46,11 @@ export type GitStatusLineStatsWriteToken = { // reuse identity), so a missed watcher signal pins counts for at most this long. export const GIT_STATUS_LINE_STATS_CACHE_MAX_AGE_MS = 2 * 60_000 const GIT_STATUS_LINE_STATS_CACHE_MAX_ENTRIES = 128 -const GIT_STATUS_LINE_STATS_WRITE_KEYS_MAX_ENTRIES = 1024 const lineStatsByWorktree = new Map() -// Why: mutation invalidation must retire scans that began before it. A scan -// captures these generations at begin; a mismatch at store/clear time means an -// invalidation happened mid-scan and the derived stats may be pre-mutation. -let globalInvalidationGeneration = 0 -const keyInvalidationGenerationByWorktree = new Map() -// Why: overlapping recomputes must resolve latest-begun-wins without letting a -// reuse-only read (which never stores) starve an older recompute's store. -const lastStoredBeginSeqByWorktree = new Map() -let nextBeginSeq = 0 // Why: wall-clock steps (NTP, VM resume) must not extend or shrink the TTL. const monotonicNowMs = (): number => performance.now() -function bumpBoundedKeyMap(map: Map, cacheKey: string, value: number): void { - map.delete(cacheKey) - map.set(cacheKey, value) - while (map.size > GIT_STATUS_LINE_STATS_WRITE_KEYS_MAX_ENTRIES) { - const oldestKey = map.keys().next().value - if (oldestKey === undefined) { - return - } - map.delete(oldestKey) - } -} - -export function beginGitStatusLineStatsCacheWrite(cacheKey: string): GitStatusLineStatsWriteToken { - return { - cacheKey, - globalGeneration: globalInvalidationGeneration, - keyGeneration: keyInvalidationGenerationByWorktree.get(cacheKey) ?? 0, - beginSeq: ++nextBeginSeq - } -} - -function isWriteTokenCurrent(token: GitStatusLineStatsWriteToken): boolean { - return ( - token.globalGeneration === globalInvalidationGeneration && - token.keyGeneration === (keyInvalidationGenerationByWorktree.get(token.cacheKey) ?? 0) && - token.beginSeq >= (lastStoredBeginSeqByWorktree.get(token.cacheKey) ?? 0) - ) -} - function createInputIdentity(head: string | undefined, entries: GitStatusLineStatsEntry[]): string { return JSON.stringify([ head ?? null, @@ -143,27 +117,72 @@ export function applyCachedGitStatusLineStats(input: { return true } +/** + * Only valid immediately after `applyCachedGitStatusLineStats` returned true — + * that call is what proves the snapshot is fresh and matches this scan's + * entries. The merge base must match too, or the total describes a fork point + * this scan is no longer comparing against. + */ +export function readCachedGitBranchLineTotal(input: { + cacheKey: string + mergeBase: string +}): GitBranchLineTotal | undefined { + const total = lineStatsByWorktree.get(input.cacheKey)?.branchLineTotal + return total?.mergeBase === input.mergeBase ? total : undefined +} + +/** + * Backfills a total onto a snapshot that was reused for its entry stats. Purely + * additive, so unlike a full store it must not retire older in-flight scans. + */ +export function updateCachedGitBranchLineTotal(input: { + cacheKey: string + head?: string + entries: GitStatusLineStatsEntry[] + branchLineTotal: GitBranchLineTotal + writeToken: GitStatusLineStatsWriteToken +}): void { + if (!isWriteTokenCurrent(input.writeToken)) { + return + } + const cached = lineStatsByWorktree.get(input.cacheKey) + if (!cached || cached.identity !== createInputIdentity(input.head, input.entries)) { + return + } + cached.branchLineTotal = input.branchLineTotal +} + export function storeGitStatusLineStats(input: { cacheKey: string head?: string entries: GitStatusLineStatsEntry[] now?: number writeToken?: GitStatusLineStatsWriteToken + branchLineTotal?: GitBranchLineTotal }): void { const writeToken = input.writeToken ?? beginGitStatusLineStatsCacheWrite(input.cacheKey) if (!isWriteTokenCurrent(writeToken)) { return } - bumpBoundedKeyMap(lastStoredBeginSeqByWorktree, input.cacheKey, writeToken.beginSeq) + markGitStatusLineStatsStored(input.cacheKey, writeToken.beginSeq) const now = input.now ?? monotonicNowMs() + const identity = createInputIdentity(input.head, input.entries) + // Why: a total that missed its soft deadline lands here later; an unchanged + // snapshot must carry it forward or the next recompute would drop it and the + // chip could never appear on a repo where the diff is always slow. + const previous = lineStatsByWorktree.get(input.cacheKey) + const branchLineTotal = + input.branchLineTotal ?? + (previous?.identity === identity ? previous.branchLineTotal : undefined) lineStatsByWorktree.delete(input.cacheKey) lineStatsByWorktree.set(input.cacheKey, { - identity: createInputIdentity(input.head, input.entries), + identity, storedAt: now, stats: input.entries.map((entry) => ({ ...(entry.added === undefined ? {} : { added: entry.added }), ...(entry.removed === undefined ? {} : { removed: entry.removed }) - })) + })), + ...(branchLineTotal === undefined ? {} : { branchLineTotal }) }) trimLineStatsCache(now) } @@ -189,7 +208,12 @@ export async function reuseOrRecomputeGitStatusLineStats(input: { reuse: boolean isAborted: () => boolean recompute: () => Promise -}): Promise { + /** Omitted when the caller did not ask for a branch total, which costs nothing. */ + branchLineTotal?: { + mergeBase: string + compute: () => Promise + } +}): Promise<{ branchLineTotal?: GitBranchLineTotal }> { if (input.isAborted()) { // Why: reject rather than resolve — a cancelled scan must not look like a // completed status result (including a cache-hit reuse path). @@ -206,9 +230,26 @@ export async function reuseOrRecomputeGitStatusLineStats(input: { if (input.isAborted()) { throw createGitStatusLineStatsAbortError() } - return + return reuseCachedBranchLineTotal(input) } + // Why: started before the await below so both diffs run concurrently, and + // pre-caught so an aborted total can never surface as an unhandled rejection + // when recompute rejects first. + const totalPromise = input.branchLineTotal?.compute().catch(() => undefined) const complete = await input.recompute() + const branchLineTotal = totalPromise + ? await settleGitBranchLineTotalWithinSoftDeadline({ + total: totalPromise, + onLateArrival: (late) => + updateCachedGitBranchLineTotal({ + cacheKey: input.cacheKey, + head: input.head, + entries: input.entries, + branchLineTotal: late, + writeToken: input.writeToken + }) + }) + : undefined if (input.isAborted()) { // Why: an aborted pass never reached storeGitStatusLineStats, so there is // nothing partial to undo; clearing here would instead evict a concurrent @@ -217,21 +258,80 @@ export async function reuseOrRecomputeGitStatusLineStats(input: { throw createGitStatusLineStatsAbortError() } if (!complete) { - return + // Why: the total comes from its own ranged diff, so a failed per-area + // numstat leaves it exact even though the entry stats are uncacheable. + return branchLineTotal === undefined ? {} : { branchLineTotal } } storeGitStatusLineStats({ cacheKey: input.cacheKey, head: input.head, entries: input.entries, - writeToken: input.writeToken + writeToken: input.writeToken, + ...(branchLineTotal === undefined ? {} : { branchLineTotal }) }) + if (!input.branchLineTotal) { + return {} + } + // Why: read back rather than return the local — the store carries forward a + // total that arrived late on an unchanged snapshot, which is the only way the + // chip ever appears where the diff always outruns the soft deadline. + const published = readCachedGitBranchLineTotal({ + cacheKey: input.cacheKey, + mergeBase: input.branchLineTotal.mergeBase + }) + return published === undefined ? {} : { branchLineTotal: published } +} + +async function reuseCachedBranchLineTotal(input: { + cacheKey: string + head?: string + entries: GitStatusLineStatsEntry[] + writeToken: GitStatusLineStatsWriteToken + isAborted: () => boolean + branchLineTotal?: { + mergeBase: string + compute: () => Promise + } +}): Promise<{ branchLineTotal?: GitBranchLineTotal }> { + if (!input.branchLineTotal) { + return {} + } + const cached = readCachedGitBranchLineTotal({ + cacheKey: input.cacheKey, + mergeBase: input.branchLineTotal.mergeBase + }) + if (cached) { + return { branchLineTotal: cached } + } + // The snapshot predates this feature or was computed against another fork + // point; compute once and backfill so the next reuse hit is free. A reuse pass + // exists to be cheap, so it waits no longer than any other for the diff. + const backfill = (late: GitBranchLineTotal): void => { + updateCachedGitBranchLineTotal({ + cacheKey: input.cacheKey, + head: input.head, + entries: input.entries, + branchLineTotal: late, + writeToken: input.writeToken + }) + } + const branchLineTotal = await settleGitBranchLineTotalWithinSoftDeadline({ + total: input.branchLineTotal.compute().catch(() => undefined), + onLateArrival: backfill + }) + if (input.isAborted()) { + throw createGitStatusLineStatsAbortError() + } + if (branchLineTotal === undefined) { + return {} + } + backfill(branchLineTotal) + return { branchLineTotal } } export function clearGitStatusLineStatsCache(): void { - globalInvalidationGeneration += 1 lineStatsByWorktree.clear() - keyInvalidationGenerationByWorktree.clear() - lastStoredBeginSeqByWorktree.clear() + resetGitStatusLineStatsWriteGenerations() } export function clearGitStatusLineStatsCacheKey( @@ -242,15 +342,11 @@ export function clearGitStatusLineStatsCacheKey( return } if (writeToken === undefined) { - bumpBoundedKeyMap( - keyInvalidationGenerationByWorktree, - cacheKey, - (keyInvalidationGenerationByWorktree.get(cacheKey) ?? 0) + 1 - ) + bumpGitStatusLineStatsKeyGeneration(cacheKey) } else { // Why: a token-scoped purge must retire scans that began before it, so an // older in-flight scan can't store pre-purge counts and repopulate this key. - bumpBoundedKeyMap(lastStoredBeginSeqByWorktree, cacheKey, writeToken.beginSeq) + markGitStatusLineStatsStored(cacheKey, writeToken.beginSeq) } lineStatsByWorktree.delete(cacheKey) } diff --git a/src/shared/git-status-line-stats-write-token.ts b/src/shared/git-status-line-stats-write-token.ts new file mode 100644 index 00000000000..0a15bd3919a --- /dev/null +++ b/src/shared/git-status-line-stats-write-token.ts @@ -0,0 +1,69 @@ +/** + * Generation bookkeeping that decides whether a git-status line-stats scan may + * still write. Split from the cache itself so the snapshot store stays readable. + */ +export type GitStatusLineStatsWriteToken = { + cacheKey: string + globalGeneration: number + keyGeneration: number + beginSeq: number +} + +const GIT_STATUS_LINE_STATS_WRITE_KEYS_MAX_ENTRIES = 1024 + +// Why: mutation invalidation must retire scans that began before it. A scan +// captures these generations at begin; a mismatch at store/clear time means an +// invalidation happened mid-scan and the derived stats may be pre-mutation. +let globalInvalidationGeneration = 0 +const keyInvalidationGenerationByWorktree = new Map() +// Why: overlapping recomputes must resolve latest-begun-wins without letting a +// reuse-only read (which never stores) starve an older recompute's store. +const lastStoredBeginSeqByWorktree = new Map() +let nextBeginSeq = 0 + +function bumpBoundedKeyMap(map: Map, cacheKey: string, value: number): void { + map.delete(cacheKey) + map.set(cacheKey, value) + while (map.size > GIT_STATUS_LINE_STATS_WRITE_KEYS_MAX_ENTRIES) { + const oldestKey = map.keys().next().value + if (oldestKey === undefined) { + return + } + map.delete(oldestKey) + } +} + +export function beginGitStatusLineStatsCacheWrite(cacheKey: string): GitStatusLineStatsWriteToken { + return { + cacheKey, + globalGeneration: globalInvalidationGeneration, + keyGeneration: keyInvalidationGenerationByWorktree.get(cacheKey) ?? 0, + beginSeq: ++nextBeginSeq + } +} + +export function isWriteTokenCurrent(token: GitStatusLineStatsWriteToken): boolean { + return ( + token.globalGeneration === globalInvalidationGeneration && + token.keyGeneration === (keyInvalidationGenerationByWorktree.get(token.cacheKey) ?? 0) && + token.beginSeq >= (lastStoredBeginSeqByWorktree.get(token.cacheKey) ?? 0) + ) +} + +export function markGitStatusLineStatsStored(cacheKey: string, beginSeq: number): void { + bumpBoundedKeyMap(lastStoredBeginSeqByWorktree, cacheKey, beginSeq) +} + +export function bumpGitStatusLineStatsKeyGeneration(cacheKey: string): void { + bumpBoundedKeyMap( + keyInvalidationGenerationByWorktree, + cacheKey, + (keyInvalidationGenerationByWorktree.get(cacheKey) ?? 0) + 1 + ) +} + +export function resetGitStatusLineStatsWriteGenerations(): void { + globalInvalidationGeneration += 1 + keyInvalidationGenerationByWorktree.clear() + lastStoredBeginSeqByWorktree.clear() +} diff --git a/src/shared/git-status-read-lease-owner.ts b/src/shared/git-status-read-lease-owner.ts new file mode 100644 index 00000000000..a0012da7d3a --- /dev/null +++ b/src/shared/git-status-read-lease-owner.ts @@ -0,0 +1,101 @@ +type StatusReadEntry = { + controller: AbortController + promise: Promise + liveLeases: number + settled: boolean +} + +function getAbortReason(signal: AbortSignal): unknown { + try { + signal.throwIfAborted() + } catch (error) { + return error + } + return new DOMException('This operation was aborted', 'AbortError') +} + +export class GitStatusReadLeaseOwner { + private readonly entries = new Map>() + + lease( + key: string, + signal: AbortSignal | undefined, + load: (sharedSignal: AbortSignal) => Promise + ): Promise { + if (signal?.aborted) { + return Promise.reject(getAbortReason(signal)) + } + + let entry = this.entries.get(key) + if (!entry) { + const controller = new AbortController() + const promise = load(controller.signal) + const createdEntry = { controller, promise, liveLeases: 0, settled: false } + entry = createdEntry + this.entries.set(key, createdEntry) + void promise.then( + () => this.settle(key, createdEntry), + () => this.settle(key, createdEntry) + ) + } + + entry.liveLeases += 1 + return this.createLease(key, entry, signal) + } + + invalidate(): void { + this.entries.clear() + } + + private createLease( + key: string, + entry: StatusReadEntry, + signal: AbortSignal | undefined + ): Promise { + return new Promise((resolve, reject) => { + let active = true + const release = (abortReason?: unknown): boolean => { + if (!active) { + return false + } + active = false + signal?.removeEventListener('abort', onAbort) + entry.liveLeases -= 1 + if (abortReason !== undefined && entry.liveLeases === 0 && !entry.settled) { + if (this.entries.get(key) === entry) { + this.entries.delete(key) + } + entry.controller.abort(abortReason) + } + return true + } + const onAbort = (): void => { + const reason = getAbortReason(signal!) + if (release(reason)) { + reject(reason) + } + } + + signal?.addEventListener('abort', onAbort, { once: true }) + void entry.promise.then( + (value) => { + if (release()) { + resolve(value) + } + }, + (error: unknown) => { + if (release()) { + reject(error) + } + } + ) + }) + } + + private settle(key: string, entry: StatusReadEntry): void { + entry.settled = true + if (this.entries.get(key) === entry) { + this.entries.delete(key) + } + } +} diff --git a/src/shared/git-status-types.ts b/src/shared/git-status-types.ts index 8f1a7008c65..7b6c6e12c81 100644 --- a/src/shared/git-status-types.ts +++ b/src/shared/git-status-types.ts @@ -52,6 +52,15 @@ export type GitUncommittedEntry = { export type GitStatusEntry = GitUncommittedEntry +// `mergeBase(base, HEAD) → working tree`, deduplicated, so committing doesn't move it. +// Matches the per-file rows rather than git: binary and >2MB untracked count zero. +// `mergeBase` is echoed so a renderer can drop a moved fork point. +export type GitBranchLineTotal = { + added: number + removed: number + mergeBase: string +} + export type GitStatusResult = { entries: GitStatusEntry[] conflictOperation: GitConflictOperation @@ -69,6 +78,9 @@ export type GitStatusResult = { // "too many changes" state. didHitLimit?: boolean statusLength?: number + // Only computed when the request carried a merge-base OID (the renderer's + // visibility gate), and omitted — never zeroed — whenever it cannot be trusted. + branchLineTotal?: GitBranchLineTotal } // Why: when hasUpstream is false, ahead/behind are placeholder zeros, not a diff --git a/src/shared/native-chat-ask.test.ts b/src/shared/native-chat-ask.test.ts index 4cdad014842..29220967157 100644 --- a/src/shared/native-chat-ask.test.ts +++ b/src/shared/native-chat-ask.test.ts @@ -4,7 +4,12 @@ import { type NativeChatBlock, type NativeChatMessage } from './native-chat-types' -import { extractPendingAsk, nativeChatAskDismissKey, parseAskFromStatus } from './native-chat-ask' +import { + extractPendingAsk, + nativeChatAskDismissKey, + parseAskFromStatus, + resolveNativeChatAsk +} from './native-chat-ask' function message(id: string, blocks: NativeChatBlock[]): NativeChatMessage { return { id, role: 'assistant', blocks, timestamp: 1, source: 'transcript' } @@ -178,3 +183,23 @@ describe('parseAskFromStatus', () => { expect(prompt?.questions[0]?.options.map((o) => o.label)).toEqual(['a', 'b']) }) }) + +describe('resolveNativeChatAsk', () => { + const transcript = [message('m1', [call('AskUserQuestion', QUESTIONS_INPUT)])] + + it('withholds transcript state until the read settles', () => { + expect( + resolveNativeChatAsk({ liveAsk: null, messages: transcript, transcriptSettled: false }) + ).toBeNull() + expect( + resolveNativeChatAsk({ liveAsk: null, messages: transcript, transcriptSettled: true }) + )?.toMatchObject(QUESTIONS_INPUT) + }) + + it('keeps a live ask authoritative while transcript history is unsettled', () => { + const liveAsk = { questions: [{ question: 'Live?', options: [], multiSelect: false }] } + expect(resolveNativeChatAsk({ liveAsk, messages: transcript, transcriptSettled: false })).toBe( + liveAsk + ) + }) +}) diff --git a/src/shared/native-chat-ask.ts b/src/shared/native-chat-ask.ts index 1a37cedf3c5..5b21520fd95 100644 --- a/src/shared/native-chat-ask.ts +++ b/src/shared/native-chat-ask.ts @@ -131,6 +131,15 @@ export function extractPendingAsk(messages: readonly NativeChatMessage[]): AskPr return pending } +/** Prefers live status and consults transcript history only after its read settles. */ +export function resolveNativeChatAsk(args: { + liveAsk: AskPrompt | null + messages: readonly NativeChatMessage[] + transcriptSettled: boolean +}): AskPrompt | null { + return args.liveAsk ?? (args.transcriptSettled ? extractPendingAsk(args.messages) : null) +} + /** One question's chosen answer, normalized for delivery: the selected option * indices (in option order) plus any free-text "other" answer. Index-based (not * label text) so the answer can be delivered by the selector's stable option diff --git a/src/shared/native-chat-transcript-retention.test.ts b/src/shared/native-chat-transcript-retention.test.ts new file mode 100644 index 00000000000..e62d980bd3f --- /dev/null +++ b/src/shared/native-chat-transcript-retention.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from './native-chat-types' +import { + createNativeChatTranscriptRetention, + encodeNativeChatTranscriptIdentity +} from './native-chat-transcript-retention' + +function message(id: string): NativeChatMessage { + return { id, role: 'assistant', blocks: [], timestamp: 0, source: 'transcript' } +} + +describe('native chat transcript retention', () => { + it('holds only the latest settled transcript for the same identity while loading', () => { + const retention = createNativeChatTranscriptRetention() + const first = [message('first')] + const second = [message('second')] + + retention.capture('source-a', first) + expect( + retention.visible({ identity: 'source-a', messages: [], settled: false, loading: true }) + ).toBe(first) + expect( + retention.visible({ identity: 'source-b', messages: [], settled: false, loading: true }) + ).toEqual([]) + + retention.capture('source-b', second) + expect( + retention.visible({ identity: 'source-a', messages: [], settled: false, loading: true }) + ).toEqual([]) + expect( + retention.visible({ identity: 'source-b', messages: [], settled: false, loading: true }) + ).toBe(second) + }) + + it('never substitutes retained history for a settled or non-loading read', () => { + const retention = createNativeChatTranscriptRetention() + const retained = [message('retained')] + const fresh = [message('fresh')] + retention.capture('source', retained) + + expect( + retention.visible({ identity: 'source', messages: fresh, settled: true, loading: false }) + ).toBe(fresh) + expect( + retention.visible({ identity: 'source', messages: [], settled: false, loading: false }) + ).toEqual([]) + }) + + it('encodes identity components without delimiter collisions', () => { + expect(encodeNativeChatTranscriptIdentity(['host\0workspace', 'session'])).not.toBe( + encodeNativeChatTranscriptIdentity(['host', 'workspace\0session']) + ) + }) +}) diff --git a/src/shared/native-chat-transcript-retention.ts b/src/shared/native-chat-transcript-retention.ts new file mode 100644 index 00000000000..a2f891bb763 --- /dev/null +++ b/src/shared/native-chat-transcript-retention.ts @@ -0,0 +1,36 @@ +import type { NativeChatMessage } from './native-chat-types' + +export const EMPTY_NATIVE_CHAT_TRANSCRIPT: NativeChatMessage[] = [] + +export function encodeNativeChatTranscriptIdentity(parts: readonly (string | null)[]): string { + return JSON.stringify(parts) +} + +export type NativeChatTranscriptRetention = { + capture: (identity: string, messages: NativeChatMessage[]) => void + visible: (args: { + identity: string + messages: NativeChatMessage[] + settled: boolean + loading: boolean + }) => NativeChatMessage[] +} + +/** Retains one settled transcript so a same-source rebind does not blank the conversation. */ +export function createNativeChatTranscriptRetention(): NativeChatTranscriptRetention { + let captured: { identity: string; messages: NativeChatMessage[] } | null = null + + return { + capture(identity, messages) { + captured = { identity, messages } + }, + visible({ identity, messages, settled, loading }) { + if (settled) { + return messages + } + return loading && captured?.identity === identity + ? captured.messages + : EMPTY_NATIVE_CHAT_TRANSCRIPT + } + } +} diff --git a/src/shared/quick-open-directory-reader.test.ts b/src/shared/quick-open-directory-reader.test.ts index fcd11985523..33f9cb46a9b 100644 --- a/src/shared/quick-open-directory-reader.test.ts +++ b/src/shared/quick-open-directory-reader.test.ts @@ -22,6 +22,29 @@ beforeEach(() => { }) describe('quick-open streaming directory reader', () => { + it('orders numbered entries naturally before the recursive walk', async () => { + opendirMock.mockResolvedValue({ + async *[Symbol.asyncIterator]() { + for (const name of ['100 - notes', '9 - notes', '99 - notes']) { + yield { + name, + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false + } + } + } + }) + + const entries = await readQuickOpenDirectoryEntries({ + absPath: '/numbered', + allowSymlinkedRoot: false, + budget: createQuickOpenReaddirBudget() + }) + + expect(entries.map((entry) => entry.name)).toEqual(['9 - notes', '99 - notes', '100 - notes']) + }) + it('stops a huge directory one entry beyond the exact cap and closes its iterator', async () => { let produced = 0 let closed = false diff --git a/src/shared/quick-open-directory-reader.ts b/src/shared/quick-open-directory-reader.ts index cc53d8d6438..68a6dd66acf 100644 --- a/src/shared/quick-open-directory-reader.ts +++ b/src/shared/quick-open-directory-reader.ts @@ -1,4 +1,5 @@ import { lstat, opendir } from 'node:fs/promises' +import { compareFileNames } from './file-name-sort' import { isFileListingCancellation, throwIfFileListingCancelled } from './file-listing-cancellation' import { isQuickOpenReadableDirectory } from './quick-open-directory-validation' import { @@ -46,7 +47,7 @@ export async function readQuickOpenDirectoryEntries(opts: { : 'other' }) } - entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0)) + entries.sort((left, right) => compareFileNames(left.name, right.name)) // Why: discard buffered names if the path became a symlink while its // directory handle was open; descendants must never escape the root. diff --git a/src/shared/reconnect-jitter.test.ts b/src/shared/reconnect-jitter.test.ts new file mode 100644 index 00000000000..81b0bd448dd --- /dev/null +++ b/src/shared/reconnect-jitter.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { withReconnectJitter } from './reconnect-jitter' + +describe('withReconnectJitter', () => { + it('never returns less than the backoff floor', () => { + expect(withReconnectJitter(500, () => 0)).toBe(500) + }) + + it('spreads a fleet that was dropped by one shared blip', () => { + const fleet = Array.from({ length: 32 }, (_, index) => + withReconnectJitter(500, () => index / 32) + ) + expect(new Set(fleet).size).toBeGreaterThan(1) + expect(Math.min(...fleet)).toBeGreaterThanOrEqual(500) + expect(Math.max(...fleet)).toBeLessThanOrEqual(600) + }) +}) diff --git a/src/shared/reconnect-jitter.ts b/src/shared/reconnect-jitter.ts new file mode 100644 index 00000000000..576db7f0270 --- /dev/null +++ b/src/shared/reconnect-jitter.ts @@ -0,0 +1,7 @@ +// Why: a blip on a shared path (Tailscale, cellular, host restart) drops every socket on that path in +// the same instant. A jitterless backoff ladder then re-dials all of them at the same millisecond, +// which is exactly the thundering herd the host is least able to absorb while it is still recovering. +// One-sided so a delay is never shortened below its backoff floor. +export function withReconnectJitter(delayMs: number, random: () => number = Math.random): number { + return delayMs + Math.floor(delayMs * 0.2 * random()) +} diff --git a/src/shared/remote-runtime-shared-control-state.ts b/src/shared/remote-runtime-shared-control-state.ts index c74c50916c9..eba1d4a1be8 100644 --- a/src/shared/remote-runtime-shared-control-state.ts +++ b/src/shared/remote-runtime-shared-control-state.ts @@ -10,6 +10,7 @@ import type { SharedControlReadyWaiter } from './remote-runtime-shared-control-types' import { getSubscriptionId, isEndResult } from './remote-runtime-shared-control-protocol' +import { withReconnectJitter } from './reconnect-jitter' import { tagRuntimeSubscriptionReplayResponse } from './runtime-subscription-replay' export function buildSharedControlDiagnostics(args: { @@ -208,9 +209,3 @@ export function scheduleSharedControlReconnect(args: { return { timer, reconnectAttempt: args.reconnectAttempt + 1 } } -function withReconnectJitter(delayMs: number): number { - // Why: when a remote host restarts, all passive subscriptions reconnect - // together. A small one-sided jitter avoids synchronized retry spikes. - const jitterMs = Math.floor(delayMs * 0.2 * Math.random()) - return delayMs + jitterMs -} diff --git a/src/shared/source-control-group-order.test.ts b/src/shared/source-control-group-order.test.ts deleted file mode 100644 index 165d2359c3b..00000000000 --- a/src/shared/source-control-group-order.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - DEFAULT_SOURCE_CONTROL_GROUP_ORDER, - normalizeSourceControlGroupOrder -} from './source-control-group-order' - -describe('normalizeSourceControlGroupOrder', () => { - it('keeps supported source control group orders', () => { - expect(normalizeSourceControlGroupOrder('changes-first')).toBe('changes-first') - expect(normalizeSourceControlGroupOrder('staged-first')).toBe('staged-first') - expect(normalizeSourceControlGroupOrder('untracked-first')).toBe('untracked-first') - }) - - it('falls back to the default for malformed values', () => { - expect(normalizeSourceControlGroupOrder('tracked-first')).toBe( - DEFAULT_SOURCE_CONTROL_GROUP_ORDER - ) - expect(normalizeSourceControlGroupOrder(undefined)).toBe(DEFAULT_SOURCE_CONTROL_GROUP_ORDER) - }) -}) diff --git a/src/shared/source-control-group-order.ts b/src/shared/source-control-group-order.ts deleted file mode 100644 index acfe5160ac7..00000000000 --- a/src/shared/source-control-group-order.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { SourceControlGroupOrder } from './types' - -export const DEFAULT_SOURCE_CONTROL_GROUP_ORDER: SourceControlGroupOrder = 'changes-first' - -export function normalizeSourceControlGroupOrder(value: unknown): SourceControlGroupOrder { - return value === 'changes-first' || value === 'staged-first' || value === 'untracked-first' - ? value - : DEFAULT_SOURCE_CONTROL_GROUP_ORDER -} diff --git a/src/shared/types.ts b/src/shared/types.ts index cfa041d396f..1c68a69b54b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2704,7 +2704,6 @@ export type OpenInApplication = { } export type SourceControlViewMode = 'list' | 'tree' -export type SourceControlGroupOrder = 'changes-first' | 'staged-first' | 'untracked-first' export type LeftSidebarAppearanceMode = 'default' | 'match-terminal' | 'tinted' @@ -2890,8 +2889,6 @@ export type GlobalSettings = { showGitIgnoredFiles?: boolean /** Preferred Source Control changes layout. Per-user, not per-workspace. */ sourceControlViewMode: SourceControlViewMode - /** Preferred Source Control group order. Per-user, not per-workspace. */ - sourceControlGroupOrder: SourceControlGroupOrder /** Compare base defaults to the branch upstream instead of the repo default; affects only the compare/diff view, not the PR/rebase target. Per-user. */ sourceControlCompareAgainstUpstream: boolean /** Whether to show the Orca app name in the titlebar. */ diff --git a/tests/e2e/headless-serve-focused-terminal-create.spec.ts b/tests/e2e/headless-serve-focused-terminal-create.spec.ts new file mode 100644 index 00000000000..dd9de9467a2 --- /dev/null +++ b/tests/e2e/headless-serve-focused-terminal-create.spec.ts @@ -0,0 +1,57 @@ +import { expect, test } from './helpers/orca-app' +import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' + +// Why (#10333): a windowless `orca serve` host answered every focus-requested +// create with "No renderer window available", so `terminal create --focus` had +// no workaround. Drive the real RPC a remote CLI sends, against a real serve +// process, so the degrade is proven on the topology that broke. +test('creates a focus-requested terminal against a headless serve host', async ({ + testRepoPath +}) => { + test.setTimeout(180_000) + const host = await launchHeadlessPairedRuntimeHost() + try { + const added = await host.client.call<{ repo: { id: string } }>('repo.add', { + path: testRepoPath, + kind: 'git' + }) + let worktreeId = '' + await expect + .poll( + async () => { + const listed = await host.client.call<{ worktrees: { id: string }[] }>('worktree.list', { + repo: `id:${added.result.repo.id}` + }) + worktreeId = listed.result.worktrees[0]?.id ?? '' + return worktreeId + }, + { timeout: 30_000 } + ) + .not.toBe('') + + // Exactly what `orca --environment terminal create --worktree + // --command "echo test" --focus --json` puts on the wire. + const created = await host.client.call<{ + terminal: { handle: string; worktreeId: string; ptyId?: string } + }>('terminal.create', { + worktree: `id:${worktreeId}`, + command: 'echo orca-10333-focus', + focus: true, + presentation: 'focused' + }) + + expect(created.result.terminal.handle).toMatch(/^term_/) + expect(created.result.terminal.worktreeId).toBe(worktreeId) + + // Why: a handle the host cannot resolve back to a live PTY would be a + // hollow pass — confirm the degraded create really produced a terminal. + const listedTerminals = await host.client.call<{ + terminals: { handle: string }[] + }>('terminal.list', { worktree: `id:${worktreeId}` }) + expect(listedTerminals.result.terminals.map((terminal) => terminal.handle)).toContain( + created.result.terminal.handle + ) + } finally { + await host.dispose() + } +}) diff --git a/tests/e2e/native-chat-ask-user-question-card.spec.ts b/tests/e2e/native-chat-ask-user-question-card.spec.ts index a4cc2d7c4aa..a3788e5aa96 100644 --- a/tests/e2e/native-chat-ask-user-question-card.spec.ts +++ b/tests/e2e/native-chat-ask-user-question-card.spec.ts @@ -146,10 +146,10 @@ test.describe('Desktop chat AskUserQuestion card (#11761)', () => { // The submit button reads "Skip" until an option is chosen; picking one is // what proves the card is answerable rather than merely rendered. await orcaPage.getByRole('button', { name: /Spaces/ }).click() - await expect(orcaPage.getByRole('button', { name: 'Send answer' })).toBeVisible() + await expect(orcaPage.getByRole('button', { name: 'Submit' })).toBeVisible() await orcaPage.screenshot({ path: path.join(screenshotDir, '02-option-selected.png') }) - await orcaPage.getByRole('button', { name: 'Send answer' }).click() + await orcaPage.getByRole('button', { name: 'Submit' }).click() // The card owns the composer slot, so its disappearance is the visible // signal that the answer was accepted and chat input came back. await expect(orcaPage.getByText(QUESTION)).toHaveCount(0, { timeout: 20_000 }) diff --git a/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts b/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts index 5cc07ef0e5b..151a4b02bbc 100644 --- a/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts +++ b/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts @@ -65,8 +65,9 @@ async function installUnreadableOrcaYamlFault(electronApp: ElectronApplication): /** orca.yaml becomes readable again — every later check runs the production handler. */ async function healOrcaYamlRead(electronApp: ElectronApplication): Promise { await electronApp.evaluate(() => { - ;(globalThis as typeof globalThis & { __orcaE2eOrcaYamlUnreadable?: boolean }) - .__orcaE2eOrcaYamlUnreadable = false + ;( + globalThis as typeof globalThis & { __orcaE2eOrcaYamlUnreadable?: boolean } + ).__orcaE2eOrcaYamlUnreadable = false }) } diff --git a/tests/e2e/worktree-card-status-indicator.spec.ts b/tests/e2e/worktree-card-status-indicator.spec.ts deleted file mode 100644 index 78075cb26c2..00000000000 --- a/tests/e2e/worktree-card-status-indicator.spec.ts +++ /dev/null @@ -1,84 +0,0 @@ -// Regression coverage for active status taking precedence over passive identity (#8813). - -import type { Locator, Page } from '@stablyai/playwright-test' -import { test, expect } from './helpers/orca-app' -import { - waitForSessionReady, - waitForActiveWorktree, - getAllWorktreeIds, - ensureTerminalVisible -} from './helpers/store' -import { worktreeRow, worktreeRowSurface } from './worktree-row-locators' - -function statusLane(page: Page, worktreeId: string): Locator { - return worktreeRow(page, worktreeId).locator('[data-worktree-card-status-slot]').first() -} - -function statusDot(page: Page, worktreeId: string): Locator { - return statusLane(page, worktreeId).locator('span.bg-emerald-500').first() -} - -function branchIdentityGlyph(page: Page, worktreeId: string): Locator { - return statusLane(page, worktreeId).locator('svg.lucide-git-branch') -} - -// PTY liveness, not the tab row, drives the activity heuristic. -async function waitForLivePty(page: Page, worktreeId: string): Promise { - await expect - .poll( - async () => - page.evaluate((id) => { - const state = window.__store!.getState() - return (state.tabsByWorktree[id] ?? []).some( - (tab) => (state.ptyIdsByTabId[tab.id] ?? []).length > 0 - ) - }, worktreeId), - { timeout: 30_000, message: `No live PTY attached for worktree ${worktreeId}` } - ) - .toBe(true) -} - -test.describe('Worktree card status indicator', () => { - test.beforeEach(async ({ orcaPage }) => { - await waitForSessionReady(orcaPage) - await waitForActiveWorktree(orcaPage) - }) - - test('paints the emerald Active dot instead of the grey branch glyph once a workspace goes live', async ({ - orcaPage - }) => { - const liveWorktreeId = await waitForActiveWorktree(orcaPage) - await ensureTerminalVisible(orcaPage) - await waitForLivePty(orcaPage, liveWorktreeId) - - // The regression requires both the new card style and branch identity. - await orcaPage.evaluate(async () => { - const state = window.__store!.getState() - await state.updateSettings({ experimentalNewWorktreeCardStyle: true }) - state.setWorktreeCardProperties(['status', 'unread', 'branch']) - }) - - const quietWorktreeId = (await getAllWorktreeIds(orcaPage)).find((id) => id !== liveWorktreeId) - if (!quietWorktreeId) { - throw new Error('Seeded repo did not expose a second worktree to keep quiet') - } - - await expect(branchIdentityGlyph(orcaPage, quietWorktreeId)).toBeVisible() - await expect(statusLane(orcaPage, quietWorktreeId)).toHaveText('Branch') - - await expect(statusDot(orcaPage, liveWorktreeId)).toBeVisible() - await expect(statusLane(orcaPage, liveWorktreeId)).toHaveText('Active') - await expect(branchIdentityGlyph(orcaPage, liveWorktreeId)).toHaveCount(0) - - await worktreeRowSurface(orcaPage, quietWorktreeId).click() - await expect - .poll(async () => orcaPage.evaluate(() => window.__store!.getState().activeWorktreeId)) - .toBe(quietWorktreeId) - await ensureTerminalVisible(orcaPage) - await waitForLivePty(orcaPage, quietWorktreeId) - - await expect(statusDot(orcaPage, quietWorktreeId)).toBeVisible() - await expect(statusLane(orcaPage, quietWorktreeId)).toHaveText('Active') - await expect(branchIdentityGlyph(orcaPage, quietWorktreeId)).toHaveCount(0) - }) -})