mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
Refactor lower max-lines modules (#16760)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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 ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' })[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 ' || '') + ')';",
|
||||
" 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 ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 ' || '') + ')';
|
||||
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}`
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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: []
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -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
-1
@@ -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'
|
||||
|
||||
+3
-3
@@ -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
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -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,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 } }
|
||||
})
|
||||
}
|
||||
@@ -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(() => ({
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user