mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 16:02:55 +00:00
Merge #23962's new head (main 40f9e7a3fc) into brennanb2025/launch-prompt-argv-carry
This commit is contained in:
@@ -5375,7 +5375,7 @@
|
||||
"src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-restore-signal-equivalence.test.tsx": 552,
|
||||
"src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-scrollbar-drag.test.ts": 59,
|
||||
"src/renderer/src/components/editor/conflict-review-file-tree-windowing.test.tsx": 2238,
|
||||
"src/renderer/src/components/editor/csv-parse.test.ts": 122,
|
||||
"src/renderer/src/components/editor/csv/csv-parse.test.ts": 122,
|
||||
"src/renderer/src/components/editor/details-markdown-html.test.ts": 99,
|
||||
"src/renderer/src/components/editor/diff-editor-hide-unchanged-options.test.ts": 35,
|
||||
"src/renderer/src/components/editor/diff-editor-line-number-options.test.ts": 48,
|
||||
|
||||
@@ -7,6 +7,7 @@ const openURL = vi.fn(() => Promise.resolve())
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Linking: { openURL: (url: string) => openURL(url) },
|
||||
Platform: { OS: 'ios' },
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { openExternalLink } from '../platform/external-link'
|
||||
import { createMarkdownInlineMatcher, type MarkdownInlineMatch } from './markdown-inline-matcher'
|
||||
import { INLINE_TEXT_SELECTION } from './inline-text-selection'
|
||||
import { MobileSelectableText } from './MobileSelectableText'
|
||||
import {
|
||||
Fragment,
|
||||
@@ -33,6 +34,8 @@ type Props = {
|
||||
fallback?: string
|
||||
/** Enables iOS range selection for native-chat transcript prose. */
|
||||
rangeSelectable?: boolean
|
||||
/** Forward long presses from interactive Android transcript spans to the message. */
|
||||
onLongPress?: () => void
|
||||
/** Multiplier for prose font size (paragraphs, lists, quotes). Defaults to 1;
|
||||
* the chat view passes >1 so agent prose reads larger than the compact base. */
|
||||
textScale?: number
|
||||
@@ -47,11 +50,28 @@ const MAX_TABLE_ROWS = 40
|
||||
const MAX_TABLE_COLUMNS = 8
|
||||
/** Prose base size — passed to MermaidDiagram fallback mono text. */
|
||||
const MERMAID_BASE = 13
|
||||
const MarkdownTextContext = createContext<ComponentType<TextProps>>(NativeText)
|
||||
type MarkdownTextSetup = {
|
||||
TextComponent: ComponentType<TextProps>
|
||||
/** Disable native selection only within the Android transcript. */
|
||||
androidTranscript: boolean
|
||||
onLongPress?: () => void
|
||||
}
|
||||
const MarkdownTextContext = createContext<MarkdownTextSetup>({
|
||||
TextComponent: NativeText,
|
||||
androidTranscript: false
|
||||
})
|
||||
|
||||
function MarkdownText(props: TextProps): React.JSX.Element {
|
||||
const TextComponent = useContext(MarkdownTextContext)
|
||||
return createElement(TextComponent, props)
|
||||
const { TextComponent, androidTranscript, onLongPress } = useContext(MarkdownTextContext)
|
||||
if (!androidTranscript) {
|
||||
return createElement(TextComponent, props)
|
||||
}
|
||||
// Override selection without changing the nested spans' inherited behavior.
|
||||
return createElement(TextComponent, {
|
||||
...props,
|
||||
...(props.selectable === true ? { selectable: false } : {}),
|
||||
...(onLongPress && props.onPress ? { onLongPress } : {})
|
||||
})
|
||||
}
|
||||
|
||||
// Web/mail hrefs open the system handler; file-target hrefs (file: URIs and
|
||||
@@ -215,6 +235,9 @@ function MobileMarkdownContent({
|
||||
textScale = 1,
|
||||
onOpenFile
|
||||
}: Props) {
|
||||
// Interactive children own their touches and must forward the row action.
|
||||
const setup = useContext(MarkdownTextContext)
|
||||
const rowLongPress = setup.androidTranscript ? setup.onLongPress : undefined
|
||||
const text = content?.trim() ?? ''
|
||||
const previewText = useMemo(() => normalizeMobileMarkdownPreviewHtml(text), [text])
|
||||
const blocks = useMemo(() => parseMobileMarkdown(previewText), [previewText])
|
||||
@@ -288,6 +311,7 @@ function MobileMarkdownContent({
|
||||
key={index}
|
||||
style={styles.imageFrame}
|
||||
onPress={() => openMarkdownHref(block.url, onOpenFile)}
|
||||
onLongPress={rowLongPress}
|
||||
>
|
||||
<NativeText style={styles.link}>{block.alt || 'Open image'}</NativeText>
|
||||
<NativeText style={styles.imageCaption} numberOfLines={1}>
|
||||
@@ -380,9 +404,19 @@ function MobileMarkdownContent({
|
||||
}
|
||||
|
||||
function MobileMarkdownInner(props: Props): React.JSX.Element | null {
|
||||
const TextComponent = props.rangeSelectable ? MobileSelectableText : NativeText
|
||||
const { rangeSelectable = false, onLongPress } = props
|
||||
// Other Markdown surfaces retain their existing selection behavior.
|
||||
const androidTranscript = rangeSelectable && !INLINE_TEXT_SELECTION
|
||||
const setup = useMemo<MarkdownTextSetup>(
|
||||
() => ({
|
||||
TextComponent: rangeSelectable && !androidTranscript ? MobileSelectableText : NativeText,
|
||||
androidTranscript,
|
||||
...(onLongPress ? { onLongPress } : {})
|
||||
}),
|
||||
[rangeSelectable, androidTranscript, onLongPress]
|
||||
)
|
||||
return (
|
||||
<MarkdownTextContext.Provider value={TextComponent}>
|
||||
<MarkdownTextContext.Provider value={setup}>
|
||||
<MobileMarkdownContent {...props} />
|
||||
</MarkdownTextContext.Provider>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react-native', () => ({ Platform: { OS: 'android' } }))
|
||||
|
||||
const { INLINE_TEXT_SELECTION, inlineTextSelectionAllowed } =
|
||||
await import('./inline-text-selection')
|
||||
|
||||
describe('inline text selection policy', () => {
|
||||
it('keeps inline selection everywhere but Android', () => {
|
||||
expect(inlineTextSelectionAllowed('ios')).toBe(true)
|
||||
expect(inlineTextSelectionAllowed('web')).toBe(true)
|
||||
expect(inlineTextSelectionAllowed('android')).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves the running platform once', () => {
|
||||
expect(INLINE_TEXT_SELECTION).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Platform } from 'react-native'
|
||||
|
||||
// Android double-tap selection can mistake successive scroll flicks for word selection.
|
||||
export function inlineTextSelectionAllowed(os: string): boolean {
|
||||
return os !== 'android'
|
||||
}
|
||||
|
||||
export const INLINE_TEXT_SELECTION = inlineTextSelectionAllowed(Platform.OS)
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MobileMarkdown } from './MobileMarkdown'
|
||||
|
||||
// Why Android here: the sibling selectable test runs as iOS and proves prose IS selectable there;
|
||||
// this one proves the transcript carries no selectable span on Android, where a selectable
|
||||
// TextView selects words while the transcript scrolls — and that other surfaces are untouched.
|
||||
vi.mock('react-native', () => ({
|
||||
Linking: { openURL: () => Promise.resolve() },
|
||||
Platform: { OS: 'android' },
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('./pr-sidebar/MermaidDiagram', () => ({ MermaidDiagram: 'MermaidDiagram' }))
|
||||
|
||||
const CONTENT = [
|
||||
'# Heading',
|
||||
'A paragraph with **bold**, `code` and https://example.com/link.',
|
||||
'> quoted',
|
||||
'- item one',
|
||||
'| a | b |',
|
||||
'| - | - |',
|
||||
'| 1 | 2 |',
|
||||
'```',
|
||||
'fenced()',
|
||||
'```'
|
||||
].join('\n')
|
||||
|
||||
describe('MobileMarkdown on Android', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
function render(props: Parameters<typeof MobileMarkdown>[0]): ReactTestInstance[] {
|
||||
act(() => {
|
||||
renderer = create(createElement(MobileMarkdown, props))
|
||||
})
|
||||
return renderer!.root.findAll((node) => String(node.type) === 'Text')
|
||||
}
|
||||
|
||||
const tappable = (nodes: ReactTestInstance[]) =>
|
||||
nodes.filter((node) => typeof node.props.onPress === 'function')
|
||||
|
||||
it('renders the transcript with no selectable span, leaving untouched spans alone', () => {
|
||||
const all = render({ content: CONTENT, rangeSelectable: true })
|
||||
expect(all.length).toBeGreaterThan(5)
|
||||
expect(all.filter((node) => node.props.selectable === true)).toHaveLength(0)
|
||||
// Inline spans (bold, code, links) never asked for selection; writing `false` onto them
|
||||
// would map to `userSelect: none` on the web, so the gate must leave them alone.
|
||||
const links = tappable(all)
|
||||
expect(links.length).toBeGreaterThan(0)
|
||||
for (const node of links) {
|
||||
expect(node.props.selectable).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('routes a long press on a tappable span or image to the row, so neither swallows it', () => {
|
||||
const onLongPress = vi.fn()
|
||||
const content = `${CONTENT}\n\n`
|
||||
const all = render({ content, rangeSelectable: true, onLongPress })
|
||||
const links = tappable(all)
|
||||
expect(links.length).toBeGreaterThan(0)
|
||||
for (const node of links) {
|
||||
expect(node.props.onLongPress).toBe(onLongPress)
|
||||
}
|
||||
expect(all.filter((node) => !node.props.onPress && node.props.onLongPress)).toHaveLength(0)
|
||||
const images = renderer!.root.findAll(
|
||||
(node) => String(node.type) === 'Pressable' && typeof node.props.onPress === 'function'
|
||||
)
|
||||
expect(images.length).toBeGreaterThan(0)
|
||||
for (const node of images) {
|
||||
expect(node.props.onLongPress).toBe(onLongPress)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps other surfaces (task comments, previews) selectable as before', () => {
|
||||
const all = render({ content: CONTENT })
|
||||
expect(all.filter((node) => node.props.selectable === true).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import { MobileMarkdown } from './MobileMarkdown'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Linking: { openURL: vi.fn() },
|
||||
Platform: { OS: 'ios' },
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: <T>(styles: T) => styles, hairlineWidth: 1 },
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MobileMarkdown } from './MobileMarkdown'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Linking: { openURL: () => Promise.resolve() },
|
||||
Platform: { OS: 'ios' },
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { createElement, type ReactNode } from 'react'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
|
||||
// Why a separate file: the sibling suite runs as iOS, where the row has no long press at all.
|
||||
// This one runs as Android and pins the wiring from the bubble's long press to the actions sheet.
|
||||
vi.mock('react-native', async () => {
|
||||
const React = await import('react')
|
||||
const Text = ({ children, ...props }: { children?: ReactNode }): ReactNode =>
|
||||
React.createElement('Text', props, children)
|
||||
return {
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
Animated: {
|
||||
Text,
|
||||
Value: class {
|
||||
setValue(): void {}
|
||||
},
|
||||
loop: (animation: unknown) => animation,
|
||||
sequence: () => ({ start: vi.fn(), stop: vi.fn() }),
|
||||
timing: () => ({ start: vi.fn(), stop: vi.fn() })
|
||||
},
|
||||
Image: 'Image',
|
||||
Platform: { OS: 'android' },
|
||||
Pressable: 'Pressable',
|
||||
Text,
|
||||
View: ({ children, ...props }: { children?: ReactNode }) =>
|
||||
React.createElement('View', props, children),
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
|
||||
}
|
||||
})
|
||||
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
ArrowUp: 'ArrowUp',
|
||||
ChevronDown: 'ChevronDown',
|
||||
Copy: 'Copy',
|
||||
SquareChevronRight: 'SquareChevronRight',
|
||||
SquareTerminal: 'SquareTerminal',
|
||||
Wrench: 'Wrench',
|
||||
ChevronRight: 'ChevronRight'
|
||||
}))
|
||||
vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
|
||||
vi.mock('./MobileNativeChatMessageActionsSheet', () => ({
|
||||
MobileNativeChatMessageActionsSheet: 'MessageActionsSheet'
|
||||
}))
|
||||
|
||||
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
|
||||
|
||||
const message: NativeChatMessage = {
|
||||
id: 'a1',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: 'Read https://example.com then reply.' }],
|
||||
timestamp: null,
|
||||
source: 'transcript'
|
||||
}
|
||||
|
||||
describe('MobileNativeChatMessage on Android', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
function byType(type: string): ReactTestInstance[] {
|
||||
return renderer!.root.findAll((node) => String(node.type) === type)
|
||||
}
|
||||
|
||||
it('opens the actions sheet from a long press on the bubble, and closes it again', () => {
|
||||
act(() => {
|
||||
renderer = create(createElement(MobileNativeChatMessage, { message }))
|
||||
})
|
||||
expect(byType('MessageActionsSheet')).toHaveLength(0)
|
||||
|
||||
const [bubble] = byType('Pressable')
|
||||
expect(typeof bubble!.props.onLongPress).toBe('function')
|
||||
// The markdown gets the same handler, so a link span or image under the finger opens the sheet.
|
||||
const [markdown] = byType('MobileMarkdown')
|
||||
expect(markdown!.props.onLongPress).toBe(bubble!.props.onLongPress)
|
||||
expect(markdown!.props.rangeSelectable).toBe(true)
|
||||
|
||||
act(() => bubble!.props.onLongPress())
|
||||
const [sheet] = byType('MessageActionsSheet')
|
||||
expect(sheet!.props.message).toBe(message)
|
||||
|
||||
act(() => sheet!.props.onClose())
|
||||
expect(byType('MessageActionsSheet')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('disables inline selection for host notices too', () => {
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatMessage, {
|
||||
message: {
|
||||
...message,
|
||||
role: 'system',
|
||||
blocks: [{ type: 'text', text: 'fallback', presentation: 'history-item-too-large' }]
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
const texts = byType('Text').filter((node) => node.props.selectable !== undefined)
|
||||
expect(texts).toHaveLength(1)
|
||||
expect(texts[0]!.props.selectable).toBe(false)
|
||||
act(() => byType('Pressable')[0]!.props.onLongPress())
|
||||
expect(byType('MessageActionsSheet')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('renders the user bubble without inline selection', () => {
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatMessage, { message: { ...message, id: 'u1', role: 'user' } })
|
||||
)
|
||||
})
|
||||
const texts = byType('Text').filter((node) => node.props.selectable !== undefined)
|
||||
expect(texts.length).toBeGreaterThan(0)
|
||||
for (const node of texts) {
|
||||
expect(node.props.selectable).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -22,6 +22,7 @@ vi.mock('react-native', async () => {
|
||||
timing: () => ({ start: vi.fn(), stop: vi.fn() })
|
||||
},
|
||||
Image: 'Image',
|
||||
Platform: { OS: 'ios' },
|
||||
Pressable: 'Pressable',
|
||||
Text,
|
||||
View: ({ children, ...props }: { children?: unknown }) =>
|
||||
@@ -40,6 +41,9 @@ vi.mock('lucide-react-native', () => ({
|
||||
ChevronRight: 'ChevronRight'
|
||||
}))
|
||||
vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
|
||||
vi.mock('./MobileNativeChatMessageActionsSheet', () => ({
|
||||
MobileNativeChatMessageActionsSheet: 'MessageActionsSheet'
|
||||
}))
|
||||
|
||||
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { MobileSelectableText as Text } from '../components/MobileSelectableText'
|
||||
import { memo } from 'react'
|
||||
import { Image, Text as NativeText, View } from 'react-native'
|
||||
import { memo, useCallback, useState, type ComponentProps, type ReactNode } from 'react'
|
||||
import { Image, Text as NativeText, Pressable, View } from 'react-native'
|
||||
import { INLINE_TEXT_SELECTION } from '../components/inline-text-selection'
|
||||
import { MobileNativeChatMessageActionsSheet } from './MobileNativeChatMessageActionsSheet'
|
||||
import { splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold'
|
||||
import { selectActiveToolCall } from '../../../src/shared/native-chat-tool-activity'
|
||||
import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types'
|
||||
@@ -20,17 +22,23 @@ function Prose({
|
||||
block,
|
||||
invert,
|
||||
fontScale,
|
||||
onOpenFile
|
||||
onOpenFile,
|
||||
onLongPress
|
||||
}: {
|
||||
block: NativeChatBlock
|
||||
invert?: boolean
|
||||
fontScale: number
|
||||
onOpenFile?: (relativePath: string) => void
|
||||
/** Android only: routes a long press on a link span to the row's actions sheet. */
|
||||
onLongPress?: () => void
|
||||
}): React.JSX.Element | null {
|
||||
if (isTextBlock(block)) {
|
||||
if (isAgentSessionHostStatusPresentation(block.presentation)) {
|
||||
return (
|
||||
<Text selectable style={[styles.hostNotice, { fontSize: TEXT_SIZE * fontScale }]}>
|
||||
<Text
|
||||
selectable={INLINE_TEXT_SELECTION}
|
||||
style={[styles.hostNotice, { fontSize: TEXT_SIZE * fontScale }]}
|
||||
>
|
||||
{AGENT_SESSION_HOST_STATUS_COPY[block.presentation]}
|
||||
</Text>
|
||||
)
|
||||
@@ -39,7 +47,10 @@ function Prose({
|
||||
// markdown renderer's light-on-dark palette.
|
||||
if (invert) {
|
||||
return (
|
||||
<Text selectable style={[styles.userText, { fontSize: TEXT_SIZE * fontScale }]}>
|
||||
<Text
|
||||
selectable={INLINE_TEXT_SELECTION}
|
||||
style={[styles.userText, { fontSize: TEXT_SIZE * fontScale }]}
|
||||
>
|
||||
{block.text}
|
||||
</Text>
|
||||
)
|
||||
@@ -50,6 +61,7 @@ function Prose({
|
||||
rangeSelectable
|
||||
textScale={1.25 * fontScale}
|
||||
onOpenFile={onOpenFile}
|
||||
onLongPress={onLongPress}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -76,6 +88,25 @@ function Prose({
|
||||
return null
|
||||
}
|
||||
|
||||
// Keep the existing responder hierarchy on platforms with inline selection.
|
||||
function Content({
|
||||
onLongPress,
|
||||
style,
|
||||
children
|
||||
}: {
|
||||
onLongPress?: () => void
|
||||
style: ComponentProps<typeof View>['style']
|
||||
children: ReactNode
|
||||
}): React.JSX.Element {
|
||||
return onLongPress ? (
|
||||
<Pressable onLongPress={onLongPress} style={style}>
|
||||
{children}
|
||||
</Pressable>
|
||||
) : (
|
||||
<View style={style}>{children}</View>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileNativeChatMessageImpl({
|
||||
message,
|
||||
toolsExpanded = false,
|
||||
@@ -129,6 +160,11 @@ function MobileNativeChatMessageImpl({
|
||||
!turnExpanded &&
|
||||
!toolsExpanded
|
||||
const showToolRun = tools.length > 0 && !settledToolsHidden
|
||||
// Mount selection UI only for the message being copied.
|
||||
const [actionsOpen, setActionsOpen] = useState(false)
|
||||
// Keep the memoized Markdown context stable as the message streams.
|
||||
const openActions = useCallback(() => setActionsOpen(true), [])
|
||||
const onLongPress = INLINE_TEXT_SELECTION ? undefined : openActions
|
||||
|
||||
const statusRow = turnStatus ? (
|
||||
<MobileNativeChatTurnStatus
|
||||
@@ -144,7 +180,8 @@ function MobileNativeChatMessageImpl({
|
||||
{/* A turn with no user bubble carries its bar above its first row. */}
|
||||
{turnStatusAbove ? statusRow : null}
|
||||
<View style={[styles.row, isUser && styles.rowUser]}>
|
||||
<View
|
||||
<Content
|
||||
onLongPress={onLongPress}
|
||||
style={[styles.content, isUser && styles.userBubble, isReasoning && styles.reasoning]}
|
||||
>
|
||||
{prose.map((block, index) => (
|
||||
@@ -154,6 +191,7 @@ function MobileNativeChatMessageImpl({
|
||||
invert={isUser}
|
||||
fontScale={fontScale}
|
||||
onOpenFile={onOpenFile}
|
||||
onLongPress={onLongPress}
|
||||
/>
|
||||
))}
|
||||
{showToolRun ? (
|
||||
@@ -168,8 +206,14 @@ function MobileNativeChatMessageImpl({
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</Content>
|
||||
</View>
|
||||
{actionsOpen ? (
|
||||
<MobileNativeChatMessageActionsSheet
|
||||
message={message}
|
||||
onClose={() => setActionsOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{turnStatusAbove ? null : statusRow}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { MobileNativeChatMessageActionsSheet } from './MobileNativeChatMessageActionsSheet'
|
||||
|
||||
const { writeText, alert } = vi.hoisted(() => ({ writeText: vi.fn(), alert: vi.fn() }))
|
||||
vi.mock('react-native', () => ({
|
||||
Alert: { alert },
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
Modal: 'Modal',
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: 'ScrollView',
|
||||
Text: 'Text',
|
||||
View: 'View',
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
|
||||
}))
|
||||
vi.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: () => ({ top: 24, bottom: 16, left: 0, right: 0 })
|
||||
}))
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
Copy: 'Copy',
|
||||
TextSelect: 'TextSelect',
|
||||
X: 'X',
|
||||
Edit3: 'Edit3',
|
||||
Trash2: 'Trash2'
|
||||
}))
|
||||
vi.mock('../platform/clipboard', () => ({ useClipboardWriter: () => ({ writeText }) }))
|
||||
// Keep the real drawer lifecycle; only native animation completion is driven by the test.
|
||||
vi.mock('../components/mounted-bottom-drawer', () => ({ MountedBottomDrawer: 'MountedDrawer' }))
|
||||
|
||||
const message: NativeChatMessage = {
|
||||
id: 'reply',
|
||||
source: 'transcript',
|
||||
role: 'assistant',
|
||||
timestamp: null,
|
||||
blocks: [{ type: 'text', text: 'First words' }]
|
||||
}
|
||||
|
||||
describe('message selection actions', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const onClose = vi.fn()
|
||||
const nodes = (type: string) => renderer!.root.findAll((node) => String(node.type) === type)
|
||||
const render = (value = message) => {
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatMessageActionsSheet, { message: value, onClose })
|
||||
)
|
||||
})
|
||||
}
|
||||
const pressAction = (index: number) => act(() => nodes('Pressable')[index]!.props.onPress())
|
||||
const finishClosing = () => act(() => nodes('MountedDrawer')[0]!.props.onHidden())
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
writeText.mockResolvedValue(undefined)
|
||||
})
|
||||
afterEach(() => act(() => renderer?.unmount()))
|
||||
|
||||
it('waits for drawer removal, then selects a stable snapshot while the reply streams', () => {
|
||||
render()
|
||||
act(() =>
|
||||
renderer!.update(
|
||||
createElement(MobileNativeChatMessageActionsSheet, {
|
||||
message: {
|
||||
...message,
|
||||
blocks: [{ type: 'text', text: 'First words plus streamed text' }]
|
||||
},
|
||||
onClose
|
||||
})
|
||||
)
|
||||
)
|
||||
pressAction(1)
|
||||
expect(nodes('MountedDrawer')[0]!.props.visible).toBe(false)
|
||||
expect(nodes('Modal')).toHaveLength(0)
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
finishClosing()
|
||||
expect(nodes('MountedDrawer')).toHaveLength(0)
|
||||
expect(nodes('Modal')).toHaveLength(1)
|
||||
const selected = nodes('Text').find((node) => node.props.selectable)
|
||||
expect(selected?.props.children).toBe('First words')
|
||||
act(() => nodes('Modal')[0]!.props.onRequestClose())
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('closes selection with the accessible close button', () => {
|
||||
render()
|
||||
pressAction(1)
|
||||
finishClosing()
|
||||
const close = nodes('Pressable').find((node) => node.props.accessibilityLabel === 'Close')
|
||||
expect(close?.props.accessibilityRole).toBe('button')
|
||||
act(() => close!.props.onPress())
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('copies prose and dismisses only after the drawer closes', () => {
|
||||
render()
|
||||
pressAction(0)
|
||||
expect(writeText).toHaveBeenCalledWith('First words')
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
finishClosing()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
expect(nodes('Modal')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('surfaces clipboard rejection', async () => {
|
||||
writeText.mockRejectedValue(new Error('Clipboard unavailable'))
|
||||
render()
|
||||
await act(async () => pressAction(0))
|
||||
expect(alert).toHaveBeenCalledWith('Copy failed', 'Clipboard unavailable')
|
||||
})
|
||||
|
||||
it('dismisses without selecting when the drawer is cancelled', () => {
|
||||
render()
|
||||
act(() => nodes('MountedDrawer')[0]!.props.onClose())
|
||||
finishClosing()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
expect(nodes('Modal')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('disables actions when the message has no copyable prose', () => {
|
||||
render({ ...message, blocks: [{ type: 'image-ref', path: '/image.png' }] })
|
||||
expect(nodes('Pressable').map((node) => node.props.disabled)).toEqual([true, true])
|
||||
expect(writeText).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { Copy, TextSelect, X } from 'lucide-react-native'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { ActionSheetContent } from '../components/ActionSheetModal'
|
||||
import { BottomDrawer } from '../components/BottomDrawer'
|
||||
import { useClipboardWriter } from '../platform/clipboard'
|
||||
import { colors, spacing, typography } from '../theme/mobile-theme'
|
||||
import { nativeChatMessagePlainText } from './mobile-native-chat-message-plain-text'
|
||||
|
||||
type Props = {
|
||||
/** The long-pressed message. The owner mounts this only while the sheet is open. */
|
||||
message: NativeChatMessage
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function MobileNativeChatMessageActionsSheet({
|
||||
message,
|
||||
onClose
|
||||
}: Props): React.JSX.Element {
|
||||
const clipboard = useClipboardWriter()
|
||||
const [sheetVisible, setSheetVisible] = useState(true)
|
||||
const [selecting, setSelecting] = useState(false)
|
||||
// Wait for the drawer to unmount before presenting another native modal.
|
||||
const selectRequested = useRef(false)
|
||||
// Streaming updates must not reset an active native text selection.
|
||||
const [text] = useState(() => nativeChatMessagePlainText(message))
|
||||
const closeSheet = () => setSheetVisible(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<BottomDrawer
|
||||
visible={sheetVisible}
|
||||
onClose={closeSheet}
|
||||
onAfterClose={() => (selectRequested.current ? setSelecting(true) : onClose())}
|
||||
dragContentToDismiss
|
||||
>
|
||||
<ActionSheetContent
|
||||
onClose={closeSheet}
|
||||
actions={[
|
||||
{
|
||||
label: 'Copy message',
|
||||
icon: Copy,
|
||||
disabled: text.length === 0,
|
||||
onPress: () => {
|
||||
void clipboard.writeText(text).catch((error: unknown) => {
|
||||
Alert.alert(
|
||||
'Copy failed',
|
||||
error instanceof Error ? error.message : 'The clipboard rejected the text.'
|
||||
)
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Select text',
|
||||
icon: TextSelect,
|
||||
disabled: text.length === 0,
|
||||
onPress: () => {
|
||||
selectRequested.current = true
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</BottomDrawer>
|
||||
{selecting ? <SelectTextScreen text={text} onClose={onClose} /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTextScreen({
|
||||
text,
|
||||
onClose
|
||||
}: {
|
||||
text: string
|
||||
onClose: () => void
|
||||
}): React.JSX.Element {
|
||||
const insets = useSafeAreaInsets()
|
||||
return (
|
||||
<Modal visible animationType="slide" onRequestClose={onClose}>
|
||||
<View style={[styles.screen, { paddingTop: insets.top, paddingBottom: insets.bottom }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Select text</Text>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
hitSlop={12}
|
||||
accessibilityLabel="Close"
|
||||
accessibilityRole="button"
|
||||
style={styles.close}
|
||||
>
|
||||
<X size={20} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView contentContainerStyle={styles.body}>
|
||||
<Text selectable style={styles.text}>
|
||||
{text}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: colors.bgBase },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
title: { color: colors.textPrimary, fontSize: typography.titleSize, fontWeight: '600' },
|
||||
close: { padding: spacing.xs },
|
||||
body: { padding: spacing.lg },
|
||||
text: { color: colors.textPrimary, fontSize: typography.bodySize, lineHeight: 22 }
|
||||
})
|
||||
@@ -78,14 +78,15 @@ describe('MobileNativeChatTurnStatus', () => {
|
||||
expect(onToggleExpanded).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('heads a turn a crash cut off as failed, and a turn the user stopped as interrupted', () => {
|
||||
// The crash's notice row explains the cut; the turn bar reads like any finished turn.
|
||||
it('heads a turn a crash cut off as worked, and a turn the user stopped as interrupted', () => {
|
||||
const crashed = render({
|
||||
startedAt: Date.now(),
|
||||
workedSeconds: 12,
|
||||
verdict: 'interruption',
|
||||
onToggleExpanded: vi.fn()
|
||||
})
|
||||
expect(labels(crashed.root)).toEqual(['Failed after 12s'])
|
||||
expect(labels(crashed.root)).toEqual(['Worked for 12s'])
|
||||
act(() => crashed.unmount())
|
||||
const stopped = render({
|
||||
startedAt: Date.now(),
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AGENT_SESSION_HOST_STATUS_COPY } from '../../../src/shared/agent-session-host-status-rows'
|
||||
import type { NativeChatBlock } from '../../../src/shared/native-chat-types'
|
||||
import { nativeChatMessagePlainText } from './mobile-native-chat-message-plain-text'
|
||||
|
||||
describe('nativeChatMessagePlainText', () => {
|
||||
it('joins the prose blocks, keeps their whitespace, and leaves tool calls out', () => {
|
||||
const blocks: NativeChatBlock[] = [
|
||||
{ type: 'text', text: ' indented first line\n' },
|
||||
{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } },
|
||||
{ type: 'text', text: ' \n' },
|
||||
{ type: 'text', text: 'Second, with `code`.' }
|
||||
]
|
||||
expect(nativeChatMessagePlainText({ blocks })).toBe(
|
||||
' indented first line\n\n\nSecond, with `code`.'
|
||||
)
|
||||
})
|
||||
|
||||
it('copies the displayed host notice instead of its fallback wire text', () => {
|
||||
expect(
|
||||
nativeChatMessagePlainText({
|
||||
blocks: [{ type: 'text', text: 'fallback', presentation: 'history-item-too-large' }]
|
||||
})
|
||||
).toBe(AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'])
|
||||
})
|
||||
|
||||
it('is empty for a message with no prose', () => {
|
||||
expect(
|
||||
nativeChatMessagePlainText({ blocks: [{ type: 'tool-call', name: 'Read', input: {} }] })
|
||||
).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
AGENT_SESSION_HOST_STATUS_COPY,
|
||||
isAgentSessionHostStatusPresentation
|
||||
} from '../../../src/shared/agent-session-host-status-rows'
|
||||
import { isTextBlock } from '../../../src/shared/native-chat-types'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
|
||||
/** Copy the displayed prose, excluding tool activity and image attachments. */
|
||||
export function nativeChatMessagePlainText(message: Pick<NativeChatMessage, 'blocks'>): string {
|
||||
// Preserve indentation in code and nested lists.
|
||||
return message.blocks
|
||||
.filter(isTextBlock)
|
||||
.map((block) =>
|
||||
isAgentSessionHostStatusPresentation(block.presentation)
|
||||
? AGENT_SESSION_HOST_STATUS_COPY[block.presentation]
|
||||
: block.text
|
||||
)
|
||||
.filter((text) => text.trim().length > 0)
|
||||
.join('\n\n')
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
|
||||
import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: { getItem: vi.fn(async () => null), setItem: vi.fn(), removeItem: vi.fn() }
|
||||
}))
|
||||
|
||||
const SCOPE = { kind: 'turn' as const, turnItemId: 'turn-1' }
|
||||
const ITEMS: AgentJournalRenderItem[] = [
|
||||
{
|
||||
itemId: 'user-1',
|
||||
revision: 0,
|
||||
sequence: 1,
|
||||
observedAt: 1,
|
||||
turnScope: { kind: 'thread' },
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'long job' }] }
|
||||
},
|
||||
{
|
||||
itemId: 'turn-1',
|
||||
revision: 1,
|
||||
sequence: 2,
|
||||
observedAt: 2,
|
||||
turnScope: { kind: 'thread' },
|
||||
body: {
|
||||
kind: 'turn',
|
||||
turnId: 'turn-1',
|
||||
userItemId: 'user-1',
|
||||
state: 'interrupted',
|
||||
startedAt: 2,
|
||||
completedAt: 9
|
||||
}
|
||||
},
|
||||
{
|
||||
itemId: 'reply-1',
|
||||
revision: 0,
|
||||
sequence: 3,
|
||||
observedAt: 3,
|
||||
turnScope: SCOPE,
|
||||
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'half' }] }
|
||||
}
|
||||
]
|
||||
|
||||
function snapshot(items: AgentJournalRenderItem[]): AgentSessionSubscribeEvent {
|
||||
return {
|
||||
type: 'snapshot',
|
||||
sessionId: 'session-1',
|
||||
fence: 3,
|
||||
page: {
|
||||
sessionId: 'session-1',
|
||||
epoch: 'epoch-1',
|
||||
fence: 3,
|
||||
direction: 'tail',
|
||||
items,
|
||||
removedItemIds: [],
|
||||
submissions: [],
|
||||
window: {
|
||||
oldest: { epoch: 'epoch-1', sequence: 1 },
|
||||
newest: { epoch: 'epoch-1', sequence: 3 },
|
||||
nextCursor: { epoch: 'epoch-1', sequence: 4 }
|
||||
},
|
||||
liveCursor: { epoch: 'epoch-1', sequence: 3 },
|
||||
hasOlder: false,
|
||||
hasNewer: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('useMobileStructuredAgentSession transcript', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
it('shows a cut turn no row explains with the one notice desktop shows, in that turn', async () => {
|
||||
let listener: ((value: unknown) => void) | null = null
|
||||
const client: Pick<RpcClient, 'sendRequest' | 'subscribe'> = {
|
||||
sendRequest: vi.fn(async (): Promise<RpcResponse> => ({
|
||||
id: 'response-1',
|
||||
ok: true,
|
||||
result: {},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})),
|
||||
subscribe: vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => {
|
||||
listener = onData
|
||||
return () => {}
|
||||
})
|
||||
}
|
||||
const rendered: { hook: ReturnType<typeof useMobileStructuredAgentSession> | null } = {
|
||||
hook: null
|
||||
}
|
||||
function Harness(): null {
|
||||
rendered.hook = useMobileStructuredAgentSession({
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook calls only sendRequest and subscribe, both provided.
|
||||
client: client as RpcClient,
|
||||
sessionId: 'session-1',
|
||||
enabled: true,
|
||||
connected: true,
|
||||
hostSupport: null,
|
||||
agent: 'codex',
|
||||
onSendError: vi.fn()
|
||||
})
|
||||
return null
|
||||
}
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
|
||||
|
||||
act(() => listener?.(snapshot(ITEMS)))
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(rendered.hook?.session.messages.at(-1)).toMatchObject({
|
||||
role: 'system',
|
||||
blocks: [
|
||||
{
|
||||
text: 'Codex stopped while this response was in progress. You can continue in this conversation.',
|
||||
tone: 'error'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
// The turn bars place rows by the same items, so the notice joins the cut turn.
|
||||
expect(rendered.hook?.turnJournal.items.at(-1)).toMatchObject({ turnScope: SCOPE })
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,8 @@ import { useCallback, useMemo, useRef } from 'react'
|
||||
import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention'
|
||||
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
|
||||
import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection'
|
||||
import { withNativeChatCutTurnNotices } from '../../../src/shared/native-chat-cut-turn-notice'
|
||||
import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names'
|
||||
import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/structured-agent-session-main-agent-working'
|
||||
import {
|
||||
activeStructuredAgentSessionTurnId,
|
||||
@@ -157,16 +159,28 @@ export function useMobileStructuredAgentSession(args: {
|
||||
onSendError
|
||||
})
|
||||
|
||||
// What the transcript reads, as desktop does: the journal plus the one notice a cut turn with no
|
||||
// row gets.
|
||||
const transcriptItems = useMemo(
|
||||
() =>
|
||||
withNativeChatCutTurnNotices(state.items, {
|
||||
agentName: TUI_AGENT_DISPLAY_NAMES[agent === 'codex' ? 'codex' : 'claude']
|
||||
}),
|
||||
[agent, state.items]
|
||||
)
|
||||
const messages = useMemo(
|
||||
// Off: the phone hands a rejected message back to its composer, so a row would show it twice.
|
||||
() =>
|
||||
projectStructuredAgentSessionMessages(state.items, [], state.submissions, {
|
||||
projectStructuredAgentSessionMessages(transcriptItems, [], state.submissions, {
|
||||
rejectedInPlace: false
|
||||
}),
|
||||
[state.items, state.submissions]
|
||||
[transcriptItems, state.submissions]
|
||||
)
|
||||
const turnId = activeStructuredAgentSessionTurnId(state.items)
|
||||
const turnTiming = useMobileStructuredAgentTurnTiming(state, turnId)
|
||||
const turnTiming = useMobileStructuredAgentTurnTiming(
|
||||
{ ...state, items: transcriptItems },
|
||||
turnId
|
||||
)
|
||||
const activityText =
|
||||
selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null
|
||||
const thinking = isStructuredAgentSessionThinking(state.items)
|
||||
|
||||
@@ -86,6 +86,32 @@ export async function prepareLegacySharedCodexSessionResume(
|
||||
return { useRealCodexHome: true }
|
||||
}
|
||||
|
||||
/** Explicit account restarts must move the verified rollout before changing credentials. */
|
||||
export async function prepareCodexAccountRestartResume(args: {
|
||||
sourceHome: string
|
||||
transcriptPath: string
|
||||
targetHome: string
|
||||
systemCodexHomePath: string
|
||||
}): Promise<string> {
|
||||
if (sameRuntimePath(args.sourceHome, args.targetHome)) {
|
||||
return args.targetHome
|
||||
}
|
||||
const relativePath = relative(
|
||||
resolve(join(args.sourceHome, 'sessions')),
|
||||
resolve(args.transcriptPath)
|
||||
)
|
||||
if (!isDatedRolloutRelativePath(relativePath)) {
|
||||
throw new Error(RETRYABLE_RESUME_ERROR)
|
||||
}
|
||||
const paths = resolveCodexSessionBackfillPaths(args.systemCodexHomePath)
|
||||
await materializeLegacyRollout(
|
||||
args.transcriptPath,
|
||||
join(args.targetHome, 'sessions', relativePath),
|
||||
paths.auditLogPath
|
||||
)
|
||||
return args.targetHome
|
||||
}
|
||||
|
||||
/**
|
||||
* Repins a per-account resume to the selected account's home, or null to keep
|
||||
* the session's own home.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { PtyBindingPersistenceOperations } from '../persistence/loading-store/pty-binding-persistence'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { setupPtyIpcSuite, type PtyIpcSuiteFixtures } from './pty-ipc-test-harness'
|
||||
import { SessionNotFoundError } from '../daemon/daemon-errors'
|
||||
import { registerSshPtyProvider, unregisterSshPtyProvider } from './pty/provider/registry'
|
||||
import { toAppSshPtyId } from '../providers/ssh-pty-id'
|
||||
import { TerminalKilledError } from '../daemon/daemon-pty-lifecycle-errors'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { registerPtyHandlers, setLocalPtyProvider } from './pty'
|
||||
import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops'
|
||||
@@ -56,6 +59,34 @@ const leafId = '12121212-1212-4212-8212-121212121212'
|
||||
const paneKey = makePaneKey(tabId, leafId)
|
||||
|
||||
type RestartHarness = ReturnType<typeof installRestartHarness>
|
||||
type FakeSession = {
|
||||
tabsByWorktree: Record<string, { id: string; worktreeId: string; ptyId: string | null }[]>
|
||||
terminalLayoutsByTabId: Record<
|
||||
string,
|
||||
{
|
||||
root: { type: 'leaf'; leafId: string }
|
||||
activeLeafId: string
|
||||
expandedLeafId: null
|
||||
ptyIdsByLeafId: Record<string, string>
|
||||
}
|
||||
>
|
||||
terminalPtyIncarnationsByPaneKey: Record<string, string>
|
||||
}
|
||||
|
||||
function seedSession(ptyId: string): FakeSession {
|
||||
return {
|
||||
tabsByWorktree: { [worktreeId]: [{ id: tabId, worktreeId, ptyId }] },
|
||||
terminalLayoutsByTabId: {
|
||||
[tabId]: {
|
||||
root: { type: 'leaf', leafId },
|
||||
activeLeafId: leafId,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [leafId]: ptyId }
|
||||
}
|
||||
},
|
||||
terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-old' }
|
||||
}
|
||||
}
|
||||
|
||||
function registerWithFakes(
|
||||
mainWindow: PtyIpcSuiteFixtures['mainWindow'],
|
||||
@@ -78,13 +109,15 @@ function installRestartHarness(
|
||||
options: { shutdownFails?: boolean; shutdownGate?: Promise<void> } = {}
|
||||
) {
|
||||
let oldSessionAlive = true
|
||||
const control = { shutdownFails: options.shutdownFails ?? false }
|
||||
const control = { shutdownFails: options.shutdownFails ?? false, failNextWrite: false }
|
||||
const boundAtFreshLaunch: (string | undefined)[] = []
|
||||
const providerSpawn = vi.fn(async (spawnOptions: { attachOnly?: boolean }) => {
|
||||
if (!spawnOptions.attachOnly) {
|
||||
boundAtFreshLaunch.push(leafBinding())
|
||||
return { id: 'pty-new', incarnationId: 'inc-new' }
|
||||
}
|
||||
if (!oldSessionAlive) {
|
||||
throw new SessionNotFoundError('pty-old')
|
||||
throw new TerminalKilledError('pty-old')
|
||||
}
|
||||
return { id: 'pty-old', incarnationId: 'inc-old', isReattach: true }
|
||||
})
|
||||
@@ -120,31 +153,75 @@ function installRestartHarness(
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restart path calls only the provider members this fake defines.
|
||||
setLocalPtyProvider(provider as unknown as Parameters<typeof setLocalPtyProvider>[0])
|
||||
let session = {
|
||||
tabsByWorktree: { [worktreeId]: [{ id: tabId, worktreeId, ptyId: 'pty-old' }] },
|
||||
terminalLayoutsByTabId: {
|
||||
[tabId]: {
|
||||
root: { type: 'leaf' as const, leafId },
|
||||
activeLeafId: leafId,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [leafId]: 'pty-old' }
|
||||
}
|
||||
},
|
||||
terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-old' }
|
||||
}
|
||||
let session = seedSession('pty-old')
|
||||
const store = {
|
||||
getWorkspaceSession: vi.fn(() => session),
|
||||
getWorkspaceSession: vi.fn((_hostId?: string): FakeSession => session),
|
||||
setWorkspaceSession: vi.fn((next) => {
|
||||
session = next
|
||||
}),
|
||||
flushOrThrow: vi.fn(),
|
||||
runDurableMutation: vi.fn(async <T>(mutate: () => { value: T }) => mutate().value),
|
||||
persistPtyBinding: vi.fn(),
|
||||
runDurableMutation: vi.fn(
|
||||
async <T>(
|
||||
mutate: () => { value: T; persist?: boolean | 'if-dirty'; rollback?: () => void }
|
||||
) => {
|
||||
const mutation = mutate()
|
||||
if (mutation.persist !== false) {
|
||||
if (control.failNextWrite) {
|
||||
control.failNextWrite = false
|
||||
mutation.rollback?.()
|
||||
throw new Error('save failed')
|
||||
}
|
||||
durableBindings.push(leafBinding())
|
||||
}
|
||||
return mutation.value
|
||||
}
|
||||
),
|
||||
getWorkspaceSessionHostIds: vi.fn(() => [
|
||||
'local',
|
||||
...Object.keys(state.workspaceSessionsByHostId)
|
||||
]),
|
||||
getFolderWorkspace: vi.fn(() => undefined),
|
||||
getFolderWorkspaces: vi.fn(() => []),
|
||||
getProjectGroups: vi.fn(() => []),
|
||||
getRepos: vi.fn(() => [])
|
||||
}
|
||||
const state: {
|
||||
workspaceSession: FakeSession
|
||||
workspaceSessionsByHostId: Record<string, FakeSession>
|
||||
} = { workspaceSession: session, workspaceSessionsByHostId: {} }
|
||||
const durableBindings: (string | undefined)[] = []
|
||||
function leafBinding(hostId?: string): string | undefined {
|
||||
return store.getWorkspaceSession(hostId).terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId]
|
||||
}
|
||||
// Like the store, a missing host partition reads as an empty session, never the local one.
|
||||
store.getWorkspaceSession.mockImplementation((hostId?: string) =>
|
||||
hostId && hostId !== 'local'
|
||||
? (state.workspaceSessionsByHostId[hostId] ?? {
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
terminalPtyIncarnationsByPaneKey: {}
|
||||
})
|
||||
: state.workspaceSession
|
||||
)
|
||||
const bindingRuntime = {
|
||||
state,
|
||||
dirtyProfileStateDomains: new Set(),
|
||||
runDurableMutation: store.runDurableMutation,
|
||||
writeTimer: null,
|
||||
pendingWrite: null,
|
||||
quitFlushStarted: false,
|
||||
writeGeneration: 0,
|
||||
lastDurableWriteGeneration: 0
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: binding writes read only state, write bookkeeping, durable mutation and the session partitions from these fakes.
|
||||
const bindingArgs = [bindingRuntime, store] as unknown as ConstructorParameters<
|
||||
typeof PtyBindingPersistenceOperations
|
||||
>
|
||||
const bindingOperations = new PtyBindingPersistenceOperations(...bindingArgs)
|
||||
const storeWithRetirement = Object.assign(store, {
|
||||
persistPtyBinding: vi.fn(bindingOperations.persistPtyBinding.bind(bindingOperations)),
|
||||
retirePtyBinding: bindingOperations.retirePtyBinding.bind(bindingOperations)
|
||||
})
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
resolveTerminalPane: vi.fn(() => {
|
||||
@@ -165,7 +242,18 @@ function installRestartHarness(
|
||||
onPtyData: vi.fn(),
|
||||
intentionalPtyStops: new TerminalIntentionalStops()
|
||||
}
|
||||
return { providerSpawn, shutdown, store, runtime, control }
|
||||
return {
|
||||
providerSpawn,
|
||||
shutdown,
|
||||
store: storeWithRetirement,
|
||||
runtime,
|
||||
control,
|
||||
provider,
|
||||
leafBinding,
|
||||
durableBindings,
|
||||
boundAtFreshLaunch,
|
||||
partitions: state.workspaceSessionsByHostId
|
||||
}
|
||||
}
|
||||
|
||||
function restartSpawnArgs(extra: { replacesPtyId?: string } = {}) {
|
||||
@@ -283,4 +371,270 @@ describe('pty:spawn replacing a pane owner', () => {
|
||||
expect(exitPayloads('pty-old')).toHaveLength(1)
|
||||
expect(exitPayloads('pty-old')[0]).not.toHaveProperty('replacedByRestart')
|
||||
})
|
||||
it('swaps the stopped binding for the replacement in one write, never unbinding the pane', async () => {
|
||||
const { providerSpawn, store, runtime, durableBindings, boundAtFreshLaunch } =
|
||||
installRestartHarness()
|
||||
const session = store.getWorkspaceSession()
|
||||
const layout = structuredClone(session.terminalLayoutsByTabId[tabId])
|
||||
registerWithFakes(mainWindow, runtime, store)
|
||||
await handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' }))
|
||||
const swapped = store.getWorkspaceSession()
|
||||
expect(boundAtFreshLaunch).toEqual(['pty-old'])
|
||||
expect(durableBindings).toEqual(['pty-new'])
|
||||
expect(swapped.tabsByWorktree[worktreeId]).toEqual([
|
||||
{ ...session.tabsByWorktree[worktreeId][0], ptyId: 'pty-new' }
|
||||
])
|
||||
expect(swapped.terminalLayoutsByTabId[tabId]).toEqual({
|
||||
...layout,
|
||||
ptyIdsByLeafId: { [leafId]: 'pty-new' }
|
||||
})
|
||||
expect(swapped.terminalPtyIncarnationsByPaneKey).toEqual({ [paneKey]: 'inc-new' })
|
||||
expect(providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true)
|
||||
})
|
||||
|
||||
it('launches fresh when a stale snapshot re-publishes the stopped binding mid-spawn', async () => {
|
||||
const { providerSpawn, store, runtime, leafBinding } = installRestartHarness()
|
||||
runtime.createPreAllocatedTerminalHandle.mockImplementationOnce(() => {
|
||||
// A debounced renderer layout patch still carrying the old id lands after the stop.
|
||||
const current = store.getWorkspaceSession()
|
||||
current.terminalLayoutsByTabId[tabId] = {
|
||||
...current.terminalLayoutsByTabId[tabId],
|
||||
ptyIdsByLeafId: { [leafId]: 'pty-old' }
|
||||
}
|
||||
return 'term-restart'
|
||||
})
|
||||
registerWithFakes(mainWindow, runtime, store)
|
||||
await expect(
|
||||
handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' }))
|
||||
).resolves.toMatchObject({ id: 'pty-new' })
|
||||
expect(runtime.createPreAllocatedTerminalHandle).toHaveBeenCalled()
|
||||
expect(providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true)
|
||||
expect(leafBinding()).toBe('pty-new')
|
||||
})
|
||||
|
||||
it('swaps a binding the renderer withdrew before connecting the replacement', async () => {
|
||||
const { providerSpawn, store, runtime, leafBinding } = installRestartHarness()
|
||||
runtime.createPreAllocatedTerminalHandle.mockImplementationOnce(() => {
|
||||
// A split tab's partial layout map, or an SSH terminated lease, lets the renderer clear land.
|
||||
delete store.getWorkspaceSession().terminalLayoutsByTabId[tabId].ptyIdsByLeafId[leafId]
|
||||
return 'term-restart'
|
||||
})
|
||||
registerWithFakes(mainWindow, runtime, store)
|
||||
await expect(
|
||||
handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' }))
|
||||
).resolves.toMatchObject({ id: 'pty-new' })
|
||||
expect(providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true)
|
||||
expect(leafBinding()).toBe('pty-new')
|
||||
})
|
||||
|
||||
it('clears the stopped binding when the replacement fails, so the remount starts fresh', async () => {
|
||||
const { providerSpawn, store, runtime, leafBinding } = installRestartHarness()
|
||||
providerSpawn.mockRejectedValueOnce(new Error('spawn failed'))
|
||||
registerWithFakes(mainWindow, runtime, store)
|
||||
await expect(
|
||||
handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' }))
|
||||
).rejects.toThrow('spawn failed')
|
||||
const boundAfterFailure = leafBinding()
|
||||
// The renderer's recovery remount sends no replacesPtyId.
|
||||
await expect(handlers.get('pty:spawn')!(null, restartSpawnArgs())).resolves.toMatchObject({
|
||||
id: 'pty-new'
|
||||
})
|
||||
expect(boundAfterFailure).toBeUndefined()
|
||||
expect(providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true)
|
||||
})
|
||||
|
||||
it('reaps the replacement when its binding save fails, and the remount starts fresh', async () => {
|
||||
const { provider, providerSpawn, store, runtime, control, leafBinding } =
|
||||
installRestartHarness()
|
||||
control.failNextWrite = true
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
registerWithFakes(mainWindow, runtime, store)
|
||||
await expect(
|
||||
handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' }))
|
||||
).rejects.toThrow('ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED')
|
||||
expect(provider.shutdown).toHaveBeenCalledWith(
|
||||
'pty-new',
|
||||
expect.objectContaining({ immediate: true })
|
||||
)
|
||||
const boundAfterFailure = leafBinding()
|
||||
await expect(handlers.get('pty:spawn')!(null, restartSpawnArgs())).resolves.toMatchObject({
|
||||
id: 'pty-new'
|
||||
})
|
||||
expect(boundAfterFailure).toBeUndefined()
|
||||
expect(providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses an old restart after another owner has already claimed the pane', async () => {
|
||||
const { shutdown, providerSpawn, store, runtime } = installRestartHarness()
|
||||
store.getWorkspaceSession().terminalLayoutsByTabId[tabId].ptyIdsByLeafId[leafId] = 'successor'
|
||||
registerWithFakes(mainWindow, runtime, store)
|
||||
await expect(
|
||||
handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' }))
|
||||
).rejects.toThrow('terminal_pane_owner_changed')
|
||||
expect(shutdown).not.toHaveBeenCalled()
|
||||
expect(providerSpawn).not.toHaveBeenCalled()
|
||||
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reaps the replacement when a successor binds the pane during its spawn', async () => {
|
||||
const { provider, providerSpawn, store, runtime, leafBinding } = installRestartHarness()
|
||||
providerSpawn.mockImplementationOnce(async () => {
|
||||
store.getWorkspaceSession().terminalLayoutsByTabId[tabId].ptyIdsByLeafId[leafId] = 'successor'
|
||||
return { id: 'pty-new', incarnationId: 'inc-new' }
|
||||
})
|
||||
registerWithFakes(mainWindow, runtime, store)
|
||||
await expect(
|
||||
handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' }))
|
||||
).rejects.toThrow('terminal_pane_owner_changed')
|
||||
expect(provider.shutdown).toHaveBeenCalledWith(
|
||||
'pty-new',
|
||||
expect.objectContaining({ immediate: true })
|
||||
)
|
||||
expect(leafBinding()).toBe('successor')
|
||||
})
|
||||
|
||||
/** The SSH pane lives only in its host partition; the local leaf keeps an unrelated id. */
|
||||
async function withSshRestart(
|
||||
run: (
|
||||
host: RestartHarness & { oldId: string; newId: string; hostId: string },
|
||||
restart: (extra?: { replacesPtyId?: string }) => Promise<unknown>
|
||||
) => Promise<void>
|
||||
): Promise<void> {
|
||||
const harness = installRestartHarness()
|
||||
const connectionId = 'restart-ssh'
|
||||
const hostId = 'ssh:restart-ssh'
|
||||
const oldId = toAppSshPtyId(connectionId, 'pty-old')
|
||||
const newId = toAppSshPtyId(connectionId, 'pty-new')
|
||||
harness.partitions[hostId] = seedSession(oldId)
|
||||
Object.assign(harness.store, {
|
||||
markSshRemotePtyLease: vi.fn(),
|
||||
upsertSshRemotePtyLease: vi.fn(),
|
||||
removeSshRemotePtyLease: vi.fn(),
|
||||
supersedeSshRemotePtyLeasesForBoundPane: vi.fn()
|
||||
})
|
||||
let oldAlive = true
|
||||
harness.shutdown.mockImplementation(async () => {
|
||||
oldAlive = false
|
||||
})
|
||||
harness.providerSpawn.mockImplementation(async (options) => {
|
||||
if (!options.attachOnly) {
|
||||
return { id: newId, incarnationId: 'inc-new' }
|
||||
}
|
||||
if (!oldAlive) {
|
||||
throw new TerminalKilledError(oldId)
|
||||
}
|
||||
return { id: oldId, incarnationId: 'inc-old', isReattach: true }
|
||||
})
|
||||
registerSshPtyProvider(
|
||||
connectionId,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: same fake provider surface used by the local restart fixture.
|
||||
harness.provider as unknown as Parameters<typeof registerSshPtyProvider>[1]
|
||||
)
|
||||
const localSpawn = vi.fn(() => {
|
||||
throw new Error('wrong execution host')
|
||||
})
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the test must never reach the local provider.
|
||||
setLocalPtyProvider({
|
||||
...harness.provider,
|
||||
spawn: localSpawn,
|
||||
shutdown: localSpawn
|
||||
} as unknown as Parameters<typeof setLocalPtyProvider>[0])
|
||||
registerWithFakes(mainWindow, harness.runtime, harness.store)
|
||||
try {
|
||||
await run({ ...harness, oldId, newId, hostId }, async (extra = {}) =>
|
||||
handlers.get('pty:spawn')!(null, { ...restartSpawnArgs(extra), connectionId })
|
||||
)
|
||||
expect(localSpawn).not.toHaveBeenCalled()
|
||||
expect(harness.leafBinding()).toBe('pty-old')
|
||||
} finally {
|
||||
unregisterSshPtyProvider(connectionId)
|
||||
}
|
||||
}
|
||||
|
||||
it('swaps the direct-SSH host binding without touching the local provider', async () => {
|
||||
await withSshRestart(async (host, restart) => {
|
||||
await expect(restart({ replacesPtyId: host.oldId })).resolves.toMatchObject({
|
||||
id: host.newId
|
||||
})
|
||||
expect(host.shutdown).toHaveBeenCalledWith(
|
||||
host.oldId,
|
||||
expect.objectContaining({ immediate: true })
|
||||
)
|
||||
expect(host.store.persistPtyBinding).toHaveBeenCalledWith(expect.any(Function), host.hostId)
|
||||
expect(host.leafBinding(host.hostId)).toBe(host.newId)
|
||||
expect(host.providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('reaps a direct-SSH replacement when a successor binds the host pane during its spawn', async () => {
|
||||
await withSshRestart(async (host, restart) => {
|
||||
host.providerSpawn.mockImplementationOnce(async () => {
|
||||
host.partitions[host.hostId].terminalLayoutsByTabId[tabId].ptyIdsByLeafId[leafId] =
|
||||
'successor'
|
||||
return { id: host.newId, incarnationId: 'inc-new' }
|
||||
})
|
||||
await expect(restart({ replacesPtyId: host.oldId })).rejects.toThrow(
|
||||
'terminal_pane_owner_changed'
|
||||
)
|
||||
expect(host.shutdown).toHaveBeenCalledWith(
|
||||
host.newId,
|
||||
expect.objectContaining({ immediate: true })
|
||||
)
|
||||
expect(host.leafBinding(host.hostId)).toBe('successor')
|
||||
})
|
||||
})
|
||||
|
||||
it('clears the direct-SSH stopped binding when the replacement fails, so the remount starts fresh', async () => {
|
||||
await withSshRestart(async (host, restart) => {
|
||||
host.providerSpawn.mockRejectedValueOnce(new Error('spawn failed'))
|
||||
await expect(restart({ replacesPtyId: host.oldId })).rejects.toThrow('spawn failed')
|
||||
const boundAfterFailure = host.leafBinding(host.hostId)
|
||||
await expect(restart()).resolves.toMatchObject({ id: host.newId })
|
||||
expect(boundAfterFailure).toBeUndefined()
|
||||
expect(host.providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['darwin', 'linux', 'win32'])(
|
||||
'restarts a folder workspace on %s without deleting its pane',
|
||||
async (platform) => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
||||
onTestFinished(() => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
})
|
||||
const { store, runtime, leafBinding } = installRestartHarness()
|
||||
const folderId = 'folder:restart-folder'
|
||||
const session = store.getWorkspaceSession()
|
||||
session.tabsByWorktree[folderId] = session.tabsByWorktree[worktreeId].map((tab) => ({
|
||||
...tab,
|
||||
worktreeId: folderId
|
||||
}))
|
||||
session.tabsByWorktree[worktreeId] = []
|
||||
const folder = {
|
||||
id: 'restart-folder',
|
||||
folderPath: process.cwd(),
|
||||
name: 'Restart',
|
||||
projectGroupId: null
|
||||
}
|
||||
Object.assign(store, {
|
||||
getFolderWorkspaces: vi.fn(() => [folder]),
|
||||
getFolderWorkspace: vi.fn(() => folder)
|
||||
})
|
||||
registerWithFakes(mainWindow, runtime, store)
|
||||
await expect(
|
||||
handlers.get('pty:spawn')!(null, {
|
||||
...restartSpawnArgs({ replacesPtyId: 'pty-old' }),
|
||||
worktreeId: folderId,
|
||||
cwd: process.cwd()
|
||||
})
|
||||
).resolves.toMatchObject({ id: 'pty-new' })
|
||||
expect(store.getWorkspaceSession().tabsByWorktree[folderId]).toHaveLength(1)
|
||||
expect(leafBinding()).toBe('pty-new')
|
||||
expect(store.getWorkspaceSession().terminalLayoutsByTabId[tabId].root).toEqual({
|
||||
type: 'leaf',
|
||||
leafId
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -30,6 +30,7 @@ export type PrepareCodexResumeHomeArgs = {
|
||||
providerSession?: AgentProviderSessionMetadata
|
||||
target: CodexAccountSelectionTarget
|
||||
launchEnv?: NodeJS.ProcessEnv
|
||||
useSelectedAccount?: boolean
|
||||
}
|
||||
|
||||
export function prepareCodexResumeHome(
|
||||
@@ -48,7 +49,8 @@ export function prepareCodexResumeHome(
|
||||
preparation: prepareCodexSessionResume({
|
||||
providerSession,
|
||||
target: args.target,
|
||||
launchEnv: args.launchEnv
|
||||
launchEnv: args.launchEnv,
|
||||
...(args.useSelectedAccount ? { useSelectedAccount: true } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ export type PrepareCodexSessionResume = (args: {
|
||||
providerSession: AgentProviderSessionMetadata
|
||||
target: CodexAccountSelectionTarget
|
||||
launchEnv?: NodeJS.ProcessEnv
|
||||
useSelectedAccount?: boolean
|
||||
}) => Promise<CodexSessionResumePreparation | null>
|
||||
|
||||
export type CodexHomePtySpawnedLifecycleArgs = {
|
||||
|
||||
@@ -13,11 +13,12 @@ import {
|
||||
pendingRuntimePaneCreatesByOwnerKey
|
||||
} from '../pane/spawn-reservation'
|
||||
import { resolveStablePaneOwner } from '../pane/stable-owner'
|
||||
import { excludeReplacedPaneOwner } from '../pane/pane-owner-replacement'
|
||||
import type { PtyIpcSpawnState } from './spawn-state'
|
||||
import type { PtySpawnIpcArgs } from './spawn-types'
|
||||
|
||||
/** The pane key a spawn names before preflight; null when it names no stable pane. */
|
||||
function resolveEarlyPaneKey(args: PtySpawnIpcArgs): string | null {
|
||||
export function resolveEarlyPaneKey(args: PtySpawnIpcArgs): string | null {
|
||||
const leafId =
|
||||
typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null
|
||||
return typeof args.worktreeId === 'string' &&
|
||||
@@ -45,12 +46,15 @@ export async function beginPtyIpcSpawn(
|
||||
const initialStablePanePtyId = (() => {
|
||||
try {
|
||||
return !args.connectionId && initialPaneKey
|
||||
? resolveStablePaneOwner(
|
||||
ctx.deps.runtime,
|
||||
ctx.deps.store,
|
||||
initialPaneKey,
|
||||
args.worktreeId,
|
||||
args.connectionId
|
||||
? excludeReplacedPaneOwner(
|
||||
resolveStablePaneOwner(
|
||||
ctx.deps.runtime,
|
||||
ctx.deps.store,
|
||||
initialPaneKey,
|
||||
args.worktreeId,
|
||||
args.connectionId
|
||||
),
|
||||
ctx.replacedPaneOwner
|
||||
)?.ptyId
|
||||
: undefined
|
||||
} catch {
|
||||
@@ -87,12 +91,15 @@ export async function beginPtyIpcSpawn(
|
||||
}
|
||||
ctx.earlyStablePaneOwner =
|
||||
initialPaneKey && args.worktreeId
|
||||
? resolveStablePaneOwner(
|
||||
ctx.deps.runtime,
|
||||
ctx.deps.store,
|
||||
initialPaneKey,
|
||||
args.worktreeId,
|
||||
args.connectionId
|
||||
? excludeReplacedPaneOwner(
|
||||
resolveStablePaneOwner(
|
||||
ctx.deps.runtime,
|
||||
ctx.deps.store,
|
||||
initialPaneKey,
|
||||
args.worktreeId,
|
||||
args.connectionId
|
||||
),
|
||||
ctx.replacedPaneOwner
|
||||
)
|
||||
: null
|
||||
ctx.earlyWorktreeId = args.worktreeId
|
||||
|
||||
@@ -3,6 +3,7 @@ import { closeStartupQueryAuthorityForPty, getRelayPtyId } from '../provider/reg
|
||||
import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure'
|
||||
import { recordCodexPaneAccountForSpawn } from '../host-env/codex-home'
|
||||
import { persistAdmittedStablePaneBinding } from '../pane/stable-owner'
|
||||
import { swapReplacedPaneBinding } from '../pane/pane-owner-replacement'
|
||||
import { claimSshPaneLease } from '../pane/ssh-pane-lease-claim'
|
||||
import {
|
||||
pendingByPaneKey,
|
||||
@@ -60,9 +61,13 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<P
|
||||
...(ctx.cwd ? { startupCwd: ctx.cwd } : {}),
|
||||
origin: spawnCommitBindingOrigin(ctx.result)
|
||||
}
|
||||
const persisted = args.connectionId
|
||||
? await ctx.deps.store.persistPtyBinding(binding, toSshExecutionHostId(args.connectionId))
|
||||
: await ctx.deps.store.persistPtyBinding(binding)
|
||||
const hostId = args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined
|
||||
const input = ctx.replacedPaneOwner
|
||||
? swapReplacedPaneBinding(ctx.deps.store, binding, ctx.replacedPaneOwner, hostId)
|
||||
: binding
|
||||
const persisted = hostId
|
||||
? await ctx.deps.store.persistPtyBinding(input, hostId)
|
||||
: await ctx.deps.store.persistPtyBinding(input)
|
||||
if (persisted === false) {
|
||||
throw new Error('terminal_pane_owner_changed')
|
||||
}
|
||||
|
||||
@@ -39,7 +39,8 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis
|
||||
launchAgent: args.launchAgent,
|
||||
providerSession: args.resumeProviderSession,
|
||||
target: ctx.codexSelectionTarget,
|
||||
launchEnv: ctx.baseEnv
|
||||
launchEnv: ctx.baseEnv,
|
||||
...(args.replacesPtyId ? { useSelectedAccount: true } : {})
|
||||
})
|
||||
ctx.codexResumeLaunch = codexResumePreparation
|
||||
? await ctx.deps.resolveCodexResumeLaunch(args.command, codexResumePreparation)
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getCohortAtEmit } from '../../../telemetry/cohort-classifier'
|
||||
import { agentKindSchema } from '../../../../shared/telemetry-events'
|
||||
import { normalizeNodePtySpawnError } from '../provider/liveness'
|
||||
import { resolveStablePaneOwner, spawnForStablePane } from '../pane/stable-owner'
|
||||
import { excludeReplacedPaneOwner } from '../pane/pane-owner-replacement'
|
||||
import { assertSpawnReplyWasLive } from '../pane/agent-session-owners'
|
||||
import { deletePtyOwnership } from '../provider/ownership-state'
|
||||
import { ptySizes } from '../delivery/visibility-state'
|
||||
@@ -23,13 +24,19 @@ export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<void> {
|
||||
ctx.deps.trustedTerminalHandleEnv.add(ctx.preAllocatedHandle)
|
||||
}
|
||||
ctx.spawnTiming.mark('options')
|
||||
const stablePaneOwnerCandidate = resolveStablePaneOwner(
|
||||
ctx.deps.runtime,
|
||||
ctx.deps.store,
|
||||
ctx.reservationPaneKey,
|
||||
args.worktreeId,
|
||||
args.connectionId
|
||||
)
|
||||
// Why exclude: the restart's own stop leaves its binding for the bind to swap, not to reattach.
|
||||
const resolveOwner = () =>
|
||||
excludeReplacedPaneOwner(
|
||||
resolveStablePaneOwner(
|
||||
ctx.deps.runtime,
|
||||
ctx.deps.store,
|
||||
ctx.reservationPaneKey,
|
||||
args.worktreeId,
|
||||
args.connectionId
|
||||
),
|
||||
ctx.replacedPaneOwner
|
||||
)
|
||||
const stablePaneOwnerCandidate = resolveOwner()
|
||||
const expectedPtyId =
|
||||
stablePaneOwnerCandidate?.ptyId ?? ctx.effectiveSessionAppId ?? ctx.effectiveSessionId
|
||||
if (expectedPtyId) {
|
||||
@@ -56,14 +63,7 @@ export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<void> {
|
||||
owner: stablePaneOwnerCandidate,
|
||||
worktreeId: args.worktreeId,
|
||||
connectionId: args.connectionId,
|
||||
resolveOwner: () =>
|
||||
resolveStablePaneOwner(
|
||||
ctx.deps.runtime,
|
||||
ctx.deps.store,
|
||||
ctx.reservationPaneKey,
|
||||
args.worktreeId,
|
||||
args.connectionId
|
||||
)
|
||||
resolveOwner
|
||||
})
|
||||
ctx.result = stablePaneSpawn.result
|
||||
ctx.stablePaneOwner = stablePaneSpawn.owner
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { rejectPaneSpawnReservation, reserveIdlePaneSpawn } from '../pane/spawn-reservation'
|
||||
import { ptySizes } from '../delivery/visibility-state'
|
||||
import { beginPtyIpcSpawn, resolveEarlyPaneSpawnReservationKey } from './spawn-begin'
|
||||
import {
|
||||
beginPtyIpcSpawn,
|
||||
resolveEarlyPaneKey,
|
||||
resolveEarlyPaneSpawnReservationKey
|
||||
} from './spawn-begin'
|
||||
import { releaseStoppedPaneBinding, stopReplacedPaneOwner } from '../pane/pane-owner-replacement'
|
||||
import { preparePtyIpcSpawnPreflight } from './spawn-preflight'
|
||||
import { assemblePtyIpcSpawnEnv } from './spawn-env'
|
||||
import { preparePtyIpcQoderCommand } from './spawn-qoder-command'
|
||||
@@ -43,9 +48,12 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg
|
||||
}
|
||||
try {
|
||||
if (args.replacesPtyId !== undefined) {
|
||||
// Why: stop before resolving the pane owner, so the spawn below finds a dead owner and
|
||||
// launches fresh instead of reattaching the process this restart exists to replace.
|
||||
await deps.stopReplacedPty(args.replacesPtyId)
|
||||
ctx.replacedPaneOwner = await stopReplacedPaneOwner(deps, {
|
||||
replacesPtyId: args.replacesPtyId,
|
||||
paneKey: resolveEarlyPaneKey(args),
|
||||
worktreeId: args.worktreeId,
|
||||
connectionId: args.connectionId
|
||||
})
|
||||
}
|
||||
triggerPtySpawnPushTargetMaterialization(deps, args)
|
||||
const early = await beginPtyIpcSpawn(ctx)
|
||||
@@ -81,6 +89,9 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg
|
||||
)
|
||||
ctx.pendingRegistrationPtyId = null
|
||||
}
|
||||
if (ctx.replacedPaneOwner) {
|
||||
await releaseStoppedPaneBinding(deps.store, ctx.replacedPaneOwner)
|
||||
}
|
||||
// Why: once the reservation is created, any later throw —
|
||||
// spawn failure, persist failure, or a post-spawn helper such as
|
||||
// seedHeadlessTerminal/registerPty/track — must settle it. Otherwise
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createPtySpawnTiming } from '../../pty-spawn-timing'
|
||||
import { noCodexResumeLaunch, type CodexResumeLaunch } from '../host-env/codex-resume'
|
||||
import type { StablePaneOwner } from '../pane/stable-owner'
|
||||
import type { PaneSpawnReservation } from '../pane/spawn-reservation'
|
||||
import type { ReplacedPaneOwner } from '../pane/pane-owner-replacement'
|
||||
import { localProvider } from '../provider/registry'
|
||||
import type { AdoptStablePaneResult, PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types'
|
||||
|
||||
@@ -25,6 +26,7 @@ export type PtyIpcSpawnState = {
|
||||
earlyWorktreeId: string | undefined
|
||||
paneSpawnReservationKey: string | null
|
||||
paneSpawnReservation: PaneSpawnReservation | null
|
||||
replacedPaneOwner: ReplacedPaneOwner | null
|
||||
finishTerminalInstall: () => void
|
||||
result: PtySpawnResult
|
||||
stablePaneOwner: StablePaneOwner | null
|
||||
@@ -103,6 +105,7 @@ export function createPtyIpcSpawnState(
|
||||
earlyWorktreeId: undefined,
|
||||
paneSpawnReservationKey: null,
|
||||
paneSpawnReservation: null,
|
||||
replacedPaneOwner: null,
|
||||
finishTerminalInstall: () => {},
|
||||
result: { id: '' },
|
||||
stablePaneOwner: null,
|
||||
|
||||
@@ -107,6 +107,7 @@ export type PtySpawnIpcDeps = {
|
||||
providerSession?: AgentProviderSessionMetadata
|
||||
target: CodexAccountSelectionTarget
|
||||
launchEnv?: NodeJS.ProcessEnv
|
||||
useSelectedAccount?: boolean
|
||||
}) => PreparedCodexResumeHome | null
|
||||
noCodexResumeLaunch: (command: string | undefined) => CodexResumeLaunch
|
||||
resolveCodexResumeLaunch: (
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { fixture } from '../../../persistence/loading-store/profile-state-delayed-authority-fixture'
|
||||
import { TEST_LEAF_1, TEST_LEAF_2 } from '../../../persistence-session-fixtures'
|
||||
import { swapReplacedPaneBinding, type ReplacedPaneOwner } from './pane-owner-replacement'
|
||||
|
||||
vi.mock('../../../telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('../../../telemetry/cohort-classifier', () => ({
|
||||
getCohortAtEmit: () => ({ nth_repo_added: 2 })
|
||||
}))
|
||||
vi.mock('../../../ssh/ssh-config-parser', () => ({
|
||||
loadUserSshConfig: () => ({ hosts: [] }),
|
||||
sshConfigHostsToTargets: () => []
|
||||
}))
|
||||
|
||||
const worktreeId = 'repo-local::/fixture/local'
|
||||
const tabId = 'restart-tab'
|
||||
|
||||
function replaced(ptyId: string, hostId?: string): ReplacedPaneOwner {
|
||||
return { ptyId, pane: { worktreeId, tabId, leafId: TEST_LEAF_1, hostId } }
|
||||
}
|
||||
|
||||
type FixtureStore = Awaited<ReturnType<typeof fixture>>['store']
|
||||
|
||||
/** The binding swap must survive the renderer's own pre-connect clear, which strict fences refuse. */
|
||||
async function expectSwapAfterRendererClear(
|
||||
store: FixtureStore,
|
||||
oldId: string,
|
||||
newId: string,
|
||||
hostId?: string
|
||||
): Promise<void> {
|
||||
expect(
|
||||
store.getWorkspaceSession(hostId).terminalLayoutsByTabId[tabId].ptyIdsByLeafId?.[TEST_LEAF_1]
|
||||
).toBeUndefined()
|
||||
const binding = { worktreeId, tabId, leafId: TEST_LEAF_1, ptyId: newId, incarnationId: 'inc-new' }
|
||||
expect(
|
||||
await store.persistPtyBinding(
|
||||
{ ...binding, expectedBinding: { ptyId: oldId, incarnationId: 'inc-old' } },
|
||||
hostId
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
await store.persistPtyBinding(
|
||||
swapReplacedPaneBinding(store, binding, replaced(oldId, hostId), hostId),
|
||||
hostId
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
store.getWorkspaceSession(hostId).terminalLayoutsByTabId[tabId].ptyIdsByLeafId?.[TEST_LEAF_1]
|
||||
).toBe(newId)
|
||||
}
|
||||
|
||||
it('swaps a local split pane whose renderer layout patch already dropped the stopped binding', async () => {
|
||||
const { store } = await fixture()
|
||||
const pane = { worktreeId, tabId }
|
||||
await store.persistPtyBinding({
|
||||
...pane,
|
||||
leafId: TEST_LEAF_1,
|
||||
ptyId: 'old',
|
||||
incarnationId: 'inc-old'
|
||||
})
|
||||
await store.persistPtyBinding({ ...pane, leafId: TEST_LEAF_2, ptyId: 'sibling' })
|
||||
const layouts = structuredClone(store.getWorkspaceSession().terminalLayoutsByTabId)
|
||||
// The mounted restart clears only its own leaf before connecting; a partial map is honored.
|
||||
delete layouts[tabId].ptyIdsByLeafId![TEST_LEAF_1]
|
||||
store.patchWorkspaceSession({ terminalLayoutsByTabId: layouts })
|
||||
await expectSwapAfterRendererClear(store, 'old', 'new')
|
||||
expect(
|
||||
store.getWorkspaceSession().terminalLayoutsByTabId[tabId].ptyIdsByLeafId?.[TEST_LEAF_2]
|
||||
).toBe('sibling')
|
||||
})
|
||||
|
||||
it('swaps an SSH pane whose terminated lease let the renderer clear withdraw the binding', async () => {
|
||||
const { store } = await fixture()
|
||||
const hostId = 'ssh:restart'
|
||||
const oldId = 'ssh:restart@@remote-old'
|
||||
await store.persistPtyBinding(
|
||||
{ worktreeId, tabId, leafId: TEST_LEAF_1, ptyId: oldId, incarnationId: 'inc-old' },
|
||||
hostId
|
||||
)
|
||||
store.upsertSshRemotePtyLease({
|
||||
targetId: 'restart',
|
||||
ptyId: 'remote-old',
|
||||
worktreeId,
|
||||
tabId,
|
||||
leafId: TEST_LEAF_1,
|
||||
state: 'attached'
|
||||
})
|
||||
// What the replacement stop records (finishPtyShutdown).
|
||||
store.markSshRemotePtyLease('restart', 'remote-old', 'terminated')
|
||||
const layouts = structuredClone(store.getWorkspaceSession(hostId).terminalLayoutsByTabId)
|
||||
layouts[tabId].ptyIdsByLeafId = {}
|
||||
store.patchWorkspaceSession({ terminalLayoutsByTabId: layouts }, hostId)
|
||||
await expectSwapAfterRendererClear(store, oldId, 'ssh:restart@@remote-new', hostId)
|
||||
})
|
||||
|
||||
it('refuses the swap when another owner holds the leaf', async () => {
|
||||
const { store } = await fixture()
|
||||
await store.persistPtyBinding({ worktreeId, tabId, leafId: TEST_LEAF_1, ptyId: 'successor' })
|
||||
const binding = { worktreeId, tabId, leafId: TEST_LEAF_1, ptyId: 'new' }
|
||||
expect(
|
||||
await store.persistPtyBinding(
|
||||
swapReplacedPaneBinding(store, binding, replaced('old'), undefined)
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
store.getWorkspaceSession().terminalLayoutsByTabId[tabId].ptyIdsByLeafId?.[TEST_LEAF_1]
|
||||
).toBe('successor')
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import { toSshExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { parsePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import type { Store } from '../../../persistence'
|
||||
import type { PersistPtyBindingArgs } from '../../../persistence/loading-store/pty-binding-persistence'
|
||||
import type { OrcaRuntimeService } from '../../../runtime/orca-runtime'
|
||||
import { resolveStablePaneOwner, type StablePaneOwner } from './stable-owner'
|
||||
|
||||
/** The owner a replacing spawn stopped. Its binding stays until the replacement's bind swaps it. */
|
||||
export type ReplacedPaneOwner = {
|
||||
ptyId: string
|
||||
pane: { worktreeId: string; tabId: string; leafId: string; hostId: string | undefined } | null
|
||||
}
|
||||
|
||||
/** Caller holds the pane's spawn reservation, so no other spawn can claim the pane meanwhile. */
|
||||
export async function stopReplacedPaneOwner(
|
||||
deps: {
|
||||
runtime?: OrcaRuntimeService
|
||||
store?: Store
|
||||
stopReplacedPty: (id: string) => Promise<void>
|
||||
},
|
||||
args: {
|
||||
replacesPtyId: string
|
||||
paneKey: string | null
|
||||
worktreeId: string | undefined
|
||||
connectionId: string | null | undefined
|
||||
}
|
||||
): Promise<ReplacedPaneOwner> {
|
||||
const owner = resolveStablePaneOwner(
|
||||
deps.runtime,
|
||||
deps.store,
|
||||
args.paneKey,
|
||||
args.worktreeId,
|
||||
args.connectionId
|
||||
)
|
||||
if (owner && owner.ptyId !== args.replacesPtyId) {
|
||||
throw new Error('terminal_pane_owner_changed')
|
||||
}
|
||||
await deps.stopReplacedPty(args.replacesPtyId)
|
||||
const pane = args.paneKey ? parsePaneKey(args.paneKey) : null
|
||||
return {
|
||||
ptyId: args.replacesPtyId,
|
||||
pane:
|
||||
pane && args.worktreeId
|
||||
? {
|
||||
worktreeId: args.worktreeId,
|
||||
tabId: pane.tabId,
|
||||
leafId: pane.leafId,
|
||||
hostId: args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined
|
||||
}
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
/** The stopped id is dead whatever its incarnation, so a binding naming it must not be reattached. */
|
||||
export function excludeReplacedPaneOwner(
|
||||
owner: StablePaneOwner | null,
|
||||
replaced: ReplacedPaneOwner | null
|
||||
): StablePaneOwner | null {
|
||||
return owner && owner.ptyId === replaced?.ptyId ? null : owner
|
||||
}
|
||||
|
||||
/**
|
||||
* The replacement's bind is the swap: decided inside the bind's durable mutation, it takes a leaf
|
||||
* still naming the stopped id, or one the renderer already cleared; any other owner wins.
|
||||
*/
|
||||
export function swapReplacedPaneBinding(
|
||||
store: Store,
|
||||
binding: PersistPtyBindingArgs,
|
||||
replaced: ReplacedPaneOwner,
|
||||
hostId: string | undefined
|
||||
): () => PersistPtyBindingArgs | null {
|
||||
return () => {
|
||||
const bound =
|
||||
store.getWorkspaceSession(hostId).terminalLayoutsByTabId?.[binding.tabId]?.ptyIdsByLeafId?.[
|
||||
binding.leafId
|
||||
]
|
||||
// Unbound is ok: the renderer's pre-connect clear can withdraw it (split tab, SSH lease ended).
|
||||
return bound === undefined || bound === replaced.ptyId ? binding : null
|
||||
}
|
||||
}
|
||||
|
||||
/** Best effort: a failed replacement must not leave the stopped id bound for the remount to reattach. */
|
||||
export async function releaseStoppedPaneBinding(
|
||||
store: Store | undefined,
|
||||
replaced: ReplacedPaneOwner
|
||||
): Promise<void> {
|
||||
if (!store || !replaced.pane) {
|
||||
return
|
||||
}
|
||||
const { hostId, ...pane } = replaced.pane
|
||||
try {
|
||||
await store.retirePtyBinding({ ...pane, ptyId: replaced.ptyId }, hostId)
|
||||
} catch (error) {
|
||||
console.warn('[pty] could not clear the stopped pane binding after a failed restart:', error)
|
||||
}
|
||||
}
|
||||
+29
-16
@@ -12,6 +12,7 @@ import {
|
||||
import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire'
|
||||
import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record'
|
||||
import { describeNativeChatTurnStatus } from '../../../shared/native-chat-turn-status'
|
||||
import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice'
|
||||
import { selectStructuredAgentSettledTurns } from '../../../shared/structured-agent-session-turn-timing'
|
||||
import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule'
|
||||
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
|
||||
@@ -121,9 +122,19 @@ async function settledTurn() {
|
||||
const { items } = await host.journalSnapshot(SESSION)
|
||||
const turn = items.map((item) => readAgentJournalTurn(item.body)).find(Boolean)
|
||||
const [settled] = [...selectStructuredAgentSettledTurns(items).values()]
|
||||
return { turn, settled }
|
||||
const label = settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }).key
|
||||
// Every error row a reader's transcript shows, the cut turn's derived notice included.
|
||||
const notices = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }).flatMap((item) =>
|
||||
item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : []
|
||||
)
|
||||
return { turn, settled, label, notices }
|
||||
}
|
||||
|
||||
/** A turn nobody stopped reads like a finished one, with exactly one row saying it stopped. */
|
||||
const ONE_NOTICE = [
|
||||
'Codex stopped while this response was in progress. You can continue in this conversation.'
|
||||
]
|
||||
|
||||
function lastSummary(statuses: AgentSessionStatusEvent[]) {
|
||||
const last = statuses.at(-1)
|
||||
return last?.type === 'status' ? last.session : null
|
||||
@@ -136,11 +147,11 @@ describe('a turn cut short by closing its provider', () => {
|
||||
await host.close(SESSION, 'user-close')
|
||||
|
||||
expect(lastSummary(statuses)).toMatchObject({ status: 'idle', turnOutcome: 'cancellation' })
|
||||
const { turn, settled } = await settledTurn()
|
||||
const { turn, label, notices } = await settledTurn()
|
||||
expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' })
|
||||
expect(
|
||||
settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled })
|
||||
).toMatchObject({ key: 'interruptedAfter' })
|
||||
expect(label).toBe('interruptedAfter')
|
||||
// The person's own close explains itself.
|
||||
expect(notices).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves a close the user did not aim at this chat as news', async () => {
|
||||
@@ -150,12 +161,12 @@ describe('a turn cut short by closing its provider', () => {
|
||||
await host.close(SESSION, 'evict')
|
||||
|
||||
expect(lastSummary(statuses)).toMatchObject({ status: 'idle', turnOutcome: 'interruption' })
|
||||
const { turn, settled } = await settledTurn()
|
||||
const { turn, label, notices } = await settledTurn()
|
||||
expect(turn).toMatchObject({ state: 'interrupted' })
|
||||
expect(turn).not.toHaveProperty('outcome')
|
||||
expect(
|
||||
settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled })
|
||||
).toMatchObject({ key: 'failedAfter' })
|
||||
// News for the sidebar; the turn reads like a finished one beside its one notice.
|
||||
expect(label).toBe('workedFor')
|
||||
expect(notices).toEqual(ONE_NOTICE)
|
||||
})
|
||||
|
||||
it("records the user's close on a turn no adapter settled, through the host's fallback", async () => {
|
||||
@@ -247,10 +258,9 @@ describe('a turn cut short by closing its provider', () => {
|
||||
expect(statuses.findLast((event) => event.type === 'status')).toMatchObject({
|
||||
session: { status: 'idle', turnOutcome: 'interruption' }
|
||||
})
|
||||
const { settled } = await settledTurn()
|
||||
expect(
|
||||
settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled })
|
||||
).toMatchObject({ key: 'failedAfter' })
|
||||
const { label, notices } = await settledTurn()
|
||||
expect(label).toBe('workedFor')
|
||||
expect(notices).toEqual(ONE_NOTICE)
|
||||
})
|
||||
|
||||
it("keeps the user's cancellation when a close aborts after the provider settled, then retries", async () => {
|
||||
@@ -297,9 +307,10 @@ describe('a turn cut short by closing its provider', () => {
|
||||
await host.collaboratorsForTests().lifetime.idleSweep.tick()
|
||||
|
||||
expect(closeCalls).toBe(1)
|
||||
const { turn } = await settledTurn()
|
||||
const { turn, notices } = await settledTurn()
|
||||
expect(turn).toMatchObject({ state: 'interrupted' })
|
||||
expect(turn?.outcome).toBe(verdict.outcome)
|
||||
expect(notices).toEqual(cause === 'evict' ? ONE_NOTICE : [])
|
||||
}
|
||||
)
|
||||
|
||||
@@ -309,9 +320,10 @@ describe('a turn cut short by closing its provider', () => {
|
||||
|
||||
await host.flushAllStreamedEvents({ trigger: 'quit' })
|
||||
|
||||
const { turn } = await settledTurn()
|
||||
const { turn, notices } = await settledTurn()
|
||||
expect(turn).toMatchObject({ state: 'interrupted' })
|
||||
expect(turn).not.toHaveProperty('outcome')
|
||||
expect(notices).toEqual(ONE_NOTICE)
|
||||
})
|
||||
|
||||
it('leaves a crash the provider saw before the user closed the chat as news', async () => {
|
||||
@@ -319,9 +331,10 @@ describe('a turn cut short by closing its provider', () => {
|
||||
await runningTurn()
|
||||
// The provider reports its own exit, which it saw first, as it closes: no verdict.
|
||||
await host.close(SESSION, 'user-close')
|
||||
const { turn } = await settledTurn()
|
||||
const { turn, notices } = await settledTurn()
|
||||
expect(turn).toMatchObject({ state: 'interrupted' })
|
||||
expect(turn).not.toHaveProperty('outcome')
|
||||
expect(notices).toEqual(ONE_NOTICE)
|
||||
})
|
||||
|
||||
it.each(['user-close', 'evict'] as const)(
|
||||
|
||||
+32
-3
@@ -38,6 +38,7 @@ import {
|
||||
} from './structured-agent-session-adapter'
|
||||
import { resettleOpenStructuredAgentSessionConversation } from './structured-agent-session-conversation-open'
|
||||
import { StructuredAgentSessionHost } from './structured-agent-session-host'
|
||||
import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice'
|
||||
import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep'
|
||||
import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
|
||||
import {
|
||||
@@ -216,6 +217,34 @@ describe('a turn a crash cut short mid-tool', () => {
|
||||
expect(completedStructuredAgentTurnSeconds(timing)).toBe(27)
|
||||
})
|
||||
|
||||
// The turn bar reads like a finished turn, so this row is the one place the chat says why.
|
||||
it('explains the cut once, with one notice row and a turn bar that does not repeat it', async () => {
|
||||
openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) })
|
||||
|
||||
await host.restoreReadableSessions()
|
||||
|
||||
const { items } = await host.journalSnapshot(SESSION)
|
||||
// As a reader's transcript shows it: the stored row is the explanation, so none is derived.
|
||||
const statusRows = withNativeChatCutTurnNotices(items, { agentName: 'Claude' }).flatMap(
|
||||
(item) => (item.body.kind === 'status' ? [item.body] : [])
|
||||
)
|
||||
expect(statusRows).toEqual([
|
||||
expect.objectContaining({
|
||||
text: 'Claude stopped while this response was in progress. You can continue in this conversation.',
|
||||
failure: expect.objectContaining({ kind: 'providerExited' }),
|
||||
tone: 'error'
|
||||
})
|
||||
])
|
||||
const [timing] = selectStructuredAgentTurnTimings(items).values()
|
||||
expect(
|
||||
describeNativeChatTurnStatus({
|
||||
elapsedSeconds: 0,
|
||||
workedSeconds: completedStructuredAgentTurnSeconds(timing),
|
||||
verdict: timing?.verdict
|
||||
})
|
||||
).toEqual({ key: 'workedFor', duration: '27s' })
|
||||
})
|
||||
|
||||
it('ends at the pre-crash renewal when the child outlived Orca and recovery stopped it', async () => {
|
||||
// The orphan is alive at relaunch, but its output went nowhere: none of that is work shown.
|
||||
let alive = true
|
||||
@@ -298,7 +327,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', ()
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('reports the revision to the status feed as an interruption, which the chat folds as failed', async () => {
|
||||
it('reports the revision to the status feed as an interruption, which the chat folds as worked', async () => {
|
||||
const published: AgentSessionStatusSummary[] = []
|
||||
openHost({
|
||||
probeOwner: async () => ({ outcome: 'pid-absent' }),
|
||||
@@ -314,7 +343,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', ()
|
||||
await host.reconcileRestartLeases()
|
||||
await drainSession()
|
||||
|
||||
// The sidebar's red Failed, then the folded "Failed after 27s".
|
||||
// The sidebar's red Failed until seen; the turn folds as "Worked for 27s" beside its notice row.
|
||||
await vi.waitFor(() => expect(outcomes().at(-1)).toBe('interruption'))
|
||||
const [timing] = selectStructuredAgentTurnTimings(
|
||||
(await host.journalSnapshot(SESSION)).items
|
||||
@@ -325,7 +354,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', ()
|
||||
workedSeconds: completedStructuredAgentTurnSeconds(timing),
|
||||
verdict: timing?.verdict
|
||||
})
|
||||
).toEqual({ key: 'failedAfter', duration: '27s' })
|
||||
).toEqual({ key: 'workedFor', duration: '27s' })
|
||||
})
|
||||
|
||||
it('revises nothing twice, whoever re-runs the settle', async () => {
|
||||
|
||||
+3
-2
@@ -4,6 +4,7 @@ import {
|
||||
type SubmissionRejectionFact
|
||||
} from '../../../shared/agent-session-failure'
|
||||
import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import { STALE_SESSION_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity'
|
||||
import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission'
|
||||
import {
|
||||
AGENT_JOURNAL_THREAD_SCOPE,
|
||||
@@ -229,7 +230,7 @@ export async function settleStaleStructuredAgentSessionState(input: {
|
||||
)
|
||||
// Per attempt: a retry re-partitions only what is left, and a reused chunk id would skip it.
|
||||
const generation = input.acquisitionGeneration ?? `seq-${journal.cursor().sequence}`
|
||||
const settlementId = `stale-session:${input.sessionId}:${input.fence}:${generation}`
|
||||
const settlementId = `${STALE_SESSION_ROW_PREFIX}${input.sessionId}:${input.fence}:${generation}`
|
||||
const mutations: JournalLifecycleMutationInput[] = []
|
||||
for (const item of items) {
|
||||
const identity = parseAgentJournalItemKey(item.itemId)
|
||||
@@ -261,7 +262,7 @@ export async function settleStaleStructuredAgentSessionState(input: {
|
||||
// Named by the death it explains, so a retry after a partly written settle adds no second row.
|
||||
identity: {
|
||||
provider: 'orca',
|
||||
clientMessageId: `stale-session:${input.sessionId}:death-${evidence.ownerFence ?? 'unowned'}-${evidence.observedAt}`
|
||||
clientMessageId: `${STALE_SESSION_ROW_PREFIX}${input.sessionId}:death-${evidence.ownerFence ?? 'unowned'}-${evidence.observedAt}`
|
||||
},
|
||||
// The death evidence is Orca's log text, never a sentence for a person: the row says only
|
||||
// that the provider stopped.
|
||||
|
||||
+4
-4
@@ -183,8 +183,8 @@ describe('a turn recovery settled after its host went away', () => {
|
||||
}
|
||||
)
|
||||
|
||||
// The chat's turn bar and the tab's mark read one verdict: a turn nobody stopped failed, and
|
||||
// must never show the done tick of a finished turn.
|
||||
// The tab's mark reads a turn nobody stopped as failed; the chat's turn bar reads it like a
|
||||
// finished turn, since the chat's notice row says why it stopped.
|
||||
it.each([
|
||||
[
|
||||
'a restart',
|
||||
@@ -208,7 +208,7 @@ describe('a turn recovery settled after its host went away', () => {
|
||||
)
|
||||
]
|
||||
] as const)(
|
||||
'reads Failed after N, marked failed, for a turn cut off by %s',
|
||||
'reads Worked for N, marked failed, for a turn cut off by %s',
|
||||
async (_label, cut) => {
|
||||
const session = await sessionWithRunningTurn()
|
||||
session.recoverAt(RECOVERED)
|
||||
@@ -221,7 +221,7 @@ describe('a turn recovery settled after its host went away', () => {
|
||||
...selectStructuredAgentSettledTurns(session.journal.snapshot().items).values()
|
||||
]
|
||||
expect(settled && formatNativeChatTurnStatusLabel({ elapsedSeconds: 0, ...settled })).toBe(
|
||||
'Failed after 1s'
|
||||
'Worked for 1s'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
+5
-1
@@ -22,6 +22,7 @@ import {
|
||||
type AgentSessionSendResult
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import { RESTART_CONTINUATION_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity'
|
||||
import {
|
||||
AGENT_SESSION_RESTART_CONTINUATION_NOTE,
|
||||
AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE,
|
||||
@@ -120,7 +121,10 @@ function restartNoteWriter(
|
||||
return
|
||||
}
|
||||
await session.journal.appendItem(
|
||||
{ provider: 'orca', clientMessageId: `restart-continuation:${sessionId}:${host.now()}` },
|
||||
{
|
||||
provider: 'orca',
|
||||
clientMessageId: `${RESTART_CONTINUATION_ROW_PREFIX}${sessionId}:${host.now()}`
|
||||
},
|
||||
{ kind: 'status', text, ...(tone ? { tone } : {}) },
|
||||
// About the conversation, not any turn in it.
|
||||
{ fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
|
||||
+25
@@ -1,12 +1,15 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule'
|
||||
import {
|
||||
AGENT_SESSION_RESTART_CONTINUATION_NOTE,
|
||||
AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE,
|
||||
AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE
|
||||
} from '../../../shared/agent-session-restart-continuation'
|
||||
import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner'
|
||||
import {
|
||||
interruptedRestart,
|
||||
QUIT_CUT_NOTICE,
|
||||
readerNotes,
|
||||
statusNotes,
|
||||
throwAfterContinuationAccepted
|
||||
} from './structured-agent-session-restart-interruption-test-harness'
|
||||
@@ -43,6 +46,8 @@ it('files nothing for a chat the user moved on in before its attempt, and spends
|
||||
expect(await capsule.listFailed(NOW)).toEqual([])
|
||||
expect(await capsule.list(NOW)).toEqual([])
|
||||
expect(await statusNotes(host)).toEqual([])
|
||||
// No note took over, so the cut turn keeps its one notice.
|
||||
expect(await readerNotes(host)).toEqual([QUIT_CUT_NOTICE])
|
||||
})
|
||||
|
||||
// The continuation is accepted and its agent then fails to start: the message is rejected with the
|
||||
@@ -51,6 +56,8 @@ it('says so in the chat when the agent cannot start for the continuation', async
|
||||
const { host, acquire } = await interruptedRestart()
|
||||
await host.restartResume.list()
|
||||
acquire.mockRejectedValueOnce(new Error('provider could not reconnect'))
|
||||
// Before the action, the quit's cut reads with its one notice.
|
||||
expect(await readerNotes(host)).toEqual([QUIT_CUT_NOTICE])
|
||||
|
||||
await host.restartResume.continueAfterRestart([SESSION], 'modal')
|
||||
|
||||
@@ -61,6 +68,24 @@ it('says so in the chat when the agent cannot start for the continuation', async
|
||||
text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE,
|
||||
tone: 'error'
|
||||
})
|
||||
// The refusal note is now the one explanation: the cut is not said a second time beside it.
|
||||
expect(await readerNotes(host)).toEqual(await statusNotes(host))
|
||||
})
|
||||
|
||||
// A continuation that carries on, chosen in the prompt or automatically at launch, does not stand in
|
||||
// for the cut: its note is written after its own message, so the cut keeps its notice throughout.
|
||||
it("keeps the quit's notice through a continuation that carries the chat on", async () => {
|
||||
const { host } = await interruptedRestart()
|
||||
await host.restartResume.list()
|
||||
expect(await readerNotes(host)).toEqual([QUIT_CUT_NOTICE])
|
||||
|
||||
const result = await host.restartResume.continueAfterRestart([SESSION], 'modal')
|
||||
|
||||
expect(result.continued).toMatchObject([{ outcome: 'continued' }])
|
||||
expect(await readerNotes(host)).toEqual([
|
||||
QUIT_CUT_NOTICE,
|
||||
{ text: AGENT_SESSION_RESTART_CONTINUATION_NOTE, tone: undefined }
|
||||
])
|
||||
})
|
||||
|
||||
// A send that throws after Orca may have taken it cannot be proven undelivered: filed unconfirmed,
|
||||
|
||||
+25
-2
@@ -1,4 +1,8 @@
|
||||
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
|
||||
import {
|
||||
AGENT_JOURNAL_THREAD_SCOPE,
|
||||
type AgentJournalRenderItem
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice'
|
||||
// A chat interrupted mid-turn by a restart, rebuilt on a fresh host over the same store, for the
|
||||
// restart-resume ownership and failure tests.
|
||||
|
||||
@@ -177,11 +181,30 @@ export function throwAfterContinuationAccepted(): void {
|
||||
}
|
||||
|
||||
export async function statusNotes(host: StructuredAgentSessionHost) {
|
||||
return (await host.journalSnapshot(SESSION)).items.flatMap((item) =>
|
||||
return notesOf((await host.journalSnapshot(SESSION)).items)
|
||||
}
|
||||
|
||||
/** The notes a reader's transcript shows, the cut turn's derived notice included. */
|
||||
export async function readerNotes(host: StructuredAgentSessionHost) {
|
||||
return notesOf(
|
||||
withNativeChatCutTurnNotices((await host.journalSnapshot(SESSION)).items, {
|
||||
agentName: 'Codex'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function notesOf(items: readonly AgentJournalRenderItem[]) {
|
||||
return items.flatMap((item) =>
|
||||
item.body.kind === 'status' ? [{ text: item.body.text, tone: item.body.tone }] : []
|
||||
)
|
||||
}
|
||||
|
||||
/** The one row the quit's cut turn reads with when nothing else explains it. */
|
||||
export const QUIT_CUT_NOTICE = {
|
||||
text: 'Codex stopped while this response was in progress. You can continue in this conversation.',
|
||||
tone: 'error'
|
||||
}
|
||||
|
||||
/** A continuation the host refuses because the user's own message was accepted first: another
|
||||
* client's send lands after the action reserved the offer, just before the continuation is
|
||||
* accepted. `userAnswers` instead has the user send before or after the whole attempt. */
|
||||
|
||||
+3
-2
@@ -158,7 +158,8 @@ describe('a restart between a Stop and its turn end', () => {
|
||||
expect(errorRows).toEqual([])
|
||||
})
|
||||
|
||||
it('reads Failed after N, marked failed, when nobody stopped it', async () => {
|
||||
// The notice row is the one explanation; the turn bar reads like a finished turn.
|
||||
it('reads Worked for N, marked failed, when nobody stopped it', async () => {
|
||||
await runningTurn(CODEX_TURN)
|
||||
|
||||
await restartAndSettle()
|
||||
@@ -166,7 +167,7 @@ describe('a restart between a Stop and its turn end', () => {
|
||||
const { turn, label, mark, errorRows } = settled()
|
||||
expect(turn).toMatchObject({ state: 'interrupted' })
|
||||
expect(turn).not.toHaveProperty('outcome')
|
||||
expect(label).toMatch(/^Failed after /)
|
||||
expect(label).toMatch(/^Worked for /)
|
||||
expect(mark).toBe('failed')
|
||||
expect(errorRows).toEqual([
|
||||
expect.stringContaining('stopped while this response was in progress')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words'
|
||||
import { PROVIDER_EXIT_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity'
|
||||
import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import type { StructuredAgentSessionEndedEvent } from './structured-agent-session-adapter'
|
||||
@@ -193,5 +194,5 @@ function logExitFailure(
|
||||
}
|
||||
|
||||
function providerExitSettlementId(event: UnexpectedExitLifecycleEvent): string {
|
||||
return `provider-exit:${event.sessionId}:${event.fence}:${event.acquisitionGeneration}`
|
||||
return `${PROVIDER_EXIT_ROW_PREFIX}${event.sessionId}:${event.fence}:${event.acquisitionGeneration}`
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { isTerminalLeafId } from '../../../shared/stable-pane-id'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import { rollbackFailedPtyBinding } from './pty-binding-write-rollback'
|
||||
import { cloneWorkspaceSessionState } from '../restoring-sessions/session-owner-fields'
|
||||
import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../restoring-sessions/workspace-session-write-rollback'
|
||||
import { clearReplacedPaneBinding } from './replaced-pane-binding'
|
||||
|
||||
import type { PtyBindingSourceExpectation } from './store'
|
||||
|
||||
@@ -67,6 +69,59 @@ export class PtyBindingPersistenceOperations {
|
||||
this[ptyBindingPersistenceOperationsContext] = { runtime, sessions }
|
||||
}
|
||||
|
||||
/** Clears a stopped process's binding, keeping the pane; fenced on the id, which is dead in any incarnation. */
|
||||
async retirePtyBinding(
|
||||
binding: Pick<PersistPtyBindingArgs, 'worktreeId' | 'tabId' | 'leafId' | 'ptyId'>,
|
||||
hostId?: string | null
|
||||
): Promise<boolean> {
|
||||
const { runtime, sessions } = this[ptyBindingPersistenceOperationsContext]
|
||||
const resolved = resolveHostId(hostId)
|
||||
const publish = (session: WorkspaceSessionState): void => {
|
||||
if (resolved === LOCAL_EXECUTION_HOST_ID) {
|
||||
runtime.state.workspaceSession = session
|
||||
} else {
|
||||
runtime.state.workspaceSessionsByHostId = {
|
||||
...runtime.state.workspaceSessionsByHostId,
|
||||
[resolved]: session
|
||||
}
|
||||
}
|
||||
runtime.dirtyProfileStateDomains?.add(
|
||||
resolved === LOCAL_EXECUTION_HOST_ID ? 'workspaceSession' : 'workspaceSessionsByHostId'
|
||||
)
|
||||
}
|
||||
return runtime.runDurableMutation(() => {
|
||||
const session = sessions.getWorkspaceSession(resolved)
|
||||
const currentId =
|
||||
session.terminalLayoutsByTabId[binding.tabId]?.ptyIdsByLeafId?.[binding.leafId]
|
||||
if (!currentId) {
|
||||
return { value: true, persist: 'if-dirty' }
|
||||
}
|
||||
if (currentId !== binding.ptyId) {
|
||||
return { value: false, persist: false }
|
||||
}
|
||||
if (!session.tabsByWorktree[binding.worktreeId]?.some((tab) => tab.id === binding.tabId)) {
|
||||
return { value: false, persist: false }
|
||||
}
|
||||
const before = cloneWorkspaceSessionState(session)
|
||||
const retired = clearReplacedPaneBinding(session, { ...binding, parentTabId: binding.tabId })
|
||||
// Host retirement must not run renderer snapshot repair, which would put the old binding back.
|
||||
publish(retired)
|
||||
const staged = cloneWorkspaceSessionState(retired)
|
||||
return {
|
||||
value: true,
|
||||
rollback: () => {
|
||||
publish(
|
||||
rollbackWorkspaceSessionAfterFailedAsyncWrite(
|
||||
before,
|
||||
staged,
|
||||
sessions.getWorkspaceSession(resolved)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async persistPtyBinding(
|
||||
input: PersistPtyBindingArgs | (() => PersistPtyBindingArgs | null),
|
||||
hostId?: string | null
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { fixture } from './profile-state-delayed-authority-fixture'
|
||||
import { TEST_LEAF_1 } from '../../persistence-session-fixtures'
|
||||
import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors'
|
||||
|
||||
vi.mock('../../telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('../../telemetry/cohort-classifier', () => ({
|
||||
getCohortAtEmit: () => ({ nth_repo_added: 2 })
|
||||
}))
|
||||
vi.mock('../../ssh/ssh-config-parser', () => ({
|
||||
loadUserSshConfig: () => ({ hosts: [] }),
|
||||
sshConfigHostsToTargets: () => []
|
||||
}))
|
||||
|
||||
const binding = {
|
||||
worktreeId: 'repo-local::/fixture/local',
|
||||
tabId: 'restart-tab',
|
||||
leafId: TEST_LEAF_1,
|
||||
ptyId: 'old',
|
||||
incarnationId: 'old-incarnation'
|
||||
}
|
||||
|
||||
it.each([undefined, 'ssh:restart'])(
|
||||
'durably retires only the process binding on %s',
|
||||
async (hostId) => {
|
||||
const { store, readState } = await fixture()
|
||||
await store.persistPtyBinding(binding, hostId)
|
||||
const before = structuredClone(store.getWorkspaceSession(hostId))
|
||||
expect(await store.retirePtyBinding(binding, hostId)).toBe(true)
|
||||
const retired = store.getWorkspaceSession(hostId)
|
||||
expect(retired.terminalLayoutsByTabId[binding.tabId]).toEqual({
|
||||
...before.terminalLayoutsByTabId[binding.tabId],
|
||||
ptyIdsByLeafId: {}
|
||||
})
|
||||
expect(retired.tabsByWorktree[binding.worktreeId]).toEqual(
|
||||
before.tabsByWorktree[binding.worktreeId].map((tab) =>
|
||||
tab.id === binding.tabId ? { ...tab, ptyId: null } : tab
|
||||
)
|
||||
)
|
||||
const durable = hostId
|
||||
? readState().workspaceSessionsByHostId?.[hostId]
|
||||
: readState().workspaceSession
|
||||
expect(durable?.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId).toEqual({})
|
||||
await store.persistPtyBinding(
|
||||
{ ...binding, ptyId: 'new', incarnationId: 'new-incarnation' },
|
||||
hostId
|
||||
)
|
||||
expect(await store.retirePtyBinding(binding, hostId)).toBe(false)
|
||||
expect(
|
||||
store.getWorkspaceSession(hostId).terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId?.[
|
||||
binding.leafId
|
||||
]
|
||||
).toBe('new')
|
||||
}
|
||||
)
|
||||
|
||||
it('clears the stopped id whatever incarnation the binding carries', async () => {
|
||||
const { store } = await fixture()
|
||||
await store.persistPtyBinding({ ...binding, incarnationId: 'republished-incarnation' })
|
||||
expect(await store.retirePtyBinding(binding)).toBe(true)
|
||||
expect(store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId).toEqual(
|
||||
{}
|
||||
)
|
||||
})
|
||||
|
||||
it('restores the old binding after a known save failure and allows another attempt', async () => {
|
||||
const { store, authority } = await fixture()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
await store.persistPtyBinding(binding)
|
||||
const gate = authority.pause()
|
||||
const rejected = expect(store.retirePtyBinding(binding)).rejects.toThrow('disk refused')
|
||||
await gate.started.promise
|
||||
gate.finish.reject(
|
||||
new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure')
|
||||
)
|
||||
await rejected
|
||||
expect(
|
||||
store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId?.[
|
||||
binding.leafId
|
||||
]
|
||||
).toBe('old')
|
||||
expect(await store.retirePtyBinding(binding)).toBe(true)
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import type { RetiredTerminalSurface } from '../../runtime/mobile-session-terminal-retirement'
|
||||
|
||||
/** The caller fences the old owner; a restart keeps the pane and removes only its process binding. */
|
||||
export function clearReplacedPaneBinding(
|
||||
session: WorkspaceSessionState,
|
||||
surface: RetiredTerminalSurface
|
||||
): WorkspaceSessionState {
|
||||
const layout = session.terminalLayoutsByTabId[surface.parentTabId]
|
||||
if (!layout || layout.ptyIdsByLeafId?.[surface.leafId] !== surface.ptyId) {
|
||||
return session
|
||||
}
|
||||
const ptyIdsByLeafId = { ...layout.ptyIdsByLeafId }
|
||||
delete ptyIdsByLeafId[surface.leafId]
|
||||
const terminalPtyIncarnationsByPaneKey = { ...session.terminalPtyIncarnationsByPaneKey }
|
||||
delete terminalPtyIncarnationsByPaneKey[`${surface.parentTabId}:${surface.leafId}`]
|
||||
return {
|
||||
...session,
|
||||
terminalPtyIncarnationsByPaneKey,
|
||||
terminalLayoutsByTabId: {
|
||||
...session.terminalLayoutsByTabId,
|
||||
[surface.parentTabId]: { ...layout, ptyIdsByLeafId }
|
||||
},
|
||||
tabsByWorktree: {
|
||||
...session.tabsByWorktree,
|
||||
[surface.worktreeId]: (session.tabsByWorktree[surface.worktreeId] ?? []).map((tab) =>
|
||||
tab.id === surface.parentTabId && tab.ptyId === surface.ptyId
|
||||
? { ...tab, ptyId: null }
|
||||
: tab
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { describe, expect, it } from 'vitest'
|
||||
// means a call site was added, removed, or moved: re-audit the file and update the count.
|
||||
const AUDITED_NON_NET_FETCH_CALLS = new Map<string, number>([
|
||||
// Isolated cookie-jar session, proxied by createOpenCodeRequestSession before any request.
|
||||
['main/rate-limits/opencode-go-usage-fetcher.ts', 2],
|
||||
['main/rate-limits/opencode-go-usage-fetcher.ts', 3],
|
||||
// Isolated cookie-jar session that does NOT apply the proxy — a pre-existing gap, not a
|
||||
// regression: no proxy has ever reached this partition. Keep it listed so it stays visible.
|
||||
['main/rate-limits/minimax/minimax-request-context.ts', 2],
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fetchClaudeRateLimits } from './claude-fetcher'
|
||||
import { primeClaudeFetcherMocks, restorePlatform } from './claude-fetcher-test-harness'
|
||||
import { readActiveClaudeKeychainCredentialsStrict } from '../claude-accounts/keychain'
|
||||
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
|
||||
|
||||
const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted(
|
||||
() => ({
|
||||
netFetchMock: vi.fn(),
|
||||
readFileMock: vi.fn(),
|
||||
resolveProxyMock: vi.fn(),
|
||||
setProxyMock: vi.fn(),
|
||||
appGetPathMock: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: readFileMock
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: appGetPathMock
|
||||
},
|
||||
net: {
|
||||
fetch: netFetchMock
|
||||
},
|
||||
session: {
|
||||
defaultSession: {
|
||||
resolveProxy: resolveProxyMock,
|
||||
setProxy: setProxyMock
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./claude-pty', () => ({
|
||||
fetchViaPty: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../claude-accounts/keychain', () => ({
|
||||
deleteActiveClaudeKeychainCredentialsStrict: vi.fn(),
|
||||
readActiveClaudeKeychainCredentials: vi.fn(),
|
||||
readActiveClaudeKeychainCredentialsStrict: vi.fn(),
|
||||
readManagedClaudeKeychainCredentials: vi.fn(),
|
||||
writeActiveClaudeKeychainCredentials: vi.fn(),
|
||||
writeManagedClaudeKeychainCredentials: vi.fn()
|
||||
}))
|
||||
|
||||
function oauthPrep(): ClaudeRuntimeAuthPreparation {
|
||||
return {
|
||||
configDir: '/Users/test/.claude',
|
||||
envPatch: { CLAUDE_CONFIG_DIR: '/Users/test/.claude' },
|
||||
stripAuthEnv: false,
|
||||
provenance: 'system'
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUsageResponse(body: string) {
|
||||
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
claudeAiOauth: { accessToken: 'oauth-token', expiresAt: Date.now() + 60_000 }
|
||||
})
|
||||
)
|
||||
netFetchMock.mockResolvedValueOnce(new Response(body, { status: 200 }))
|
||||
return fetchClaudeRateLimits({ authPreparation: oauthPrep() })
|
||||
}
|
||||
|
||||
describe('fetchClaudeRateLimits extra usage', () => {
|
||||
beforeEach(() => {
|
||||
primeClaudeFetcherMocks({
|
||||
netFetchMock,
|
||||
readFileMock,
|
||||
resolveProxyMock,
|
||||
setProxyMock,
|
||||
appGetPathMock
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restorePlatform()
|
||||
})
|
||||
|
||||
it('maps the usage-credits spend object into a capped balance in major units', async () => {
|
||||
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'oauth-token',
|
||||
expiresAt: Date.now() + 60_000
|
||||
}
|
||||
})
|
||||
)
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
five_hour: { utilization: 10 },
|
||||
spend: {
|
||||
used: { amount_minor: 5000, currency: 'EUR', exponent: 2 },
|
||||
limit: { amount_minor: 200000, currency: 'EUR', exponent: 2 },
|
||||
percent: 2.5,
|
||||
enabled: true,
|
||||
balance: { amount_minor: 1000, currency: 'EUR', exponent: 2 }
|
||||
}
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
)
|
||||
|
||||
await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({
|
||||
provider: 'claude',
|
||||
status: 'ok',
|
||||
extraUsage: {
|
||||
balance: 10,
|
||||
spent: 50,
|
||||
spendLimit: 2000,
|
||||
spentPercent: 2.5,
|
||||
currencyCode: 'EUR',
|
||||
enabled: true,
|
||||
disabledReason: null,
|
||||
resetsAt: null
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the usage-credits cap visible without inventing an out-of-credits balance', async () => {
|
||||
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'oauth-token',
|
||||
expiresAt: Date.now() + 60_000
|
||||
}
|
||||
})
|
||||
)
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
five_hour: { utilization: 10 },
|
||||
spend: {
|
||||
used: { amount_minor: 0, currency: 'EUR', exponent: 2 },
|
||||
limit: { amount_minor: 200000, currency: 'EUR', exponent: 2 },
|
||||
percent: 0,
|
||||
enabled: false,
|
||||
disabled_reason: 'out_of_credits',
|
||||
balance: null
|
||||
}
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
)
|
||||
|
||||
await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({
|
||||
provider: 'claude',
|
||||
status: 'ok',
|
||||
extraUsage: {
|
||||
balance: null,
|
||||
spent: 0,
|
||||
spendLimit: 2000,
|
||||
spentPercent: 0,
|
||||
currencyCode: 'EUR',
|
||||
enabled: false,
|
||||
disabledReason: 'out_of_credits'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps legacy extra_usage spend data without inventing a missing balance', async () => {
|
||||
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'oauth-token',
|
||||
expiresAt: Date.now() + 60_000
|
||||
}
|
||||
})
|
||||
)
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
five_hour: { utilization: 10 },
|
||||
extra_usage: {
|
||||
is_enabled: true,
|
||||
monthly_limit: 200000,
|
||||
used_credits: 5000,
|
||||
utilization: 2.5,
|
||||
currency: 'EUR',
|
||||
decimal_places: 2
|
||||
}
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
)
|
||||
|
||||
await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({
|
||||
provider: 'claude',
|
||||
status: 'ok',
|
||||
extraUsage: {
|
||||
balance: null,
|
||||
spent: 50,
|
||||
spendLimit: 2000,
|
||||
spentPercent: 2.5,
|
||||
currencyCode: 'EUR',
|
||||
enabled: true
|
||||
}
|
||||
})
|
||||
})
|
||||
it('keeps a missing balance unknown while preserving a known cap', async () => {
|
||||
const result = await fetchUsageResponse(
|
||||
'{"five_hour":{"utilization":10},"spend":{"limit":{"amount_minor":200000,"currency":"EUR"},"enabled":true}}'
|
||||
)
|
||||
expect(result.extraUsage).toMatchObject({
|
||||
balance: null,
|
||||
spendLimit: 2000,
|
||||
currencyCode: 'EUR'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([1000, 0])('preserves a balance-only EUR amount of %s minor units', async (amount) => {
|
||||
const result = await fetchUsageResponse(
|
||||
JSON.stringify({
|
||||
five_hour: { utilization: 10 },
|
||||
spend: { balance: { amount_minor: amount, currency: 'EUR' }, enabled: true }
|
||||
})
|
||||
)
|
||||
expect(result.extraUsage).toMatchObject({
|
||||
balance: amount / 100,
|
||||
currencyCode: 'EUR',
|
||||
spendLimit: null
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a numeric overflow balance unknown and derives a finite percentage', async () => {
|
||||
const result = await fetchUsageResponse(
|
||||
'{"five_hour":{"utilization":10},"spend":{"used":{"amount_minor":5000},"limit":{"amount_minor":200000},"balance":{"amount_minor":1e309},"percent":1e309,"enabled":true}}'
|
||||
)
|
||||
expect(result.extraUsage).toMatchObject({
|
||||
balance: null,
|
||||
spent: 50,
|
||||
spendLimit: 2000,
|
||||
spentPercent: 2.5
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps nullable legacy spend unknown despite a supplied percentage', async () => {
|
||||
const result = await fetchUsageResponse(
|
||||
'{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":200000,"used_credits":null,"utilization":10,"currency":"EUR","is_enabled":true}}'
|
||||
)
|
||||
expect(result.extraUsage).toMatchObject({
|
||||
balance: null,
|
||||
spent: null,
|
||||
spendLimit: 2000,
|
||||
spentPercent: 10
|
||||
})
|
||||
})
|
||||
|
||||
it('derives legacy percentage when the response percentage overflows', async () => {
|
||||
const result = await fetchUsageResponse(
|
||||
'{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":200000,"used_credits":5000,"utilization":1e309,"currency":"EUR","is_enabled":true}}'
|
||||
)
|
||||
expect(result.extraUsage).toMatchObject({
|
||||
balance: null,
|
||||
spent: 50,
|
||||
spendLimit: 2000,
|
||||
spentPercent: 2.5
|
||||
})
|
||||
})
|
||||
|
||||
it('does not turn an overflowing legacy spend into a known amount', async () => {
|
||||
const result = await fetchUsageResponse(
|
||||
'{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":200000,"used_credits":1e309,"currency":"EUR","is_enabled":true}}'
|
||||
)
|
||||
expect(result.extraUsage).toMatchObject({
|
||||
balance: null,
|
||||
spent: null,
|
||||
spendLimit: 2000,
|
||||
spentPercent: null
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['null', '1e309'])('omits a legacy cap whose amount is %s', async (limit) => {
|
||||
const result = await fetchUsageResponse(
|
||||
`{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":${limit},"used_credits":5000,"currency":"EUR","is_enabled":true}}`
|
||||
)
|
||||
expect(result.extraUsage ?? null).toBeNull()
|
||||
})
|
||||
|
||||
it.each([-1, 0.5, 309])(
|
||||
'omits malformed legacy precision %s rather than displaying zero',
|
||||
async (exponent) => {
|
||||
const result = await fetchUsageResponse(
|
||||
JSON.stringify({
|
||||
five_hour: { utilization: 10 },
|
||||
extra_usage: {
|
||||
monthly_limit: 200000,
|
||||
used_credits: 5000,
|
||||
decimal_places: exponent,
|
||||
currency: 'EUR',
|
||||
is_enabled: true
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(result.extraUsage ?? null).toBeNull()
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -4,6 +4,11 @@ import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings'
|
||||
import { createOAuthUsageError } from './claude-oauth-usage-error'
|
||||
import { mapClaudeUsageWindow, type ClaudeUsageWindowInput } from './claude-usage-window'
|
||||
import { abortedClaudeRateLimitResult } from './claude-usage-result'
|
||||
import {
|
||||
mapClaudeExtraUsage,
|
||||
type ClaudeOAuthExtraUsage,
|
||||
type ClaudeOAuthSpend
|
||||
} from './claude-usage-credits'
|
||||
|
||||
const OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
|
||||
const API_TIMEOUT_MS = 10_000
|
||||
@@ -22,6 +27,8 @@ type OAuthUsageResponse = {
|
||||
fable_weekly?: ClaudeUsageWindowInput
|
||||
fable_seven_day?: ClaudeUsageWindowInput
|
||||
seven_day_fable?: ClaudeUsageWindowInput
|
||||
spend?: ClaudeOAuthSpend
|
||||
extra_usage?: ClaudeOAuthExtraUsage
|
||||
limits?: OAuthUsageLimit[] | null
|
||||
}
|
||||
|
||||
@@ -90,6 +97,7 @@ export async function fetchClaudeOAuthUsage(
|
||||
session: mapClaudeUsageWindow(data.five_hour, 300),
|
||||
weekly: mapClaudeUsageWindow(data.seven_day, 10080),
|
||||
fableWeekly: mapFableWeeklyWindow(data),
|
||||
extraUsage: mapClaudeExtraUsage(data),
|
||||
updatedAt: Date.now(),
|
||||
error: null,
|
||||
status: 'ok'
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { ExtraUsageBalance } from '../../shared/rate-limit-types'
|
||||
|
||||
// Money is reported as minor units plus an exponent (e.g. 200000 @ exp 2 = €2000).
|
||||
type OAuthMoney = {
|
||||
amount_minor?: number
|
||||
currency?: string
|
||||
exponent?: number
|
||||
}
|
||||
|
||||
// Optional richer spend fields are retained for response compatibility.
|
||||
export type ClaudeOAuthSpend = {
|
||||
used?: OAuthMoney
|
||||
limit?: OAuthMoney
|
||||
percent?: number
|
||||
enabled?: boolean
|
||||
disabled_reason?: string | null
|
||||
cap?: { money?: OAuthMoney | null } | null
|
||||
balance?: OAuthMoney | number | null
|
||||
}
|
||||
|
||||
// Legacy shape kept as a fallback for older responses that only send `extra_usage`.
|
||||
export type ClaudeOAuthExtraUsage = {
|
||||
is_enabled?: boolean
|
||||
monthly_limit?: number | null
|
||||
used_credits?: number | null
|
||||
utilization?: number | null
|
||||
currency?: string
|
||||
decimal_places?: number
|
||||
disabled_reason?: string | null
|
||||
}
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
return Math.min(100, Math.max(0, value))
|
||||
}
|
||||
|
||||
function moneyToMajor(money: OAuthMoney | number | null | undefined): number | null {
|
||||
if (typeof money === 'number') {
|
||||
return Number.isFinite(money) ? money : null
|
||||
}
|
||||
if (!money || typeof money.amount_minor !== 'number' || !Number.isFinite(money.amount_minor)) {
|
||||
return null
|
||||
}
|
||||
const exponent = typeof money.exponent === 'number' ? money.exponent : 2
|
||||
if (!Number.isInteger(exponent) || exponent < 0 || exponent > 6) {
|
||||
return null
|
||||
}
|
||||
return money.amount_minor / 10 ** exponent
|
||||
}
|
||||
|
||||
// Prefer the richer `spend` object; fall back to the legacy `extra_usage` shape.
|
||||
export function mapClaudeExtraUsage(data: {
|
||||
spend?: ClaudeOAuthSpend
|
||||
extra_usage?: ClaudeOAuthExtraUsage
|
||||
}): ExtraUsageBalance | null {
|
||||
return mapSpend(data.spend) ?? mapLegacyExtraUsage(data.extra_usage)
|
||||
}
|
||||
|
||||
function mapSpend(spend: ClaudeOAuthSpend | undefined): ExtraUsageBalance | null {
|
||||
if (!spend) {
|
||||
return null
|
||||
}
|
||||
const spent = moneyToMajor(spend.used)
|
||||
const spendLimit = moneyToMajor(spend.limit) ?? moneyToMajor(spend.cap?.money)
|
||||
const balance = moneyToMajor(spend.balance)
|
||||
// Nothing worth showing when the account has neither a configured cap nor a balance.
|
||||
if (spendLimit === null && balance === null) {
|
||||
return null
|
||||
}
|
||||
const spentPercent =
|
||||
typeof spend.percent === 'number' && Number.isFinite(spend.percent)
|
||||
? clampPercent(spend.percent)
|
||||
: spendLimit !== null && spendLimit > 0 && spent !== null
|
||||
? clampPercent((spent / spendLimit) * 100)
|
||||
: null
|
||||
return {
|
||||
balance,
|
||||
unit: 'currency',
|
||||
currencyCode:
|
||||
spend.used?.currency ??
|
||||
spend.limit?.currency ??
|
||||
spend.cap?.money?.currency ??
|
||||
(typeof spend.balance === 'object' && spend.balance !== null
|
||||
? spend.balance.currency
|
||||
: undefined) ??
|
||||
'USD',
|
||||
enabled: spend.enabled === true,
|
||||
disabledReason: spend.disabled_reason ?? null,
|
||||
spent,
|
||||
spendLimit,
|
||||
spentPercent,
|
||||
resetsAt: null
|
||||
}
|
||||
}
|
||||
|
||||
function mapLegacyExtraUsage(extra: ClaudeOAuthExtraUsage | undefined): ExtraUsageBalance | null {
|
||||
if (!extra) {
|
||||
return null
|
||||
}
|
||||
const spendLimit = moneyToMajor({
|
||||
amount_minor: extra.monthly_limit ?? undefined,
|
||||
exponent: extra.decimal_places
|
||||
})
|
||||
if (spendLimit === null) {
|
||||
return null
|
||||
}
|
||||
const spent = moneyToMajor({
|
||||
amount_minor: extra.used_credits ?? undefined,
|
||||
exponent: extra.decimal_places
|
||||
})
|
||||
const spentPercent =
|
||||
typeof extra.utilization === 'number' && Number.isFinite(extra.utilization)
|
||||
? clampPercent(extra.utilization)
|
||||
: spendLimit > 0 && spent !== null
|
||||
? clampPercent((spent / spendLimit) * 100)
|
||||
: null
|
||||
return {
|
||||
balance: null,
|
||||
unit: 'currency',
|
||||
currencyCode: extra.currency?.trim() || 'USD',
|
||||
enabled: extra.is_enabled === true,
|
||||
disabledReason: extra.disabled_reason ?? null,
|
||||
spent,
|
||||
spendLimit,
|
||||
spentPercent,
|
||||
resetsAt: null
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,9 @@ export function mergeClaudeUsageWindows(
|
||||
...primary,
|
||||
session: primary.session ?? supplement.session,
|
||||
weekly: primary.weekly ?? supplement.weekly,
|
||||
fableWeekly: primary.fableWeekly ?? supplement.fableWeekly ?? null
|
||||
fableWeekly: primary.fableWeekly ?? supplement.fableWeekly ?? null,
|
||||
// Only OAuth reports the extra-usage cap; keep it when the CLI supplements windows.
|
||||
extraUsage: primary.extraUsage ?? supplement.extraUsage ?? null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ExtraUsageBalance } from '../../shared/rate-limit-types'
|
||||
import type { CodexRateLimitWindowsSnapshot } from './codex-rate-limit-window-classification'
|
||||
|
||||
// Codex's pay-as-you-go credit balance, reported alongside the rate windows.
|
||||
// `balance` is a plain credit count (string), not a currency amount.
|
||||
type RpcCredits = NonNullable<CodexRateLimitWindowsSnapshot['credits']>
|
||||
|
||||
// Codex credits are a unitless count, not currency. Only surface the balance
|
||||
// when the account actually has credits (or unlimited) so accounts that never
|
||||
// bought any don't get an empty "0 credits" row.
|
||||
export function mapCodexCredits(raw: RpcCredits | null | undefined): ExtraUsageBalance | null {
|
||||
if (!raw || (raw.hasCredits !== true && raw.unlimited !== true)) {
|
||||
return null
|
||||
}
|
||||
const parsed =
|
||||
typeof raw.balance === 'string' && raw.balance.trim() !== '' ? Number(raw.balance) : raw.balance
|
||||
if ((typeof parsed !== 'number' || !Number.isFinite(parsed)) && raw.unlimited !== true) {
|
||||
return null
|
||||
}
|
||||
const balance = typeof parsed === 'number' && Number.isFinite(parsed) ? Math.max(0, parsed) : 0
|
||||
return {
|
||||
balance,
|
||||
unit: 'credits',
|
||||
unlimited: raw.unlimited === true,
|
||||
enabled: raw.hasCredits === true || raw.unlimited === true,
|
||||
disabledReason: null,
|
||||
resetsAt: null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { childSpawnMock, readFileMock } = vi.hoisted(() => ({
|
||||
childSpawnMock: vi.fn(),
|
||||
readFileMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({ spawn: childSpawnMock }))
|
||||
vi.mock('node:fs/promises', () => ({ readFile: readFileMock }))
|
||||
vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' }))
|
||||
vi.mock('node-pty', () => ({ spawn: vi.fn() }))
|
||||
vi.mock('./codex-auth-presence', () => ({
|
||||
probeCodexAuthPresence: vi.fn(async () => 'present')
|
||||
}))
|
||||
|
||||
import { fetchCodexRateLimits } from './codex-fetcher'
|
||||
|
||||
function parseRpcRequest(line: string): { id?: number; method?: string } {
|
||||
const parsed: unknown = JSON.parse(line)
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
id: 'id' in parsed && typeof parsed.id === 'number' ? parsed.id : undefined,
|
||||
method: 'method' in parsed && typeof parsed.method === 'string' ? parsed.method : undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Like the real app-server, the fake dies on stdin EOF or a signal.
|
||||
class FakeRpcChild extends EventEmitter {
|
||||
readonly stdout = new EventEmitter()
|
||||
readonly stderr = new EventEmitter()
|
||||
exitCode: number | null = null
|
||||
rateLimits: unknown = null
|
||||
readonly kill = vi.fn(() => {
|
||||
this.exitNow()
|
||||
return true
|
||||
})
|
||||
readonly stdin = Object.assign(new EventEmitter(), {
|
||||
end: vi.fn(() => this.exitNow()),
|
||||
write: vi.fn((line: string) => {
|
||||
const request = parseRpcRequest(line)
|
||||
if (request.method === 'initialize') {
|
||||
this.reply(request.id, {})
|
||||
}
|
||||
if (request.method === 'account/rateLimits/read') {
|
||||
this.reply(request.id, { rateLimits: this.rateLimits })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
private reply(id: number | undefined, result: unknown): void {
|
||||
setTimeout(() => {
|
||||
this.stdout.emit('data', Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`))
|
||||
}, 0)
|
||||
}
|
||||
|
||||
private exitNow(): void {
|
||||
this.exitCode = 0
|
||||
this.emit('exit', 0, null)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithRateLimits(rateLimits: unknown) {
|
||||
const child = new FakeRpcChild()
|
||||
child.rateLimits = rateLimits
|
||||
childSpawnMock.mockReturnValue(child)
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
return resultPromise
|
||||
}
|
||||
|
||||
describe('fetchCodexRateLimits credit balance', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
readFileMock.mockRejectedValue(new Error('no auth fixture'))
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('maps the RPC credits object into a Codex credit-count balance', async () => {
|
||||
const result = await fetchWithRateLimits({
|
||||
primary: { usedPercent: 20, windowDurationMins: 10079 },
|
||||
secondary: null,
|
||||
credits: { hasCredits: true, unlimited: false, balance: '500' }
|
||||
})
|
||||
|
||||
expect(result.extraUsage).toMatchObject({
|
||||
balance: 500,
|
||||
unit: 'credits',
|
||||
unlimited: false,
|
||||
enabled: true
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the Codex balance when the account has no credits', async () => {
|
||||
const result = await fetchWithRateLimits({
|
||||
primary: { usedPercent: 20, windowDurationMins: 10079 },
|
||||
credits: { hasCredits: false, unlimited: false, balance: '0' }
|
||||
})
|
||||
|
||||
expect(result.extraUsage ?? null).toBeNull()
|
||||
})
|
||||
it.each([undefined, null, '', ' ', 'invalid', 'Infinity', '1e999'])(
|
||||
'omits unavailable credit balance %s despite hasCredits',
|
||||
async (balance) => {
|
||||
const result = await fetchWithRateLimits({
|
||||
primary: { usedPercent: 20, windowDurationMins: 10079 },
|
||||
credits: { hasCredits: true, unlimited: false, balance }
|
||||
})
|
||||
expect(result.extraUsage ?? null).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves an explicitly reported zero credit balance', async () => {
|
||||
const result = await fetchWithRateLimits({
|
||||
primary: { usedPercent: 20, windowDurationMins: 10079 },
|
||||
credits: { hasCredits: true, unlimited: false, balance: '0' }
|
||||
})
|
||||
expect(result.extraUsage).toMatchObject({ unit: 'credits', unlimited: false, balance: 0 })
|
||||
})
|
||||
|
||||
it('keeps the unlimited indication without requiring a numeric balance', async () => {
|
||||
const result = await fetchWithRateLimits({
|
||||
primary: { usedPercent: 20, windowDurationMins: 10079 },
|
||||
credits: { hasCredits: false, unlimited: true, balance: null }
|
||||
})
|
||||
expect(result.extraUsage).toMatchObject({ unit: 'credits', unlimited: true, enabled: true })
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,12 @@ export type CodexRateWindowSnapshot = {
|
||||
export type CodexRateLimitWindowsSnapshot = {
|
||||
primary?: CodexRateWindowSnapshot | null
|
||||
secondary?: CodexRateWindowSnapshot | null
|
||||
/** Pay-as-you-go credit balance, when the app-server reports one. */
|
||||
credits?: {
|
||||
hasCredits?: boolean
|
||||
unlimited?: boolean
|
||||
balance?: string | number
|
||||
} | null
|
||||
}
|
||||
|
||||
type MappableCodexRateWindowSnapshot = CodexRateWindowSnapshot & { usedPercent: number }
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
CODEX_WEEKLY_WINDOW_MINUTES,
|
||||
type CodexRateLimitWindowsSnapshot
|
||||
} from './codex-rate-limit-window-classification'
|
||||
import { mapCodexCredits } from './codex-credit-balance'
|
||||
import type { CodexRateLimitFetchOptions } from './codex-rate-limit-fetch-options'
|
||||
import { abortedCodexRateLimitResult } from './codex-rate-limit-fetch-result'
|
||||
import { mapCodexRateLimitWindow } from './codex-rate-limit-window-mapper'
|
||||
@@ -244,12 +245,14 @@ export function readCodexRateLimitsViaRpc(
|
||||
const wrapper = message.result as RpcRateLimitsResponse | undefined
|
||||
const classified = classifyCodexRateLimitWindows(wrapper?.rateLimits)
|
||||
const credits = mapRpcRateLimitResetCredits(wrapper?.rateLimitResetCredits)
|
||||
const extraUsage = mapCodexCredits(wrapper?.rateLimits?.credits)
|
||||
settle(
|
||||
{
|
||||
provider: 'codex',
|
||||
session: mapCodexRateLimitWindow(classified.session, CODEX_SESSION_WINDOW_MINUTES),
|
||||
weekly: mapCodexRateLimitWindow(classified.weekly, CODEX_WEEKLY_WINDOW_MINUTES),
|
||||
...(credits !== undefined ? { rateLimitResetCredits: credits } : {}),
|
||||
...(extraUsage ? { extraUsage } : {}),
|
||||
updatedAt: Date.now(),
|
||||
error: null,
|
||||
status: 'ok'
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isOpenCodeGoExplicitNoAccessPayload,
|
||||
parseOpenCodeGoBillingStatusPayload,
|
||||
parseOpenCodeGoStatusPayload,
|
||||
parseOpenCodeGoUsageApiPayload
|
||||
} from './opencode-go-status-parsing'
|
||||
@@ -120,6 +122,76 @@ describe('parseOpenCodeGoStatusPayload', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseOpenCodeGoBillingStatusPayload', () => {
|
||||
it('maps the verified PAYG micro-cent string into USD major units', () => {
|
||||
expect(
|
||||
parseOpenCodeGoBillingStatusPayload(
|
||||
JSON.stringify({
|
||||
billingMode: 'prepaid',
|
||||
mode: 'pay-as-you-go',
|
||||
balanceMicroCents: '2786781005'
|
||||
})
|
||||
)
|
||||
).toBe(27.86781005)
|
||||
})
|
||||
|
||||
it('preserves zero and negative balances', () => {
|
||||
expect(
|
||||
parseOpenCodeGoBillingStatusPayload(
|
||||
JSON.stringify({
|
||||
billingMode: 'prepaid',
|
||||
mode: 'pay-as-you-go',
|
||||
balanceMicroCents: '0'
|
||||
})
|
||||
)
|
||||
).toBe(0)
|
||||
expect(
|
||||
parseOpenCodeGoBillingStatusPayload(
|
||||
JSON.stringify({
|
||||
billingMode: 'prepaid',
|
||||
mode: 'pay-as-you-go',
|
||||
balanceMicroCents: '-125000000'
|
||||
})
|
||||
)
|
||||
).toBe(-1.25)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ billingMode: 'credit', mode: 'pay-as-you-go', balanceMicroCents: '100000000' },
|
||||
{ billingMode: 'seat', mode: 'pay-as-you-go', balanceMicroCents: '100000000' },
|
||||
{ billingMode: 'prepaid', mode: 'invoiceable', balanceMicroCents: '100000000' },
|
||||
{ billingMode: 'prepaid', mode: 'pay-as-you-go', balanceMicroCents: 100000000 },
|
||||
{ billingMode: 'prepaid', mode: 'pay-as-you-go', balanceMicroCents: '1.5' },
|
||||
{
|
||||
billingMode: 'prepaid',
|
||||
mode: 'pay-as-you-go',
|
||||
balanceMicroCents: '9007199254740992'
|
||||
}
|
||||
])('rejects unsupported or unsafe billing payloads: $billingMode/$mode', (payload) => {
|
||||
expect(parseOpenCodeGoBillingStatusPayload(JSON.stringify(payload))).toBeNull()
|
||||
})
|
||||
|
||||
it('fails closed for malformed and non-object bodies', () => {
|
||||
expect(parseOpenCodeGoBillingStatusPayload('{not json')).toBeNull()
|
||||
expect(parseOpenCodeGoBillingStatusPayload('[]')).toBeNull()
|
||||
expect(parseOpenCodeGoBillingStatusPayload('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isOpenCodeGoExplicitNoAccessPayload', () => {
|
||||
it('accepts only explicit JSON null or access:null without an error verdict', () => {
|
||||
expect(isOpenCodeGoExplicitNoAccessPayload('null')).toBe(true)
|
||||
expect(isOpenCodeGoExplicitNoAccessPayload('{"access":null}')).toBe(true)
|
||||
expect(
|
||||
isOpenCodeGoExplicitNoAccessPayload('{"access":null,"error":{"type":"AuthError"}}')
|
||||
).toBe(false)
|
||||
expect(isOpenCodeGoExplicitNoAccessPayload('{"access":{}}')).toBe(false)
|
||||
expect(isOpenCodeGoExplicitNoAccessPayload('{}')).toBe(false)
|
||||
expect(isOpenCodeGoExplicitNoAccessPayload('<html></html>')).toBe(false)
|
||||
expect(isOpenCodeGoExplicitNoAccessPayload('{not json')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseOpenCodeGoUsageApiPayload', () => {
|
||||
it('clamps an out-of-range percent and tolerates a missing resetsAt', () => {
|
||||
const parsed = parseOpenCodeGoUsageApiPayload(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RateLimitWindow } from '../../shared/rate-limit-types'
|
||||
import type { ExtraUsageBalance, RateLimitWindow } from '../../shared/rate-limit-types'
|
||||
|
||||
const SESSION_WINDOW_MINUTES = 300
|
||||
const WEEKLY_WINDOW_MINUTES = 10_080
|
||||
@@ -100,6 +100,61 @@ export function parseOpenCodeGoUsageApiPayload(text: string): OpenCodeGoUsageWin
|
||||
}
|
||||
}
|
||||
|
||||
// Console BillingStatus uses integer-string LedgerMicroCents (100,000,000 per USD).
|
||||
export function parseOpenCodeGoBillingStatusPayload(text: string): number | null {
|
||||
if (!text || text.length > MAX_STATUS_PAYLOAD_CHARS) {
|
||||
return null
|
||||
}
|
||||
let payload: unknown
|
||||
try {
|
||||
payload = JSON.parse(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
!isRecord(payload) ||
|
||||
payload.billingMode !== 'prepaid' ||
|
||||
payload.mode !== 'pay-as-you-go' ||
|
||||
typeof payload.balanceMicroCents !== 'string' ||
|
||||
!/^-?\d+$/.test(payload.balanceMicroCents)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const microCents = Number(payload.balanceMicroCents)
|
||||
return Number.isSafeInteger(microCents) ? microCents / 100_000_000 : null
|
||||
}
|
||||
|
||||
export function makeOpenCodeGoZenBalance(
|
||||
balance: number | null,
|
||||
disabledReason: string | null = null
|
||||
): ExtraUsageBalance {
|
||||
return {
|
||||
balance,
|
||||
unit: 'currency',
|
||||
currencyCode: 'USD',
|
||||
enabled: balance !== null,
|
||||
disabledReason,
|
||||
spent: null,
|
||||
spendLimit: null,
|
||||
spentPercent: null,
|
||||
resetsAt: null
|
||||
}
|
||||
}
|
||||
|
||||
export function isOpenCodeGoExplicitNoAccessPayload(text: string): boolean {
|
||||
if (!text || text.length > MAX_STATUS_PAYLOAD_CHARS) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const payload: unknown = JSON.parse(text)
|
||||
return (
|
||||
payload === null || (isRecord(payload) && payload.access === null && !('error' in payload))
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function parseOpenCodeGoStatusPayload(text: string): OpenCodeGoUsageWindows | null {
|
||||
if (!text || text.length > MAX_STATUS_PAYLOAD_CHARS) {
|
||||
return null
|
||||
|
||||
@@ -15,6 +15,7 @@ import { fetchOpenCodeGoRateLimits, normalizeCookieInput } from './opencode-go-u
|
||||
|
||||
const WORKSPACES_SERVER_ID = 'def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f'
|
||||
const CONSOLE_STATUS_URL = 'https://opencode.ai/console/api/go/status'
|
||||
const CONSOLE_BILLING_STATUS_URL = 'https://opencode.ai/console/api/billing/status'
|
||||
const LEGACY_WORKSPACE_GO_URL = /https:\/\/opencode\.ai\/workspace\/[^/]+\/go/
|
||||
|
||||
function makeResponse(body: string, status = 200): Response {
|
||||
@@ -259,8 +260,10 @@ describe('fetchOpenCodeGoRateLimits', () => {
|
||||
netFetchMock
|
||||
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
||||
.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY))
|
||||
.mockResolvedValueOnce(makeResponse('unavailable', 503))
|
||||
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
||||
.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY))
|
||||
.mockResolvedValueOnce(makeResponse('unavailable', 503))
|
||||
|
||||
const proxySettings = {
|
||||
httpProxyUrl: 'http://proxy.example:8080',
|
||||
@@ -343,6 +346,254 @@ describe('fetchOpenCodeGoRateLimits', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['discovery', 'override'])('accepts an org_ workspace from %s', async (source) => {
|
||||
netFetchMock.mockImplementation(async (url) => {
|
||||
if (String(url).startsWith('https://opencode.ai/_server')) {
|
||||
return makeResponse('id: "org_CONSOLE123"')
|
||||
}
|
||||
return url === CONSOLE_STATUS_URL
|
||||
? makeJsonResponse(STATUS_WITH_MONTHLY)
|
||||
: makeJsonResponse({
|
||||
billingMode: 'prepaid',
|
||||
mode: 'pay-as-you-go',
|
||||
balanceMicroCents: '2786781005'
|
||||
})
|
||||
})
|
||||
|
||||
const result = await fetchOpenCodeGoRateLimits(
|
||||
'__Host-console_session=placeholder',
|
||||
source === 'override' ? 'org_CONSOLE123' : undefined
|
||||
)
|
||||
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.session?.usedPercent).toBe(30)
|
||||
expect(result.extraUsage?.balance).toBe(27.86781005)
|
||||
expect(netFetchMock).toHaveBeenCalledWith(
|
||||
CONSOLE_BILLING_STATUS_URL,
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'x-org-id': 'org_CONSOLE123' })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('enriches the selected cookie workspace with its verified PAYG Zen balance', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)).mockResolvedValueOnce(
|
||||
makeJsonResponse({
|
||||
billingMode: 'prepaid',
|
||||
mode: 'pay-as-you-go',
|
||||
balanceMicroCents: '2786781005'
|
||||
})
|
||||
)
|
||||
|
||||
const result = await fetchOpenCodeGoRateLimits(
|
||||
'__Host-console_session=consoleTok',
|
||||
'wrk_OVERRIDE123'
|
||||
)
|
||||
|
||||
expect(netFetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
CONSOLE_BILLING_STATUS_URL,
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: expect.objectContaining({
|
||||
'x-org-id': 'wrk_OVERRIDE123',
|
||||
Accept: 'application/json'
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(netFetchMock.mock.calls[1][1].headers).not.toHaveProperty('Cookie')
|
||||
expect(result.extraUsage).toEqual({
|
||||
balance: 27.86781005,
|
||||
unit: 'currency',
|
||||
currencyCode: 'USD',
|
||||
enabled: true,
|
||||
disabledReason: null,
|
||||
spent: null,
|
||||
spendLimit: null,
|
||||
spentPercent: null,
|
||||
resetsAt: null
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'unsupported mode',
|
||||
makeJsonResponse({ billingMode: 'seat', mode: 'pay-as-you-go' }),
|
||||
'billing-unavailable'
|
||||
],
|
||||
['billing failure', makeResponse('unavailable', 503), 'refresh-failed']
|
||||
])(
|
||||
'keeps valid quota when optional billing has an %s',
|
||||
async (_label, billingResponse, disabledReason) => {
|
||||
netFetchMock
|
||||
.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY))
|
||||
.mockResolvedValueOnce(billingResponse)
|
||||
|
||||
const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123')
|
||||
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.session?.usedPercent).toBe(30)
|
||||
expect(result.extraUsage).toEqual(
|
||||
expect.objectContaining({ balance: null, enabled: false, disabledReason })
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('returns a verified balance-only snapshot for explicit access:null', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeJsonResponse({ access: null })).mockResolvedValueOnce(
|
||||
makeJsonResponse({
|
||||
billingMode: 'prepaid',
|
||||
mode: 'pay-as-you-go',
|
||||
balanceMicroCents: '0'
|
||||
})
|
||||
)
|
||||
|
||||
const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123')
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
provider: 'opencode-go',
|
||||
session: null,
|
||||
weekly: null,
|
||||
monthly: null,
|
||||
error: null,
|
||||
status: 'ok',
|
||||
extraUsage: expect.objectContaining({ balance: 0, currencyCode: 'USD' })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('returns a verified balance-only snapshot for an explicit JSON null status', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeJsonResponse(null)).mockResolvedValueOnce(
|
||||
makeJsonResponse({
|
||||
billingMode: 'prepaid',
|
||||
mode: 'pay-as-you-go',
|
||||
balanceMicroCents: '-125000000'
|
||||
})
|
||||
)
|
||||
|
||||
const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123')
|
||||
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.session).toBeNull()
|
||||
expect(result.extraUsage).toEqual(expect.objectContaining({ balance: -1.25 }))
|
||||
})
|
||||
|
||||
it('aborts billing before returning a generic malformed-usage error', async () => {
|
||||
let billingSignal: AbortSignal | undefined
|
||||
netFetchMock
|
||||
.mockResolvedValueOnce(makeJsonResponse({ access: {} }))
|
||||
.mockImplementationOnce((_url, init: RequestInit) => {
|
||||
billingSignal = init.signal ?? undefined
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
billingSignal?.addEventListener('abort', () => reject(new Error('aborted')), {
|
||||
once: true
|
||||
})
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve(
|
||||
makeJsonResponse({
|
||||
billingMode: 'prepaid',
|
||||
mode: 'pay-as-you-go',
|
||||
balanceMicroCents: '100000000'
|
||||
})
|
||||
),
|
||||
100
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const resultPromise = fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
const abortedBeforeBillingResponse = billingSignal?.aborted
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
const result = await resultPromise
|
||||
|
||||
expect(abortedBeforeBillingResponse).toBe(true)
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.error).toBe('Could not parse usage data')
|
||||
expect(clearStorageDataMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('prefers a later valid Go workspace over an earlier balance-only candidate', async () => {
|
||||
netFetchMock
|
||||
.mockResolvedValueOnce(makeResponse('id: "wrk_FIRST" id: "wrk_SECOND"'))
|
||||
.mockResolvedValueOnce(makeJsonResponse({ access: null }))
|
||||
.mockResolvedValueOnce(
|
||||
makeJsonResponse({
|
||||
billingMode: 'prepaid',
|
||||
mode: 'pay-as-you-go',
|
||||
balanceMicroCents: '900000000'
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(makeJsonResponse(STATUS_NO_MONTHLY))
|
||||
.mockResolvedValueOnce(makeResponse('unavailable', 503))
|
||||
|
||||
const result = await fetchOpenCodeGoRateLimits('auth=mytoken')
|
||||
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.session?.usedPercent).toBe(10)
|
||||
expect(result.extraUsage).toEqual(
|
||||
expect.objectContaining({ balance: null, enabled: false, disabledReason: 'refresh-failed' })
|
||||
)
|
||||
expect(netFetchMock).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
CONSOLE_STATUS_URL,
|
||||
expect.objectContaining({ headers: expect.objectContaining({ 'x-org-id': 'wrk_SECOND' }) })
|
||||
)
|
||||
})
|
||||
|
||||
it('aborts and settles an optional billing timeout before clearing the cookie jar', async () => {
|
||||
let billingSignal: AbortSignal | undefined
|
||||
netFetchMock
|
||||
.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY))
|
||||
.mockImplementationOnce((_url, init: RequestInit) => {
|
||||
billingSignal = init.signal ?? undefined
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
billingSignal?.addEventListener('abort', () => reject(new Error('aborted')), {
|
||||
once: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const resultPromise = fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123')
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
const result = await resultPromise
|
||||
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.session?.usedPercent).toBe(30)
|
||||
expect(billingSignal?.aborted).toBe(true)
|
||||
expect(clearStorageDataMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('aborts and settles billing before continuing after a non-ok usage response', async () => {
|
||||
let billingSignal: AbortSignal | undefined
|
||||
let billingSettled = false
|
||||
netFetchMock
|
||||
.mockResolvedValueOnce(makeResponse('Unauthorized', 401))
|
||||
.mockImplementationOnce((_url, init: RequestInit) => {
|
||||
billingSignal = init.signal ?? undefined
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
billingSignal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
billingSettled = true
|
||||
reject(new Error('aborted'))
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123')
|
||||
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.error).toContain('Usage fetch failed (401)')
|
||||
expect(billingSignal?.aborted).toBe(true)
|
||||
expect(billingSettled).toBe(true)
|
||||
expect(clearStorageDataMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('returns ok with null monthly when the month meter is absent', async () => {
|
||||
netFetchMock
|
||||
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
||||
@@ -400,7 +651,7 @@ describe('fetchOpenCodeGoRateLimits', () => {
|
||||
|
||||
const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123')
|
||||
|
||||
expect(netFetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(netFetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(requestedUrls().some((url) => LEGACY_WORKSPACE_GO_URL.test(url))).toBe(false)
|
||||
expect(netFetchMock).toHaveBeenCalledWith(
|
||||
CONSOLE_STATUS_URL,
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import type { Session } from 'electron'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { NetworkProxySettings } from '../../shared/network-proxy'
|
||||
import type { ProviderRateLimits } from '../../shared/rate-limit-types'
|
||||
import type { ExtraUsageBalance, ProviderRateLimits } from '../../shared/rate-limit-types'
|
||||
import {
|
||||
clearOpenCodeSessionCookies,
|
||||
createOpenCodeRequestSession,
|
||||
OPENCODE_BASE_URL
|
||||
} from './opencode-go-request-session'
|
||||
import { parseOpenCodeGoStatusPayload } from './opencode-go-status-parsing'
|
||||
import {
|
||||
isOpenCodeGoExplicitNoAccessPayload,
|
||||
makeOpenCodeGoZenBalance,
|
||||
parseOpenCodeGoBillingStatusPayload,
|
||||
parseOpenCodeGoStatusPayload
|
||||
} from './opencode-go-status-parsing'
|
||||
|
||||
const OPENCODE_SERVER_URL = 'https://opencode.ai/_server'
|
||||
const OPENCODE_GO_STATUS_URL = `${OPENCODE_BASE_URL}/console/api/go/status`
|
||||
const OPENCODE_BILLING_STATUS_URL = `${OPENCODE_BASE_URL}/console/api/billing/status`
|
||||
const API_TIMEOUT_MS = 15_000
|
||||
const OPTIONAL_BILLING_TIMEOUT_MS = 5_000
|
||||
|
||||
// Server-function hash for the workspaces endpoint — stable identifier used by
|
||||
// the opencode.ai SST/TanStack router server-fn protocol.
|
||||
@@ -59,12 +66,9 @@ function parseAuthCookies(raw: string): { name: string; value: string }[] {
|
||||
}
|
||||
|
||||
function parseWorkspaceIds(text: string): string[] {
|
||||
// Match id:"wrk_..." or id: "wrk_..." patterns in JS-serialized output.
|
||||
// Why: Workspace IDs follow a 'wrk_xxx' or 'wk_xxx' pattern. Using a
|
||||
// more specific regex with word boundaries avoids picking up unrelated
|
||||
// object properties that might match a generic ID pattern.
|
||||
// Console uses org_/wrk_; wk_ remains accepted for older workspace responses.
|
||||
const ids: string[] = []
|
||||
const workspaceIdRegex = /\bid\s*:\s*["']((?:wrk|wk)_[a-zA-Z0-9]+)["']/g
|
||||
const workspaceIdRegex = /\bid\s*:\s*["']((?:org|wrk|wk)_[a-zA-Z0-9]+)["']/g
|
||||
for (const match of text.matchAll(workspaceIdRegex)) {
|
||||
const id = match[1]
|
||||
if (id && !ids.includes(id)) {
|
||||
@@ -126,6 +130,35 @@ export async function fetchOpenCodeGoRateLimits(
|
||||
}
|
||||
}
|
||||
|
||||
function startOptionalBillingFetch(openCodeSession: Session, workspaceId: string) {
|
||||
const abortController = new AbortController()
|
||||
const promise = (async (): Promise<ExtraUsageBalance> => {
|
||||
const timeout = setTimeout(() => abortController.abort(), OPTIONAL_BILLING_TIMEOUT_MS)
|
||||
try {
|
||||
const response = await openCodeSession.fetch(OPENCODE_BILLING_STATUS_URL, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Origin: OPENCODE_BASE_URL,
|
||||
Referer: `${OPENCODE_BASE_URL}/console/${workspaceId}/billing`,
|
||||
'x-org-id': workspaceId
|
||||
},
|
||||
signal: abortController.signal
|
||||
})
|
||||
if (!response.ok) {
|
||||
return makeOpenCodeGoZenBalance(null, 'refresh-failed')
|
||||
}
|
||||
const balance = parseOpenCodeGoBillingStatusPayload(await response.text())
|
||||
return makeOpenCodeGoZenBalance(balance, balance === null ? 'billing-unavailable' : null)
|
||||
} catch {
|
||||
return makeOpenCodeGoZenBalance(null, 'refresh-failed')
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
})()
|
||||
return { promise, abort: () => abortController.abort() }
|
||||
}
|
||||
|
||||
function makeOpenCodeError(error: unknown): ProviderRateLimits {
|
||||
return {
|
||||
provider: 'opencode-go',
|
||||
@@ -147,14 +180,14 @@ async function fetchOpenCodeGoRateLimitsWithSession(
|
||||
const override = workspaceIdOverride?.trim()
|
||||
|
||||
if (override) {
|
||||
if (!/^(wrk|wk)_[A-Za-z0-9]+$/.test(override)) {
|
||||
if (!/^(org|wrk|wk)_[A-Za-z0-9]+$/.test(override)) {
|
||||
return {
|
||||
provider: 'opencode-go',
|
||||
session: null,
|
||||
weekly: null,
|
||||
monthly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: 'Invalid workspace ID format: must match ^(wrk|wk)_[A-Za-z0-9]+$',
|
||||
error: 'Invalid workspace ID format: must match ^(org|wrk|wk)_[A-Za-z0-9]+$',
|
||||
status: 'error'
|
||||
}
|
||||
}
|
||||
@@ -221,9 +254,11 @@ async function fetchOpenCodeGoRateLimitsWithSession(
|
||||
// Why: /workspace/<id>/go now 302s to console login. Usage is JSON at
|
||||
// /console/api/go/status, scoped by x-org-id and authed by the console session.
|
||||
let lastError = ''
|
||||
let balanceOnlyResult: ProviderRateLimits | null = null
|
||||
for (const candidateId of ids) {
|
||||
let billingTask: ReturnType<typeof startOptionalBillingFetch> | null = null
|
||||
try {
|
||||
const statusRes = await openCodeSession.fetch(OPENCODE_GO_STATUS_URL, {
|
||||
const statusPromise = openCodeSession.fetch(OPENCODE_GO_STATUS_URL, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
@@ -233,8 +268,11 @@ async function fetchOpenCodeGoRateLimitsWithSession(
|
||||
},
|
||||
signal: AbortSignal.timeout(API_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
billingTask = startOptionalBillingFetch(openCodeSession, candidateId)
|
||||
const statusRes = await statusPromise
|
||||
if (!statusRes.ok) {
|
||||
billingTask.abort()
|
||||
await billingTask.promise
|
||||
lastError =
|
||||
statusRes.status === 401
|
||||
? 'Usage fetch failed (401) — paste the full Cookie header including __Host-console_session (auth alone is not enough)'
|
||||
@@ -242,25 +280,56 @@ async function fetchOpenCodeGoRateLimitsWithSession(
|
||||
continue
|
||||
}
|
||||
|
||||
const parsed = parseOpenCodeGoStatusPayload(await statusRes.text())
|
||||
const statusText = await statusRes.text()
|
||||
const parsed = parseOpenCodeGoStatusPayload(statusText)
|
||||
const explicitNoAccess = isOpenCodeGoExplicitNoAccessPayload(statusText)
|
||||
if (!parsed && !explicitNoAccess) {
|
||||
billingTask.abort()
|
||||
await billingTask.promise
|
||||
lastError = 'Could not parse usage data'
|
||||
continue
|
||||
}
|
||||
|
||||
const balance = await billingTask.promise
|
||||
if (parsed) {
|
||||
return {
|
||||
provider: 'opencode-go',
|
||||
session: parsed.session,
|
||||
weekly: parsed.weekly,
|
||||
monthly: parsed.monthly,
|
||||
extraUsage: balance,
|
||||
updatedAt: Date.now(),
|
||||
error: null,
|
||||
status: 'ok'
|
||||
}
|
||||
}
|
||||
lastError = 'Could not parse usage data'
|
||||
if (balance.balance !== null) {
|
||||
balanceOnlyResult ??= {
|
||||
provider: 'opencode-go',
|
||||
session: null,
|
||||
weekly: null,
|
||||
monthly: null,
|
||||
extraUsage: balance,
|
||||
updatedAt: Date.now(),
|
||||
error: null,
|
||||
status: 'ok'
|
||||
}
|
||||
} else {
|
||||
lastError = 'Could not parse usage data'
|
||||
}
|
||||
} catch (err) {
|
||||
if (billingTask) {
|
||||
billingTask.abort()
|
||||
await billingTask.promise
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
lastError = message
|
||||
}
|
||||
}
|
||||
|
||||
if (balanceOnlyResult) {
|
||||
return balanceOnlyResult
|
||||
}
|
||||
return {
|
||||
provider: 'opencode-go',
|
||||
session: null,
|
||||
|
||||
@@ -66,6 +66,9 @@ describe('fetchOpenCodeGoUsage', () => {
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.session).toEqual(WINDOWS.session)
|
||||
expect(result.usageMetadata?.credentialSource).toBe('environment')
|
||||
expect(result.extraUsage).toEqual(
|
||||
expect.objectContaining({ balance: null, enabled: false, disabledReason: 'api-key-source' })
|
||||
)
|
||||
})
|
||||
|
||||
it('passes the settings override down as the highest-precedence tier', async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
resolveOpenCodeGoApiKey,
|
||||
type OpenCodeGoApiKeyResolution
|
||||
} from './opencode-go-api-key-source'
|
||||
import type { OpenCodeGoUsageWindows } from './opencode-go-status-parsing'
|
||||
import { makeOpenCodeGoZenBalance, type OpenCodeGoUsageWindows } from './opencode-go-status-parsing'
|
||||
import type { OpenCodeCredentialBackend } from '../opencode/opencode-credential-backend'
|
||||
import {
|
||||
fetchOpenCodeGoUsageWithApiKey,
|
||||
@@ -52,6 +52,7 @@ function usageResult(
|
||||
session: windows.session,
|
||||
weekly: windows.weekly,
|
||||
monthly: windows.monthly,
|
||||
extraUsage: makeOpenCodeGoZenBalance(null, 'api-key-source'),
|
||||
updatedAt: Date.now(),
|
||||
error: null,
|
||||
status: 'ok',
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { dirname, join } from 'node:path'
|
||||
import { linkSync, readFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
const homes = vi.hoisted(() => ({ original: '', selected: '', system: '' }))
|
||||
vi.mock('electron', () => ({ app: { getPath: () => homes.system } }))
|
||||
vi.mock('./main-process-state', () => ({
|
||||
mainProcessState: {
|
||||
codexRuntimeHome: {
|
||||
isHostSystemDefaultRealHome: () => false,
|
||||
getHostCodexHomePathsForSessionDiscovery: () => [homes.original, homes.selected],
|
||||
resolveSelectedHostAccountCodexHomePathForResume: () => homes.selected || null
|
||||
},
|
||||
store: { getSettings: () => ({}) }
|
||||
}
|
||||
}))
|
||||
vi.mock('../codex/hook-service', () => ({
|
||||
codexHookService: {
|
||||
installForLaunchPrep: vi.fn(),
|
||||
refreshRuntimeUserHooksForLaunchPrep: vi.fn()
|
||||
}
|
||||
}))
|
||||
vi.mock('../codex/codex-real-home-hook-install', () => ({
|
||||
ensureRealHomeCodexHookState: vi.fn(),
|
||||
awaitRealHomeCodexHookTrust: vi.fn()
|
||||
}))
|
||||
vi.mock('../codex/codex-home-paths', () => ({
|
||||
getCodexSessionBackfillStateDirPath: () => join(homes.system, 'backfill'),
|
||||
getSystemCodexHomePath: () => homes.system,
|
||||
getOrcaManagedCodexHomePath: () => join(homes.system, 'legacy')
|
||||
}))
|
||||
vi.mock('../codex/codex-config-mirror', () => ({ ensureCodexDaemonSocketGuard: vi.fn() }))
|
||||
|
||||
import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch'
|
||||
|
||||
let root: string
|
||||
let transcriptPath: string
|
||||
const sessionId = 'abcdef00-1234-4321-9999-cafecafecafe'
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'orca-account-restart-'))
|
||||
homes.original = join(root, 'codex-accounts', 'old', 'home')
|
||||
homes.selected = join(root, 'codex-accounts', 'new', 'home')
|
||||
homes.system = join(root, 'system')
|
||||
const relativePath = join('sessions', '2026', '10', '05', `rollout-${sessionId}.jsonl`)
|
||||
transcriptPath = join(homes.original, relativePath)
|
||||
const bridged = join(homes.selected, relativePath)
|
||||
mkdirSync(dirname(transcriptPath), { recursive: true })
|
||||
mkdirSync(dirname(bridged), { recursive: true })
|
||||
writeFileSync(transcriptPath, '{"type":"session_meta"}\n')
|
||||
linkSync(transcriptPath, bridged)
|
||||
})
|
||||
afterEach(() => rmSync(root, { recursive: true, force: true }))
|
||||
|
||||
it('resumes an explicit account restart in the selected home using the bridged transcript', async () => {
|
||||
const result = await prepareCodexSessionResumeForLaunch({
|
||||
providerSession: { key: 'session_id', id: sessionId, transcriptPath },
|
||||
target: { runtime: 'host' },
|
||||
useSelectedAccount: true
|
||||
})
|
||||
expect(result).toMatchObject({ outcome: 'resume', codexHomePath: homes.selected })
|
||||
})
|
||||
|
||||
it('keeps ordinary automatic restores pinned to their original account', async () => {
|
||||
const result = await prepareCodexSessionResumeForLaunch({
|
||||
providerSession: { key: 'session_id', id: sessionId, transcriptPath },
|
||||
target: { runtime: 'host' }
|
||||
})
|
||||
expect(result).toMatchObject({ outcome: 'resume', codexHomePath: homes.original })
|
||||
})
|
||||
|
||||
it('materializes a rollout when the account bridge has not caught up', async () => {
|
||||
rmSync(join(homes.selected, 'sessions'), { recursive: true })
|
||||
const result = await prepareCodexSessionResumeForLaunch({
|
||||
providerSession: { key: 'session_id', id: sessionId, transcriptPath },
|
||||
target: { runtime: 'host' },
|
||||
useSelectedAccount: true
|
||||
})
|
||||
expect(result).toMatchObject({ codexHomePath: homes.selected })
|
||||
expect(readFileSync(transcriptPath.replace(homes.original, homes.selected), 'utf8')).toBe(
|
||||
readFileSync(transcriptPath, 'utf8')
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a conflicting target rollout instead of restarting the old account', async () => {
|
||||
const target = transcriptPath.replace(homes.original, homes.selected)
|
||||
rmSync(target)
|
||||
writeFileSync(target, 'another session')
|
||||
await expect(
|
||||
prepareCodexSessionResumeForLaunch({
|
||||
providerSession: { key: 'session_id', id: sessionId, transcriptPath },
|
||||
target: { runtime: 'host' },
|
||||
useSelectedAccount: true
|
||||
})
|
||||
).rejects.toThrow('different rollout')
|
||||
expect(readFileSync(target, 'utf8')).toBe('another session')
|
||||
})
|
||||
|
||||
it('uses the system home when an explicit restart follows deselection', async () => {
|
||||
homes.selected = ''
|
||||
const result = await prepareCodexSessionResumeForLaunch({
|
||||
providerSession: { key: 'session_id', id: sessionId, transcriptPath },
|
||||
target: { runtime: 'host' },
|
||||
useSelectedAccount: true
|
||||
})
|
||||
expect(result).toMatchObject({ codexHomePath: homes.system })
|
||||
})
|
||||
|
||||
it('leaves WSL preparation on the execution host', async () => {
|
||||
expect(
|
||||
await prepareCodexSessionResumeForLaunch({
|
||||
providerSession: { key: 'session_id', id: sessionId, transcriptPath },
|
||||
target: { runtime: 'wsl', wslDistro: 'Ubuntu' },
|
||||
useSelectedAccount: true
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
@@ -3,7 +3,10 @@ import type { AgentProviderSessionMetadata } from '../../shared/agent-session-re
|
||||
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
|
||||
import type { CodexSessionResumePreparation } from '../codex/codex-session-resume-home'
|
||||
import { prepareCodexSessionResume } from '../codex/codex-session-resume-preparation'
|
||||
import { prepareLegacySharedCodexSessionResume } from '../codex/codex-legacy-session-resume'
|
||||
import {
|
||||
prepareCodexAccountRestartResume,
|
||||
prepareLegacySharedCodexSessionResume
|
||||
} from '../codex/codex-legacy-session-resume'
|
||||
import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership'
|
||||
import { codexHookService } from '../codex/hook-service'
|
||||
import {
|
||||
@@ -20,6 +23,7 @@ export async function prepareCodexSessionResumeForLaunch(args: {
|
||||
providerSession: AgentProviderSessionMetadata
|
||||
target: CodexAccountSelectionTarget
|
||||
launchEnv?: NodeJS.ProcessEnv
|
||||
useSelectedAccount?: boolean
|
||||
}): Promise<CodexSessionResumePreparation | null> {
|
||||
const runtimeHome = state.codexRuntimeHome
|
||||
const store = state.store
|
||||
@@ -66,12 +70,7 @@ export async function prepareCodexSessionResumeForLaunch(args: {
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
// Why: this launch path pins CODEX_HOME to the account that OWNS the
|
||||
// rollout and deliberately refuses to repin onto whichever account is
|
||||
// selected now (#10793), so it does not wire
|
||||
// getSelectedHostAccountCodexHomePath and this branch cannot fire today.
|
||||
// It stays as a contract guard: the blanket catch below must never
|
||||
// silently swallow a typed refusal if that ever changes.
|
||||
// A credential-read refusal must never fall back to the old account.
|
||||
if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) {
|
||||
throw error
|
||||
}
|
||||
@@ -81,7 +80,16 @@ export async function prepareCodexSessionResumeForLaunch(args: {
|
||||
error
|
||||
)
|
||||
}
|
||||
const resumeHome = migrated.useRealCodexHome ? systemHomePath : sessionSource.homePath
|
||||
const resumeHome = args.useSelectedAccount
|
||||
? await prepareCodexAccountRestartResume({
|
||||
sourceHome: sessionSource.homePath,
|
||||
transcriptPath: sessionSource.transcriptPath,
|
||||
targetHome: selectedAccountCodexHome ?? systemHomePath,
|
||||
systemCodexHomePath: systemHomePath
|
||||
})
|
||||
: migrated.useRealCodexHome
|
||||
? systemHomePath
|
||||
: sessionSource.homePath
|
||||
const isSystemHome =
|
||||
normalizeRuntimePathForComparison(resumeHome) ===
|
||||
normalizeRuntimePathForComparison(systemHomePath)
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { detectCsvDelimiter, parseCsv } from './csv-parse'
|
||||
import { CsvDelimiterPicker, type CsvDelimiterChoice } from './csv-delimiter-picker'
|
||||
import { CsvGrid } from './CsvGrid'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { CSV_MAX_COLUMNS, CSV_RECORD_BYTES } from './csv-byte-index'
|
||||
import { openCsvHttpLink } from './csv-link-routing'
|
||||
import { CSV_PAGED_PREVIEW_BYTES } from './editor-csv-file-content'
|
||||
|
||||
export default function CsvViewer({
|
||||
content,
|
||||
filePath,
|
||||
worktreeId,
|
||||
runtimeEnvironmentId
|
||||
}: {
|
||||
content: string
|
||||
filePath: string
|
||||
worktreeId?: string
|
||||
runtimeEnvironmentId?: string | null
|
||||
}): React.JSX.Element {
|
||||
const [delimiterChoice, setDelimiterChoice] = useState<CsvDelimiterChoice>('auto')
|
||||
const detectedDelimiter = useMemo(
|
||||
() => detectCsvDelimiter(filePath, content),
|
||||
[filePath, content]
|
||||
)
|
||||
const delimiter = csvChosenDelimiter(delimiterChoice, detectedDelimiter)
|
||||
const result = useMemo(() => {
|
||||
try {
|
||||
return {
|
||||
parsed: parseCsv(content, delimiter, {
|
||||
maxCells: CSV_PAGED_PREVIEW_BYTES + 1,
|
||||
maxColumns: CSV_MAX_COLUMNS,
|
||||
maxRecordLength: CSV_RECORD_BYTES
|
||||
}),
|
||||
error: null
|
||||
}
|
||||
} catch (error) {
|
||||
return { parsed: null, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}, [content, delimiter])
|
||||
const header = result.parsed?.rows[0] ?? []
|
||||
const rowCount = Math.max(0, (result.parsed?.rows.length ?? 0) - 1)
|
||||
const sample = useMemo(() => result.parsed?.rows.slice(1, 201) ?? [], [result.parsed])
|
||||
const getRow = useCallback((index: number) => result.parsed?.rows[index + 1], [result.parsed])
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{result.error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-1 items-center justify-center px-4 text-sm text-muted-foreground"
|
||||
>
|
||||
{result.error}
|
||||
</div>
|
||||
) : result.parsed?.rows.length ? (
|
||||
<CsvGrid
|
||||
key={delimiter}
|
||||
header={header}
|
||||
rowCount={rowCount}
|
||||
columnCount={result.parsed.maxColumns}
|
||||
sampleRows={sample}
|
||||
getRow={getRow}
|
||||
onOpenUrl={(url, event) =>
|
||||
openCsvHttpLink(url, event, { filePath, worktreeId, runtimeEnvironmentId })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
{translate('auto.components.editor.CsvViewer.a233d55b77', 'Empty file')}
|
||||
</div>
|
||||
)}
|
||||
<CsvFooter
|
||||
rowCount={rowCount}
|
||||
columnCount={result.parsed?.maxColumns ?? 0}
|
||||
delimiterChoice={delimiterChoice}
|
||||
detectedDelimiter={detectedDelimiter}
|
||||
onDelimiterChange={setDelimiterChoice}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function csvChosenDelimiter(choice: CsvDelimiterChoice, detected: string): string {
|
||||
return choice === 'auto'
|
||||
? detected
|
||||
: choice === 'comma'
|
||||
? ','
|
||||
: choice === 'semicolon'
|
||||
? ';'
|
||||
: '\t'
|
||||
}
|
||||
|
||||
export function CsvFooter({
|
||||
rowCount,
|
||||
columnCount,
|
||||
delimiterChoice,
|
||||
detectedDelimiter,
|
||||
onDelimiterChange
|
||||
}: {
|
||||
rowCount: number
|
||||
columnCount: number
|
||||
delimiterChoice: CsvDelimiterChoice
|
||||
detectedDelimiter: string
|
||||
onDelimiterChange: (choice: CsvDelimiterChoice) => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-4 border-t border-border/60 px-3 py-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{rowCount.toLocaleString()}{' '}
|
||||
{translate('auto.components.editor.CsvViewer.ac31d2cd60', 'rows')}
|
||||
</span>
|
||||
<span>
|
||||
{columnCount.toLocaleString()}{' '}
|
||||
{translate('auto.components.editor.CsvViewer.eedd0d37a7', 'columns')}
|
||||
</span>
|
||||
<CsvDelimiterPicker
|
||||
value={delimiterChoice}
|
||||
detectedDelimiter={detectedDelimiter}
|
||||
onChange={onDelimiterChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -106,6 +106,7 @@ export function EditorEditFileSurface({
|
||||
<CsvPagedViewer
|
||||
key={activeFile.id}
|
||||
file={fileContent.csvPreview}
|
||||
preferenceKey={pdfPreferenceKey}
|
||||
filePath={activeFile.filePath}
|
||||
onReload={() => reloadContent(activeFile)}
|
||||
/>
|
||||
@@ -260,6 +261,12 @@ export function EditorEditFileSurface({
|
||||
filePath={activeFile.filePath}
|
||||
worktreeId={activeFile.worktreeId}
|
||||
runtimeEnvironmentId={activeFile.runtimeEnvironmentId}
|
||||
preferenceKey={pdfPreferenceKey}
|
||||
fileId={activeFile.id}
|
||||
isDirty={activeFile.isDirty}
|
||||
onDirtyStateHint={activeFile.readOnly ? undefined : handleDirtyStateHint}
|
||||
onContentChange={activeFile.readOnly ? undefined : handleContentChange}
|
||||
onSave={activeFile.readOnly ? undefined : handleSave}
|
||||
/>
|
||||
) : isNotebook && mdViewMode === 'rich' ? (
|
||||
<IpynbViewer
|
||||
|
||||
@@ -66,9 +66,7 @@ const DEFAULT_VIEW_MODE_METADATA: Record<EditorToggleValue, ViewModeMetadata> =
|
||||
}
|
||||
}
|
||||
|
||||
// Why: CSV/TSV files reuse the 'rich' view mode slot but the rendered surface
|
||||
// is a read-only table, not an editor. The Pencil icon implies editability,
|
||||
// which we don't offer, so callers can override the per-mode presentation.
|
||||
// CSV/TSV uses a table icon for both editable tables and read-only previews.
|
||||
export const CSV_VIEW_MODE_METADATA: Partial<Record<MarkdownViewMode, ViewModeMetadata>> = {
|
||||
rich: {
|
||||
get label() {
|
||||
|
||||
+2
-3
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export const CSV_MIN_COLUMN_WIDTH = 48
|
||||
export const CSV_MAX_COLUMN_WIDTH = 1200
|
||||
import { CSV_MIN_COLUMN_WIDTH, CSV_MAX_COLUMN_WIDTH } from './csv-column-width-limits'
|
||||
export { CSV_MIN_COLUMN_WIDTH, CSV_MAX_COLUMN_WIDTH } from './csv-column-width-limits'
|
||||
|
||||
export function CsvColumnResizeHandle({
|
||||
index,
|
||||
+169
-31
@@ -1,9 +1,11 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { CsvColumnResizeHandle } from './CsvColumnResizeHandle'
|
||||
import { CsvCellValue } from './CsvCellValue'
|
||||
import { CsvGridCell } from './CsvGridCell'
|
||||
import type { CsvGridInteraction } from './csv-grid-interaction'
|
||||
import type { CsvColumnWidths } from './csv-column-width-preferences'
|
||||
|
||||
const ROW_HEIGHT = 28
|
||||
// Keep the scroll surface below Chromium's layout extent limit.
|
||||
@@ -16,7 +18,10 @@ export function CsvGrid({
|
||||
sampleRows,
|
||||
getRow,
|
||||
onVisibleRows,
|
||||
onOpenUrl
|
||||
onOpenUrl,
|
||||
interaction,
|
||||
columnWidths,
|
||||
onColumnWidthsChange
|
||||
}: {
|
||||
header: string[]
|
||||
rowCount: number
|
||||
@@ -25,14 +30,23 @@ export function CsvGrid({
|
||||
getRow: (index: number) => string[] | undefined
|
||||
onVisibleRows?: (first: number, last: number) => void
|
||||
onOpenUrl?: (url: string, event: React.MouseEvent<HTMLAnchorElement>) => void
|
||||
interaction?: CsvGridInteraction
|
||||
columnWidths?: CsvColumnWidths
|
||||
onColumnWidthsChange?: (widths: CsvColumnWidths) => void
|
||||
}): React.JSX.Element {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
const gridId = useId()
|
||||
const dragging = useRef(false)
|
||||
const scrolledFocusVersion = useRef(-1)
|
||||
const focusGrid = (): void => gridRef.current?.focus({ preventScroll: true })
|
||||
const [requestedWindowStart, setWindowStart] = useState(0)
|
||||
const windowStart = Math.min(
|
||||
requestedWindowStart,
|
||||
Math.floor(Math.max(0, rowCount - 1) / SCROLL_WINDOW_ROWS) * SCROLL_WINDOW_ROWS
|
||||
)
|
||||
const [widthOverrides, setWidthOverrides] = useState<Record<number, number>>({})
|
||||
const [localWidths, setLocalWidths] = useState<CsvColumnWidths>({})
|
||||
const widthOverrides = columnWidths ?? localWidths
|
||||
const rowNumberWidth = Math.max(48, String(rowCount).length * 8 + 16)
|
||||
const widths = useMemo(() => {
|
||||
const result = Array.from({ length: columnCount }, () => 80)
|
||||
@@ -60,7 +74,7 @@ export function CsvGrid({
|
||||
paddingStart: rowNumberWidth,
|
||||
overscan: 2
|
||||
})
|
||||
useLayoutEffect(() => columns.measure(), [columns, widths])
|
||||
useLayoutEffect(() => columns.measure(), [columns, widths, columnWidths])
|
||||
const virtualRows = rows.getVirtualItems()
|
||||
const virtualColumns = columns.getVirtualItems()
|
||||
const first = virtualRows[0]?.index
|
||||
@@ -72,12 +86,82 @@ export function CsvGrid({
|
||||
}, [first, last, windowStart, onVisibleRows])
|
||||
const resize = useCallback(
|
||||
(index: number, width: number) => {
|
||||
setWidthOverrides((previous) => ({ ...previous, [index]: width }))
|
||||
const next = { ...widthOverrides, [index]: width }
|
||||
if (onColumnWidthsChange) {
|
||||
onColumnWidthsChange(next)
|
||||
} else {
|
||||
setLocalWidths(next)
|
||||
}
|
||||
columns.resizeItem(index, width)
|
||||
},
|
||||
[columns]
|
||||
[columns, onColumnWidthsChange, widthOverrides]
|
||||
)
|
||||
const reset = useCallback((index: number) => resize(index, widths[index] ?? 80), [resize, widths])
|
||||
const reset = (index: number): void => {
|
||||
const next = { ...widthOverrides }
|
||||
delete next[index]
|
||||
if (onColumnWidthsChange) {
|
||||
onColumnWidthsChange(next)
|
||||
} else {
|
||||
setLocalWidths(next)
|
||||
}
|
||||
columns.resizeItem(index, widths[index] ?? 80)
|
||||
}
|
||||
const active = interaction?.selection?.focus
|
||||
useLayoutEffect(() => {
|
||||
if (!active || scrolledFocusVersion.current === interaction?.focusVersion) {
|
||||
return
|
||||
}
|
||||
if (interaction?.editing) {
|
||||
scrolledFocusVersion.current = interaction.focusVersion
|
||||
return
|
||||
}
|
||||
const targetWindow =
|
||||
Math.floor(Math.max(0, active.row - 1) / SCROLL_WINDOW_ROWS) * SCROLL_WINDOW_ROWS
|
||||
if (targetWindow !== windowStart) {
|
||||
setWindowStart(targetWindow)
|
||||
return
|
||||
}
|
||||
if (active.row > 0) {
|
||||
rows.scrollToIndex(active.row - 1 - windowStart, { align: 'auto' })
|
||||
} else {
|
||||
scrollRef.current?.scrollTo({ top: 0 })
|
||||
}
|
||||
columns.scrollToIndex(active.column, { align: 'auto' })
|
||||
scrolledFocusVersion.current = interaction?.focusVersion ?? -1
|
||||
}, [active, interaction?.editing, interaction?.focusVersion, rows, columns, windowStart])
|
||||
useEffect(() => {
|
||||
const finish = (): void => {
|
||||
dragging.current = false
|
||||
}
|
||||
window.addEventListener('pointerup', finish)
|
||||
window.addEventListener('pointercancel', finish)
|
||||
window.addEventListener('blur', finish)
|
||||
return () => {
|
||||
window.removeEventListener('pointerup', finish)
|
||||
window.removeEventListener('pointercancel', finish)
|
||||
window.removeEventListener('blur', finish)
|
||||
}
|
||||
}, [])
|
||||
const pointerCell = (event: React.PointerEvent): { row: number; column: number } | null => {
|
||||
if (
|
||||
!(event.target instanceof Element) ||
|
||||
event.target.closest('a,textarea,[role="separator"]')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const cell = event.target.closest('[data-csv-row]')
|
||||
if (!cell) {
|
||||
return null
|
||||
}
|
||||
const row = Number(cell.getAttribute('data-csv-row'))
|
||||
const column = Number(cell.getAttribute('data-csv-column'))
|
||||
return Number.isInteger(row) && Number.isInteger(column) ? { row, column } : null
|
||||
}
|
||||
const activeMounted =
|
||||
active &&
|
||||
virtualColumns.some((column) => column.index === active.column) &&
|
||||
(active.row === 0 || virtualRows.some((row) => row.index + windowStart + 1 === active.row))
|
||||
|
||||
const totalWidth = columns.getTotalSize()
|
||||
const gridTemplate = `${rowNumberWidth}px ${Math.max(0, (virtualColumns[0]?.start ?? rowNumberWidth) - rowNumberWidth)}px ${virtualColumns.map((column) => `${column.size}px`).join(' ')} ${Math.max(0, totalWidth - (virtualColumns.at(-1)?.end ?? rowNumberWidth))}px`
|
||||
const moveWindow = (start: number): void => {
|
||||
@@ -90,9 +174,65 @@ export function CsvGrid({
|
||||
ref={scrollRef}
|
||||
data-testid="csv-scroll"
|
||||
className="relative min-h-0 flex-1 overflow-auto scrollbar-editor font-mono text-xs"
|
||||
onScroll={(event) => {
|
||||
if (!interaction?.editing) {
|
||||
return
|
||||
}
|
||||
const position = interaction.editing.position
|
||||
const cell = gridRef.current
|
||||
?.querySelector(
|
||||
`[data-csv-row="${position.row}"][data-csv-column="${position.column}"]`
|
||||
)
|
||||
?.getBoundingClientRect()
|
||||
const viewport = event.currentTarget.getBoundingClientRect()
|
||||
// Caret visibility can scroll a clipped input while the user is still typing.
|
||||
if (
|
||||
cell &&
|
||||
cell.bottom > viewport.top + (position.row ? ROW_HEIGHT : 0) &&
|
||||
cell.top < viewport.bottom &&
|
||||
cell.right > viewport.left &&
|
||||
cell.left < viewport.right
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!interaction.commit()) {
|
||||
if (position.row > 0) {
|
||||
rows.scrollToIndex(position.row - 1 - windowStart, { align: 'auto' })
|
||||
}
|
||||
columns.scrollToIndex(position.column, { align: 'auto' })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="table"
|
||||
ref={gridRef}
|
||||
role={interaction ? 'grid' : 'table'}
|
||||
data-testid="csv-grid"
|
||||
data-csv-owner={interaction?.ownerId}
|
||||
tabIndex={interaction ? 0 : undefined}
|
||||
aria-label={interaction ? translate('csv.editor', 'CSV editor') : undefined}
|
||||
aria-activedescendant={
|
||||
activeMounted ? `${gridId}-${active.row}-${active.column}` : undefined
|
||||
}
|
||||
onKeyDown={interaction?.keyDown}
|
||||
onCopy={interaction?.copy}
|
||||
onPaste={interaction?.paste}
|
||||
onPointerDown={(event) => {
|
||||
const cell = pointerCell(event)
|
||||
if (!interaction || !cell || event.button !== 0) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
if (interaction.select(cell.row, cell.column, event.shiftKey)) {
|
||||
dragging.current = true
|
||||
focusGrid()
|
||||
}
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
const cell = pointerCell(event)
|
||||
if (interaction && dragging.current && cell) {
|
||||
interaction.select(cell.row, cell.column, true)
|
||||
}
|
||||
}}
|
||||
aria-rowcount={rowCount + 1}
|
||||
aria-colcount={columnCount + 1}
|
||||
className="relative min-w-full"
|
||||
@@ -113,23 +253,23 @@ export function CsvGrid({
|
||||
</div>
|
||||
<div aria-hidden="true" />
|
||||
{virtualColumns.map((column) => (
|
||||
<div
|
||||
role="columnheader"
|
||||
aria-colindex={column.index + 2}
|
||||
aria-label={header[column.index] ?? ''}
|
||||
<CsvGridCell
|
||||
key={column.key}
|
||||
className="relative flex min-w-0 items-center border-b border-r border-border/60 px-2 font-medium text-foreground"
|
||||
id={`${gridId}-0-${column.index}`}
|
||||
header
|
||||
value={header[column.index] ?? ''}
|
||||
row={0}
|
||||
column={column.index}
|
||||
interaction={interaction}
|
||||
focusGrid={focusGrid}
|
||||
>
|
||||
<span className="truncate" title={header[column.index] ?? ''}>
|
||||
{header[column.index] ?? ''}
|
||||
</span>
|
||||
<CsvColumnResizeHandle
|
||||
index={column.index}
|
||||
width={column.size}
|
||||
onResize={resize}
|
||||
onReset={reset}
|
||||
/>
|
||||
</div>
|
||||
</CsvGridCell>
|
||||
))}
|
||||
<div aria-hidden="true" />
|
||||
</div>
|
||||
@@ -164,20 +304,18 @@ export function CsvGrid({
|
||||
</div>
|
||||
<div aria-hidden="true" />
|
||||
{virtualColumns.map((column) => (
|
||||
<div
|
||||
role="cell"
|
||||
aria-colindex={column.index + 2}
|
||||
<CsvGridCell
|
||||
key={column.key}
|
||||
className="flex min-w-0 items-center overflow-hidden border-b border-r border-border/40 px-2 text-foreground"
|
||||
title={row?.[column.index] ?? ''}
|
||||
>
|
||||
<CsvCellValue
|
||||
value={
|
||||
row ? (row[column.index] ?? '') : translate('csv.loadingCell', 'Loading…')
|
||||
}
|
||||
onOpenUrl={onOpenUrl}
|
||||
/>
|
||||
</div>
|
||||
id={`${gridId}-${index + 1}-${column.index}`}
|
||||
value={
|
||||
row ? (row[column.index] ?? '') : translate('csv.loadingCell', 'Loading…')
|
||||
}
|
||||
row={index + 1}
|
||||
column={column.index}
|
||||
interaction={interaction}
|
||||
focusGrid={focusGrid}
|
||||
onOpenUrl={onOpenUrl}
|
||||
/>
|
||||
))}
|
||||
<div aria-hidden="true" />
|
||||
</div>
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { CsvCellValue } from './CsvCellValue'
|
||||
import { csvCellIsSelected } from './csv-cell-selection'
|
||||
import type { CsvGridInteraction } from './csv-grid-interaction'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
function focusCellInput(node: HTMLTextAreaElement | null): void {
|
||||
node?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
export function CsvGridCell({
|
||||
value,
|
||||
row,
|
||||
column,
|
||||
id,
|
||||
header = false,
|
||||
interaction,
|
||||
onOpenUrl,
|
||||
focusGrid,
|
||||
children
|
||||
}: {
|
||||
value: string
|
||||
row: number
|
||||
column: number
|
||||
id: string
|
||||
header?: boolean
|
||||
interaction?: CsvGridInteraction
|
||||
onOpenUrl?: (url: string, event: React.MouseEvent<HTMLAnchorElement>) => void
|
||||
focusGrid: () => void
|
||||
children?: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const editing = interaction?.editing
|
||||
const active = editing?.position.row === row && editing.position.column === column
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
role={header ? 'columnheader' : interaction ? 'gridcell' : 'cell'}
|
||||
aria-colindex={column + 2}
|
||||
aria-label={header ? value : undefined}
|
||||
aria-selected={
|
||||
interaction ? csvCellIsSelected(interaction.selection, row, column) : undefined
|
||||
}
|
||||
data-active={
|
||||
interaction?.selection?.focus.row === row && interaction.selection.focus.column === column
|
||||
}
|
||||
data-csv-row={row}
|
||||
data-csv-column={column}
|
||||
className={
|
||||
header
|
||||
? 'relative flex min-w-0 items-center border-b border-r border-border/60 font-medium text-foreground aria-selected:bg-accent data-[active=true]:ring-1 data-[active=true]:ring-inset data-[active=true]:ring-ring'
|
||||
: 'flex min-w-0 items-center overflow-hidden border-b border-r border-border/40 text-foreground aria-selected:bg-accent data-[active=true]:ring-1 data-[active=true]:ring-inset data-[active=true]:ring-ring'
|
||||
}
|
||||
title={value}
|
||||
onDoubleClick={(event) => {
|
||||
if (event.target instanceof Element && event.target.closest('a,[role="separator"]')) {
|
||||
return
|
||||
}
|
||||
interaction?.edit(row, column)
|
||||
}}
|
||||
>
|
||||
{active && editing ? (
|
||||
<Textarea
|
||||
ref={focusCellInput}
|
||||
variant="cell"
|
||||
rows={1}
|
||||
aria-label={translate('csv.editCell', 'Edit row {{row}}, column {{column}}', {
|
||||
row: row + 1,
|
||||
column: column + 1
|
||||
})}
|
||||
value={editing.value}
|
||||
onChange={(event) => interaction?.change(event.target.value)}
|
||||
onBlur={() => interaction?.commit()}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.nativeEvent.isComposing) {
|
||||
return
|
||||
}
|
||||
const modifier = navigator.userAgent.includes('Mac') ? event.metaKey : event.ctrlKey
|
||||
if (modifier && event.key.toLowerCase() === 's') {
|
||||
event.preventDefault()
|
||||
interaction?.save()
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
interaction?.cancel()
|
||||
focusGrid()
|
||||
} else if ((event.key === 'Enter' && !event.shiftKey) || event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
if (interaction?.commit()) {
|
||||
focusGrid()
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-1 items-center px-2">
|
||||
{header ? (
|
||||
<span className="truncate">{value}</span>
|
||||
) : (
|
||||
<CsvCellValue value={value} onOpenUrl={onOpenUrl} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+22
-2
@@ -4,19 +4,23 @@ import { translate } from '@/i18n/i18n'
|
||||
import { detectCsvDelimiter } from './csv-parse'
|
||||
import { CsvFooter, csvChosenDelimiter } from './CsvViewer'
|
||||
import { CsvGrid } from './CsvGrid'
|
||||
import type { CsvFilePreview } from './editor-csv-file-content'
|
||||
import type { CsvFilePreview } from './csv-file-content'
|
||||
import type { CsvDelimiterChoice } from './csv-delimiter-picker'
|
||||
import { useCsvPagedPreview } from './useCsvPagedPreview'
|
||||
import { EditorFileLoadErrorView } from './EditorFileLoadErrorView'
|
||||
import { EditorFileLoadErrorView } from '../EditorFileLoadErrorView'
|
||||
import { useCsvColumnWidths } from './useCsvColumnWidths'
|
||||
import { buildFileViewPreferenceKey } from '../file-view-preference-storage'
|
||||
import { openCsvHttpLink } from './csv-link-routing'
|
||||
|
||||
export default function CsvPagedViewer({
|
||||
file,
|
||||
filePath,
|
||||
preferenceKey,
|
||||
onReload
|
||||
}: {
|
||||
file: CsvFilePreview
|
||||
filePath: string
|
||||
preferenceKey?: string
|
||||
onReload: () => void
|
||||
}): React.JSX.Element {
|
||||
const [sniff, setSniff] = useState<{ delimiter: string; error: string | null } | null>(null)
|
||||
@@ -56,6 +60,7 @@ export default function CsvPagedViewer({
|
||||
<CsvPagedTable
|
||||
key={csvChosenDelimiter(choice, sniff.delimiter)}
|
||||
file={file}
|
||||
preferenceKey={preferenceKey}
|
||||
delimiter={csvChosenDelimiter(choice, sniff.delimiter)}
|
||||
choice={choice}
|
||||
detected={sniff.delimiter}
|
||||
@@ -73,6 +78,7 @@ export default function CsvPagedViewer({
|
||||
|
||||
function CsvPagedTable({
|
||||
file,
|
||||
preferenceKey,
|
||||
delimiter,
|
||||
choice,
|
||||
detected,
|
||||
@@ -80,12 +86,24 @@ function CsvPagedTable({
|
||||
onReload
|
||||
}: {
|
||||
file: CsvFilePreview
|
||||
preferenceKey?: string
|
||||
delimiter: string
|
||||
choice: CsvDelimiterChoice
|
||||
detected: string
|
||||
onChange: (choice: CsvDelimiterChoice) => void
|
||||
onReload: () => void
|
||||
}): React.JSX.Element {
|
||||
const storedKey = JSON.stringify([
|
||||
preferenceKey ??
|
||||
buildFileViewPreferenceKey({
|
||||
worktreeId: file.readArgs.worktreeId ?? '',
|
||||
runtimeEnvironmentId: file.readArgs.settings?.activeRuntimeEnvironmentId,
|
||||
externalSshTargetId: file.readArgs.connectionId,
|
||||
filePath: file.readArgs.filePath
|
||||
}),
|
||||
delimiter
|
||||
])
|
||||
const { widths, changeWidths } = useCsvColumnWidths(storedKey)
|
||||
const preview = useCsvPagedPreview(file, delimiter)
|
||||
const rowCount = Math.max(0, (preview.index?.rowCount ?? 0) - 1)
|
||||
return (
|
||||
@@ -95,6 +113,8 @@ function CsvPagedTable({
|
||||
) : preview.index ? (
|
||||
<CsvGrid
|
||||
header={preview.header}
|
||||
columnWidths={widths}
|
||||
onColumnWidthsChange={changeWidths}
|
||||
rowCount={rowCount}
|
||||
columnCount={preview.index.columnCount}
|
||||
sampleRows={preview.sample}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { csvSelectionBounds, type CsvCellSelection } from './csv-cell-selection'
|
||||
import type { CsvTableMutation } from './csv-text-document'
|
||||
import type { CsvInspectionSort } from './csv-inspection'
|
||||
|
||||
export function CsvTableToolbar({
|
||||
selection,
|
||||
getSelectedRows,
|
||||
sourceRow,
|
||||
sourceRowCount,
|
||||
columnCount,
|
||||
filter,
|
||||
sort,
|
||||
saving,
|
||||
canEdit,
|
||||
canSave,
|
||||
onFilter,
|
||||
onSort,
|
||||
onApply,
|
||||
onEdit,
|
||||
onSave,
|
||||
onCopy,
|
||||
onPaste
|
||||
}: {
|
||||
selection: CsvCellSelection | null
|
||||
getSelectedRows: () => number[]
|
||||
sourceRow: number
|
||||
sourceRowCount: number
|
||||
columnCount: number
|
||||
filter: string
|
||||
sort: CsvInspectionSort | null
|
||||
saving: boolean
|
||||
canEdit: boolean
|
||||
canSave: boolean
|
||||
onFilter: (value: string) => void
|
||||
onSort: (value: CsvInspectionSort | null) => void
|
||||
onApply: (mutation: CsvTableMutation) => boolean
|
||||
onEdit: () => void
|
||||
onSave: () => void
|
||||
onCopy: () => void
|
||||
onPaste: () => void
|
||||
}): React.JSX.Element {
|
||||
const column = selection?.focus.column ?? 0
|
||||
const row = Math.max(1, sourceRow)
|
||||
const blankRow = () => Array.from({ length: columnCount }, () => '')
|
||||
const selectedColumns = selection ? csvSelectionBounds(selection) : null
|
||||
const deleteColumns = () =>
|
||||
selectedColumns
|
||||
? Array.from(
|
||||
{ length: selectedColumns.lastColumn - selectedColumns.firstColumn + 1 },
|
||||
(_, offset) => selectedColumns.firstColumn + offset
|
||||
)
|
||||
: []
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2">
|
||||
<div className="min-w-0 flex-1 max-w-xs">
|
||||
<Input
|
||||
aria-label={translate('csv.filterRows', 'Filter rows')}
|
||||
placeholder={translate('csv.filterRows', 'Filter rows')}
|
||||
value={filter}
|
||||
onChange={(event) => onFilter(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="ghost" size="xs" disabled={!selection || !canEdit} onClick={onEdit}>
|
||||
{translate('csv.edit', 'Edit cell')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="xs" disabled={!selection} onClick={onCopy}>
|
||||
{translate('csv.copy', 'Copy')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="xs" disabled={!selection || !canEdit} onClick={onPaste}>
|
||||
{translate('csv.paste', 'Paste')}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="xs" disabled={!canEdit || !columnCount}>
|
||||
{translate('csv.rows', 'Rows')}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onApply({ kind: 'insert-rows', at: row, rows: [blankRow()] })}
|
||||
>
|
||||
{translate('csv.insertRowAbove', 'Insert row above')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
onApply({
|
||||
kind: 'insert-rows',
|
||||
at: sourceRow === 0 ? 1 : Math.min(row + 1, sourceRowCount),
|
||||
rows: [blankRow()]
|
||||
})
|
||||
}
|
||||
>
|
||||
{translate('csv.insertRowBelow', 'Insert row below')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!selection || csvSelectionBounds(selection).lastRow === 0}
|
||||
onSelect={() =>
|
||||
onApply({ kind: 'delete-rows', rows: getSelectedRows().filter((row) => row > 0) })
|
||||
}
|
||||
>
|
||||
{translate('csv.deleteRows', 'Delete selected rows')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="xs" disabled={!canEdit}>
|
||||
{translate('csv.columns', 'Columns')}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
onApply({
|
||||
kind: 'insert-column',
|
||||
at: column,
|
||||
label: translate('csv.newColumn', 'Column {{column}}', { column: columnCount + 1 })
|
||||
})
|
||||
}
|
||||
>
|
||||
{translate('csv.insertColumnBefore', 'Insert column before')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
onApply({
|
||||
kind: 'insert-column',
|
||||
at: Math.min(columnCount, column + 1),
|
||||
label: translate('csv.newColumn', 'Column {{column}}', { column: columnCount + 1 })
|
||||
})
|
||||
}
|
||||
>
|
||||
{translate('csv.insertColumnAfter', 'Insert column after')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!selection || column === 0}
|
||||
onSelect={() => onApply({ kind: 'move-column', from: column, to: column - 1 })}
|
||||
>
|
||||
{translate('csv.moveColumnLeft', 'Move column left')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!selection || column >= columnCount - 1}
|
||||
onSelect={() => onApply({ kind: 'move-column', from: column, to: column + 1 })}
|
||||
>
|
||||
{translate('csv.moveColumnRight', 'Move column right')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!selection}
|
||||
onSelect={() => onApply({ kind: 'delete-columns', columns: deleteColumns() })}
|
||||
>
|
||||
{translate('csv.deleteColumns', 'Delete selected columns')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="xs" disabled={!columnCount}>
|
||||
{translate('csv.sort', 'Sort')}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onSelect={() => onSort({ column, direction: 'ascending' })}>
|
||||
{translate('csv.sortAscending', 'Sort selected column ascending')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSort({ column, direction: 'descending' })}>
|
||||
{translate('csv.sortDescending', 'Sort selected column descending')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!sort} onSelect={() => onSort(null)}>
|
||||
{translate('csv.originalOrder', 'Original row order')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{canSave && (
|
||||
<Button variant="outline" size="xs" disabled={saving} aria-busy={saving} onClick={onSave}>
|
||||
{translate('csv.save', 'Save')}
|
||||
</Button>
|
||||
)}
|
||||
{(filter || sort) && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{translate('csv.inspectionOnly', 'Inspection only · File row order unchanged')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { useState } from 'react'
|
||||
import { renderHook, cleanup, fireEvent, render, screen, act } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import CsvViewer from './CsvViewer'
|
||||
import { useCsvTableEditor } from './useCsvTableEditor'
|
||||
import { parseCsvTextDocument } from './csv-text-document'
|
||||
import { flushPendingEditorChange, hasPendingEditorChange } from '../editor-pending-flush'
|
||||
import { APP_MENU_PASTE_EVENT } from '@/lib/app-menu-paste'
|
||||
import { ORCA_EDITOR_FILE_SAVED_EVENT } from '../editor-autosave'
|
||||
|
||||
vi.mock('@tanstack/react-virtual', () => ({
|
||||
useVirtualizer: ({
|
||||
count,
|
||||
estimateSize,
|
||||
paddingStart = 0
|
||||
}: {
|
||||
count: number
|
||||
estimateSize: (index: number) => number
|
||||
paddingStart?: number
|
||||
}) => ({
|
||||
getVirtualItems: () =>
|
||||
Array.from({ length: Math.min(count, 20) }, (_, index) => ({
|
||||
index,
|
||||
key: index,
|
||||
start: paddingStart + index * estimateSize(index),
|
||||
size: estimateSize(index),
|
||||
end: paddingStart + (index + 1) * estimateSize(index)
|
||||
})),
|
||||
getTotalSize: () => paddingStart + count * estimateSize(0),
|
||||
resizeItem: vi.fn(),
|
||||
measure: vi.fn(),
|
||||
scrollToIndex: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
localStorage.clear()
|
||||
Reflect.deleteProperty(window, 'api')
|
||||
})
|
||||
|
||||
function setup(source = '\ufeffname,value\r\nB,2\r\nA,1') {
|
||||
const changes = vi.fn()
|
||||
const save = vi.fn().mockResolvedValue(true)
|
||||
function Editor() {
|
||||
const [content, setContent] = useState(source)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<CsvViewer
|
||||
content={content}
|
||||
filePath="test.csv"
|
||||
fileId="csv-edit-test"
|
||||
isDirty={dirty}
|
||||
onDirtyStateHint={setDirty}
|
||||
onContentChange={(next) => {
|
||||
changes(next)
|
||||
setContent(next)
|
||||
setDirty(next !== source)
|
||||
}}
|
||||
onSave={save}
|
||||
/>
|
||||
<output data-testid="source">{content}</output>
|
||||
<output data-testid="dirty">{String(dirty)}</output>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return { ...render(<Editor />), changes, save }
|
||||
}
|
||||
|
||||
function edit(value: string, next: string): HTMLElement {
|
||||
fireEvent.doubleClick(screen.getByRole('gridcell', { name: value }))
|
||||
const input = screen.getByRole('textbox', { name: /Edit row/ })
|
||||
fireEvent.change(input, { target: { value: next } })
|
||||
return input
|
||||
}
|
||||
|
||||
describe('editable CSV table', () => {
|
||||
it('commits escaped multiline values while preserving BOM, endings and source order', () => {
|
||||
const { changes } = setup()
|
||||
const input = edit('B', 'x,"quoted"\nnext')
|
||||
expect(screen.getByTestId('dirty').textContent).toBe('true')
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(changes).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByTestId('source').textContent).toBe(
|
||||
'\ufeffname,value\r\n"x,""quoted""\nnext",2\r\nA,1'
|
||||
)
|
||||
})
|
||||
|
||||
it('Escape cancels before the following blur can commit, preserving earlier dirty state', () => {
|
||||
const { changes } = setup()
|
||||
fireEvent.keyDown(edit('B', 'discarded'), { key: 'Escape' })
|
||||
expect(changes).not.toHaveBeenCalled()
|
||||
expect(screen.getByTestId('dirty').textContent).toBe('false')
|
||||
fireEvent.keyDown(edit('B', 'kept'), { key: 'Enter' })
|
||||
fireEvent.keyDown(edit('A', 'discarded too'), { key: 'Escape' })
|
||||
expect(changes).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByTestId('dirty').textContent).toBe('true')
|
||||
})
|
||||
|
||||
it('flushes the active input into the draft for global Save and hot exit', () => {
|
||||
const { changes } = setup()
|
||||
edit('B', 'pending')
|
||||
act(() => flushPendingEditorChange('csv-edit-test'))
|
||||
expect(changes).toHaveBeenCalledWith('\ufeffname,value\r\npending,2\r\nA,1')
|
||||
})
|
||||
|
||||
it('retains pending input when switching away from the table', () => {
|
||||
const { changes, unmount } = setup()
|
||||
edit('B', 'switching')
|
||||
unmount()
|
||||
expect(changes).toHaveBeenCalledWith('\ufeffname,value\r\nswitching,2\r\nA,1')
|
||||
})
|
||||
|
||||
it('saves the current cell value and reports a failed write without dropping the draft', async () => {
|
||||
const { save } = setup()
|
||||
save.mockResolvedValue(false)
|
||||
fireEvent.keyDown(edit('B', 'saved value'), { key: 's', ctrlKey: true, metaKey: true })
|
||||
expect(save).toHaveBeenCalledWith('\ufeffname,value\r\nsaved value,2\r\nA,1')
|
||||
expect((await screen.findByRole('alert')).textContent).toContain(
|
||||
'Your edits remain in the draft'
|
||||
)
|
||||
expect(screen.getByTestId('source').textContent).toContain('saved value')
|
||||
})
|
||||
|
||||
it('pastes atomically into selected cells and does not partially apply an oversized rectangle', () => {
|
||||
const { changes } = setup()
|
||||
const cell = screen.getByRole('gridcell', { name: 'B' })
|
||||
fireEvent.pointerDown(cell, { button: 0 })
|
||||
const grid = screen.getByRole('grid')
|
||||
fireEvent.paste(grid, { clipboardData: { getData: () => 'new\t3\nother\t4' } })
|
||||
expect(screen.getByTestId('source').textContent).toBe('\ufeffname,value\r\nnew,3\r\nother,4')
|
||||
fireEvent.paste(grid, { clipboardData: { getData: () => 'one\ttwo\tthree' } })
|
||||
expect(changes).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByRole('alert').textContent).toContain('Paste exceeds the table')
|
||||
})
|
||||
|
||||
it('edits a filtered row in its original source position', () => {
|
||||
setup()
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Filter rows' }), {
|
||||
target: { value: 'A' }
|
||||
})
|
||||
fireEvent.keyDown(edit('1', 'updated'), { key: 'Enter' })
|
||||
expect(screen.getByTestId('source').textContent).toBe('\ufeffname,value\r\nB,2\r\nA,updated')
|
||||
})
|
||||
|
||||
it('keeps the chosen table delimiter when an edit would change the Auto guess', () => {
|
||||
setup('first;second\nvalue;two')
|
||||
fireEvent.doubleClick(screen.getByRole('columnheader', { name: 'first' }))
|
||||
const input = screen.getByRole('textbox', { name: /Edit row/ })
|
||||
fireEvent.change(input, { target: { value: 'tabs\there\ttoo' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(screen.getByRole('grid').getAttribute('aria-colcount')).toBe('3')
|
||||
expect(screen.getByRole('columnheader', { name: 'tabs\there\ttoo' })).toBeTruthy()
|
||||
expect(screen.getByRole('combobox', { name: 'Delimiter' }).textContent).toBe('Semicolon (;)')
|
||||
})
|
||||
|
||||
it('cancels only the new cell after an earlier save completes', () => {
|
||||
setup()
|
||||
fireEvent.keyDown(edit('B', 'saved base'), { key: 'Enter' })
|
||||
const source = screen.getByTestId('source').textContent
|
||||
const input = edit('A', 'new pending cell')
|
||||
expect(hasPendingEditorChange('csv-edit-test')).toBe(true)
|
||||
act(() =>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(ORCA_EDITOR_FILE_SAVED_EVENT, {
|
||||
detail: { fileId: 'csv-edit-test', content: source }
|
||||
})
|
||||
)
|
||||
)
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
expect(hasPendingEditorChange('csv-edit-test')).toBe(false)
|
||||
expect(screen.getByTestId('dirty').textContent).toBe('false')
|
||||
expect(screen.getByTestId('source').textContent).toBe(source)
|
||||
})
|
||||
|
||||
it('keeps a rejected over-limit edit visible and recoverable on view detachment', () => {
|
||||
const { changes, unmount } = setup()
|
||||
const value = 'x'.repeat(1024 * 1024 + 1)
|
||||
fireEvent.keyDown(edit('B', value), { key: 'Enter' })
|
||||
expect(screen.getByRole('alert').textContent).toContain('record is too large')
|
||||
expect(changes).not.toHaveBeenCalled()
|
||||
const retained = screen.getByRole('textbox', { name: /Edit row/ })
|
||||
expect(retained instanceof HTMLTextAreaElement && retained.value).toBe(value)
|
||||
unmount()
|
||||
expect(changes).toHaveBeenCalledWith(`\ufeffname,value\r\n${value},2\r\nA,1`)
|
||||
})
|
||||
|
||||
it('blocks table mutations after a rejected pending edit and preserves its recovery draft', () => {
|
||||
const { changes, unmount } = setup()
|
||||
const value = 'x'.repeat(1024 * 1024 + 1)
|
||||
fireEvent.keyDown(edit('B', value), { key: 'Enter' })
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: 'Rows' }), {
|
||||
key: 'ArrowDown'
|
||||
})
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Insert row below' }))
|
||||
expect(changes).not.toHaveBeenCalled()
|
||||
expect(screen.getByTestId('source').textContent).toBe('\ufeffname,value\r\nB,2\r\nA,1')
|
||||
unmount()
|
||||
expect(changes).toHaveBeenCalledWith(`\ufeffname,value\r\n${value},2\r\nA,1`)
|
||||
})
|
||||
|
||||
it('applies a pending cell and a subsequent structural mutation atomically', () => {
|
||||
const changes = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useCsvTableEditor({
|
||||
document: parseCsvTextDocument('name,value\nB,2', ','),
|
||||
inspectionRows: null,
|
||||
onContentChange: changes,
|
||||
onStructureChange: vi.fn()
|
||||
})
|
||||
)
|
||||
act(() => result.current.edit(1, 0))
|
||||
act(() => result.current.interaction?.change('pending'))
|
||||
act(() =>
|
||||
expect(result.current.apply({ kind: 'insert-column', at: 1, label: 'new' })).toBe(true)
|
||||
)
|
||||
expect(changes).toHaveBeenCalledExactlyOnceWith('name,new,value\npending,,2')
|
||||
expect(result.current.interaction?.editing).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves a read-only table without mutation controls or an input', () => {
|
||||
render(<CsvViewer content={'a,b\nx,y'} filePath="readonly.csv" />)
|
||||
fireEvent.doubleClick(screen.getByRole('cell', { name: 'x' }))
|
||||
expect(screen.queryByRole('grid')).toBeNull()
|
||||
expect(screen.queryByRole('textbox', { name: /Edit row/ })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Save' })).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects an asynchronous paste when the selection changes while reading', async () => {
|
||||
let resolveRead: (value: string) => void = () => {
|
||||
throw new Error('read not initialized')
|
||||
}
|
||||
const pending = new Promise<string>((resolve) => {
|
||||
resolveRead = resolve
|
||||
})
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { ui: { readClipboardText: () => pending } }
|
||||
})
|
||||
const { changes } = setup()
|
||||
fireEvent.pointerDown(screen.getByRole('gridcell', { name: 'B' }), { button: 0 })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Paste' }))
|
||||
fireEvent.pointerDown(screen.getByRole('gridcell', { name: 'A' }), { button: 0 })
|
||||
await act(async () => {
|
||||
resolveRead('stale paste')
|
||||
await pending
|
||||
})
|
||||
expect(changes).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('alert').textContent).toContain('CSV changed before paste completed')
|
||||
})
|
||||
|
||||
it('rejects a delayed paste if typing starts before the clipboard read completes', async () => {
|
||||
let resolveRead: (value: string) => void = () => {}
|
||||
const pending = new Promise<string>((resolve) => {
|
||||
resolveRead = resolve
|
||||
})
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { ui: { readClipboardText: () => pending } }
|
||||
})
|
||||
const { changes } = setup()
|
||||
fireEvent.pointerDown(screen.getByRole('gridcell', { name: 'B' }), { button: 0 })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Paste' }))
|
||||
edit('B', 'new pending input')
|
||||
await act(async () => {
|
||||
resolveRead('old clipboard')
|
||||
await pending
|
||||
})
|
||||
expect(changes).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('alert').textContent).toContain('CSV changed before paste completed')
|
||||
const retained = screen.getByRole('textbox', { name: /Edit row/ })
|
||||
expect(retained instanceof HTMLTextAreaElement && retained.value).toBe('new pending input')
|
||||
})
|
||||
|
||||
it('routes menu paste only to the focused grid when two file panes are mounted', async () => {
|
||||
const first = vi.fn()
|
||||
const second = vi.fn()
|
||||
const read = vi.fn().mockResolvedValue('pasted')
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { ui: { readClipboardText: read } }
|
||||
})
|
||||
render(
|
||||
<>
|
||||
<CsvViewer content={'header\nfirst'} filePath="first.csv" onContentChange={first} />
|
||||
<CsvViewer content={'header\nsecond'} filePath="second.csv" onContentChange={second} />
|
||||
</>
|
||||
)
|
||||
fireEvent.pointerDown(screen.getByRole('gridcell', { name: 'first' }), { button: 0 })
|
||||
fireEvent.pointerDown(screen.getByRole('gridcell', { name: 'second' }), { button: 0 })
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event(APP_MENU_PASTE_EVENT, { cancelable: true }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(read).toHaveBeenCalledTimes(1)
|
||||
expect(first).not.toHaveBeenCalled()
|
||||
expect(second).toHaveBeenCalledWith('header\npasted')
|
||||
})
|
||||
|
||||
it('cancels delayed app-menu paste when focus moves to another file pane', async () => {
|
||||
const first = vi.fn()
|
||||
const second = vi.fn()
|
||||
let resolveRead: (value: string) => void = () => {}
|
||||
const pending = new Promise<string>((resolve) => {
|
||||
resolveRead = resolve
|
||||
})
|
||||
const read = vi.fn(() => pending)
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { ui: { readClipboardText: read } }
|
||||
})
|
||||
render(
|
||||
<>
|
||||
<CsvViewer content={'header\nfirst'} filePath="first.csv" onContentChange={first} />
|
||||
<CsvViewer content={'header\nsecond'} filePath="second.csv" onContentChange={second} />
|
||||
</>
|
||||
)
|
||||
fireEvent.pointerDown(screen.getByRole('gridcell', { name: 'first' }), { button: 0 })
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event(APP_MENU_PASTE_EVENT, { cancelable: true }))
|
||||
})
|
||||
expect(read).toHaveBeenCalledTimes(1)
|
||||
act(() => {
|
||||
screen.getAllByRole('grid')[1]?.focus()
|
||||
})
|
||||
await act(async () => {
|
||||
resolveRead('stale paste')
|
||||
await pending
|
||||
})
|
||||
expect(first).not.toHaveBeenCalled()
|
||||
expect(second).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('flushes an edited pane even when another pane of the same file registered later', () => {
|
||||
const first = vi.fn()
|
||||
const second = vi.fn()
|
||||
render(
|
||||
<>
|
||||
<CsvViewer
|
||||
content={'header\nfirst'}
|
||||
filePath="same.csv"
|
||||
fileId="same-file"
|
||||
onContentChange={first}
|
||||
/>
|
||||
<CsvViewer
|
||||
content={'header\nfirst'}
|
||||
filePath="same.csv"
|
||||
fileId="same-file"
|
||||
onContentChange={second}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
fireEvent.doubleClick(screen.getAllByRole('gridcell', { name: 'first' })[0]!)
|
||||
fireEvent.change(screen.getByRole('textbox', { name: /Edit row/ }), {
|
||||
target: { value: 'pending first pane' }
|
||||
})
|
||||
expect(hasPendingEditorChange('same-file')).toBe(true)
|
||||
act(() => flushPendingEditorChange('same-file'))
|
||||
expect(first).toHaveBeenCalledWith('header\npending first pane')
|
||||
expect(second).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { detectCsvDelimiter } from './csv-parse'
|
||||
import { CsvDelimiterPicker, type CsvDelimiterChoice } from './csv-delimiter-picker'
|
||||
import { CsvGrid } from './CsvGrid'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { openCsvHttpLink } from './csv-link-routing'
|
||||
import { parseCsvTextDocument } from './csv-text-document'
|
||||
import { csvInspectionRows, csvSourceRow, type CsvInspectionSort } from './csv-inspection'
|
||||
import { useCsvTableEditor } from './useCsvTableEditor'
|
||||
import { CsvTableToolbar } from './CsvTableToolbar'
|
||||
import { remapCsvColumnWidths } from './csv-column-width-preferences'
|
||||
import { useCsvColumnWidths } from './useCsvColumnWidths'
|
||||
import { buildFileViewPreferenceKey } from '../file-view-preference-storage'
|
||||
|
||||
export default function CsvViewer({
|
||||
content,
|
||||
filePath,
|
||||
worktreeId,
|
||||
runtimeEnvironmentId,
|
||||
preferenceKey,
|
||||
onContentChange,
|
||||
onSave,
|
||||
fileId,
|
||||
isDirty,
|
||||
onDirtyStateHint
|
||||
}: {
|
||||
content: string
|
||||
filePath: string
|
||||
worktreeId?: string
|
||||
runtimeEnvironmentId?: string | null
|
||||
preferenceKey?: string
|
||||
fileId?: string
|
||||
isDirty?: boolean
|
||||
onDirtyStateHint?: (dirty: boolean) => void
|
||||
onContentChange?: (content: string) => void
|
||||
onSave?: (content: string) => Promise<boolean>
|
||||
}): React.JSX.Element {
|
||||
const [delimiterChoice, setDelimiterChoice] = useState<CsvDelimiterChoice>('auto')
|
||||
const detectedDelimiter = useMemo(
|
||||
() => detectCsvDelimiter(filePath, content),
|
||||
[filePath, content]
|
||||
)
|
||||
const delimiter = csvChosenDelimiter(delimiterChoice, detectedDelimiter)
|
||||
const result = useMemo(() => {
|
||||
try {
|
||||
return {
|
||||
parsed: parseCsvTextDocument(content, delimiter),
|
||||
error: null
|
||||
}
|
||||
} catch (error) {
|
||||
return { parsed: null, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}, [content, delimiter])
|
||||
const [filter, setFilter] = useState('')
|
||||
const [sort, setSort] = useState<CsvInspectionSort | null>(null)
|
||||
const storedKey = JSON.stringify([
|
||||
preferenceKey ??
|
||||
buildFileViewPreferenceKey({
|
||||
worktreeId: worktreeId ?? '',
|
||||
runtimeEnvironmentId,
|
||||
filePath
|
||||
}),
|
||||
delimiter
|
||||
])
|
||||
const { widths: columnWidths, changeWidths } = useCsvColumnWidths(storedKey)
|
||||
const document = result.parsed
|
||||
const effectiveSort = sort && sort.column < (document?.columnCount ?? 0) ? sort : null
|
||||
const inspectionRows = useMemo(
|
||||
() => (document ? csvInspectionRows(document.rows, filter, effectiveSort) : null),
|
||||
[document, filter, effectiveSort]
|
||||
)
|
||||
const editor = useCsvTableEditor({
|
||||
document,
|
||||
fileId,
|
||||
isDirty,
|
||||
onDirtyStateHint,
|
||||
inspectionRows,
|
||||
onContentChange,
|
||||
onSave,
|
||||
onStructureChange: (mutation) => {
|
||||
if (delimiterChoice === 'auto') {
|
||||
setDelimiterChoice(delimiter === ',' ? 'comma' : delimiter === ';' ? 'semicolon' : 'tab')
|
||||
}
|
||||
const widths = remapCsvColumnWidths(columnWidths, document?.columnCount ?? 0, mutation)
|
||||
if (widths !== columnWidths) {
|
||||
changeWidths(widths)
|
||||
}
|
||||
if (
|
||||
mutation.kind === 'delete-columns' ||
|
||||
mutation.kind === 'insert-column' ||
|
||||
mutation.kind === 'move-column'
|
||||
) {
|
||||
setSort(null)
|
||||
}
|
||||
}
|
||||
})
|
||||
const header = document?.rows[0] ?? []
|
||||
const rowCount = inspectionRows?.length ?? Math.max(0, (document?.rows.length ?? 0) - 1)
|
||||
const sample = useMemo(() => document?.rows.slice(1, 201) ?? [], [document])
|
||||
const getRow = useCallback(
|
||||
(index: number) => document?.rows[csvSourceRow(index + 1, inspectionRows)],
|
||||
[document, inspectionRows]
|
||||
)
|
||||
return (
|
||||
<div ref={editor.setRootRef} className="flex h-full min-h-0 flex-col">
|
||||
{onContentChange && (
|
||||
<CsvTableToolbar
|
||||
selection={editor.selection}
|
||||
getSelectedRows={editor.selectedRows}
|
||||
sourceRow={csvSourceRow(editor.selection?.focus.row ?? 0, inspectionRows)}
|
||||
sourceRowCount={document?.rows.length ?? 0}
|
||||
columnCount={document?.columnCount ?? 0}
|
||||
filter={filter}
|
||||
sort={effectiveSort}
|
||||
saving={editor.saving}
|
||||
canEdit={!result.error}
|
||||
canSave={Boolean(onSave)}
|
||||
onFilter={(next) => {
|
||||
if (editor.interaction?.commit() !== false) {
|
||||
setFilter(next)
|
||||
}
|
||||
}}
|
||||
onSort={(next) => {
|
||||
if (editor.interaction?.commit() !== false) {
|
||||
setSort(next)
|
||||
}
|
||||
}}
|
||||
onApply={editor.apply}
|
||||
onEdit={() =>
|
||||
editor.edit(editor.selection?.focus.row ?? 0, editor.selection?.focus.column ?? 0)
|
||||
}
|
||||
onSave={() => void editor.save()}
|
||||
onCopy={() => void editor.copy()}
|
||||
onPaste={() => void editor.paste()}
|
||||
/>
|
||||
)}
|
||||
{editor.error && (
|
||||
<div role="alert" className="border-b border-border px-3 py-2 text-sm text-destructive">
|
||||
{editor.error}
|
||||
</div>
|
||||
)}
|
||||
{result.error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-1 items-center justify-center px-4 text-sm text-muted-foreground"
|
||||
>
|
||||
{result.error}
|
||||
</div>
|
||||
) : result.parsed?.rows.length ? (
|
||||
<CsvGrid
|
||||
key={delimiter}
|
||||
header={header}
|
||||
rowCount={rowCount}
|
||||
columnCount={result.parsed.columnCount}
|
||||
interaction={editor.interaction}
|
||||
columnWidths={columnWidths}
|
||||
onColumnWidthsChange={changeWidths}
|
||||
sampleRows={sample}
|
||||
getRow={getRow}
|
||||
onOpenUrl={(url, event) =>
|
||||
openCsvHttpLink(url, event, { filePath, worktreeId, runtimeEnvironmentId })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
{translate('auto.components.editor.CsvViewer.a233d55b77', 'Empty file')}
|
||||
</div>
|
||||
)}
|
||||
<CsvFooter
|
||||
rowCount={rowCount}
|
||||
columnCount={result.parsed?.columnCount ?? 0}
|
||||
delimiterChoice={delimiterChoice}
|
||||
detectedDelimiter={detectedDelimiter}
|
||||
onDelimiterChange={(next) => {
|
||||
if (editor.interaction?.commit() !== false) {
|
||||
setDelimiterChoice(next)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function csvChosenDelimiter(choice: CsvDelimiterChoice, detected: string): string {
|
||||
return choice === 'auto'
|
||||
? detected
|
||||
: choice === 'comma'
|
||||
? ','
|
||||
: choice === 'semicolon'
|
||||
? ';'
|
||||
: '\t'
|
||||
}
|
||||
|
||||
export function CsvFooter({
|
||||
rowCount,
|
||||
columnCount,
|
||||
delimiterChoice,
|
||||
detectedDelimiter,
|
||||
onDelimiterChange
|
||||
}: {
|
||||
rowCount: number
|
||||
columnCount: number
|
||||
delimiterChoice: CsvDelimiterChoice
|
||||
detectedDelimiter: string
|
||||
onDelimiterChange: (choice: CsvDelimiterChoice) => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-4 border-t border-border/60 px-3 py-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{rowCount.toLocaleString()}{' '}
|
||||
{translate('auto.components.editor.CsvViewer.ac31d2cd60', 'rows')}
|
||||
</span>
|
||||
<span>
|
||||
{columnCount.toLocaleString()}{' '}
|
||||
{translate('auto.components.editor.CsvViewer.eedd0d37a7', 'columns')}
|
||||
</span>
|
||||
<CsvDelimiterPicker
|
||||
value={delimiterChoice}
|
||||
detectedDelimiter={detectedDelimiter}
|
||||
onChange={onDelimiterChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
CLIPBOARD_TEXT_WRITE_MAX_BYTES,
|
||||
assertClipboardTextWithinLimit,
|
||||
assertClipboardTextWriteWithinLimit
|
||||
} from '../../../../../shared/clipboard-text'
|
||||
import { CSV_MAX_COLUMNS, CSV_RECORD_BYTES } from './csv-byte-index'
|
||||
import { measureUtf8ByteLength } from '../../../../../shared/utf8-byte-limits'
|
||||
import { parseCsv } from './csv-parse'
|
||||
import { serializeCsvRecord } from './csv-serialization'
|
||||
import { csvSelectionBounds, type CsvCellSelection } from './csv-cell-selection'
|
||||
import { csvSourceRow } from './csv-inspection'
|
||||
import type { CsvCellEdit } from './csv-text-document'
|
||||
|
||||
const CLIPBOARD_CELLS = 1024 * 1024
|
||||
|
||||
export function copyCsvSelection(
|
||||
selection: CsvCellSelection,
|
||||
rows: readonly string[][],
|
||||
inspectionRows: readonly number[] | null
|
||||
): string {
|
||||
const bounds = csvSelectionBounds(selection)
|
||||
const records: string[] = []
|
||||
let cells = 0
|
||||
let bytes = 0
|
||||
for (let row = bounds.firstRow; row <= bounds.lastRow; row += 1) {
|
||||
const source = rows[csvSourceRow(row, inspectionRows)]
|
||||
if (!source) {
|
||||
throw new Error('The selected CSV row no longer exists.')
|
||||
}
|
||||
const values: string[] = []
|
||||
for (let column = bounds.firstColumn; column <= bounds.lastColumn; column += 1) {
|
||||
if (++cells > CLIPBOARD_CELLS) {
|
||||
throw new Error('Select fewer cells to copy at once.')
|
||||
}
|
||||
values.push(source[column] ?? '')
|
||||
}
|
||||
const record = serializeCsvRecord(values, '\t')
|
||||
bytes +=
|
||||
measureUtf8ByteLength(record, { stopAfterBytes: CLIPBOARD_TEXT_WRITE_MAX_BYTES }).byteLength +
|
||||
(records.length ? 1 : 0)
|
||||
if (bytes > CLIPBOARD_TEXT_WRITE_MAX_BYTES) {
|
||||
throw new Error('Selected cells are too large to copy safely.')
|
||||
}
|
||||
records.push(record)
|
||||
}
|
||||
return assertClipboardTextWriteWithinLimit(records.join('\n'))
|
||||
}
|
||||
|
||||
export function pasteCsvSelection(
|
||||
text: string,
|
||||
selection: CsvCellSelection,
|
||||
rows: readonly string[][],
|
||||
columnCount: number,
|
||||
inspectionRows: readonly number[] | null
|
||||
): CsvCellEdit[] {
|
||||
assertClipboardTextWithinLimit(text)
|
||||
const pasted =
|
||||
text === ''
|
||||
? [['']]
|
||||
: parseCsv(text, '\t', {
|
||||
maxCells: CLIPBOARD_CELLS,
|
||||
maxColumns: CSV_MAX_COLUMNS,
|
||||
maxRecordLength: CSV_RECORD_BYTES,
|
||||
stripBom: false
|
||||
}).rows
|
||||
const bounds = csvSelectionBounds(selection)
|
||||
const fill = pasted.length === 1 && pasted[0]?.length === 1
|
||||
const height = fill ? bounds.lastRow - bounds.firstRow + 1 : pasted.length
|
||||
const width = fill
|
||||
? bounds.lastColumn - bounds.firstColumn + 1
|
||||
: pasted.reduce((maximum, row) => Math.max(maximum, row.length), 0)
|
||||
const viewRowCount = inspectionRows ? inspectionRows.length + 1 : rows.length
|
||||
if (bounds.firstRow + height > viewRowCount || bounds.firstColumn + width > columnCount) {
|
||||
throw new Error('Paste exceeds the table. Add rows or columns first.')
|
||||
}
|
||||
if (height * width > CLIPBOARD_CELLS) {
|
||||
throw new Error('Select fewer cells to paste at once.')
|
||||
}
|
||||
const edits: CsvCellEdit[] = []
|
||||
for (let row = 0; row < height; row += 1) {
|
||||
for (let column = 0; column < width; column += 1) {
|
||||
edits.push({
|
||||
row: csvSourceRow(bounds.firstRow + row, inspectionRows),
|
||||
column: bounds.firstColumn + column,
|
||||
value: fill ? pasted[0]![0]! : (pasted[row]?.[column] ?? '')
|
||||
})
|
||||
}
|
||||
}
|
||||
return edits
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
export type CsvCellPosition = { row: number; column: number }
|
||||
export type CsvCellSelection = { anchor: CsvCellPosition; focus: CsvCellPosition }
|
||||
|
||||
export function remapCsvSelectionAfterColumnMove(
|
||||
selection: CsvCellSelection | null,
|
||||
from: number,
|
||||
to: number
|
||||
): CsvCellSelection | null {
|
||||
if (!selection) {
|
||||
return null
|
||||
}
|
||||
const moved = (column: number): number => {
|
||||
if (column === from) {
|
||||
return to
|
||||
}
|
||||
if (from < to && column > from && column <= to) {
|
||||
return column - 1
|
||||
}
|
||||
if (from > to && column >= to && column < from) {
|
||||
return column + 1
|
||||
}
|
||||
return column
|
||||
}
|
||||
return {
|
||||
anchor: { ...selection.anchor, column: moved(selection.anchor.column) },
|
||||
focus: { ...selection.focus, column: moved(selection.focus.column) }
|
||||
}
|
||||
}
|
||||
|
||||
export function csvSelectionBounds(selection: CsvCellSelection) {
|
||||
return {
|
||||
firstRow: Math.min(selection.anchor.row, selection.focus.row),
|
||||
lastRow: Math.max(selection.anchor.row, selection.focus.row),
|
||||
firstColumn: Math.min(selection.anchor.column, selection.focus.column),
|
||||
lastColumn: Math.max(selection.anchor.column, selection.focus.column)
|
||||
}
|
||||
}
|
||||
|
||||
export function csvCellIsSelected(
|
||||
selection: CsvCellSelection | null,
|
||||
row: number,
|
||||
column: number
|
||||
): boolean {
|
||||
if (!selection) {
|
||||
return false
|
||||
}
|
||||
const bounds = csvSelectionBounds(selection)
|
||||
return (
|
||||
row >= bounds.firstRow &&
|
||||
row <= bounds.lastRow &&
|
||||
column >= bounds.firstColumn &&
|
||||
column <= bounds.lastColumn
|
||||
)
|
||||
}
|
||||
|
||||
export function moveCsvSelection(
|
||||
selection: CsvCellSelection | null,
|
||||
row: number,
|
||||
column: number,
|
||||
extend: boolean
|
||||
): CsvCellSelection {
|
||||
const focus = { row, column }
|
||||
return { anchor: extend && selection ? selection.anchor : focus, focus }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const CSV_MIN_COLUMN_WIDTH = 48
|
||||
export const CSV_MAX_COLUMN_WIDTH = 1200
|
||||
@@ -0,0 +1,77 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
CSV_COLUMN_WIDTHS_STORAGE_KEY,
|
||||
readCsvColumnWidths,
|
||||
writeCsvColumnWidths,
|
||||
remapCsvColumnWidths
|
||||
} from './csv-column-width-preferences'
|
||||
import { buildFileViewPreferenceKey } from '../file-view-preference-storage'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('persists widths through remounts and isolates the same path by owner', () => {
|
||||
const owner = { worktreeId: 'folder:a', filePath: '/repo/a.csv' }
|
||||
const local = buildFileViewPreferenceKey(owner)
|
||||
const paired = buildFileViewPreferenceKey({ ...owner, runtimeEnvironmentId: 'paired' })
|
||||
const ssh = buildFileViewPreferenceKey({ ...owner, externalSshTargetId: 'ssh' })
|
||||
writeCsvColumnWidths(local, { 0: 180, 2: 300 })
|
||||
expect(readCsvColumnWidths(local)).toEqual({ 0: 180, 2: 300 })
|
||||
expect(readCsvColumnWidths(paired)).toEqual({})
|
||||
expect(readCsvColumnWidths(ssh)).toEqual({})
|
||||
writeCsvColumnWidths(local, {})
|
||||
expect(readCsvColumnWidths(local)).toEqual({})
|
||||
})
|
||||
|
||||
it('ignores corrupted indexes, malformed values and unsupported widths', () => {
|
||||
localStorage.setItem(
|
||||
CSV_COLUMN_WIDTHS_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
file: {
|
||||
'0': 160,
|
||||
'1': '80',
|
||||
'-1': 160,
|
||||
'02': 160,
|
||||
'2.5': 160,
|
||||
'4096': 160,
|
||||
'3': 20,
|
||||
'4': 1300,
|
||||
'5': null
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(readCsvColumnWidths('file')).toEqual({ 0: 160 })
|
||||
localStorage.setItem(CSV_COLUMN_WIDTHS_STORAGE_KEY, 'invalid json')
|
||||
expect(readCsvColumnWidths('file')).toEqual({})
|
||||
})
|
||||
|
||||
it('bounds retained file preferences and tolerates storage failure', () => {
|
||||
for (let index = 0; index <= 100; index += 1) {
|
||||
writeCsvColumnWidths(`file-${index}`, { 0: 160 })
|
||||
}
|
||||
expect(readCsvColumnWidths('file-0')).toEqual({})
|
||||
expect(readCsvColumnWidths('file-100')).toEqual({ 0: 160 })
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('quota')
|
||||
})
|
||||
expect(() => writeCsvColumnWidths('file', { 0: 180 })).not.toThrow()
|
||||
})
|
||||
|
||||
it('moves widths with column identity through insertion, deletion and reordering', () => {
|
||||
const widths = { 0: 180, 2: 300 }
|
||||
expect(remapCsvColumnWidths(widths, 3, { kind: 'insert-column', at: 1, label: '' })).toEqual({
|
||||
0: 180,
|
||||
3: 300
|
||||
})
|
||||
expect(remapCsvColumnWidths(widths, 3, { kind: 'delete-columns', columns: [1] })).toEqual({
|
||||
0: 180,
|
||||
1: 300
|
||||
})
|
||||
expect(remapCsvColumnWidths(widths, 3, { kind: 'move-column', from: 2, to: 0 })).toEqual({
|
||||
0: 300,
|
||||
1: 180
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { CSV_MAX_COLUMNS } from './csv-byte-index'
|
||||
import { CSV_MAX_COLUMN_WIDTH, CSV_MIN_COLUMN_WIDTH } from './csv-column-width-limits'
|
||||
import { readFileViewPreference, writeFileViewPreference } from '../file-view-preference-storage'
|
||||
import type { CsvTableMutation } from './csv-text-document'
|
||||
|
||||
export const CSV_COLUMN_WIDTHS_STORAGE_KEY = 'orca.csv.column-widths.v1'
|
||||
export type CsvColumnWidths = Record<number, number>
|
||||
|
||||
export function readCsvColumnWidths(fileKey: string): CsvColumnWidths {
|
||||
const stored = readFileViewPreference(CSV_COLUMN_WIDTHS_STORAGE_KEY, fileKey)
|
||||
const result: CsvColumnWidths = {}
|
||||
if (typeof stored !== 'object' || stored === null || Array.isArray(stored)) {
|
||||
return result
|
||||
}
|
||||
for (const [key, width] of Object.entries(stored)) {
|
||||
const index = Number(key)
|
||||
if (
|
||||
Number.isInteger(index) &&
|
||||
String(index) === key &&
|
||||
index >= 0 &&
|
||||
index < CSV_MAX_COLUMNS &&
|
||||
typeof width === 'number' &&
|
||||
Number.isFinite(width) &&
|
||||
width >= CSV_MIN_COLUMN_WIDTH &&
|
||||
width <= CSV_MAX_COLUMN_WIDTH
|
||||
) {
|
||||
result[index] = width
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function writeCsvColumnWidths(fileKey: string, widths: CsvColumnWidths): void {
|
||||
writeFileViewPreference(CSV_COLUMN_WIDTHS_STORAGE_KEY, fileKey, widths)
|
||||
}
|
||||
|
||||
export function remapCsvColumnWidths(
|
||||
widths: CsvColumnWidths,
|
||||
count: number,
|
||||
mutation: CsvTableMutation
|
||||
): CsvColumnWidths {
|
||||
const columns = Array.from({ length: count }, (_, index) => index)
|
||||
if (mutation.kind === 'insert-column') {
|
||||
columns.splice(mutation.at, 0, -1)
|
||||
} else if (mutation.kind === 'delete-columns') {
|
||||
const deleted = new Set(mutation.columns)
|
||||
return Object.fromEntries(
|
||||
columns
|
||||
.filter((column) => !deleted.has(column))
|
||||
.flatMap((column, index) => (widths[column] === undefined ? [] : [[index, widths[column]]]))
|
||||
)
|
||||
} else if (mutation.kind === 'move-column') {
|
||||
columns.splice(mutation.to, 0, columns.splice(mutation.from, 1)[0]!)
|
||||
} else {
|
||||
return widths
|
||||
}
|
||||
return Object.fromEntries(
|
||||
columns.flatMap((column, index) =>
|
||||
widths[column] === undefined ? [] : [[index, widths[column]]]
|
||||
)
|
||||
)
|
||||
}
|
||||
+4
-2
@@ -4,10 +4,12 @@ import {
|
||||
statRuntimeReadTarget,
|
||||
type RuntimeFileSnapshot
|
||||
} from '@/runtime/runtime-file-range-client'
|
||||
import type { FileContent } from './editor-panel-content-types'
|
||||
import type { FileContent } from '../editor-panel-content-types'
|
||||
|
||||
import { CSV_PAGED_PREVIEW_BYTES } from './csv-file-limits'
|
||||
export { CSV_PAGED_PREVIEW_BYTES } from './csv-file-limits'
|
||||
|
||||
export type CsvFilePreview = { readArgs: RuntimeFileReadArgs; snapshot: RuntimeFileSnapshot }
|
||||
export const CSV_PAGED_PREVIEW_BYTES = 1024 * 1024
|
||||
|
||||
export async function readEditorCsvFileContent(
|
||||
args: RuntimeFileReadArgs,
|
||||
@@ -0,0 +1 @@
|
||||
export const CSV_PAGED_PREVIEW_BYTES = 1024 * 1024
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ClipboardEvent, KeyboardEvent } from 'react'
|
||||
import type { CsvCellPosition, CsvCellSelection } from './csv-cell-selection'
|
||||
|
||||
export type CsvCellEditSession = {
|
||||
position: CsvCellPosition
|
||||
value: string
|
||||
sourceRow: number
|
||||
source: string
|
||||
wasDirty: boolean
|
||||
}
|
||||
|
||||
export type CsvGridInteraction = {
|
||||
selection: CsvCellSelection | null
|
||||
focusVersion: number
|
||||
ownerId: string
|
||||
editing: { position: CsvCellPosition; value: string } | null
|
||||
select: (row: number, column: number, extend: boolean) => boolean
|
||||
edit: (row: number, column: number) => void
|
||||
change: (value: string) => void
|
||||
commit: () => boolean
|
||||
cancel: () => void
|
||||
save: () => void
|
||||
keyDown: (event: KeyboardEvent<HTMLDivElement>) => void
|
||||
copy: (event: ClipboardEvent<HTMLDivElement>) => void
|
||||
paste: (event: ClipboardEvent<HTMLDivElement>) => void
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { KeyboardEvent } from 'react'
|
||||
import type { CsvCellSelection } from './csv-cell-selection'
|
||||
|
||||
export function csvGridKeyDown(
|
||||
event: KeyboardEvent<HTMLDivElement>,
|
||||
{
|
||||
selection,
|
||||
columns,
|
||||
count,
|
||||
copy,
|
||||
paste,
|
||||
save,
|
||||
edit,
|
||||
clear,
|
||||
cancel,
|
||||
select,
|
||||
selectAll
|
||||
}: {
|
||||
selection: CsvCellSelection | null
|
||||
columns: number
|
||||
count: number
|
||||
copy: () => Promise<void>
|
||||
paste: () => Promise<void>
|
||||
save: () => Promise<void>
|
||||
edit: (row: number, column: number) => void
|
||||
clear: () => void
|
||||
cancel: () => void
|
||||
select: (row: number, column: number, extend: boolean) => boolean
|
||||
selectAll: (selection: CsvCellSelection) => void
|
||||
}
|
||||
): void {
|
||||
if (
|
||||
event.target !== event.currentTarget ||
|
||||
event.nativeEvent.isComposing ||
|
||||
event.defaultPrevented
|
||||
) {
|
||||
return
|
||||
}
|
||||
const modifier = navigator.userAgent.includes('Mac') ? event.metaKey : event.ctrlKey
|
||||
if (modifier && ['c', 'v', 's', 'a'].includes(event.key.toLowerCase())) {
|
||||
event.preventDefault()
|
||||
if (event.key.toLowerCase() === 'c') {
|
||||
void copy()
|
||||
}
|
||||
if (event.key.toLowerCase() === 'v') {
|
||||
void paste()
|
||||
}
|
||||
if (event.key.toLowerCase() === 's') {
|
||||
void save()
|
||||
}
|
||||
if (event.key.toLowerCase() === 'a' && columns) {
|
||||
selectAll({
|
||||
anchor: { row: 0, column: 0 },
|
||||
focus: { row: count, column: columns - 1 }
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
const active = selection?.focus ?? { row: 0, column: 0 }
|
||||
if (event.key === 'Enter' || event.key === 'F2') {
|
||||
event.preventDefault()
|
||||
edit(active.row, active.column)
|
||||
} else if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
event.preventDefault()
|
||||
clear()
|
||||
} else if (event.key === 'Escape') {
|
||||
cancel()
|
||||
} else if (
|
||||
['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'Tab'].includes(event.key)
|
||||
) {
|
||||
event.preventDefault()
|
||||
let row = active.row
|
||||
let column = active.column
|
||||
if (event.key === 'ArrowUp') {
|
||||
row -= 1
|
||||
}
|
||||
if (event.key === 'ArrowDown') {
|
||||
row += 1
|
||||
}
|
||||
if (event.key === 'ArrowLeft') {
|
||||
column -= 1
|
||||
}
|
||||
if (event.key === 'ArrowRight') {
|
||||
column += 1
|
||||
}
|
||||
if (event.key === 'Home') {
|
||||
column = 0
|
||||
if (modifier) {
|
||||
row = 0
|
||||
}
|
||||
}
|
||||
if (event.key === 'End') {
|
||||
column = columns - 1
|
||||
if (modifier) {
|
||||
row = count
|
||||
}
|
||||
}
|
||||
if (event.key === 'Tab') {
|
||||
column += event.shiftKey ? -1 : 1
|
||||
if (column >= columns) {
|
||||
column = 0
|
||||
row += 1
|
||||
}
|
||||
if (column < 0) {
|
||||
column = columns - 1
|
||||
row -= 1
|
||||
}
|
||||
}
|
||||
select(
|
||||
Math.max(0, Math.min(count, row)),
|
||||
Math.max(0, Math.min(columns - 1, column)),
|
||||
event.shiftKey && event.key !== 'Tab'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export type CsvInspectionSort = { column: number; direction: 'ascending' | 'descending' }
|
||||
|
||||
export function csvInspectionRows(
|
||||
rows: readonly string[][],
|
||||
filter: string,
|
||||
sort: CsvInspectionSort | null
|
||||
): number[] | null {
|
||||
if (!filter && !sort) {
|
||||
return null
|
||||
}
|
||||
const query = filter.toLocaleLowerCase()
|
||||
const result: number[] = []
|
||||
for (let row = 1; row < rows.length; row += 1) {
|
||||
if (!query || rows[row]!.some((cell) => cell.toLocaleLowerCase().includes(query))) {
|
||||
result.push(row)
|
||||
}
|
||||
}
|
||||
if (sort) {
|
||||
const collator = new Intl.Collator(undefined, { numeric: true })
|
||||
const direction = sort.direction === 'ascending' ? 1 : -1
|
||||
result.sort(
|
||||
(a, b) =>
|
||||
direction * collator.compare(rows[a]?.[sort.column] ?? '', rows[b]?.[sort.column] ?? '') ||
|
||||
a - b
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function csvSourceRow(viewRow: number, inspectionRows: readonly number[] | null): number {
|
||||
return viewRow === 0 ? 0 : inspectionRows ? (inspectionRows[viewRow - 1] ?? -1) : viewRow
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { getConnectionIdForFile } from '@/lib/connection-context'
|
||||
import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing'
|
||||
import { resolveMarkdownPreviewHttpOpenOptions } from './markdown-preview-links'
|
||||
import { resolveMarkdownPreviewHttpOpenOptions } from '../markdown-preview-links'
|
||||
|
||||
export function openCsvHttpLink(
|
||||
url: string,
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { MAX_FILE_RANGE_READ_BYTES } from '../../../../shared/file-range-read'
|
||||
import { MAX_FILE_RANGE_READ_BYTES } from '../../../../../shared/file-range-read'
|
||||
import { readRuntimeFileRange, statRuntimeReadTarget } from '@/runtime/runtime-file-range-client'
|
||||
import type { CsvFilePreview } from './editor-csv-file-content'
|
||||
import type { CsvFilePreview } from './csv-file-content'
|
||||
import {
|
||||
CSV_MAX_PAGE_BYTES,
|
||||
csvPageForRow,
|
||||
+11
-1
@@ -6,6 +6,9 @@ export type CsvParseLimits = {
|
||||
maxColumns?: number
|
||||
maxRecordLength?: number
|
||||
stripBom?: boolean
|
||||
retainRows?: boolean
|
||||
onField?: (start: number, end: number) => void
|
||||
onRecord?: (start: number, contentEnd: number, end: number) => void
|
||||
}
|
||||
|
||||
export function parseCsv(
|
||||
@@ -17,6 +20,7 @@ export function parseCsv(
|
||||
let row: string[] = []
|
||||
let start = limits.stripBom !== false && source.charCodeAt(0) === 0xfeff ? 1 : 0
|
||||
let recordStart = start
|
||||
let fieldStart = start
|
||||
let parts: string[] = []
|
||||
let fieldLength = 0
|
||||
let inQuotes = false
|
||||
@@ -40,9 +44,11 @@ export function parseCsv(
|
||||
} else {
|
||||
row.push(source.slice(start, end))
|
||||
}
|
||||
limits.onField?.(fieldStart, end)
|
||||
parts = []
|
||||
fieldLength = 0
|
||||
start = end + 1
|
||||
fieldStart = start
|
||||
}
|
||||
const pushRow = (end: number, next: number): void => {
|
||||
if (end - recordStart > (limits.maxRecordLength ?? Infinity)) {
|
||||
@@ -50,11 +56,15 @@ export function parseCsv(
|
||||
}
|
||||
pushField(end)
|
||||
maxColumns = Math.max(maxColumns, row.length)
|
||||
rows.push(row)
|
||||
if (limits.retainRows !== false) {
|
||||
rows.push(row)
|
||||
}
|
||||
limits.onRecord?.(recordStart, end, next)
|
||||
row = []
|
||||
hasContent = false
|
||||
start = next
|
||||
recordStart = next
|
||||
fieldStart = next
|
||||
}
|
||||
for (let i = start; i < source.length; i += 1) {
|
||||
const ch = source[i]
|
||||
@@ -0,0 +1,127 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { createEditorSaveQueue } from '../editor-save-queue'
|
||||
import { registerPendingEditorFlush } from '../editor-pending-flush'
|
||||
import {
|
||||
createEditorStore,
|
||||
stubEditorWindowWithDisk
|
||||
} from '../editor-autosave-controller-test-fixture'
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
function setup() {
|
||||
const disk = stubEditorWindowWithDisk()
|
||||
const store = createEditorStore()
|
||||
store.getState().openFile({
|
||||
filePath: '/repo/data.csv',
|
||||
relativePath: 'data.csv',
|
||||
worktreeId: 'wt-1',
|
||||
mode: 'edit',
|
||||
language: 'plaintext'
|
||||
})
|
||||
const file = store.getState().openFiles[0]!
|
||||
store.getState().setEditorDraft(file.id, 'header\nprevious')
|
||||
store.getState().markFileDirty(file.id, true)
|
||||
const queue = createEditorSaveQueue(store)
|
||||
return { disk, store, file, queue }
|
||||
}
|
||||
|
||||
it('autosave flushes a legacy editor and reads its updated draft before writing', async () => {
|
||||
const { disk, store, file, queue } = setup()
|
||||
const unregister = registerPendingEditorFlush(file.id, () =>
|
||||
store.getState().setEditorDraft(file.id, 'header\nlatest')
|
||||
)
|
||||
try {
|
||||
await queue.queueSave(file, 'header\nstale', 'autosave')
|
||||
expect(disk.files.get(file.filePath)).toBe('header\nlatest')
|
||||
expect(store.getState().openFiles[0]?.isDirty).toBe(false)
|
||||
} finally {
|
||||
unregister()
|
||||
queue.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('a failed pending edit prevents the write and keeps the existing dirty draft', async () => {
|
||||
const { disk, store, file, queue } = setup()
|
||||
const unregister = registerPendingEditorFlush(file.id, () => {
|
||||
throw new Error('invalid pending cell')
|
||||
})
|
||||
try {
|
||||
await expect(queue.queueSave(file, 'fallback')).rejects.toThrow('invalid pending cell')
|
||||
expect(disk.fs.writeFile).not.toHaveBeenCalled()
|
||||
expect(store.getState().editorDrafts[file.id]).toBe('header\nprevious')
|
||||
expect(store.getState().openFiles[0]?.isDirty).toBe(true)
|
||||
} finally {
|
||||
unregister()
|
||||
queue.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('a suspended autosave does not flush a pending cell or write the file', async () => {
|
||||
const { disk, store, file, queue } = setup()
|
||||
store.getState().setExternalMutation(file.id, 'changed')
|
||||
const flush = vi.fn()
|
||||
const unregister = registerPendingEditorFlush(file.id, flush)
|
||||
try {
|
||||
await queue.queueSave(file, 'fallback', 'autosave')
|
||||
expect(flush).not.toHaveBeenCalled()
|
||||
expect(disk.fs.writeFile).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
unregister()
|
||||
queue.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a newer pending cell dirty when an earlier remote save completes', async () => {
|
||||
const { disk, store, file, queue } = setup()
|
||||
let releaseWrite: () => void = () => {
|
||||
throw new Error('write not started')
|
||||
}
|
||||
disk.fs.writeFile.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseWrite = resolve
|
||||
})
|
||||
)
|
||||
let pending = false
|
||||
const unregister = registerPendingEditorFlush(
|
||||
file.id,
|
||||
() => {},
|
||||
() => pending
|
||||
)
|
||||
try {
|
||||
const saving = queue.queueSave(file, 'fallback')
|
||||
await vi.waitFor(() => expect(disk.fs.writeFile).toHaveBeenCalledTimes(1))
|
||||
pending = true
|
||||
releaseWrite()
|
||||
await saving
|
||||
expect(store.getState().openFiles[0]?.isDirty).toBe(true)
|
||||
expect(store.getState().editorDrafts[file.id]).toBe('header\nprevious')
|
||||
} finally {
|
||||
unregister()
|
||||
queue.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'autosave leaves an active CSV input alone (changed: %s)',
|
||||
async (changed) => {
|
||||
const { disk, store, file, queue } = setup()
|
||||
const flush = vi.fn(() => {
|
||||
throw new Error('rejected cell')
|
||||
})
|
||||
const unregister = registerPendingEditorFlush(file.id, flush, () => changed)
|
||||
try {
|
||||
await queue.queueSave(file, 'fallback', 'autosave')
|
||||
expect(flush).not.toHaveBeenCalled()
|
||||
expect(disk.files.get(file.filePath)).toBe('header\nprevious')
|
||||
expect(store.getState().openFiles[0]?.isDirty).toBe(changed)
|
||||
if (changed) {
|
||||
await queue.queueSave(file, 'fallback', 'autosave')
|
||||
expect(disk.fs.writeFile).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
} finally {
|
||||
unregister()
|
||||
queue.dispose()
|
||||
}
|
||||
}
|
||||
)
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { createEditorStore } from '@/store/slices/editor-slice-test-harness'
|
||||
import { createEditorSaveQueue } from './editor-save-queue'
|
||||
import { canAutoSaveOpenFile } from './editor-autosave'
|
||||
import { createEditorSaveQueue } from '../editor-save-queue'
|
||||
import { canAutoSaveOpenFile } from '../editor-autosave'
|
||||
|
||||
const { writeFile } = vi.hoisted(() => ({ writeFile: vi.fn() }))
|
||||
vi.mock('@/runtime/runtime-file-client', () => ({ writeRuntimeFile: writeFile }))
|
||||
@@ -0,0 +1,30 @@
|
||||
export function serializeCsvCell(value: string, delimiter: string, quoted = false): string {
|
||||
return quoted || /["\r\n]/.test(value) || value.includes(delimiter) || value.startsWith('\ufeff')
|
||||
? `"${value.replaceAll('"', '""')}"`
|
||||
: value
|
||||
}
|
||||
|
||||
export function serializeCsvRecord(
|
||||
values: readonly string[],
|
||||
delimiter: string,
|
||||
originalValues: readonly string[] = [],
|
||||
originalTokens: readonly string[] = []
|
||||
): string {
|
||||
return values
|
||||
.map((value, index) => {
|
||||
const token = originalTokens[index]
|
||||
const quoteSingleField = values.length === 1 && (value === '' || /[,;\t]/.test(value))
|
||||
const validToken =
|
||||
token !== undefined &&
|
||||
(token.startsWith('"') ? /^"(?:[^"]|"")*"$/.test(token) : !/["\r\n]/.test(token))
|
||||
if (
|
||||
value === originalValues[index] &&
|
||||
validToken &&
|
||||
!(quoteSingleField && !token?.startsWith('"'))
|
||||
) {
|
||||
return token
|
||||
}
|
||||
return serializeCsvCell(value, delimiter, token?.startsWith('"') || quoteSingleField)
|
||||
})
|
||||
.join(delimiter)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user