Merge remote-tracking branch 'origin/main' into brennanb2025/provider-native-tab-titles

This commit is contained in:
Brennan Benson
2026-08-05 20:50:05 -07:00
166 changed files with 7423 additions and 1117 deletions
+1 -6
View File
@@ -150,10 +150,5 @@
}
}
],
"ignorePatterns": [
"**/node_modules",
"**/dist",
"**/out",
"tests/e2e/.cross-version-checkouts"
]
"ignorePatterns": ["**/node_modules", "**/dist", "**/out", "tests/e2e/.cross-version-checkouts"]
}
+10 -12
View File
@@ -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)
+2 -2
View File
@@ -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",
@@ -4986,6 +4986,7 @@ export default function SessionScreen() {
<MobileTerminalLiveInputStatus
dictation={dictation}
isAttaching={isAttaching}
liveInputText={liveInputCapture}
/>
</Pressable>
<MobileTerminalInputActions
+12
View File
@@ -34,6 +34,18 @@ describe('file-tree', () => {
])
})
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 = {
'': {
+2 -1
View File
@@ -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 {
+1 -1
View File
@@ -187,7 +187,7 @@ export function MobileNativeChatAsk({ prompt, onAnswer, onCancel }: Props): Reac
disabled={!canAdvance}
>
<Text style={[styles.nextText, !canAdvance && styles.nextTextDisabled]}>
{isLast ? 'Send answer' : 'Next'}
{isLast ? 'Submit' : 'Next'}
</Text>
</Pressable>
</View>
@@ -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 (
<View style={styles.status}>
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
<Text style={styles.detail} numberOfLines={1}>
<Text style={styles.detail} numberOfLines={1} ellipsizeMode="head">
{detail}
</Text>
</View>
@@ -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<boolean>
/** Resolved agent for the active chat tab (names the empty-state copy). */
nativeChatAgent: string | null
chatComposerText: string
setChatComposerText: Dispatch<SetStateAction<string>>
chatPending: MobileNativeChatPendingMessage[]
chatImagePreviewsByMessageId: Record<string, string[]>
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
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<typeof detectAgentPermission>
nativeChatQuestion: ReturnType<typeof parseAgentQuestion>
/** The pending ask, already null while dismissed (dismissal lives here so it
* survives the chat-view subtree unmounting on a view toggle). */
nativeChatAsk: ReturnType<typeof parseAskFromStatus>
/** 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<boolean>
handleNativeChatCancelAsk: () => Promise<boolean>
handleNativeChatRespondPermission: (text: string) => Promise<boolean>
handleNativeChatStop: () => void
nativeChatFilePaths: string[]
loadNativeChatFiles: (query: string) => void
handleNativeChatQuestionAnswer: (text: string) => Promise<boolean>
handleNativeChatSend: (text: string, images?: string[]) => Promise<boolean>
/** 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<MobileNativeChatSendOutcome>
/** 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
}
@@ -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<boolean>
/** Resolved agent for the active chat tab (names the empty-state copy). */
nativeChatAgent: string | null
chatComposerText: string
setChatComposerText: Dispatch<SetStateAction<string>>
chatPending: MobileNativeChatPendingMessage[]
chatImagePreviewsByMessageId: Record<string, string[]>
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
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<typeof detectAgentPermission>
nativeChatQuestion: ReturnType<typeof parseAgentQuestion>
/** The pending ask, already null while dismissed (dismissal lives here so it
* survives the chat-view subtree unmounting on a view toggle). */
nativeChatAsk: ReturnType<typeof parseAskFromStatus>
/** 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<boolean>
handleNativeChatCancelAsk: () => Promise<boolean>
handleNativeChatRespondPermission: (text: string) => Promise<boolean>
handleNativeChatStop: () => void
nativeChatFilePaths: string[]
loadNativeChatFiles: (query: string) => void
handleNativeChatQuestionAnswer: (text: string) => Promise<boolean>
handleNativeChatSend: (text: string, images?: string[]) => Promise<boolean>
/** 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<MobileNativeChatSendOutcome>
/** 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 =
@@ -15,14 +15,16 @@ const ASK = JSON.stringify({
function promptsFor(
status: Partial<AgentStatusEntry> | null,
messages: NativeChatMessage[] = []
messages: NativeChatMessage[] = [],
transcriptLoading = false
): ReturnType<typeof useMobileNativeChatPrompts> {
let captured: ReturnType<typeof useMobileNativeChatPrompts> | 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
@@ -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 {
@@ -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 () => {
@@ -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<NativeChatMessage[]>([])
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,
@@ -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', () => {
@@ -19,7 +19,7 @@ export type MobileSourceControlSection<TEntry extends MobileGitStatusEntry = Mob
data: TEntry[]
}
const AREA_ORDER: MobileGitStagingArea[] = ['unstaged', 'untracked', 'staged']
const AREA_ORDER: MobileGitStagingArea[] = ['unstaged', 'staged']
const AREA_TITLES: Record<MobileGitStagingArea, string> = {
unstaged: 'Changes',
@@ -59,7 +59,13 @@ export function buildMobileSourceControlSections<TEntry extends MobileGitStatusE
return AREA_ORDER.map((area) => ({
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)
}
@@ -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')
@@ -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<boolean>((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<boolean>((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<boolean> => {
payloads.push(payload)
if (payloads.length === 1) {
return new Promise<boolean>((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<boolean> =>
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)
})
})
@@ -1,5 +1,30 @@
type TerminalLiveMirrorSender = (handle: string, payload: string) => Promise<boolean>
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<boolean> | 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<boolean> {
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<boolean>
handle: string,
payload: string,
sender: TerminalLiveMirrorSender
): Promise<boolean> {
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<boolean>((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
}
@@ -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<TTabType extends string>({
setLiveInputCapture
}: TerminalLivePendingInputFlushOptions<TTabType>): TerminalLivePendingInputFlush {
const heldCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingLiveInputFlushRef = useRef<Promise<boolean> | null>(null)
const pendingLiveInputFlushRef = useRef(createTerminalLivePendingFlushState())
const heldLiveInputTextRef = useRef('')
const sentLiveInputTextRef = useRef('')
const pendingLiveInputHandleRef = useRef<string | null>(null)
@@ -56,6 +58,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
const resetMirrorState = useCallback(() => {
clearHeldCommitTimer()
cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
heldLiveInputTextRef.current = ''
sentLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
@@ -68,9 +71,15 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
}, [liveInputRef, resetMirrorState, setLiveInputCapture])
const waitForPendingLiveInputFlush = useCallback(async (): Promise<boolean> => {
return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef)
return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
}, [])
const sendQueuedMirrorPayload = useCallback(
(handle: string, payload: string): Promise<boolean> =>
sendLiveTerminalInputRef.current(handle, payload),
[sendLiveTerminalInputRef]
)
const runMirrorStep = useCallback(
async (handle: string, fieldText: string, commitHeld: boolean): Promise<boolean> => {
if (
@@ -107,8 +116,11 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
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<TTabType extends string>({
clearHeldCommitTimer,
liveInputTerminalHandlesRef,
resetMirrorState,
sendLiveTerminalInputRef,
sendQueuedMirrorPayload,
waitForPendingLiveInputFlush
]
)
@@ -164,7 +176,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
heldLiveInputTextRef.current = ''
sentLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
pendingLiveInputFlushRef.current = null
cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
}
}, [])
@@ -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()
})
})
@@ -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<MobileRelayPairingRecoveryResult> | 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'
}
+1 -1
View File
@@ -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",
@@ -105,7 +105,6 @@ function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings {
openLinksInAppPreferencePrompted: false,
rightSidebarOpenByDefault: true,
sourceControlViewMode: 'list',
sourceControlGroupOrder: 'changes-first',
sourceControlCompareAgainstUpstream: false,
showTitlebarAppName: true,
showTasksButton: true,
-1
View File
@@ -97,7 +97,6 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
openLinksInAppPreferencePrompted: false,
rightSidebarOpenByDefault: true,
sourceControlViewMode: 'list',
sourceControlGroupOrder: 'changes-first',
sourceControlCompareAgainstUpstream: false,
showTitlebarAppName: true,
showTasksButton: true,
+3 -101
View File
@@ -1,101 +1,3 @@
type StatusReadEntry<T> = {
controller: AbortController
promise: Promise<T>
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<T> {
private readonly entries = new Map<string, StatusReadEntry<T>>()
lease(
key: string,
signal: AbortSignal | undefined,
load: (sharedSignal: AbortSignal) => Promise<T>
): Promise<T> {
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<T>,
signal: AbortSignal | undefined
): Promise<T> {
return new Promise<T>((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<T>): 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'
@@ -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> | void) },
coalescerJoins: { count: 0, onJoin: undefined as undefined | (() => void) }
}))
vi.mock('../../shared/git-branch-line-total', async (importOriginal) => {
const actual = await importOriginal<typeof BranchLineTotal>()
return {
...actual,
computeGitBranchLineTotal: (
input: Parameters<typeof BranchLineTotal.computeGitBranchLineTotal>[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<typeof GitRunner>()
return {
...actual,
gitExecFileAsync: async (...args: Parameters<typeof GitRunner.gitExecFileAsync>) => {
gitExecCalls.push(args[0])
await execHooks.beforeExec?.(args[0])
return actual.gitExecFileAsync(...args)
},
gitExecFileAsyncBuffer: async (
...args: Parameters<typeof GitRunner.gitExecFileAsyncBuffer>
) => {
gitExecCalls.push(args[0])
return actual.gitExecFileAsyncBuffer(...args)
},
gitStreamStdout: async (...args: Parameters<typeof GitRunner.gitStreamStdout>) => {
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<void> {
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<void> {
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)
})
})
@@ -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<string> {
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<void> {
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 <mergeBase>` 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 })
})
})
@@ -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<string> {
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)
})
})
+65 -3
View File
@@ -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<GitDiffResult>()
const effectiveUpstreamStatusWriteGeneration = new Map<string, number>()
const statusReadLeaseOwner = new GitStatusReadLeaseOwner<GitStatusResult>()
// 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<GitBranchLineTotal | undefined> } | 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}`
+24
View File
@@ -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)
+9 -7
View File
@@ -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<GitStatusResult> => {
@@ -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 }
: {}),
+3 -7
View File
@@ -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 })
}
+134 -1
View File
@@ -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:<uuid>" 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',
+82 -2
View File
@@ -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<string, WorktreeMeta>,
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 (
+16 -33
View File
@@ -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<string, unknown> }
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<string, unknown>
}
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')
})
+15 -21
View File
@@ -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<GlobalSettings> | undefined
): Partial<GlobalSettings> {
const { terminalScrollbackBytes: _legacyScrollbackBytes, ...rest } = (settings ??
{}) as Partial<GlobalSettings> & { terminalScrollbackBytes?: unknown }
const {
terminalScrollbackBytes: _legacyScrollbackBytes,
sourceControlGroupOrder: _sourceControlGroupOrder,
sourceControlHierarchyDefaultedV2: _sourceControlHierarchyDefaultedV2,
...rest
} = (settings ?? {}) as Partial<GlobalSettings> & {
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<GlobalSettings>,
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)
}
@@ -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
}
+6 -1
View File
@@ -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 })
+5 -7
View File
@@ -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(
+206
View File
@@ -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 <wt> --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 }) => {
+9 -13
View File
@@ -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) {
+3 -1
View File
@@ -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({
+4
View File
@@ -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
@@ -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<typeof vi.fn>).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<void> => {
now += INTERVAL_MS
await vi.advanceTimersByTimeAsync(INTERVAL_MS)
}
const reaped = (candidate: WebSocket): boolean =>
(candidate.terminate as unknown as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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()
})
})
@@ -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])
@@ -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<typeof setInterval> | null = null
private lastTickAt: number | null = null
private readonly alive = new WeakSet<WebSocket>()
// Why: consecutive probes a socket has left unanswered. Absent = zero, so live sockets cost nothing.
private readonly missedProbes = new WeakMap<WebSocket, number>()
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.
@@ -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])
@@ -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<WebSocketTransport> {
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<void>((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)
})
})
+1 -1
View File
@@ -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 = {
+7
View File
@@ -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<HostQualifiedDetectedWorktreeResult | DetectedWorktreeListResult>
(args: LegacyDetectedWorktreeRequest): Promise<DetectedWorktreeListResult>
}
listKnownForExecutionHost?: (
args: ListKnownWorktreesForExecutionHostArgs
) => Promise<HostQualifiedKnownWorktreeResult>
cancelListDetected?: (args: { providerRequestId: ProviderRequestId }) => Promise<void>
listAll: () => Promise<Worktree[]>
create: (args: CreateWorktreeArgs) => Promise<CreateWorktreeResult>
@@ -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<GitStatusResult>
cancelStatus: (args: { requestToken: string }) => Promise<void>
+4
View File
@@ -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<unknown> => ipcRenderer.invoke('git:status', args),
cancelStatus: (args: { requestToken: string }): Promise<void> =>
@@ -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',
+11 -7
View File
@@ -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 () => {
+2 -6
View File
@@ -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<string, unknown>) {
+29 -4
View File
@@ -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<string> {
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 } : {})
}
}
+2
View File
@@ -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)
}
@@ -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<GitExec>
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<GitExec>(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<string> {
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<GitExec>(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<GitExec>(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<GitExec>(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<GitExec>(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<GitExec>(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)
})
})
})
+44
View File
@@ -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<string, unknown>[],
mergeBase: string | undefined,
signal?: AbortSignal
): { mergeBase: string; compute: () => Promise<GitBranchLineTotal | undefined> } | undefined {
if (!mergeBase) {
return undefined
}
return {
mergeBase,
compute: () =>
computeGitBranchLineTotal({
worktreePath,
// Matches the `relay\0<path>` 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 } : {})
})
}
}
@@ -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<GitStagingArea, string> = {
unstaged: 'Changes',
staged: 'Staged Changes',
@@ -39,26 +43,35 @@ const UNCOMMITTED_AREA_LABELS: Record<GitStagingArea, string> = {
function buildUncommittedRows(
entries: readonly CombinedDiffFileTreeEntry[],
collapsedDirectoryKeys: ReadonlySet<string>
collapsedDirectoryKeys: ReadonlySet<string>,
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]
)
@@ -13,7 +13,10 @@ export function GitHubMarkdownComposerEditorPane({
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
return (
<div ref={scrollContainerRef} className="relative max-h-[360px] overflow-y-auto scrollbar-sleek">
<div
ref={scrollContainerRef}
className="relative max-h-[360px] overflow-y-auto scrollbar-sleek"
>
<EditorContent editor={editor} />
<RichMarkdownTableControls
disabled={disabled}
@@ -48,13 +48,15 @@ function renderCard(canSend = true): ReturnType<typeof render> {
function cardElement(
canSend = true,
messages?: readonly NativeChatMessage[],
onShowingQuestionChange?: (showing: boolean) => void
onShowingQuestionChange?: (showing: boolean) => void,
transcriptSettled = true
): React.JSX.Element {
return (
<NativeChatInteractiveCard
paneKey="tab-1:leaf-1"
canSend={canSend}
messages={messages}
transcriptSettled={transcriptSettled}
onShowingQuestionChange={onShowingQuestionChange}
send={{
sendAnswer: mocks.sendAnswer,
@@ -101,7 +103,7 @@ function askResultMessage(): NativeChatMessage {
function chooseSpacesAndSubmit(): void {
fireEvent.click(screen.getByRole('button', { name: /Spaces/ }))
fireEvent.click(screen.getByRole('button', { name: 'Send answer' }))
fireEvent.click(screen.getByRole('button', { name: 'Submit' }))
}
describe('NativeChatInteractiveCard answer lifecycle', () => {
@@ -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?')]))
@@ -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<string | null>(null)
// A question answer is a paced multi-step write (body→Enter per question); keep
@@ -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' }])
})
@@ -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')}
</button>
@@ -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}
/>
@@ -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<void> {
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'])
})
})
@@ -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 })
}
}
@@ -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
}
])
@@ -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
}
@@ -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<string, unknown>
}
})
vi.mock('@/store', () => {
const useAppStore = Object.assign(
(selector?: (state: Record<string, unknown>) => 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<unknown> {
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<Record<string, unknown>> = {}): 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(
<TooltipProvider>
<SourceControl />
</TooltipProvider>
)
})
}
function chip(): HTMLElement | null {
return container.querySelector<HTMLElement>('[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(<TooltipProvider>{null}</TooltipProvider>))
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')
})
})
@@ -104,6 +104,7 @@ function resetState(overrides: Partial<Record<string, unknown>> = {}): void {
gitStatusByWorktree: { [mocks.activeWorktree.id]: [] },
gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] },
gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: null },
gitBranchLineTotalByWorktree: {},
gitConflictOperationByWorktree: {},
remoteStatusesByWorktree: {},
isRemoteOperationActive: false,
@@ -156,6 +156,7 @@ function resetState(overrides: Partial<Record<string, unknown>> = {}): void {
gitStatusByWorktree: { [mocks.activeWorktree.id]: [] },
gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] },
gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: null },
gitBranchLineTotalByWorktree: {},
gitConflictOperationByWorktree: {},
remoteStatusesByWorktree: {},
isRemoteOperationActive: false,
@@ -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<Set<string>>(new Set())
const [baseRefDialogOpen, setBaseRefDialogOpen] = useState(false)
const [pendingDiscard, setPendingDiscard] = useState<PendingDiscardConfirmation | null>(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<void> => {
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 {
<div className="flex items-center can-hover:opacity-0 transition-opacity group-hover/section:opacity-100 focus-within:opacity-100">
{canRevertAll && (
<ActionButton
icon={area === 'untracked' ? Trash : Undo2}
icon={discardHasUntracked ? Trash : Undo2}
// Why: for untracked files, discard deletes outright (rm -rf), so label the destructive variant explicitly.
title={
area === 'untracked'
@@ -5918,10 +5968,15 @@ function SourceControlInner(): React.JSX.Element {
'auto.components.right.sidebar.SourceControl.2f609a2e7c',
'Delete all untracked'
)
: translate(
'auto.components.right.sidebar.SourceControl.ce41708855',
'Discard all'
)
: discardHasUntracked
? translate(
'auto.components.right.sidebar.SourceControl.discardAllMixedUntracked',
'Discard changes and delete untracked files'
)
: translate(
'auto.components.right.sidebar.SourceControl.ce41708855',
'Discard all'
)
}
onClick={(event) => {
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<void>
onUnstagePaths: (paths: readonly string[]) => Promise<void>
}): React.JSX.Element {
@@ -7710,21 +7770,30 @@ function SourceControlTreeDirectoryRow({
<div className={SOURCE_CONTROL_ROW_ACTION_OVERLAY_CLASS}>
{canDiscard && (
<ActionButton
icon={node.area === 'untracked' ? Trash : Undo2}
icon={actionPaths.discardHasUntracked ? Trash : Undo2}
title={
node.area === 'untracked'
? translate(
'auto.components.right.sidebar.SourceControl.9b367363b6',
'Delete untracked in folder'
)
: translate(
'auto.components.right.sidebar.SourceControl.6d7f2a47e5',
'Discard folder'
)
: actionPaths.discardHasUntracked
? translate(
'auto.components.right.sidebar.SourceControl.discardFolderMixedUntracked',
'Discard changes and delete untracked files in folder'
)
: translate(
'auto.components.right.sidebar.SourceControl.6d7f2a47e5',
'Discard folder'
)
}
onClick={(event) => {
event.stopPropagation()
onRequestDiscardPaths(node.area, actionPaths.discardPaths)
onRequestDiscardPaths(
node.area,
actionPaths.discardPaths,
actionPaths.discardHasUntracked
)
}}
disabled={isExecutingBulk}
/>
@@ -116,6 +116,7 @@ function resetState(overrides: Partial<Record<string, unknown>> = {}): void {
gitStatusByWorktree: { [mocks.activeWorktree.id]: [] },
gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] },
gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: null },
gitBranchLineTotalByWorktree: {},
gitConflictOperationByWorktree: {},
remoteStatusesByWorktree: {},
isRemoteOperationActive: false,
@@ -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')
})
})
@@ -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<string, string>()
/** `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()
}
@@ -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' }),
@@ -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'
)
@@ -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'
])
})
})
@@ -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 }
}
@@ -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)
@@ -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<typeof vi.fn> {
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
})
})
})
@@ -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<void> {
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
@@ -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(
<SourceControlBranchContextRow
summary={summary}
compareBaseRef={null}
headDisplay={{ kind: 'branch', branchName: 'feature/line-total' }}
manualReviewUrl="https://example.test/review"
branchLineTotal={branchLineTotal}
onChangeBaseRef={vi.fn()}
onRetry={vi.fn()}
/>
)
}
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('<button type="button" data-testid="source-control-branch')
})
it('keeps full precision instead of a compact 8.3k form', () => {
const markup = renderWithLineTotal({ added: 123456, removed: 0, mergeBase: 'base' })
expect(markup).toContain(`+${(123456).toLocaleString()}`)
expect(markup).not.toContain('123k')
expect(markup).not.toContain('123.5')
})
it('omits the zero half rather than printing +42 -0', () => {
const addedOnly = renderWithLineTotal({ added: 42, removed: 0, mergeBase: 'base' })
expect(addedOnly).toContain('>+42<')
expect(addedOnly).not.toContain('>-0<')
expect(addedOnly).toContain('aria-label="42 additions"')
const removedOnly = renderWithLineTotal({ added: 0, removed: 7, mergeBase: 'base' })
expect(removedOnly).toContain('>-7<')
expect(removedOnly).not.toContain('>+0<')
expect(removedOnly).toContain('aria-label="7 deletions"')
})
it('hides the chip when both counts are zero', () => {
const markup = renderWithLineTotal({ added: 0, removed: 0, mergeBase: 'base' })
expect(markup).not.toContain('data-testid="source-control-branch-line-total"')
expect(markup).not.toContain('>+0<')
expect(markup).not.toContain('>-0<')
})
it('hides the chip when the total is absent', () => {
for (const total of [null, undefined]) {
const markup = renderWithLineTotal(total)
expect(markup).not.toContain('data-testid="source-control-branch-line-total"')
expect(markup).not.toContain('NaN')
}
})
// Lines measure the branch's work, commits measure the comparison, so each sits
// on the line that names its subject. Adjacency is what made them read as one
// number in the first place.
it('puts the chip on the head line, ahead of the base line and its commit count', () => {
const markup = renderWithLineTotal(
{ added: 8259, removed: 670, mergeBase: 'base' },
{
...readySummary,
commitsAhead: 2
}
)
const headIndex = markup.indexOf('data-testid="source-control-head-identity"')
const chipIndex = markup.indexOf('data-testid="source-control-branch-line-total"')
const aheadIndex = markup.indexOf('↑2')
const reviewIndex = markup.indexOf('aria-label="Open review page in browser"')
expect(headIndex).toBeGreaterThan(-1)
expect(chipIndex).toBeGreaterThan(headIndex)
expect(aheadIndex).toBeGreaterThan(chipIndex)
expect(reviewIndex).toBeGreaterThan(aheadIndex)
})
it('keeps the ahead count out of the line-total colors', () => {
const markup = renderWithLineTotal(
{ added: 8259, removed: 670, mergeBase: 'base' },
{ ...readySummary, commitsAhead: 2 }
)
// The `↑2` span must carry the muted class, not added-green — two adjacent
// green numbers counting different units is the bug this guards.
const aheadSpan = markup.slice(
markup.lastIndexOf('<span', markup.indexOf('↑2')),
markup.indexOf('↑2')
)
expect(aheadSpan).toContain('text-muted-foreground/70')
expect(aheadSpan).not.toContain('--git-decoration-added')
})
it('folds the chip onto the base line when there is no head identity', () => {
const markup = renderToStaticMarkup(
<SourceControlBranchContextRow
summary={readySummary}
compareBaseRef={null}
headDisplay={null}
branchLineTotal={{ added: 8259, removed: 670, mergeBase: 'base' }}
onChangeBaseRef={vi.fn()}
onRetry={vi.fn()}
/>
)
expect(markup).toContain('data-testid="source-control-branch-line-total"')
expect(markup).toContain('+8,259')
})
it('stays hidden while compare is loading or failed', () => {
const total: GitBranchLineTotal = { added: 8259, removed: 670, mergeBase: 'base' }
for (const summary of [
null,
{ ...readySummary, status: 'loading' } as GitBranchCompareSummary,
{
...readySummary,
status: 'error',
errorMessage: 'Could not compare against base'
} as GitBranchCompareSummary
]) {
const markup = renderWithLineTotal(total, summary)
expect(markup).not.toContain('data-testid="source-control-branch-line-total"')
expect(markup).not.toContain('+8,259')
}
})
})
@@ -1,12 +1,14 @@
import React from 'react'
import { ExternalLink, Loader2, RefreshCw } from 'lucide-react'
import type { GitBranchCompareSummary, GitUpstreamStatus } from '../../../../shared/types'
import type { GitBranchLineTotal } from '../../../../shared/git-status-types'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { DetachedHeadBadge } from '@/components/DetachedHeadBadge'
import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display'
import { SourceControlHeaderIconButton } from './source-control-header-icon-button'
import { SourceControlBranchLineTotalChip } from './source-control-branch-line-total-chip'
import {
buildSourceControlBranchContextStats,
formatSourceControlRefLabel,
@@ -49,9 +51,7 @@ function ContextStat({
}): React.JSX.Element {
const className = cn(
'shrink-0 tabular-nums text-muted-foreground',
stat.tone === 'muted' && 'text-muted-foreground/70',
stat.tone === 'ahead' && 'text-[color:var(--git-decoration-added)]',
stat.tone === 'behind' && 'text-[color:var(--git-decoration-deleted)]'
stat.tone === 'muted' && 'text-muted-foreground/70'
)
if (!stat.title) {
@@ -218,7 +218,8 @@ function StackedCompareFlow({
onChangeBaseRef,
changeBaseTitle,
leading,
trailing
trailing,
headTrailing
}: {
headDisplay: WorktreeGitIdentityDisplay | null
baseRef: string
@@ -227,6 +228,7 @@ function StackedCompareFlow({
changeBaseTitle: string
leading?: React.ReactNode
trailing?: React.ReactNode
headTrailing?: React.ReactNode
}): React.JSX.Element {
if (!headDisplay) {
return (
@@ -237,15 +239,27 @@ function StackedCompareFlow({
changeBaseTitle={changeBaseTitle}
showArrow={false}
leading={leading}
trailing={trailing}
// Why: no head line exists to host it, so fold it onto the base line
// rather than dropping it.
trailing={
<>
{headTrailing}
{trailing}
</>
}
/>
)
}
return (
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="min-w-0">
<HeadIdentity display={headDisplay} />
{/* Why: the line total belongs beside HEAD — it measures this branch's work.
The base line keeps the commit count, which measures the comparison. */}
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 flex-1">
<HeadIdentity display={headDisplay} />
</span>
{headTrailing}
</div>
{/* Why: spinner/actions sit on the base line — they describe compare state, not HEAD. */}
<BaseLine
@@ -267,6 +281,7 @@ export function SourceControlBranchContextRow({
headDisplay = null,
upstreamStatus,
manualReviewUrl,
branchLineTotal,
onChangeBaseRef,
onRetry
}: {
@@ -275,6 +290,7 @@ export function SourceControlBranchContextRow({
headDisplay?: WorktreeGitIdentityDisplay | null
upstreamStatus?: GitUpstreamStatus
manualReviewUrl?: string | null
branchLineTotal?: GitBranchLineTotal | null
onChangeBaseRef: () => void
onRetry: () => void
}): React.JSX.Element | null {
@@ -397,6 +413,7 @@ export function SourceControlBranchContextRow({
onChangeBaseRef={onChangeBaseRef}
changeBaseTitle={changeBaseTitle}
trailing={trailing}
headTrailing={<SourceControlBranchLineTotalChip branchLineTotal={branchLineTotal} />}
/>
</CompareFlowGroup>
)
@@ -30,11 +30,15 @@ function formatBehindBaseTitle(count: number, baseRef: string): string {
)
}
// Why: ahead/behind carry no color of their own. Green and red are reserved for
// the line-total chip that sits beside them — an `↑1` in added-green next to
// `+1,114` reads as one quantity when they count different things (commits vs
// lines). The ↑/↓ glyph already carries direction.
export type SourceControlBranchContextStat = {
key: string
label: string
title?: string
tone: 'default' | 'ahead' | 'behind' | 'muted'
tone: 'default' | 'muted'
}
export function resolveSourceControlDisplayedBaseRef(
@@ -111,7 +115,7 @@ export function buildSourceControlBranchContextStats({
key: 'upstream-ahead',
label: `↑${upstreamStatus.ahead}`,
title: formatAheadOfBaseTitle(upstreamStatus.ahead, upstreamLabel),
tone: 'ahead'
tone: 'muted'
})
}
if (upstreamStatus.behind > 0) {
@@ -119,7 +123,7 @@ export function buildSourceControlBranchContextStats({
key: 'upstream-behind',
label: `↓${upstreamStatus.behind}`,
title: formatBehindBaseTitle(upstreamStatus.behind, upstreamLabel),
tone: 'behind'
tone: 'muted'
})
}
}
@@ -139,7 +143,7 @@ export function buildSourceControlBranchContextStats({
key: 'compare-ahead',
label: `↑${commitsAhead}`,
title: formatAheadOfBaseTitle(commitsAhead, baseLabel),
tone: 'ahead'
tone: 'muted'
})
}
}
@@ -0,0 +1,74 @@
import React, { useMemo } from 'react'
import type { GitBranchLineTotal } from '../../../../shared/git-status-types'
import { getIntlLocale, translate } from '@/i18n/i18n'
// Why: raw digits, not the grouped display string — screen readers announce
// "8,259" as two numbers in several locales.
function buildAccessibleLabel(added: number, removed: number): string {
if (added > 0 && removed > 0) {
return translate(
'auto.components.right.sidebar.source.control.branch.line.total.chip.daa8e8e59b',
'{{value0}} additions, {{value1}} deletions',
{ value0: added, value1: removed }
)
}
if (added > 0) {
return translate(
'auto.components.right.sidebar.source.control.branch.line.total.chip.8a9b97b666',
'{{value0}} additions',
{ value0: added }
)
}
return translate(
'auto.components.right.sidebar.source.control.branch.line.total.chip.52c366d88d',
'{{value0}} deletions',
{ value0: removed }
)
}
// Absent, incomplete and genuinely-empty all render as nothing: no `+0 -0`, no
// spinner, no reserved width. Not clickable — `openBranchAllDiffs` is narrower.
export const SourceControlBranchLineTotalChip = React.memo(
function SourceControlBranchLineTotalChip({
branchLineTotal
}: {
branchLineTotal: GitBranchLineTotal | null | undefined
}): React.JSX.Element | null {
const added = branchLineTotal?.added ?? 0
const removed = branchLineTotal?.removed ?? 0
const hasAdded = added > 0
const hasRemoved = removed > 0
// Full precision and app-locale-aware; a status tick that leaves the counts
// alone must not rebuild these strings.
const locale = getIntlLocale()
const addedText = useMemo(() => added.toLocaleString(locale), [added, locale])
const removedText = useMemo(() => removed.toLocaleString(locale), [removed, locale])
const accessibleLabel = useMemo(() => buildAccessibleLabel(added, removed), [added, removed])
if (!hasAdded && !hasRemoved) {
return null
}
// Why: no fixed `ch` width — that clips at 5+ digits; `tabular-nums` alone
// keeps digits from jittering between refreshes.
return (
<span
role="group"
aria-label={accessibleLabel}
data-testid="source-control-branch-line-total"
className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap tabular-nums"
>
{hasAdded ? (
<span aria-hidden="true" className="text-[color:var(--git-decoration-added)]">
+{addedText}
</span>
) : null}
{hasRemoved ? (
<span aria-hidden="true" className="text-[color:var(--git-decoration-deleted)]">
-{removedText}
</span>
) : null}
</span>
)
}
)
@@ -111,4 +111,13 @@ describe('getDiscardAreaConfirmationCopy', () => {
confirmLabel: 'Discard all'
})
})
it('discloses permanent deletion for combined Changes', () => {
expect(getDiscardAreaConfirmationCopy('unstaged', 3, true)).toEqual({
title: 'Discard changes and delete untracked files?',
description:
'Tracked changes will be reverted and untracked files will be permanently deleted. This cannot be undone.',
confirmLabel: 'Delete and discard'
})
})
})
@@ -62,8 +62,25 @@ export function getDiscardEntryConfirmationCopy(
export function getDiscardAreaConfirmationCopy(
area: DiscardAllArea,
count: number
count: number,
hasUntracked = false
): DiscardConfirmationCopy {
if (area === 'unstaged' && hasUntracked) {
return {
title: translate(
'auto.components.right.sidebar.source.control.discard.confirmation.mixedTitle',
'Discard changes and delete untracked files?'
),
description: translate(
'auto.components.right.sidebar.source.control.discard.confirmation.mixedDescription',
'Tracked changes will be reverted and untracked files will be permanently deleted. This cannot be undone.'
),
confirmLabel: translate(
'auto.components.right.sidebar.source.control.discard.confirmation.mixedConfirm',
'Delete and discard'
)
}
}
switch (area) {
case 'untracked':
return {
@@ -20,7 +20,12 @@ import { translate } from '@/i18n/i18n'
export type PendingDiscardConfirmation =
| { kind: 'entry'; entry: GitStatusEntry }
| { kind: 'area'; area: DiscardAllArea; paths: readonly string[] }
| {
kind: 'area'
area: DiscardAllArea
paths: readonly string[]
hasUntracked?: boolean
}
export function focusDiscardDialogConfirmButton(
event: Event,
@@ -51,7 +56,11 @@ export function SourceControlDiscardDialog({
if (pendingDiscard.kind === 'entry') {
return getDiscardEntryConfirmationCopy(pendingDiscard.entry)
}
return getDiscardAreaConfirmationCopy(pendingDiscard.area, pendingDiscard.paths.length)
return getDiscardAreaConfirmationCopy(
pendingDiscard.area,
pendingDiscard.paths.length,
pendingDiscard.hasUntracked
)
}, [pendingDiscard])
const PendingDiscardIcon = pendingDiscardCopy?.confirmLabel.startsWith('Delete') ? Trash : Undo2
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
SOURCE_CONTROL_FILE_FILTER_QUERY_MAX_BYTES,
filterAndSortSourceControlPathEntries,
filterSourceControlGroupedPathEntries,
filterSourceControlPathEntries,
getSourceControlFileFilterState,
@@ -9,6 +10,26 @@ import {
} from './source-control-file-filter'
describe('source-control-file-filter', () => {
it('naturally orders committed branch rows without mutating store input', () => {
const entries = [
{ path: 'migrations/100.sql' },
{ path: 'migrations/9.sql' },
{ path: 'migrations/99.sql' }
]
expect(
filterAndSortSourceControlPathEntries(entries, {
normalizedFilter: '',
tooLarge: false
}).map((entry) => entry.path)
).toEqual(['migrations/9.sql', 'migrations/99.sql', 'migrations/100.sql'])
expect(entries.map((entry) => entry.path)).toEqual([
'migrations/100.sql',
'migrations/9.sql',
'migrations/99.sql'
])
})
it('normalizes bounded queries and filters entries by path', () => {
const filter = getSourceControlFileFilterState(' SRC/button ')
@@ -1,4 +1,5 @@
import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text'
import { compareFileNames } from '../../../../shared/file-name-sort'
export const SOURCE_CONTROL_FILE_FILTER_QUERY_MAX_BYTES = 2 * 1024
@@ -48,6 +49,15 @@ export function filterSourceControlPathEntries<T extends SourceControlPathEntry>
return entries.filter((entry) => entry.path.toLowerCase().includes(filter.normalizedFilter))
}
export function filterAndSortSourceControlPathEntries<T extends SourceControlPathEntry>(
entries: T[],
filter: SourceControlFileFilterState
): T[] {
return [...filterSourceControlPathEntries(entries, filter)].sort((a, b) =>
compareFileNames(a.path, b.path)
)
}
export function filterSourceControlGroupedPathEntries<T extends SourceControlPathEntry>(
grouped: SourceControlGroupedPathEntries<T>,
filter: SourceControlFileFilterState
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { SourceControlHeaderToolbar } from './source-control-header-toolbar'
import type { GitBranchCompareSummary } from '../../../../shared/types'
import type { GitBranchLineTotal } from '../../../../shared/git-status-types'
import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display'
import type { PrimaryAction } from './source-control-primary-action'
@@ -38,6 +39,7 @@ function renderToolbar(options?: {
headDisplay?: WorktreeGitIdentityDisplay | null
branchSummary?: GitBranchCompareSummary | null
compareBaseRef?: string | null
branchLineTotal?: GitBranchLineTotal | null
}): string {
return renderToStaticMarkup(
<SourceControlHeaderToolbar
@@ -66,6 +68,7 @@ function renderToolbar(options?: {
? { kind: 'branch', branchName: 'brennanb2025/source-control-branch-name' }
: options.headDisplay
}
branchLineTotal={options?.branchLineTotal}
/>
)
}
@@ -125,4 +128,17 @@ describe('SourceControlHeaderToolbar branch identity', () => {
expect(markup).not.toContain('→')
expect(markup).toContain('Create PR')
})
it('threads the branch line total down to the base-ref line', () => {
const markup = renderToolbar({ branchLineTotal: { added: 24, removed: 3, mergeBase: 'base' } })
expect(markup).toContain('aria-label="24 additions, 3 deletions"')
expect(markup).toContain('+24')
expect(markup).toContain('-3')
})
it('renders exactly as before when no branch line total is supplied', () => {
expect(renderToolbar({ branchLineTotal: undefined })).toBe(renderToolbar())
expect(renderToolbar()).not.toContain('data-testid="source-control-branch-line-total"')
})
})
@@ -5,6 +5,7 @@ import type {
GitUpstreamStatus,
SourceControlViewMode
} from '../../../../shared/types'
import type { GitBranchLineTotal } from '../../../../shared/git-status-types'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type { PrimaryAction } from './source-control-primary-action'
import { Button } from '@/components/ui/button'
@@ -41,6 +42,7 @@ type SourceControlHeaderToolbarProps = {
headDisplay?: WorktreeGitIdentityDisplay | null
upstreamStatus?: GitUpstreamStatus
manualReviewUrl?: string | null
branchLineTotal?: GitBranchLineTotal | null
}
function HostedReviewToolbarLink({
@@ -146,7 +148,8 @@ export function SourceControlHeaderToolbar({
compareBaseRef,
headDisplay = null,
upstreamStatus,
manualReviewUrl
manualReviewUrl,
branchLineTotal
}: SourceControlHeaderToolbarProps): React.JSX.Element {
const filterInputRef = useRef<HTMLInputElement>(null)
const normalizedFilter = filterQuery.trim()
@@ -293,6 +296,7 @@ export function SourceControlHeaderToolbar({
headDisplay={headDisplay}
upstreamStatus={upstreamStatus}
manualReviewUrl={manualReviewUrl}
branchLineTotal={branchLineTotal}
onChangeBaseRef={onChangeBaseRef}
onRetry={onRefreshBranchCompare}
/>
@@ -4,7 +4,8 @@ import {
buildSourceControlDisplaySections,
getConflictReviewEntries,
getSourceControlSectionViewAction,
resolveSourceControlGroupOrder,
mergeUntrackedIntoChanges,
SOURCE_CONTROL_GROUP_ORDER,
splitPinnedSourceControlConflicts,
type SourceControlEntryGroups
} from './source-control-section-order'
@@ -26,37 +27,38 @@ function groups(partial: Partial<SourceControlEntryGroups>): SourceControlEntryG
}
}
describe('resolveSourceControlGroupOrder', () => {
it('keeps Changes first by default', () => {
expect(resolveSourceControlGroupOrder(undefined)).toEqual(['unstaged', 'staged', 'untracked'])
describe('SOURCE_CONTROL_GROUP_ORDER', () => {
it('follows the edit, stage, commit workflow', () => {
expect(SOURCE_CONTROL_GROUP_ORDER).toEqual(['unstaged', 'staged', 'untracked'])
})
})
it('supports staged-first and untracked-first presets', () => {
expect(resolveSourceControlGroupOrder('staged-first')).toEqual([
'staged',
'unstaged',
'untracked'
])
expect(resolveSourceControlGroupOrder('untracked-first')).toEqual([
'untracked',
'unstaged',
'staged'
])
describe('mergeUntrackedIntoChanges', () => {
it('folds untracked entries into Changes without changing their Git area', () => {
const unstaged = entry({ area: 'unstaged', path: 'changed.ts' })
const untracked = entry({ area: 'untracked', path: 'new.ts', status: 'untracked' })
const merged = mergeUntrackedIntoChanges(
groups({ unstaged: [unstaged], untracked: [untracked] })
)
expect(merged.unstaged).toEqual([unstaged, untracked])
expect(merged.untracked).toEqual([])
expect(merged.unstaged[1]?.area).toBe('untracked')
})
})
describe('buildSourceControlDisplaySections', () => {
it('uses the configured order for normal sections', () => {
it('uses the fixed workflow order for normal sections', () => {
const sections = buildSourceControlDisplaySections(
groups({
staged: [entry({ area: 'staged', path: 'staged.ts' })],
unstaged: [entry({ area: 'unstaged', path: 'changed.ts' })],
untracked: [entry({ area: 'untracked', path: 'new.ts', status: 'untracked' })]
}),
resolveSourceControlGroupOrder('staged-first')
SOURCE_CONTROL_GROUP_ORDER
)
expect(sections.map((section) => section.id)).toEqual(['staged', 'unstaged', 'untracked'])
expect(sections.map((section) => section.id)).toEqual(['unstaged', 'staged', 'untracked'])
})
it('keeps conflicts pinned before the configured normal order', () => {
@@ -74,13 +76,13 @@ describe('buildSourceControlDisplaySections', () => {
],
untracked: [entry({ area: 'untracked', path: 'new.ts', status: 'untracked' })]
}),
resolveSourceControlGroupOrder('staged-first')
SOURCE_CONTROL_GROUP_ORDER
)
expect(sections.map((section) => section.id)).toEqual([
'conflicts',
'staged',
'unstaged',
'staged',
'untracked'
])
})
@@ -100,10 +102,7 @@ describe('buildSourceControlDisplaySections', () => {
const input = groups({ unstaged: [unresolved, resolved, normal] })
const split = splitPinnedSourceControlConflicts(input)
const sections = buildSourceControlDisplaySections(
input,
resolveSourceControlGroupOrder('changes-first')
)
const sections = buildSourceControlDisplaySections(input, SOURCE_CONTROL_GROUP_ORDER)
expect(split.pinnedConflicts.map((item) => item.path)).toEqual(['conflict.ts', 'resolved.ts'])
expect(split.normalGroups.unstaged.map((item) => item.path)).toEqual(['normal.ts'])
@@ -123,10 +122,7 @@ describe('buildSourceControlDisplaySections', () => {
const input = groups({ staged: [resolvedStaged, staged] })
const split = splitPinnedSourceControlConflicts(input)
const sections = buildSourceControlDisplaySections(
input,
resolveSourceControlGroupOrder('staged-first')
)
const sections = buildSourceControlDisplaySections(input, SOURCE_CONTROL_GROUP_ORDER)
expect(split.pinnedConflicts).toEqual([resolvedStaged])
expect(split.normalGroups.staged).toEqual([staged])
@@ -167,7 +163,7 @@ describe('buildSourceControlDisplaySections', () => {
entry({ area: 'unstaged', path: 'normal.ts' })
]
}),
resolveSourceControlGroupOrder('changes-first')
SOURCE_CONTROL_GROUP_ORDER
)
expect(getSourceControlSectionViewAction(sections[0]!)).toEqual({
@@ -191,7 +187,7 @@ describe('buildSourceControlDisplaySections', () => {
const normal = entry({ area: 'unstaged', path: 'normal.ts' })
const sections = buildSourceControlDisplaySections(
groups({ unstaged: [pinned, normal] }),
resolveSourceControlGroupOrder('changes-first')
SOURCE_CONTROL_GROUP_ORDER
)
expect(getSourceControlSectionViewAction(sections[1]!)).toEqual({
@@ -212,7 +208,7 @@ describe('buildSourceControlDisplaySections', () => {
groups({
unstaged: [resolved]
}),
resolveSourceControlGroupOrder('changes-first')
SOURCE_CONTROL_GROUP_ORDER
)
expect(getSourceControlSectionViewAction(sections[0]!)).toEqual({
@@ -240,7 +236,7 @@ describe('buildSourceControlDisplaySections', () => {
staged: [staged],
unstaged: [unstaged]
}),
resolveSourceControlGroupOrder('staged-first')
SOURCE_CONTROL_GROUP_ORDER
)
expect(getSourceControlSectionViewAction(sections[0]!)).toEqual({
@@ -1,5 +1,4 @@
import { normalizeSourceControlGroupOrder } from '../../../../shared/source-control-group-order'
import type { GitStatusEntry, SourceControlGroupOrder } from '../../../../shared/types'
import type { GitStatusEntry } from '../../../../shared/types'
export const SOURCE_CONTROL_AREAS = ['unstaged', 'staged', 'untracked'] as const
export type SourceControlSectionArea = (typeof SOURCE_CONTROL_AREAS)[number]
@@ -22,16 +21,23 @@ export type SourceControlSectionViewAction =
| { kind: 'conflict-review'; entries: SourceControlConflictReviewEntry[] }
| { kind: 'combined-diff'; area?: SourceControlSectionArea; entries: GitStatusEntry[] }
const ORDER_BY_PRESET: Record<SourceControlGroupOrder, readonly SourceControlSectionArea[]> = {
'changes-first': ['unstaged', 'staged', 'untracked'],
'staged-first': ['staged', 'unstaged', 'untracked'],
'untracked-first': ['untracked', 'unstaged', 'staged']
}
export const SOURCE_CONTROL_GROUP_ORDER: readonly SourceControlSectionArea[] = [
'unstaged',
'staged',
'untracked'
]
export function resolveSourceControlGroupOrder(
value: SourceControlGroupOrder | null | undefined
): readonly SourceControlSectionArea[] {
return ORDER_BY_PRESET[normalizeSourceControlGroupOrder(value)]
export function mergeUntrackedIntoChanges(
groups: SourceControlEntryGroups
): SourceControlEntryGroups {
if (groups.untracked.length === 0) {
return groups
}
return {
staged: groups.staged,
unstaged: [...groups.unstaged, ...groups.untracked],
untracked: []
}
}
export function isPinnedConflictEntry(entry: GitStatusEntry): boolean {
@@ -22,7 +22,9 @@ function referenceCompare(a: GitStatusEntry, b: GitStatusEntry): number {
}
return 2
}
return rank(a) - rank(b) || a.path.localeCompare(b.path, undefined, { numeric: true })
// 'en' pinned to match the shared collator — an undefined locale would make
// this oracle environment-dependent (sv/cs/da collate differently).
return rank(a) - rank(b) || a.path.localeCompare(b.path, 'en', { numeric: true })
}
describe('compareGitStatusEntries', () => {
@@ -1,14 +1,10 @@
import type { GitStatusEntry } from '../../../../shared/types'
// Why hoisted: localeCompare with an options object resolves a fresh ICU collator
// on every comparison, so a changed-file sort paid for one per O(n log n) step.
export const sourceControlPathCollator = new Intl.Collator(undefined, { numeric: true })
import { compareFileNames } from '../../../../shared/file-name-sort'
export function compareGitStatusEntries(a: GitStatusEntry, b: GitStatusEntry): number {
return (
getConflictSortRank(a) - getConflictSortRank(b) ||
sourceControlPathCollator.compare(a.path, b.path)
)
// Why: compareFileNames (not the raw collator) so numeric-collation ties
// ("2" vs "02") stay a total order shared with the File Explorer.
return getConflictSortRank(a) - getConflictSortRank(b) || compareFileNames(a.path, b.path)
}
function getConflictSortRank(entry: GitStatusEntry): number {
@@ -39,6 +39,24 @@ describe('buildSourceControlTree', () => {
])
})
it('orders numbered sibling directories naturally, matching the file rows', () => {
const tree = buildSourceControlTree('unstaged', [
entry({ path: 'migrations/100 - b/a.sql' }),
entry({ path: 'migrations/9 - a/a.sql' }),
entry({ path: 'migrations/99 - c/a.sql' })
])
expect(labels(flattenSourceControlTree(tree, new Set()))).toEqual([
'directory:migrations',
'directory:migrations/9 - a',
'file:migrations/9 - a/a.sql',
'directory:migrations/99 - c',
'file:migrations/99 - c/a.sql',
'directory:migrations/100 - b',
'file:migrations/100 - b/a.sql'
])
})
it('keeps conflict rows ahead of normal rows within a directory', () => {
const tree = buildGitStatusSourceControlTree('unstaged', [
entry({ path: 'src/a.ts' }),

Some files were not shown because too many files have changed in this diff Show More