Refactor lower max-lines modules (#16760)

This commit is contained in:
Neil
2026-08-27 16:10:51 -07:00
committed by GitHub
parent bb77a2b159
commit 7ee8b5e1a6
128 changed files with 6818 additions and 6360 deletions
-22
View File
@@ -2,8 +2,6 @@
# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —
# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").
# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)
inline src/cli/handlers/automations.ts
inline src/cli/help.ts
inline src/main/agent-hooks/server.ts
inline src/main/automations/external-manager.ts
inline src/main/automations/hermes-cron-output.ts
@@ -12,7 +10,6 @@ inline src/main/browser/browser-cookie-import.ts
inline src/main/browser/browser-manager.ts
inline src/main/browser/cdp-bridge.ts
inline src/main/claude-accounts/runtime-auth-service.ts
inline src/main/cli/wsl-cli-installer.ts
inline src/main/codex-accounts/runtime-home-service.ts
inline src/main/codex-accounts/service.ts
inline src/main/codex/hook-service.ts
@@ -23,14 +20,10 @@ inline src/main/index.ts
inline src/main/ipc/filesystem-watcher.ts
inline src/main/ipc/filesystem.ts
inline src/main/ipc/worktree-remote.ts
inline src/main/keybindings/keybinding-file.ts
inline src/main/linear/issues.ts
inline src/main/memory/collector.ts
inline src/main/ports/advertised-url-watcher.ts
inline src/main/ports/local-workspace-port-scanner.ts
inline src/main/project-groups/nested-repo-discovery.ts
inline src/main/providers/local-pty-provider.ts
inline src/main/rate-limits/claude-pty.ts
inline src/main/rate-limits/service.ts
inline src/main/runtime/orca-runtime-browser.ts
inline src/main/runtime/orca-runtime-files.ts
@@ -69,7 +62,6 @@ inline src/renderer/src/components/right-sidebar/PortsPanel.tsx
inline src/renderer/src/components/settings/AccountsPane.tsx
inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
inline src/renderer/src/components/settings/Settings.tsx
inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts
inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
inline src/renderer/src/components/status-bar/StatusBar.tsx
inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
@@ -82,24 +74,16 @@ inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts
inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts
inline src/renderer/src/runtime/runtime-file-client.ts
inline src/renderer/src/runtime/runtime-linear-client.ts
inline src/renderer/src/runtime/sync-runtime-graph.ts
inline src/renderer/src/runtime/web-runtime-session.ts
inline src/renderer/src/runtime/web-session-tabs-sync.ts
inline src/renderer/src/store/slices/agent-status.ts
inline src/renderer/src/store/slices/browser.ts
inline src/renderer/src/store/slices/diffComments.ts
inline src/renderer/src/store/slices/hosted-review.ts
inline src/renderer/src/store/slices/linear.ts
inline src/renderer/src/store/slices/tabs.ts
inline src/renderer/src/store/slices/ui.ts
inline src/shared/automation-schedules.ts
inline src/shared/commit-message-agent-spec.ts
inline src/shared/constants.ts
inline src/shared/keybindings.ts
inline src/shared/marine-creatures.ts
inline src/shared/telemetry-events.ts
inline src/shared/text-search.ts
inline tests/e2e/helpers/terminal.ts
mobile-config app/h/*/files/*.tsx
mobile-config app/h/*/index.tsx
@@ -107,15 +91,9 @@ mobile-config app/h/*/session/*.tsx
mobile-config app/h/*/source-control/*.tsx
mobile-config app/h/*/tasks.tsx
mobile-config app/index.tsx
mobile-config app/pair-scan.tsx
mobile-config app/troubleshoot.tsx
mobile-config scripts/mock-server.ts
mobile-config scripts/repro-terminal-colors.ts
mobile-config scripts/repro-worktree-startup-stream.ts
mobile-config src/browser/MobileBrowserPane.tsx
mobile-config src/components/CustomKeyModal.tsx
mobile-config src/components/NewWorktreeModal.tsx
mobile-config src/components/mobile-rich-markdown-editor-html.ts
mobile-config src/terminal/terminal-accessory-keys.ts
mobile-config src/terminal/terminal-webview-html.ts
mobile-config src/transport/rpc-client.ts
-36
View File
@@ -75,54 +75,18 @@
"max-lines": ["error", { "max": 1074, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["src/components/mobile-rich-markdown-editor-html.ts"],
"rules": {
"max-lines": ["error", { "max": 648, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["src/components/CustomKeyModal.tsx"],
"rules": {
"max-lines": ["error", { "max": 645, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/pair-scan.tsx"],
"rules": {
"max-lines": ["error", { "max": 531, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["scripts/mock-server.ts"],
"rules": {
"max-lines": ["error", { "max": 407, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["src/terminal/terminal-accessory-keys.ts"],
"rules": {
"max-lines": ["error", { "max": 388, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/troubleshoot.tsx"],
"rules": {
"max-lines": ["error", { "max": 436, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["scripts/repro-worktree-startup-stream.ts"],
"rules": {
"max-lines": ["error", { "max": 326, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["scripts/repro-terminal-colors.ts"],
"rules": {
"max-lines": ["error", { "max": 319, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/h/*/files/*.tsx"],
"rules": {
+2 -185
View File
@@ -2,7 +2,6 @@ import { useState, useRef, useCallback } from 'react'
import {
View,
Text,
StyleSheet,
Pressable,
ActivityIndicator,
Linking,
@@ -19,13 +18,14 @@ import {
} from '../src/transport/pre-profile-pairing-coordinator'
import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types'
import { useRefreshHostClient } from '../src/transport/client-context'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { colors, spacing } from '../src/theme/mobile-theme'
import { TextInputModal } from '../src/components/TextInputModal'
import { ConnectionLog } from '../src/components/ConnectionLog'
import {
loadMobileOnboardingSteps,
mobileOnboardingDestination
} from '../src/onboarding/mobile-onboarding-plan'
import { pairScanStyles as styles } from '../src/pair-scan-styles'
// Why: see pair-confirm.tsx — cap initial-pair "Connecting…" so a broken
// route surfaces as a real error with the log visible instead of a
@@ -356,186 +356,3 @@ export default function PairScanScreen() {
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase,
padding: spacing.lg
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.sm
},
steps: {
gap: spacing.sm,
marginBottom: spacing.lg,
marginLeft: 7
},
step: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
stepBadge: {
width: 22,
height: 22,
borderRadius: 11,
backgroundColor: colors.bgRaised,
alignItems: 'center',
justifyContent: 'center'
},
stepNumber: {
fontSize: 12,
fontWeight: '700',
color: colors.textSecondary
},
stepText: {
fontSize: typography.bodySize,
color: colors.textSecondary
},
cameraWrap: {
flex: 1,
borderRadius: radii.camera,
overflow: 'hidden'
},
// Why: holds the layout slot while the camera is unmounted during
// paste, so the bottom action button doesn't snap up to fill the
// empty space.
cameraPlaceholder: {
flex: 1,
backgroundColor: colors.bgPanel,
borderRadius: radii.camera
},
camera: {
...StyleSheet.absoluteFillObject
},
reticle: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center'
},
reticleFrame: {
position: 'relative'
},
corner: {
position: 'absolute',
width: 28,
height: 28,
borderColor: 'rgba(255,255,255,0.7)'
},
cornerTL: {
top: 0,
left: 0,
borderTopWidth: 2.5,
borderLeftWidth: 2.5,
borderTopLeftRadius: 6
},
cornerTR: {
top: 0,
right: 0,
borderTopWidth: 2.5,
borderRightWidth: 2.5,
borderTopRightRadius: 6
},
cornerBL: {
bottom: 0,
left: 0,
borderBottomWidth: 2.5,
borderLeftWidth: 2.5,
borderBottomLeftRadius: 6
},
cornerBR: {
bottom: 0,
right: 0,
borderBottomWidth: 2.5,
borderRightWidth: 2.5,
borderBottomRightRadius: 6
},
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center'
},
title: {
fontSize: typography.titleSize,
fontWeight: '600',
color: colors.textPrimary,
marginBottom: spacing.sm
},
subtitle: {
maxWidth: 310,
fontSize: typography.bodySize,
color: colors.textSecondary,
textAlign: 'center',
marginBottom: spacing.xl,
lineHeight: 20
},
connectingText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
marginTop: spacing.lg
},
logSlot: {
width: '100%',
marginTop: spacing.lg,
paddingHorizontal: spacing.sm
},
errorText: {
color: colors.statusRed,
fontSize: typography.bodySize,
textAlign: 'center',
marginBottom: spacing.xl,
lineHeight: 20
},
primaryButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
backgroundColor: colors.textPrimary,
paddingHorizontal: spacing.xl,
paddingVertical: spacing.sm + 2,
borderRadius: radii.button
},
primaryButtonText: {
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '600'
},
pasteButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
marginTop: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radii.button
},
pasteButtonPressed: {
opacity: 0.6
},
pasteButtonText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '500'
},
errorActions: {
alignItems: 'center',
gap: spacing.sm
},
secondaryButton: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.sm,
borderRadius: radii.button
},
secondaryButtonText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '500'
}
})
@@ -0,0 +1,60 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
const ESC = String.fromCharCode(27)
export type TerminalColorSnapshot = {
label: string
handle: string
cols: number | null
rows: number | null
serialized: string
lines: string
}
function countMatches(value: string, pattern: RegExp): number {
return value.match(pattern)?.length ?? 0
}
export function summarizeTerminalColorSnapshot(
snapshot: TerminalColorSnapshot
): Record<string, string | number | null> {
const data = snapshot.serialized
return {
label: snapshot.label,
handle: snapshot.handle,
cols: snapshot.cols,
rows: snapshot.rows,
serializedBytes: Buffer.byteLength(data),
sgrTotal: countMatches(data, new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')),
sgrColor: countMatches(
data,
new RegExp(
`${ESC}\\[(?:[0-9;:]*[;:])?(?:3[0-7]|4[0-7]|9[0-7]|10[0-7]|38[;:]|48[;:])[0-9;:]*m`,
'g'
)
),
sgrReset: countMatches(data, new RegExp(`${ESC}\\[(?:0|39|49|0;39;49)m`, 'g')),
altScreen: data.includes(`${ESC}[?1049h`) ? 'yes' : 'no',
containsTruecolor: data.includes('38;2') || data.includes('38:2') ? 'yes' : 'no',
containsPaletteColor: data.includes('38;5') || data.includes('38:5') ? 'yes' : 'no'
}
}
export function saveTerminalColorSnapshots(snapshots: TerminalColorSnapshot[]): string {
const dir = join(process.cwd(), 'terminal-color-repro')
mkdirSync(dir, { recursive: true })
for (const snapshot of snapshots) {
const base = snapshot.label.replace(/[^a-z0-9_-]/gi, '-')
writeFileSync(join(dir, `${base}.ansi`), snapshot.serialized || snapshot.lines)
writeFileSync(
join(dir, `${base}.escaped.txt`),
JSON.stringify(snapshot.serialized || snapshot.lines)
)
}
writeFileSync(
join(dir, 'summary.json'),
JSON.stringify(snapshots.map(summarizeTerminalColorSnapshot), null, 2)
)
return dir
}
+17 -59
View File
@@ -10,10 +10,13 @@
* ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 \
* pnpm exec tsx scripts/repro-terminal-colors.ts <deviceToken> <serverPublicKeyB64> <worktreeSelector> [handleA] [handleB]
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import nacl from 'tweetnacl'
import WebSocket from 'ws'
import {
saveTerminalColorSnapshots,
summarizeTerminalColorSnapshot,
type TerminalColorSnapshot
} from './repro-terminal-color-output'
const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768'
const token = process.argv[2]
@@ -21,7 +24,6 @@ const serverPublicKeyB64 = process.argv[3]
const worktreeSelector = process.argv[4]
const explicitHandleA = process.argv[5]
const explicitHandleB = process.argv[6]
const ESC = String.fromCharCode(27)
type RpcResponse = {
id: string
@@ -37,15 +39,6 @@ type PendingRequest = {
reject: (error: Error) => void
}
type Snapshot = {
label: string
handle: string
cols: number | null
rows: number | null
serialized: string
lines: string
}
if (!token || !serverPublicKeyB64 || !worktreeSelector) {
console.error(
'Usage: pnpm exec tsx scripts/repro-terminal-colors.ts <deviceToken> <serverPublicKeyB64> <worktreeSelector> [handleA] [handleB]'
@@ -158,9 +151,13 @@ async function ensureSecondHandle(ws: WebSocket, handleA: string): Promise<strin
return handle
}
async function captureSnapshot(ws: WebSocket, label: string, handle: string): Promise<Snapshot> {
async function captureSnapshot(
ws: WebSocket,
label: string,
handle: string
): Promise<TerminalColorSnapshot> {
const id = nextId()
const snapshot = await new Promise<Snapshot>((resolve, reject) => {
const snapshot = await new Promise<TerminalColorSnapshot>((resolve, reject) => {
const timeout = setTimeout(() => {
pending.delete(id)
streamListeners.delete(id)
@@ -207,48 +204,6 @@ async function captureSnapshot(ws: WebSocket, label: string, handle: string): Pr
return snapshot
}
function countMatches(value: string, pattern: RegExp): number {
return value.match(pattern)?.length ?? 0
}
function summarize(snapshot: Snapshot): Record<string, string | number | null> {
const data = snapshot.serialized
return {
label: snapshot.label,
handle: snapshot.handle,
cols: snapshot.cols,
rows: snapshot.rows,
serializedBytes: Buffer.byteLength(data),
sgrTotal: countMatches(data, new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')),
sgrColor: countMatches(
data,
new RegExp(
`${ESC}\\[(?:[0-9;:]*[;:])?(?:3[0-7]|4[0-7]|9[0-7]|10[0-7]|38[;:]|48[;:])[0-9;:]*m`,
'g'
)
),
sgrReset: countMatches(data, new RegExp(`${ESC}\\[(?:0|39|49|0;39;49)m`, 'g')),
altScreen: data.includes(`${ESC}[?1049h`) ? 'yes' : 'no',
containsTruecolor: data.includes('38;2') || data.includes('38:2') ? 'yes' : 'no',
containsPaletteColor: data.includes('38;5') || data.includes('38:5') ? 'yes' : 'no'
}
}
function saveSnapshots(snapshots: Snapshot[]): string {
const dir = join(process.cwd(), 'terminal-color-repro')
mkdirSync(dir, { recursive: true })
for (const snapshot of snapshots) {
const base = snapshot.label.replace(/[^a-z0-9_-]/gi, '-')
writeFileSync(join(dir, `${base}.ansi`), snapshot.serialized || snapshot.lines)
writeFileSync(
join(dir, `${base}.escaped.txt`),
JSON.stringify(snapshot.serialized || snapshot.lines)
)
}
writeFileSync(join(dir, 'summary.json'), JSON.stringify(snapshots.map(summarize), null, 2))
return dir
}
async function run(ws: WebSocket): Promise<void> {
ws.send(
JSON.stringify({
@@ -303,11 +258,14 @@ async function run(ws: WebSocket): Promise<void> {
const firstB = await captureSnapshot(ws, 'b-during-switch', handleB)
const secondA = await captureSnapshot(ws, 'a-after-switch', handleA)
const snapshots = [firstA, firstB, secondA]
const dir = saveSnapshots(snapshots)
const dir = saveTerminalColorSnapshots(snapshots)
console.table(snapshots.map(summarize))
console.table(snapshots.map(summarizeTerminalColorSnapshot))
console.log(`saved: ${dir}`)
if (summarize(firstA).sgrColor !== summarize(secondA).sgrColor) {
if (
summarizeTerminalColorSnapshot(firstA).sgrColor !==
summarizeTerminalColorSnapshot(secondA).sgrColor
) {
console.log(
'color SGR count changed between A snapshots: serialization/state changed on desktop'
)
@@ -0,0 +1,75 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
const ESC = String.fromCharCode(27)
export type StartupTerminalCapture = {
handle: string
title: string | null
scrollback: Record<string, unknown> | null
chunks: string[]
}
function stripAnsi(value: string): string {
return (
value
// eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
// eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
.replace(/\r/g, '\n')
)
}
function normalizePreview(value: string): string {
return stripAnsi(value)
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.slice(0, 8)
.join('\n')
}
export function summarizeStartupCapture(capture: StartupTerminalCapture): Record<string, unknown> {
const serialized =
typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
const live = capture.chunks.join('')
const serializedPreview = normalizePreview(serialized)
const livePreview = normalizePreview(live)
return {
handle: capture.handle,
title: capture.title,
cols: capture.scrollback?.cols ?? null,
rows: capture.scrollback?.rows ?? null,
serializedBytes: Buffer.byteLength(serialized),
liveBytes: Buffer.byteLength(live),
liveChunks: capture.chunks.length,
serializedSgr: (serialized.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
liveSgr: (live.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
livePreviewContainedInSerialized:
livePreview.length > 0 && stripAnsi(serialized).includes(livePreview.split('\n')[0] ?? ''),
serializedPreview,
livePreview
}
}
export function saveStartupCaptures(
captures: StartupTerminalCapture[],
worktreeName: string
): string {
const dir = join(process.cwd(), 'terminal-startup-repro', worktreeName)
mkdirSync(dir, { recursive: true })
for (const capture of captures) {
const base = capture.handle.replace(/[^a-z0-9_-]/gi, '-')
const serialized =
typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
writeFileSync(join(dir, `${base}.serialized.ansi`), serialized)
writeFileSync(join(dir, `${base}.live.ansi`), capture.chunks.join(''))
writeFileSync(join(dir, `${base}.json`), JSON.stringify(capture, null, 2))
}
writeFileSync(
join(dir, 'summary.json'),
JSON.stringify(captures.map(summarizeStartupCapture), null, 2)
)
return dir
}
@@ -4,10 +4,15 @@
* Usage:
* pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts <repoSelector> <worktreeName> [startupCommand]
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import nacl from 'tweetnacl'
import WebSocket from 'ws'
import {
saveStartupCaptures,
summarizeStartupCapture,
type StartupTerminalCapture
} from './repro-worktree-startup-output'
const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768'
const USER_DATA =
@@ -15,7 +20,6 @@ const USER_DATA =
const repoSelector = process.argv[2]
const worktreeName = process.argv[3]
const startupCommand = process.argv[4] || 'claude'
const ESC = String.fromCharCode(27)
type RpcResponse = {
id: string
@@ -36,13 +40,6 @@ type TerminalInfo = {
title: string | null
}
type Capture = {
handle: string
title: string | null
scrollback: Record<string, unknown> | null
chunks: string[]
}
if (!repoSelector || !worktreeName) {
console.error(
'Usage: pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts <repoSelector> <worktreeName> [startupCommand]'
@@ -141,26 +138,6 @@ function formatError(response: RpcResponse): string {
return JSON.stringify(response.error ?? response.result ?? response).slice(0, 1000)
}
function stripAnsi(value: string): string {
return (
value
// eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
// eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
.replace(/\r/g, '\n')
)
}
function normalizePreview(value: string): string {
return stripAnsi(value)
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.slice(0, 8)
.join('\n')
}
async function waitForTerminals(ws: WebSocket, worktreeId: string): Promise<TerminalInfo[]> {
const deadline = Date.now() + 60_000
while (Date.now() < deadline) {
@@ -185,7 +162,7 @@ async function waitForTerminals(ws: WebSocket, worktreeId: string): Promise<Term
throw new Error('Timed out waiting for startup terminals')
}
async function subscribe(ws: WebSocket, capture: Capture): Promise<void> {
async function subscribe(ws: WebSocket, capture: StartupTerminalCapture): Promise<void> {
const id = nextId()
streamListeners.set(id, (result) => {
if (result.type === 'scrollback') {
@@ -204,44 +181,6 @@ async function subscribe(ws: WebSocket, capture: Capture): Promise<void> {
})
}
function summarize(capture: Capture): Record<string, unknown> {
const serialized =
typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
const live = capture.chunks.join('')
const serializedPreview = normalizePreview(serialized)
const livePreview = normalizePreview(live)
return {
handle: capture.handle,
title: capture.title,
cols: capture.scrollback?.cols ?? null,
rows: capture.scrollback?.rows ?? null,
serializedBytes: Buffer.byteLength(serialized),
liveBytes: Buffer.byteLength(live),
liveChunks: capture.chunks.length,
serializedSgr: (serialized.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
liveSgr: (live.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
livePreviewContainedInSerialized:
livePreview.length > 0 && stripAnsi(serialized).includes(livePreview.split('\n')[0] ?? ''),
serializedPreview,
livePreview
}
}
function save(captures: Capture[], worktreeName: string): string {
const dir = join(process.cwd(), 'terminal-startup-repro', worktreeName)
mkdirSync(dir, { recursive: true })
for (const capture of captures) {
const base = capture.handle.replace(/[^a-z0-9_-]/gi, '-')
const serialized =
typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
writeFileSync(join(dir, `${base}.serialized.ansi`), serialized)
writeFileSync(join(dir, `${base}.live.ansi`), capture.chunks.join(''))
writeFileSync(join(dir, `${base}.json`), JSON.stringify(capture, null, 2))
}
writeFileSync(join(dir, 'summary.json'), JSON.stringify(captures.map(summarize), null, 2))
return dir
}
async function run(ws: WebSocket): Promise<void> {
ws.send(
JSON.stringify({
@@ -312,8 +251,8 @@ async function run(ws: WebSocket): Promise<void> {
for (const capture of captures) {
await send(ws, 'terminal.unsubscribe', { subscriptionId: capture.handle }).catch(() => null)
}
const dir = save(captures, worktreeName)
console.table(captures.map(summarize))
const dir = saveStartupCaptures(captures, worktreeName)
console.table(captures.map(summarizeStartupCapture))
console.log(`saved: ${dir}`)
}
@@ -0,0 +1,276 @@
import { StyleSheet } from 'react-native'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
export const customKeyModalStyles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
paddingBottom: spacing.sm
},
backButton: {
width: 30,
height: 30,
borderRadius: 15,
alignItems: 'center',
justifyContent: 'center'
},
backButtonPressed: {
backgroundColor: colors.bgRaised
},
backSpacer: {
width: 30
},
title: {
flex: 1,
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary,
textAlign: 'center'
},
group: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
overflow: 'hidden'
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
row: {
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
rowLabel: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary,
marginBottom: 1
},
rowHint: {
fontSize: 12,
color: colors.textMuted
},
shortcutForm: {
paddingTop: spacing.sm
},
preview: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingVertical: spacing.lg + spacing.xs,
flexWrap: 'wrap'
},
previewKeycapRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
previewPlus: {
color: colors.textMuted,
fontSize: 16
},
keycap: {
minWidth: 48,
height: 48,
paddingHorizontal: spacing.md,
borderRadius: 10,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
alignItems: 'center',
justifyContent: 'center'
},
keycapModifier: {
minWidth: 0
},
keycapWarn: {
borderColor: colors.statusAmber
},
keycapText: {
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: 17,
fontWeight: '600'
},
keycapTextWarn: {
color: colors.statusAmber
},
keycapModifierText: {
color: colors.textSecondary,
fontFamily: typography.monoFamily,
fontSize: 14,
fontWeight: '600'
},
section: {
marginTop: spacing.md
},
sectionLabel: {
fontSize: 11,
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.8,
marginBottom: spacing.sm,
paddingLeft: 2
},
mods: {
flexDirection: 'row',
gap: spacing.sm
},
chip: {
flex: 1,
height: 40,
borderRadius: 8,
backgroundColor: colors.bgPanel,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 4
},
chipSelected: {
backgroundColor: colors.textPrimary
},
chipPressed: {
backgroundColor: colors.bgRaised
},
chipText: {
color: colors.textSecondary,
fontSize: 14,
fontWeight: '500'
},
chipTextSelected: {
color: colors.bgBase
},
chipGlyph: {
color: colors.textMuted,
fontSize: 13,
fontFamily: typography.monoFamily
},
chipGlyphSelected: {
color: 'rgba(10,10,10,0.5)'
},
keyInput: {
width: '100%',
height: 56,
borderRadius: 10,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: 22,
fontWeight: '600',
textAlign: 'center'
},
moreLink: {
paddingVertical: spacing.sm,
alignItems: 'center'
},
moreLinkPressed: {
opacity: 0.6
},
moreLinkText: {
color: colors.textSecondary,
fontSize: 13,
textDecorationLine: 'underline'
},
specialKeysForm: {
paddingTop: spacing.xs,
paddingBottom: spacing.md,
gap: spacing.md
},
specialGroup: {
gap: spacing.xs
},
specialGroupTitle: {
fontSize: 11,
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.8,
paddingLeft: 2,
marginBottom: spacing.xs
},
keyGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
marginHorizontal: -spacing.xs / 2
},
keyCellWrap: {
paddingHorizontal: spacing.xs / 2,
paddingVertical: spacing.xs / 2
},
keyCell: {
height: 40,
borderRadius: 8,
backgroundColor: colors.bgPanel,
alignItems: 'center',
justifyContent: 'center'
},
keyCellPressed: {
backgroundColor: colors.bgRaised
},
keyCellSelected: {
backgroundColor: colors.textPrimary
},
keyCellText: {
fontSize: 13,
fontWeight: '600',
color: colors.textPrimary,
fontFamily: typography.monoFamily
},
keyCellTextSelected: {
color: colors.bgBase
},
macroForm: {
padding: spacing.md,
gap: spacing.sm
},
fieldLabel: {
fontSize: 13,
fontWeight: '500',
color: colors.textSecondary
},
fieldInput: {
backgroundColor: colors.bgBase,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
fontSize: 14,
fontFamily: typography.monoFamily,
borderWidth: 1,
borderColor: colors.borderSubtle
},
switchRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.xs
},
switchLabel: {
fontSize: typography.bodySize,
color: colors.textPrimary
},
saveButton: {
marginTop: spacing.md,
backgroundColor: colors.textPrimary,
paddingVertical: spacing.md,
borderRadius: 10,
alignItems: 'center'
},
saveButtonDisabled: {
backgroundColor: colors.bgRaised
},
saveButtonText: {
color: colors.bgBase,
fontSize: 15,
fontWeight: '600'
},
saveButtonTextDisabled: {
color: colors.textMuted
}
})
+3 -276
View File
@@ -1,8 +1,8 @@
import { useCallback, useMemo, useState } from 'react'
import { View, Text, Pressable, TextInput, StyleSheet, Switch } from 'react-native'
import { View, Text, Pressable, TextInput, Switch } from 'react-native'
import { ChevronLeft } from 'lucide-react-native'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
import { colors } from '../theme/mobile-theme'
import { BottomDrawer } from './BottomDrawer'
import {
buildTerminalShortcutKey,
@@ -11,6 +11,7 @@ import {
type TerminalShortcutModifier,
type TerminalShortcutSpecialKey
} from '../terminal/terminal-accessory-keys'
import { customKeyModalStyles as styles } from './CustomKeyModal.styles'
const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys'
@@ -409,277 +410,3 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
</BottomDrawer>
)
}
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
paddingBottom: spacing.sm
},
backButton: {
width: 30,
height: 30,
borderRadius: 15,
alignItems: 'center',
justifyContent: 'center'
},
backButtonPressed: {
backgroundColor: colors.bgRaised
},
backSpacer: {
width: 30
},
title: {
flex: 1,
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary,
textAlign: 'center'
},
group: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
overflow: 'hidden'
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
row: {
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
rowLabel: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary,
marginBottom: 1
},
rowHint: {
fontSize: 12,
color: colors.textMuted
},
shortcutForm: {
paddingTop: spacing.sm
},
preview: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingVertical: spacing.lg + spacing.xs,
flexWrap: 'wrap'
},
previewKeycapRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
previewPlus: {
color: colors.textMuted,
fontSize: 16
},
keycap: {
minWidth: 48,
height: 48,
paddingHorizontal: spacing.md,
borderRadius: 10,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
alignItems: 'center',
justifyContent: 'center'
},
keycapModifier: {
minWidth: 0
},
keycapWarn: {
borderColor: colors.statusAmber
},
keycapText: {
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: 17,
fontWeight: '600'
},
keycapTextWarn: {
color: colors.statusAmber
},
keycapModifierText: {
color: colors.textSecondary,
fontFamily: typography.monoFamily,
fontSize: 14,
fontWeight: '600'
},
section: {
marginTop: spacing.md
},
sectionLabel: {
fontSize: 11,
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.8,
marginBottom: spacing.sm,
paddingLeft: 2
},
mods: {
flexDirection: 'row',
gap: spacing.sm
},
chip: {
flex: 1,
height: 40,
borderRadius: 8,
backgroundColor: colors.bgPanel,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 4
},
chipSelected: {
backgroundColor: colors.textPrimary
},
chipPressed: {
backgroundColor: colors.bgRaised
},
chipText: {
color: colors.textSecondary,
fontSize: 14,
fontWeight: '500'
},
chipTextSelected: {
color: colors.bgBase
},
chipGlyph: {
color: colors.textMuted,
fontSize: 13,
fontFamily: typography.monoFamily
},
chipGlyphSelected: {
color: 'rgba(10,10,10,0.5)'
},
keyInput: {
width: '100%',
height: 56,
borderRadius: 10,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: 22,
fontWeight: '600',
textAlign: 'center'
},
moreLink: {
paddingVertical: spacing.sm,
alignItems: 'center'
},
moreLinkPressed: {
opacity: 0.6
},
moreLinkText: {
color: colors.textSecondary,
fontSize: 13,
textDecorationLine: 'underline'
},
specialKeysForm: {
paddingTop: spacing.xs,
paddingBottom: spacing.md,
gap: spacing.md
},
specialGroup: {
gap: spacing.xs
},
specialGroupTitle: {
fontSize: 11,
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.8,
paddingLeft: 2,
marginBottom: spacing.xs
},
keyGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
marginHorizontal: -spacing.xs / 2
},
keyCellWrap: {
paddingHorizontal: spacing.xs / 2,
paddingVertical: spacing.xs / 2
},
keyCell: {
height: 40,
borderRadius: 8,
backgroundColor: colors.bgPanel,
alignItems: 'center',
justifyContent: 'center'
},
keyCellPressed: {
backgroundColor: colors.bgRaised
},
keyCellSelected: {
backgroundColor: colors.textPrimary
},
keyCellText: {
fontSize: 13,
fontWeight: '600',
color: colors.textPrimary,
fontFamily: typography.monoFamily
},
keyCellTextSelected: {
color: colors.bgBase
},
macroForm: {
padding: spacing.md,
gap: spacing.sm
},
fieldLabel: {
fontSize: 13,
fontWeight: '500',
color: colors.textSecondary
},
fieldInput: {
backgroundColor: colors.bgBase,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
fontSize: 14,
fontFamily: typography.monoFamily,
borderWidth: 1,
borderColor: colors.borderSubtle
},
switchRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.xs
},
switchLabel: {
fontSize: typography.bodySize,
color: colors.textPrimary
},
saveButton: {
marginTop: spacing.md,
backgroundColor: colors.textPrimary,
paddingVertical: spacing.md,
borderRadius: 10,
alignItems: 'center'
},
saveButtonDisabled: {
backgroundColor: colors.bgRaised
},
saveButtonText: {
color: colors.bgBase,
fontSize: 15,
fontWeight: '600'
},
saveButtonTextDisabled: {
color: colors.textMuted
}
})
@@ -0,0 +1,279 @@
export const MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY = [
';',
' --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;',
' --font-sans: Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;',
' }',
' * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }',
' html, body {',
' width: 100%;',
' min-height: 100%;',
' margin: 0;',
' background: var(--editor-surface);',
' color: var(--foreground);',
' font-family: var(--font-sans);',
' overscroll-behavior: contain;',
' }',
' body { overflow: auto; }',
' #editor {',
' min-height: 100vh;',
' padding: 18px 16px 112px;',
' outline: none;',
' font-size: 14px;',
' line-height: 1.7;',
' word-wrap: break-word;',
' overflow-wrap: anywhere;',
' caret-color: var(--foreground);',
' }',
' #editor[contenteditable="false"] {',
' opacity: 0.78;',
' }',
' #editor:empty::before,',
' #editor p.is-empty:first-child::before {',
' content: attr(data-placeholder);',
' color: var(--muted-foreground);',
' pointer-events: none;',
' }',
' #editor > :first-child { margin-top: 0; }',
' h1, h2, h3, h4, h5, h6 {',
' margin: 1.5em 0 0.5em;',
' font-weight: 600;',
' line-height: 1.3;',
' letter-spacing: 0;',
' }',
' h1 { font-size: 1.85em; font-weight: 700; }',
' h2 { font-size: 1.4em; }',
' h3 { font-size: 1.15em; }',
' p, ul, ol, blockquote { margin: 0.75em 0; }',
' ul, ol { padding-left: 1.5em; }',
' ul { list-style: disc; }',
' ol { list-style: decimal; }',
' li { margin: 0.15em 0; }',
' li > p { margin: 0; }',
' ul[data-type="taskList"] {',
' padding-left: 0;',
' list-style: none;',
' }',
' ul[data-type="taskList"] > li {',
' display: flex;',
' align-items: flex-start;',
' gap: 6px;',
' }',
' ul[data-type="taskList"] > li > label {',
' flex-shrink: 0;',
' display: flex;',
' align-items: center;',
' height: 1.55em;',
' user-select: none;',
' }',
' ul[data-type="taskList"] input[type="checkbox"] {',
' appearance: none;',
' width: 16px;',
' height: 16px;',
' margin: 0;',
' border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent);',
' border-radius: 4px;',
' background: transparent;',
' position: relative;',
' }',
' ul[data-type="taskList"] input[type="checkbox"]:checked {',
' background: var(--primary);',
' border-color: var(--primary);',
' }',
' ul[data-type="taskList"] input[type="checkbox"]:checked::after {',
' content: "";',
' position: absolute;',
' left: 4px;',
' top: 1px;',
' width: 5px;',
' height: 9px;',
' border: solid var(--primary-foreground);',
' border-width: 0 2px 2px 0;',
' transform: rotate(45deg);',
' }',
' ul[data-type="taskList"] input[type="checkbox"]:disabled {',
' opacity: 0.65;',
' }',
' ul[data-type="taskList"] > li > div {',
' flex: 1;',
' min-width: 0;',
' }',
' ul[data-type="taskList"] > li[data-checked="true"] > div {',
' text-decoration: line-through;',
' color: var(--muted-foreground);',
' }',
' blockquote {',
' padding: 0.5em 1em;',
' border-left: 3px solid var(--border);',
' border-radius: 0 6px 6px 0;',
' color: var(--muted-foreground);',
' background: color-mix(in srgb, var(--foreground) 2%, transparent);',
' }',
' table {',
' width: 100%;',
' margin: 1em 0;',
' border-collapse: collapse;',
' font-size: 0.95em;',
' }',
' th, td {',
' padding: 8px 14px;',
' border: 1px solid var(--border);',
' text-align: left;',
' vertical-align: top;',
' }',
' th {',
' font-weight: 600;',
' background: color-mix(in srgb, var(--foreground) 4%, transparent);',
' }',
' tr:nth-child(odd) td {',
' background: color-mix(in srgb, var(--foreground) 1.5%, transparent);',
' }',
' code {',
' padding: 0.2em 0.4em;',
' border-radius: 5px;',
' background: color-mix(in srgb, var(--foreground) 8%, transparent);',
' font-size: 0.88em;',
' font-family: var(--font-mono);',
' }',
' pre {',
' margin: 0.75em 0;',
' padding: 14px 18px;',
' border-radius: 8px;',
' border: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);',
' overflow-x: auto;',
' line-height: 1.55;',
' background: color-mix(in srgb, var(--foreground) 6%, transparent);',
' font-family: var(--font-mono);',
' white-space: pre-wrap;',
' }',
' pre::before {',
' content: attr(data-language);',
' display: block;',
' min-height: 13px;',
' margin-bottom: 4px;',
' color: var(--muted-foreground);',
' font-size: 11px;',
' text-transform: uppercase;',
' }',
' pre code {',
' padding: 0;',
' border-radius: 0;',
' background: transparent;',
' font-size: 0.92em;',
' white-space: pre-wrap;',
' }',
' hr {',
' margin: 1.5em 0;',
' border: none;',
' border-top: 1px solid var(--border);',
' }',
' a {',
' color: var(--accent-link);',
' text-decoration: underline;',
' text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);',
' text-underline-offset: 2px;',
' }',
' img {',
' display: block;',
' max-width: 100%;',
' margin: 0.75em 0;',
' border-radius: 8px;',
' }',
' </style>',
'</head>',
'<body>',
' <main id="editor" contenteditable="true" data-placeholder="Start writing..."></main>',
' <script>',
' (function () {',
" var editor = document.getElementById('editor');",
" var lastMarkdown = '';",
' var inputTimer = null;',
' var documentGeneration = 0;',
' var editable = true;',
' var suppressInput = false;',
'',
' function post(message) {',
' window.ReactNativeWebView && window.ReactNativeWebView.postMessage(JSON.stringify(message));',
' }',
'',
' function decodeMarkdownEntities(value) {',
' return String(value).replace(/&(#x[0-9a-f]+|#\\d+|amp|lt|gt|quot|apos);/gi, function (match, entity) {',
' var lower = String(entity).toLowerCase();',
" if (lower === 'amp') return '&';",
" if (lower === 'lt') return '<';",
" if (lower === 'gt') return '>';",
" if (lower === 'quot') return '\"';",
" if (lower === 'apos') return \"'\";",
" if (lower.indexOf('#x') === 0) {",
' var hex = Number.parseInt(lower.slice(2), 16);',
' return Number.isFinite(hex) && hex >= 0 && hex <= 0x10ffff ? String.fromCodePoint(hex) : match;',
' }',
" if (lower.indexOf('#') === 0) {",
' var code = Number.parseInt(lower.slice(1), 10);',
' return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;',
' }',
' return match;',
' });',
' }',
'',
' function escapeHtml(value) {',
' return decodeMarkdownEntities(value).replace(/[&<>"\']/g, function (char) {',
" return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' })[char];",
' });',
' }',
'',
' function escapeAttr(value) {',
" return escapeHtml(value).replace(/\\n/g, ' ');",
' }',
'',
' function isSafeUrl(value) {',
" var trimmed = String(value || '').trim();",
' return !/^javascript:/i.test(trimmed);',
' }',
'',
' function splitTableRow(line) {',
" return line.trim().replace(/^\\|/, '').replace(/\\|$/, '').split('|').map(function (cell) {",
' return cell.trim();',
' });',
' }',
'',
' function isTableSeparator(line) {',
' var cells = splitTableRow(line);',
' return cells.length > 0 && cells.every(function (cell) {',
' return /^:?-{3,}:?$/.test(cell);',
' });',
' }',
'',
' function renderInline(text) {',
" var output = '';",
' var pattern = /(!\\[[^\\]]*\\]\\([^)]+\\)|`[^`]+`|~~[^~]+~~|\\*\\*[^*]+\\*\\*|__[^_]+__|\\*[^*\\n]+\\*|_[^_\\n]+_|\\[[^\\]]+\\]\\([^)]+\\)|https?:\\/\\/[^\\s<]+)/g;',
' var lastIndex = 0;',
' var match;',
' while ((match = pattern.exec(text))) {',
' output += escapeHtml(text.slice(lastIndex, match.index));',
' var token = match[0];',
' var image = token.match(/^!\\[([^\\]]*)\\]\\(([^)]+)\\)$/);',
' var link = token.match(/^\\[([^\\]]+)\\]\\(([^)]+)\\)$/);',
' if (image && isSafeUrl(image[2])) {',
" output += '<img src=\"' + escapeAttr(image[2]) + '\" alt=\"' + escapeAttr(image[1] || '') + '\" />';",
' } else if (link && isSafeUrl(link[2])) {',
" output += '<a href=\"' + escapeAttr(link[2]) + '\">' + renderInline(link[1]) + '</a>';",
' } else if (/^https?:\\/\\//i.test(token)) {',
" output += '<a href=\"' + escapeAttr(token) + '\">' + escapeHtml(token) + '</a>';",
" } else if (token.indexOf('`') === 0) {",
" output += '<code>' + escapeHtml(token.slice(1, -1)) + '</code>';",
" } else if (token.indexOf('~~') === 0) {",
" output += '<s>' + renderInline(token.slice(2, -2)) + '</s>';",
" } else if (token.indexOf('**') === 0 || token.indexOf('__') === 0) {",
" output += '<strong>' + renderInline(token.slice(2, -2)) + '</strong>';",
' } else {',
" output += '<em>' + renderInline(token.slice(1, -1)) + '</em>';",
' }',
' lastIndex = pattern.lastIndex;',
' }',
' output += escapeHtml(text.slice(lastIndex));',
' return output;',
' }',
'',
' function isBlockStart(line) {',
' return /^(```|#{1,6}\\s+|>\\s?|\\s*(?:[-*+]|\\d+[.)])\\s+|\\s*(-{3,}|\\*{3,}|_{3,})\\s*$)/.test(line);'
].join('\n')
@@ -0,0 +1,278 @@
export const MOBILE_RICH_MARKDOWN_EDITOR_BODY_SECONDARY = [
' }',
'',
' function indentationWidth(value) {',
" return String(value || '').replace(/\\t/g, ' ').length;",
' }',
'',
' function parseListLine(line) {',
" var match = String(line || '').match(/^(\\s*)((?:[-*+])|(?:\\d+[.)]))\\s+(.+)$/);",
' if (!match) return null;',
" var rawText = match[3] || '';",
' var task = rawText.match(/^\\[([ xX])\\]\\s+(.+)$/);',
' return {',
" indent: indentationWidth(match[1] || ''),",
" ordered: /^\\d/.test(match[2] || ''),",
" orderedNumber: /^\\d/.test(match[2] || '') ? Number.parseInt(match[2], 10) : null,",
" task: task ? task[1].toLowerCase() === 'x' : null,",
' text: task ? task[2] : rawText,',
' children: []',
' };',
' }',
'',
' function listKind(item) {',
" if (item.task !== null) return 'task';",
" return item.ordered ? 'ol' : 'ul';",
' }',
'',
' function parseListTree(lines, startIndex) {',
' var root = { indent: -1, children: [] };',
' var stack = [root];',
' var index = startIndex;',
' while (index < lines.length) {',
" var item = parseListLine(lines[index] || '');",
' if (!item) break;',
' while (stack.length > 1 && item.indent <= stack[stack.length - 1].indent) {',
' stack.pop();',
' }',
' stack[stack.length - 1].children.push(item);',
' stack.push(item);',
' index += 1;',
' }',
' return { items: root.children, nextIndex: index };',
' }',
'',
' function renderListItems(items) {',
' var html = [];',
' var index = 0;',
' while (index < items.length) {',
' var kind = listKind(items[index]);',
' var group = [];',
' while (index < items.length && listKind(items[index]) === kind) {',
' group.push(items[index]);',
' index += 1;',
' }',
" var tag = kind === 'ol' ? 'ol' : 'ul';",
" var attrs = kind === 'task' ? ' data-type=\"taskList\"' : kind === 'ol' && group[0].orderedNumber !== null ? ' start=\"' + group[0].orderedNumber + '\"' : '';",
" html.push('<' + tag + attrs + '>' + group.map(function (item) {",
" var children = item.children.length ? renderListItems(item.children) : '';",
" if (kind === 'task') {",
' var checked = item.task === true;',
" return '<li data-checked=\"' + String(checked) + '\"><label contenteditable=\"false\"><input type=\"checkbox\" ' + (checked ? 'checked ' : '') + (editable ? '' : 'disabled ') + '/></label><div><p>' + renderInline(item.text) + '</p>' + children + '</div></li>';",
' }',
" var orderedAttrs = kind === 'ol' && item.orderedNumber !== null ? ' value=\"' + item.orderedNumber + '\" data-list-number=\"' + item.orderedNumber + '\"' : '';",
" return '<li' + orderedAttrs + '><p>' + renderInline(item.text) + '</p>' + children + '</li>';",
" }).join('') + '</' + tag + '>');",
' }',
" return html.join('');",
' }',
'',
' function markdownToHtml(markdown) {',
" var lines = String(markdown || '').replace(/\\r\\n?/g, '\\n').split('\\n');",
' var html = [];',
' var index = 0;',
' while (index < lines.length) {',
" var line = lines[index] || '';",
' if (!line.trim()) {',
' index += 1;',
' continue;',
' }',
' var fence = line.match(/^\\```([^\\s`]*)\\s*$/);',
' if (fence) {',
' index += 1;',
' var code = [];',
" while (index < lines.length && !/^\\```\\s*$/.test(lines[index] || '')) {",
" code.push(lines[index] || '');",
' index += 1;',
' }',
' if (index < lines.length) index += 1;',
" html.push('<pre data-language=\"' + escapeAttr(fence[1] || '') + '\"><code>' + escapeHtml(code.join('\\n')) + '</code></pre>');",
' continue;',
' }',
' if (/^\\s*(-{3,}|\\*{3,}|_{3,})\\s*$/.test(line)) {',
" html.push('<hr />');",
' index += 1;',
' continue;',
' }',
" if (line.indexOf('|') >= 0 && index + 1 < lines.length && isTableSeparator(lines[index + 1] || '')) {",
' var headers = splitTableRow(line);',
' index += 2;',
' var rows = [];',
" while (index < lines.length && (lines[index] || '').indexOf('|') >= 0 && (lines[index] || '').trim()) {",
" rows.push(splitTableRow(lines[index] || ''));",
' index += 1;',
' }',
" html.push('<table><thead><tr>' + headers.map(function (cell) { return '<th>' + renderInline(cell) + '</th>'; }).join('') + '</tr></thead><tbody>' + rows.map(function (row) {",
" return '<tr>' + headers.map(function (_, cellIndex) { return '<td>' + renderInline(row[cellIndex] || '') + '</td>'; }).join('') + '</tr>';",
" }).join('') + '</tbody></table>');",
' continue;',
' }',
' var heading = line.match(/^(#{1,6})\\s+(.+)$/);',
' if (heading) {',
" html.push('<h' + heading[1].length + '>' + renderInline(heading[2].trim()) + '</h' + heading[1].length + '>');",
' index += 1;',
' continue;',
' }',
' if (/^>\\s?/.test(line)) {',
' var quote = [];',
" while (index < lines.length && /^>\\s?/.test(lines[index] || '')) {",
" quote.push((lines[index] || '').replace(/^>\\s?/, ''));",
' index += 1;',
' }',
" html.push('<blockquote><p>' + renderInline(quote.join('\\n').trim()).replace(/\\n/g, '<br />') + '</p></blockquote>');",
' continue;',
' }',
' if (/^\\s*(?:[-*+]|\\d+[.)])\\s+/.test(line)) {',
' var list = parseListTree(lines, index);',
' html.push(renderListItems(list.items));',
' index = list.nextIndex;',
' continue;',
' }',
' var paragraph = [];',
" while (index < lines.length && (lines[index] || '').trim() && !isBlockStart(lines[index] || '') && !(index + 1 < lines.length && (lines[index] || '').indexOf('|') >= 0 && isTableSeparator(lines[index + 1] || ''))) {",
" paragraph.push(lines[index] || '');",
' index += 1;',
' }',
" html.push('<p>' + renderInline(paragraph.join('\\n')).replace(/\\n/g, '<br />') + '</p>');",
' }',
" return html.join('\\n') || '<p class=\"is-empty\"><br /></p>';",
' }',
'',
' function textContent(node) {',
" return (node.textContent || '').replace(/\\u00a0/g, ' ');",
' }',
'',
' function inlineMarkdown(node) {',
" if (!node) return '';",
' if (node.nodeType === Node.TEXT_NODE) return textContent(node);',
" if (node.nodeType !== Node.ELEMENT_NODE) return '';",
' var el = node;',
' var tag = el.tagName.toLowerCase();',
" if (tag === 'br') return '\\n';",
" if (tag === 'strong' || tag === 'b') return '**' + inlineChildren(el) + '**';",
" if (tag === 'em' || tag === 'i') return '*' + inlineChildren(el) + '*';",
" if (tag === 's' || tag === 'del' || tag === 'strike') return '~~' + inlineChildren(el) + '~~';",
" if (tag === 'code' && el.parentElement && el.parentElement.tagName.toLowerCase() !== 'pre') return '`' + textContent(el) + '`';",
" if (tag === 'a') return '[' + inlineChildren(el) + '](' + (el.getAttribute('href') || '') + ')';",
" if (tag === 'img') return '![' + (el.getAttribute('alt') || '') + '](' + (el.getAttribute('src') || '') + ')';",
" if (tag === 'label') return '';",
' return inlineChildren(el);',
' }',
'',
' function inlineChildren(el) {',
" return Array.prototype.map.call(el.childNodes, inlineMarkdown).join('');",
' }',
'',
' function listItemText(li) {',
' var clone = li.cloneNode(true);',
" Array.prototype.forEach.call(clone.querySelectorAll('label'), function (label) { label.remove(); });",
" Array.prototype.forEach.call(clone.querySelectorAll('ul, ol'), function (list) { list.remove(); });",
' return inlineChildren(clone).trim();',
' }',
'',
' function directNestedLists(li) {',
" return Array.prototype.filter.call(li.querySelectorAll('ul, ol'), function (list) {",
" return list.closest('li') === li;",
' });',
' }',
'',
' function listMarkdown(el, depth) {',
' var tag = el.tagName.toLowerCase();',
" var isTask = el.getAttribute('data-type') === 'taskList';",
" var orderedStart = tag === 'ol' ? Number.parseInt(el.getAttribute('start') || '1', 10) : 1;",
' if (!Number.isFinite(orderedStart)) orderedStart = 1;',
" var indent = ' '.repeat(depth);",
' return Array.prototype.map.call(el.children, function (li, index) {',
" if (li.tagName.toLowerCase() !== 'li') return '';",
' var marker;',
' if (isTask) {',
' var input = li.querySelector(\'input[type="checkbox"]\');',
" marker = '- [' + (input && input.checked ? 'x' : ' ') + '] ';",
' } else {',
' var listNumber =',
' li.getAttribute &&',
" (li.getAttribute('data-list-number') || li.getAttribute('value'));",
" marker = tag === 'ol' ? String(listNumber || orderedStart + index) + '. ' : '- ';",
' }',
' var line = indent + marker + listItemText(li);',
' var nested = directNestedLists(li).map(function (list) {',
' return listMarkdown(list, depth + 1);',
" }).filter(Boolean).join('\\n');",
" return nested ? line + '\\n' + nested : line;",
" }).filter(Boolean).join('\\n');",
' }',
'',
' function blockMarkdown(node) {',
' if (node.nodeType === Node.TEXT_NODE) return textContent(node).trim();',
" if (node.nodeType !== Node.ELEMENT_NODE) return '';",
' var el = node;',
' var tag = el.tagName.toLowerCase();',
" if (tag.match(/^h[1-6]$/)) return '#'.repeat(Number(tag.slice(1))) + ' ' + inlineChildren(el).trim();",
" if (tag === 'p' || tag === 'div') return inlineChildren(el).trim();",
" if (tag === 'blockquote') {",
" return inlineChildren(el).trim().split('\\n').map(function (line) { return '> ' + line; }).join('\\n');",
' }',
" if (tag === 'pre') {",
" var lang = el.getAttribute('data-language') || '';",
" var code = textContent(el.querySelector('code') || el).replace(/\\n+$/g, '');",
" return '```' + lang + '\\n' + code + '\\n```';",
' }',
" if (tag === 'ul' || tag === 'ol') {",
' return listMarkdown(el, 0);',
' }',
" if (tag === 'table') {",
" var rows = Array.prototype.slice.call(el.querySelectorAll('tr'));",
" if (rows.length === 0) return '';",
' var cellsFor = function (row) {',
' return Array.prototype.map.call(row.children, function (cell) { return inlineChildren(cell).trim(); });',
' };',
' var headers = cellsFor(rows[0]);',
' var bodyRows = rows.slice(1).map(cellsFor);',
" return '| ' + headers.join(' | ') + ' |\\n| ' + headers.map(function () { return '---'; }).join(' | ') + ' |' + (bodyRows.length ? '\\n' + bodyRows.map(function (row) { return '| ' + row.join(' | ') + ' |'; }).join('\\n') : '');",
' }',
" if (tag === 'hr') return '---';",
" if (tag === 'img') return inlineMarkdown(el);",
' return inlineChildren(el).trim();',
' }',
'',
' function currentMarkdown() {',
' return Array.prototype.map.call(editor.childNodes, blockMarkdown).filter(function (block) {',
' return block.trim().length > 0;',
" }).join('\\n\\n').trimEnd();",
' }',
'',
' function syncTaskCheckboxesDisabled() {',
' Array.prototype.forEach.call(editor.querySelectorAll(\'input[type="checkbox"]\'), function (input) {',
' input.disabled = !editable;',
' });',
' }',
'',
' function emitChange() {',
' if (suppressInput || !editable) return;',
' window.clearTimeout(inputTimer);',
' var pendingGeneration = documentGeneration;',
' lastMarkdown = currentMarkdown();',
" post({ type: 'change', markdown: lastMarkdown, generation: pendingGeneration });",
' }',
'',
' function setMarkdown(markdown, generation) {',
' window.clearTimeout(inputTimer);',
' documentGeneration = Number(generation) || 0;',
' suppressInput = true;',
" // Why: replacing innerHTML detaches the remembered caret's nodes.",
' savedSelectionRange = null;',
' selectionDroppedOnBlur = false;',
" lastMarkdown = String(markdown || '');",
' editor.innerHTML = markdownToHtml(lastMarkdown);',
' syncTaskCheckboxesDisabled();',
' suppressInput = false;',
' }',
'',
' function setEditable(nextEditable) {',
' editable = Boolean(nextEditable);',
" editor.setAttribute('contenteditable', editable ? 'true' : 'false');",
' syncTaskCheckboxesDisabled();',
' }',
'',
''
].join('\n')
@@ -0,0 +1,93 @@
export const MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS = [
'',
' function runCommand(command) {',
" if (!editable || editor.getAttribute('contenteditable') !== 'true') return;",
' restoreSelectionOrEnd();',
" if (command === 'paragraph') document.execCommand('formatBlock', false, 'p');",
" else if (command === 'heading1') document.execCommand('formatBlock', false, 'h1');",
" else if (command === 'heading2') document.execCommand('formatBlock', false, 'h2');",
" else if (command === 'heading3') document.execCommand('formatBlock', false, 'h3');",
" else if (command === 'bold') document.execCommand('bold');",
" else if (command === 'italic') document.execCommand('italic');",
" else if (command === 'strike') document.execCommand('strikeThrough');",
" else if (command === 'bulletList') document.execCommand('insertUnorderedList');",
" else if (command === 'orderedList') document.execCommand('insertOrderedList');",
" else if (command === 'quote') document.execCommand('formatBlock', false, 'blockquote');",
" else if (command === 'inlineCode') wrapSelection('code');",
" else if (command === 'codeBlock') document.execCommand('insertHTML', false, '<pre data-language=\"\"><code>code</code></pre><p><br></p>');",
' else if (command === \'taskList\') document.execCommand(\'insertHTML\', false, \'<ul data-type="taskList"><li data-checked="false"><label contenteditable="false"><input type="checkbox" /></label><div><p>Task</p></div></li></ul>\');',
" else if (command === 'link') {",
" var href = window.prompt('Link URL');",
" if (href && isSafeUrl(href)) document.execCommand('createLink', false, href);",
" } else if (command === 'image') {",
" var src = window.prompt('Image URL');",
" if (src && isSafeUrl(src)) document.execCommand('insertImage', false, src);",
' }',
' syncTaskCheckboxesDisabled();',
' emitChange();',
' }',
'',
" editor.addEventListener('input', function () {",
' selectionDroppedOnBlur = false;',
' if (editable) emitChange();',
' });',
" editor.addEventListener('change', function (event) {",
' var input = event.target && event.target.closest && event.target.closest(\'input[type="checkbox"]\');',
' if (input) {',
' if (!editable) {',
' event.preventDefault();',
' return;',
' }',
" var li = input.closest('li');",
" if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');",
' }',
' if (editable) emitChange();',
' });',
" editor.addEventListener('click', function (event) {",
" var link = event.target && event.target.closest && event.target.closest('a[href]');",
' if (link) {',
' event.preventDefault();',
" post({ type: 'openLink', url: link.getAttribute('href') || '' });",
' return;',
' }',
' var input = event.target && event.target.closest && event.target.closest(\'input[type="checkbox"]\');',
' if (!input) {',
' if (!editable) return;',
' // Why: a task-list label forwards its click to the checkbox, so refocusing here would steal it and re-open the keyboard.',
' var uneditable = event.target && event.target.closest && event.target.closest(\'[contenteditable="false"]\');',
' if (uneditable && uneditable !== editor) return;',
' selectionDroppedOnBlur = false;',
' if (document.activeElement === editor) return;',
' // Why: refocusing after a dismissal otherwise types at the stale caret, not where the user tapped.',
' var caret = caretRangeAtPoint(event.clientX, event.clientY);',
' focusEditor();',
' if (caret && editor.contains(caret.commonAncestorContainer)) applySelectionRange(caret);',
' return;',
' }',
' if (!editable) {',
' event.preventDefault();',
' return;',
' }',
" var li = input.closest('li');",
" if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');",
' emitChange();',
' });',
" editor.addEventListener('keydown', function (event) {",
" if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'b') {",
' event.preventDefault();',
" runCommand('bold');",
' }',
' });',
'',
' window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown, dismissKeyboard: dismissKeyboard };',
''
].join('\n')
export const MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_END = [
'',
" post({ type: 'ready' });",
' })();',
' </script>',
'</body>',
'</html>'
].join('\n')
@@ -2,6 +2,12 @@ import { colors } from '../theme/mobile-theme'
import { MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT } from './mobile-rich-markdown-keyboard-dismiss-script'
import { MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT } from './mobile-rich-markdown-editor-keyboard-inset-script'
import { MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT } from './mobile-rich-markdown-selection-script'
import { MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY } from './mobile-rich-markdown-editor-body-primary'
import { MOBILE_RICH_MARKDOWN_EDITOR_BODY_SECONDARY } from './mobile-rich-markdown-editor-body-secondary'
import {
MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS,
MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_END
} from './mobile-rich-markdown-editor-document-suffix'
export function escapeInjectedJavaScriptString(value: string): string {
return JSON.stringify(value).replace(/<\/script/gi, '<\\/script')
@@ -24,644 +30,7 @@ export function buildMobileRichMarkdownEditorHtml(): string {
--border: ${colors.borderSubtle};
--primary: ${colors.textPrimary};
--primary-foreground: ${colors.bgBase};
--accent-link: ${colors.accentBlue};
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
--font-sans: Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body {
width: 100%;
min-height: 100%;
margin: 0;
background: var(--editor-surface);
color: var(--foreground);
font-family: var(--font-sans);
overscroll-behavior: contain;
}
body { overflow: auto; }
#editor {
min-height: 100vh;
padding: 18px 16px 112px;
outline: none;
font-size: 14px;
line-height: 1.7;
word-wrap: break-word;
overflow-wrap: anywhere;
caret-color: var(--foreground);
}
#editor[contenteditable="false"] {
opacity: 0.78;
}
#editor:empty::before,
#editor p.is-empty:first-child::before {
content: attr(data-placeholder);
color: var(--muted-foreground);
pointer-events: none;
}
#editor > :first-child { margin-top: 0; }
h1, h2, h3, h4, h5, h6 {
margin: 1.5em 0 0.5em;
font-weight: 600;
line-height: 1.3;
letter-spacing: 0;
}
h1 { font-size: 1.85em; font-weight: 700; }
h2 { font-size: 1.4em; }
h3 { font-size: 1.15em; }
p, ul, ol, blockquote { margin: 0.75em 0; }
ul, ol { padding-left: 1.5em; }
ul { list-style: disc; }
ol { list-style: decimal; }
li { margin: 0.15em 0; }
li > p { margin: 0; }
ul[data-type="taskList"] {
padding-left: 0;
list-style: none;
}
ul[data-type="taskList"] > li {
display: flex;
align-items: flex-start;
gap: 6px;
}
ul[data-type="taskList"] > li > label {
flex-shrink: 0;
display: flex;
align-items: center;
height: 1.55em;
user-select: none;
}
ul[data-type="taskList"] input[type="checkbox"] {
appearance: none;
width: 16px;
height: 16px;
margin: 0;
border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent);
border-radius: 4px;
background: transparent;
position: relative;
}
ul[data-type="taskList"] input[type="checkbox"]:checked {
background: var(--primary);
border-color: var(--primary);
}
ul[data-type="taskList"] input[type="checkbox"]:checked::after {
content: "";
position: absolute;
left: 4px;
top: 1px;
width: 5px;
height: 9px;
border: solid var(--primary-foreground);
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
ul[data-type="taskList"] input[type="checkbox"]:disabled {
opacity: 0.65;
}
ul[data-type="taskList"] > li > div {
flex: 1;
min-width: 0;
}
ul[data-type="taskList"] > li[data-checked="true"] > div {
text-decoration: line-through;
color: var(--muted-foreground);
}
blockquote {
padding: 0.5em 1em;
border-left: 3px solid var(--border);
border-radius: 0 6px 6px 0;
color: var(--muted-foreground);
background: color-mix(in srgb, var(--foreground) 2%, transparent);
}
table {
width: 100%;
margin: 1em 0;
border-collapse: collapse;
font-size: 0.95em;
}
th, td {
padding: 8px 14px;
border: 1px solid var(--border);
text-align: left;
vertical-align: top;
}
th {
font-weight: 600;
background: color-mix(in srgb, var(--foreground) 4%, transparent);
}
tr:nth-child(odd) td {
background: color-mix(in srgb, var(--foreground) 1.5%, transparent);
}
code {
padding: 0.2em 0.4em;
border-radius: 5px;
background: color-mix(in srgb, var(--foreground) 8%, transparent);
font-size: 0.88em;
font-family: var(--font-mono);
}
pre {
margin: 0.75em 0;
padding: 14px 18px;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);
overflow-x: auto;
line-height: 1.55;
background: color-mix(in srgb, var(--foreground) 6%, transparent);
font-family: var(--font-mono);
white-space: pre-wrap;
}
pre::before {
content: attr(data-language);
display: block;
min-height: 13px;
margin-bottom: 4px;
color: var(--muted-foreground);
font-size: 11px;
text-transform: uppercase;
}
pre code {
padding: 0;
border-radius: 0;
background: transparent;
font-size: 0.92em;
white-space: pre-wrap;
}
hr {
margin: 1.5em 0;
border: none;
border-top: 1px solid var(--border);
}
a {
color: var(--accent-link);
text-decoration: underline;
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
text-underline-offset: 2px;
}
img {
display: block;
max-width: 100%;
margin: 0.75em 0;
border-radius: 8px;
}
</style>
</head>
<body>
<main id="editor" contenteditable="true" data-placeholder="Start writing..."></main>
<script>
(function () {
var editor = document.getElementById('editor');
var lastMarkdown = '';
var inputTimer = null;
var documentGeneration = 0;
var editable = true;
var suppressInput = false;
function post(message) {
window.ReactNativeWebView && window.ReactNativeWebView.postMessage(JSON.stringify(message));
}
function decodeMarkdownEntities(value) {
return String(value).replace(/&(#x[0-9a-f]+|#\\d+|amp|lt|gt|quot|apos);/gi, function (match, entity) {
var lower = String(entity).toLowerCase();
if (lower === 'amp') return '&';
if (lower === 'lt') return '<';
if (lower === 'gt') return '>';
if (lower === 'quot') return '"';
if (lower === 'apos') return "'";
if (lower.indexOf('#x') === 0) {
var hex = Number.parseInt(lower.slice(2), 16);
return Number.isFinite(hex) && hex >= 0 && hex <= 0x10ffff ? String.fromCodePoint(hex) : match;
}
if (lower.indexOf('#') === 0) {
var code = Number.parseInt(lower.slice(1), 10);
return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;
}
return match;
});
}
function escapeHtml(value) {
return decodeMarkdownEntities(value).replace(/[&<>"']/g, function (char) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char];
});
}
function escapeAttr(value) {
return escapeHtml(value).replace(/\\n/g, ' ');
}
function isSafeUrl(value) {
var trimmed = String(value || '').trim();
return !/^javascript:/i.test(trimmed);
}
function splitTableRow(line) {
return line.trim().replace(/^\\|/, '').replace(/\\|$/, '').split('|').map(function (cell) {
return cell.trim();
});
}
function isTableSeparator(line) {
var cells = splitTableRow(line);
return cells.length > 0 && cells.every(function (cell) {
return /^:?-{3,}:?$/.test(cell);
});
}
function renderInline(text) {
var output = '';
var pattern = /(!\\[[^\\]]*\\]\\([^)]+\\)|\`[^\`]+\`|~~[^~]+~~|\\*\\*[^*]+\\*\\*|__[^_]+__|\\*[^*\\n]+\\*|_[^_\\n]+_|\\[[^\\]]+\\]\\([^)]+\\)|https?:\\/\\/[^\\s<]+)/g;
var lastIndex = 0;
var match;
while ((match = pattern.exec(text))) {
output += escapeHtml(text.slice(lastIndex, match.index));
var token = match[0];
var image = token.match(/^!\\[([^\\]]*)\\]\\(([^)]+)\\)$/);
var link = token.match(/^\\[([^\\]]+)\\]\\(([^)]+)\\)$/);
if (image && isSafeUrl(image[2])) {
output += '<img src="' + escapeAttr(image[2]) + '" alt="' + escapeAttr(image[1] || '') + '" />';
} else if (link && isSafeUrl(link[2])) {
output += '<a href="' + escapeAttr(link[2]) + '">' + renderInline(link[1]) + '</a>';
} else if (/^https?:\\/\\//i.test(token)) {
output += '<a href="' + escapeAttr(token) + '">' + escapeHtml(token) + '</a>';
} else if (token.indexOf('\`') === 0) {
output += '<code>' + escapeHtml(token.slice(1, -1)) + '</code>';
} else if (token.indexOf('~~') === 0) {
output += '<s>' + renderInline(token.slice(2, -2)) + '</s>';
} else if (token.indexOf('**') === 0 || token.indexOf('__') === 0) {
output += '<strong>' + renderInline(token.slice(2, -2)) + '</strong>';
} else {
output += '<em>' + renderInline(token.slice(1, -1)) + '</em>';
}
lastIndex = pattern.lastIndex;
}
output += escapeHtml(text.slice(lastIndex));
return output;
}
function isBlockStart(line) {
return /^(\`\`\`|#{1,6}\\s+|>\\s?|\\s*(?:[-*+]|\\d+[.)])\\s+|\\s*(-{3,}|\\*{3,}|_{3,})\\s*$)/.test(line);
}
function indentationWidth(value) {
return String(value || '').replace(/\\t/g, ' ').length;
}
function parseListLine(line) {
var match = String(line || '').match(/^(\\s*)((?:[-*+])|(?:\\d+[.)]))\\s+(.+)$/);
if (!match) return null;
var rawText = match[3] || '';
var task = rawText.match(/^\\[([ xX])\\]\\s+(.+)$/);
return {
indent: indentationWidth(match[1] || ''),
ordered: /^\\d/.test(match[2] || ''),
orderedNumber: /^\\d/.test(match[2] || '') ? Number.parseInt(match[2], 10) : null,
task: task ? task[1].toLowerCase() === 'x' : null,
text: task ? task[2] : rawText,
children: []
};
}
function listKind(item) {
if (item.task !== null) return 'task';
return item.ordered ? 'ol' : 'ul';
}
function parseListTree(lines, startIndex) {
var root = { indent: -1, children: [] };
var stack = [root];
var index = startIndex;
while (index < lines.length) {
var item = parseListLine(lines[index] || '');
if (!item) break;
while (stack.length > 1 && item.indent <= stack[stack.length - 1].indent) {
stack.pop();
}
stack[stack.length - 1].children.push(item);
stack.push(item);
index += 1;
}
return { items: root.children, nextIndex: index };
}
function renderListItems(items) {
var html = [];
var index = 0;
while (index < items.length) {
var kind = listKind(items[index]);
var group = [];
while (index < items.length && listKind(items[index]) === kind) {
group.push(items[index]);
index += 1;
}
var tag = kind === 'ol' ? 'ol' : 'ul';
var attrs = kind === 'task' ? ' data-type="taskList"' : kind === 'ol' && group[0].orderedNumber !== null ? ' start="' + group[0].orderedNumber + '"' : '';
html.push('<' + tag + attrs + '>' + group.map(function (item) {
var children = item.children.length ? renderListItems(item.children) : '';
if (kind === 'task') {
var checked = item.task === true;
return '<li data-checked="' + String(checked) + '"><label contenteditable="false"><input type="checkbox" ' + (checked ? 'checked ' : '') + (editable ? '' : 'disabled ') + '/></label><div><p>' + renderInline(item.text) + '</p>' + children + '</div></li>';
}
var orderedAttrs = kind === 'ol' && item.orderedNumber !== null ? ' value="' + item.orderedNumber + '" data-list-number="' + item.orderedNumber + '"' : '';
return '<li' + orderedAttrs + '><p>' + renderInline(item.text) + '</p>' + children + '</li>';
}).join('') + '</' + tag + '>');
}
return html.join('');
}
function markdownToHtml(markdown) {
var lines = String(markdown || '').replace(/\\r\\n?/g, '\\n').split('\\n');
var html = [];
var index = 0;
while (index < lines.length) {
var line = lines[index] || '';
if (!line.trim()) {
index += 1;
continue;
}
var fence = line.match(/^\\\`\`\`([^\\s\`]*)\\s*$/);
if (fence) {
index += 1;
var code = [];
while (index < lines.length && !/^\\\`\`\`\\s*$/.test(lines[index] || '')) {
code.push(lines[index] || '');
index += 1;
}
if (index < lines.length) index += 1;
html.push('<pre data-language="' + escapeAttr(fence[1] || '') + '"><code>' + escapeHtml(code.join('\\n')) + '</code></pre>');
continue;
}
if (/^\\s*(-{3,}|\\*{3,}|_{3,})\\s*$/.test(line)) {
html.push('<hr />');
index += 1;
continue;
}
if (line.indexOf('|') >= 0 && index + 1 < lines.length && isTableSeparator(lines[index + 1] || '')) {
var headers = splitTableRow(line);
index += 2;
var rows = [];
while (index < lines.length && (lines[index] || '').indexOf('|') >= 0 && (lines[index] || '').trim()) {
rows.push(splitTableRow(lines[index] || ''));
index += 1;
}
html.push('<table><thead><tr>' + headers.map(function (cell) { return '<th>' + renderInline(cell) + '</th>'; }).join('') + '</tr></thead><tbody>' + rows.map(function (row) {
return '<tr>' + headers.map(function (_, cellIndex) { return '<td>' + renderInline(row[cellIndex] || '') + '</td>'; }).join('') + '</tr>';
}).join('') + '</tbody></table>');
continue;
}
var heading = line.match(/^(#{1,6})\\s+(.+)$/);
if (heading) {
html.push('<h' + heading[1].length + '>' + renderInline(heading[2].trim()) + '</h' + heading[1].length + '>');
index += 1;
continue;
}
if (/^>\\s?/.test(line)) {
var quote = [];
while (index < lines.length && /^>\\s?/.test(lines[index] || '')) {
quote.push((lines[index] || '').replace(/^>\\s?/, ''));
index += 1;
}
html.push('<blockquote><p>' + renderInline(quote.join('\\n').trim()).replace(/\\n/g, '<br />') + '</p></blockquote>');
continue;
}
if (/^\\s*(?:[-*+]|\\d+[.)])\\s+/.test(line)) {
var list = parseListTree(lines, index);
html.push(renderListItems(list.items));
index = list.nextIndex;
continue;
}
var paragraph = [];
while (index < lines.length && (lines[index] || '').trim() && !isBlockStart(lines[index] || '') && !(index + 1 < lines.length && (lines[index] || '').indexOf('|') >= 0 && isTableSeparator(lines[index + 1] || ''))) {
paragraph.push(lines[index] || '');
index += 1;
}
html.push('<p>' + renderInline(paragraph.join('\\n')).replace(/\\n/g, '<br />') + '</p>');
}
return html.join('\\n') || '<p class="is-empty"><br /></p>';
}
function textContent(node) {
return (node.textContent || '').replace(/\\u00a0/g, ' ');
}
function inlineMarkdown(node) {
if (!node) return '';
if (node.nodeType === Node.TEXT_NODE) return textContent(node);
if (node.nodeType !== Node.ELEMENT_NODE) return '';
var el = node;
var tag = el.tagName.toLowerCase();
if (tag === 'br') return '\\n';
if (tag === 'strong' || tag === 'b') return '**' + inlineChildren(el) + '**';
if (tag === 'em' || tag === 'i') return '*' + inlineChildren(el) + '*';
if (tag === 's' || tag === 'del' || tag === 'strike') return '~~' + inlineChildren(el) + '~~';
if (tag === 'code' && el.parentElement && el.parentElement.tagName.toLowerCase() !== 'pre') return '\`' + textContent(el) + '\`';
if (tag === 'a') return '[' + inlineChildren(el) + '](' + (el.getAttribute('href') || '') + ')';
if (tag === 'img') return '![' + (el.getAttribute('alt') || '') + '](' + (el.getAttribute('src') || '') + ')';
if (tag === 'label') return '';
return inlineChildren(el);
}
function inlineChildren(el) {
return Array.prototype.map.call(el.childNodes, inlineMarkdown).join('');
}
function listItemText(li) {
var clone = li.cloneNode(true);
Array.prototype.forEach.call(clone.querySelectorAll('label'), function (label) { label.remove(); });
Array.prototype.forEach.call(clone.querySelectorAll('ul, ol'), function (list) { list.remove(); });
return inlineChildren(clone).trim();
}
function directNestedLists(li) {
return Array.prototype.filter.call(li.querySelectorAll('ul, ol'), function (list) {
return list.closest('li') === li;
});
}
function listMarkdown(el, depth) {
var tag = el.tagName.toLowerCase();
var isTask = el.getAttribute('data-type') === 'taskList';
var orderedStart = tag === 'ol' ? Number.parseInt(el.getAttribute('start') || '1', 10) : 1;
if (!Number.isFinite(orderedStart)) orderedStart = 1;
var indent = ' '.repeat(depth);
return Array.prototype.map.call(el.children, function (li, index) {
if (li.tagName.toLowerCase() !== 'li') return '';
var marker;
if (isTask) {
var input = li.querySelector('input[type="checkbox"]');
marker = '- [' + (input && input.checked ? 'x' : ' ') + '] ';
} else {
var listNumber =
li.getAttribute &&
(li.getAttribute('data-list-number') || li.getAttribute('value'));
marker = tag === 'ol' ? String(listNumber || orderedStart + index) + '. ' : '- ';
}
var line = indent + marker + listItemText(li);
var nested = directNestedLists(li).map(function (list) {
return listMarkdown(list, depth + 1);
}).filter(Boolean).join('\\n');
return nested ? line + '\\n' + nested : line;
}).filter(Boolean).join('\\n');
}
function blockMarkdown(node) {
if (node.nodeType === Node.TEXT_NODE) return textContent(node).trim();
if (node.nodeType !== Node.ELEMENT_NODE) return '';
var el = node;
var tag = el.tagName.toLowerCase();
if (tag.match(/^h[1-6]$/)) return '#'.repeat(Number(tag.slice(1))) + ' ' + inlineChildren(el).trim();
if (tag === 'p' || tag === 'div') return inlineChildren(el).trim();
if (tag === 'blockquote') {
return inlineChildren(el).trim().split('\\n').map(function (line) { return '> ' + line; }).join('\\n');
}
if (tag === 'pre') {
var lang = el.getAttribute('data-language') || '';
var code = textContent(el.querySelector('code') || el).replace(/\\n+$/g, '');
return '\`\`\`' + lang + '\\n' + code + '\\n\`\`\`';
}
if (tag === 'ul' || tag === 'ol') {
return listMarkdown(el, 0);
}
if (tag === 'table') {
var rows = Array.prototype.slice.call(el.querySelectorAll('tr'));
if (rows.length === 0) return '';
var cellsFor = function (row) {
return Array.prototype.map.call(row.children, function (cell) { return inlineChildren(cell).trim(); });
};
var headers = cellsFor(rows[0]);
var bodyRows = rows.slice(1).map(cellsFor);
return '| ' + headers.join(' | ') + ' |\\n| ' + headers.map(function () { return '---'; }).join(' | ') + ' |' + (bodyRows.length ? '\\n' + bodyRows.map(function (row) { return '| ' + row.join(' | ') + ' |'; }).join('\\n') : '');
}
if (tag === 'hr') return '---';
if (tag === 'img') return inlineMarkdown(el);
return inlineChildren(el).trim();
}
function currentMarkdown() {
return Array.prototype.map.call(editor.childNodes, blockMarkdown).filter(function (block) {
return block.trim().length > 0;
}).join('\\n\\n').trimEnd();
}
function syncTaskCheckboxesDisabled() {
Array.prototype.forEach.call(editor.querySelectorAll('input[type="checkbox"]'), function (input) {
input.disabled = !editable;
});
}
function emitChange() {
if (suppressInput || !editable) return;
window.clearTimeout(inputTimer);
var pendingGeneration = documentGeneration;
lastMarkdown = currentMarkdown();
post({ type: 'change', markdown: lastMarkdown, generation: pendingGeneration });
}
function setMarkdown(markdown, generation) {
window.clearTimeout(inputTimer);
documentGeneration = Number(generation) || 0;
suppressInput = true;
// Why: replacing innerHTML detaches the remembered caret's nodes.
savedSelectionRange = null;
selectionDroppedOnBlur = false;
lastMarkdown = String(markdown || '');
editor.innerHTML = markdownToHtml(lastMarkdown);
syncTaskCheckboxesDisabled();
suppressInput = false;
}
function setEditable(nextEditable) {
editable = Boolean(nextEditable);
editor.setAttribute('contenteditable', editable ? 'true' : 'false');
syncTaskCheckboxesDisabled();
}
${MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT}
${MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT}
function runCommand(command) {
if (!editable || editor.getAttribute('contenteditable') !== 'true') return;
restoreSelectionOrEnd();
if (command === 'paragraph') document.execCommand('formatBlock', false, 'p');
else if (command === 'heading1') document.execCommand('formatBlock', false, 'h1');
else if (command === 'heading2') document.execCommand('formatBlock', false, 'h2');
else if (command === 'heading3') document.execCommand('formatBlock', false, 'h3');
else if (command === 'bold') document.execCommand('bold');
else if (command === 'italic') document.execCommand('italic');
else if (command === 'strike') document.execCommand('strikeThrough');
else if (command === 'bulletList') document.execCommand('insertUnorderedList');
else if (command === 'orderedList') document.execCommand('insertOrderedList');
else if (command === 'quote') document.execCommand('formatBlock', false, 'blockquote');
else if (command === 'inlineCode') wrapSelection('code');
else if (command === 'codeBlock') document.execCommand('insertHTML', false, '<pre data-language=""><code>code</code></pre><p><br></p>');
else if (command === 'taskList') document.execCommand('insertHTML', false, '<ul data-type="taskList"><li data-checked="false"><label contenteditable="false"><input type="checkbox" /></label><div><p>Task</p></div></li></ul>');
else if (command === 'link') {
var href = window.prompt('Link URL');
if (href && isSafeUrl(href)) document.execCommand('createLink', false, href);
} else if (command === 'image') {
var src = window.prompt('Image URL');
if (src && isSafeUrl(src)) document.execCommand('insertImage', false, src);
}
syncTaskCheckboxesDisabled();
emitChange();
}
editor.addEventListener('input', function () {
selectionDroppedOnBlur = false;
if (editable) emitChange();
});
editor.addEventListener('change', function (event) {
var input = event.target && event.target.closest && event.target.closest('input[type="checkbox"]');
if (input) {
if (!editable) {
event.preventDefault();
return;
}
var li = input.closest('li');
if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');
}
if (editable) emitChange();
});
editor.addEventListener('click', function (event) {
var link = event.target && event.target.closest && event.target.closest('a[href]');
if (link) {
event.preventDefault();
post({ type: 'openLink', url: link.getAttribute('href') || '' });
return;
}
var input = event.target && event.target.closest && event.target.closest('input[type="checkbox"]');
if (!input) {
if (!editable) return;
// Why: a task-list label forwards its click to the checkbox, so refocusing here would steal it and re-open the keyboard.
var uneditable = event.target && event.target.closest && event.target.closest('[contenteditable="false"]');
if (uneditable && uneditable !== editor) return;
selectionDroppedOnBlur = false;
if (document.activeElement === editor) return;
// Why: refocusing after a dismissal otherwise types at the stale caret, not where the user tapped.
var caret = caretRangeAtPoint(event.clientX, event.clientY);
focusEditor();
if (caret && editor.contains(caret.commonAncestorContainer)) applySelectionRange(caret);
return;
}
if (!editable) {
event.preventDefault();
return;
}
var li = input.closest('li');
if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');
emitChange();
});
editor.addEventListener('keydown', function (event) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'b') {
event.preventDefault();
runCommand('bold');
}
});
window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown, dismissKeyboard: dismissKeyboard };
${MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT}
post({ type: 'ready' });
})();
</script>
</body>
</html>`
--accent-link: ${colors.accentBlue}${MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY}
${MOBILE_RICH_MARKDOWN_EDITOR_BODY_SECONDARY}${MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT}
${MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT}${MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS}${MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT}${MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_END}`
}
+185
View File
@@ -0,0 +1,185 @@
import { StyleSheet } from 'react-native'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
export const pairScanStyles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase,
padding: spacing.lg
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.sm
},
steps: {
gap: spacing.sm,
marginBottom: spacing.lg,
marginLeft: 7
},
step: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
stepBadge: {
width: 22,
height: 22,
borderRadius: 11,
backgroundColor: colors.bgRaised,
alignItems: 'center',
justifyContent: 'center'
},
stepNumber: {
fontSize: 12,
fontWeight: '700',
color: colors.textSecondary
},
stepText: {
fontSize: typography.bodySize,
color: colors.textSecondary
},
cameraWrap: {
flex: 1,
borderRadius: radii.camera,
overflow: 'hidden'
},
// Why: holds the layout slot while the camera is unmounted during
// paste, so the bottom action button doesn't snap up to fill the
// empty space.
cameraPlaceholder: {
flex: 1,
backgroundColor: colors.bgPanel,
borderRadius: radii.camera
},
camera: {
...StyleSheet.absoluteFillObject
},
reticle: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center'
},
reticleFrame: {
position: 'relative'
},
corner: {
position: 'absolute',
width: 28,
height: 28,
borderColor: 'rgba(255,255,255,0.7)'
},
cornerTL: {
top: 0,
left: 0,
borderTopWidth: 2.5,
borderLeftWidth: 2.5,
borderTopLeftRadius: 6
},
cornerTR: {
top: 0,
right: 0,
borderTopWidth: 2.5,
borderRightWidth: 2.5,
borderTopRightRadius: 6
},
cornerBL: {
bottom: 0,
left: 0,
borderBottomWidth: 2.5,
borderLeftWidth: 2.5,
borderBottomLeftRadius: 6
},
cornerBR: {
bottom: 0,
right: 0,
borderBottomWidth: 2.5,
borderRightWidth: 2.5,
borderBottomRightRadius: 6
},
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center'
},
title: {
fontSize: typography.titleSize,
fontWeight: '600',
color: colors.textPrimary,
marginBottom: spacing.sm
},
subtitle: {
maxWidth: 310,
fontSize: typography.bodySize,
color: colors.textSecondary,
textAlign: 'center',
marginBottom: spacing.xl,
lineHeight: 20
},
connectingText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
marginTop: spacing.lg
},
logSlot: {
width: '100%',
marginTop: spacing.lg,
paddingHorizontal: spacing.sm
},
errorText: {
color: colors.statusRed,
fontSize: typography.bodySize,
textAlign: 'center',
marginBottom: spacing.xl,
lineHeight: 20
},
primaryButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
backgroundColor: colors.textPrimary,
paddingHorizontal: spacing.xl,
paddingVertical: spacing.sm + 2,
borderRadius: radii.button
},
primaryButtonText: {
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '600'
},
pasteButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
marginTop: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radii.button
},
pasteButtonPressed: {
opacity: 0.6
},
pasteButtonText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '500'
},
errorActions: {
alignItems: 'center',
gap: spacing.sm
},
secondaryButton: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.sm,
borderRadius: radii.button
},
secondaryButtonText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '500'
}
})
+8 -140
View File
@@ -1,3 +1,9 @@
import {
SPECIAL_KEY_LABELS,
TERMINAL_ACCESSORY_KEY_DEFINITIONS,
TERMINAL_SHORTCUT_SPECIAL_KEY_DEFINITIONS
} from './terminal-key-definitions'
export type TerminalAccessoryKey = {
id: string
label: string
@@ -75,66 +81,6 @@ const CTRL_PRINTABLE_BYTES: Record<string, string> = {
'?': '\x7f'
}
const SPECIAL_KEY_LABELS: Record<string, string> = {
escape: 'Esc',
tab: 'Tab',
enter: 'Enter',
backspace: '⌫',
delete: 'Del',
insert: 'Ins',
arrowUp: '↑',
arrowDown: '↓',
arrowLeft: '←',
arrowRight: '→',
home: 'Home',
end: 'End',
pageUp: 'PgUp',
pageDown: 'PgDn',
space: 'Space',
f1: 'F1',
f2: 'F2',
f3: 'F3',
f4: 'F4',
f5: 'F5',
f6: 'F6',
f7: 'F7',
f8: 'F8',
f9: 'F9',
f10: 'F10',
f11: 'F11',
f12: 'F12'
}
const SPECIAL_KEY_ACCESSIBILITY_LABELS: Record<string, string> = {
escape: 'Escape',
tab: 'Tab',
enter: 'Enter',
backspace: 'Backspace',
delete: 'Forward delete',
insert: 'Insert',
arrowUp: 'Arrow up',
arrowDown: 'Arrow down',
arrowLeft: 'Arrow left',
arrowRight: 'Arrow right',
home: 'Home',
end: 'End',
pageUp: 'Page up',
pageDown: 'Page down',
space: 'Space',
f1: 'F1',
f2: 'F2',
f3: 'F3',
f4: 'F4',
f5: 'F5',
f6: 'F6',
f7: 'F7',
f8: 'F8',
f9: 'F9',
f10: 'F10',
f11: 'F11',
f12: 'F12'
}
const CSI_FINAL_SPECIAL_KEYS: Record<string, string> = {
arrowUp: 'A',
arrowDown: 'B',
@@ -165,87 +111,9 @@ const CSI_TILDE_SPECIAL_KEYS: Record<string, number> = {
f12: 24
}
export const TERMINAL_SHORTCUT_SPECIAL_KEYS: TerminalShortcutSpecialKey[] = [
'escape',
'tab',
'enter',
'backspace',
'delete',
'insert',
'arrowUp',
'arrowDown',
'arrowLeft',
'arrowRight',
'home',
'end',
'pageUp',
'pageDown',
'space',
'f1',
'f2',
'f3',
'f4',
'f5',
'f6',
'f7',
'f8',
'f9',
'f10',
'f11',
'f12'
].map((id) => ({
id,
label: SPECIAL_KEY_LABELS[id]!,
accessibilityLabel: SPECIAL_KEY_ACCESSIBILITY_LABELS[id]!
}))
export const TERMINAL_SHORTCUT_SPECIAL_KEYS = TERMINAL_SHORTCUT_SPECIAL_KEY_DEFINITIONS
export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [
{ id: 'escape', label: 'Esc', bytes: '\x1b', accessibilityLabel: 'Escape' },
{ id: 'tab', label: 'Tab', bytes: '\t', accessibilityLabel: 'Tab' },
{ id: 'enter', label: 'Enter', bytes: '\r', accessibilityLabel: 'Enter' },
// Why: terminal apps recognize ESC [ Z as the reverse-tab sequence.
{ id: 'shiftTab', label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' },
{ id: 'space', label: 'Space', bytes: ' ', accessibilityLabel: 'Space' },
{ id: 'backspace', label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true },
{
id: 'delete',
label: 'Del',
bytes: '\x1b[3~',
accessibilityLabel: 'Forward delete',
repeatable: true
},
{ id: 'arrowUp', label: '↑', bytes: '\x1b[A', accessibilityLabel: 'Arrow Up', repeatable: true },
{
id: 'arrowDown',
label: '↓',
bytes: '\x1b[B',
accessibilityLabel: 'Arrow Down',
repeatable: true
},
{
id: 'arrowLeft',
label: '←',
bytes: '\x1b[D',
accessibilityLabel: 'Arrow Left',
repeatable: true
},
{
id: 'arrowRight',
label: '→',
bytes: '\x1b[C',
accessibilityLabel: 'Arrow Right',
repeatable: true
},
{ id: 'ctrlC', label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' },
{ id: 'ctrlD', label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' },
{ id: 'ctrlL', label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' },
{ id: 'ctrlZ', label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' },
{ id: 'ctrlR', label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' },
{ id: 'ctrlA', label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' },
{ id: 'ctrlE', label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' },
{ id: 'ctrlW', label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' },
{ id: 'ctrlU', label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' }
]
export const TERMINAL_ACCESSORY_KEYS = TERMINAL_ACCESSORY_KEY_DEFINITIONS
export function buildTerminalShortcutKey(
binding: TerminalShortcutBinding
@@ -0,0 +1,143 @@
import type { TerminalAccessoryKey, TerminalShortcutSpecialKey } from './terminal-accessory-keys'
export const SPECIAL_KEY_LABELS: Record<string, string> = {
escape: 'Esc',
tab: 'Tab',
enter: 'Enter',
backspace: '⌫',
delete: 'Del',
insert: 'Ins',
arrowUp: '↑',
arrowDown: '↓',
arrowLeft: '←',
arrowRight: '→',
home: 'Home',
end: 'End',
pageUp: 'PgUp',
pageDown: 'PgDn',
space: 'Space',
f1: 'F1',
f2: 'F2',
f3: 'F3',
f4: 'F4',
f5: 'F5',
f6: 'F6',
f7: 'F7',
f8: 'F8',
f9: 'F9',
f10: 'F10',
f11: 'F11',
f12: 'F12'
}
const SPECIAL_KEY_ACCESSIBILITY_LABELS: Record<string, string> = {
escape: 'Escape',
tab: 'Tab',
enter: 'Enter',
backspace: 'Backspace',
delete: 'Forward delete',
insert: 'Insert',
arrowUp: 'Arrow up',
arrowDown: 'Arrow down',
arrowLeft: 'Arrow left',
arrowRight: 'Arrow right',
home: 'Home',
end: 'End',
pageUp: 'Page up',
pageDown: 'Page down',
space: 'Space',
f1: 'F1',
f2: 'F2',
f3: 'F3',
f4: 'F4',
f5: 'F5',
f6: 'F6',
f7: 'F7',
f8: 'F8',
f9: 'F9',
f10: 'F10',
f11: 'F11',
f12: 'F12'
}
export const TERMINAL_SHORTCUT_SPECIAL_KEY_DEFINITIONS: TerminalShortcutSpecialKey[] = [
'escape',
'tab',
'enter',
'backspace',
'delete',
'insert',
'arrowUp',
'arrowDown',
'arrowLeft',
'arrowRight',
'home',
'end',
'pageUp',
'pageDown',
'space',
'f1',
'f2',
'f3',
'f4',
'f5',
'f6',
'f7',
'f8',
'f9',
'f10',
'f11',
'f12'
].map((id) => ({
id,
label: SPECIAL_KEY_LABELS[id]!,
accessibilityLabel: SPECIAL_KEY_ACCESSIBILITY_LABELS[id]!
}))
export const TERMINAL_ACCESSORY_KEY_DEFINITIONS: TerminalAccessoryKey[] = [
{ id: 'escape', label: 'Esc', bytes: '\x1b', accessibilityLabel: 'Escape' },
{ id: 'tab', label: 'Tab', bytes: '\t', accessibilityLabel: 'Tab' },
{ id: 'enter', label: 'Enter', bytes: '\r', accessibilityLabel: 'Enter' },
// Why: terminal apps recognize ESC [ Z as the reverse-tab sequence.
{ id: 'shiftTab', label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' },
{ id: 'space', label: 'Space', bytes: ' ', accessibilityLabel: 'Space' },
{ id: 'backspace', label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true },
{
id: 'delete',
label: 'Del',
bytes: '\x1b[3~',
accessibilityLabel: 'Forward delete',
repeatable: true
},
{ id: 'arrowUp', label: '↑', bytes: '\x1b[A', accessibilityLabel: 'Arrow Up', repeatable: true },
{
id: 'arrowDown',
label: '↓',
bytes: '\x1b[B',
accessibilityLabel: 'Arrow Down',
repeatable: true
},
{
id: 'arrowLeft',
label: '←',
bytes: '\x1b[D',
accessibilityLabel: 'Arrow Left',
repeatable: true
},
{
id: 'arrowRight',
label: '→',
bytes: '\x1b[C',
accessibilityLabel: 'Arrow Right',
repeatable: true
},
{ id: 'ctrlC', label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' },
{ id: 'ctrlD', label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' },
{ id: 'ctrlL', label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' },
{ id: 'ctrlZ', label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' },
{ id: 'ctrlR', label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' },
{ id: 'ctrlA', label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' },
{ id: 'ctrlE', label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' },
{ id: 'ctrlW', label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' },
{ id: 'ctrlU', label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' }
]
@@ -0,0 +1,308 @@
import type { AutomationPrecheck, AutomationSchedulePreset } from '../../shared/automations-types'
import {
normalizeTaskSourceContext,
type TaskSourceContext
} from '../../shared/task-source-context'
import type { TuiAgent } from '../../shared/tui-agent'
import {
DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS,
MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS
} from '../../shared/automation-precheck'
import { buildAutomationRrule } from '../../shared/automation-schedule-occurrences'
import { isValidAutomationSchedule } from '../../shared/automation-schedule-parsing'
import { isTuiAgent } from '../../shared/tui-agent-config'
import {
getOptionalPositiveIntegerFlag,
getOptionalStringFlag,
getRequiredStringFlag
} from '../flags'
import { RuntimeClientError } from '../runtime-client'
const PRESET_TRIGGERS = new Set<AutomationSchedulePreset>(['hourly', 'daily', 'weekdays', 'weekly'])
const SCHEDULE_MODIFIER_FLAGS = ['day', 'time'] as const
function getScheduleModifierFlag(flags: Map<string, string | boolean>): string | undefined {
return SCHEDULE_MODIFIER_FLAGS.find((flag) => flags.has(flag))
}
function validateScheduleModifierApplicability(
flags: Map<string, string | boolean>,
raw: string
): void {
const isPreset = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset)
if (!isPreset) {
if (flags.has('time')) {
throw new RuntimeClientError(
'invalid_argument',
'--time can only be used with preset automation triggers'
)
}
if (flags.has('day')) {
throw new RuntimeClientError(
'invalid_argument',
'--day can only be used with the weekly automation preset'
)
}
return
}
if (raw === 'hourly' && flags.has('time')) {
throw new RuntimeClientError(
'invalid_argument',
'--time cannot be used with the hourly automation preset; use a cron trigger such as "30 * * * *" to choose the minute'
)
}
if (raw !== 'weekly' && flags.has('day')) {
throw new RuntimeClientError(
'invalid_argument',
'--day can only be used with the weekly automation preset'
)
}
}
function getOptionalDayFlag(flags: Map<string, string | boolean>): number | undefined {
const raw = flags.get('day')
if (raw === undefined) {
return undefined
}
const day = typeof raw === 'string' && /^\d+$/.test(raw) ? Number(raw) : Number.NaN
if (!Number.isInteger(day) || day < 0 || day > 6) {
throw new RuntimeClientError('invalid_argument', '--day must be an integer from 0 to 6')
}
return day
}
export function getProviderFlag(flags: Map<string, string | boolean>): TuiAgent {
const provider = getRequiredStringFlag(flags, 'provider')
if (!isTuiAgent(provider)) {
throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`)
}
return provider
}
export function getOptionalProviderFlag(
flags: Map<string, string | boolean>
): TuiAgent | undefined {
const provider = getOptionalStringFlag(flags, 'provider')
if (provider === undefined) {
return undefined
}
if (!isTuiAgent(provider)) {
throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`)
}
return provider
}
function getTimeFlag(flags: Map<string, string | boolean>): { hour: number; minute: number } {
const value = flags.get('time')
if (value === undefined) {
return { hour: 9, minute: 0 }
}
if (typeof value !== 'string' || value.length === 0) {
throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format')
}
return parseTimeFlag(value)
}
function parseTimeFlag(value: string): { hour: number; minute: number } {
const match = /^(\d{1,2}):(\d{2})$/.exec(value)
if (!match) {
throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format')
}
const hour = Number(match[1])
const minute = Number(match[2])
if (!Number.isInteger(hour) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
throw new RuntimeClientError('invalid_argument', '--time must be a valid 24-hour time')
}
return { hour, minute }
}
export function getScheduleFlag(
flags: Map<string, string | boolean>,
required: boolean
): { rrule: string; dtstart: number } | undefined {
const trigger = getOptionalStringFlag(flags, 'trigger')
const schedule = getOptionalStringFlag(flags, 'schedule')
if (trigger && schedule) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --trigger or --schedule, not both.'
)
}
const raw = trigger ?? schedule
if (!raw) {
const modifier = getScheduleModifierFlag(flags)
if (modifier) {
throw new RuntimeClientError(
'invalid_argument',
`--${modifier} requires --trigger or --schedule`
)
}
if (required) {
throw new RuntimeClientError('invalid_argument', 'Missing required --trigger')
}
return undefined
}
if (raw === 'manual') {
throw new RuntimeClientError(
'invalid_argument',
'Manual-only automations are not supported yet. Create a scheduled automation with --disabled and run it with `orca automations run <id>` when needed.'
)
}
validateScheduleModifierApplicability(flags, raw)
const { hour, minute } = raw === 'hourly' ? { hour: 0, minute: 0 } : getTimeFlag(flags)
const dayOfWeek = raw === 'weekly' ? (getOptionalDayFlag(flags) ?? 1) : 1
const rrule = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset)
? buildAutomationRrule({
preset: raw as Exclude<AutomationSchedulePreset, 'custom'>,
hour,
minute,
dayOfWeek
})
: raw
if (!isValidAutomationSchedule(rrule)) {
throw new RuntimeClientError('invalid_argument', `Invalid automation trigger: ${raw}`)
}
return { rrule, dtstart: Date.now() }
}
export function getEnabledFlag(flags: Map<string, string | boolean>): boolean | undefined {
const enabledFlag = flags.get('enabled')
const disabledFlag = flags.get('disabled')
if (typeof enabledFlag === 'string') {
throw new RuntimeClientError('invalid_argument', '--enabled does not take a value')
}
if (typeof disabledFlag === 'string') {
throw new RuntimeClientError('invalid_argument', '--disabled does not take a value')
}
const enabled = enabledFlag === true
const disabled = disabledFlag === true
if (enabled && disabled) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --enabled or --disabled, not both.'
)
}
if (enabled) {
return true
}
if (disabled) {
return false
}
return undefined
}
export function getReuseSessionFlag(flags: Map<string, string | boolean>): boolean | undefined {
const reuseFlag = flags.get('reuse-session')
const freshFlag = flags.get('fresh-session')
if (typeof reuseFlag === 'string') {
throw new RuntimeClientError('invalid_argument', '--reuse-session does not take a value')
}
if (typeof freshFlag === 'string') {
throw new RuntimeClientError('invalid_argument', '--fresh-session does not take a value')
}
const reuse = reuseFlag === true
const fresh = freshFlag === true
if (reuse && fresh) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --reuse-session or --fresh-session, not both.'
)
}
if (reuse) {
return true
}
if (fresh) {
return false
}
return undefined
}
export function getPrecheckFlag(
flags: Map<string, string | boolean>
): AutomationPrecheck | null | undefined {
const hasPrecheck = flags.has('precheck')
const timeoutSeconds = getOptionalPositiveIntegerFlag(flags, 'precheck-timeout')
if (!hasPrecheck) {
if (timeoutSeconds !== undefined) {
throw new RuntimeClientError('invalid_argument', '--precheck-timeout requires --precheck')
}
return undefined
}
const value = flags.get('precheck')
if (typeof value !== 'string') {
throw new RuntimeClientError('invalid_argument', '--precheck requires a command')
}
const command = value.trim()
if (!command) {
return null
}
if (timeoutSeconds !== undefined && timeoutSeconds > MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS) {
throw new RuntimeClientError(
'invalid_argument',
`--precheck-timeout must be at most ${MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS} seconds`
)
}
return {
command,
timeoutSeconds: timeoutSeconds ?? DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS
}
}
export function getSourceContextFlag(
flags: Map<string, string | boolean>
): TaskSourceContext | null | undefined {
if (!flags.has('source-context')) {
return undefined
}
const value = flags.get('source-context')
if (typeof value !== 'string') {
throw new RuntimeClientError(
'invalid_argument',
'--source-context requires a JSON TaskSourceContext or null'
)
}
let parsed: unknown
try {
parsed = JSON.parse(value)
} catch {
throw new RuntimeClientError('invalid_argument', '--source-context must be valid JSON')
}
if (parsed === null) {
return null
}
if (!parsed || typeof parsed !== 'object') {
throw new RuntimeClientError(
'invalid_argument',
'--source-context must be a JSON TaskSourceContext or null'
)
}
const sourceContext = normalizeTaskSourceContext(
parsed as Parameters<typeof normalizeTaskSourceContext>[0]
)
if (!sourceContext) {
throw new RuntimeClientError(
'invalid_argument',
'--source-context is not a valid TaskSourceContext'
)
}
return sourceContext
}
export function getWorkspaceModeFlag(
flags: Map<string, string | boolean>
): 'existing' | 'new_per_run' | undefined {
const value = getOptionalStringFlag(flags, 'workspace-mode')
if (value === undefined) {
return undefined
}
if (value === 'existing') {
return 'existing'
}
if (value === 'new-per-run' || value === 'new_per_run') {
return 'new_per_run'
}
throw new RuntimeClientError(
'invalid_argument',
'--workspace-mode must be existing or new-per-run'
)
}
+10 -299
View File
@@ -1,16 +1,11 @@
/* eslint-disable max-lines -- Why: automation handlers share schedule parsing, target resolution, and RPC payload shaping for one command family. */
import type {
Automation,
AutomationCreateInput,
AutomationPrecheck,
AutomationRun,
AutomationSchedulePreset,
AutomationUpdateInput
} from '../../shared/automations-types'
import {
buildWorkspaceRunContext,
normalizeTaskSourceContext,
type TaskSourceContext,
type WorkspaceRunContext
} from '../../shared/task-source-context'
import type {
@@ -18,13 +13,6 @@ import type {
AutomationOwnerPrecondition
} from '../../shared/automation-owner-precondition'
import type { ProjectHostSetup } from '../../shared/project-types'
import type { TuiAgent } from '../../shared/tui-agent'
import {
DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS,
MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS
} from '../../shared/automation-precheck'
import { buildAutomationRrule, isValidAutomationSchedule } from '../../shared/automation-schedules'
import { isTuiAgent } from '../../shared/tui-agent-config'
import type { CommandHandler } from '../dispatch'
import {
formatAutomationList,
@@ -48,6 +36,16 @@ import {
hasWorkspaceProjectTarget,
resolveProjectCreateTarget
} from '../worktree-project-target'
import {
getEnabledFlag,
getOptionalProviderFlag,
getPrecheckFlag,
getProviderFlag,
getReuseSessionFlag,
getScheduleFlag,
getSourceContextFlag,
getWorkspaceModeFlag
} from './automation-handler-flags'
type AutomationCreateParams = Omit<AutomationCreateInput, 'projectId' | 'timezone'> & {
destination?: AutomationDestination
@@ -61,293 +59,6 @@ type AutomationUpdateParams = AutomationUpdateInput & {
workspace?: string
}
const PRESET_TRIGGERS = new Set<AutomationSchedulePreset>(['hourly', 'daily', 'weekdays', 'weekly'])
const SCHEDULE_MODIFIER_FLAGS = ['day', 'time'] as const
function getScheduleModifierFlag(flags: Map<string, string | boolean>): string | undefined {
return SCHEDULE_MODIFIER_FLAGS.find((flag) => flags.has(flag))
}
function validateScheduleModifierApplicability(
flags: Map<string, string | boolean>,
raw: string
): void {
const isPreset = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset)
if (!isPreset) {
if (flags.has('time')) {
throw new RuntimeClientError(
'invalid_argument',
'--time can only be used with preset automation triggers'
)
}
if (flags.has('day')) {
throw new RuntimeClientError(
'invalid_argument',
'--day can only be used with the weekly automation preset'
)
}
return
}
if (raw === 'hourly' && flags.has('time')) {
throw new RuntimeClientError(
'invalid_argument',
'--time cannot be used with the hourly automation preset; use a cron trigger such as "30 * * * *" to choose the minute'
)
}
if (raw !== 'weekly' && flags.has('day')) {
throw new RuntimeClientError(
'invalid_argument',
'--day can only be used with the weekly automation preset'
)
}
}
function getOptionalDayFlag(flags: Map<string, string | boolean>): number | undefined {
const raw = flags.get('day')
if (raw === undefined) {
return undefined
}
const day = typeof raw === 'string' && /^\d+$/.test(raw) ? Number(raw) : Number.NaN
if (!Number.isInteger(day) || day < 0 || day > 6) {
throw new RuntimeClientError('invalid_argument', '--day must be an integer from 0 to 6')
}
return day
}
function getProviderFlag(flags: Map<string, string | boolean>): TuiAgent {
const provider = getRequiredStringFlag(flags, 'provider')
if (!isTuiAgent(provider)) {
throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`)
}
return provider
}
function getOptionalProviderFlag(flags: Map<string, string | boolean>): TuiAgent | undefined {
const provider = getOptionalStringFlag(flags, 'provider')
if (provider === undefined) {
return undefined
}
if (!isTuiAgent(provider)) {
throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`)
}
return provider
}
function getTimeFlag(flags: Map<string, string | boolean>): { hour: number; minute: number } {
const value = flags.get('time')
if (value === undefined) {
return { hour: 9, minute: 0 }
}
if (typeof value !== 'string' || value.length === 0) {
throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format')
}
return parseTimeFlag(value)
}
function parseTimeFlag(value: string): { hour: number; minute: number } {
const match = /^(\d{1,2}):(\d{2})$/.exec(value)
if (!match) {
throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format')
}
const hour = Number(match[1])
const minute = Number(match[2])
if (!Number.isInteger(hour) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
throw new RuntimeClientError('invalid_argument', '--time must be a valid 24-hour time')
}
return { hour, minute }
}
function getScheduleFlag(
flags: Map<string, string | boolean>,
required: boolean
): { rrule: string; dtstart: number } | undefined {
const trigger = getOptionalStringFlag(flags, 'trigger')
const schedule = getOptionalStringFlag(flags, 'schedule')
if (trigger && schedule) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --trigger or --schedule, not both.'
)
}
const raw = trigger ?? schedule
if (!raw) {
const modifier = getScheduleModifierFlag(flags)
if (modifier) {
throw new RuntimeClientError(
'invalid_argument',
`--${modifier} requires --trigger or --schedule`
)
}
if (required) {
throw new RuntimeClientError('invalid_argument', 'Missing required --trigger')
}
return undefined
}
if (raw === 'manual') {
throw new RuntimeClientError(
'invalid_argument',
'Manual-only automations are not supported yet. Create a scheduled automation with --disabled and run it with `orca automations run <id>` when needed.'
)
}
validateScheduleModifierApplicability(flags, raw)
const { hour, minute } = raw === 'hourly' ? { hour: 0, minute: 0 } : getTimeFlag(flags)
const dayOfWeek = raw === 'weekly' ? (getOptionalDayFlag(flags) ?? 1) : 1
const rrule = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset)
? buildAutomationRrule({
preset: raw as Exclude<AutomationSchedulePreset, 'custom'>,
hour,
minute,
dayOfWeek
})
: raw
if (!isValidAutomationSchedule(rrule)) {
throw new RuntimeClientError('invalid_argument', `Invalid automation trigger: ${raw}`)
}
return { rrule, dtstart: Date.now() }
}
function getEnabledFlag(flags: Map<string, string | boolean>): boolean | undefined {
const enabledFlag = flags.get('enabled')
const disabledFlag = flags.get('disabled')
if (typeof enabledFlag === 'string') {
throw new RuntimeClientError('invalid_argument', '--enabled does not take a value')
}
if (typeof disabledFlag === 'string') {
throw new RuntimeClientError('invalid_argument', '--disabled does not take a value')
}
const enabled = enabledFlag === true
const disabled = disabledFlag === true
if (enabled && disabled) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --enabled or --disabled, not both.'
)
}
if (enabled) {
return true
}
if (disabled) {
return false
}
return undefined
}
function getReuseSessionFlag(flags: Map<string, string | boolean>): boolean | undefined {
const reuseFlag = flags.get('reuse-session')
const freshFlag = flags.get('fresh-session')
if (typeof reuseFlag === 'string') {
throw new RuntimeClientError('invalid_argument', '--reuse-session does not take a value')
}
if (typeof freshFlag === 'string') {
throw new RuntimeClientError('invalid_argument', '--fresh-session does not take a value')
}
const reuse = reuseFlag === true
const fresh = freshFlag === true
if (reuse && fresh) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --reuse-session or --fresh-session, not both.'
)
}
if (reuse) {
return true
}
if (fresh) {
return false
}
return undefined
}
function getPrecheckFlag(
flags: Map<string, string | boolean>
): AutomationPrecheck | null | undefined {
const hasPrecheck = flags.has('precheck')
const timeoutSeconds = getOptionalPositiveIntegerFlag(flags, 'precheck-timeout')
if (!hasPrecheck) {
if (timeoutSeconds !== undefined) {
throw new RuntimeClientError('invalid_argument', '--precheck-timeout requires --precheck')
}
return undefined
}
const value = flags.get('precheck')
if (typeof value !== 'string') {
throw new RuntimeClientError('invalid_argument', '--precheck requires a command')
}
const command = value.trim()
if (!command) {
return null
}
if (timeoutSeconds !== undefined && timeoutSeconds > MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS) {
throw new RuntimeClientError(
'invalid_argument',
`--precheck-timeout must be at most ${MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS} seconds`
)
}
return {
command,
timeoutSeconds: timeoutSeconds ?? DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS
}
}
function getSourceContextFlag(
flags: Map<string, string | boolean>
): TaskSourceContext | null | undefined {
if (!flags.has('source-context')) {
return undefined
}
const value = flags.get('source-context')
if (typeof value !== 'string') {
throw new RuntimeClientError(
'invalid_argument',
'--source-context requires a JSON TaskSourceContext or null'
)
}
let parsed: unknown
try {
parsed = JSON.parse(value)
} catch {
throw new RuntimeClientError('invalid_argument', '--source-context must be valid JSON')
}
if (parsed === null) {
return null
}
if (!parsed || typeof parsed !== 'object') {
throw new RuntimeClientError(
'invalid_argument',
'--source-context must be a JSON TaskSourceContext or null'
)
}
const sourceContext = normalizeTaskSourceContext(
parsed as Parameters<typeof normalizeTaskSourceContext>[0]
)
if (!sourceContext) {
throw new RuntimeClientError(
'invalid_argument',
'--source-context is not a valid TaskSourceContext'
)
}
return sourceContext
}
function getWorkspaceModeFlag(
flags: Map<string, string | boolean>
): 'existing' | 'new_per_run' | undefined {
const value = getOptionalStringFlag(flags, 'workspace-mode')
if (value === undefined) {
return undefined
}
if (value === 'existing') {
return 'existing'
}
if (value === 'new-per-run' || value === 'new_per_run') {
return 'new_per_run'
}
throw new RuntimeClientError(
'invalid_argument',
'--workspace-mode must be existing or new-per-run'
)
}
async function resolveDefaultTarget(
flags: Map<string, string | boolean>,
cwd: string,
+3 -364
View File
@@ -1,371 +1,10 @@
/* eslint-disable max-lines -- Why: root and generated command help text live together so CLI discovery stays greppable. */
import type { CommandSpec } from './args'
import { findCommandSpec, isCommandGroup, supportsBrowserPageFlag } from './args'
import { unknownCommandData } from './command-suggestion'
import { ROOT_HELP_TEXT_PRIMARY } from './root-help-text-primary'
import { ROOT_HELP_TEXT_SECONDARY } from './root-help-text-secondary'
const ROOT_HELP_TEXT = `orca
Usage: orca <command> [options]
Startup:
open Launch Orca and wait for the runtime to be reachable
serve Start a headless Orca runtime server
status Show app/runtime/graph readiness
Diagnostics:
diagnostics memory Collect a memory snapshot for Orca and managed terminals
Agent Discovery:
agent-context Print the machine-readable command schema for agents
Accounts:
account add Add a managed Claude or Codex account on this Orca host
account list List managed Claude and Codex accounts on this Orca host
Skills:
skills installed List installed skill selectors
skills share Publish selected skills behind one unlisted link
skills list List version-matched skill guides bundled with this Orca CLI
skills get Print a version-matched skill guide as Markdown
skills install Install bundled Orca skills globally via the community skills CLI
skills update Update already-installed Orca skills via the community skills CLI
Hosts:
host list List targetable machines and how to name each one
Environments:
environment add Save a remote Orca runtime from a pairing code
environment list List saved remote Orca runtimes
environment show Show one saved remote Orca runtime
environment rm Remove a saved remote Orca runtime
Environment Recipes:
vm recipe doctor Validate a per-workspace environment recipe
Automations:
automations list List scheduled Orca automations
automations show Show one Orca automation
automations create Create a scheduled Orca automation
automations edit Edit an Orca automation
automations remove Remove an Orca automation and its run history
automations run Run an Orca automation now
automations runs List automation run history
Projects:
project list List durable projects known to Orca
project setups List project host setups
project setup-existing-folder Make a project available on a host by importing an existing folder
project setup-clone Make a project available on a host by cloning a repository
project setup-create Create independent project host setup metadata
project setup-update Update project host setup metadata
project setup-delete Remove a project host setup
Repos:
repo list List repos registered in Orca
repo add Add a project to Orca by filesystem path
repo show Show one registered repo
repo set-base-ref Set the repo's default base ref for future worktrees
repo search-refs Search branch/tag refs within a repo
Worktrees:
worktree list List Orca-managed worktrees
worktree show Show one worktree
worktree current Show the Orca-managed worktree for the current directory
worktree create Create a new Orca-managed worktree
worktree set Update Orca metadata for a worktree
worktree rm Remove a worktree from Orca and git
worktree ps Show a compact orchestration summary across worktrees
Files:
file open Open a workspace file in the Orca editor
file diff Open a workspace file diff in the Orca editor
file open-changed Open all git-changed files for a workspace
Terminals:
terminal list List live Orca-managed terminals
terminal show Show terminal metadata and preview
terminal read Read bounded terminal output
terminal send Send input to a live terminal
terminal wait Wait for a terminal condition (exit, tui-idle)
terminal stop Stop terminals for a worktree
terminal create Create a terminal session in a worktree
terminal rename Set or clear the title of a terminal tab
terminal split Split an existing terminal pane
terminal switch Bring a terminal tab to the foreground
terminal focus Alias for terminal switch
terminal close Close a terminal pane/session, or its whole tab with --tab
Orchestration:
orchestration run-create Create and bind a lightweight orchestration Run
orchestration run-use Bind this coordinator terminal to an existing Run
orchestration run-current Show this terminal's bound Run
orchestration run-list List lightweight orchestration Runs
orchestration run-show Show one lightweight orchestration Run
orchestration send Send an inter-agent message
orchestration check Check the bound Run mailbox
orchestration ask Ask the coordinator a blocking question
orchestration reply Reply to a message
orchestration inbox Show all messages across recipients
orchestration task-create Create an orchestration task
orchestration task-list List orchestration tasks
orchestration task-update Update a task status
orchestration dispatch Dispatch a task to a terminal
orchestration dispatch-show Show dispatch context for a task
orchestration worker-start Start a supervised worker locally or on a connected Orca server
orchestration worker-show Inspect one supervised worker
orchestration worker-read Read bounded output from one supervised worker
orchestration worker-stop Fence one Dispatch; stop only its supervised worker
orchestration worker-abandon Fence an uncertain worker without claiming it stopped
orchestration worker-release Release a settled worker's terminal after archiving its output
orchestration worker-retain Keep a worker terminal live for debugging
orchestration worker-list Report worker terminal resource accounting
orchestration coordinator-start Start the legacy automatic coordinator loop
orchestration coordinator-stop Stop the legacy automatic coordinator loop
orchestration gate-create Create a decision gate blocking a task
orchestration gate-resolve Resolve a pending decision gate
orchestration gate-list List decision gates
orchestration reset Reset orchestration state
Computer Use:
computer capabilities Show computer-use provider capabilities
computer permissions Show or open computer-use permission setup
computer list-apps List running apps available to computer-use
computer list-windows List visible windows for a target app
computer get-app-state Capture a compact accessibility snapshot of an app
computer click Click an app element or window coordinate
computer perform-secondary-action Run an advertised accessibility action
computer scroll Scroll an app element
computer drag Drag between app elements or window coordinates
computer type-text Type literal text at the current app focus
computer press-key Press a single key such as Return or Escape
computer hotkey Press a shortcut combination such as CmdOrCtrl+A
computer paste-text Paste text through the native clipboard path
computer set-value Set the value of a settable app element
Linear:
linear Read Linear ticket context for agents
Mobile Emulator (iOS Simulator):
emulator list List available/running emulators (Orca-managed + raw serve-sim)
emulator attach <device> Attach/start helper and make active for the worktree
emulator tap <x> <y> Tap at normalized 0..1 coords (preferred for single taps)
emulator type <text> Type text (US ASCII only)
emulator gesture <json> Send begin/move/end touch points
emulator button <name> Hardware button (home, side_button, etc.)
emulator rotate <o> Rotate device (portrait|landscape_left|...)
emulator exec --command Raw serve-sim subcommand passthrough (no "serve-sim" prefix)
emulator kill Stop helper for device
Browser Automation:
tab create Create a new browser tab (navigates to --url)
tab list List open browser tabs
tab show Show one browser tab by page id
tab current Show the current browser tab
tab profile list List browser session profiles
tab profile create Create a browser session profile
tab profile delete Delete a browser session profile
tab profile set Switch a browser tab to a different profile
tab profile show Show the profile bound to a browser tab
tab profile use-default Switch a browser tab back to the default profile
tab profile clone Clone a browser tab into another profile
tab switch Switch the active browser tab by --index or --page
tab close Close a browser tab by --index/--page or the current tab
snapshot Accessibility snapshot with element refs (e.g. @e1, @e2)
goto Navigate the active tab to --url
click Click element by --element ref
fill Clear and fill input by --element ref with --value
type Type --input text at the current focus (no element needed)
select Select dropdown option by --element ref and --value
hover Hover element by --element ref
keypress Press a key (e.g. --key Enter, --key Tab)
scroll Scroll --direction (up/down) by --amount pixels
back Navigate back in browser history
reload Reload the active browser tab
screenshot Capture viewport screenshot (--format png|jpeg)
eval Evaluate --expression JavaScript in the page context
wait Wait for page idle or --timeout ms
check Check a checkbox by --element ref
uncheck Uncheck a checkbox by --element ref
focus Focus an element by --element ref
clear Clear an input by --element ref
drag Drag --from ref to --to ref
upload Upload --files to a file input by --element ref
dblclick Double-click element by --element ref
forward Navigate forward in browser history
scrollintoview Scroll --element into view
get Get element property (--what: text, html, value, url, title)
is Check element state (--what: visible, enabled, checked)
inserttext Insert text without key events
mouse move Move mouse to --x --y coordinates
mouse down Press mouse button
mouse up Release mouse button
mouse wheel Scroll wheel --dy [--dx]
find Find element by locator (--locator role|text|label --value <v>)
set device Emulate device (--name "iPhone 12")
set offline Toggle offline mode (--state on|off)
set headers Set HTTP headers (--headers '{"key":"val"}')
set credentials Set HTTP auth (--user <u> --pass <p>)
set media Set color scheme (--color-scheme dark|light)
clipboard read Read clipboard contents
clipboard write Write --text to clipboard
dialog accept Accept browser dialog (--text for prompt response)
dialog dismiss Dismiss browser dialog
storage local get Get localStorage value by --key
storage local set Set localStorage --key --value
storage local clear Clear localStorage
storage session get Get sessionStorage value by --key
storage session set Set sessionStorage --key --value
storage session clear Clear sessionStorage
download Download file via --selector to --path
highlight Highlight --selector on page
exec Run any agent-browser command (--command "...")
Common Commands:
orca open [--json]
orca serve [--port <port>] [--pairing-address <host>] [--mobile-pairing] [--no-pairing] [--project-root <path>] [--recipe-json] [--json]
orca status [--json]
orca diagnostics memory [--json]
orca agent-context [--json]
orca account add [--agent claude|codex] [--json]
orca account list [--json]
orca host list [--json]
orca environment add --name <name> --pairing-code <code> [--json]
orca environment list [--json]
orca environment show --environment <selector> [--json]
orca environment rm --environment <selector> [--json]
orca worktree list [--repo <selector>] [--limit <n>] [--json]
orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]
orca worktree show --worktree <selector> [--json]
orca worktree current [--json]
orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--linear-issue <identifier-or-url|null>] [--comment <text>] [--workspace-status <id>] [--parent-worktree <selector>|--no-parent] [--json]
orca worktree rm --worktree <selector> [--force] [--run-hooks] [--json]
orca worktree ps [--limit <n>] [--json]
orca file open <path> [--worktree <selector>] [--json]
orca file diff <path> [--staged] [--worktree <selector>] [--json]
orca file open-changed [--mode edit|diff|both] [--worktree <selector>] [--json]
orca terminal list [--worktree <selector>] [--limit <n>] [--include-visual-layouts] [--json]
orca terminal show [--terminal <handle>] [--json]
orca terminal read [--terminal <handle>] [--cursor <n>] [--limit <n>] [--json]
orca terminal send [--terminal <handle>] [--text <text>] [--enter] [--interrupt] [--json]
orca terminal wait [--terminal <handle>] --for exit|tui-idle [--timeout-ms <ms>] [--json]
orca terminal stop --worktree <selector> [--json]
orca terminal create [--worktree <selector>] [--title <name>] [--command <text>] [--focus] [--json]
orca terminal split [--terminal <handle>] [--direction horizontal|vertical] [--json]
orca terminal switch [--terminal <handle>] [--json]
orca terminal close [--terminal <handle>] [--tab] [--json]
orca project list [--json]
orca project setups [--project <id>] [--host <host-id>] [--json]
orca project setup-existing-folder --project <id> --host <host-id> --path <path> [--kind git|folder] [--display-name <name>] [--json]
orca project setup-clone --project <id> --host <host-id> --url <clone-url> --destination <path> [--display-name <name>] [--json]
orca project setup-create --project <id> --host <host-id> [--setup-id <id>] [--path <path>] [--kind git|folder] [--display-name <name>] [--worktree-base-path <path>] [--git-username <name>] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json]
orca project setup-update --setup <setup-id> [--display-name <name>] [--path <path>] [--worktree-base-path <path>] [--git-username <name>] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json]
orca project setup-delete --setup <setup-id> [--json]
orca repo list [--json]
orca repo add --path <path> [--json]
orca repo show --repo <selector> [--json]
orca repo set-base-ref --repo <selector> --ref <ref> [--json]
orca repo search-refs --repo <selector> --query <text> [--limit <n>] [--json]
Selectors:
--repo <selector> Registered repo selector such as id:<id>, name:<name>, or path:<path>
--worktree <selector> Worktree selector such as identity:<identity>, id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current
--terminal <handle> Runtime-issued terminal handle returned by \`orca terminal list --json\`
--parent-worktree <selector> Parent worktree selector such as identity:<identity>, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, or active/current
--no-parent Force no parent lineage for unrelated worktree creation/update
Terminal Send Options:
--text <text> Text to send to the terminal
--enter Append Enter after sending text
--interrupt Send as an interrupt-style input when supported
Terminal List Options:
--include-visual-layouts Include tab and pane topology in JSON output
Wait Options:
--for exit Wait until the target terminal exits
--timeout-ms <ms> Maximum wait time before timing out
Output Options:
--json Emit machine-readable JSON instead of human text
--pairing-code <code> Connect to a remote Orca runtime using an orca://pair?... code
--environment <selector> Connect using a saved environment id or name
--help Show this help message
Behavior:
Most commands require a running Orca runtime. If Orca is not open yet, run \`orca open\` first.
Remote runtime access can also be supplied with ORCA_PAIRING_CODE or ORCA_ENVIRONMENT.
Use selectors for discovery and handles for repeated live terminal operations.
Agent Sessions And Worktrees:
\`worktree create --agent\` creates a new checkout with an agent.
To start a fresh agent in the current worktree, use:
orca terminal create --worktree active --command "codex"
Browser Workflow:
1. Create or navigate: orca tab create --url https://example.com
orca goto --url https://example.com
2. Inspect the page: orca snapshot
(Returns an accessibility tree with element refs like e1, e2, e3)
For concurrent workflows, prefer: orca tab list --json
then reuse tabs[].browserPageId with --page <id> on later commands.
3. Interact: orca click --element e2
orca fill --element e5 --value "search query"
orca keypress --key Enter
4. Re-inspect: orca snapshot
(Element refs change after navigation — always re-snapshot before interacting)
Browser Options:
--element <ref> Element ref from snapshot (e.g. @e3)
--url <url> URL to navigate to
--value <text> Value to fill or select
--input <text> Text to type at current focus (no element needed)
--expression <js> JavaScript expression to evaluate
--key <key> Key to press (Enter, Tab, Escape, Control+a, etc.)
--direction <dir> Scroll direction: up or down
--amount <pixels> Scroll distance in pixels (default: viewport height)
--index <n> Tab index (from \`tab list\`)
--page <id> Stable browser page id (preferred for concurrent workflows)
--profile <id> Browser profile id
--show-profile Include the tab's browser profile in text output
--no-ua-spoof Keep Electron's native user agent for a new profile
--format <png|jpeg> Screenshot image format
--from <ref> Drag source element ref
--to <ref> Drag target element ref
--files <path,...> Comma-separated file paths for upload
--timeout <ms> Wait timeout in milliseconds
--worktree <selector> Scope commands to a specific worktree's browser tabs
Examples:
$ orca open
$ orca status --json
$ orca diagnostics memory --json
$ orca repo list
$ orca worktree create --name agent-task --agent codex --prompt "hi"
$ orca worktree create --repo name:orca --name cli-test-1 --issue 273
$ orca worktree create --repo name:orca --name linear-task --linear-issue https://linear.app/stably/issue/STA-335/test-issue
$ orca worktree create --name linear-task --linear-issue STA-335
$ orca worktree show --worktree branch:Jinwoo-H/cli
$ orca worktree current
$ orca worktree set --worktree active --comment "waiting on review"
$ orca worktree set --worktree active --linear-issue null
$ orca worktree ps --limit 10
$ orca file open-changed --mode diff
$ orca file open src/App.tsx
$ orca terminal create --worktree active --command "codex"
$ orca terminal list --worktree path:/Users/me/orca/workspaces/orca/cli-test-1 --json
$ orca terminal send --terminal term_123 --text "hi" --enter
$ orca terminal wait --terminal term_123 --for exit --timeout-ms 60000 --json
$ orca tab current --json
$ orca tab show --page page_123 --json
$ orca tab create --url https://example.com --profile work
$ orca tab profile clone --page page_123 --profile work --json
$ orca snapshot
$ orca click --element e3
$ orca fill --element e5 --value "hello"
$ orca goto --url https://example.com/login
$ orca keypress --key Enter
$ orca eval --expression "document.title"
$ orca tab list --json`
const ROOT_HELP_TEXT = [ROOT_HELP_TEXT_PRIMARY, ROOT_HELP_TEXT_SECONDARY].join('\n')
export function printHelp(specs: CommandSpec[], commandPath: string[] = []): void {
const exactSpec = findCommandSpec(specs, commandPath)
+184
View File
@@ -0,0 +1,184 @@
export const ROOT_HELP_TEXT_PRIMARY = [
'orca',
'',
'Usage: orca <command> [options]',
'',
'Startup:',
' open Launch Orca and wait for the runtime to be reachable',
' serve Start a headless Orca runtime server',
' status Show app/runtime/graph readiness',
'',
'Diagnostics:',
' diagnostics memory Collect a memory snapshot for Orca and managed terminals',
'',
'Agent Discovery:',
' agent-context Print the machine-readable command schema for agents',
'',
'Accounts:',
' account add Add a managed Claude or Codex account on this Orca host',
' account list List managed Claude and Codex accounts on this Orca host',
'',
'Skills:',
' skills installed List installed skill selectors',
' skills share Publish selected skills behind one unlisted link',
' skills list List version-matched skill guides bundled with this Orca CLI',
' skills get Print a version-matched skill guide as Markdown',
' skills install Install bundled Orca skills globally via the community skills CLI',
' skills update Update already-installed Orca skills via the community skills CLI',
'',
'Hosts:',
' host list List targetable machines and how to name each one',
'',
'Environments:',
' environment add Save a remote Orca runtime from a pairing code',
' environment list List saved remote Orca runtimes',
' environment show Show one saved remote Orca runtime',
' environment rm Remove a saved remote Orca runtime',
'',
'Environment Recipes:',
' vm recipe doctor Validate a per-workspace environment recipe',
'',
'Automations:',
' automations list List scheduled Orca automations',
' automations show Show one Orca automation',
' automations create Create a scheduled Orca automation',
' automations edit Edit an Orca automation',
' automations remove Remove an Orca automation and its run history',
' automations run Run an Orca automation now',
' automations runs List automation run history',
'',
'Projects:',
' project list List durable projects known to Orca',
' project setups List project host setups',
' project setup-existing-folder Make a project available on a host by importing an existing folder',
' project setup-clone Make a project available on a host by cloning a repository',
' project setup-create Create independent project host setup metadata',
' project setup-update Update project host setup metadata',
' project setup-delete Remove a project host setup',
'',
'Repos:',
' repo list List repos registered in Orca',
' repo add Add a project to Orca by filesystem path',
' repo show Show one registered repo',
" repo set-base-ref Set the repo's default base ref for future worktrees",
' repo search-refs Search branch/tag refs within a repo',
'',
'Worktrees:',
' worktree list List Orca-managed worktrees',
' worktree show Show one worktree',
' worktree current Show the Orca-managed worktree for the current directory',
' worktree create Create a new Orca-managed worktree',
' worktree set Update Orca metadata for a worktree',
' worktree rm Remove a worktree from Orca and git',
' worktree ps Show a compact orchestration summary across worktrees',
'',
'Files:',
' file open Open a workspace file in the Orca editor',
' file diff Open a workspace file diff in the Orca editor',
' file open-changed Open all git-changed files for a workspace',
'',
'Terminals:',
' terminal list List live Orca-managed terminals',
' terminal show Show terminal metadata and preview',
' terminal read Read bounded terminal output',
' terminal send Send input to a live terminal',
' terminal wait Wait for a terminal condition (exit, tui-idle)',
' terminal stop Stop terminals for a worktree',
' terminal create Create a terminal session in a worktree',
' terminal rename Set or clear the title of a terminal tab',
' terminal split Split an existing terminal pane',
' terminal switch Bring a terminal tab to the foreground',
' terminal focus Alias for terminal switch',
' terminal close Close a terminal pane/session, or its whole tab with --tab',
'',
'Orchestration:',
' orchestration run-create Create and bind a lightweight orchestration Run',
' orchestration run-use Bind this coordinator terminal to an existing Run',
" orchestration run-current Show this terminal's bound Run",
' orchestration run-list List lightweight orchestration Runs',
' orchestration run-show Show one lightweight orchestration Run',
' orchestration send Send an inter-agent message',
' orchestration check Check the bound Run mailbox',
' orchestration ask Ask the coordinator a blocking question',
' orchestration reply Reply to a message',
' orchestration inbox Show all messages across recipients',
' orchestration task-create Create an orchestration task',
' orchestration task-list List orchestration tasks',
' orchestration task-update Update a task status',
' orchestration dispatch Dispatch a task to a terminal',
' orchestration dispatch-show Show dispatch context for a task',
' orchestration worker-start Start a supervised worker locally or on a connected Orca server',
' orchestration worker-show Inspect one supervised worker',
' orchestration worker-read Read bounded output from one supervised worker',
' orchestration worker-stop Fence one Dispatch; stop only its supervised worker',
' orchestration worker-abandon Fence an uncertain worker without claiming it stopped',
" orchestration worker-release Release a settled worker's terminal after archiving its output",
' orchestration worker-retain Keep a worker terminal live for debugging',
' orchestration worker-list Report worker terminal resource accounting',
' orchestration coordinator-start Start the legacy automatic coordinator loop',
' orchestration coordinator-stop Stop the legacy automatic coordinator loop',
' orchestration gate-create Create a decision gate blocking a task',
' orchestration gate-resolve Resolve a pending decision gate',
' orchestration gate-list List decision gates',
' orchestration reset Reset orchestration state',
'',
'Computer Use:',
' computer capabilities Show computer-use provider capabilities',
' computer permissions Show or open computer-use permission setup',
' computer list-apps List running apps available to computer-use',
' computer list-windows List visible windows for a target app',
' computer get-app-state Capture a compact accessibility snapshot of an app',
' computer click Click an app element or window coordinate',
' computer perform-secondary-action Run an advertised accessibility action',
' computer scroll Scroll an app element',
' computer drag Drag between app elements or window coordinates',
' computer type-text Type literal text at the current app focus',
' computer press-key Press a single key such as Return or Escape',
' computer hotkey Press a shortcut combination such as CmdOrCtrl+A',
' computer paste-text Paste text through the native clipboard path',
' computer set-value Set the value of a settable app element',
'',
'Linear:',
' linear Read Linear ticket context for agents',
'',
'Mobile Emulator (iOS Simulator):',
' emulator list List available/running emulators (Orca-managed + raw serve-sim)',
' emulator attach <device> Attach/start helper and make active for the worktree',
' emulator tap <x> <y> Tap at normalized 0..1 coords (preferred for single taps)',
' emulator type <text> Type text (US ASCII only)',
' emulator gesture <json> Send begin/move/end touch points',
' emulator button <name> Hardware button (home, side_button, etc.)',
' emulator rotate <o> Rotate device (portrait|landscape_left|...)',
' emulator exec --command Raw serve-sim subcommand passthrough (no "serve-sim" prefix)',
' emulator kill Stop helper for device',
'',
'Browser Automation:',
' tab create Create a new browser tab (navigates to --url)',
' tab list List open browser tabs',
' tab show Show one browser tab by page id',
' tab current Show the current browser tab',
' tab profile list List browser session profiles',
' tab profile create Create a browser session profile',
' tab profile delete Delete a browser session profile',
' tab profile set Switch a browser tab to a different profile',
' tab profile show Show the profile bound to a browser tab',
' tab profile use-default Switch a browser tab back to the default profile',
' tab profile clone Clone a browser tab into another profile',
' tab switch Switch the active browser tab by --index or --page',
' tab close Close a browser tab by --index/--page or the current tab',
' snapshot Accessibility snapshot with element refs (e.g. @e1, @e2)',
' goto Navigate the active tab to --url',
' click Click element by --element ref',
' fill Clear and fill input by --element ref with --value',
' type Type --input text at the current focus (no element needed)',
' select Select dropdown option by --element ref and --value',
' hover Hover element by --element ref',
' keypress Press a key (e.g. --key Enter, --key Tab)',
' scroll Scroll --direction (up/down) by --amount pixels',
' back Navigate back in browser history',
' reload Reload the active browser tab',
' screenshot Capture viewport screenshot (--format png|jpeg)',
' eval Evaluate --expression JavaScript in the page context',
' wait Wait for page idle or --timeout ms',
' check Check a checkbox by --element ref'
].join('\n')
+183
View File
@@ -0,0 +1,183 @@
export const ROOT_HELP_TEXT_SECONDARY = [
' uncheck Uncheck a checkbox by --element ref',
' focus Focus an element by --element ref',
' clear Clear an input by --element ref',
' drag Drag --from ref to --to ref',
' upload Upload --files to a file input by --element ref',
' dblclick Double-click element by --element ref',
' forward Navigate forward in browser history',
' scrollintoview Scroll --element into view',
' get Get element property (--what: text, html, value, url, title)',
' is Check element state (--what: visible, enabled, checked)',
' inserttext Insert text without key events',
' mouse move Move mouse to --x --y coordinates',
' mouse down Press mouse button',
' mouse up Release mouse button',
' mouse wheel Scroll wheel --dy [--dx]',
' find Find element by locator (--locator role|text|label --value <v>)',
' set device Emulate device (--name "iPhone 12")',
' set offline Toggle offline mode (--state on|off)',
' set headers Set HTTP headers (--headers \'{"key":"val"}\')',
' set credentials Set HTTP auth (--user <u> --pass <p>)',
' set media Set color scheme (--color-scheme dark|light)',
' clipboard read Read clipboard contents',
' clipboard write Write --text to clipboard',
' dialog accept Accept browser dialog (--text for prompt response)',
' dialog dismiss Dismiss browser dialog',
' storage local get Get localStorage value by --key',
' storage local set Set localStorage --key --value',
' storage local clear Clear localStorage',
' storage session get Get sessionStorage value by --key',
' storage session set Set sessionStorage --key --value',
' storage session clear Clear sessionStorage',
' download Download file via --selector to --path',
' highlight Highlight --selector on page',
' exec Run any agent-browser command (--command "...")',
'',
'Common Commands:',
' orca open [--json]',
' orca serve [--port <port>] [--pairing-address <host>] [--mobile-pairing] [--no-pairing] [--project-root <path>] [--recipe-json] [--json]',
' orca status [--json]',
' orca diagnostics memory [--json]',
' orca agent-context [--json]',
' orca account add [--agent claude|codex] [--json]',
' orca account list [--json]',
' orca host list [--json]',
' orca environment add --name <name> --pairing-code <code> [--json]',
' orca environment list [--json]',
' orca environment show --environment <selector> [--json]',
' orca environment rm --environment <selector> [--json]',
' orca worktree list [--repo <selector>] [--limit <n>] [--json]',
' orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]',
' orca worktree show --worktree <selector> [--json]',
' orca worktree current [--json]',
' orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--linear-issue <identifier-or-url|null>] [--comment <text>] [--workspace-status <id>] [--parent-worktree <selector>|--no-parent] [--json]',
' orca worktree rm --worktree <selector> [--force] [--run-hooks] [--json]',
' orca worktree ps [--limit <n>] [--json]',
' orca file open <path> [--worktree <selector>] [--json]',
' orca file diff <path> [--staged] [--worktree <selector>] [--json]',
' orca file open-changed [--mode edit|diff|both] [--worktree <selector>] [--json]',
' orca terminal list [--worktree <selector>] [--limit <n>] [--include-visual-layouts] [--json]',
' orca terminal show [--terminal <handle>] [--json]',
' orca terminal read [--terminal <handle>] [--cursor <n>] [--limit <n>] [--json]',
' orca terminal send [--terminal <handle>] [--text <text>] [--enter] [--interrupt] [--json]',
' orca terminal wait [--terminal <handle>] --for exit|tui-idle [--timeout-ms <ms>] [--json]',
' orca terminal stop --worktree <selector> [--json]',
' orca terminal create [--worktree <selector>] [--title <name>] [--command <text>] [--focus] [--json]',
' orca terminal split [--terminal <handle>] [--direction horizontal|vertical] [--json]',
' orca terminal switch [--terminal <handle>] [--json]',
' orca terminal close [--terminal <handle>] [--tab] [--json]',
' orca project list [--json]',
' orca project setups [--project <id>] [--host <host-id>] [--json]',
' orca project setup-existing-folder --project <id> --host <host-id> --path <path> [--kind git|folder] [--display-name <name>] [--json]',
' orca project setup-clone --project <id> --host <host-id> --url <clone-url> --destination <path> [--display-name <name>] [--json]',
' orca project setup-create --project <id> --host <host-id> [--setup-id <id>] [--path <path>] [--kind git|folder] [--display-name <name>] [--worktree-base-path <path>] [--git-username <name>] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json]',
' orca project setup-update --setup <setup-id> [--display-name <name>] [--path <path>] [--worktree-base-path <path>] [--git-username <name>] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json]',
' orca project setup-delete --setup <setup-id> [--json]',
' orca repo list [--json]',
' orca repo add --path <path> [--json]',
' orca repo show --repo <selector> [--json]',
' orca repo set-base-ref --repo <selector> --ref <ref> [--json]',
' orca repo search-refs --repo <selector> --query <text> [--limit <n>] [--json]',
'',
'Selectors:',
' --repo <selector> Registered repo selector such as id:<id>, name:<name>, or path:<path>',
' --worktree <selector> Worktree selector such as identity:<identity>, id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current',
' --terminal <handle> Runtime-issued terminal handle returned by `orca terminal list --json`',
' --parent-worktree <selector> Parent worktree selector such as identity:<identity>, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, or active/current',
' --no-parent Force no parent lineage for unrelated worktree creation/update',
'',
'Terminal Send Options:',
' --text <text> Text to send to the terminal',
' --enter Append Enter after sending text',
' --interrupt Send as an interrupt-style input when supported',
'',
'Terminal List Options:',
' --include-visual-layouts Include tab and pane topology in JSON output',
'',
'Wait Options:',
' --for exit Wait until the target terminal exits',
' --timeout-ms <ms> Maximum wait time before timing out',
'',
'Output Options:',
' --json Emit machine-readable JSON instead of human text',
' --pairing-code <code> Connect to a remote Orca runtime using an orca://pair?... code',
' --environment <selector> Connect using a saved environment id or name',
' --help Show this help message',
'',
'Behavior:',
' Most commands require a running Orca runtime. If Orca is not open yet, run `orca open` first.',
' Remote runtime access can also be supplied with ORCA_PAIRING_CODE or ORCA_ENVIRONMENT.',
' Use selectors for discovery and handles for repeated live terminal operations.',
'',
'Agent Sessions And Worktrees:',
' `worktree create --agent` creates a new checkout with an agent.',
' To start a fresh agent in the current worktree, use:',
' orca terminal create --worktree active --command "codex"',
'',
'Browser Workflow:',
' 1. Create or navigate: orca tab create --url https://example.com',
' orca goto --url https://example.com',
' 2. Inspect the page: orca snapshot',
' (Returns an accessibility tree with element refs like e1, e2, e3)',
' For concurrent workflows, prefer: orca tab list --json',
' then reuse tabs[].browserPageId with --page <id> on later commands.',
' 3. Interact: orca click --element e2',
' orca fill --element e5 --value "search query"',
' orca keypress --key Enter',
' 4. Re-inspect: orca snapshot',
' (Element refs change after navigation — always re-snapshot before interacting)',
'',
'Browser Options:',
' --element <ref> Element ref from snapshot (e.g. @e3)',
' --url <url> URL to navigate to',
' --value <text> Value to fill or select',
' --input <text> Text to type at current focus (no element needed)',
' --expression <js> JavaScript expression to evaluate',
' --key <key> Key to press (Enter, Tab, Escape, Control+a, etc.)',
' --direction <dir> Scroll direction: up or down',
' --amount <pixels> Scroll distance in pixels (default: viewport height)',
' --index <n> Tab index (from `tab list`)',
' --page <id> Stable browser page id (preferred for concurrent workflows)',
' --profile <id> Browser profile id',
" --show-profile Include the tab's browser profile in text output",
" --no-ua-spoof Keep Electron's native user agent for a new profile",
' --format <png|jpeg> Screenshot image format',
' --from <ref> Drag source element ref',
' --to <ref> Drag target element ref',
' --files <path,...> Comma-separated file paths for upload',
' --timeout <ms> Wait timeout in milliseconds',
" --worktree <selector> Scope commands to a specific worktree's browser tabs",
'',
'Examples:',
' $ orca open',
' $ orca status --json',
' $ orca diagnostics memory --json',
' $ orca repo list',
' $ orca worktree create --name agent-task --agent codex --prompt "hi"',
' $ orca worktree create --repo name:orca --name cli-test-1 --issue 273',
' $ orca worktree create --repo name:orca --name linear-task --linear-issue https://linear.app/stably/issue/STA-335/test-issue',
' $ orca worktree create --name linear-task --linear-issue STA-335',
' $ orca worktree show --worktree branch:Jinwoo-H/cli',
' $ orca worktree current',
' $ orca worktree set --worktree active --comment "waiting on review"',
' $ orca worktree set --worktree active --linear-issue null',
' $ orca worktree ps --limit 10',
' $ orca file open-changed --mode diff',
' $ orca file open src/App.tsx',
' $ orca terminal create --worktree active --command "codex"',
' $ orca terminal list --worktree path:/Users/me/orca/workspaces/orca/cli-test-1 --json',
' $ orca terminal send --terminal term_123 --text "hi" --enter',
' $ orca terminal wait --terminal term_123 --for exit --timeout-ms 60000 --json',
' $ orca tab current --json',
' $ orca tab show --page page_123 --json',
' $ orca tab create --url https://example.com --profile work',
' $ orca tab profile clone --page page_123 --profile work --json',
' $ orca snapshot',
' $ orca click --element e3',
' $ orca fill --element e5 --value "hello"',
' $ orca goto --url https://example.com/login',
' $ orca keypress --key Enter',
' $ orca eval --expression "document.title"',
' $ orca tab list --json'
].join('\n')
+17 -198
View File
@@ -1,14 +1,10 @@
/* eslint-disable max-lines -- Why: WSL CLI status/install/remove share one state machine;
splitting the installer would separate conflict checks from the operations they guard. */
import type { CliInstallStatus } from '../../shared/cli-install-types'
import { getDefaultWslDistro } from '../wsl'
import { runWslProcess } from '../wsl/wsl-runner'
import { CliInstaller } from './cli-installer'
import {
buildManagedLegacyRemoveCommand,
buildRegistrationLockPrelude,
buildSafeRemoveCommand,
buildSafeReplaceGuard,
buildWslBridgeScript,
buildWslLauncher,
getBridgePathFromCommandPath,
@@ -18,10 +14,11 @@ import {
parseManagedLauncherTarget,
quoteShell
} from './wsl-cli-scripts'
import { buildWslCliInstallCommand } from './wsl-cli-registration-command'
import { buildWslCliStatus, readWslCliCommandFile, resolveReadyWslCliState } from './wsl-cli-status'
const MANAGED_MARKER = getWslLauncherMarker()
const BRIDGE_MANAGED_MARKER = getWslBridgeMarker()
const WSL_COMMAND_NAME = 'orca-ide'
const LEGACY_WSL_COMMAND_NAME = 'orca'
const WSL_COMMAND_TIMEOUT_MS = 10_000
@@ -60,7 +57,12 @@ export class WslCliInstaller {
}
async getStatus(): Promise<CliInstallStatus> {
const ready = await this.resolveReadyState()
const ready = await resolveReadyWslCliState({
platform: this.platform,
distro: this.distro,
getHostStatus: () => this.hostInstaller.getStatus(),
run: (distro, command) => this.run(distro, command)
})
if ('status' in ready) {
return ready.status
}
@@ -208,67 +210,13 @@ export class WslCliInstaller {
throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}.`)
}
// Why: the launcher and PowerShell bridge are one registration; the
// command replacement stays a single atomic rename (never missing for a
// concurrent shell) while a bridge copy enables rollback of the pair.
await this.run(
this.distro as string,
[
// Why -eu not -euo pipefail: transported via runWslProcess's `sh -s`,
// and no pipe here needs pipefail -- dash on Ubuntu 20.04 lacks the option.
'set -eu',
`mkdir -p ${quoteShell(status.pathDirectory as string)}`,
`mkdir -p ${quoteShell(getPosixDirname(getBridgePathFromCommandPath(status.commandPath)))}`,
buildRegistrationLockPrelude(status.commandPath),
`command_tmp=${quoteShell(`${status.commandPath}.tmp`)}.$$`,
`bridge_path=${quoteShell(getBridgePathFromCommandPath(status.commandPath))}`,
`legacy_command_path=${quoteShell(
`${getPosixDirname(status.commandPath)}/${LEGACY_WSL_COMMAND_NAME}`
)}`,
'bridge_tmp="${bridge_path}.tmp.$$"',
'bridge_backup="${bridge_tmp}.backup"',
'bridge_had_original=0',
'bridge_touched=0',
'committed=0',
'rollback() {',
' result=$?',
' set +e',
' if [ "$committed" -ne 1 ]; then',
` if [ "$bridge_had_original" -eq 1 ]; then mv -f "$bridge_backup" ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}; elif [ "$bridge_touched" -eq 1 ]; then rm -f ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}; fi`,
' fi',
' rm -f "$command_tmp" "$bridge_tmp" "$bridge_backup"',
' exit "$result"',
'}',
'trap rollback EXIT',
buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER),
buildSafeReplaceGuard(
getBridgePathFromCommandPath(status.commandPath),
BRIDGE_MANAGED_MARKER
),
`cat > "$command_tmp" <<'ORCA_WSL_CLI'`,
buildWslLauncher(status.launcherPath, getBridgePathFromCommandPath(status.commandPath)),
'ORCA_WSL_CLI',
`cat > "$bridge_tmp" <<'ORCA_WSL_BRIDGE'`,
buildWslBridgeScript(),
'ORCA_WSL_BRIDGE',
'chmod 755 "$command_tmp"',
'chmod 644 "$bridge_tmp"',
buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER),
buildSafeReplaceGuard(
getBridgePathFromCommandPath(status.commandPath),
BRIDGE_MANAGED_MARKER
),
`if [ -f ${quoteShell(getBridgePathFromCommandPath(status.commandPath))} ]; then cp -p ${quoteShell(getBridgePathFromCommandPath(status.commandPath))} "$bridge_backup"; bridge_had_original=1; fi`,
`mv -f "$bridge_tmp" ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}`,
'bridge_touched=1',
`mv -f "$command_tmp" ${quoteShell(status.commandPath)}`,
'committed=1',
'rm -f "$bridge_backup"',
// Why: the command was renamed to avoid GNOME Orca; remove only the
// old Orca-managed WSL wrapper after the replacement has committed.
buildManagedLegacyRemoveCommand('"$legacy_command_path"'),
'trap - EXIT'
].join('\n')
buildWslCliInstallCommand({
...status,
commandPath: status.commandPath,
launcherPath: status.launcherPath
})
)
return this.getStatus()
}
@@ -299,108 +247,15 @@ export class WslCliInstaller {
return this.getStatus()
}
private async resolveReadyState(): Promise<
| { status: CliInstallStatus }
| {
distro: string
commandPath: string
bridgePath: string
launcherPath: string
pathConfigured: boolean
}
> {
if (this.platform !== 'win32') {
return {
status: this.unsupported(
'platform_not_supported',
'WSL CLI registration is only available on Windows.'
)
}
}
if (!this.distro) {
return {
status: this.unsupported('platform_not_supported', 'No WSL distribution is available.')
}
}
const hostStatus = await this.hostInstaller.getStatus()
if (!hostStatus.launcherPath) {
return {
status: this.unsupported(
hostStatus.unsupportedReason ?? 'launcher_missing',
hostStatus.detail ?? 'The Windows Orca CLI launcher is missing.'
)
}
}
const home = (await this.run(this.distro, 'printf %s "$HOME"')).trim()
if (!home.startsWith('/')) {
return {
status: this.unsupported('launcher_missing', 'Unable to resolve the WSL home directory.')
}
}
const interopReady =
(
await this.run(
this.distro,
'{ command -v powershell.exe >/dev/null 2>&1 || [ -x /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe ]; } && command -v wslpath >/dev/null 2>&1 && printf yes || printf no'
)
).trim() === 'yes'
if (!interopReady) {
return {
status: this.unsupported(
'launcher_missing',
'WSL Windows interop is unavailable; Orca cannot launch the Windows CLI from WSL.'
)
}
}
const pathDirectory = `${home}/.local/bin`
// Why: matches the Linux CLI rename to `orca-ide` (avoids GNOME Orca conflict).
const commandPath = `${pathDirectory}/${WSL_COMMAND_NAME}`
const pathConfigured =
(
await this.run(
this.distro,
`case ":$PATH:" in *:${quoteShell(pathDirectory)}:*) printf yes ;; *) printf no ;; esac`
)
).trim() === 'yes'
return {
distro: this.distro,
commandPath,
bridgePath: getBridgePathFromCommandPath(commandPath),
launcherPath: hostStatus.launcherPath,
pathConfigured
}
}
private async readCommandFile(
distro: string,
commandPath: string
): Promise<(string & {}) | 'not_file' | null> {
const output = await this.run(
return readWslCliCommandFile(
(targetDistro, command) => this.run(targetDistro, command),
distro,
[
`if [ -L ${quoteShell(commandPath)} ]; then`,
' printf __ORCA_NOT_FILE__',
`elif [ ! -e ${quoteShell(commandPath)} ]; then`,
' printf __ORCA_MISSING__',
`elif [ ! -f ${quoteShell(commandPath)} ]; then`,
' printf __ORCA_NOT_FILE__',
'else',
` cat ${quoteShell(commandPath)}`,
'fi'
].join('\n')
commandPath
)
if (output === '__ORCA_MISSING__') {
return null
}
if (output === '__ORCA_NOT_FILE__') {
return 'not_file'
}
return output
}
private buildStatus(args: {
@@ -412,43 +267,7 @@ export class WslCliInstaller {
pathConfigured: boolean
detail: string
}): CliInstallStatus {
return {
platform: 'linux',
commandName: WSL_COMMAND_NAME,
commandPath: args.commandPath,
pathDirectory: getPosixDirname(args.commandPath),
pathConfigured: args.pathConfigured,
launcherPath: args.launcherPath,
installMethod: 'wrapper',
supported: true,
state: args.state,
currentTarget: args.currentTarget,
unsupportedReason: null,
detail:
args.state === 'installed' && !args.pathConfigured
? `${args.commandPath} is registered, but ${getPosixDirname(args.commandPath)} is not on PATH in ${args.distro}.`
: args.detail
}
}
private unsupported(
unsupportedReason: NonNullable<CliInstallStatus['unsupportedReason']>,
detail: string
): CliInstallStatus {
return {
platform: 'linux',
commandName: WSL_COMMAND_NAME,
commandPath: null,
pathDirectory: null,
pathConfigured: false,
launcherPath: null,
installMethod: null,
supported: false,
state: 'unsupported',
currentTarget: null,
unsupportedReason,
detail
}
return buildWslCliStatus(args)
}
private async run(distro: string, command: string): Promise<string> {
@@ -0,0 +1,70 @@
import type { CliInstallStatus } from '../../shared/cli-install-types'
import {
buildManagedLegacyRemoveCommand,
buildRegistrationLockPrelude,
buildSafeReplaceGuard,
buildWslBridgeScript,
buildWslLauncher,
getBridgePathFromCommandPath,
getPosixDirname,
getWslBridgeMarker,
getWslLauncherMarker,
quoteShell
} from './wsl-cli-scripts'
const MANAGED_MARKER = getWslLauncherMarker()
const BRIDGE_MANAGED_MARKER = getWslBridgeMarker()
const LEGACY_WSL_COMMAND_NAME = 'orca'
export function buildWslCliInstallCommand(
status: CliInstallStatus & { commandPath: string; launcherPath: string }
): string {
const bridgePath = getBridgePathFromCommandPath(status.commandPath)
const legacyCommandPath = `${getPosixDirname(status.commandPath)}/${LEGACY_WSL_COMMAND_NAME}`
return [
// Why -eu not -euo pipefail: transported via runWslProcess's `sh -s`,
// and no pipe here needs pipefail -- dash on Ubuntu 20.04 lacks the option.
'set -eu',
`mkdir -p ${quoteShell(status.pathDirectory as string)}`,
`mkdir -p ${quoteShell(getPosixDirname(bridgePath))}`,
buildRegistrationLockPrelude(status.commandPath),
`command_tmp=${quoteShell(`${status.commandPath}.tmp`)}.$$`,
`bridge_path=${quoteShell(bridgePath)}`,
`legacy_command_path=${quoteShell(legacyCommandPath)}`,
'bridge_tmp="${bridge_path}.tmp.$$"',
'bridge_backup="${bridge_tmp}.backup"',
'bridge_had_original=0',
'bridge_touched=0',
'committed=0',
'rollback() {',
' result=$?',
' set +e',
' if [ "$committed" -ne 1 ]; then',
` if [ "$bridge_had_original" -eq 1 ]; then mv -f "$bridge_backup" ${quoteShell(bridgePath)}; elif [ "$bridge_touched" -eq 1 ]; then rm -f ${quoteShell(bridgePath)}; fi`,
' fi',
' rm -f "$command_tmp" "$bridge_tmp" "$bridge_backup"',
' exit "$result"',
'}',
'trap rollback EXIT',
buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER),
buildSafeReplaceGuard(bridgePath, BRIDGE_MANAGED_MARKER),
`cat > "$command_tmp" <<'ORCA_WSL_CLI'`,
buildWslLauncher(status.launcherPath, bridgePath),
'ORCA_WSL_CLI',
`cat > "$bridge_tmp" <<'ORCA_WSL_BRIDGE'`,
buildWslBridgeScript(),
'ORCA_WSL_BRIDGE',
'chmod 755 "$command_tmp"',
'chmod 644 "$bridge_tmp"',
buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER),
buildSafeReplaceGuard(bridgePath, BRIDGE_MANAGED_MARKER),
`if [ -f ${quoteShell(bridgePath)} ]; then cp -p ${quoteShell(bridgePath)} "$bridge_backup"; bridge_had_original=1; fi`,
`mv -f "$bridge_tmp" ${quoteShell(bridgePath)}`,
'bridge_touched=1',
`mv -f "$command_tmp" ${quoteShell(status.commandPath)}`,
'committed=1',
'rm -f "$bridge_backup"',
buildManagedLegacyRemoveCommand('"$legacy_command_path"'),
'trap - EXIT'
].join('\n')
}
+163
View File
@@ -0,0 +1,163 @@
import type { CliInstallStatus } from '../../shared/cli-install-types'
import { getBridgePathFromCommandPath, getPosixDirname, quoteShell } from './wsl-cli-scripts'
const WSL_COMMAND_NAME = 'orca-ide'
export type ReadyWslCliState = {
distro: string
commandPath: string
bridgePath: string
launcherPath: string
pathConfigured: boolean
}
export async function readWslCliCommandFile(
run: (distro: string, command: string) => Promise<string>,
distro: string,
commandPath: string
): Promise<(string & {}) | 'not_file' | null> {
const output = await run(
distro,
[
`if [ -L ${quoteShell(commandPath)} ]; then`,
' printf __ORCA_NOT_FILE__',
`elif [ ! -e ${quoteShell(commandPath)} ]; then`,
' printf __ORCA_MISSING__',
`elif [ ! -f ${quoteShell(commandPath)} ]; then`,
' printf __ORCA_NOT_FILE__',
'else',
` cat ${quoteShell(commandPath)}`,
'fi'
].join('\n')
)
if (output === '__ORCA_MISSING__') {
return null
}
if (output === '__ORCA_NOT_FILE__') {
return 'not_file'
}
return output
}
export function buildWslCliStatus(args: {
distro: string
commandPath: string
launcherPath: string
state: CliInstallStatus['state']
currentTarget: string | null
pathConfigured: boolean
detail: string
}): CliInstallStatus {
return {
platform: 'linux',
commandName: WSL_COMMAND_NAME,
commandPath: args.commandPath,
pathDirectory: getPosixDirname(args.commandPath),
pathConfigured: args.pathConfigured,
launcherPath: args.launcherPath,
installMethod: 'wrapper',
supported: true,
state: args.state,
currentTarget: args.currentTarget,
unsupportedReason: null,
detail:
args.state === 'installed' && !args.pathConfigured
? `${args.commandPath} is registered, but ${getPosixDirname(args.commandPath)} is not on PATH in ${args.distro}.`
: args.detail
}
}
export function unsupportedWslCliStatus(
unsupportedReason: NonNullable<CliInstallStatus['unsupportedReason']>,
detail: string
): CliInstallStatus {
return {
platform: 'linux',
commandName: WSL_COMMAND_NAME,
commandPath: null,
pathDirectory: null,
pathConfigured: false,
launcherPath: null,
installMethod: null,
supported: false,
state: 'unsupported',
currentTarget: null,
unsupportedReason,
detail
}
}
export async function resolveReadyWslCliState(args: {
platform: NodeJS.Platform
distro: string | null
getHostStatus: () => Promise<CliInstallStatus>
run: (distro: string, command: string) => Promise<string>
}): Promise<{ status: CliInstallStatus } | ReadyWslCliState> {
if (args.platform !== 'win32') {
return {
status: unsupportedWslCliStatus(
'platform_not_supported',
'WSL CLI registration is only available on Windows.'
)
}
}
if (!args.distro) {
return {
status: unsupportedWslCliStatus('platform_not_supported', 'No WSL distribution is available.')
}
}
const hostStatus = await args.getHostStatus()
if (!hostStatus.launcherPath) {
return {
status: unsupportedWslCliStatus(
hostStatus.unsupportedReason ?? 'launcher_missing',
hostStatus.detail ?? 'The Windows Orca CLI launcher is missing.'
)
}
}
const home = (await args.run(args.distro, 'printf %s "$HOME"')).trim()
if (!home.startsWith('/')) {
return {
status: unsupportedWslCliStatus(
'launcher_missing',
'Unable to resolve the WSL home directory.'
)
}
}
const interopReady =
(
await args.run(
args.distro,
'{ command -v powershell.exe >/dev/null 2>&1 || [ -x /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe ]; } && command -v wslpath >/dev/null 2>&1 && printf yes || printf no'
)
).trim() === 'yes'
if (!interopReady) {
return {
status: unsupportedWslCliStatus(
'launcher_missing',
'WSL Windows interop is unavailable; Orca cannot launch the Windows CLI from WSL.'
)
}
}
const pathDirectory = `${home}/.local/bin`
const commandPath = `${pathDirectory}/${WSL_COMMAND_NAME}`
const pathConfigured =
(
await args.run(
args.distro,
`case ":$PATH:" in *:${quoteShell(pathDirectory)}:*) printf yes ;; *) printf no ;; esac`
)
).trim() === 'yes'
return {
distro: args.distro,
commandPath,
bridgePath: getBridgePathFromCommandPath(commandPath),
launcherPath: hostStatus.launcherPath,
pathConfigured
}
}
@@ -0,0 +1,241 @@
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
import { dirname } from 'node:path'
import {
findKeybindingConflicts,
getKeybindingDefinition,
isKeybindingActionId,
normalizeKeybindingArrayForAction,
normalizeKeybindingListForAction,
type KeybindingActionId,
type KeybindingFileDiagnostic,
type KeybindingOverrides,
type KeybindingPlatform
} from '../../shared/keybindings'
export type JsonObject = Record<string, unknown>
export const FILE_VERSION = 1
const PLATFORM_KEYS: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32']
const ROOT_KEYS = new Set(['$schema', 'version', 'keybindings', 'platforms'])
export function isJsonObject(value: unknown): value is JsonObject {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
export function createEmptyDocument(): JsonObject {
return {
version: FILE_VERSION,
keybindings: {},
platforms: {
darwin: {},
linux: {},
win32: {}
}
}
}
export function readJsonDocument(path: string): {
exists: boolean
document: JsonObject | null
error?: string
} {
if (!existsSync(path)) {
return { exists: false, document: createEmptyDocument() }
}
try {
const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown
if (!isJsonObject(parsed)) {
return { exists: true, document: null, error: 'Keybindings file must contain a JSON object.' }
}
return { exists: true, document: parsed }
} catch (error) {
return {
exists: true,
document: null,
error: error instanceof Error ? error.message : String(error)
}
}
}
export function writeJsonDocument(path: string, document: JsonObject): void {
mkdirSync(dirname(path), { recursive: true })
const tempPath = `${path}.tmp`
try {
writeFileSync(tempPath, `${JSON.stringify(document, null, 2)}\n`, 'utf8')
renameSync(tempPath, path)
} catch (error) {
try {
if (existsSync(tempPath)) {
unlinkSync(tempPath)
}
} catch {
// Ignore cleanup failure; the original write error is more actionable.
}
throw error
}
}
function normalizeBindingValue(
actionId: KeybindingActionId,
value: unknown
): { ok: true; value: string[] } | { ok: false; error: string } {
if (value === null || value === false) {
return { ok: true, value: [] }
}
if (typeof value === 'string') {
const normalized = normalizeKeybindingListForAction(actionId, value)
return Array.isArray(normalized)
? { ok: true, value: normalized }
: normalized.ok
? { ok: true, value: [normalized.value] }
: normalized
}
if (Array.isArray(value)) {
if (!value.every((item) => typeof item === 'string')) {
return { ok: false, error: 'Use a string, string array, null, or false.' }
}
const normalized = normalizeKeybindingArrayForAction(actionId, value)
return Array.isArray(normalized)
? { ok: true, value: normalized }
: normalized.ok
? { ok: true, value: [normalized.value] }
: normalized
}
return { ok: false, error: 'Use a string, string array, null, or false.' }
}
export function normalizeWriteBindingValue(
actionId: KeybindingActionId,
value: unknown
): string[] | null {
if (value === null) {
return null
}
if (!Array.isArray(value) || !value.every((binding) => typeof binding === 'string')) {
throw new Error('Use a string array or null.')
}
const normalized = normalizeKeybindingArrayForAction(actionId, value)
if (!Array.isArray(normalized)) {
throw new Error(normalized.ok ? 'Unable to parse shortcut.' : normalized.error)
}
return normalized
}
export function parseBindingSection(
value: unknown,
section: string,
diagnostics: KeybindingFileDiagnostic[],
options: { skipRootKeys?: boolean } = {}
): KeybindingOverrides {
if (value === undefined) {
return {}
}
if (!isJsonObject(value)) {
diagnostics.push({
severity: 'error',
section,
message: `${section} must be an object.`
})
return {}
}
const overrides: KeybindingOverrides = {}
for (const [actionId, rawBinding] of Object.entries(value)) {
if (options.skipRootKeys && ROOT_KEYS.has(actionId)) {
continue
}
if (!isKeybindingActionId(actionId)) {
diagnostics.push({
severity: 'warning',
section,
actionId,
message: `Unknown keybinding action "${actionId}" was ignored.`
})
continue
}
const normalized = normalizeBindingValue(actionId, rawBinding)
if (!normalized.ok) {
diagnostics.push({
severity: 'error',
section,
actionId,
message: `Shortcut for "${actionId}" was ignored: ${normalized.error}`
})
continue
}
overrides[actionId] = normalized.value
}
return overrides
}
export function parsePlatformOverrides(
document: JsonObject,
diagnostics: KeybindingFileDiagnostic[]
): Partial<Record<KeybindingPlatform, KeybindingOverrides>> {
const rawPlatforms = document.platforms
if (rawPlatforms === undefined) {
return {}
}
if (!isJsonObject(rawPlatforms)) {
diagnostics.push({
severity: 'error',
section: 'platforms',
message: 'platforms must be an object with darwin, linux, or win32 sections.'
})
return {}
}
const result: Partial<Record<KeybindingPlatform, KeybindingOverrides>> = {}
for (const [platform, value] of Object.entries(rawPlatforms)) {
if (!PLATFORM_KEYS.includes(platform as KeybindingPlatform)) {
diagnostics.push({
severity: 'warning',
section: `platforms.${platform}`,
message: `Unknown platform "${platform}" was ignored.`
})
continue
}
result[platform as KeybindingPlatform] = parseBindingSection(
value,
`platforms.${platform}`,
diagnostics
)
}
return result
}
export function removeConflictingOverrides(
platform: KeybindingPlatform,
overrides: KeybindingOverrides,
diagnostics: KeybindingFileDiagnostic[]
): KeybindingOverrides {
let next = { ...overrides }
for (let attempt = 0; attempt < 20; attempt++) {
const conflicts = findKeybindingConflicts(platform, next)
const conflictingOverrides = new Set<KeybindingActionId>()
for (const conflict of conflicts) {
for (const actionId of conflict.actionIds) {
if (Object.hasOwn(next, actionId)) {
conflictingOverrides.add(actionId)
}
}
}
if (conflictingOverrides.size === 0) {
return next
}
for (const actionId of conflictingOverrides) {
delete next[actionId]
}
diagnostics.push({
severity: 'error',
message: `Conflicting custom shortcuts were ignored: ${Array.from(conflictingOverrides)
.map((actionId) => getKeybindingDefinition(actionId)?.title ?? actionId)
.join(', ')}.`
})
}
return next
}
+15 -232
View File
@@ -1,250 +1,33 @@
/* eslint-disable max-lines -- Why: parsing, sanitizing, migrating, and writing the keybindings file must stay together so file-format edge cases share one validation path. */
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import {
findKeybindingConflicts,
formatKeybindingList,
getKeybindingDefinition,
getKeybindingPlatform,
isKeybindingActionId,
normalizeKeybindingArrayForAction,
normalizeKeybindingListForAction,
type KeybindingActionId,
type KeybindingFileDiagnostic,
type KeybindingFileSnapshot,
type KeybindingOverrides,
type KeybindingPlatform
type KeybindingOverrides
} from '../../shared/keybindings'
type JsonObject = Record<string, unknown>
const FILE_VERSION = 1
const PLATFORM_KEYS: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32']
const ROOT_KEYS = new Set(['$schema', 'version', 'keybindings', 'platforms'])
import {
createEmptyDocument,
FILE_VERSION,
isJsonObject,
normalizeWriteBindingValue,
parseBindingSection,
parsePlatformOverrides,
readJsonDocument,
removeConflictingOverrides,
writeJsonDocument,
type JsonObject
} from './keybinding-file-parser'
export function getUserKeybindingsPath(homePath: string): string {
return join(homePath, '.orca', 'keybindings.json')
}
function isJsonObject(value: unknown): value is JsonObject {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function createEmptyDocument(): JsonObject {
return {
version: FILE_VERSION,
keybindings: {},
platforms: {
darwin: {},
linux: {},
win32: {}
}
}
}
function readJsonDocument(path: string): {
exists: boolean
document: JsonObject | null
error?: string
} {
if (!existsSync(path)) {
return { exists: false, document: createEmptyDocument() }
}
try {
const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown
if (!isJsonObject(parsed)) {
return { exists: true, document: null, error: 'Keybindings file must contain a JSON object.' }
}
return { exists: true, document: parsed }
} catch (error) {
return {
exists: true,
document: null,
error: error instanceof Error ? error.message : String(error)
}
}
}
function writeJsonDocument(path: string, document: JsonObject): void {
mkdirSync(dirname(path), { recursive: true })
const tempPath = `${path}.tmp`
try {
writeFileSync(tempPath, `${JSON.stringify(document, null, 2)}\n`, 'utf8')
renameSync(tempPath, path)
} catch (error) {
try {
if (existsSync(tempPath)) {
unlinkSync(tempPath)
}
} catch {
// Ignore cleanup failure; the original write error is more actionable.
}
throw error
}
}
function normalizeBindingValue(
actionId: KeybindingActionId,
value: unknown
): { ok: true; value: string[] } | { ok: false; error: string } {
if (value === null || value === false) {
return { ok: true, value: [] }
}
if (typeof value === 'string') {
const normalized = normalizeKeybindingListForAction(actionId, value)
return Array.isArray(normalized)
? { ok: true, value: normalized }
: normalized.ok
? { ok: true, value: [normalized.value] }
: normalized
}
if (Array.isArray(value)) {
if (!value.every((item) => typeof item === 'string')) {
return { ok: false, error: 'Use a string, string array, null, or false.' }
}
const normalized = normalizeKeybindingArrayForAction(actionId, value)
return Array.isArray(normalized)
? { ok: true, value: normalized }
: normalized.ok
? { ok: true, value: [normalized.value] }
: normalized
}
return { ok: false, error: 'Use a string, string array, null, or false.' }
}
function normalizeWriteBindingValue(actionId: KeybindingActionId, value: unknown): string[] | null {
if (value === null) {
return null
}
if (!Array.isArray(value) || !value.every((binding) => typeof binding === 'string')) {
throw new Error('Use a string array or null.')
}
const normalized = normalizeKeybindingArrayForAction(actionId, value)
if (!Array.isArray(normalized)) {
throw new Error(normalized.ok ? 'Unable to parse shortcut.' : normalized.error)
}
return normalized
}
function parseBindingSection(
value: unknown,
section: string,
diagnostics: KeybindingFileDiagnostic[],
options: { skipRootKeys?: boolean } = {}
): KeybindingOverrides {
if (value === undefined) {
return {}
}
if (!isJsonObject(value)) {
diagnostics.push({
severity: 'error',
section,
message: `${section} must be an object.`
})
return {}
}
const overrides: KeybindingOverrides = {}
for (const [actionId, rawBinding] of Object.entries(value)) {
if (options.skipRootKeys && ROOT_KEYS.has(actionId)) {
continue
}
if (!isKeybindingActionId(actionId)) {
diagnostics.push({
severity: 'warning',
section,
actionId,
message: `Unknown keybinding action "${actionId}" was ignored.`
})
continue
}
const normalized = normalizeBindingValue(actionId, rawBinding)
if (!normalized.ok) {
diagnostics.push({
severity: 'error',
section,
actionId,
message: `Shortcut for "${actionId}" was ignored: ${normalized.error}`
})
continue
}
overrides[actionId] = normalized.value
}
return overrides
}
function parsePlatformOverrides(
document: JsonObject,
diagnostics: KeybindingFileDiagnostic[]
): Partial<Record<KeybindingPlatform, KeybindingOverrides>> {
const rawPlatforms = document.platforms
if (rawPlatforms === undefined) {
return {}
}
if (!isJsonObject(rawPlatforms)) {
diagnostics.push({
severity: 'error',
section: 'platforms',
message: 'platforms must be an object with darwin, linux, or win32 sections.'
})
return {}
}
const result: Partial<Record<KeybindingPlatform, KeybindingOverrides>> = {}
for (const [platform, value] of Object.entries(rawPlatforms)) {
if (!PLATFORM_KEYS.includes(platform as KeybindingPlatform)) {
diagnostics.push({
severity: 'warning',
section: `platforms.${platform}`,
message: `Unknown platform "${platform}" was ignored.`
})
continue
}
result[platform as KeybindingPlatform] = parseBindingSection(
value,
`platforms.${platform}`,
diagnostics
)
}
return result
}
function removeConflictingOverrides(
platform: KeybindingPlatform,
overrides: KeybindingOverrides,
diagnostics: KeybindingFileDiagnostic[]
): KeybindingOverrides {
let next = { ...overrides }
for (let attempt = 0; attempt < 20; attempt++) {
const conflicts = findKeybindingConflicts(platform, next)
const conflictingOverrides = new Set<KeybindingActionId>()
for (const conflict of conflicts) {
for (const actionId of conflict.actionIds) {
if (Object.hasOwn(next, actionId)) {
conflictingOverrides.add(actionId)
}
}
}
if (conflictingOverrides.size === 0) {
return next
}
for (const actionId of conflictingOverrides) {
delete next[actionId]
}
diagnostics.push({
severity: 'error',
message: `Conflicting custom shortcuts were ignored: ${Array.from(conflictingOverrides)
.map((actionId) => getKeybindingDefinition(actionId)?.title ?? actionId)
.join(', ')}.`
})
}
return next
}
export function readKeybindingFile(
path: string,
platform: NodeJS.Platform = process.platform
+46 -168
View File
@@ -1,6 +1,3 @@
/* eslint-disable max-lines -- Why: the collector's host-process index,
history ring buffer, Electron bucketing, and worktree attribution live
together to keep one snapshot's orchestration in one place. */
/**
* Memory dashboard collector.
*
@@ -17,11 +14,9 @@
* renderer polls never produces overlapping child processes.
*/
import { basename } from 'node:path'
import { exec } from 'node:child_process'
import { promisify } from 'node:util'
import os from 'node:os'
import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id'
import {
getProcessOutputFields,
iterateProcessOutputLines
@@ -30,7 +25,6 @@ import { getAppEnvironment, type AppEnvironment } from '../../shared/app-environ
import type {
AppMemory,
MemorySnapshot,
ProcessCommitMetric,
SessionMemory,
UsageValues,
WorktreeMemory
@@ -39,8 +33,24 @@ import type { Store } from '../persistence'
import { ORPHAN_WORKTREE_ID } from '../../shared/constants'
import { listRegisteredPtys } from './pty-registry'
import { enumerateWindowsProcessResources } from './windows-process-resource-collector'
import { collectHostMemory, fallbackHostMemory } from './host-memory'
import { collectHostMemory } from './host-memory'
import { getProcessMemoryMetric } from './process-memory-metric'
import {
createEmptyWorktreeMemoryBucket,
pushAppMemoryHistory,
pushMemoryHistorySample,
readAppMemoryHistory,
readMemoryHistory,
resolveWorktreeMemoryNames,
sweepStaleMemoryHistory,
type WorktreeMemoryBucket
} from './memory-snapshot-buckets'
import {
clampMemoryMetric,
emptyMemorySnapshot,
optionalCommitField,
snapshotCommitFields
} from './memory-snapshot-values'
export type MemorySnapshotStore = Pick<Store, 'getRepo' | 'getWorktreeMeta'>
@@ -61,7 +71,7 @@ export async function collectMemorySnapshot(store: MemorySnapshotStore): Promise
inflight = runSnapshot(store)
.catch((err) => {
console.warn('[memory] snapshot failed; returning empty', err)
return emptySnapshot()
return emptyMemorySnapshot()
})
.finally(() => {
inflight = null
@@ -99,88 +109,6 @@ type ProcIndex = {
hasPrivateMemory: boolean
}
const PROCESS_COMMIT_METRIC: ProcessCommitMetric = 'private-bytes'
/**
* The one rule for every committed-bytes key: present only when the sweep could
* measure it, because a 0 would read as "these processes commit nothing".
*/
function commitField(hasPrivateMemory: boolean, privateMemory: number): { privateMemory?: number } {
return hasPrivateMemory ? { privateMemory: clampNumber(privateMemory) } : {}
}
/** The snapshot-level pair, which names the unit alongside the total. */
function snapshotCommitFields(
hasPrivateMemory: boolean,
totalPrivateMemory: number
): Pick<MemorySnapshot, 'processCommitMetric' | 'totalPrivateMemory'> {
return hasPrivateMemory
? {
processCommitMetric: PROCESS_COMMIT_METRIC,
totalPrivateMemory: clampNumber(totalPrivateMemory)
}
: {}
}
function clampNumber(value: unknown): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return 0
}
return Math.max(0, value)
}
function emptySnapshot(): MemorySnapshot {
const zero = { cpu: 0, memory: 0 }
return {
app: { ...zero, main: zero, renderer: zero, other: zero, history: [] },
worktrees: [],
host: fallbackHostMemory(),
processMemoryMetric: getProcessMemoryMetric(),
totalCpu: 0,
totalMemory: 0,
collectedAt: Date.now()
}
}
// ─── History ring buffers ───────────────────────────────────────────
const APP_HISTORY_KEY = '__app__'
const HISTORY_CAPACITY = 60
const HISTORY_STALE_MS = 10 * 60 * 1000
type HistoryRing = {
samples: number[]
touchedAt: number
}
const historyByKey = new Map<string, HistoryRing>()
function pushHistorySample(key: string, memoryBytes: number, now: number): void {
let ring = historyByKey.get(key)
if (!ring) {
ring = { samples: [], touchedAt: now }
historyByKey.set(key, ring)
}
ring.samples.push(memoryBytes)
if (ring.samples.length > HISTORY_CAPACITY) {
ring.samples.shift()
}
ring.touchedAt = now
}
function readHistory(key: string): number[] {
const ring = historyByKey.get(key)
return ring ? [...ring.samples] : []
}
function sweepStaleHistory(now: number): void {
for (const [key, ring] of historyByKey) {
if (now - ring.touchedAt > HISTORY_STALE_MS) {
historyByKey.delete(key)
}
}
}
// ─── Host process enumeration ───────────────────────────────────────
async function enumerateProcesses(): Promise<ProcIndex> {
@@ -292,7 +220,7 @@ function electronMetricMemoryBytes(
// Why: on macOS, getAppEnvironment().getAppMetrics().workingSetSize can include large shared
// Chromium/Electron mappings. Prefer the host RSS sweep used elsewhere, but
// keep workingSetSize as a fallback when the process disappears mid-snapshot.
return clampNumber(proc.memory?.workingSetSize) * 1024
return clampMemoryMetric(proc.memory?.workingSetSize) * 1024
}
function bucketElectronMetrics(processIndex: ProcIndex): AppBucketsRaw {
@@ -301,11 +229,11 @@ function bucketElectronMetrics(processIndex: ProcIndex): AppBucketsRaw {
const other = { cpu: 0, memory: 0, privateMemory: 0 }
for (const proc of getAppEnvironment().getAppMetrics()) {
const cpu = clampNumber(proc.cpu?.percentCPUUsage)
const cpu = clampMemoryMetric(proc.cpu?.percentCPUUsage)
const memoryBytes = electronMetricMemoryBytes(proc, processIndex)
// Why the host row rather than Electron's own metric: getAppMetrics has no
// commit figure for helper processes, and the sweep already indexed them.
const privateBytes = clampNumber(processIndex.byPid.get(proc.pid)?.privateMemory)
const privateBytes = clampMemoryMetric(processIndex.byPid.get(proc.pid)?.privateMemory)
// Why: lowercase once so future Electron versions emitting different
// casing ('browser' vs 'Browser') still bucket correctly.
@@ -325,7 +253,7 @@ function bucketElectronMetrics(processIndex: ProcIndex): AppBucketsRaw {
const usage = (bucket: typeof main): UsageValues => ({
cpu: bucket.cpu,
memory: bucket.memory,
...commitField(processIndex.hasPrivateMemory, bucket.privateMemory)
...optionalCommitField(processIndex.hasPrivateMemory, bucket.privateMemory)
})
return {
@@ -340,61 +268,6 @@ function bucketElectronMetrics(processIndex: ProcIndex): AppBucketsRaw {
}
}
// ─── Worktree attribution ───────────────────────────────────────────
type WorktreeBucket = {
worktreeId: string
worktreeName: string
repoId: string
repoName: string
cpu: number
memory: number
privateMemory: number
sessions: SessionMemory[]
}
function resolveWorktreeNames(
worktreeId: string,
store: MemorySnapshotStore
): {
worktreeName: string
repoId: string
repoName: string
} {
// Orca worktree ids look like `${repoId}::${absolutePath}`.
const parsed = splitWorktreeIdForFilesystem(worktreeId)
const repoId = parsed?.repoId ?? worktreeId
const worktreePath = parsed?.worktreePath ?? ''
const fallbackName = worktreePath ? basename(worktreePath) : worktreeId
const meta = store.getWorktreeMeta(worktreeId)
const repo = store.getRepo(repoId)
return {
worktreeName: meta?.displayName?.trim() || fallbackName,
repoId,
repoName: repo?.displayName?.trim() || repoId || 'Unknown Repo'
}
}
function makeEmptyBucket(
worktreeId: string,
worktreeName: string,
repoId: string,
repoName: string
): WorktreeBucket {
return {
worktreeId,
worktreeName,
repoId,
repoName,
cpu: 0,
memory: 0,
privateMemory: 0,
sessions: []
}
}
// ─── Main collection path ───────────────────────────────────────────
async function runSnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot> {
@@ -408,13 +281,13 @@ async function runSnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot>
// attribute to the first PTY (registration order) to see each pid.
const claimed = new Set<number>()
const orphan = makeEmptyBucket(
const orphan = createEmptyWorktreeMemoryBucket(
ORPHAN_WORKTREE_ID,
'Unattributed terminals',
ORPHAN_WORKTREE_ID,
'Other'
)
const worktreeBuckets = new Map<string, WorktreeBucket>()
const worktreeBuckets = new Map<string, WorktreeMemoryBucket>()
for (const pty of ptys) {
let sessionCpu = 0
@@ -435,7 +308,7 @@ async function runSnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot>
sessionMemory += row.memory
// Why the whole subtree: an agent's committed bytes live in the
// children it spawned (codex.exe, MCP servers), not in the shell.
sessionPrivateMemory += clampNumber(row.privateMemory)
sessionPrivateMemory += clampMemoryMetric(row.privateMemory)
}
}
@@ -443,19 +316,24 @@ async function runSnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot>
sessionId: pty.sessionId ?? pty.ptyId,
paneKey: pty.paneKey,
pid: pty.pid ?? 0,
cpu: clampNumber(sessionCpu),
memory: clampNumber(sessionMemory),
...commitField(processIndex.hasPrivateMemory, sessionPrivateMemory)
cpu: clampMemoryMetric(sessionCpu),
memory: clampMemoryMetric(sessionMemory),
...optionalCommitField(processIndex.hasPrivateMemory, sessionPrivateMemory)
}
let bucket: WorktreeBucket
let bucket: WorktreeMemoryBucket
if (pty.worktreeId) {
const existing = worktreeBuckets.get(pty.worktreeId)
if (existing) {
bucket = existing
} else {
const names = resolveWorktreeNames(pty.worktreeId, store)
bucket = makeEmptyBucket(pty.worktreeId, names.worktreeName, names.repoId, names.repoName)
const names = resolveWorktreeMemoryNames(pty.worktreeId, store)
bucket = createEmptyWorktreeMemoryBucket(
pty.worktreeId,
names.worktreeName,
names.repoId,
names.repoName
)
worktreeBuckets.set(pty.worktreeId, bucket)
}
} else {
@@ -464,11 +342,11 @@ async function runSnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot>
bucket.cpu += session.cpu
bucket.memory += session.memory
bucket.privateMemory += clampNumber(session.privateMemory)
bucket.privateMemory += clampMemoryMetric(session.privateMemory)
bucket.sessions.push(session)
}
const bucketList: WorktreeBucket[] = [...worktreeBuckets.values()]
const bucketList: WorktreeMemoryBucket[] = [...worktreeBuckets.values()]
if (orphan.sessions.length > 0) {
bucketList.push(orphan)
}
@@ -477,16 +355,16 @@ async function runSnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot>
// returned arrays end with the freshly-collected value. Each write also
// acts as a keep-alive so active worktrees survive the staleness sweep.
const now = Date.now()
pushHistorySample(APP_HISTORY_KEY, appBuckets.memory, now)
pushAppMemoryHistory(appBuckets.memory, now)
for (const bucket of bucketList) {
pushHistorySample(bucket.worktreeId, bucket.memory, now)
pushMemoryHistorySample(bucket.worktreeId, bucket.memory, now)
}
sweepStaleHistory(now)
sweepStaleMemoryHistory(now)
const worktrees: WorktreeMemory[] = bucketList.map(({ privateMemory, ...b }) => ({
...b,
...commitField(processIndex.hasPrivateMemory, privateMemory),
history: readHistory(b.worktreeId)
...optionalCommitField(processIndex.hasPrivateMemory, privateMemory),
history: readMemoryHistory(b.worktreeId)
}))
let sessionCpuTotal = 0
@@ -495,17 +373,17 @@ async function runSnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot>
for (const wt of worktrees) {
sessionCpuTotal += wt.cpu
sessionMemoryTotal += wt.memory
sessionPrivateTotal += clampNumber(wt.privateMemory)
sessionPrivateTotal += clampMemoryMetric(wt.privateMemory)
}
return {
app: { ...appBuckets, history: readHistory(APP_HISTORY_KEY) },
app: { ...appBuckets, history: readAppMemoryHistory() },
worktrees,
host,
processMemoryMetric: getProcessMemoryMetric(),
...snapshotCommitFields(
processIndex.hasPrivateMemory,
clampNumber(appBuckets.privateMemory) + sessionPrivateTotal
clampMemoryMetric(appBuckets.privateMemory) + sessionPrivateTotal
),
totalCpu: appBuckets.cpu + sessionCpuTotal,
totalMemory: appBuckets.memory + sessionMemoryTotal,
+101
View File
@@ -0,0 +1,101 @@
import { basename } from 'node:path'
import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id'
import type { SessionMemory } from '../../shared/process-stats-types'
import type { MemorySnapshotStore } from './collector'
const APP_HISTORY_KEY = '__app__'
const HISTORY_CAPACITY = 60
const HISTORY_STALE_MS = 10 * 60 * 1000
type HistoryRing = {
samples: number[]
touchedAt: number
}
const historyByKey = new Map<string, HistoryRing>()
export function pushMemoryHistorySample(key: string, memoryBytes: number, now: number): void {
let ring = historyByKey.get(key)
if (!ring) {
ring = { samples: [], touchedAt: now }
historyByKey.set(key, ring)
}
ring.samples.push(memoryBytes)
if (ring.samples.length > HISTORY_CAPACITY) {
ring.samples.shift()
}
ring.touchedAt = now
}
export function readMemoryHistory(key: string): number[] {
const ring = historyByKey.get(key)
return ring ? [...ring.samples] : []
}
export function sweepStaleMemoryHistory(now: number): void {
for (const [key, ring] of historyByKey) {
if (now - ring.touchedAt > HISTORY_STALE_MS) {
historyByKey.delete(key)
}
}
}
export function pushAppMemoryHistory(memoryBytes: number, now: number): void {
pushMemoryHistorySample(APP_HISTORY_KEY, memoryBytes, now)
}
export function readAppMemoryHistory(): number[] {
return readMemoryHistory(APP_HISTORY_KEY)
}
export type WorktreeMemoryBucket = {
worktreeId: string
worktreeName: string
repoId: string
repoName: string
cpu: number
memory: number
privateMemory: number
sessions: SessionMemory[]
}
export function resolveWorktreeMemoryNames(
worktreeId: string,
store: MemorySnapshotStore
): {
worktreeName: string
repoId: string
repoName: string
} {
// Orca worktree ids look like `${repoId}::${absolutePath}`.
const parsed = splitWorktreeIdForFilesystem(worktreeId)
const repoId = parsed?.repoId ?? worktreeId
const worktreePath = parsed?.worktreePath ?? ''
const fallbackName = worktreePath ? basename(worktreePath) : worktreeId
const meta = store.getWorktreeMeta(worktreeId)
const repo = store.getRepo(repoId)
return {
worktreeName: meta?.displayName?.trim() || fallbackName,
repoId,
repoName: repo?.displayName?.trim() || repoId || 'Unknown Repo'
}
}
export function createEmptyWorktreeMemoryBucket(
worktreeId: string,
worktreeName: string,
repoId: string,
repoName: string
): WorktreeMemoryBucket {
return {
worktreeId,
worktreeName,
repoId,
repoName,
cpu: 0,
memory: 0,
privateMemory: 0,
sessions: []
}
}
+49
View File
@@ -0,0 +1,49 @@
import type { MemorySnapshot, ProcessCommitMetric } from '../../shared/process-stats-types'
import { fallbackHostMemory } from './host-memory'
import { getProcessMemoryMetric } from './process-memory-metric'
const PROCESS_COMMIT_METRIC: ProcessCommitMetric = 'private-bytes'
export function clampMemoryMetric(value: unknown): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return 0
}
return Math.max(0, value)
}
/**
* The one rule for every committed-bytes key: present only when the sweep could
* measure it, because a 0 would read as "these processes commit nothing".
*/
export function optionalCommitField(
hasPrivateMemory: boolean,
privateMemory: number
): { privateMemory?: number } {
return hasPrivateMemory ? { privateMemory: clampMemoryMetric(privateMemory) } : {}
}
/** The snapshot-level pair, which names the unit alongside the total. */
export function snapshotCommitFields(
hasPrivateMemory: boolean,
totalPrivateMemory: number
): Pick<MemorySnapshot, 'processCommitMetric' | 'totalPrivateMemory'> {
return hasPrivateMemory
? {
processCommitMetric: PROCESS_COMMIT_METRIC,
totalPrivateMemory: clampMemoryMetric(totalPrivateMemory)
}
: {}
}
export function emptyMemorySnapshot(): MemorySnapshot {
const zero = { cpu: 0, memory: 0 }
return {
app: { ...zero, main: zero, renderer: zero, other: zero, history: [] },
worktrees: [],
host: fallbackHostMemory(),
processMemoryMetric: getProcessMemoryMetric(),
totalCpu: 0,
totalMemory: 0,
collectedAt: Date.now()
}
}
@@ -6,7 +6,7 @@ import type {
} from '../../../shared/automations-types'
import type { PersistedState } from '../../../shared/persisted-state-types'
import { normalizeAutomationPrecheck } from '../../../shared/automation-precheck'
import { nextAutomationOccurrenceAfter } from '../../../shared/automation-schedules'
import { nextAutomationOccurrenceAfter } from '../../../shared/automation-schedule-occurrences'
import {
applyAutomationExecutionTarget,
deriveAutomationExecutionTargetForCreate,
@@ -3,7 +3,7 @@ import type { PersistedState } from '../../../shared/persisted-state-types'
import {
latestAutomationOccurrenceAtOrBefore,
nextAutomationOccurrenceAfter
} from '../../../shared/automation-schedules'
} from '../../../shared/automation-schedule-occurrences'
export function advanceAutomationNextRun(
state: PersistedState,
+12 -179
View File
@@ -1,182 +1,15 @@
/* eslint-disable max-lines -- Why: scanner traversal, ignore matching, and filesystem
abstraction stay together so local, SSH, and runtime scans cannot drift. */
import { readFile, readdir, stat } from 'node:fs/promises'
import { basename, join } from 'node:path'
import type {
NestedRepoCandidate,
NestedRepoScanOptions,
NestedRepoScanResult
} from '../../shared/project-group-types'
import type { NestedRepoCandidate, NestedRepoScanResult } from '../../shared/project-group-types'
import { isGitRepo } from '../git/repo'
type NestedRepoDirectoryEntry = {
name: string
isDirectory: boolean
isSymlink?: boolean
}
type NestedRepoScanFilesystem = {
readDirectory: (dirPath: string) => Promise<NestedRepoDirectoryEntry[]>
readTextFile?: (filePath: string) => Promise<string>
joinPath: (parentPath: string, childName: string) => string
basename: (path: string) => string
hasGitMarker: (path: string) => Promise<boolean> | boolean
isSelectedPathGitRepo: (path: string) => Promise<boolean> | boolean
}
type IgnoreRule = {
pattern: string
negate: boolean
basenameOnly: boolean
baseSegments: string[]
}
type TraversalFolder = {
path: string
depth: number
segments: string[]
ignoreRules: IgnoreRule[]
}
type NormalizedNestedRepoScanOptions = {
maxDepth: number
maxRepos: number
timeoutMs: number | null
}
const DEFAULT_MAX_DEPTH = 3
const DEFAULT_MAX_REPOS = 100
const SKIPPED_DIRS = new Set([
'node_modules',
'.next',
'dist',
'build',
'.cache',
'vendor',
'__pycache__',
'.turbo',
'.parcel-cache'
])
const VCS_METADATA_DIRS = new Set(['.git', '.svn', '.hg', '.jj', '.sl', '.repo', 'CVS'])
function normalizeScanOptions(options: unknown): NormalizedNestedRepoScanOptions {
const raw = options && typeof options === 'object' ? (options as NestedRepoScanOptions) : {}
return {
maxDepth:
typeof raw.maxDepth === 'number' && Number.isFinite(raw.maxDepth)
? Math.max(1, Math.min(8, Math.floor(raw.maxDepth)))
: DEFAULT_MAX_DEPTH,
maxRepos:
typeof raw.maxRepos === 'number' && Number.isFinite(raw.maxRepos)
? Math.max(1, Math.min(500, Math.floor(raw.maxRepos)))
: DEFAULT_MAX_REPOS,
timeoutMs:
raw.timeoutMs === null
? null
: typeof raw.timeoutMs === 'number' && Number.isFinite(raw.timeoutMs)
? Math.max(500, Math.min(30_000, Math.floor(raw.timeoutMs)))
: null
}
}
function shouldSkipDirectory(name: string, depth: number): boolean {
if (VCS_METADATA_DIRS.has(name)) {
return true
}
if (SKIPPED_DIRS.has(name)) {
return true
}
return depth > 0 && name.startsWith('.')
}
function globSegmentMatches(pattern: string, value: string): boolean {
if (!pattern.includes('*') && !pattern.includes('?')) {
return pattern === value
}
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&')
const regex = new RegExp(`^${escaped.replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]')}$`)
return regex.test(value)
}
function pathSegmentsMatch(patternSegments: string[], candidateSegments: string[]): boolean {
const matchFrom = (patternIndex: number, candidateIndex: number): boolean => {
if (patternIndex >= patternSegments.length) {
return candidateIndex >= candidateSegments.length
}
const pattern = patternSegments[patternIndex]
if (pattern === '**') {
return (
matchFrom(patternIndex + 1, candidateIndex) ||
(candidateIndex < candidateSegments.length && matchFrom(patternIndex, candidateIndex + 1))
)
}
return (
candidateIndex < candidateSegments.length &&
globSegmentMatches(pattern, candidateSegments[candidateIndex] ?? '') &&
matchFrom(patternIndex + 1, candidateIndex + 1)
)
}
return matchFrom(0, 0)
}
function parseGitignoreRules(content: string, baseSegments: string[]): IgnoreRule[] {
return content
.split(/\r?\n/)
.map((rawLine) => rawLine.trim())
.filter((line) => line.length > 0 && !line.startsWith('#'))
.map((line) => {
const negate = line.startsWith('!')
const unprefixed = negate ? line.slice(1) : line
const anchored = unprefixed.startsWith('/')
const pattern = unprefixed.replace(/^\/+/, '').replace(/\/+$/, '')
return {
pattern,
negate,
basenameOnly: !anchored && !pattern.includes('/'),
baseSegments
}
})
.filter((rule) => rule.pattern.length > 0)
}
function isIgnoredByRules(name: string, segments: string[], rules: IgnoreRule[]): boolean {
let ignored = false
for (const rule of rules) {
if (segments.length <= rule.baseSegments.length) {
continue
}
const relativeSegments = segments.slice(rule.baseSegments.length)
const patternSegments = rule.pattern.split('/')
const matches = rule.basenameOnly
? relativeSegments.some((segment) => globSegmentMatches(rule.pattern, segment))
: pathSegmentsMatch(patternSegments, relativeSegments)
if (matches) {
ignored = !rule.negate
}
}
return ignored || shouldSkipDirectory(name, segments.length - 1)
}
async function readGitignoreRules(args: {
folderPath: string
entries: NestedRepoDirectoryEntry[]
filesystem: NestedRepoScanFilesystem
baseSegments: string[]
}): Promise<IgnoreRule[]> {
if (!args.filesystem.readTextFile || !args.entries.some((entry) => entry.name === '.gitignore')) {
return []
}
try {
const content = await args.filesystem.readTextFile(
args.filesystem.joinPath(args.folderPath, '.gitignore')
)
return parseGitignoreRules(content, args.baseSegments)
} catch {
return []
}
}
import {
isIgnoredNestedRepoDirectory,
normalizeNestedRepoScanOptions,
readNestedRepoGitignoreRules,
type NestedRepoDirectoryEntry,
type NestedRepoScanFilesystem,
type TraversalFolder
} from './nested-repo-scan-rules'
async function hasGitMarker(dirPath: string): Promise<boolean> {
try {
@@ -214,7 +47,7 @@ export async function scanNestedRepos(args: {
onProgress?: (scan: NestedRepoScanResult) => void
}): Promise<NestedRepoScanResult> {
const startedAt = Date.now()
const options = normalizeScanOptions(args.options)
const options = normalizeNestedRepoScanOptions(args.options)
const repos: NestedRepoCandidate[] = []
let truncated = false
let timedOut = false
@@ -290,7 +123,7 @@ export async function scanNestedRepos(args: {
}
const currentIgnoreRules = [
...currentFolder.ignoreRules,
...(await readGitignoreRules({
...(await readNestedRepoGitignoreRules({
folderPath: currentFolder.path,
entries,
filesystem,
@@ -315,7 +148,7 @@ export async function scanNestedRepos(args: {
break
}
const childSegments = [...currentFolder.segments, name]
if (isIgnoredByRules(name, childSegments, currentIgnoreRules)) {
if (isIgnoredNestedRepoDirectory(name, childSegments, currentIgnoreRules)) {
continue
}
const childPath = filesystem.joinPath(currentFolder.path, name)
@@ -0,0 +1,174 @@
import type { NestedRepoScanOptions } from '../../shared/project-group-types'
export type NestedRepoDirectoryEntry = {
name: string
isDirectory: boolean
isSymlink?: boolean
}
export type NestedRepoScanFilesystem = {
readDirectory: (dirPath: string) => Promise<NestedRepoDirectoryEntry[]>
readTextFile?: (filePath: string) => Promise<string>
joinPath: (parentPath: string, childName: string) => string
basename: (path: string) => string
hasGitMarker: (path: string) => Promise<boolean> | boolean
isSelectedPathGitRepo: (path: string) => Promise<boolean> | boolean
}
type IgnoreRule = {
pattern: string
negate: boolean
basenameOnly: boolean
baseSegments: string[]
}
export type TraversalFolder = {
path: string
depth: number
segments: string[]
ignoreRules: IgnoreRule[]
}
export type NormalizedNestedRepoScanOptions = {
maxDepth: number
maxRepos: number
timeoutMs: number | null
}
const DEFAULT_MAX_DEPTH = 3
const DEFAULT_MAX_REPOS = 100
const SKIPPED_DIRS = new Set([
'node_modules',
'.next',
'dist',
'build',
'.cache',
'vendor',
'__pycache__',
'.turbo',
'.parcel-cache'
])
const VCS_METADATA_DIRS = new Set(['.git', '.svn', '.hg', '.jj', '.sl', '.repo', 'CVS'])
export function normalizeNestedRepoScanOptions(options: unknown): NormalizedNestedRepoScanOptions {
const raw = options && typeof options === 'object' ? (options as NestedRepoScanOptions) : {}
return {
maxDepth:
typeof raw.maxDepth === 'number' && Number.isFinite(raw.maxDepth)
? Math.max(1, Math.min(8, Math.floor(raw.maxDepth)))
: DEFAULT_MAX_DEPTH,
maxRepos:
typeof raw.maxRepos === 'number' && Number.isFinite(raw.maxRepos)
? Math.max(1, Math.min(500, Math.floor(raw.maxRepos)))
: DEFAULT_MAX_REPOS,
timeoutMs:
raw.timeoutMs === null
? null
: typeof raw.timeoutMs === 'number' && Number.isFinite(raw.timeoutMs)
? Math.max(500, Math.min(30_000, Math.floor(raw.timeoutMs)))
: null
}
}
function shouldSkipDirectory(name: string, depth: number): boolean {
if (VCS_METADATA_DIRS.has(name)) {
return true
}
if (SKIPPED_DIRS.has(name)) {
return true
}
return depth > 0 && name.startsWith('.')
}
function globSegmentMatches(pattern: string, value: string): boolean {
if (!pattern.includes('*') && !pattern.includes('?')) {
return pattern === value
}
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&')
const regex = new RegExp(`^${escaped.replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]')}$`)
return regex.test(value)
}
function pathSegmentsMatch(patternSegments: string[], candidateSegments: string[]): boolean {
const matchFrom = (patternIndex: number, candidateIndex: number): boolean => {
if (patternIndex >= patternSegments.length) {
return candidateIndex >= candidateSegments.length
}
const pattern = patternSegments[patternIndex]
if (pattern === '**') {
return (
matchFrom(patternIndex + 1, candidateIndex) ||
(candidateIndex < candidateSegments.length && matchFrom(patternIndex, candidateIndex + 1))
)
}
return (
candidateIndex < candidateSegments.length &&
globSegmentMatches(pattern, candidateSegments[candidateIndex] ?? '') &&
matchFrom(patternIndex + 1, candidateIndex + 1)
)
}
return matchFrom(0, 0)
}
function parseGitignoreRules(content: string, baseSegments: string[]): IgnoreRule[] {
return content
.split(/\r?\n/)
.map((rawLine) => rawLine.trim())
.filter((line) => line.length > 0 && !line.startsWith('#'))
.map((line) => {
const negate = line.startsWith('!')
const unprefixed = negate ? line.slice(1) : line
const anchored = unprefixed.startsWith('/')
const pattern = unprefixed.replace(/^\/+/, '').replace(/\/+$/, '')
return {
pattern,
negate,
basenameOnly: !anchored && !pattern.includes('/'),
baseSegments
}
})
.filter((rule) => rule.pattern.length > 0)
}
export function isIgnoredNestedRepoDirectory(
name: string,
segments: string[],
rules: IgnoreRule[]
): boolean {
let ignored = false
for (const rule of rules) {
if (segments.length <= rule.baseSegments.length) {
continue
}
const relativeSegments = segments.slice(rule.baseSegments.length)
const patternSegments = rule.pattern.split('/')
const matches = rule.basenameOnly
? relativeSegments.some((segment) => globSegmentMatches(rule.pattern, segment))
: pathSegmentsMatch(patternSegments, relativeSegments)
if (matches) {
ignored = !rule.negate
}
}
return ignored || shouldSkipDirectory(name, segments.length - 1)
}
export async function readNestedRepoGitignoreRules(args: {
folderPath: string
entries: NestedRepoDirectoryEntry[]
filesystem: NestedRepoScanFilesystem
baseSegments: string[]
}): Promise<IgnoreRule[]> {
if (!args.filesystem.readTextFile || !args.entries.some((entry) => entry.name === '.gitignore')) {
return []
}
try {
const content = await args.filesystem.readTextFile(
args.filesystem.joinPath(args.folderPath, '.gitignore')
)
return parseGitignoreRules(content, args.baseSegments)
} catch {
return []
}
}
@@ -0,0 +1,14 @@
// Why: these substrings indicate the /usage TUI panel has finished rendering.
export const CLAUDE_USAGE_STOP_SUBSTRINGS = [
'Current week (all models)',
'Current week (Opus)',
'Current week (Sonnet only)',
'Current week (Sonnet)',
'Weekly limits',
'Weekly limit',
'Weekly usage',
'7-day',
'Current session',
'Failed to load usage data',
'failed to load usage data'
]
@@ -0,0 +1,166 @@
import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types'
import { extractClaudePtyResetMetadata } from './claude-pty-reset-parser'
// Why: these patterns match the Claude CLI's /usage TUI panel output.
// "Current session" shows a percent like "62% used" or "62% left".
// Weekly labels have varied between "Current week" and "Weekly limits".
const SESSION_RE = /current\s*session/i
const WEEKLY_RE = /(?:current\s*week|weekly\s*(?:limits?|usage|rate\s*limits?)|7\s*[- ]?\s*day)/i
const FABLE_WORD_RE = /\bfable\b/i
const FABLE_LABEL_RE = /^\s*fable\s*$/i
// Why: derive from WEEKLY_RE so a future weekly-wording change stays in one place
// instead of silently reopening the Fable-weekly parsing gap this fix closed.
const FABLE_WEEKLY_LABEL_RE = new RegExp(
`${WEEKLY_RE.source}\\s*(?:\\([^)]*\\bfable\\b[^)]*\\)|[-:]?\\s*\\bfable\\b)`,
'i'
)
const PERCENT_RE = /(\d{1,3})(?:\.\d+)?\s*%\s*(used|consumed|left|remaining|available)/i
const ESC = String.fromCharCode(27)
const BEL = String.fromCharCode(7)
const OSC_SEQUENCE_RE = new RegExp(`${ESC}\\][^${BEL}]*(?:${BEL}|${ESC}\\\\)`, 'g')
const CSI_SEQUENCE_RE = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, 'g')
export function stripTerminalControlSequences(output: string): string {
return output.replace(OSC_SEQUENCE_RE, '').replace(CSI_SEQUENCE_RE, '')
}
/**
* Extract percent-left from lines following a label match.
* Scans up to 12 lines after the label to find the associated percent.
*/
function matchesWeeklyLabel(line: string): boolean {
return WEEKLY_RE.test(line) && !FABLE_WORD_RE.test(line)
}
function matchesFableBoundary(line: string): boolean {
return FABLE_LABEL_RE.test(line) || (FABLE_WORD_RE.test(line) && WEEKLY_RE.test(line))
}
function matchesFableUsageLabel(line: string): boolean {
// Why: broad Fable-weekly copy should stop nearby scans, but only a real
// Fable usage heading should produce the distinct Fable meter.
return FABLE_LABEL_RE.test(line) || FABLE_WEEKLY_LABEL_RE.test(line)
}
function isSectionLabel(line: string): boolean {
return SESSION_RE.test(line) || matchesWeeklyLabel(line) || matchesFableBoundary(line)
}
function extractPercentAfterLabel(
lines: string[],
matchesLabel: (line: string) => boolean
): number | null {
for (let i = 0; i < lines.length; i++) {
if (!matchesLabel(lines[i])) {
continue
}
// Scan next 12 lines for a percent
for (let j = i; j < Math.min(i + 12, lines.length); j++) {
if (j > i && isSectionLabel(lines[j])) {
break
}
const m = PERCENT_RE.exec(lines[j])
if (m) {
const pct = Number.parseFloat(m[1])
const word = m[2].toLowerCase()
const isUsed = word === 'used' || word === 'consumed'
return isUsed ? pct : 100 - pct
}
}
}
return null
}
export function parseClaudePtyUsage(output: string): {
session: RateLimitWindow | null
weekly: RateLimitWindow | null
fableWeekly: RateLimitWindow | null
} {
const lines = output.split(/\r\n|\n|\r/)
const sessionPct = extractPercentAfterLabel(lines, (line) => SESSION_RE.test(line))
const weeklyPct = extractPercentAfterLabel(lines, matchesWeeklyLabel)
const fableWeeklyPct = extractPercentAfterLabel(lines, matchesFableUsageLabel)
const sessionReset = extractClaudePtyResetMetadata(
lines,
(line) => SESSION_RE.test(line),
isSectionLabel
)
const weeklyReset = extractClaudePtyResetMetadata(lines, matchesWeeklyLabel, isSectionLabel)
const fableWeeklyReset = extractClaudePtyResetMetadata(
lines,
matchesFableUsageLabel,
isSectionLabel
)
const session: RateLimitWindow | null =
sessionPct !== null
? {
usedPercent: Math.min(100, Math.max(0, sessionPct)),
windowMinutes: 300,
resetsAt: sessionReset.resetsAt,
resetDescription: sessionReset.resetDescription
}
: null
const weekly: RateLimitWindow | null =
weeklyPct !== null
? {
usedPercent: Math.min(100, Math.max(0, weeklyPct)),
windowMinutes: 10080,
resetsAt: weeklyReset.resetsAt,
resetDescription: weeklyReset.resetDescription
}
: null
const fableWeekly: RateLimitWindow | null =
fableWeeklyPct !== null
? {
usedPercent: Math.min(100, Math.max(0, fableWeeklyPct)),
windowMinutes: 10080,
resetsAt: fableWeeklyReset.resetsAt,
resetDescription: fableWeeklyReset.resetDescription
}
: null
return { session, weekly, fableWeekly }
}
const RATE_LIMITED_RE = /rate limited\.?\s+please try again later/i
const LOAD_FAILED_RE = /failed to load usage data/i
const CLAUDE_21_USAGE_TABS_RE = /settings?\s+status?\s+config\s+usage\s+stats/i
const CLAUDE_21_SESSION_STATS_RE = /total\s*cost|total\s*duration|usage:\s*\d+\s*input/i
export function isClaude21UsagePanel(output: string): boolean {
return CLAUDE_21_USAGE_TABS_RE.test(output) || CLAUDE_21_SESSION_STATS_RE.test(output)
}
export function describeClaudeUsageFailure(output: string): string {
if (RATE_LIMITED_RE.test(output)) {
return 'Claude usage is rate limited right now.'
}
if (LOAD_FAILED_RE.test(output)) {
return 'Claude usage is unavailable right now.'
}
if (CLAUDE_21_USAGE_TABS_RE.test(output) || CLAUDE_21_SESSION_STATS_RE.test(output)) {
return 'Claude plan usage is unavailable for this Claude CLI session.'
}
// Why: parser failures are an implementation detail of Orca's PTY fallback.
// The UI should explain the user-visible outcome, not leak internal parsing
// mechanics that the user cannot act on.
return 'Claude usage is unavailable right now.'
}
export function abortedClaudeUsageResult(): ProviderRateLimits {
return {
provider: 'claude',
session: null,
weekly: null,
updatedAt: Date.now(),
error: 'Rate-limit fetch aborted',
status: 'error'
}
}
+20 -196
View File
@@ -1,7 +1,4 @@
/* eslint-disable max-lines -- Why: Claude PTY usage scraping keeps prompt
driving, parser, timers, and teardown in one state machine; splitting it would
make the lifecycle harder to audit. */
import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types'
import type { ProviderRateLimits } from '../../shared/rate-limit-types'
import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy'
import { resolveClaudeCommand } from '../codex-cli/command'
// Why: import from the shared module, not the codex-cli re-export, so a test that
@@ -11,11 +8,19 @@ import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-au
import { applyClaudeEnvPatch } from '../claude-accounts/environment'
import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic'
import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup'
import { extractClaudePtyResetMetadata } from './claude-pty-reset-parser'
import {
getHiddenRateLimitWslCwdSetupCommands,
resolveHiddenRateLimitPtyCwd
} from './hidden-rate-limit-pty-cwd'
import {
abortedClaudeUsageResult,
describeClaudeUsageFailure,
isClaude21UsagePanel,
parseClaudePtyUsage,
stripTerminalControlSequences
} from './claude-pty-usage-parser'
import { quoteHiddenRateLimitShellValue } from './hidden-rate-limit-shell'
import { CLAUDE_USAGE_STOP_SUBSTRINGS } from './claude-pty-stop-markers'
const PTY_TIMEOUT_MS = 25_000
const MAX_OUTPUT_LENGTH = 100_000 // 100KB buffer limit
@@ -24,148 +29,6 @@ const MAX_OUTPUT_LENGTH = 100_000 // 100KB buffer limit
// PTY fallback — spawn interactive `claude`, send `/usage`, parse the TUI
// ---------------------------------------------------------------------------
// Why: these patterns match the Claude CLI's /usage TUI panel output.
// "Current session" shows a percent like "62% used" or "62% left".
// Weekly labels have varied between "Current week" and "Weekly limits".
const SESSION_RE = /current\s*session/i
const WEEKLY_RE = /(?:current\s*week|weekly\s*(?:limits?|usage|rate\s*limits?)|7\s*[- ]?\s*day)/i
const FABLE_WORD_RE = /\bfable\b/i
const FABLE_LABEL_RE = /^\s*fable\s*$/i
// Why: derive from WEEKLY_RE so a future weekly-wording change stays in one place
// instead of silently reopening the Fable-weekly parsing gap this fix closed.
const FABLE_WEEKLY_LABEL_RE = new RegExp(
`${WEEKLY_RE.source}\\s*(?:\\([^)]*\\bfable\\b[^)]*\\)|[-:]?\\s*\\bfable\\b)`,
'i'
)
const PERCENT_RE = /(\d{1,3})(?:\.\d+)?\s*%\s*(used|consumed|left|remaining|available)/i
const ESC = String.fromCharCode(27)
const BEL = String.fromCharCode(7)
const OSC_SEQUENCE_RE = new RegExp(`${ESC}\\][^${BEL}]*(?:${BEL}|${ESC}\\\\)`, 'g')
const CSI_SEQUENCE_RE = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, 'g')
function stripTerminalControlSequences(output: string): string {
return output.replace(OSC_SEQUENCE_RE, '').replace(CSI_SEQUENCE_RE, '')
}
/**
* Extract percent-left from lines following a label match.
* Scans up to 12 lines after the label to find the associated percent.
*/
function matchesWeeklyLabel(line: string): boolean {
return WEEKLY_RE.test(line) && !FABLE_WORD_RE.test(line)
}
function matchesFableBoundary(line: string): boolean {
return FABLE_LABEL_RE.test(line) || (FABLE_WORD_RE.test(line) && WEEKLY_RE.test(line))
}
function matchesFableUsageLabel(line: string): boolean {
// Why: broad Fable-weekly copy should stop nearby scans, but only a real
// Fable usage heading should produce the distinct Fable meter.
return FABLE_LABEL_RE.test(line) || FABLE_WEEKLY_LABEL_RE.test(line)
}
function isSectionLabel(line: string): boolean {
return SESSION_RE.test(line) || matchesWeeklyLabel(line) || matchesFableBoundary(line)
}
function extractPercentAfterLabel(
lines: string[],
matchesLabel: (line: string) => boolean
): number | null {
for (let i = 0; i < lines.length; i++) {
if (!matchesLabel(lines[i])) {
continue
}
// Scan next 12 lines for a percent
for (let j = i; j < Math.min(i + 12, lines.length); j++) {
if (j > i && isSectionLabel(lines[j])) {
break
}
const m = PERCENT_RE.exec(lines[j])
if (m) {
const pct = Number.parseFloat(m[1])
const word = m[2].toLowerCase()
const isUsed = word === 'used' || word === 'consumed'
return isUsed ? pct : 100 - pct
}
}
}
return null
}
function parsePtyUsage(output: string): {
session: RateLimitWindow | null
weekly: RateLimitWindow | null
fableWeekly: RateLimitWindow | null
} {
const lines = output.split(/\r\n|\n|\r/)
const sessionPct = extractPercentAfterLabel(lines, (line) => SESSION_RE.test(line))
const weeklyPct = extractPercentAfterLabel(lines, matchesWeeklyLabel)
const fableWeeklyPct = extractPercentAfterLabel(lines, matchesFableUsageLabel)
const sessionReset = extractClaudePtyResetMetadata(
lines,
(line) => SESSION_RE.test(line),
isSectionLabel
)
const weeklyReset = extractClaudePtyResetMetadata(lines, matchesWeeklyLabel, isSectionLabel)
const fableWeeklyReset = extractClaudePtyResetMetadata(
lines,
matchesFableUsageLabel,
isSectionLabel
)
const session: RateLimitWindow | null =
sessionPct !== null
? {
usedPercent: Math.min(100, Math.max(0, sessionPct)),
windowMinutes: 300,
resetsAt: sessionReset.resetsAt,
resetDescription: sessionReset.resetDescription
}
: null
const weekly: RateLimitWindow | null =
weeklyPct !== null
? {
usedPercent: Math.min(100, Math.max(0, weeklyPct)),
windowMinutes: 10080,
resetsAt: weeklyReset.resetsAt,
resetDescription: weeklyReset.resetDescription
}
: null
const fableWeekly: RateLimitWindow | null =
fableWeeklyPct !== null
? {
usedPercent: Math.min(100, Math.max(0, fableWeeklyPct)),
windowMinutes: 10080,
resetsAt: fableWeeklyReset.resetsAt,
resetDescription: fableWeeklyReset.resetDescription
}
: null
return { session, weekly, fableWeekly }
}
// Why: these substrings indicate the /usage TUI panel has finished
// rendering. We stop collecting output once one appears, then allow
// a settle period for the rest of the content to flush.
const STOP_SUBSTRINGS = [
'Current week (all models)',
'Current week (Opus)',
'Current week (Sonnet only)',
'Current week (Sonnet)',
'Weekly limits',
'Weekly limit',
'Weekly usage',
'7-day',
'Current session',
'Failed to load usage data',
'failed to load usage data'
]
// Why: prompt detection is unreliable because the Claude CLI v2.x renders
// a status bar and TUI elements that push the `` prompt out of any
// reasonable detection window. Instead we wait a fixed 2s after spawning
@@ -173,48 +36,10 @@ const STOP_SUBSTRINGS = [
// palette prompts ("Show plan usage limits") are auto-confirmed with Enter.
const COMMAND_PALETTE_RE = /show plan|usage limits/i
const TRUST_PROMPT_RE = /do you trust|trust the files|safety check/i
const RATE_LIMITED_RE = /rate limited\.?\s+please try again later/i
const LOAD_FAILED_RE = /failed to load usage data/i
const CLAUDE_21_USAGE_TABS_RE = /settings?\s+status?\s+config\s+usage\s+stats/i
const CLAUDE_21_SESSION_STATS_RE = /total\s*cost|total\s*duration|usage:\s*\d+\s*input/i
const STARTUP_DELAY_MS = 2_000
const SETTLE_AFTER_STOP_MS = 2_000
const SETTLE_AFTER_CLAUDE_21_USAGE_MS = 8_000
function shellQuote(value: string): string {
return `'${value.replace(/'/g, "'\\''")}'`
}
function describeClaudeUsageFailure(output: string): string {
if (RATE_LIMITED_RE.test(output)) {
return 'Claude usage is rate limited right now.'
}
if (LOAD_FAILED_RE.test(output)) {
return 'Claude usage is unavailable right now.'
}
if (CLAUDE_21_USAGE_TABS_RE.test(output) || CLAUDE_21_SESSION_STATS_RE.test(output)) {
return 'Claude plan usage is unavailable for this Claude CLI session.'
}
// Why: parser failures are an implementation detail of Orca's PTY fallback.
// The UI should explain the user-visible outcome, not leak internal parsing
// mechanics that the user cannot act on.
return 'Claude usage is unavailable right now.'
}
function abortedClaudeUsageResult(): ProviderRateLimits {
return {
provider: 'claude',
session: null,
weekly: null,
updatedAt: Date.now(),
error: 'Rate-limit fetch aborted',
status: 'error'
}
}
export async function fetchViaPty(options?: {
authPreparation?: ClaudeRuntimeAuthPreparation
networkProxySettings?: NetworkProxySettings
@@ -279,8 +104,10 @@ export async function fetchViaPty(options?: {
// Why: hidden usage probes must not inherit a root-like WSL cwd;
// keep Claude discovery bounded to a tiny temp directory.
...getHiddenRateLimitWslCwdSetupCommands(),
`export CLAUDE_CONFIG_DIR=${shellQuote(wslConfig.linuxConfigDir)}`,
...Object.entries(proxyEnv).map(([key, value]) => `export ${key}=${shellQuote(value)}`),
`export CLAUDE_CONFIG_DIR=${quoteHiddenRateLimitShellValue(wslConfig.linuxConfigDir)}`,
...Object.entries(proxyEnv).map(
([key, value]) => `export ${key}=${quoteHiddenRateLimitShellValue(value)}`
),
'exec claude'
].join(' && ')
]
@@ -352,7 +179,7 @@ export async function fetchViaPty(options?: {
cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
// Even on timeout, try to parse whatever we collected
const clean = stripTerminalControlSequences(output)
const { session, weekly, fableWeekly } = parsePtyUsage(clean)
const { session, weekly, fableWeekly } = parseClaudePtyUsage(clean)
if (session || weekly || fableWeekly) {
resolve({
provider: 'claude',
@@ -370,7 +197,7 @@ export async function fetchViaPty(options?: {
weekly: null,
updatedAt: Date.now(),
error: withMacTailscaleDnsHint(
CLAUDE_21_USAGE_TABS_RE.test(clean) || CLAUDE_21_SESSION_STATS_RE.test(clean)
isClaude21UsagePanel(clean)
? describeClaudeUsageFailure(clean)
: 'PTY timeout — /usage panel did not render',
clean
@@ -407,7 +234,7 @@ export async function fetchViaPty(options?: {
cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
const clean = stripTerminalControlSequences(output)
const { session, weekly, fableWeekly } = parsePtyUsage(clean)
const { session, weekly, fableWeekly } = parseClaudePtyUsage(clean)
if (!session && !weekly && !fableWeekly) {
resolve({
@@ -469,10 +296,7 @@ export async function fetchViaPty(options?: {
// Check if we've hit a stop substring indicating the panel rendered
if (sentUsage && !stopDetected) {
const clean = stripTerminalControlSequences(output)
if (
!claude21UsageDetected &&
(CLAUDE_21_USAGE_TABS_RE.test(clean) || CLAUDE_21_SESSION_STATS_RE.test(clean))
) {
if (!claude21UsageDetected && isClaude21UsagePanel(clean)) {
claude21UsageDetected = true
if (enterInterval) {
clearInterval(enterInterval)
@@ -483,7 +307,7 @@ export async function fetchViaPty(options?: {
// with a user-facing unavailable state instead of a false PTY timeout.
claude21UsageSettleTimer = setTimeout(finalize, SETTLE_AFTER_CLAUDE_21_USAGE_MS)
}
for (const sub of STOP_SUBSTRINGS) {
for (const sub of CLAUDE_USAGE_STOP_SUBSTRINGS) {
if (clean.includes(sub)) {
stopDetected = true
// Why: 2.0s settle time after detecting the stop substring
@@ -508,7 +332,7 @@ export async function fetchViaPty(options?: {
timeout = null
}
const clean = stripTerminalControlSequences(output)
const { session, weekly, fableWeekly } = parsePtyUsage(clean)
const { session, weekly, fableWeekly } = parseClaudePtyUsage(clean)
resolve({
provider: 'claude',
session,
+3 -6
View File
@@ -37,6 +37,7 @@ import {
getHiddenRateLimitWslCwdSetupCommands,
resolveHiddenRateLimitPtyCwd
} from './hidden-rate-limit-pty-cwd'
import { quoteHiddenRateLimitShellValue } from './hidden-rate-limit-shell'
const RPC_TIMEOUT_MS = 10_000
const WSL_RPC_TIMEOUT_MS = 25_000
@@ -45,10 +46,6 @@ const WSL_RPC_INIT_TIMEOUT_MS = 40_000
export type FetchCodexRateLimitsOptions = CodexRateLimitFetchOptions
function shellQuote(value: string): string {
return `'${value.replace(/'/g, "'\\''")}'`
}
function buildWslCodexCommand(
codexHomePath: string,
args: string[],
@@ -60,9 +57,9 @@ function buildWslCodexCommand(
}
const setupCommands = [
...getHiddenRateLimitWslCwdSetupCommands(),
`export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}`
`export CODEX_HOME=${quoteHiddenRateLimitShellValue(wslInfo.linuxPath)}`
].join(' && ')
const execSuffix = `${args.map(shellQuote).join(' ')}${
const execSuffix = `${args.map(quoteHiddenRateLimitShellValue).join(' ')}${
isolateRpcStdio ? ' <&3 >&4 3<&- 4>&-' : ''
}`
const loginShellCommand = buildWslLoginShellCommand(
@@ -0,0 +1,3 @@
export function quoteHiddenRateLimitShellValue(value: string): string {
return `'${value.replace(/'/g, "'\\''")}'`
}
@@ -1,6 +1,6 @@
// Why: the automation method table stays readable only if its field-level validation lives beside it rather than inside it.
import { z } from 'zod'
import { isValidAutomationSchedule } from '../../../../shared/automation-schedules'
import { isValidAutomationSchedule } from '../../../../shared/automation-schedule-parsing'
import {
MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS,
normalizeAutomationPrecheckTimeoutSeconds
@@ -7,7 +7,7 @@ import { useMountedRef } from '@/hooks/useMountedRef'
import { cn } from '@/lib/utils'
import { useAppStore } from '@/store'
import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import { linearUpdateIssue } from '@/runtime/runtime-linear-client'
import { linearUpdateIssue } from '@/runtime/runtime-linear-issue-mutations'
import type { LinearIssue } from '../../../shared/linear/issue-types'
import type { TaskSourceContext } from '../../../shared/task-source-context'
import {
@@ -62,7 +62,7 @@ import {
linearGetIssue,
linearIssueComments,
linearUpdateIssue
} from '@/runtime/runtime-linear-client'
} from '@/runtime/runtime-linear-issue-mutations'
import { translate } from '@/i18n/i18n'
import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format'
@@ -2,10 +2,8 @@ import React from 'react'
import { CheckCircle2, CircleAlert } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import {
describeAutomationSchedule,
getAutomationCronExpressionFields
} from '../../../../shared/automation-schedules'
import { describeAutomationSchedule } from '../../../../shared/automation-schedules'
import { getAutomationCronExpressionFields } from '../../../../shared/automation-schedule-parsing'
import { formatUiAutomationScheduleDescriptor } from './automation-schedule-label'
import type { AutomationDraft } from './AutomationEditorDialog'
import { Field } from './automation-page-parts'
@@ -4,7 +4,7 @@ import { translate } from '@/i18n/i18n'
import {
isValidAutomationCronSchedule,
isValidAutomationSchedule
} from '../../../../shared/automation-schedules'
} from '../../../../shared/automation-schedule-parsing'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types'
import type { ProjectHostSetup } from '../../../../shared/project-types'
@@ -5,7 +5,7 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AutomationDraft } from './AutomationEditorDialog'
import { AutomationSchedulePicker } from './AutomationSchedulePicker'
import { isValidAutomationCronSchedule } from '../../../../shared/automation-schedules'
import { isValidAutomationCronSchedule } from '../../../../shared/automation-schedule-parsing'
import { useTranslation } from 'react-i18next'
import { i18n } from '@/i18n/i18n'
@@ -14,7 +14,7 @@ import {
getSchedulePresetDraft
} from './AutomationSchedulePicker'
import { formatUiAutomationSchedule } from './automation-schedule-label'
import { isValidAutomationCronSchedule } from '../../../../shared/automation-schedules'
import { isValidAutomationCronSchedule } from '../../../../shared/automation-schedule-parsing'
import { SelectItem } from '@/components/ui/select'
import { i18n } from '@/i18n/i18n'
@@ -7,10 +7,8 @@ import {
SelectValue
} from '@/components/ui/select'
import type { AutomationSchedulePreset } from '../../../../shared/automations-types'
import {
buildAutomationCronSchedule,
isValidAutomationSchedule
} from '../../../../shared/automation-schedules'
import { buildAutomationCronSchedule } from '../../../../shared/automation-schedule-occurrences'
import { isValidAutomationSchedule } from '../../../../shared/automation-schedule-parsing'
import type { AutomationDraft } from './AutomationEditorDialog'
import { AutomationCustomCronPanel } from './AutomationCustomCronPanel'
import { AutomationTimeField, parseAutomationTime } from './AutomationTimeField'
@@ -33,11 +33,11 @@ import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types'
import type { Repo } from '../../../../shared/repo-types'
import { getWorktreePathBasenameFromId } from '../../../../shared/worktree/id'
import type { Worktree } from '../../../../shared/worktree/types'
import { buildAutomationRrule } from '../../../../shared/automation-schedule-occurrences'
import {
buildAutomationRrule,
isValidAutomationCronSchedule,
isValidAutomationSchedule
} from '../../../../shared/automation-schedules'
} from '../../../../shared/automation-schedule-parsing'
import {
canRerunAutomationRun,
getAutomationRunViewState,
@@ -14,7 +14,7 @@ import {
import type { LucideIcon } from 'lucide-react'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { cn } from '@/lib/utils'
import { isValidAutomationSchedule } from '../../../../shared/automation-schedules'
import { isValidAutomationSchedule } from '../../../../shared/automation-schedule-parsing'
import { formatUiAutomationSchedule } from './automation-schedule-label'
import { translate } from '@/i18n/i18n'
import { parseHermesOutput, type ParsedHermesSection } from './hermes-cron-output-parse'
@@ -1,6 +1,6 @@
import { getAgentCatalog } from '@/lib/agent-catalog'
import type { AutomationPrecheck } from '../../../../shared/automations-types'
import { buildAutomationCronSchedule } from '../../../../shared/automation-schedules'
import { buildAutomationCronSchedule } from '../../../../shared/automation-schedule-occurrences'
import type { Worktree } from '../../../../shared/worktree/types'
import type { AutomationDraft } from './AutomationEditorDialog'
@@ -12,7 +12,7 @@ import {
isValidAutomationCronSchedule,
isValidAutomationSchedule,
tryParseAutomationRrule
} from '../../../../shared/automation-schedules'
} from '../../../../shared/automation-schedule-parsing'
import type { AutomationDraft } from './AutomationEditorDialog'
import { AUTOMATION_DEFAULT_TIME, formatTimeInput } from './automation-draft-model'
import { getAutomationSetupDecisionDraftValue } from './automation-setup-decision'
@@ -2,7 +2,7 @@ import type {
ExternalAutomationJob,
ExternalAutomationManager
} from '../../../../shared/automations-types'
import { isValidAutomationCronSchedule } from '../../../../shared/automation-schedules'
import { isValidAutomationCronSchedule } from '../../../../shared/automation-schedule-parsing'
import { formatUiAutomationSchedule } from './automation-schedule-label'
import { translate } from '@/i18n/i18n'
@@ -20,7 +20,12 @@ const storeMocks = vi.hoisted(() => ({
}
}))
vi.mock('@/runtime/runtime-linear-client', () => runtimeMocks)
vi.mock('@/runtime/runtime-linear-project-client', () => ({
linearListProjects: runtimeMocks.linearListProjects
}))
vi.mock('@/runtime/runtime-linear-issue-mutations', () => ({
linearUpdateIssue: runtimeMocks.linearUpdateIssue
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: typeof storeMocks.state) => unknown) => selector(storeMocks.state)
}))
@@ -6,7 +6,8 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
import { getLinearProjectSearchRequestQuery } from '@/components/linear-project-search-query'
import { linearListProjects, linearUpdateIssue } from '@/runtime/runtime-linear-client'
import { linearUpdateIssue } from '@/runtime/runtime-linear-issue-mutations'
import { linearListProjects } from '@/runtime/runtime-linear-project-client'
import { useAppStore } from '@/store'
import type { LinearIssue } from '../../../shared/linear/issue-types'
import type { LinearProjectSummary } from '../../../shared/linear/project-types'
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
import { linearCreateSubIssue } from '@/runtime/runtime-linear-client'
import { linearCreateSubIssue } from '@/runtime/runtime-linear-issue-mutations'
import { useAppStore } from '@/store'
import type { LinearIssue, LinearIssueChildSummary } from '../../../shared/linear/issue-types'
import type { TaskSourceContext } from '../../../shared/task-source-context'
@@ -20,7 +20,7 @@ const runtimeMocks = vi.hoisted(() => ({
linearIssueComments: vi.fn()
}))
vi.mock('@/runtime/runtime-linear-client', () => runtimeMocks)
vi.mock('@/runtime/runtime-linear-issue-mutations', () => runtimeMocks)
vi.mock('@/components/LinearItemDrawer', () => ({
initLinearIssueEditState: (selected: LinearIssue) => ({
state: selected.state,
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { useMountedRef } from '@/hooks/useMountedRef'
import { linearGetIssue, linearIssueComments } from '@/runtime/runtime-linear-client'
import { linearGetIssue, linearIssueComments } from '@/runtime/runtime-linear-issue-mutations'
import type { RuntimeLinearSettings } from '@/runtime/runtime-linear-client'
import {
initLinearIssueEditState,
@@ -10,7 +10,7 @@ import type { WorkspaceLineage } from '../../../../shared/worktree/lineage-types
import type { Worktree } from '../../../../shared/worktree/types'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity'
import { getGitHubRepoCacheKey } from '@/store/slices/github-cache-key'
import { prChecksCacheSuffix } from '@/store/github/cache-identity'
@@ -8,7 +8,7 @@ import { translate } from '@/i18n/i18n'
import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/github/check-types'
import { getAttachedWorktreesForFolderWorkspace } from './folder-workspace-attached-worktrees'
import { FolderWorkspacePrChecksRow } from './FolderWorkspacePrChecksRow'
import type { ParentPrChecksRefreshOutcome, ParentPrChecksRow } from './parent-pr-checks-rows'
import type { ParentPrChecksRefreshOutcome, ParentPrChecksRow } from './parent-pr-checks-row-types'
import {
getParentPrChecksRefreshCandidates,
runLimitedParentPrChecksRefreshes
@@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { CheckStatus } from '../../../../shared/github/pull-request-types'
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import type { ParentPrChecksRow, ParentPrChecksRowStatus } from './parent-pr-checks-rows'
import type { ParentPrChecksRow, ParentPrChecksRowStatus } from './parent-pr-checks-row-types'
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, values?: Record<string, unknown>) =>
@@ -11,7 +11,7 @@ import {
PullRequestIcon
} from './checks-panel/check-presentation'
import { ChecksList } from './checks-panel/checks-list'
import type { ParentPrChecksRow } from './parent-pr-checks-rows'
import type { ParentPrChecksRow } from './parent-pr-checks-row-types'
type FolderWorkspacePrChecksRowProps = {
row: ParentPrChecksRow
@@ -1,6 +1,6 @@
import React, { useCallback } from 'react'
import { toast } from 'sonner'
import { refreshHostedReviewCard } from '@/store/slices/hosted-review'
import { refreshHostedReviewCard } from '@/store/slices/hosted-review-card-refresh'
import { checksPanelAsyncResultKey } from '../checks-panel-async-result-key'
import {
buildFixBrokenChecksPrompt,
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import { useAppStore } from '@/store'
import { isFolderRepo } from '../../../../../shared/repo-kind'
import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity'
import { selectReviewCacheEntry } from '../review-cache-entry-selection'
import { selectChecksPanelReview, type ChecksPanelReview } from '../checks-panel-review'
import { isGitLabChecksPanelReview } from './gitlab-review-client'
@@ -1,6 +1,6 @@
import { useCallback } from 'react'
import { toast } from 'sonner'
import { refreshHostedReviewCard } from '@/store/slices/hosted-review'
import { refreshHostedReviewCard } from '@/store/slices/hosted-review-card-refresh'
import { openHttpLink } from '@/lib/http-link-routing'
import { resolveCreatedHostedReviewLink } from '../source-control-created-review-link'
import { formatCreateError } from '../create-pull-request-review-copy'
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useRef } from 'react'
import { ENTRY_REFRESH_GRACE_MS, shouldEntryRefresh } from '../checks-entry-refresh'
import { refreshHostedReviewCard } from '@/store/slices/hosted-review'
import { refreshHostedReviewCard } from '@/store/slices/hosted-review-card-refresh'
import { toast } from 'sonner'
import type { ChecksPanelContextState } from './use-checks-panel-context-state'
@@ -2,12 +2,12 @@
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type * as HostedReviewStore from '@/store/slices/hosted-review'
import type * as HostedReviewCardRefresh from '@/store/slices/hosted-review-card-refresh'
const refresh = vi.hoisted(() => ({ calls: [] as string[] }))
vi.mock('@/store/slices/hosted-review', async (importOriginal) => {
const original = await importOriginal<typeof HostedReviewStore>()
vi.mock('@/store/slices/hosted-review-card-refresh', async (importOriginal) => {
const original = await importOriginal<typeof HostedReviewCardRefresh>()
return {
...original,
refreshHostedReviewCard: vi.fn(async () => {
@@ -1,7 +1,7 @@
import { useCallback } from 'react'
import { useAppStore } from '@/store'
import { buildGitHubPRRefreshStateClearToken } from '@/store/github/pr-refresh-state'
import { refreshHostedReviewCard } from '@/store/slices/hosted-review'
import { refreshHostedReviewCard } from '@/store/slices/hosted-review-card-refresh'
import { getRuntimeGitStatus, getRuntimeGitUpstreamStatus } from '@/runtime/runtime-git-client'
import {
hasChecksPanelGitStatusBranchChanged,
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity'
import { buildParentPrChecksProjection } from './parent-pr-checks-rows'
import { createParentPrChecksProjectionSelector } from './parent-pr-checks-projection-selector'
@@ -6,10 +6,8 @@ import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import { isFolderRepo } from '../../../../shared/repo-kind'
import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display'
import { compareWorktreeDisplayName } from '@/lib/worktree-display-name-order'
import {
getParentPrChecksRefreshIdentity,
type ParentPrChecksRefreshOutcome
} from './parent-pr-checks-rows'
import { getParentPrChecksRefreshIdentity } from './parent-pr-checks-rows'
import type { ParentPrChecksRefreshOutcome } from './parent-pr-checks-row-types'
type FetchHostedReview = (
repoPath: string,
@@ -4,7 +4,7 @@ import type { PRInfo } from '../../../../shared/github/pull-request-types'
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity'
import {
getGitHubPRCacheKey,
getGitHubRepoCacheKey,
@@ -13,9 +13,9 @@ import {
import { prChecksCacheSuffix } from '@/store/github/cache-identity'
import {
buildParentPrChecksProjection,
getParentPrChecksRefreshIdentity,
type ParentPrChecksRefreshOutcome
getParentPrChecksRefreshIdentity
} from './parent-pr-checks-rows'
import type { ParentPrChecksRefreshOutcome } from './parent-pr-checks-row-types'
const settings = null as never
@@ -7,7 +7,10 @@ import { isFolderRepo } from '../../../../shared/repo-kind'
import { getWorktreeCardPrDisplay } from '@/components/sidebar/worktree-card-pr-display'
import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display'
import { getGitHubRepoCacheKey } from '@/store/slices/github-cache-key'
import { getHostedReviewCacheKey, linkedReviewHintKey } from '@/store/slices/hosted-review'
import {
getHostedReviewCacheKey,
linkedReviewHintKey
} from '@/store/slices/hosted-review-cache-identity'
import { prChecksCacheSuffix } from '@/store/github/cache-identity'
import {
PARENT_PR_CHECKS_GROUP_LABELS,
@@ -33,15 +36,6 @@ import { canUseParentPrChecksHostedReviewCacheEntry } from './parent-pr-checks-h
type ParentPrChecksRowSourceArgs = Omit<BuildParentPrChecksRowsArgs, 'repos'>
export type {
ParentPrChecksGroupKey,
ParentPrChecksProjection,
ParentPrChecksRefreshOutcome,
ParentPrChecksRow,
ParentPrChecksRowStatus,
ParentPrChecksSummary
} from './parent-pr-checks-row-types'
export function buildParentPrChecksProjection(
args: BuildParentPrChecksRowsArgs
): ParentPrChecksProjection {
@@ -7,7 +7,7 @@ import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-displ
import { useAppStore } from '@/store'
import { useActiveWorktree, useRepoById, useWorktreeMap } from '@/store/selectors'
import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity'
import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types'
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
import { isFolderRepo } from '../../../../../../shared/repo-kind'
@@ -3,10 +3,8 @@ import type { Repo } from '../../../../shared/repo-types'
import type { WorkspaceLineage, WorktreeLineage } from '../../../../shared/worktree/lineage-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope'
import {
buildParentPrChecksRows,
type ParentPrChecksRow
} from '@/components/right-sidebar/parent-pr-checks-rows'
import { buildParentPrChecksRows } from '@/components/right-sidebar/parent-pr-checks-rows'
import type { ParentPrChecksRow } from '@/components/right-sidebar/parent-pr-checks-row-types'
import type { WorktreeCardPrDisplay } from './worktree-card-pr-display'
import { getProjectedWorktreeLineageChildrenByParentId } from './worktree-lineage-projection'
@@ -1,9 +1,5 @@
/* eslint-disable max-lines -- Why: marquee selection coordinates pointer capture, lane-scroll refresh, auto-scroll, preview cleanup, and final commit against one drag state. Splitting those phases would make the interaction easier to desynchronize. */
import React, { useCallback, useEffect, useRef } from 'react'
import {
getAreaSelectionCardRects,
type AreaSelectionCardRect
} from './workspace-kanban-area-selection-card-rects'
import { getAreaSelectionCardRects } from './workspace-kanban-area-selection-card-rects'
import {
clearPreviewSelection,
getAreaSelectionAutoScrollDelta,
@@ -16,54 +12,12 @@ import {
shouldIgnoreAreaSelectionStart,
updatePreviewSelection
} from './workspace-kanban-area-selection-dom'
type AreaSelectionDragState = {
startX: number
startY: number
currentX: number
currentY: number
additive: boolean
baseSelectedIds: Set<string>
baseAnchorId: string | null
boardRect: DOMRect
cardRects: readonly AreaSelectionCardRect[]
scrollStartContentYByElement: ReadonlyMap<HTMLElement, number>
previewIds: Set<string>
finalAreaIds: string[]
started: boolean
frameId: number | null
scrollFrameId: number | null
}
type UpdateSelectionForArea = (
areaIds: readonly string[],
additive: boolean,
baseSelectedIds?: ReadonlySet<string>,
baseAnchorId?: string | null
) => void
type UseWorkspaceKanbanAreaSelectionParams = {
open: boolean
boardRef: React.RefObject<HTMLDivElement | null>
overlayRef: React.RefObject<HTMLDivElement | null>
selectedWorktreeIds: ReadonlySet<string>
selectionAnchorId: string | null
updateSelectionForArea: UpdateSelectionForArea
}
const AREA_SELECTION_DRAG_THRESHOLD = 4
export function shouldCommitWorkspaceKanbanAreaSelection({
additive,
started
}: {
additive: boolean
started: boolean
}): boolean {
// Why: a plain click on empty board space is the user's "click off" gesture;
// modifier-clicking empty space should not accidentally drop a selected batch.
return started || !additive
}
import {
AREA_SELECTION_DRAG_THRESHOLD,
shouldCommitWorkspaceKanbanAreaSelection,
type AreaSelectionDragState,
type UseWorkspaceKanbanAreaSelectionParams
} from './workspace-kanban-area-selection-state'
export function useWorkspaceKanbanAreaSelection({
open,
@@ -2,7 +2,7 @@ import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-displ
import { useAppStore } from '@/store'
import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key'
import { issueCacheKey as getIssueCacheKey } from '@/store/github/cache-identity'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity'
import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-review-github'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import { isFolderRepo } from '../../../../shared/repo-kind'
@@ -1,10 +1,6 @@
import {
linearGetIssue,
linearTeamStates,
linearUpdateIssue,
type LinearMutationResult,
type RuntimeLinearSettings
} from '@/runtime/runtime-linear-client'
import { linearGetIssue, linearUpdateIssue } from '@/runtime/runtime-linear-issue-mutations'
import { linearTeamStates } from '@/runtime/runtime-linear-project-client'
import type { LinearMutationResult, RuntimeLinearSettings } from '@/runtime/runtime-linear-client'
import type { LinearIssue } from '../../../../shared/linear/issue-types'
import type { LinearWorkflowState } from '../../../../shared/linear/workspace-types'
import type {
@@ -0,0 +1,50 @@
import type React from 'react'
import type { AreaSelectionCardRect } from './workspace-kanban-area-selection-card-rects'
export type AreaSelectionDragState = {
startX: number
startY: number
currentX: number
currentY: number
additive: boolean
baseSelectedIds: Set<string>
baseAnchorId: string | null
boardRect: DOMRect
cardRects: readonly AreaSelectionCardRect[]
scrollStartContentYByElement: ReadonlyMap<HTMLElement, number>
previewIds: Set<string>
finalAreaIds: string[]
started: boolean
frameId: number | null
scrollFrameId: number | null
}
type UpdateSelectionForArea = (
areaIds: readonly string[],
additive: boolean,
baseSelectedIds?: ReadonlySet<string>,
baseAnchorId?: string | null
) => void
export type UseWorkspaceKanbanAreaSelectionParams = {
open: boolean
boardRef: React.RefObject<HTMLDivElement | null>
overlayRef: React.RefObject<HTMLDivElement | null>
selectedWorktreeIds: ReadonlySet<string>
selectionAnchorId: string | null
updateSelectionForArea: UpdateSelectionForArea
}
export const AREA_SELECTION_DRAG_THRESHOLD = 4
export function shouldCommitWorkspaceKanbanAreaSelection({
additive,
started
}: {
additive: boolean
started: boolean
}): boolean {
// Why: a plain click on empty board space is the user's "click off" gesture;
// modifier-clicking empty space should not accidentally drop a selected batch.
return started || !additive
}
@@ -6,7 +6,7 @@ import {
getAreaSelectionCardIds,
updatePreviewSelection
} from './workspace-kanban-area-selection-dom'
import { shouldCommitWorkspaceKanbanAreaSelection } from './use-workspace-kanban-area-selection'
import { shouldCommitWorkspaceKanbanAreaSelection } from './workspace-kanban-area-selection-state'
describe('workspace kanban area selection finish', () => {
it('commits an empty non-additive surface click so selection clears', () => {
@@ -54,7 +54,7 @@ import {
getWorktreeMapFromState,
getWorktreeOnHostFromState
} from '../../store/selectors'
import { getHostedReviewCacheKey } from '../../store/slices/hosted-review'
import { getHostedReviewCacheKey } from '../../store/slices/hosted-review-cache-identity'
import { issueCacheKey as getIssueCacheKey } from '../../store/github/cache-identity'
import { findRepoForHost } from '@/store/slices/repo-host-identity'
import {
@@ -3,11 +3,8 @@ import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import {
linearCreateIssue,
linearCreateProject,
linearGetIssue
} from '@/runtime/runtime-linear-client'
import { linearCreateIssue, linearGetIssue } from '@/runtime/runtime-linear-issue-mutations'
import { linearCreateProject } from '@/runtime/runtime-linear-project-client'
import type { GlobalSettings } from '../../../../../shared/global-settings-types'
import type { LinearIssue } from '../../../../../shared/linear/issue-types'
import type {
@@ -7,7 +7,8 @@ import {
readLinearBoardIssueDragData,
writeLinearBoardIssueDragData
} from '@/lib/linear-board-drag-payload'
import { linearTeamStates, linearUpdateIssue } from '@/runtime/runtime-linear-client'
import { linearUpdateIssue } from '@/runtime/runtime-linear-issue-mutations'
import { linearTeamStates } from '@/runtime/runtime-linear-project-client'
import type { GlobalSettings } from '../../../../../shared/global-settings-types'
import type { LinearIssue } from '../../../../../shared/linear/issue-types'
import type { LinearTeam } from '../../../../../shared/linear/workspace-types'
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState, type SetStateAction } from 'react'
import { useTaskCreationDraftRetention } from '@/components/use-task-creation-draft-retention'
import { useTeamLabels, useTeamMembers, useTeamStates } from '@/hooks/useIssueMetadata'
import { linearListProjects } from '@/runtime/runtime-linear-client'
import { linearListProjects } from '@/runtime/runtime-linear-project-client'
import {
writeNewLinearIssueDraft,
writeNewLinearProjectDraft
@@ -9,7 +9,7 @@ import {
getLinearStatePillStyle
} from '@/components/linear-state-pill-style'
import { useTeamStates } from '@/hooks/useIssueMetadata'
import { linearUpdateIssue } from '@/runtime/runtime-linear-client'
import { linearUpdateIssue } from '@/runtime/runtime-linear-issue-mutations'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { LinearIssue } from '../../../../../shared/linear/issue-types'
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity'
import {
filterWorkspaceCleanupCandidates,
getWorkspaceCleanupGitLabel,
@@ -1,4 +1,4 @@
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity'
import type { AppState } from '@/store/types'
import { translate } from '@/i18n/i18n'
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
@@ -21,7 +21,7 @@ const linearMocks = vi.hoisted(() => ({
const runtimeMocks = vi.hoisted(() => ({ callRuntimeRpc: vi.fn() }))
const githubMocks = vi.hoisted(() => ({ listLabels: vi.fn() }))
vi.mock('@/runtime/runtime-linear-client', () => ({
vi.mock('@/runtime/runtime-linear-project-client', () => ({
linearTeamStates: linearMocks.linearTeamStates,
linearTeamLabels: linearMocks.linearTeamLabels,
linearTeamMembers: linearMocks.linearTeamMembers
+3 -3
View File
@@ -3,9 +3,9 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-cl
import {
linearTeamLabels,
linearTeamMembers,
linearTeamStates,
type RuntimeLinearSettings
} from '@/runtime/runtime-linear-client'
linearTeamStates
} from '@/runtime/runtime-linear-project-client'
import type { RuntimeLinearSettings } from '@/runtime/runtime-linear-client'
import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types'
import type {
LinearLabel,
@@ -1,8 +1,17 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
linearListIssues,
linearSearchIssues,
linearSelectWorkspace,
linearStatus
} from './runtime-linear-client'
import {
linearCreateIssue,
linearCreateProject,
linearCreateSubIssue,
linearUpdateIssue
} from './runtime-linear-issue-mutations'
import {
linearCreateProject,
linearGetCustomView,
linearGetProject,
linearListCustomViewIssues,
@@ -10,13 +19,8 @@ import {
linearListCustomViews,
linearListProjectIssues,
linearListProjects,
linearListTeams,
linearListIssues,
linearSearchIssues,
linearSelectWorkspace,
linearStatus,
linearUpdateIssue
} from './runtime-linear-client'
linearListTeams
} from './runtime-linear-project-client'
import {
createCompatibleRuntimeStatusResponse,
createCompatibleRuntimeStatusResponseIfNeeded,
@@ -1,23 +1,10 @@
/* eslint-disable max-lines -- Why: the renderer Linear client mirrors the
preload/RPC Linear namespace so local and remote runtime routing stays in
one auditable boundary. */
import type { GlobalSettings } from '../../../shared/global-settings-types'
import type { LinearIssueUpdate } from '../../../shared/issue-mutation-types'
import type { LinearComment, LinearIssue } from '../../../shared/linear/issue-types'
import type {
LinearCustomViewModel,
LinearCustomViewSummary,
LinearProjectDetail,
LinearProjectSummary
} from '../../../shared/linear/project-types'
import type { LinearIssue } from '../../../shared/linear/issue-types'
import type { LinearProjectDetail } from '../../../shared/linear/project-types'
import type {
LinearCollectionResult,
LinearConnectionStatus,
LinearLabel,
LinearMember,
LinearTeam,
LinearViewer,
LinearWorkflowState,
LinearWorkspaceSelection
} from '../../../shared/linear/workspace-types'
import {
@@ -71,7 +58,7 @@ export type LinearMutationResult = { ok: true } | { ok: false; error: string }
export type LinearCommentResult = { ok: true; id: string } | { ok: false; error: string }
export type LinearReadOptions = { force?: boolean }
function linearReadForce(options?: LinearReadOptions): { force: true } | {} {
export function linearReadForce(options?: LinearReadOptions): { force: true } | {} {
return options?.force ? { force: true } : {}
}
@@ -81,7 +68,7 @@ function isTaskSourceRuntimeSettings(
return settings !== null && settings !== undefined && 'kind' in settings
}
function getLinearRuntimeTarget(
export function getLinearRuntimeTarget(
settings: RuntimeLinearSettings
): ReturnType<typeof getActiveRuntimeTarget> {
// Why: task source context makes provider ownership explicit; legacy callers
@@ -251,348 +238,3 @@ export async function linearListIssues(
: await window.api.linear.listIssues(payload)
return normalizeLinearIssueCollectionResult(result)
}
export async function linearCreateIssue(
settings: RuntimeLinearSettings,
args: {
teamId: string
title: string
description?: string
workspaceId?: string
parentIssueId?: string
projectId?: string | null
stateId?: string
priority?: number
assigneeId?: string | null
labelIds?: string[]
}
): Promise<LinearCreateIssueResult> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearCreateIssueResult>(target, 'linear.createIssue', args, {
timeoutMs: 30_000
})
: window.api.linear.createIssue(args)
}
export async function linearCreateSubIssue(
settings: RuntimeLinearSettings,
args: {
parentIssueId: string
teamId: string
title: string
description?: string
workspaceId?: string
projectId?: string | null
}
): Promise<LinearCreateIssueResult> {
return linearCreateIssue(settings, args)
}
export async function linearGetIssue(
settings: RuntimeLinearSettings,
id: string,
workspaceId?: string | null
): Promise<LinearIssue | null> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearIssue | null>(
target,
'linear.getIssue',
{ id, workspaceId: workspaceId ?? undefined },
{ timeoutMs: 30_000 }
)
: window.api.linear.getIssue({ id, workspaceId: workspaceId ?? undefined })
}
export async function linearUpdateIssue(
settings: RuntimeLinearSettings,
id: string,
updates: LinearIssueUpdate,
workspaceId?: string | null
): Promise<LinearMutationResult> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearMutationResult>(
target,
'linear.updateIssue',
{ id, updates, workspaceId: workspaceId ?? undefined },
{ timeoutMs: 30_000 }
)
: window.api.linear.updateIssue({ id, updates, workspaceId: workspaceId ?? undefined })
}
export async function linearAddIssueComment(
settings: RuntimeLinearSettings,
issueId: string,
body: string,
workspaceId?: string | null
): Promise<LinearCommentResult> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearCommentResult>(
target,
'linear.addIssueComment',
{ issueId, body, workspaceId: workspaceId ?? undefined },
{ timeoutMs: 30_000 }
)
: window.api.linear.addIssueComment({ issueId, body, workspaceId: workspaceId ?? undefined })
}
export async function linearIssueComments(
settings: RuntimeLinearSettings,
issueId: string,
workspaceId?: string | null
): Promise<LinearComment[]> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearComment[]>(
target,
'linear.issueComments',
{ issueId, workspaceId: workspaceId ?? undefined },
{ timeoutMs: 30_000 }
)
: window.api.linear.issueComments({ issueId, workspaceId: workspaceId ?? undefined })
}
export async function linearListTeams(
settings: RuntimeLinearSettings,
workspaceId?: LinearWorkspaceSelection | null
): Promise<LinearTeam[]> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearTeam[]>(
target,
'linear.listTeams',
workspaceId ? { workspaceId } : undefined,
{ timeoutMs: 30_000 }
)
: window.api.linear.listTeams(workspaceId ? { workspaceId } : undefined)
}
export async function linearListProjects(
settings: RuntimeLinearSettings,
query?: string,
limit?: number,
workspaceId?: LinearWorkspaceSelection | null,
options?: LinearReadOptions
): Promise<LinearCollectionResult<LinearProjectSummary>> {
if (!isRuntimeProviderSearchQueryWithinLimit(query)) {
return { items: [] }
}
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearCollectionResult<LinearProjectSummary>>(
target,
'linear.listProjects',
{ query, limit, workspaceId: workspaceId ?? undefined, ...linearReadForce(options) },
{ timeoutMs: 30_000 }
)
: typeof window.api.linear.listProjects === 'function'
? window.api.linear.listProjects({
query,
limit,
workspaceId: workspaceId ?? undefined,
...linearReadForce(options)
})
: { items: [] }
}
export async function linearCreateProject(
settings: RuntimeLinearSettings,
args: {
name: string
description?: string
content?: string
teamIds: string[]
workspaceId?: string
leadId?: string | null
memberIds?: string[]
labelIds?: string[]
priority?: number
startDate?: string
targetDate?: string
}
): Promise<LinearCreateProjectResult> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearCreateProjectResult>(target, 'linear.createProject', args, {
timeoutMs: 30_000
})
: window.api.linear.createProject(args)
}
export async function linearGetProject(
settings: RuntimeLinearSettings,
id: string,
workspaceId: string,
options?: LinearReadOptions
): Promise<LinearProjectDetail | null> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearProjectDetail | null>(
target,
'linear.getProject',
{ id, workspaceId, ...linearReadForce(options) },
{ timeoutMs: 30_000 }
)
: window.api.linear.getProject({ id, workspaceId, ...linearReadForce(options) })
}
export async function linearListProjectIssues(
settings: RuntimeLinearSettings,
projectId: string,
limit: number | undefined,
workspaceId: string,
options?: LinearReadOptions
): Promise<LinearCollectionResult<LinearIssue>> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearCollectionResult<LinearIssue>>(
target,
'linear.listProjectIssues',
{ projectId, limit, workspaceId, ...linearReadForce(options) },
{ timeoutMs: 30_000 }
)
: window.api.linear.listProjectIssues({
projectId,
limit,
workspaceId,
...linearReadForce(options)
})
}
export async function linearListCustomViews(
settings: RuntimeLinearSettings,
model: LinearCustomViewModel,
limit?: number,
workspaceId?: LinearWorkspaceSelection | null,
options?: LinearReadOptions
): Promise<LinearCollectionResult<LinearCustomViewSummary>> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearCollectionResult<LinearCustomViewSummary>>(
target,
'linear.listCustomViews',
{ model, limit, workspaceId: workspaceId ?? undefined, ...linearReadForce(options) },
{ timeoutMs: 30_000 }
)
: window.api.linear.listCustomViews({
model,
limit,
workspaceId: workspaceId ?? undefined,
...linearReadForce(options)
})
}
export async function linearGetCustomView(
settings: RuntimeLinearSettings,
viewId: string,
model: LinearCustomViewModel,
workspaceId: string,
options?: LinearReadOptions
): Promise<LinearCustomViewSummary | null> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearCustomViewSummary | null>(
target,
'linear.getCustomView',
{ viewId, model, workspaceId, ...linearReadForce(options) },
{ timeoutMs: 30_000 }
)
: window.api.linear.getCustomView({ viewId, model, workspaceId, ...linearReadForce(options) })
}
export async function linearListCustomViewIssues(
settings: RuntimeLinearSettings,
viewId: string,
limit: number | undefined,
workspaceId: string,
options?: LinearReadOptions
): Promise<LinearCollectionResult<LinearIssue>> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearCollectionResult<LinearIssue>>(
target,
'linear.listCustomViewIssues',
{ viewId, limit, workspaceId, ...linearReadForce(options) },
{ timeoutMs: 30_000 }
)
: window.api.linear.listCustomViewIssues({
viewId,
limit,
workspaceId,
...linearReadForce(options)
})
}
export async function linearListCustomViewProjects(
settings: RuntimeLinearSettings,
viewId: string,
limit: number | undefined,
workspaceId: string,
options?: LinearReadOptions
): Promise<LinearCollectionResult<LinearProjectSummary>> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearCollectionResult<LinearProjectSummary>>(
target,
'linear.listCustomViewProjects',
{ viewId, limit, workspaceId, ...linearReadForce(options) },
{ timeoutMs: 30_000 }
)
: window.api.linear.listCustomViewProjects({
viewId,
limit,
workspaceId,
...linearReadForce(options)
})
}
export async function linearTeamStates(
settings: RuntimeLinearSettings,
teamId: string,
workspaceId?: string | null
): Promise<LinearWorkflowState[]> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearWorkflowState[]>(
target,
'linear.teamStates',
{ teamId, workspaceId: workspaceId ?? undefined },
{ timeoutMs: 30_000 }
)
: window.api.linear.teamStates({ teamId, workspaceId: workspaceId ?? undefined })
}
export async function linearTeamLabels(
settings: RuntimeLinearSettings,
teamId: string,
workspaceId?: string | null
): Promise<LinearLabel[]> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearLabel[]>(
target,
'linear.teamLabels',
{ teamId, workspaceId: workspaceId ?? undefined },
{ timeoutMs: 30_000 }
)
: window.api.linear.teamLabels({ teamId, workspaceId: workspaceId ?? undefined })
}
export async function linearTeamMembers(
settings: RuntimeLinearSettings,
teamId: string,
workspaceId?: string | null
): Promise<LinearMember[]> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearMember[]>(
target,
'linear.teamMembers',
{ teamId, workspaceId: workspaceId ?? undefined },
{ timeoutMs: 30_000 }
)
: window.api.linear.teamMembers({ teamId, workspaceId: workspaceId ?? undefined })
}
@@ -0,0 +1,113 @@
import type { LinearIssueUpdate } from '../../../shared/issue-mutation-types'
import type { LinearComment, LinearIssue } from '../../../shared/linear/issue-types'
import { callRuntimeRpc } from './runtime-rpc-client'
import {
getLinearRuntimeTarget,
type LinearCommentResult,
type LinearCreateIssueResult,
type LinearMutationResult,
type RuntimeLinearSettings
} from './runtime-linear-client'
export async function linearCreateIssue(
settings: RuntimeLinearSettings,
args: {
teamId: string
title: string
description?: string
workspaceId?: string
parentIssueId?: string
projectId?: string | null
stateId?: string
priority?: number
assigneeId?: string | null
labelIds?: string[]
}
): Promise<LinearCreateIssueResult> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearCreateIssueResult>(target, 'linear.createIssue', args, {
timeoutMs: 30_000
})
: window.api.linear.createIssue(args)
}
export async function linearCreateSubIssue(
settings: RuntimeLinearSettings,
args: {
parentIssueId: string
teamId: string
title: string
description?: string
workspaceId?: string
projectId?: string | null
}
): Promise<LinearCreateIssueResult> {
return linearCreateIssue(settings, args)
}
export async function linearGetIssue(
settings: RuntimeLinearSettings,
id: string,
workspaceId?: string | null
): Promise<LinearIssue | null> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearIssue | null>(
target,
'linear.getIssue',
{ id, workspaceId: workspaceId ?? undefined },
{ timeoutMs: 30_000 }
)
: window.api.linear.getIssue({ id, workspaceId: workspaceId ?? undefined })
}
export async function linearUpdateIssue(
settings: RuntimeLinearSettings,
id: string,
updates: LinearIssueUpdate,
workspaceId?: string | null
): Promise<LinearMutationResult> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearMutationResult>(
target,
'linear.updateIssue',
{ id, updates, workspaceId: workspaceId ?? undefined },
{ timeoutMs: 30_000 }
)
: window.api.linear.updateIssue({ id, updates, workspaceId: workspaceId ?? undefined })
}
export async function linearAddIssueComment(
settings: RuntimeLinearSettings,
issueId: string,
body: string,
workspaceId?: string | null
): Promise<LinearCommentResult> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearCommentResult>(
target,
'linear.addIssueComment',
{ issueId, body, workspaceId: workspaceId ?? undefined },
{ timeoutMs: 30_000 }
)
: window.api.linear.addIssueComment({ issueId, body, workspaceId: workspaceId ?? undefined })
}
export async function linearIssueComments(
settings: RuntimeLinearSettings,
issueId: string,
workspaceId?: string | null
): Promise<LinearComment[]> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearComment[]>(
target,
'linear.issueComments',
{ issueId, workspaceId: workspaceId ?? undefined },
{ timeoutMs: 30_000 }
)
: window.api.linear.issueComments({ issueId, workspaceId: workspaceId ?? undefined })
}
@@ -0,0 +1,266 @@
import type { LinearIssue } from '../../../shared/linear/issue-types'
import type {
LinearCustomViewModel,
LinearCustomViewSummary,
LinearProjectDetail,
LinearProjectSummary
} from '../../../shared/linear/project-types'
import type {
LinearCollectionResult,
LinearLabel,
LinearMember,
LinearTeam,
LinearWorkflowState,
LinearWorkspaceSelection
} from '../../../shared/linear/workspace-types'
import { isRuntimeProviderSearchQueryWithinLimit } from './runtime-provider-search-bounds'
import { callRuntimeRpc } from './runtime-rpc-client'
import {
getLinearRuntimeTarget,
linearReadForce,
type LinearCreateProjectResult,
type LinearReadOptions,
type RuntimeLinearSettings
} from './runtime-linear-client'
export async function linearListTeams(
settings: RuntimeLinearSettings,
workspaceId?: LinearWorkspaceSelection | null
): Promise<LinearTeam[]> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearTeam[]>(
target,
'linear.listTeams',
workspaceId ? { workspaceId } : undefined,
{ timeoutMs: 30_000 }
)
: window.api.linear.listTeams(workspaceId ? { workspaceId } : undefined)
}
export async function linearListProjects(
settings: RuntimeLinearSettings,
query?: string,
limit?: number,
workspaceId?: LinearWorkspaceSelection | null,
options?: LinearReadOptions
): Promise<LinearCollectionResult<LinearProjectSummary>> {
if (!isRuntimeProviderSearchQueryWithinLimit(query)) {
return { items: [] }
}
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearCollectionResult<LinearProjectSummary>>(
target,
'linear.listProjects',
{ query, limit, workspaceId: workspaceId ?? undefined, ...linearReadForce(options) },
{ timeoutMs: 30_000 }
)
: typeof window.api.linear.listProjects === 'function'
? window.api.linear.listProjects({
query,
limit,
workspaceId: workspaceId ?? undefined,
...linearReadForce(options)
})
: { items: [] }
}
export async function linearCreateProject(
settings: RuntimeLinearSettings,
args: {
name: string
description?: string
content?: string
teamIds: string[]
workspaceId?: string
leadId?: string | null
memberIds?: string[]
labelIds?: string[]
priority?: number
startDate?: string
targetDate?: string
}
): Promise<LinearCreateProjectResult> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearCreateProjectResult>(target, 'linear.createProject', args, {
timeoutMs: 30_000
})
: window.api.linear.createProject(args)
}
export async function linearGetProject(
settings: RuntimeLinearSettings,
id: string,
workspaceId: string,
options?: LinearReadOptions
): Promise<LinearProjectDetail | null> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearProjectDetail | null>(
target,
'linear.getProject',
{ id, workspaceId, ...linearReadForce(options) },
{ timeoutMs: 30_000 }
)
: window.api.linear.getProject({ id, workspaceId, ...linearReadForce(options) })
}
export async function linearListProjectIssues(
settings: RuntimeLinearSettings,
projectId: string,
limit: number | undefined,
workspaceId: string,
options?: LinearReadOptions
): Promise<LinearCollectionResult<LinearIssue>> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearCollectionResult<LinearIssue>>(
target,
'linear.listProjectIssues',
{ projectId, limit, workspaceId, ...linearReadForce(options) },
{ timeoutMs: 30_000 }
)
: window.api.linear.listProjectIssues({
projectId,
limit,
workspaceId,
...linearReadForce(options)
})
}
export async function linearListCustomViews(
settings: RuntimeLinearSettings,
model: LinearCustomViewModel,
limit?: number,
workspaceId?: LinearWorkspaceSelection | null,
options?: LinearReadOptions
): Promise<LinearCollectionResult<LinearCustomViewSummary>> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearCollectionResult<LinearCustomViewSummary>>(
target,
'linear.listCustomViews',
{ model, limit, workspaceId: workspaceId ?? undefined, ...linearReadForce(options) },
{ timeoutMs: 30_000 }
)
: window.api.linear.listCustomViews({
model,
limit,
workspaceId: workspaceId ?? undefined,
...linearReadForce(options)
})
}
export async function linearGetCustomView(
settings: RuntimeLinearSettings,
viewId: string,
model: LinearCustomViewModel,
workspaceId: string,
options?: LinearReadOptions
): Promise<LinearCustomViewSummary | null> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearCustomViewSummary | null>(
target,
'linear.getCustomView',
{ viewId, model, workspaceId, ...linearReadForce(options) },
{ timeoutMs: 30_000 }
)
: window.api.linear.getCustomView({ viewId, model, workspaceId, ...linearReadForce(options) })
}
export async function linearListCustomViewIssues(
settings: RuntimeLinearSettings,
viewId: string,
limit: number | undefined,
workspaceId: string,
options?: LinearReadOptions
): Promise<LinearCollectionResult<LinearIssue>> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearCollectionResult<LinearIssue>>(
target,
'linear.listCustomViewIssues',
{ viewId, limit, workspaceId, ...linearReadForce(options) },
{ timeoutMs: 30_000 }
)
: window.api.linear.listCustomViewIssues({
viewId,
limit,
workspaceId,
...linearReadForce(options)
})
}
export async function linearListCustomViewProjects(
settings: RuntimeLinearSettings,
viewId: string,
limit: number | undefined,
workspaceId: string,
options?: LinearReadOptions
): Promise<LinearCollectionResult<LinearProjectSummary>> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearCollectionResult<LinearProjectSummary>>(
target,
'linear.listCustomViewProjects',
{ viewId, limit, workspaceId, ...linearReadForce(options) },
{ timeoutMs: 30_000 }
)
: window.api.linear.listCustomViewProjects({
viewId,
limit,
workspaceId,
...linearReadForce(options)
})
}
export async function linearTeamStates(
settings: RuntimeLinearSettings,
teamId: string,
workspaceId?: string | null
): Promise<LinearWorkflowState[]> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearWorkflowState[]>(
target,
'linear.teamStates',
{ teamId, workspaceId: workspaceId ?? undefined },
{ timeoutMs: 30_000 }
)
: window.api.linear.teamStates({ teamId, workspaceId: workspaceId ?? undefined })
}
export async function linearTeamLabels(
settings: RuntimeLinearSettings,
teamId: string,
workspaceId?: string | null
): Promise<LinearLabel[]> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearLabel[]>(
target,
'linear.teamLabels',
{ teamId, workspaceId: workspaceId ?? undefined },
{ timeoutMs: 30_000 }
)
: window.api.linear.teamLabels({ teamId, workspaceId: workspaceId ?? undefined })
}
export async function linearTeamMembers(
settings: RuntimeLinearSettings,
teamId: string,
workspaceId?: string | null
): Promise<LinearMember[]> {
const target = getLinearRuntimeTarget(settings)
return target.kind === 'environment'
? callRuntimeRpc<LinearMember[]>(
target,
'linear.teamMembers',
{ teamId, workspaceId: workspaceId ?? undefined },
{ timeoutMs: 30_000 }
)
: window.api.linear.teamMembers({ teamId, workspaceId: workspaceId ?? undefined })
}
@@ -0,0 +1,322 @@
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { DiffComment } from '../../../../shared/diff-comment-types'
import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { getRepoIdFromWorktreeId } from './worktree-helpers'
import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client'
import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import {
findFolderWorkspaceOwner,
getExecutionHostIdForFolderWorkspace,
getRuntimeEnvironmentIdForFolderWorkspace
} from '@/lib/folder-workspace-runtime-owner'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
export function normalizeDiffComment(comment: DiffComment): DiffComment {
const rawSource = (comment as { source?: unknown }).source
const source = rawSource === 'markdown' || rawSource === 'diff' ? rawSource : undefined
const rawStartLine = (comment as { startLine?: unknown }).startLine
const startLine =
Number.isInteger(rawStartLine) &&
typeof rawStartLine === 'number' &&
rawStartLine >= 1 &&
rawStartLine <= comment.lineNumber
? rawStartLine
: undefined
const rawSelectedText = (comment as { selectedText?: unknown }).selectedText
const selectedText =
typeof rawSelectedText === 'string' && rawSelectedText.trim().length > 0
? rawSelectedText.trim()
: undefined
const rawSentAt = (comment as { sentAt?: unknown }).sentAt
const sentAt =
typeof rawSentAt === 'number' && Number.isFinite(rawSentAt) && rawSentAt > 0
? rawSentAt
: undefined
return {
...comment,
...(source !== undefined ? { source } : {}),
...(source === undefined ? { source: undefined } : {}),
...(selectedText !== undefined ? { selectedText } : {}),
...(selectedText === undefined ? { selectedText: undefined } : {}),
...(startLine !== undefined ? { startLine } : {}),
...(startLine === undefined ? { startLine: undefined } : {}),
...(sentAt !== undefined ? { sentAt } : {}),
...(sentAt === undefined ? { sentAt: undefined } : {})
}
}
async function persist(
state: AppState,
settings: AppState['settings'],
worktreeId: string,
diffComments: DiffComment[],
folderExecutionHostId?: ReturnType<typeof getExecutionHostIdForFolderWorkspace>
): Promise<void> {
const scope = parseWorkspaceKey(worktreeId)
if (scope?.type === 'folder') {
const executionHostId =
folderExecutionHostId ?? getExecutionHostIdForFolderWorkspace(state, scope.folderWorkspaceId)
const runtimeEnvironmentId = getRuntimeEnvironmentIdForFolderWorkspace(
state,
scope.folderWorkspaceId,
executionHostId
)
const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId: runtimeEnvironmentId })
const updated =
target.kind === 'local'
? await window.api.folderWorkspaces.update({
folderWorkspaceId: scope.folderWorkspaceId,
updates: { diffComments }
})
: (
await callRuntimeRpc<{ folderWorkspace: FolderWorkspace | null }>(
target,
'folderWorkspace.update',
{ folderWorkspaceId: scope.folderWorkspaceId, updates: { diffComments } },
{ timeoutMs: 15_000 }
)
).folderWorkspace
if (!updated?.diffComments) {
throw new Error('Failed to persist folder workspace review notes')
}
return
}
const target = getActiveRuntimeTarget(settings)
if (target.kind === 'local') {
await window.api.worktrees.updateMeta({
worktreeId,
updates: { diffComments }
})
return
}
await callRuntimeRpc(
target,
'worktree.set',
{ worktree: toRuntimeWorktreeSelector(worktreeId), diffComments },
{ timeoutMs: 15_000 }
)
}
function settingsForWorktreeOwner(state: AppState, worktreeId: string): AppState['settings'] {
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId)
return state.settings
? { ...state.settings, activeRuntimeEnvironmentId: runtimeEnvironmentId }
: ({ activeRuntimeEnvironmentId: runtimeEnvironmentId } as AppState['settings'])
}
// Why: IPC writes aren't ordered, so serialize per worktree to stop an older snapshot from overwriting a newer one on disk.
const persistQueueByWorktree = new Map<string, Promise<void>>()
// Why: rollback must converge to what actually reached disk; in a burst of failed writes each mutation's own predecessor is stale.
const lastPersistedByQueue = new Map<string, DiffComment[] | undefined>()
// Why: the floor is only valid across an unbroken mutation chain; this is how an out-of-band replacement mid-burst is detected.
const lastMutationNextByQueue = new Map<string, DiffComment[]>()
// Why: bumped on every re-seed, so an in-flight write can tell whether the floor it captured is still the current one.
const floorSeedEpochByQueue = new Map<string, number>()
export type DiffCommentMutation = {
previous: DiffComment[] | undefined
next: DiffComment[]
folderExecutionHostId?: ReturnType<typeof getExecutionHostIdForFolderWorkspace>
}
// Why: one key for the queue and both bookkeeping maps, so they can never drift apart.
function persistQueueKey(
worktreeId: string,
folderExecutionHostId?: ReturnType<typeof getExecutionHostIdForFolderWorkspace>
): string {
return folderExecutionHostId ? `${folderExecutionHostId}\0${worktreeId}` : worktreeId
}
// Why: chain each write onto the prior promise so writes land in call order; both then handlers keep the chain alive past a failure.
// Why: queued work reads the latest list at dequeue time, and the returned promise settles for THIS write.
// Why: this promise rejects only after this write's rollback has been applied — callers must not roll back themselves.
export function enqueueDiffCommentPersist(
set: Parameters<StateCreator<AppState>>[0],
worktreeId: string,
get: () => AppState,
mutation: DiffCommentMutation
): Promise<void> {
const folderExecutionHostId = mutation.folderExecutionHostId
const queueKey = persistQueueKey(worktreeId, folderExecutionHostId)
const prior = persistQueueByWorktree.get(queueKey) ?? Promise.resolve()
// Why: an idle queue means disk still holds the pre-mutation list, so that list is this burst's rollback floor.
// Why: a `previous` that isn't the last mutation's `next` means something outside the mutators replaced the list
// (hydration, folderWorkspaces refresh, remote push), so the old floor predates that state and must be re-seeded.
// Why: seed before the queue entry below, or `has` is always true and the floor is never seeded.
const chainBroken = lastMutationNextByQueue.get(queueKey) !== mutation.previous
if (!persistQueueByWorktree.has(queueKey) || chainBroken) {
lastPersistedByQueue.set(queueKey, mutation.previous)
floorSeedEpochByQueue.set(queueKey, (floorSeedEpochByQueue.get(queueKey) ?? 0) + 1)
}
lastMutationNextByQueue.set(queueKey, mutation.next)
const run = async (): Promise<void> => {
// Why: capture at dequeue time, alongside `stateList` — an epoch read at enqueue time would bar every write
// that straddles a chain break from recording a floor its coalesced payload already carries.
const seedEpoch = floorSeedEpochByQueue.get(queueKey)
// Why: the state-side array, not the normalized copy sent to disk — restoring the same instance keeps
// `getDiffComments` identity stable instead of churning selectors.
let stateList: DiffComment[] | undefined
try {
const scope = parseWorkspaceKey(worktreeId)
if (scope?.type === 'folder') {
const state = get()
const folderWorkspace = findFolderWorkspaceOwner(
state,
scope.folderWorkspaceId,
folderExecutionHostId
)
stateList = folderWorkspace?.diffComments
await persist(
state,
state.settings,
worktreeId,
(stateList ?? []).map(normalizeDiffComment),
folderExecutionHostId
)
} else {
const repoId = getRepoIdFromWorktreeId(worktreeId)
const target = get().worktreesByRepo[repoId]?.find((w) => w.id === worktreeId)
stateList = target?.diffComments
const state = get()
await persist(
state,
settingsForWorktreeOwner(state, worktreeId),
worktreeId,
(stateList ?? []).map(normalizeDiffComment)
)
}
} catch (err) {
// Why: converge to what actually landed; rollback's identity guard no-ops when a later mutation owns the array.
// Why: `has()`, not `get() ?? …` — an absent entry and a legitimately-`undefined` floor are different things.
const floor = lastPersistedByQueue.has(queueKey)
? lastPersistedByQueue.get(queueKey)
: mutation.previous
rollback(set, worktreeId, floor, mutation.next, folderExecutionHostId)
throw err
}
// Why: a chain break re-seeded the floor to an out-of-band replacement while this write was awaiting, so the
// list captured before the await predates it and must not be reinstated as the floor.
if (floorSeedEpochByQueue.get(queueKey) === seedEpoch) {
lastPersistedByQueue.set(queueKey, stateList)
}
}
const next = prior.then(run, run)
persistQueueByWorktree.set(queueKey, next)
// Why: clear the queue entry only if still the tail, so later enqueues chain onto the real in-flight promise.
// Why: then(cleanup, cleanup) not finally, so a rejection is consumed here rather than re-thrown as unhandledRejection.
const cleanup = (): void => {
if (persistQueueByWorktree.get(queueKey) === next) {
persistQueueByWorktree.delete(queueKey)
// Why: tail-guarded only; a mid-burst delete would strand the remaining writes of the burst without a floor.
lastPersistedByQueue.delete(queueKey)
lastMutationNextByQueue.delete(queueKey)
floorSeedEpochByQueue.delete(queueKey)
}
}
next.then(cleanup, cleanup)
return next
}
// Why: derive the next list inside the `set` updater so concurrent writes can't clobber each other via a stale closure.
export function mutateDiffComments(
set: Parameters<StateCreator<AppState>>[0],
worktreeId: string,
mutate: (existing: DiffComment[]) => DiffComment[] | null
): DiffCommentMutation | null {
const repoId = getRepoIdFromWorktreeId(worktreeId)
let previous: DiffComment[] | undefined
let next: DiffComment[] | null = null
let folderExecutionHostId: ReturnType<typeof getExecutionHostIdForFolderWorkspace> | undefined
set((s) => {
const scope = parseWorkspaceKey(worktreeId)
if (scope?.type === 'folder') {
const target = findFolderWorkspaceOwner(s, scope.folderWorkspaceId)
if (!target) {
return {}
}
folderExecutionHostId = getExecutionHostIdForFolderWorkspace(s, scope.folderWorkspaceId)
previous = target.diffComments
const computed = mutate(previous ?? [])
if (computed === null) {
return {}
}
next = computed
return {
folderWorkspaces: s.folderWorkspaces.map((workspace) =>
workspace === target ? { ...workspace, diffComments: computed } : workspace
)
}
}
const repoList = s.worktreesByRepo[repoId]
if (!repoList) {
return {}
}
const target = repoList.find((w) => w.id === worktreeId)
if (!target) {
return {}
}
previous = target.diffComments
const computed = mutate(previous ?? [])
if (computed === null) {
return {}
}
next = computed
const nextList: Worktree[] = repoList.map((w) =>
w.id === worktreeId ? { ...w, diffComments: computed } : w
)
return { worktreesByRepo: { ...s.worktreesByRepo, [repoId]: nextList } }
})
if (next === null) {
return null
}
return { previous, next, folderExecutionHostId }
}
// Why: on IPC-write failure, roll back optimistic state so the renderer matches disk (identity-guarded below).
function rollback(
set: Parameters<StateCreator<AppState>>[0],
worktreeId: string,
previous: DiffComment[] | undefined,
expectedCurrent: DiffComment[],
folderExecutionHostId?: ReturnType<typeof getExecutionHostIdForFolderWorkspace>
): void {
const repoId = getRepoIdFromWorktreeId(worktreeId)
set((s) => {
const scope = parseWorkspaceKey(worktreeId)
if (scope?.type === 'folder') {
const target = findFolderWorkspaceOwner(s, scope.folderWorkspaceId, folderExecutionHostId)
if (!target || target.diffComments !== expectedCurrent) {
return {}
}
return {
folderWorkspaces: s.folderWorkspaces.map((workspace) =>
workspace === target ? { ...workspace, diffComments: previous } : workspace
)
}
}
const repoList = s.worktreesByRepo[repoId]
if (!repoList) {
return {}
}
const target = repoList.find((w) => w.id === worktreeId)
// Why: worktree gone since the mutation; bail before remapping so we don't allocate a new array identity and fire spurious notifications.
if (!target) {
return {}
}
// Why: only roll back if no later mutation replaced the array, else our stale `previous` would erase newer state.
if (target.diffComments !== expectedCurrent) {
return {}
}
const nextList: Worktree[] = repoList.map((w) =>
w.id === worktreeId ? { ...w, diffComments: previous } : w
)
return { worktreesByRepo: { ...s.worktreesByRepo, [repoId]: nextList } }
})
}
+21 -333
View File
@@ -1,20 +1,15 @@
/* eslint-disable max-lines -- Why: keeps note mutation, rollback, persistence ordering, and sent-state transitions under shared queue/rollback invariants. */
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { DiffComment } from '../../../../shared/diff-comment-types'
import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { findWorktreeById, getRepoIdFromWorktreeId } from './worktree-helpers'
import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client'
import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector'
import { findWorktreeById } from './worktree-helpers'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import {
findFolderWorkspaceOwner,
getExecutionHostIdForFolderWorkspace,
getRuntimeEnvironmentIdForFolderWorkspace
} from '@/lib/folder-workspace-runtime-owner'
import { findFolderWorkspaceOwner } from '@/lib/folder-workspace-runtime-owner'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import {
enqueueDiffCommentPersist,
mutateDiffComments,
normalizeDiffComment
} from './diff-comment-persistence'
export type DiffCommentsSlice = {
getDiffComments: (worktreeId: string | null | undefined) => DiffComment[]
@@ -43,41 +38,6 @@ function generateId(): string {
return createBrowserUuid()
}
function normalizeDiffComment(comment: DiffComment): DiffComment {
const rawSource = (comment as { source?: unknown }).source
const source = rawSource === 'markdown' || rawSource === 'diff' ? rawSource : undefined
const rawStartLine = (comment as { startLine?: unknown }).startLine
const startLine =
Number.isInteger(rawStartLine) &&
typeof rawStartLine === 'number' &&
rawStartLine >= 1 &&
rawStartLine <= comment.lineNumber
? rawStartLine
: undefined
const rawSelectedText = (comment as { selectedText?: unknown }).selectedText
const selectedText =
typeof rawSelectedText === 'string' && rawSelectedText.trim().length > 0
? rawSelectedText.trim()
: undefined
const rawSentAt = (comment as { sentAt?: unknown }).sentAt
const sentAt =
typeof rawSentAt === 'number' && Number.isFinite(rawSentAt) && rawSentAt > 0
? rawSentAt
: undefined
return {
...comment,
...(source !== undefined ? { source } : {}),
...(source === undefined ? { source: undefined } : {}),
...(selectedText !== undefined ? { selectedText } : {}),
...(selectedText === undefined ? { selectedText: undefined } : {}),
...(startLine !== undefined ? { startLine } : {}),
...(startLine === undefined ? { startLine: undefined } : {}),
...(sentAt !== undefined ? { sentAt } : {}),
...(sentAt === undefined ? { sentAt: undefined } : {})
}
}
function deliverySnapshotMatches(
comment: DiffComment,
snapshot: DiffCommentDeliverySnapshot
@@ -96,181 +56,6 @@ function deliverySnapshotMatches(
// Why: a frozen shared sentinel avoids selector re-renders and mutation.
const EMPTY_COMMENTS: readonly DiffComment[] = Object.freeze([])
async function persist(
state: AppState,
settings: AppState['settings'],
worktreeId: string,
diffComments: DiffComment[],
folderExecutionHostId?: ReturnType<typeof getExecutionHostIdForFolderWorkspace>
): Promise<void> {
const scope = parseWorkspaceKey(worktreeId)
if (scope?.type === 'folder') {
const executionHostId =
folderExecutionHostId ?? getExecutionHostIdForFolderWorkspace(state, scope.folderWorkspaceId)
const runtimeEnvironmentId = getRuntimeEnvironmentIdForFolderWorkspace(
state,
scope.folderWorkspaceId,
executionHostId
)
const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId: runtimeEnvironmentId })
const updated =
target.kind === 'local'
? await window.api.folderWorkspaces.update({
folderWorkspaceId: scope.folderWorkspaceId,
updates: { diffComments }
})
: (
await callRuntimeRpc<{ folderWorkspace: FolderWorkspace | null }>(
target,
'folderWorkspace.update',
{ folderWorkspaceId: scope.folderWorkspaceId, updates: { diffComments } },
{ timeoutMs: 15_000 }
)
).folderWorkspace
if (!updated?.diffComments) {
throw new Error('Failed to persist folder workspace review notes')
}
return
}
const target = getActiveRuntimeTarget(settings)
if (target.kind === 'local') {
await window.api.worktrees.updateMeta({
worktreeId,
updates: { diffComments }
})
return
}
await callRuntimeRpc(
target,
'worktree.set',
{ worktree: toRuntimeWorktreeSelector(worktreeId), diffComments },
{ timeoutMs: 15_000 }
)
}
function settingsForWorktreeOwner(state: AppState, worktreeId: string): AppState['settings'] {
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId)
return state.settings
? { ...state.settings, activeRuntimeEnvironmentId: runtimeEnvironmentId }
: ({ activeRuntimeEnvironmentId: runtimeEnvironmentId } as AppState['settings'])
}
// Why: IPC writes aren't ordered, so serialize per worktree to stop an older snapshot from overwriting a newer one on disk.
const persistQueueByWorktree = new Map<string, Promise<void>>()
// Why: rollback must converge to what actually reached disk; in a burst of failed writes each mutation's own predecessor is stale.
const lastPersistedByQueue = new Map<string, DiffComment[] | undefined>()
// Why: the floor is only valid across an unbroken mutation chain; this is how an out-of-band replacement mid-burst is detected.
const lastMutationNextByQueue = new Map<string, DiffComment[]>()
// Why: bumped on every re-seed, so an in-flight write can tell whether the floor it captured is still the current one.
const floorSeedEpochByQueue = new Map<string, number>()
type CommentMutation = {
previous: DiffComment[] | undefined
next: DiffComment[]
folderExecutionHostId?: ReturnType<typeof getExecutionHostIdForFolderWorkspace>
}
// Why: one key for the queue and both bookkeeping maps, so they can never drift apart.
function persistQueueKey(
worktreeId: string,
folderExecutionHostId?: ReturnType<typeof getExecutionHostIdForFolderWorkspace>
): string {
return folderExecutionHostId ? `${folderExecutionHostId}\0${worktreeId}` : worktreeId
}
// Why: chain each write onto the prior promise so writes land in call order; both then handlers keep the chain alive past a failure.
// Why: queued work reads the latest list at dequeue time, and the returned promise settles for THIS write.
// Why: this promise rejects only after this write's rollback has been applied — callers must not roll back themselves.
function enqueuePersist(
set: Parameters<StateCreator<AppState, [], [], DiffCommentsSlice>>[0],
worktreeId: string,
get: () => AppState,
mutation: CommentMutation
): Promise<void> {
const folderExecutionHostId = mutation.folderExecutionHostId
const queueKey = persistQueueKey(worktreeId, folderExecutionHostId)
const prior = persistQueueByWorktree.get(queueKey) ?? Promise.resolve()
// Why: an idle queue means disk still holds the pre-mutation list, so that list is this burst's rollback floor.
// Why: a `previous` that isn't the last mutation's `next` means something outside the mutators replaced the list
// (hydration, folderWorkspaces refresh, remote push), so the old floor predates that state and must be re-seeded.
// Why: seed before the queue entry below, or `has` is always true and the floor is never seeded.
const chainBroken = lastMutationNextByQueue.get(queueKey) !== mutation.previous
if (!persistQueueByWorktree.has(queueKey) || chainBroken) {
lastPersistedByQueue.set(queueKey, mutation.previous)
floorSeedEpochByQueue.set(queueKey, (floorSeedEpochByQueue.get(queueKey) ?? 0) + 1)
}
lastMutationNextByQueue.set(queueKey, mutation.next)
const run = async (): Promise<void> => {
// Why: capture at dequeue time, alongside `stateList` — an epoch read at enqueue time would bar every write
// that straddles a chain break from recording a floor its coalesced payload already carries.
const seedEpoch = floorSeedEpochByQueue.get(queueKey)
// Why: the state-side array, not the normalized copy sent to disk — restoring the same instance keeps
// `getDiffComments` identity stable instead of churning selectors.
let stateList: DiffComment[] | undefined
try {
const scope = parseWorkspaceKey(worktreeId)
if (scope?.type === 'folder') {
const state = get()
const folderWorkspace = findFolderWorkspaceOwner(
state,
scope.folderWorkspaceId,
folderExecutionHostId
)
stateList = folderWorkspace?.diffComments
await persist(
state,
state.settings,
worktreeId,
(stateList ?? []).map(normalizeDiffComment),
folderExecutionHostId
)
} else {
const repoId = getRepoIdFromWorktreeId(worktreeId)
const target = get().worktreesByRepo[repoId]?.find((w) => w.id === worktreeId)
stateList = target?.diffComments
const state = get()
await persist(
state,
settingsForWorktreeOwner(state, worktreeId),
worktreeId,
(stateList ?? []).map(normalizeDiffComment)
)
}
} catch (err) {
// Why: converge to what actually landed; rollback's identity guard no-ops when a later mutation owns the array.
// Why: `has()`, not `get() ?? …` — an absent entry and a legitimately-`undefined` floor are different things.
const floor = lastPersistedByQueue.has(queueKey)
? lastPersistedByQueue.get(queueKey)
: mutation.previous
rollback(set, worktreeId, floor, mutation.next, folderExecutionHostId)
throw err
}
// Why: a chain break re-seeded the floor to an out-of-band replacement while this write was awaiting, so the
// list captured before the await predates it and must not be reinstated as the floor.
if (floorSeedEpochByQueue.get(queueKey) === seedEpoch) {
lastPersistedByQueue.set(queueKey, stateList)
}
}
const next = prior.then(run, run)
persistQueueByWorktree.set(queueKey, next)
// Why: clear the queue entry only if still the tail, so later enqueues chain onto the real in-flight promise.
// Why: then(cleanup, cleanup) not finally, so a rejection is consumed here rather than re-thrown as unhandledRejection.
const cleanup = (): void => {
if (persistQueueByWorktree.get(queueKey) === next) {
persistQueueByWorktree.delete(queueKey)
// Why: tail-guarded only; a mid-burst delete would strand the remaining writes of the burst without a floor.
lastPersistedByQueue.delete(queueKey)
lastMutationNextByQueue.delete(queueKey)
floorSeedEpochByQueue.delete(queueKey)
}
}
next.then(cleanup, cleanup)
return next
}
// Why: best-effort telemetry runs only after the note is on disk, so a throw here must not report a failed save.
function recordReviewNoteInteraction(get: () => AppState): void {
try {
@@ -280,103 +65,6 @@ function recordReviewNoteInteraction(get: () => AppState): void {
}
}
// Why: derive the next list inside the `set` updater so concurrent writes can't clobber each other via a stale closure.
function mutateComments(
set: Parameters<StateCreator<AppState, [], [], DiffCommentsSlice>>[0],
worktreeId: string,
mutate: (existing: DiffComment[]) => DiffComment[] | null
): CommentMutation | null {
const repoId = getRepoIdFromWorktreeId(worktreeId)
let previous: DiffComment[] | undefined
let next: DiffComment[] | null = null
let folderExecutionHostId: ReturnType<typeof getExecutionHostIdForFolderWorkspace> | undefined
set((s) => {
const scope = parseWorkspaceKey(worktreeId)
if (scope?.type === 'folder') {
const target = findFolderWorkspaceOwner(s, scope.folderWorkspaceId)
if (!target) {
return {}
}
folderExecutionHostId = getExecutionHostIdForFolderWorkspace(s, scope.folderWorkspaceId)
previous = target.diffComments
const computed = mutate(previous ?? [])
if (computed === null) {
return {}
}
next = computed
return {
folderWorkspaces: s.folderWorkspaces.map((workspace) =>
workspace === target ? { ...workspace, diffComments: computed } : workspace
)
}
}
const repoList = s.worktreesByRepo[repoId]
if (!repoList) {
return {}
}
const target = repoList.find((w) => w.id === worktreeId)
if (!target) {
return {}
}
previous = target.diffComments
const computed = mutate(previous ?? [])
if (computed === null) {
return {}
}
next = computed
const nextList: Worktree[] = repoList.map((w) =>
w.id === worktreeId ? { ...w, diffComments: computed } : w
)
return { worktreesByRepo: { ...s.worktreesByRepo, [repoId]: nextList } }
})
if (next === null) {
return null
}
return { previous, next, folderExecutionHostId }
}
// Why: on IPC-write failure, roll back optimistic state so the renderer matches disk (identity-guarded below).
function rollback(
set: Parameters<StateCreator<AppState, [], [], DiffCommentsSlice>>[0],
worktreeId: string,
previous: DiffComment[] | undefined,
expectedCurrent: DiffComment[],
folderExecutionHostId?: ReturnType<typeof getExecutionHostIdForFolderWorkspace>
): void {
const repoId = getRepoIdFromWorktreeId(worktreeId)
set((s) => {
const scope = parseWorkspaceKey(worktreeId)
if (scope?.type === 'folder') {
const target = findFolderWorkspaceOwner(s, scope.folderWorkspaceId, folderExecutionHostId)
if (!target || target.diffComments !== expectedCurrent) {
return {}
}
return {
folderWorkspaces: s.folderWorkspaces.map((workspace) =>
workspace === target ? { ...workspace, diffComments: previous } : workspace
)
}
}
const repoList = s.worktreesByRepo[repoId]
if (!repoList) {
return {}
}
const target = repoList.find((w) => w.id === worktreeId)
// Why: worktree gone since the mutation; bail before remapping so we don't allocate a new array identity and fire spurious notifications.
if (!target) {
return {}
}
// Why: only roll back if no later mutation replaced the array, else our stale `previous` would erase newer state.
if (target.diffComments !== expectedCurrent) {
return {}
}
const nextList: Worktree[] = repoList.map((w) =>
w.id === worktreeId ? { ...w, diffComments: previous } : w
)
return { worktreesByRepo: { ...s.worktreesByRepo, [repoId]: nextList } }
})
}
export const createDiffCommentsSlice: StateCreator<AppState, [], [], DiffCommentsSlice> = (
set,
get
@@ -404,13 +92,13 @@ export const createDiffCommentsSlice: StateCreator<AppState, [], [], DiffComment
id: generateId(),
createdAt: Date.now()
})
const result = mutateComments(set, input.worktreeId, (existing) => [...existing, comment])
const result = mutateDiffComments(set, input.worktreeId, (existing) => [...existing, comment])
if (!result) {
return null
}
try {
// Why: serialize through the per-worktree queue so concurrent writes can't land on disk out of call order.
await enqueuePersist(set, input.worktreeId, get, result)
await enqueueDiffCommentPersist(set, input.worktreeId, get, result)
} catch (err) {
console.error('Failed to persist diff comments:', err)
return null
@@ -436,7 +124,7 @@ export const createDiffCommentsSlice: StateCreator<AppState, [], [], DiffComment
return true
}
const result = mutateComments(set, worktreeId, (current) => {
const result = mutateDiffComments(set, worktreeId, (current) => {
const idx = current.findIndex((c) => c.id === commentId)
if (idx === -1) {
return null
@@ -454,7 +142,7 @@ export const createDiffCommentsSlice: StateCreator<AppState, [], [], DiffComment
return true
}
try {
await enqueuePersist(set, worktreeId, get, result)
await enqueueDiffCommentPersist(set, worktreeId, get, result)
return true
} catch (err) {
console.error('Failed to persist diff comments:', err)
@@ -467,7 +155,7 @@ export const createDiffCommentsSlice: StateCreator<AppState, [], [], DiffComment
return true
}
const snapshotsById = new Map(comments.map((comment) => [comment.id, comment]))
const result = mutateComments(set, worktreeId, (existing) => {
const result = mutateDiffComments(set, worktreeId, (existing) => {
const next = existing.filter((comment) => {
const snapshot = snapshotsById.get(comment.id)
// Why: delivery is async; a note edited after its snapshot was sent is a fresh pending note that must stay visible.
@@ -479,7 +167,7 @@ export const createDiffCommentsSlice: StateCreator<AppState, [], [], DiffComment
return true
}
try {
await enqueuePersist(set, worktreeId, get, result)
await enqueueDiffCommentPersist(set, worktreeId, get, result)
} catch (err) {
console.error('Failed to persist diff comments:', err)
return false
@@ -493,7 +181,7 @@ export const createDiffCommentsSlice: StateCreator<AppState, [], [], DiffComment
return true
}
const ids = new Set(commentIds)
const result = mutateComments(set, worktreeId, (existing) => {
const result = mutateDiffComments(set, worktreeId, (existing) => {
let changed = false
const next = existing.map((comment) => {
if (!ids.has(comment.id) || comment.sentAt === sentAt) {
@@ -508,7 +196,7 @@ export const createDiffCommentsSlice: StateCreator<AppState, [], [], DiffComment
return true
}
try {
await enqueuePersist(set, worktreeId, get, result)
await enqueueDiffCommentPersist(set, worktreeId, get, result)
} catch (err) {
console.error('Failed to persist diff comments:', err)
return false
@@ -518,7 +206,7 @@ export const createDiffCommentsSlice: StateCreator<AppState, [], [], DiffComment
},
deleteDiffComment: async (worktreeId, commentId) => {
const result = mutateComments(set, worktreeId, (existing) => {
const result = mutateDiffComments(set, worktreeId, (existing) => {
const next = existing.filter((c) => c.id !== commentId)
return next.length === existing.length ? null : next
})
@@ -527,21 +215,21 @@ export const createDiffCommentsSlice: StateCreator<AppState, [], [], DiffComment
}
try {
// Why: serialize through the per-worktree queue so concurrent writes can't land out of call order.
await enqueuePersist(set, worktreeId, get, result)
await enqueueDiffCommentPersist(set, worktreeId, get, result)
} catch (err) {
console.error('Failed to persist diff comments:', err)
}
},
clearDiffComments: async (worktreeId) => {
const result = mutateComments(set, worktreeId, (existing) =>
const result = mutateDiffComments(set, worktreeId, (existing) =>
existing.length === 0 ? null : []
)
if (!result) {
return true
}
try {
await enqueuePersist(set, worktreeId, get, result)
await enqueueDiffCommentPersist(set, worktreeId, get, result)
return true
} catch (err) {
console.error('Failed to persist diff comments:', err)
@@ -550,7 +238,7 @@ export const createDiffCommentsSlice: StateCreator<AppState, [], [], DiffComment
},
clearDiffCommentsForFile: async (worktreeId, filePath) => {
const result = mutateComments(set, worktreeId, (existing) => {
const result = mutateDiffComments(set, worktreeId, (existing) => {
const next = existing.filter((c) => c.filePath !== filePath)
return next.length === existing.length ? null : next
})
@@ -558,7 +246,7 @@ export const createDiffCommentsSlice: StateCreator<AppState, [], [], DiffComment
return true
}
try {
await enqueuePersist(set, worktreeId, get, result)
await enqueueDiffCommentPersist(set, worktreeId, get, result)
return true
} catch (err) {
console.error('Failed to persist diff comments:', err)
@@ -1,7 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { create } from 'zustand'
import type { AppState } from '../types'
import { createHostedReviewSlice, getHostedReviewCacheKey } from './hosted-review'
import { createHostedReviewSlice } from './hosted-review'
import { getHostedReviewCacheKey } from './hosted-review-cache-identity'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
const runtimeRpc = vi.hoisted(() => ({
@@ -0,0 +1,207 @@
import type {
CreateHostedReviewInput,
CreateStackedHostedReviewInput,
HostedReviewCreationEligibility,
HostedReviewInfo
} from '../../../../shared/hosted-review'
import type { Repo } from '../../../../shared/repo-types'
import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host'
import type { AppState } from '../types'
export type HostedReviewCacheEntry<T> = {
data: T | null
fetchedAt: number
linkedReviewHintKey?: string
branchLookupGitHubPRNumber?: number
}
export type HostedReviewCache = Record<string, HostedReviewCacheEntry<HostedReviewInfo>>
export type HostedReviewFetchOptions = {
force?: boolean
repoId?: string
staleWhileRevalidate?: boolean
currentHeadOid?: string | null
/**
* Pass from surfaces that only render the selected worktree. The host re-checks
* that branch per minute and paces the O(N) card list far slower (#11532).
*/
active?: boolean
}
export type CreateHostedReviewStoreInput = CreateHostedReviewInput & { repoId?: string | null }
export type CreateStackedHostedReviewStoreInput = CreateStackedHostedReviewInput & {
repoId?: string | null
}
const CACHE_TTL_MS = 60_000
const HOSTED_REVIEW_CACHE_MAX = 500
// Why: the runtime path is bounded by callRuntimeRpc's own timeout; the local
// Electron path had none, so a hung git/gh subprocess (e.g. a stalled Windows
// credential probe) could leave the Create PR header stuck in its "Checking…"
// loading state forever. Mirror the runtime bound so a never-settling probe
// rejects and the UI can fall back to an actionable/retryable state.
const CREATION_ELIGIBILITY_TIMEOUT_MS = 30_000
export class HostedReviewCreationEligibilityTimeoutError extends Error {
constructor(timeoutMs: number) {
super(`Timed out checking pull request creation eligibility after ${timeoutMs}ms`)
this.name = 'HostedReviewCreationEligibilityTimeoutError'
}
}
export function withCreationEligibilityTimeout(
promise: Promise<HostedReviewCreationEligibility>,
timeoutMs = CREATION_ELIGIBILITY_TIMEOUT_MS
): Promise<HostedReviewCreationEligibility> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new HostedReviewCreationEligibilityTimeoutError(timeoutMs))
}, timeoutMs)
promise.then(
(value) => {
clearTimeout(timer)
resolve(value)
},
(error) => {
clearTimeout(timer)
reject(error)
}
)
})
}
export function isFreshHostedReview<T>(
entry: HostedReviewCacheEntry<T> | undefined
): entry is HostedReviewCacheEntry<T> {
return entry !== undefined && Date.now() - entry.fetchedAt < CACHE_TTL_MS
}
export function findHostedReviewRepoByPath(
repos: readonly Repo[] | undefined,
repoPath: string,
repoId?: string | null
): Repo | undefined {
return repos?.find((candidate) =>
repoId ? candidate.id === repoId : candidate.path === repoPath
)
}
export function shouldRefetchForLinkedHint(
cached: HostedReviewCacheEntry<HostedReviewInfo> | undefined,
hintKey: string
): boolean {
return cached !== undefined && hintKey !== '' && (cached.linkedReviewHintKey ?? '') !== hintKey
}
function isGitHubLinkedReviewHintKey(hintKey: string | undefined): boolean {
return hintKey?.split('|').some((key) => key.startsWith('github:')) ?? false
}
export function shouldRefetchGitHubScopedResultForNoHint(
cached: HostedReviewCacheEntry<HostedReviewInfo> | undefined,
hintKey: string
): boolean {
// Why: a GitHub-scoped result does not prove the branch's publishing remote
// has no GitLab/other review for neutral lookup.
return (
cached !== undefined &&
hintKey === '' &&
isGitHubLinkedReviewHintKey(cached.linkedReviewHintKey)
)
}
export function canReuseInflightHint(inflightHintKey: string, nextHintKey: string): boolean {
return inflightHintKey === nextHintKey
}
export function isStaleMergedGitHubReviewForHead(
cached: HostedReviewCacheEntry<HostedReviewInfo> | undefined,
currentHeadOid: string | null | undefined
): boolean {
// Why: a merged GitHub PR is only shown when the worktree sits on its head
// or on a commit confirmed to be part of the PR. The cache key is
// branch-scoped, so a worktree that advanced off the merged line of work
// must not reuse (or, on failure, preserve) the now-stale merged review.
const head = typeof currentHeadOid === 'string' ? currentHeadOid.trim() : ''
if (head.length === 0) {
return false
}
const data = cached?.data
return (
data?.provider === 'github' &&
data.state === 'merged' &&
typeof data.headSha === 'string' &&
data.headSha.length > 0 &&
data.headSha !== head &&
data.confirmedContainedHeadOid !== head
)
}
export function hasNewerHostedReviewCacheEntry(
cache: HostedReviewCache,
cacheKey: string,
requestStartedAt: number,
requestStartedEntry: HostedReviewCacheEntry<HostedReviewInfo> | undefined
): boolean {
// Why: GitHub refresh events can update this shared cache while a branch
// lookup is in flight; older lookups must not resurrect stale results.
const entry = cache[cacheKey]
return (
entry !== undefined &&
(entry.fetchedAt > requestStartedAt ||
(entry.fetchedAt === requestStartedAt && entry !== requestStartedEntry))
)
}
export function withHostedReviewCacheEntry(
cache: HostedReviewCache,
cacheKey: string,
entry: HostedReviewCacheEntry<HostedReviewInfo>
): HostedReviewCache {
const next = { ...cache, [cacheKey]: entry }
const keys = Object.keys(next)
if (keys.length <= HOSTED_REVIEW_CACHE_MAX) {
return next
}
const keep = new Set(
keys
.map((key) => ({ key, fetchedAt: next[key].fetchedAt }))
.sort((a, b) => b.fetchedAt - a.fetchedAt)
.slice(0, HOSTED_REVIEW_CACHE_MAX)
.map((item) => item.key)
)
const pruned: HostedReviewCache = {}
for (const key of keep) {
pruned[key] = next[key]
}
return pruned
}
export function settingsForHostedReviewRepoOwner(
settings: AppState['settings'],
repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined
): AppState['settings'] {
if (!repo) {
return settings
}
const parsed = parseExecutionHostId(getRepoExecutionHostId(repo))
if (parsed?.kind === 'runtime') {
return settings
? { ...settings, activeRuntimeEnvironmentId: parsed.environmentId }
: ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings'])
}
// Why: local and SSH-owned reviews are served by the desktop client's local
// IPC path, even when the sidebar is focused on a runtime host.
return settings
? { ...settings, activeRuntimeEnvironmentId: null }
: ({ activeRuntimeEnvironmentId: null } as AppState['settings'])
}
export function settingsForHostedReviewActionOwner(
settings: AppState['settings'],
repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined
): AppState['settings'] {
if (!repo?.executionHostId && !repo?.connectionId) {
return settings
}
return settingsForHostedReviewRepoOwner(settings, repo)
}
@@ -1,12 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { create } from 'zustand'
import type { AppState } from '../types'
import { createHostedReviewSlice } from './hosted-review'
import {
_clearHostedReviewRequestGenerationsForTest,
_getHostedReviewRequestGenerationCountForTest,
createHostedReviewSlice,
getHostedReviewCacheKey
} from './hosted-review'
_getHostedReviewRequestGenerationCountForTest
} from './hosted-review-request-state'
import { getHostedReviewCacheKey } from './hosted-review-cache-identity'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
const runtimeRpc = vi.hoisted(() => ({
@@ -0,0 +1,38 @@
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type { LinkedReviewHints } from './hosted-review-cache-identity'
import type { HostedReviewFetchOptions } from './hosted-review-cache-state'
type FetchHostedReviewForBranch = (
repoPath: string,
branch: string,
options?: HostedReviewFetchOptions & LinkedReviewHints
) => Promise<HostedReviewInfo | null>
type RefreshHostedReviewCardArgs = {
repoPath: string
repoId: string
branch: string
linkedGitHubPR?: number | null
fallbackGitHubPR?: number | null
linkedGitLabMR?: number | null
linkedBitbucketPR?: number | null
linkedAzureDevOpsPR?: number | null
linkedGiteaPR?: number | null
}
export function refreshHostedReviewCard(
fetchHostedReviewForBranch: FetchHostedReviewForBranch,
args: RefreshHostedReviewCardArgs
): Promise<HostedReviewInfo | null> {
const fallbackGitHubPR = args.linkedGitHubPR == null ? (args.fallbackGitHubPR ?? null) : null
return fetchHostedReviewForBranch(args.repoPath, args.branch, {
force: true,
repoId: args.repoId,
linkedGitHubPR: args.linkedGitHubPR ?? null,
...(fallbackGitHubPR !== null ? { fallbackGitHubPR } : {}),
linkedGitLabMR: args.linkedGitLabMR ?? null,
linkedBitbucketPR: args.linkedBitbucketPR ?? null,
linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null,
linkedGiteaPR: args.linkedGiteaPR ?? null
})
}
@@ -0,0 +1,39 @@
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type { Repo } from '../../../../shared/repo-types'
import type { AppState } from '../types'
import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from './github-cache-key'
export function clearHostedReviewConflictingPrCache(args: {
cache: NonNullable<AppState['prCache']>
review: HostedReviewInfo | null
repoPath: string
repoId: string | undefined
branch: string
settings: AppState['settings']
repo: Repo | undefined
}): NonNullable<AppState['prCache']> {
if (!args.review || args.review.provider === 'github') {
return args.cache
}
const keys = [
getGitHubPRCacheKey(
args.repoPath,
args.repoId,
args.branch,
args.settings,
args.repo?.connectionId,
args.repo?.executionHostId,
args.repo !== undefined
),
getLegacyGitHubPRCacheKey(args.repoPath, args.repoId, args.branch),
getLegacyGitHubPRCacheKey(args.repoPath, undefined, args.branch)
]
if (!keys.some((key) => args.cache[key])) {
return args.cache
}
const next = { ...args.cache }
for (const key of keys) {
delete next[key]
}
return next
}
@@ -0,0 +1,23 @@
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
export const inflightHostedReviewRequests = new Map<
string,
{
promise: Promise<HostedReviewInfo | null>
force: boolean
generation: number
linkedReviewHintKey: string
}
>()
export const hostedReviewRequestGenerations = new Map<string, number>()
/** @internal - exposed for leak-regression tests only */
export function _getHostedReviewRequestGenerationCountForTest(): number {
return hostedReviewRequestGenerations.size
}
/** @internal - exposed for leak-regression tests only */
export function _clearHostedReviewRequestGenerationsForTest(): void {
hostedReviewRequestGenerations.clear()
}
@@ -1,12 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { create } from 'zustand'
import type { AppState } from '../types'
import {
createHostedReviewSlice,
getHostedReviewCacheKey,
HostedReviewCreationEligibilityTimeoutError,
refreshHostedReviewCard
} from './hosted-review'
import { createHostedReviewSlice } from './hosted-review'
import { refreshHostedReviewCard } from './hosted-review-card-refresh'
import { getHostedReviewCacheKey } from './hosted-review-cache-identity'
import { HostedReviewCreationEligibilityTimeoutError } from './hosted-review-cache-state'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
const runtimeRpc = vi.hoisted(() => ({
+34 -281
View File
@@ -1,16 +1,11 @@
/* eslint-disable max-lines -- Why: hosted-review cache identity, runtime dispatch,
and race protection are kept together so branch review lookup invariants stay testable. */
import type { StateCreator } from 'zustand'
import type {
CreateHostedReviewInput,
CreateHostedReviewResult,
CreateStackedHostedReviewInput,
CreateStackedHostedReviewResult,
HostedReviewCreationEligibility,
HostedReviewCreationEligibilityArgs,
HostedReviewInfo
} from '../../../../shared/hosted-review'
import type { Repo } from '../../../../shared/repo-types'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import type { AppState } from '../types'
import {
@@ -18,228 +13,31 @@ import {
linkedReviewHintKey,
type LinkedReviewHints
} from './hosted-review-cache-identity'
import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from './github-cache-key'
import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host'
export { getHostedReviewCacheKey, linkedReviewHintKey } from './hosted-review-cache-identity'
type CacheEntry<T> = {
data: T | null
fetchedAt: number
linkedReviewHintKey?: string
branchLookupGitHubPRNumber?: number
}
type FetchOptions = {
force?: boolean
repoId?: string
staleWhileRevalidate?: boolean
currentHeadOid?: string | null
/**
* Pass from surfaces that only render the selected worktree. The host re-checks
* that branch per minute and paces the O(N) card list far slower (#11532).
*/
active?: boolean
}
type CreateHostedReviewStoreInput = CreateHostedReviewInput & { repoId?: string | null }
type CreateStackedHostedReviewStoreInput = CreateStackedHostedReviewInput & {
repoId?: string | null
}
const CACHE_TTL_MS = 60_000
const HOSTED_REVIEW_CACHE_MAX = 500
// Why: the runtime path is bounded by callRuntimeRpc's own timeout; the local
// Electron path had none, so a hung git/gh subprocess (e.g. a stalled Windows
// credential probe) could leave the Create PR header stuck in its "Checking…"
// loading state forever. Mirror the runtime bound so a never-settling probe
// rejects and the UI can fall back to an actionable/retryable state.
const CREATION_ELIGIBILITY_TIMEOUT_MS = 30_000
export class HostedReviewCreationEligibilityTimeoutError extends Error {
constructor(timeoutMs: number) {
super(`Timed out checking pull request creation eligibility after ${timeoutMs}ms`)
this.name = 'HostedReviewCreationEligibilityTimeoutError'
}
}
function withCreationEligibilityTimeout(
promise: Promise<HostedReviewCreationEligibility>,
timeoutMs = CREATION_ELIGIBILITY_TIMEOUT_MS
): Promise<HostedReviewCreationEligibility> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new HostedReviewCreationEligibilityTimeoutError(timeoutMs))
}, timeoutMs)
promise.then(
(value) => {
clearTimeout(timer)
resolve(value)
},
(error) => {
clearTimeout(timer)
reject(error)
}
)
})
}
const inflightHostedReviewRequests = new Map<
string,
{
promise: Promise<HostedReviewInfo | null>
force: boolean
generation: number
linkedReviewHintKey: string
}
>()
const requestGenerations = new Map<string, number>()
/** @internal - exposed for leak-regression tests only */
export function _getHostedReviewRequestGenerationCountForTest(): number {
return requestGenerations.size
}
/** @internal - exposed for leak-regression tests only */
export function _clearHostedReviewRequestGenerationsForTest(): void {
requestGenerations.clear()
}
function isFresh<T>(entry: CacheEntry<T> | undefined): entry is CacheEntry<T> {
return entry !== undefined && Date.now() - entry.fetchedAt < CACHE_TTL_MS
}
function findHostedReviewRepoByPath(
repos: readonly Repo[] | undefined,
repoPath: string,
repoId?: string | null
): Repo | undefined {
return repos?.find((candidate) =>
repoId ? candidate.id === repoId : candidate.path === repoPath
)
}
function shouldRefetchForLinkedHint(
cached: CacheEntry<HostedReviewInfo> | undefined,
hintKey: string
): boolean {
return cached !== undefined && hintKey !== '' && (cached.linkedReviewHintKey ?? '') !== hintKey
}
function isGitHubLinkedReviewHintKey(hintKey: string | undefined): boolean {
return hintKey?.split('|').some((key) => key.startsWith('github:')) ?? false
}
function shouldRefetchGitHubScopedResultForNoHint(
cached: CacheEntry<HostedReviewInfo> | undefined,
hintKey: string
): boolean {
// Why: a GitHub-scoped result does not prove the branch's publishing remote
// has no GitLab/other review for neutral lookup.
return (
cached !== undefined &&
hintKey === '' &&
isGitHubLinkedReviewHintKey(cached.linkedReviewHintKey)
)
}
function canReuseInflightHint(inflightHintKey: string, nextHintKey: string): boolean {
return inflightHintKey === nextHintKey
}
function isStaleMergedGitHubReviewForHead(
cached: CacheEntry<HostedReviewInfo> | undefined,
currentHeadOid: string | null | undefined
): boolean {
// Why: a merged GitHub PR is only shown when the worktree sits on its head
// or on a commit confirmed to be part of the PR. The cache key is
// branch-scoped, so a worktree that advanced off the merged line of work
// must not reuse (or, on failure, preserve) the now-stale merged review.
const head = typeof currentHeadOid === 'string' ? currentHeadOid.trim() : ''
if (head.length === 0) {
return false
}
const data = cached?.data
return (
data?.provider === 'github' &&
data.state === 'merged' &&
typeof data.headSha === 'string' &&
data.headSha.length > 0 &&
data.headSha !== head &&
data.confirmedContainedHeadOid !== head
)
}
function hasNewerHostedReviewCacheEntry(
cache: HostedReviewSlice['hostedReviewCache'],
cacheKey: string,
requestStartedAt: number,
requestStartedEntry: CacheEntry<HostedReviewInfo> | undefined
): boolean {
// Why: GitHub refresh events can update this shared cache while a branch
// lookup is in flight; older lookups must not resurrect stale results.
const entry = cache[cacheKey]
return (
entry !== undefined &&
(entry.fetchedAt > requestStartedAt ||
(entry.fetchedAt === requestStartedAt && entry !== requestStartedEntry))
)
}
function withHostedReviewCacheEntry(
cache: HostedReviewSlice['hostedReviewCache'],
cacheKey: string,
entry: CacheEntry<HostedReviewInfo>
): HostedReviewSlice['hostedReviewCache'] {
const next = { ...cache, [cacheKey]: entry }
const keys = Object.keys(next)
if (keys.length <= HOSTED_REVIEW_CACHE_MAX) {
return next
}
const keep = new Set(
keys
.map((key) => ({ key, fetchedAt: next[key].fetchedAt }))
.sort((a, b) => b.fetchedAt - a.fetchedAt)
.slice(0, HOSTED_REVIEW_CACHE_MAX)
.map((item) => item.key)
)
const pruned: HostedReviewSlice['hostedReviewCache'] = {}
for (const key of keep) {
pruned[key] = next[key]
}
return pruned
}
function settingsForHostedReviewRepoOwner(
settings: AppState['settings'],
repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined
): AppState['settings'] {
if (!repo) {
return settings
}
const parsed = parseExecutionHostId(getRepoExecutionHostId(repo))
if (parsed?.kind === 'runtime') {
return settings
? { ...settings, activeRuntimeEnvironmentId: parsed.environmentId }
: ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings'])
}
// Why: local and SSH-owned reviews are served by the desktop client's local
// IPC path, even when the sidebar is focused on a runtime host.
return settings
? { ...settings, activeRuntimeEnvironmentId: null }
: ({ activeRuntimeEnvironmentId: null } as AppState['settings'])
}
function settingsForHostedReviewActionOwner(
settings: AppState['settings'],
repo: Pick<Repo, 'connectionId' | 'executionHostId'> | undefined
): AppState['settings'] {
if (!repo?.executionHostId && !repo?.connectionId) {
return settings
}
return settingsForHostedReviewRepoOwner(settings, repo)
}
import {
canReuseInflightHint,
findHostedReviewRepoByPath,
hasNewerHostedReviewCacheEntry,
isFreshHostedReview,
isStaleMergedGitHubReviewForHead,
settingsForHostedReviewActionOwner,
settingsForHostedReviewRepoOwner,
shouldRefetchForLinkedHint,
shouldRefetchGitHubScopedResultForNoHint,
withCreationEligibilityTimeout,
withHostedReviewCacheEntry,
type CreateHostedReviewStoreInput,
type CreateStackedHostedReviewStoreInput,
type HostedReviewCacheEntry,
type HostedReviewFetchOptions
} from './hosted-review-cache-state'
import { clearHostedReviewConflictingPrCache } from './hosted-review-pr-cache'
import {
hostedReviewRequestGenerations as requestGenerations,
inflightHostedReviewRequests
} from './hosted-review-request-state'
export type HostedReviewSlice = {
hostedReviewCache: Record<string, CacheEntry<HostedReviewInfo>>
hostedReviewCache: Record<string, HostedReviewCacheEntry<HostedReviewInfo>>
getHostedReviewCreationEligibility: (
args: HostedReviewCreationEligibilityArgs
) => Promise<HostedReviewCreationEligibility>
@@ -254,39 +52,10 @@ export type HostedReviewSlice = {
fetchHostedReviewForBranch: (
repoPath: string,
branch: string,
options?: FetchOptions & LinkedReviewHints
options?: HostedReviewFetchOptions & LinkedReviewHints
) => Promise<HostedReviewInfo | null>
}
type RefreshHostedReviewCardArgs = {
repoPath: string
repoId: string
branch: string
linkedGitHubPR?: number | null
fallbackGitHubPR?: number | null
linkedGitLabMR?: number | null
linkedBitbucketPR?: number | null
linkedAzureDevOpsPR?: number | null
linkedGiteaPR?: number | null
}
export function refreshHostedReviewCard(
fetchHostedReviewForBranch: HostedReviewSlice['fetchHostedReviewForBranch'],
args: RefreshHostedReviewCardArgs
): Promise<HostedReviewInfo | null> {
const fallbackGitHubPR = args.linkedGitHubPR == null ? (args.fallbackGitHubPR ?? null) : null
return fetchHostedReviewForBranch(args.repoPath, args.branch, {
force: true,
repoId: args.repoId,
linkedGitHubPR: args.linkedGitHubPR ?? null,
...(fallbackGitHubPR !== null ? { fallbackGitHubPR } : {}),
linkedGitLabMR: args.linkedGitLabMR ?? null,
linkedBitbucketPR: args.linkedBitbucketPR ?? null,
linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null,
linkedGiteaPR: args.linkedGiteaPR ?? null
})
}
export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedReviewSlice> = (
set,
get
@@ -406,7 +175,7 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie
!linkedRefetch &&
!scopedResultRefetch &&
!staleMergedHeadRefetch &&
isFresh(cached)
isFreshHostedReview(cached)
) {
return cached.data
}
@@ -464,32 +233,16 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie
) {
return {}
}
const prCacheKeys = [
getGitHubPRCacheKey(
repoPath,
repoId,
branch,
ownerSettings,
repo?.connectionId,
repo?.executionHostId,
repo !== undefined
),
getLegacyGitHubPRCacheKey(repoPath, repoId, branch),
getLegacyGitHubPRCacheKey(repoPath, undefined, branch)
]
const currentPRCache = state.prCache ?? {}
const prCache =
review &&
review.provider !== 'github' &&
prCacheKeys.some((key) => currentPRCache[key])
? (() => {
const next = { ...currentPRCache }
for (const key of prCacheKeys) {
delete next[key]
}
return next
})()
: currentPRCache
const prCache = clearHostedReviewConflictingPrCache({
cache: currentPRCache,
review,
repoPath,
repoId,
branch,
settings: ownerSettings,
repo
})
return {
...(prCache === currentPRCache ? {} : { prCache }),
hostedReviewCache: withHostedReviewCacheEntry(state.hostedReviewCache, cacheKey, {

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