mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/provider-native-tab-titles
This commit is contained in:
+1
-6
@@ -150,10 +150,5 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"ignorePatterns": [
|
||||
"**/node_modules",
|
||||
"**/dist",
|
||||
"**/out",
|
||||
"tests/e2e/.cross-version-checkouts"
|
||||
]
|
||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/out", "tests/e2e/.cross-version-checkouts"]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
'': {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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}`
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,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 })
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
+224
@@ -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
|
||||
|
||||
+16
@@ -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
Reference in New Issue
Block a user