Merge remote-tracking branch 'origin/main' into remove-host-on-mobile-freeze-the-app

This commit is contained in:
Jinjing
2026-07-11 16:43:46 -07:00
200 changed files with 10051 additions and 1573 deletions
+165
View File
@@ -2114,6 +2114,171 @@
],
"demotionRule": "Keep non-blocking or demote to protection none if the Windows byte test silently skips, flakes without an actionable artifact, routes bytes using tab/title/terminal-output heuristics, or adds any ordinary-key lookup, IPC, polling, scan, or lifecycle leak."
},
{
"id": "terminal-input.guarded-agent-send-authorization",
"title": "Guarded agent sends authorize only the confirmed exact PTY",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-input",
"layer": "runtime-provider-contract",
"surfaces": [
"terminal input",
"guarded note sends",
"agent status",
"PTY identity",
"foreground process confirmation"
],
"platforms": [
"macos",
"linux",
"windows"
],
"providers": [
"local",
"daemon",
"ssh",
"wsl",
"remote-runtime"
],
"coveredPlatforms": [
"macos"
],
"coveredProviders": [
"local",
"daemon"
],
"coverageNotes": "Runtime and provider-contract tests on macOS cover exact-PTY authorization, local/daemon fresh confirmation, exact ConPTY membership, and unsupported-provider fail-closed behavior. Physical Windows, live Linux, SSH, WSL, and remote-runtime validation remain explicit gaps; providers without confirmation preserve conservative refusal on a shell conflict.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/8303"
],
"invariant": "A guarded note send writes only to the exact PTY binding checked by the guard and only while permission/wait evidence allows input; fresh hook state conflicting with an ordinary shell foreground requires fresh provider confirmation of a recognized agent in that PTY.",
"oracle": "Fresh explicit state plus ordinary PowerShell plus confirmed recognized agent is sendable on the same PTY. Confirmed shell/non-agent, unavailable confirmation, PTY exit, handle rebind, or a callback PTY mismatch returns a refusal or not-writable result and writes zero bytes.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send.test.ts src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx"
],
"testFiles": [
"src/main/runtime/orca-runtime.test.ts",
"src/main/runtime/rpc/terminal-send.test.ts",
"src/main/ipc/pty.test.ts",
"src/main/providers/agent-foreground-process.test.ts",
"src/main/providers/local-pty-provider.test.ts",
"src/main/providers/windows-conpty-process-membership.test.ts",
"src/main/daemon/daemon-foreground-confirmation-protocol.test.ts",
"src/main/daemon/pty-subprocess.test.ts",
"src/renderer/src/lib/active-agent-note-send.test.ts",
"src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx"
],
"assertionRefs": [
{
"file": "src/main/runtime/orca-runtime.test.ts",
"assertions": [
"a recognized fresh provider confirmation authorizes explicit hook state over an ordinary shell result",
"shell, non-agent, null, thrown, and missing confirmation remain fail-closed",
"title blockers and ordinary recognized agent evidence add no confirmation call",
"PTY exit or handle rebind during either foreground read rejects evidence bound to the original PTY",
"controller-less yields preserve stale-handle errors and confirmation methods retain their controller receiver"
]
},
{
"file": "src/main/runtime/rpc/terminal-send.test.ts",
"assertions": [
"a handle rebind during guarded status evaluation returns not writable and writes zero bytes",
"the callback's actual PTY must equal the handle binding before status can authorize a write"
]
},
{
"file": "src/main/ipc/pty.test.ts",
"assertions": [
"runtime confirmation reaches the provider owning the captured PTY",
"unsupported or missing providers return unavailable confirmation"
]
},
{
"file": "src/main/providers/agent-foreground-process.test.ts",
"assertions": [
"recognized Windows candidates require exact ConPTY membership and detached descendants are rejected",
"unavailable process inspection is distinct from authoritative shell evidence"
]
},
{
"file": "src/main/providers/local-pty-provider.test.ts",
"assertions": [
"fresh confirmation is discarded when its owning local PTY exits"
]
},
{
"file": "src/main/providers/windows-conpty-process-membership.test.ts",
"assertions": [
"exact ConPTY console membership comes from the fixed node-pty helper",
"malformed, incomplete, timed-out, and spawn-error membership reads fail closed"
]
},
{
"file": "src/main/daemon/daemon-foreground-confirmation-protocol.test.ts",
"assertions": [
"daemons from before the fresh-confirmation RPC are rejected by protocol version"
]
},
{
"file": "src/main/daemon/pty-subprocess.test.ts",
"assertions": [
"fresh confirmation bypasses cached PowerShell and waits for a post-request process scan"
]
},
{
"file": "src/renderer/src/lib/active-agent-note-send.test.ts",
"assertions": [
"selected active-agent note sends retain guarded paste and submit routing"
]
},
{
"file": "src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx",
"assertions": [
"browser annotation send content routes through review-notes send so existing agent sessions remain selectable",
"both browser annotation send surfaces wire the shared menu content"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-11",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send.test.ts src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx",
"result": "passed",
"durationSeconds": 9.58,
"summary": "Ten focused test files passed (1183 tests), covering runtime confirmation and PTY revalidation, guarded RPC zero-write behavior, PTY controller routing, local/daemon fresh scans, exact ConPTY membership, and unchanged renderer note routing."
}
],
"runtimeBudget": {
"p95Seconds": 45,
"scope": "focused runtime, RPC, PTY provider, and renderer routing units"
},
"flakeHistory": {
"status": "unknown",
"evidence": "Deterministic units have local coverage only; promotion requires repeated CI and platform history."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "Removing strong confirmation fails the shell-conflict success oracle, while removing either exact-binding comparison fails zero-write rebind coverage; saved intentional-break and physical Windows evidence remain missing."
},
"performanceBudget": {
"required": true,
"evidence": "Confirmation is invoked at most once per status evaluation and only for fresh explicit hook state whose ordinary foreground result is a shell. Count tests prove permission/title blockers and ordinary recognized-agent evidence add zero confirmations; no polling, retry, session listing, provider fanout, or runtime-global cache is added, and existing provider snapshot dedup remains authoritative."
},
"promotionCriteria": [
"Run the browser annotation existing-agent and repeat-send path in Electron on Windows ConPTY without a recognition refusal.",
"Collect stable CI and flake history across local and daemon providers on Windows plus representative macOS/Linux coverage.",
"Attach saved intentional-break evidence for confirmation removal and exact-PTY revalidation removal."
],
"knownGaps": [
"Physical Windows validation is unavailable on the current macOS host; exact ConPTY membership is covered deterministically.",
"Electron annotation golden-path, repeat-send, adjacent-menu evidence, and screenshots are left to coordinator validation.",
"SSH, WSL, legacy daemon, and remote-runtime providers without confirmation remain intentionally fail-closed on an ordinary-shell conflict; no live artifacts cover those degraded paths.",
"No live Linux PTY, paired-web, mobile/relay, restore/replay, or multi-window artifact is attached; those surfaces receive no renderer, persistence, or protocol change."
],
"demotionRule": "Keep non-blocking or demote to protection none if provider confirmation becomes unconditional, exact-PTY mismatch can write bytes, unsupported providers fail open, or the focused gate flakes without an actionable product or harness defect."
},
{
"id": "terminal-input.agent-prompt-injection",
"title": "Orchestration agent prompts arrive as bracketed paste before submit",
+84
View File
@@ -0,0 +1,84 @@
# Claude Scoped OAuth Usage Limits
## Problem
Anthropic's current OAuth usage response reports Fable in `limits` as a model-scoped weekly limit instead of one of the legacy top-level Fable fields. Orca ignores `limits`, maps `fableWeekly` to `null`, and then depends on a hidden Claude `/usage` PTY read that is disabled on Windows and can fail silently elsewhere.
- `src/main/rate-limits/claude-fetcher.ts:300` models only top-level OAuth windows.
- `src/main/rate-limits/claude-fetcher.ts:393` maps only legacy Fable field names.
- `src/main/rate-limits/service.ts:1203` disables the PTY supplement on Windows.
- `src/renderer/src/components/status-bar/tooltip.tsx:172` renders Fable whenever `fableWeekly` is populated.
## Root Cause
The OAuth response contract evolved from dedicated model fields to generic entries shaped like `kind: "weekly_scoped"`, `percent`, `resets_at`, and `scope.model.display_name`. Orca's response type and mapper were not updated for that shape.
## Non-goals
- Do not change polling, credentials, token refresh, account switching, renderer layout, or usage percentage semantics.
- Do not remove the existing PTY supplement or legacy field compatibility.
- Do not generalize shared renderer state to arbitrary model windows in this targeted bug fix.
## Design
1. Extend the private OAuth response type with an optional `limits` array containing only the fields needed for safe parsing.
2. Select a Fable entry only when `kind` is `weekly_scoped`, the model display name is Fable (case-insensitive), and `percent` is finite.
3. Map the scoped entry to the existing seven-day `fableWeekly` window, including its reset timestamp.
4. Prefer the current scoped entry, then retain the three legacy top-level fields as fallbacks.
5. Keep malformed, unrelated, inactive, or absent entries non-fatal. Treat `is_active: false` as unavailable so stale promotional limits disappear; accept a missing activity flag for compatibility.
## Data Flow
- OAuth response
- `limits[].weekly_scoped` Fable -> `fableWeekly`
- otherwise legacy explicit Fable field -> `fableWeekly`
- otherwise existing optional PTY supplement
- Existing provider state -> existing status-bar and details rendering
## Edge Cases
- `limits` is missing, null, malformed, or contains null entries.
- A scoped entry names another model.
- Fable percent is missing, non-numeric, or non-finite.
- Fable is inactive and should not be rendered.
- `is_active` is omitted by an older server response but the remaining scoped entry is valid.
- Both current and legacy fields exist; the current scoped entry wins.
- Reset timestamps may be ISO strings, epoch seconds, epoch milliseconds, or absent.
- Windows, WSL, SSH, and remote runtimes use the same OAuth mapper and require no platform-specific execution.
## Test Plan
- Unit: reproduce a current real-response shape and assert Fable maps without a PTY attempt.
- Unit: assert scoped data wins over a legacy field.
- Unit: assert inactive, malformed, and unrelated scoped entries are ignored while legacy fallback remains available.
- Regression: retain existing legacy-field and bare-`fable` behavior tests.
- Verification: focused Claude fetcher tests, typecheck, lint, and max-lines ratchet.
- Electron: refresh Claude usage and confirm Session, Weekly, and Fable remain visible in the existing status-bar details surface.
## UI Quality Bar
No UI implementation changes. The existing Fable row must reappear with the same typography, spacing, progress bar, percentage semantics, and reset copy as adjacent Session and Weekly rows.
## Review Screenshots
1. Claude usage details showing Session, Weekly, and Fable from a live OAuth refresh.
2. Adjacent status-bar context showing the Claude provider remains visually unchanged outside the restored row.
## Rollout
1. Add the scoped OAuth response types and mapper.
2. Add focused current-schema and compatibility regression tests.
3. Run focused and repository checks.
4. Validate the restored row in Electron and capture review screenshots.
5. Commit, push, and open an unmerged PR.
## Lightweight Eng Review
- Scope: Kept to the private OAuth mapper and tests; no shared-state or renderer generalization is required to restore Fable.
- Architecture/data flow: OAuth remains authoritative, with structured scoped data preferred over legacy fields and PTY used only as the existing final supplement.
- Failure modes covered: malformed optional data, unrelated models, inactive limits, missing activity flags, duplicate old/new representations, missing reset metadata, and platform-neutral execution.
- Test coverage required: current-schema success without PTY, precedence, inactivity, malformed/unrelated entries, and legacy fallback.
- Performance/blast radius: One bounded linear scan of the small response `limits` array per existing OAuth refresh; no new requests, polling, subprocesses, IPC, storage, or renderer work.
- UI quality bar: Existing status-bar visuals must remain unchanged except for the restored Fable row.
- Required review screenshots: Live Claude details with all three rows; surrounding status-bar context.
- Residual risks: Anthropic may rename the scoped model display label; legacy and PTY fallbacks remain available.
+41 -3
View File
@@ -2528,6 +2528,7 @@ export default function SessionScreen() {
}
}, [])
const pendingForegroundRecoveryRef = useRef(false)
useEffect(() => {
let previousAppState: AppStateStatus | null = AppState.currentState
const sub = AppState.addEventListener('change', (nextAppState: AppStateStatus) => {
@@ -2546,7 +2547,7 @@ export default function SessionScreen() {
// Why: iOS can resume a live WKWebView with a blank xterm backing store
// without firing web-ready/reconnect; invalidate the native readiness
// latch before replay so init waits for the document's pong.
recoverActiveTerminalAfterForeground({
const outcome = recoverActiveTerminalAfterForeground({
activeHandleRef,
terminalRefs,
initializedHandlesRef,
@@ -2555,12 +2556,36 @@ export default function SessionScreen() {
subscribeToTerminal,
schedule: scheduleDelayedAction
})
pendingForegroundRecoveryRef.current = outcome === 'deferred'
})
return () => {
sub.remove()
}
}, [scheduleDelayedAction, subscribeToTerminal, unsubscribeTerminal])
// Why: resume usually lands mid-reconnect (the socket dies after ~60-80s of
// background), so the recovery above defers. Re-run it once the connection
// is back; otherwise a blanked WKWebView whose socket was merely probed (no
// stream replay) stays stale until a manual tab switch.
useEffect(() => {
if (connState !== 'connected' || !pendingForegroundRecoveryRef.current) {
return
}
pendingForegroundRecoveryRef.current = false
if (AppState.currentState !== 'active') {
return
}
recoverActiveTerminalAfterForeground({
activeHandleRef,
terminalRefs,
initializedHandlesRef,
connStateRef,
unsubscribeTerminal,
subscribeToTerminal,
schedule: scheduleDelayedAction
})
}, [connState, scheduleDelayedAction, subscribeToTerminal, unsubscribeTerminal])
// Why: viewport refits for layout changes outside the subscribe path
// (tab strip toggling, fold/unfold, rotation) live in a dedicated hook —
// see terminal-viewport-refit.ts for the full rationale.
@@ -3046,10 +3071,15 @@ export default function SessionScreen() {
// this, subscribe(viewport=null) lands on the server first and the
// post-scrollback measure path's resubscribe sees alreadyMeasured=true
// (because measureViewportOnce won the race) and silently skips.
if (handle === activeHandleRef.current && !terminalUnsubsRef.current.has(handle)) {
// Why: a just-created tab can briefly lose activeHandleRef to a lagging
// session-tab snapshot; honor the pending marker so its one web-ready
// subscribe still fires (see handleCreateTerminal).
const isIntendedActive = () =>
handle === activeHandleRef.current || handle === pendingActiveTerminalHandleRef.current
if (isIntendedActive() && !terminalUnsubsRef.current.has(handle)) {
void (async () => {
await measureViewportOnce(handle)
if (handle === activeHandleRef.current && !terminalUnsubsRef.current.has(handle)) {
if (isIntendedActive() && !terminalUnsubsRef.current.has(handle)) {
subscribeToTerminal(handle)
}
})()
@@ -3937,6 +3967,11 @@ export default function SessionScreen() {
if (typeof created.terminal === 'string') {
const createdHandle = created.terminal
defaultTerminalHandlesToLiveInput([createdHandle])
// Why: session-tab snapshots can lag the create RPC. Without the
// handle marker, applySessionTabs snaps activeHandleRef back to the
// previous terminal, and the new pane's web-ready subscribe (gated
// on the active handle) is skipped — blank tab until a manual switch.
pendingActiveTerminalHandleRef.current = createdHandle
activeHandleRef.current = createdHandle
setActiveHandle(createdHandle)
setTerminals((prev) => {
@@ -3991,6 +4026,9 @@ export default function SessionScreen() {
})
}
} else {
// Why: a prior pending handle must not outlive a create that returned
// no terminal; web-ready subscribe gates on this ref as active.
pendingActiveTerminalHandleRef.current = null
activeHandleRef.current = null
setActiveHandle(null)
}
@@ -69,7 +69,7 @@ describe('terminal foreground recovery', () => {
const recovered = recoverActiveTerminalAfterForeground(harness)
expect(recovered).toBe(true)
expect(recovered).toBe('recovered')
expect(harness.unsubscribeTerminal).toHaveBeenCalledWith('term-1')
expect(harness.initializedHandlesRef.current.has('term-1')).toBe(false)
expect(harness.schedule).toHaveBeenCalledWith(
@@ -90,7 +90,7 @@ describe('terminal foreground recovery', () => {
const recovered = recoverActiveTerminalAfterForeground(harness)
expect(recovered).toBe(true)
expect(recovered).toBe('recovered')
expect(harness.initializedHandlesRef.current.has('term-1')).toBe(false)
expect(harness.initializedHandlesRef.current.has('term-2')).toBe(false)
expect(harness.unsubscribeTerminal).toHaveBeenCalledWith('term-1')
@@ -102,11 +102,37 @@ describe('terminal foreground recovery', () => {
const recovered = recoverActiveTerminalAfterForeground(harness)
expect(recovered).toBe(false)
expect(recovered).toBe('skipped')
expect(harness.unsubscribeTerminal).not.toHaveBeenCalled()
expect(harness.schedule).not.toHaveBeenCalled()
})
it('defers without touching state when the socket is not connected yet', () => {
const harness = createHarness()
harness.connStateRef.current = 'reconnecting'
const recovered = recoverActiveTerminalAfterForeground(harness)
expect(recovered).toBe('deferred')
// Why this matters: the deferred retry needs the initialized flags intact
// so the post-reconnect recovery can still force a scrollback replay.
expect(harness.initializedHandlesRef.current.has('term-1')).toBe(true)
expect(harness.unsubscribeTerminal).not.toHaveBeenCalled()
expect(harness.schedule).not.toHaveBeenCalled()
})
it('replays scrollback when re-run after the deferred reconnect completes', () => {
const harness = createHarness()
harness.connStateRef.current = 'reconnecting'
expect(recoverActiveTerminalAfterForeground(harness)).toBe('deferred')
harness.connStateRef.current = 'connected'
expect(recoverActiveTerminalAfterForeground(harness)).toBe('recovered')
harness.runScheduled()
expect(harness.subscribeToTerminal).toHaveBeenCalledWith('term-1')
})
it('does not replay a stale terminal if focus changes before the delayed subscribe', () => {
const harness = createHarness()
@@ -134,4 +160,17 @@ describe('terminal foreground recovery', () => {
expect(readinessInvalidation).toBeGreaterThanOrEqual(0)
expect(replay).toBeGreaterThan(readinessInvalidation)
})
it('re-runs a deferred recovery once the session screen reconnects', () => {
// Why: resume usually lands mid-reconnect; the session screen must retry
// recovery on the connState→connected transition or blanked panes stay
// stale until a manual tab switch.
expect(sessionSource).toContain("pendingForegroundRecoveryRef.current = outcome === 'deferred'")
const reconnectRetry = sliceSessionSource(
"if (connState !== 'connected' || !pendingForegroundRecoveryRef.current)",
'recoverActiveTerminalAfterForeground({'
)
expect(reconnectRetry).toContain('pendingForegroundRecoveryRef.current = false')
expect(reconnectRetry).toContain("AppState.currentState !== 'active'")
})
})
@@ -4,6 +4,11 @@ import type { TerminalWebViewHandle } from './TerminalWebView'
export const TERMINAL_FOREGROUND_RECOVERY_DELAY_MS = 120
// 'deferred' = the socket wasn't connected at the foreground edge (it usually
// dies after ~60-80s of background); the caller must re-run recovery once the
// connection is back or a blanked WKWebView stays stale until a tab switch.
export type TerminalForegroundRecoveryOutcome = 'recovered' | 'deferred' | 'skipped'
type TerminalForegroundRecoveryOptions = {
activeHandleRef: RefObject<string | null>
terminalRefs: RefObject<Map<string, TerminalWebViewHandle>>
@@ -36,15 +41,15 @@ export function recoverActiveTerminalAfterForeground({
subscribeToTerminal,
schedule,
delayMs = TERMINAL_FOREGROUND_RECOVERY_DELAY_MS
}: TerminalForegroundRecoveryOptions): boolean {
}: TerminalForegroundRecoveryOptions): TerminalForegroundRecoveryOutcome {
if (connStateRef.current !== 'connected') {
return false
return 'deferred'
}
const initializedMountedHandles = Array.from(initializedHandlesRef.current).filter((handle) =>
terminalRefs.current.has(handle)
)
if (initializedMountedHandles.length === 0) {
return false
return 'skipped'
}
const handle = activeHandleRef.current
const shouldRecoverActive =
@@ -57,7 +62,7 @@ export function recoverActiveTerminalAfterForeground({
}
if (!shouldRecoverActive || !handle) {
return true
return 'recovered'
}
unsubscribeTerminal(handle)
@@ -70,5 +75,5 @@ export function recoverActiveTerminalAfterForeground({
}
subscribeToTerminal(handle)
}, delayMs)
return true
return 'recovered'
}
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.136-rc.0",
"version": "1.4.136",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -177,6 +177,7 @@
"lint-staged": "^16.4.0",
"lowlight": "^3.3.0",
"lucide-react": "^0.577.0",
"marked": "^17.0.1",
"mermaid": "^11.15.0",
"monaco-editor": "^0.55.1",
"oxfmt": "^0.52.0",
+32 -163
View File
@@ -263,6 +263,9 @@ importers:
lucide-react:
specifier: ^0.577.0
version: 0.577.0(react@19.2.5)
marked:
specifier: ^17.0.1
version: 17.0.6
mermaid:
specifier: ^11.15.0
version: 11.15.0
@@ -399,10 +402,6 @@ packages:
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
'@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
@@ -415,10 +414,6 @@ packages:
resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==}
engines: {node: '>=6.9.0'}
'@babel/generator@7.29.1':
resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==}
engines: {node: '>=6.9.0'}
'@babel/generator@7.29.7':
resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==}
engines: {node: '>=6.9.0'}
@@ -437,10 +432,6 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0
'@babel/helper-globals@7.28.0':
resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
engines: {node: '>=6.9.0'}
'@babel/helper-globals@7.29.7':
resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
engines: {node: '>=6.9.0'}
@@ -449,20 +440,10 @@ packages:
resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==}
engines: {node: '>=6.9.0'}
'@babel/helper-module-imports@7.28.6':
resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==}
engines: {node: '>=6.9.0'}
'@babel/helper-module-imports@7.29.7':
resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
engines: {node: '>=6.9.0'}
'@babel/helper-module-transforms@7.28.6':
resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0
'@babel/helper-module-transforms@7.29.7':
resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
engines: {node: '>=6.9.0'}
@@ -487,26 +468,14 @@ packages:
resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==}
engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.28.5':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.29.7':
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-option@7.27.1':
resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-option@7.29.7':
resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
engines: {node: '>=6.9.0'}
@@ -515,11 +484,6 @@ packages:
resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.29.3':
resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/parser@7.29.7':
resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
engines: {node: '>=6.0.0'}
@@ -577,26 +541,14 @@ packages:
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
engines: {node: '>=6.9.0'}
'@babel/template@7.28.6':
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
engines: {node: '>=6.9.0'}
'@babel/template@7.29.7':
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
engines: {node: '>=6.9.0'}
'@babel/traverse@7.29.0':
resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==}
engines: {node: '>=6.9.0'}
'@babel/traverse@7.29.7':
resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==}
engines: {node: '>=6.9.0'}
'@babel/types@7.29.0':
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@babel/types@7.29.7':
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'}
@@ -3457,6 +3409,7 @@ packages:
boolean@3.2.0:
resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
brace-expansion@1.1.14:
resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==}
@@ -4354,14 +4307,6 @@ packages:
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
engines: {node: '>=12'}
fs-extra@11.3.4:
resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==}
engines: {node: '>=14.14'}
fs-extra@11.3.5:
resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==}
engines: {node: '>=14.14'}
fs-extra@11.3.6:
resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==}
engines: {node: '>=14.14'}
@@ -4446,6 +4391,7 @@ packages:
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
global-agent@3.0.0:
resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==}
@@ -4642,6 +4588,7 @@ packages:
inflight@1.0.6:
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
@@ -4967,6 +4914,7 @@ packages:
lodash.isequal@4.5.0:
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
@@ -5330,6 +5278,7 @@ packages:
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
deprecated: Use your platform's native DOMException instead
node-fetch@3.3.2:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
@@ -6740,12 +6689,6 @@ snapshots:
package-manager-detector: 1.6.0
tinyexec: 1.1.2
'@babel/code-frame@7.29.0':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@@ -6774,14 +6717,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@babel/generator@7.29.1':
dependencies:
'@babel/parser': 7.29.3
'@babel/types': 7.29.0
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
jsesc: 3.1.0
'@babel/generator@7.29.7':
dependencies:
'@babel/parser': 7.29.7
@@ -6792,7 +6727,7 @@ snapshots:
'@babel/helper-annotate-as-pure@7.27.3':
dependencies:
'@babel/types': 7.29.0
'@babel/types': 7.29.7
'@babel/helper-compilation-targets@7.29.7':
dependencies:
@@ -6810,26 +6745,17 @@ snapshots:
'@babel/helper-optimise-call-expression': 7.27.1
'@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7)
'@babel/helper-skip-transparent-expression-wrappers': 7.27.1
'@babel/traverse': 7.29.0
'@babel/traverse': 7.29.7
semver: 6.3.1
transitivePeerDependencies:
- supports-color
'@babel/helper-globals@7.28.0': {}
'@babel/helper-globals@7.29.7': {}
'@babel/helper-member-expression-to-functions@7.28.5':
dependencies:
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
transitivePeerDependencies:
- supports-color
'@babel/helper-module-imports@7.28.6':
dependencies:
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
'@babel/traverse': 7.29.7
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
@@ -6840,15 +6766,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@babel/helper-module-transforms@7.28.6(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-module-imports': 7.28.6
'@babel/helper-validator-identifier': 7.28.5
'@babel/traverse': 7.29.0
transitivePeerDependencies:
- supports-color
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
@@ -6860,7 +6777,7 @@ snapshots:
'@babel/helper-optimise-call-expression@7.27.1':
dependencies:
'@babel/types': 7.29.0
'@babel/types': 7.29.7
'@babel/helper-plugin-utils@7.28.6': {}
@@ -6869,27 +6786,21 @@ snapshots:
'@babel/core': 7.29.7
'@babel/helper-member-expression-to-functions': 7.28.5
'@babel/helper-optimise-call-expression': 7.27.1
'@babel/traverse': 7.29.0
'@babel/traverse': 7.29.7
transitivePeerDependencies:
- supports-color
'@babel/helper-skip-transparent-expression-wrappers@7.27.1':
dependencies:
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
'@babel/traverse': 7.29.7
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-string-parser@7.29.7': {}
'@babel/helper-validator-identifier@7.28.5': {}
'@babel/helper-validator-identifier@7.29.7': {}
'@babel/helper-validator-option@7.27.1': {}
'@babel/helper-validator-option@7.29.7': {}
'@babel/helpers@7.29.7':
@@ -6897,10 +6808,6 @@ snapshots:
'@babel/template': 7.29.7
'@babel/types': 7.29.7
'@babel/parser@7.29.3':
dependencies:
'@babel/types': 7.29.0
'@babel/parser@7.29.7':
dependencies:
'@babel/types': 7.29.7
@@ -6923,7 +6830,7 @@ snapshots:
'@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7)
'@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
'@babel/helper-plugin-utils': 7.28.6
transitivePeerDependencies:
- supports-color
@@ -6953,7 +6860,7 @@ snapshots:
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-validator-option': 7.27.1
'@babel/helper-validator-option': 7.29.7
'@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7)
'@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7)
'@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7)
@@ -6962,30 +6869,12 @@ snapshots:
'@babel/runtime@7.29.7': {}
'@babel/template@7.28.6':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/parser': 7.29.3
'@babel/types': 7.29.0
'@babel/template@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
'@babel/traverse@7.29.0':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/generator': 7.29.1
'@babel/helper-globals': 7.28.0
'@babel/parser': 7.29.3
'@babel/template': 7.28.6
'@babel/types': 7.29.0
debug: 4.4.3
transitivePeerDependencies:
- supports-color
'@babel/traverse@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
@@ -6998,11 +6887,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@babel/types@7.29.0':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@babel/types@7.29.7':
dependencies:
'@babel/helper-string-parser': 7.29.7
@@ -7127,7 +7011,7 @@ snapshots:
fs-extra: 10.1.0
isbinaryfile: 4.0.10
minimist: 1.2.8
plist: 3.1.0
plist: 3.1.1
transitivePeerDependencies:
- supports-color
@@ -7148,9 +7032,9 @@ snapshots:
'@malept/cross-spawn-promise': 2.0.0
debug: 4.4.3
dir-compare: 4.2.0
fs-extra: 11.3.4
fs-extra: 11.3.6
minimatch: 9.0.9
plist: 3.1.0
plist: 3.1.1
transitivePeerDependencies:
- supports-color
@@ -8992,24 +8876,24 @@ snapshots:
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.29.3
'@babel/types': 7.29.0
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
'@types/babel__generator': 7.27.0
'@types/babel__template': 7.4.4
'@types/babel__traverse': 7.28.0
'@types/babel__generator@7.27.0':
dependencies:
'@babel/types': 7.29.0
'@babel/types': 7.29.7
'@types/babel__template@7.4.4':
dependencies:
'@babel/parser': 7.29.3
'@babel/types': 7.29.0
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
'@types/babel__traverse@7.28.0':
dependencies:
'@babel/types': 7.29.0
'@babel/types': 7.29.7
'@types/cacheable-request@6.0.3':
dependencies:
@@ -9369,8 +9253,7 @@ snapshots:
'@xmldom/xmldom@0.8.13': {}
'@xmldom/xmldom@0.9.10':
optional: true
'@xmldom/xmldom@0.9.10': {}
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287)':
dependencies:
@@ -10588,24 +10471,11 @@ snapshots:
jsonfile: 6.2.1
universalify: 2.0.1
fs-extra@11.3.4:
dependencies:
graceful-fs: 4.2.11
jsonfile: 6.2.1
universalify: 2.0.1
fs-extra@11.3.5:
dependencies:
graceful-fs: 4.2.11
jsonfile: 6.2.1
universalify: 2.0.1
fs-extra@11.3.6:
dependencies:
graceful-fs: 4.2.11
jsonfile: 6.2.1
universalify: 2.0.1
optional: true
fs-extra@7.0.1:
dependencies:
@@ -12019,7 +11889,7 @@ snapshots:
parse-json@5.2.0:
dependencies:
'@babel/code-frame': 7.29.0
'@babel/code-frame': 7.29.7
error-ex: 1.3.4
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
@@ -12089,7 +11959,6 @@ snapshots:
'@xmldom/xmldom': 0.9.10
base64-js: 1.5.1
xmlbuilder: 15.1.1
optional: true
pngjs@5.0.0: {}
@@ -12689,7 +12558,7 @@ snapshots:
shadcn@4.7.0(@types/node@25.6.0)(typescript@7.0.2):
dependencies:
'@babel/core': 7.29.7
'@babel/parser': 7.29.3
'@babel/parser': 7.29.7
'@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7)
'@babel/preset-typescript': 7.28.5(@babel/core@7.29.7)
'@dotenvx/dotenvx': 1.66.0
@@ -12703,7 +12572,7 @@ snapshots:
diff: 8.0.4
execa: 9.6.1
fast-glob: 3.3.3
fs-extra: 11.3.5
fs-extra: 11.3.6
fuzzysort: 3.1.0
https-proxy-agent: 7.0.6
kleur: 4.1.5
@@ -12788,7 +12657,7 @@ snapshots:
simple-update-notifier@2.0.0:
dependencies:
semver: 7.7.4
semver: 7.8.1
sisteransi@1.0.5: {}
@@ -8,10 +8,10 @@ import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../shared/worktree-
import { parseWorkspaceKey } from '../../shared/workspace-scope'
import { parsePaneKey } from '../../shared/stable-pane-id'
import {
humanizeBranchSlug,
isAutoGeneratedCreatureBranchName,
stripConfiguredBranchPrefix
} from '../../shared/branch-name-from-work'
import { deriveWorkspaceDisplayName } from '../../shared/display-name-from-work'
import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key'
import { computeBranchName, getConfiguredBranchPrefix } from '../ipc/worktree-logic'
import { gitExecFileAsync } from '../git/runner'
@@ -307,7 +307,7 @@ async function runAutoRename(
// Keep the sidebar name in sync with the branch — but only when it is still
// the auto-generated creature name, so a name the user typed is left alone.
const currentDisplayName = deps.getCurrentDisplayName(worktreeId)
const newDisplayName = humanizeBranchSlug(newBranchLeaf)
const newDisplayName = deriveWorkspaceDisplayName({ prompt, slug, resolvedLeaf: newBranchLeaf })
const updateDisplay = !currentDisplayName || isAutoGeneratedCreatureBranchName(currentDisplayName)
if (updateDisplay) {
deps.setDisplayName(worktreeId, newDisplayName)
@@ -390,7 +390,7 @@ async function runFolderWorkspaceTitleAutoRename(
return retry(`generation failed: ${generated.error}`)
}
const newDisplayName = humanizeBranchSlug(generated.slug)
const newDisplayName = deriveWorkspaceDisplayName({ prompt, slug: generated.slug })
deps.setDisplayName(worktreeId, newDisplayName)
deps.setRenameError(worktreeId, null)
deps.onRenamed(worktreeId)
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest'
import { makePaneKey } from '../../shared/stable-pane-id'
import { AgentHookServer } from './server'
const PANE_KEY = makePaneKey('tab-question', '11111111-1111-4111-8111-111111111111')
function ingestClaudeStatus(
server: AgentHookServer,
event: {
state: 'working' | 'waiting'
hookEventName: 'PermissionRequest' | 'PreToolUse'
toolName: string
toolUseId: string
}
): void {
server.ingestRemote(
{
paneKey: PANE_KEY,
tabId: 'tab-question',
worktreeId: 'worktree-question',
hookEventName: event.hookEventName,
toolUseId: event.toolUseId,
payload: {
state: event.state,
agentType: 'claude',
toolName: event.toolName
}
},
'connection-1'
)
}
describe('Claude interactive-question status transitions', () => {
it('clears an AskUserQuestion wait when later tool work starts', () => {
const server = new AgentHookServer()
ingestClaudeStatus(server, {
state: 'waiting',
hookEventName: 'PreToolUse',
toolName: 'AskUserQuestion',
toolUseId: 'tool-question'
})
ingestClaudeStatus(server, {
state: 'working',
hookEventName: 'PreToolUse',
toolName: 'Read',
toolUseId: 'tool-after-answer'
})
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE_KEY,
state: 'working',
agentType: 'claude',
toolName: 'Read'
})
])
})
it('keeps an actual permission request sticky during unrelated tool work', () => {
const server = new AgentHookServer()
ingestClaudeStatus(server, {
state: 'waiting',
hookEventName: 'PermissionRequest',
toolName: 'Bash',
toolUseId: 'tool-needs-permission'
})
ingestClaudeStatus(server, {
state: 'working',
hookEventName: 'PreToolUse',
toolName: 'Read',
toolUseId: 'tool-unrelated'
})
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE_KEY,
state: 'waiting',
agentType: 'claude',
toolName: 'Bash'
})
])
})
})
+3 -3
View File
@@ -314,6 +314,7 @@ function shouldKeepClaudePermissionVisible(
if (
previous?.payload.agentType !== 'claude' ||
previous.payload.state !== 'waiting' ||
previous.hookEventName !== 'PermissionRequest' ||
next.payload.agentType !== 'claude' ||
next.payload.state !== 'working'
) {
@@ -325,9 +326,8 @@ function shouldKeepClaudePermissionVisible(
if (isClaudePermissionResumingApprovedTool(previous, next)) {
return false
}
// Why: Claude can run subagents concurrently in one pane. Keep permission
// sticky unless the next hook has a source-level execution id that the
// PermissionRequest event itself does not expose.
// Why: only real permission requests stay sticky across concurrent subagent
// activity; interactive questions clear on the next working hook.
return true
}
@@ -0,0 +1,164 @@
import { mkdtemp, rm, stat, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
KimiSessionIndexCache,
KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS,
KIMI_WORK_DIR_CACHE_TTL_MS,
type KimiSessionIndexIdentity
} from './session-scanner-kimi-index-cache'
import {
clearKimiSessionIndexCache,
hasKimiSessionIndexCacheEntryForTests,
readKimiWorkDirBySessionId
} from './session-scanner-kimi-paths'
const IDENTITY: KimiSessionIndexIdentity = {
changeTimeMs: 1,
mtimeMs: 1,
sizeBytes: 1
}
let tempDirs: string[] = []
afterEach(async () => {
vi.useRealTimers()
clearKimiSessionIndexCache()
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
tempDirs = []
})
describe('KimiSessionIndexCache', () => {
it('bounds prolonged path churn and retains a reused index', async () => {
const cache = new KimiSessionIndexCache()
for (let index = 0; index < KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS; index += 1) {
const path = `home-${index}/session_index.jsonl`
await cache.get(path, IDENTITY, cache.beginRead(), async () => new Map([[path, path]]))
}
const reusedPath = 'home-0/session_index.jsonl'
await cache.get(reusedPath, IDENTITY, cache.beginRead(), async () => new Map())
const firstOverflowPath = `home-${KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS}/session_index.jsonl`
await cache.get(
firstOverflowPath,
IDENTITY,
cache.beginRead(),
async () => new Map([[firstOverflowPath, firstOverflowPath]])
)
expect(cache.has(reusedPath)).toBe(true)
expect(cache.has('home-1/session_index.jsonl')).toBe(false)
for (let index = KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS + 1; index < 640; index += 1) {
const path = `home-${index}/session_index.jsonl`
await cache.get(path, IDENTITY, cache.beginRead(), async () => new Map([[path, path]]))
}
expect(cache.has(reusedPath)).toBe(false)
expect(cache.size).toBe(KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS)
expect(cache.has('home-576/session_index.jsonl')).toBe(true)
expect(cache.has('home-575/session_index.jsonl')).toBe(false)
cache.clear()
})
it('refreshes active entries and expires them after an idle TTL', async () => {
vi.useFakeTimers()
const cache = new KimiSessionIndexCache()
const path = 'active/session_index.jsonl'
const value = new Map([['session', '/repo']])
await cache.get(path, IDENTITY, cache.beginRead(), async () => value)
await vi.advanceTimersByTimeAsync(KIMI_WORK_DIR_CACHE_TTL_MS - 1)
expect(await cache.get(path, IDENTITY, cache.beginRead(), async () => new Map())).toBe(value)
await vi.advanceTimersByTimeAsync(KIMI_WORK_DIR_CACHE_TTL_MS - 1)
expect(cache.has(path)).toBe(true)
await vi.advanceTimersByTimeAsync(1)
expect(cache.has(path)).toBe(false)
expect(vi.getTimerCount()).toBe(0)
})
it('deduplicates concurrent reads of the same file identity', async () => {
const cache = new KimiSessionIndexCache()
const load = vi.fn(async () => new Map([['session', '/repo']]))
const first = cache.get('index', IDENTITY, cache.beginRead(), load)
const second = cache.get('index', IDENTITY, cache.beginRead(), load)
expect(second).toBe(first)
await expect(second).resolves.toEqual(new Map([['session', '/repo']]))
expect(load).toHaveBeenCalledOnce()
cache.clear()
})
it('does not let an older mutation race replace a newer identity', async () => {
const cache = new KimiSessionIndexCache()
const oldGeneration = cache.beginRead()
const newGeneration = cache.beginRead()
const newerIdentity = { ...IDENTITY, changeTimeMs: 2, mtimeMs: 2, sizeBytes: 2 }
const newer = new Map([['session', '/new']])
await cache.get('index', newerIdentity, newGeneration, async () => newer)
await cache.get('index', IDENTITY, oldGeneration, async () => new Map([['session', '/old']]))
const reload = vi.fn(async () => new Map())
await expect(cache.get('index', newerIdentity, cache.beginRead(), reload)).resolves.toBe(newer)
expect(reload).not.toHaveBeenCalled()
cache.clear()
})
it('does not repopulate after an owner clears an in-flight read', async () => {
const cache = new KimiSessionIndexCache()
const staleGeneration = cache.beginRead()
cache.clear()
await cache.get('index', IDENTITY, staleGeneration, async () => new Map([['session', '/old']]))
expect(cache.has('index')).toBe(false)
const current = new Map([['session', '/current']])
await expect(
cache.get('index', IDENTITY, cache.beginRead(), async () => current)
).resolves.toBe(current)
expect(cache.has('index')).toBe(true)
cache.clear()
})
})
describe('Kimi session index reader cache', () => {
it('releases a retained map when its index file disappears', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-kimi-index-delete-'))
tempDirs.push(root)
const indexPath = join(root, 'session_index.jsonl')
await writeFile(indexPath, `${JSON.stringify({ sessionId: 'session', workDir: '/repo' })}\n`)
await readKimiWorkDirBySessionId(indexPath)
expect(hasKimiSessionIndexCacheEntryForTests(indexPath)).toBe(true)
await rm(indexPath)
await expect(readKimiWorkDirBySessionId(indexPath)).resolves.toEqual(new Map())
expect(hasKimiSessionIndexCacheEntryForTests(indexPath)).toBe(false)
})
it('invalidates when size changes even if the mtime is restored', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-kimi-index-mutation-'))
tempDirs.push(root)
const indexPath = join(root, 'session_index.jsonl')
await writeFile(indexPath, `${JSON.stringify({ sessionId: 'session-old', workDir: '/old' })}\n`)
const originalStat = await stat(indexPath)
await expect(readKimiWorkDirBySessionId(indexPath)).resolves.toEqual(
new Map([['session-old', '/old']])
)
await writeFile(
indexPath,
`${JSON.stringify({ sessionId: 'session-old', workDir: '/old' })}\n${JSON.stringify({ sessionId: 'session-new', workDir: '/new' })}\n`
)
await utimes(indexPath, originalStat.atime, originalStat.mtime)
await expect(readKimiWorkDirBySessionId(indexPath)).resolves.toEqual(
new Map([
['session-old', '/old'],
['session-new', '/new']
])
)
})
})
@@ -0,0 +1,132 @@
export type KimiSessionIndexIdentity = {
changeTimeMs: number
mtimeMs: number
sizeBytes: number
}
type KimiSessionIndexCacheEntry = {
expiresAt: number
generation: number
identity: KimiSessionIndexIdentity
timer: NodeJS.Timeout | null
value: Promise<Map<string, string>>
}
export const KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS = 64
// Active Vault scans refresh this window; closing the surface releases parsed
// index maps soon without making a live Kimi session reread on every scan.
export const KIMI_WORK_DIR_CACHE_TTL_MS = 5 * 60_000
export class KimiSessionIndexCache {
private readonly entries = new Map<string, KimiSessionIndexCacheEntry>()
private minimumCacheGeneration = 0
private nextGeneration = 0
beginRead(): number {
this.nextGeneration += 1
return this.nextGeneration
}
clear(): void {
for (const entry of this.entries.values()) {
if (entry.timer) {
clearTimeout(entry.timer)
}
}
this.entries.clear()
// Why: a read already awaiting stat/load when its owner clears the cache
// may finish later, but must not silently recreate the released entry.
this.minimumCacheGeneration = this.nextGeneration + 1
}
delete(indexPath: string, generation = Number.POSITIVE_INFINITY): void {
const entry = this.entries.get(indexPath)
if (entry && entry.generation <= generation) {
this.forget(indexPath, entry)
}
}
get(
indexPath: string,
identity: KimiSessionIndexIdentity,
generation: number,
load: () => Promise<Map<string, string>>
): Promise<Map<string, string>> {
if (generation < this.minimumCacheGeneration) {
return load()
}
const cached = this.entries.get(indexPath)
const now = Date.now()
if (cached && cached.expiresAt > now && identitiesMatch(cached.identity, identity)) {
this.remember(indexPath, cached, now)
return cached.value
}
if (cached && cached.generation > generation) {
// Why: a slower, older stat must not replace a newer file generation
// that another concurrent scan already cached for the same path.
return load()
}
const entry: KimiSessionIndexCacheEntry = {
expiresAt: now + KIMI_WORK_DIR_CACHE_TTL_MS,
generation,
identity,
timer: null,
value: load()
}
this.remember(indexPath, entry, now)
return entry.value
}
has(indexPath: string): boolean {
return this.entries.has(indexPath)
}
get size(): number {
return this.entries.size
}
private forget(indexPath: string, entry: KimiSessionIndexCacheEntry): void {
if (this.entries.get(indexPath) !== entry) {
return
}
if (entry.timer) {
clearTimeout(entry.timer)
}
this.entries.delete(indexPath)
}
private remember(indexPath: string, entry: KimiSessionIndexCacheEntry, now: number): void {
const replaced = this.entries.get(indexPath)
if (replaced?.timer && replaced !== entry) {
clearTimeout(replaced.timer)
}
if (entry.timer) {
clearTimeout(entry.timer)
}
entry.expiresAt = now + KIMI_WORK_DIR_CACHE_TTL_MS
entry.timer = setTimeout(() => this.forget(indexPath, entry), KIMI_WORK_DIR_CACHE_TTL_MS)
entry.timer.unref()
this.entries.delete(indexPath)
this.entries.set(indexPath, entry)
while (this.entries.size > KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS) {
const oldest = this.entries.entries().next().value
if (!oldest) {
return
}
this.forget(oldest[0], oldest[1])
}
}
}
function identitiesMatch(
left: KimiSessionIndexIdentity,
right: KimiSessionIndexIdentity
): boolean {
return (
left.changeTimeMs === right.changeTimeMs &&
left.mtimeMs === right.mtimeMs &&
left.sizeBytes === right.sizeBytes
)
}
@@ -3,7 +3,12 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { parseKimiSessionFile } from './session-scanner-kimi-parser'
import { clearKimiSessionIndexCache } from './session-scanner-kimi-paths'
import {
clearKimiSessionIndexCache,
hasKimiSessionIndexCacheEntryForTests,
KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS,
readKimiWorkDirBySessionId
} from './session-scanner-kimi-paths'
import type { FileWithMtime } from './session-scanner-types'
let tempDirs: string[] = []
@@ -218,4 +223,34 @@ describe('parseKimiSessionFile', () => {
const session = await parseKimiSessionFile(file, 'darwin')
expect(session?.title).toBe('do the thing')
})
it('caps cached session index maps by recent index path', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-kimi-cache-'))
tempDirs.push(root)
const indexPaths: string[] = []
for (let index = 0; index <= KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS; index += 1) {
const home = join(root, `home-${index}`)
await mkdir(home, { recursive: true })
const indexPath = join(home, 'session_index.jsonl')
await writeFile(
indexPath,
`${JSON.stringify({ sessionId: `session_${index}`, workDir: `/tmp/kimi-${index}` })}\n`
)
indexPaths.push(indexPath)
}
for (const indexPath of indexPaths.slice(0, KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS)) {
await readKimiWorkDirBySessionId(indexPath)
}
const refreshedFirst = await readKimiWorkDirBySessionId(indexPaths[0])
expect(refreshedFirst.get('session_0')).toBe('/tmp/kimi-0')
await readKimiWorkDirBySessionId(indexPaths[KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS])
expect(hasKimiSessionIndexCacheEntryForTests(indexPaths[0])).toBe(true)
expect(hasKimiSessionIndexCacheEntryForTests(indexPaths[1])).toBe(false)
expect(
hasKimiSessionIndexCacheEntryForTests(indexPaths[KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS])
).toBe(true)
})
})
+29 -19
View File
@@ -4,6 +4,13 @@ import { homedir } from 'node:os'
import { basename, dirname, join } from 'node:path'
import { createInterface } from 'node:readline'
import { asRecord, extractString } from './session-scanner-values'
import {
KimiSessionIndexCache,
KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS,
KIMI_WORK_DIR_CACHE_TTL_MS
} from './session-scanner-kimi-index-cache'
export { KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS, KIMI_WORK_DIR_CACHE_TTL_MS }
// Why: Kimi Code stores sessions under <KIMI_CODE_HOME>/sessions/, mirroring the
// CLI's own `KIMI_CODE_HOME ?? ~/.kimi-code` resolution (see kimi-fetcher.ts).
@@ -55,38 +62,41 @@ export function kimiPrimaryAgentWirePath(
return join(dirname(statePath), 'agents', primaryId, 'wire.jsonl')
}
type WorkDirCacheEntry = {
mtimeMs: number
map: Promise<Map<string, string>>
}
// Why: every session under one Kimi home shares a single session_index.jsonl.
// Re-reading it once per session would be O(n^2); memoize by path + mtime so a
// scan reads the index at most once and the cache self-invalidates when Kimi
// appends a new session (mtime bump).
const workDirCacheByIndexPath = new Map<string, WorkDirCacheEntry>()
// Re-reading it once per session would be O(n^2); memoize by path + file
// identity so a scan reads the index at most once. Bound and expire entries
// because host/WSL/runtime roots can change during one main-process lifetime.
const workDirCacheByIndexPath = new KimiSessionIndexCache()
export function clearKimiSessionIndexCache(): void {
workDirCacheByIndexPath.clear()
}
export function hasKimiSessionIndexCacheEntryForTests(indexPath: string): boolean {
return workDirCacheByIndexPath.has(indexPath)
}
export async function readKimiWorkDirBySessionId(indexPath: string): Promise<Map<string, string>> {
let mtimeMs: number
const generation = workDirCacheByIndexPath.beginRead()
let identity: Awaited<ReturnType<typeof stat>>
try {
mtimeMs = (await stat(indexPath)).mtimeMs
identity = await stat(indexPath)
} catch {
// Missing index (e.g. user deleted it): sessions still list, just without cwd.
workDirCacheByIndexPath.delete(indexPath, generation)
return new Map()
}
const cached = workDirCacheByIndexPath.get(indexPath)
if (cached && cached.mtimeMs === mtimeMs) {
return cached.map
}
const map = parseKimiSessionIndex(indexPath)
workDirCacheByIndexPath.set(indexPath, { mtimeMs, map })
return map
return workDirCacheByIndexPath.get(
indexPath,
{
changeTimeMs: identity.ctimeMs,
mtimeMs: identity.mtimeMs,
sizeBytes: identity.size
},
generation,
() => parseKimiSessionIndex(indexPath)
)
}
async function parseKimiSessionIndex(indexPath: string): Promise<Map<string, string>> {
+82
View File
@@ -346,6 +346,88 @@ describe('CliInstaller', () => {
expect(userPath).not.toContain(join(fixture.root, 'Programs', 'Orca', 'bin'))
})
it.each(['UnauthorizedAccessException', 'SecurityException'])(
'rejects with a friendly message for Windows PATH denial: %s',
async (permissionMarker) => {
const fixture = await makeFixture()
const installPath = join(fixture.root, 'Programs', 'Orca', 'bin', 'orca.cmd')
const installer = new CliInstaller({
platform: 'win32',
isPackaged: false,
userDataPath: fixture.userDataPath,
execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe',
appPath: fixture.appPath,
commandPathOverride: installPath,
userPathReader: async () => 'C:\\Windows\\System32',
userPathWriter: async () => {
// The .NET error id survives localized or mojibake PowerShell output.
const error = new Error(
`Command failed: powershell -NoProfile -Command [Environment]::SetEnvironmentVariable('Path', '...', 'User')\nFullyQualifiedErrorId : ${permissionMarker},Microsoft.PowerShell.Commands`
)
Object.assign(error, { code: 1 })
throw error
}
})
const result = installer.install()
await expect(result).rejects.toThrow(/access denied|Group Policy|manually/i)
await expect(result).rejects.not.toThrow(/Command failed: powershell/)
await expect(result).rejects.toMatchObject({
cause: expect.objectContaining({
message: expect.stringContaining(permissionMarker)
})
})
}
)
it('skips the Windows PATH write when removing an absent entry', async () => {
const fixture = await makeFixture()
const userPathWriter = vi.fn()
const installer = new CliInstaller({
platform: 'win32',
isPackaged: false,
userDataPath: fixture.userDataPath,
execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe',
appPath: fixture.appPath,
commandPathOverride: join(fixture.root, 'Programs', 'Orca', 'bin', 'orca.cmd'),
userPathReader: async () => 'C:\\Windows\\System32',
userPathWriter
})
await expect(installer.remove()).resolves.toMatchObject({ state: 'not_installed' })
expect(userPathWriter).not.toHaveBeenCalled()
})
it.each([
['PowerShell timeout', 'Windows PATH command timed out after 5000ms.'],
[
'generic PowerShell method failure',
"Command failed: powershell -NoProfile -Command [Environment]::SetEnvironmentVariable('Path', '...', 'User')\nCategoryInfo : NotSpecified: (:) [], MethodInvocationException\nFullyQualifiedErrorId : MethodInvocationException"
]
])(
'propagates a non-permission Windows PATH write error unchanged: %s',
async (_name, message) => {
const fixture = await makeFixture()
const installPath = join(fixture.root, 'Programs', 'Orca', 'bin', 'orca.cmd')
const installer = new CliInstaller({
platform: 'win32',
isPackaged: false,
userDataPath: fixture.userDataPath,
execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe',
appPath: fixture.appPath,
commandPathOverride: installPath,
userPathReader: async () => 'C:\\Windows\\System32',
userPathWriter: async () => {
throw new Error(message)
}
})
const result = installer.install()
await expect(result).rejects.toThrow(message)
await expect(result).rejects.not.toThrow(/Windows blocked updating your user PATH/)
}
)
it('settles when the Windows PATH query hangs', async () => {
vi.useFakeTimers()
const fixture = await makeFixture()
+51 -5
View File
@@ -823,7 +823,7 @@ export class CliInstaller {
return
}
entries.push(pathDirectory)
await this.userPathWriter(entries.join(';'))
await this.writeWindowsUserPathEntry(entries.join(';'), pathDirectory, 'add')
}
private async removeWindowsPathEntry(pathDirectory: string): Promise<void> {
@@ -831,10 +831,36 @@ export class CliInstaller {
return
}
const current = await this.userPathReader()
const nextEntries = splitPathEntries('win32', current).filter(
(entry) => !samePathEntry('win32', entry, pathDirectory)
)
await this.userPathWriter(nextEntries.join(';'))
const entries = splitPathEntries('win32', current)
const nextEntries = entries.filter((entry) => !samePathEntry('win32', entry, pathDirectory))
if (nextEntries.length === entries.length) {
return
}
await this.writeWindowsUserPathEntry(nextEntries.join(';'), pathDirectory, 'remove')
}
// Why: raw PowerShell errors reach the UI, so translate denied PATH writes
// while preserving the original diagnostic as the error cause.
private async writeWindowsUserPathEntry(
value: string,
pathDirectory: string,
action: 'add' | 'remove'
): Promise<void> {
try {
await this.userPathWriter(value)
} catch (error) {
if (!isWindowsUserPathPermissionError(error)) {
throw error
}
const guidance =
action === 'add'
? `Add this folder to your PATH manually: ${pathDirectory}. Or run Orca as an administrator and try again.`
: `Remove this folder from your PATH manually: ${pathDirectory}. Or run Orca as an administrator and try again.`
throw new Error(
`Windows blocked updating your user PATH (access denied). This usually means your PATH environment variable is managed by Group Policy or your organization's device management. ${guidance}`,
{ cause: error }
)
}
}
}
@@ -1038,6 +1064,26 @@ function isMissingError(error: unknown): boolean {
)
}
// Why: localized permission errors retain these .NET/ACL markers even when
// their human-readable PowerShell text is mojibake.
function isWindowsUserPathPermissionError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false
}
const stderr =
'stderr' in error && typeof (error as { stderr?: unknown }).stderr === 'string'
? (error as { stderr: string }).stderr
: ''
const haystack = `${error.message}\n${stderr}`
return (
haystack.includes('UnauthorizedAccessException') ||
haystack.includes('SecurityException') ||
haystack.includes('Requested registry access is not allowed') ||
haystack.includes('Access is denied') ||
haystack.includes('Access to the registry key')
)
}
function quoteShell(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`
}
@@ -142,7 +142,6 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
experimentalActivity: true,
experimentalTerminalAttention: false,
compactWorktreeCards: false,
experimentalWorktreeSymlinks: false,
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'powershell.exe',
enableGitHubAttribution: true,
-1
View File
@@ -147,7 +147,6 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
experimentalActivity: true,
experimentalTerminalAttention: false,
compactWorktreeCards: false,
experimentalWorktreeSymlinks: false,
terminalWindowsShell: 'powershell.exe',
terminalWindowsPowerShellImplementation: 'powershell.exe',
enableGitHubAttribution: true,
+116
View File
@@ -9,10 +9,12 @@ const {
spawnMock,
isPwshAvailableMock,
validateWorkingDirectoryMock,
resolveUnixShellPathMock,
resolveAgentForegroundProcessMock
} = vi.hoisted(() => ({
spawnMock: vi.fn(),
isPwshAvailableMock: vi.fn(),
resolveUnixShellPathMock: vi.fn((shellPath: string) => shellPath),
resolveAgentForegroundProcessMock: vi.fn(),
validateWorkingDirectoryMock: vi.fn((cwd: string) => {
if (cwd.includes('definitely-missing')) {
@@ -51,6 +53,7 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => {
const actual = await importOriginal<typeof LocalPtyUtils>()
return {
...actual,
resolveUnixShellPath: resolveUnixShellPathMock,
validateWorkingDirectory: validateWorkingDirectoryMock
}
})
@@ -116,6 +119,8 @@ describe('createPtySubprocess', () => {
async (_pid: number, fallbackProcess: string | null) => fallbackProcess
)
validateWorkingDirectoryMock.mockClear()
resolveUnixShellPathMock.mockReset()
resolveUnixShellPathMock.mockImplementation((shellPath: string) => shellPath)
isPwshAvailableMock.mockReturnValue(false)
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
previousPowerlevelWizardDisable = process.env[POWERLEVEL10K_WIZARD_DISABLE_ENV]
@@ -181,6 +186,117 @@ describe('createPtySubprocess', () => {
)
})
it('resolves a missing Unix default before spawning node-pty', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
resolveUnixShellPathMock.mockReturnValue('/bin/sh')
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
const previousShell = process.env.SHELL
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
delete process.env.SHELL
try {
createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24, env: {} })
expect(resolveUnixShellPathMock).toHaveBeenCalledWith('/bin/zsh')
expect(spawnMock).toHaveBeenCalledWith(
'/bin/sh',
['-l'],
expect.objectContaining({ env: expect.objectContaining({ SHELL: '/bin/sh' }) })
)
expect(warn).toHaveBeenCalledWith(
'[daemon/pty] Preferred shell "/bin/zsh" is unavailable, fell back to "/bin/sh"'
)
} finally {
warn.mockRestore()
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
if (previousShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = previousShell
}
}
})
it('derives shell-ready launch config from the resolved fallback shell', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
resolveUnixShellPathMock.mockReturnValue('/bin/sh')
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
const previousShell = process.env.SHELL
const previousMarker = process.env.ORCA_SHELL_READY_MARKER
const previousZdotdir = process.env.ZDOTDIR
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
delete process.env.SHELL
// Why: the test runner itself can execute inside an Orca-wrapped shell
// whose exported wrapper vars would leak through the process.env spread.
delete process.env.ORCA_SHELL_READY_MARKER
delete process.env.ZDOTDIR
try {
const handle = createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24,
env: {},
command: 'echo hi'
})
expect(handle.shellPath).toBe('/bin/sh')
const [shellPath, shellArgs, spawnOptions] = spawnMock.mock.calls[0]
expect(shellPath).toBe('/bin/sh')
expect(shellArgs).toEqual(['-l'])
// A launch config derived from the missing preferred zsh would inject
// ZDOTDIR and ORCA_SHELL_READY_MARKER; /bin/sh must spawn without them.
expect(spawnOptions.env.ZDOTDIR).toBeUndefined()
expect(spawnOptions.env.ORCA_SHELL_READY_MARKER).toBeUndefined()
expect(spawnOptions.env.SHELL).toBe('/bin/sh')
} finally {
warn.mockRestore()
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
if (previousShell === undefined) {
delete process.env.SHELL
} else {
process.env.SHELL = previousShell
}
if (previousMarker === undefined) {
delete process.env.ORCA_SHELL_READY_MARKER
} else {
process.env.ORCA_SHELL_READY_MARKER = previousMarker
}
if (previousZdotdir === undefined) {
delete process.env.ZDOTDIR
} else {
process.env.ZDOTDIR = previousZdotdir
}
}
})
it('surfaces the no-executable-shell error before node-pty forks', () => {
resolveUnixShellPathMock.mockImplementation(() => {
throw new Error('No executable Unix shell found (tried: /bin/zsh, /bin/bash, /bin/sh)')
})
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
try {
expect(() => createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24, env: {} })).toThrow(
'No executable Unix shell found'
)
expect(spawnMock).not.toHaveBeenCalled()
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
})
it('uses bundled ConPTY for native Windows daemon terminals', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
+13
View File
@@ -14,6 +14,7 @@ import { isValidPtySize, normalizePtySize } from './daemon-pty-size'
import {
ensureNodePtySpawnHelperExecutable,
getNodePtySpawnHelperCandidates,
resolveUnixShellPath,
validateWorkingDirectory
} from '../providers/local-pty-utils'
import { wrapShellSpawnForMacosTccAttribution } from '../providers/macos-tcc-login-shell'
@@ -742,6 +743,17 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
if (opts.env?.TERM) {
env.TERM = opts.env.TERM
}
// Why after the scrub: SHELL must reflect the shell that actually spawns,
// matching LocalPtyProvider; and before the launch-config derivation below
// so shell-ready wrappers target the resolved shell, not a missing one.
const preferredShellPath = shellPath
shellPath = resolveUnixShellPath(shellPath)
if (shellPath !== preferredShellPath) {
env.SHELL = shellPath
console.warn(
`[daemon/pty] Preferred shell "${preferredShellPath}" is unavailable, fell back to "${shellPath}"`
)
}
// Why: OpenCode/Codex path restoration and OMP's typed-command status
// wrapper need shell-ready code after user startup files run.
let shellLaunch: ReturnType<typeof getShellReadyLaunchConfig> | null = null
@@ -1007,6 +1019,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
return {
pid: proc.pid,
shellPath,
...(startupCommandDeliveredInShellArgs ? { startupCommandDeliveredInShellArgs: true } : {}),
getForegroundProcess: () => {
// Why: node-pty's `.process` getter reports the PTY's live foreground
+4
View File
@@ -48,6 +48,10 @@ export type SubprocessHandle = {
/** True when shell launch args already delivered the startup command, so the
* terminal host must skip its stdin fallback write. */
startupCommandDeliveredInShellArgs?: boolean
/** Shell the subprocess actually spawned, after Unix/Windows fallbacks. The
* host reconciles the caller's shell-ready assumption against it so a
* fallback shell without a ready marker never gates startup commands. */
shellPath?: string
write(data: string): void
resize(cols: number, rows: number): void
/** Stop reading the PTY fd (node-pty pause()) so the kernel/ConPTY buffer
+6 -3
View File
@@ -348,13 +348,16 @@ export function resolvePtyShellPath(env: Record<string, string>): string {
return env.SHELL || process.env.SHELL || '/bin/zsh'
}
export function shellPathSupportsPtyStartupBarrier(shellPath: string): boolean {
const shellName = pathWin32.basename(basename(shellPath)).toLowerCase()
return shellName === 'zsh' || shellName === 'bash'
}
export function supportsPtyStartupBarrier(env: Record<string, string>): boolean {
if (process.platform === 'win32') {
return false
}
const resolvedShell = resolvePtyShellPath(env)
const shellName = pathWin32.basename(basename(resolvedShell)).toLowerCase()
return shellName === 'zsh' || shellName === 'bash'
return shellPathSupportsPtyStartupBarrier(resolvePtyShellPath(env))
}
type ShellLaunchConfig = {
+84 -1
View File
@@ -3,7 +3,7 @@ import { Session, type SubprocessHandle } from './session'
import { TerminalHost } from './terminal-host'
function createMockSubprocess(
options: { startupCommandDeliveredInShellArgs?: boolean } = {}
options: { startupCommandDeliveredInShellArgs?: boolean; shellPath?: string } = {}
): SubprocessHandle {
let onDataCb: ((data: string) => void) | null = null
let onExitCb: ((code: number) => void) | null = null
@@ -12,6 +12,7 @@ function createMockSubprocess(
...(options.startupCommandDeliveredInShellArgs
? { startupCommandDeliveredInShellArgs: true }
: {}),
...(options.shellPath ? { shellPath: options.shellPath } : {}),
getForegroundProcess: vi.fn(() => null),
write: vi.fn(),
resize: vi.fn(),
@@ -193,6 +194,88 @@ describe('TerminalHost', () => {
}
})
it('delivers startup commands immediately when the spawned shell cannot emit the ready marker', async () => {
spawnFn = vi.fn(() => {
const sub = createMockSubprocess({ shellPath: '/bin/sh' }) as ReturnType<
typeof createMockSubprocess
> & {
_onDataCb: ((data: string) => void) | null
_onExitCb: ((code: number) => void) | null
}
lastSubprocess = sub
return sub
})
host.dispose()
host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn })
await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
command: 'echo hello',
shellReadySupported: true,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
expect(lastSubprocess.write).toHaveBeenCalledWith(
process.platform === 'win32' ? 'echo hello\r' : 'echo hello\n'
)
})
it('does not bracketed-paste-wrap multiline commands for a fallback shell without paste mode', async () => {
spawnFn = vi.fn(() => {
const sub = createMockSubprocess({ shellPath: '/bin/sh' }) as ReturnType<
typeof createMockSubprocess
> & {
_onDataCb: ((data: string) => void) | null
_onExitCb: ((code: number) => void) | null
}
lastSubprocess = sub
return sub
})
host.dispose()
host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn })
await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
command: 'claude "line one\nline two"',
shellReadySupported: true,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
const written = (lastSubprocess.write as ReturnType<typeof vi.fn>).mock.calls[0]?.[0]
expect(written).not.toContain('\x1b[200~')
expect(written).toContain('line one\nline two')
})
it('keeps the shell-ready barrier when the spawned shell supports the marker', async () => {
spawnFn = vi.fn(() => {
const sub = createMockSubprocess({ shellPath: '/bin/bash' }) as ReturnType<
typeof createMockSubprocess
> & {
_onDataCb: ((data: string) => void) | null
_onExitCb: ((code: number) => void) | null
}
lastSubprocess = sub
return sub
})
host.dispose()
host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn })
await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
command: 'echo hello',
shellReadySupported: true,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
expect(lastSubprocess.write).not.toHaveBeenCalled()
})
it('does not write startup commands already embedded in shell args', async () => {
spawnFn = vi.fn(() => {
const sub = createMockSubprocess({
+13 -2
View File
@@ -1,5 +1,6 @@
import { Session, type SubprocessHandle } from './session'
import { normalizePtySize } from './daemon-pty-size'
import { shellPathSupportsPtyStartupBarrier } from './shell-ready'
import { resolveProcessCwd } from '../providers/process-cwd'
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
import { buildStartupCommandSubmission } from '../../shared/startup-command-submission'
@@ -104,6 +105,16 @@ export class TerminalHost {
terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation
})
// Why: the caller computed shellReadySupported from the preferred shell,
// before spawn. A Unix fallback (e.g. /bin/sh) never emits the ready
// marker, so keeping the stale flag would queue startup commands until the
// shell-ready timeout and bracketed-paste-wrap them for a line editor
// without paste mode.
const shellReadySupported =
(opts.shellReadySupported ?? false) &&
(subprocess.shellPath === undefined ||
shellPathSupportsPtyStartupBarrier(subprocess.shellPath))
const session = new Session({
sessionId: opts.sessionId,
cols: size.cols,
@@ -111,7 +122,7 @@ export class TerminalHost {
terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE,
launchAgent: opts.launchAgent,
subprocess,
shellReadySupported: opts.shellReadySupported ?? false,
shellReadySupported,
historySeed: opts.historySeed,
// Why: reap the dead session (dispose emulator + drop from the map) the
// moment its subprocess exits, instead of retaining it for the daemon's
@@ -143,7 +154,7 @@ export class TerminalHost {
session.write(
buildStartupCommandSubmission(opts.command, {
submit,
bracketedPasteSafe: opts.shellReadySupported ?? false
bracketedPasteSafe: shellReadySupported
})
)
}
+129 -1
View File
@@ -23,6 +23,8 @@ import { clearGitCapabilityStateForTests } from './git-capability-state'
import {
addSparseWorktree,
addWorktree,
_getWorktreeScanCacheSizesForTests,
_resetWorktreeScanCacheForTests,
listWorktreeGraph,
listWorktrees,
moveWorktree,
@@ -36,7 +38,13 @@ beforeEach(() => {
})
describe('listWorktrees in-flight sharing', () => {
beforeEach(() => {
beforeEach(async () => {
gitExecFileAsyncMock.mockReset()
_resetWorktreeScanCacheForTests()
// Why: capability discovery serializes same-host callers; warming it lets
// these tests isolate the scan-generation overlap they are exercising.
gitExecFileAsyncMock.mockResolvedValue({ stdout: '' })
await listWorktrees('/capability-warmup')
gitExecFileAsyncMock.mockReset()
})
@@ -87,6 +95,126 @@ describe('listWorktrees in-flight sharing', () => {
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('does not retain scan generations after mutations without active scans', async () => {
gitExecFileAsyncMock.mockResolvedValue({ stdout: '' })
await Promise.all(
Array.from({ length: 128 }, (_, index) =>
moveWorktree(`/repo-${index}`, `/repo-${index}-old`, `/repo-${index}-new`)
)
)
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 })
})
it('cleans mutation generations after stale scans settle and supports repo reuse', async () => {
const scanResolvers: ((stdout: string) => void)[] = []
let listCalls = 0
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args[0] === 'worktree' && args[1] === 'list') {
listCalls += 1
return new Promise((resolve) => {
scanResolvers.push((stdout) => resolve({ stdout }))
})
}
return Promise.resolve({ stdout: '' })
})
const staleScan = listWorktrees('/repo')
expect(scanResolvers).toHaveLength(1)
await moveWorktree('/repo', '/repo-old', '/repo-new')
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 1, generations: 1 })
const freshScan = listWorktrees('/repo')
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 2, generations: 1 })
scanResolvers[1]?.('worktree /repo-new\nHEAD fresh\nbranch refs/heads/main\n')
expect((await freshScan)[0]?.path).toBe('/repo-new')
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 1, generations: 1 })
scanResolvers[0]?.('worktree /repo\nHEAD stale\nbranch refs/heads/main\n')
expect((await staleScan)[0]?.path).toBe('/repo')
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 })
const firstReuse = listWorktrees('/repo')
const secondReuse = listWorktrees('/repo')
expect(listCalls).toBe(3)
scanResolvers[2]?.('worktree /repo-reused\nHEAD reused\nbranch refs/heads/main\n')
const [firstResult, secondResult] = await Promise.all([firstReuse, secondReuse])
expect(firstResult).toEqual(secondResult)
expect(firstResult[0]?.path).toBe('/repo-reused')
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 })
})
it('retires every overlapping scan generation across repeated mutations', async () => {
const scanResolvers: (() => void)[] = []
let listCalls = 0
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args[0] === 'worktree' && args[1] === 'list') {
listCalls += 1
return new Promise((resolve) => {
scanResolvers.push(() => resolve({ stdout: '' }))
})
}
return Promise.resolve({ stdout: '' })
})
const oldestScan = listWorktrees('/repo')
await moveWorktree('/repo', '/old-0', '/new-0')
const middleScan = listWorktrees('/repo')
await moveWorktree('/repo', '/old-1', '/new-1')
const newestScan = listWorktrees('/repo')
expect(listCalls).toBe(3)
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 3, generations: 1 })
scanResolvers[0]?.()
scanResolvers[2]?.()
scanResolvers[1]?.()
await Promise.all([oldestScan, middleScan, newestScan])
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 })
})
it('keeps WSL scans distinct while retiring both after a mutation', async () => {
const scanResolvers: (() => void)[] = []
gitExecFileAsyncMock.mockResolvedValue({ stdout: '' })
await Promise.all([
listWorktrees('/capability-warmup', { wslDistro: 'Ubuntu' }),
listWorktrees('/capability-warmup', { wslDistro: 'Debian' })
])
gitExecFileAsyncMock.mockReset()
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args[0] === 'worktree' && args[1] === 'list') {
return new Promise((resolve) => {
scanResolvers.push(() => resolve({ stdout: '' }))
})
}
return Promise.resolve({ stdout: '' })
})
const staleUbuntu = listWorktrees('/repo', { wslDistro: 'Ubuntu' })
const staleDebian = listWorktrees('/repo', { wslDistro: 'Debian' })
expect(scanResolvers).toHaveLength(2)
await removeWorktree('/repo', '/worktree', false, {
knownRemovedWorktree: { branch: '', head: '' },
wslDistro: 'Ubuntu'
})
const freshUbuntu = listWorktrees('/repo', { wslDistro: 'Ubuntu' })
const freshDebian = listWorktrees('/repo', { wslDistro: 'Debian' })
expect(scanResolvers).toHaveLength(4)
for (const resolve of scanResolvers) {
resolve()
}
await Promise.all([staleUbuntu, staleDebian, freshUbuntu, freshDebian])
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 })
})
it('does not share scans for callers with an AbortSignal', async () => {
const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
gitExecFileAsyncMock.mockResolvedValue({ stdout: scanOutput })
+39
View File
@@ -650,10 +650,48 @@ const inFlightWorktreeScans = new Map<string, Promise<GitWorktreeInfo[]>>()
// settle for their original callers).
const worktreeScanGenerations = new Map<string, number>()
function hasInFlightWorktreeScanForRepo(repoPath: string): boolean {
const keyPrefix = `${repoPath}\0`
for (const key of inFlightWorktreeScans.keys()) {
if (key.startsWith(keyPrefix)) {
return true
}
}
return false
}
function bumpWorktreeScanGeneration(repoPath: string): void {
// Why: generations only prevent joining a pre-mutation scan. Without an
// active scan, retaining the repo path just leaks completed mutation keys.
if (!hasInFlightWorktreeScanForRepo(repoPath)) {
return
}
worktreeScanGenerations.set(repoPath, (worktreeScanGenerations.get(repoPath) ?? 0) + 1)
}
function pruneWorktreeScanGeneration(repoPath: string): void {
// Why: ordinary scan settlement should stay O(1); only repos invalidated
// during an active scan need the cross-generation in-flight check.
if (!worktreeScanGenerations.has(repoPath)) {
return
}
if (!hasInFlightWorktreeScanForRepo(repoPath)) {
worktreeScanGenerations.delete(repoPath)
}
}
export function _getWorktreeScanCacheSizesForTests(): { inFlight: number; generations: number } {
return {
inFlight: inFlightWorktreeScans.size,
generations: worktreeScanGenerations.size
}
}
export function _resetWorktreeScanCacheForTests(): void {
inFlightWorktreeScans.clear()
worktreeScanGenerations.clear()
}
/**
* List all worktrees for a git repo at the given path. Concurrent calls for
* the same repo share one scan (unless the caller passes an AbortSignal,
@@ -676,6 +714,7 @@ export function listWorktrees(
if (inFlightWorktreeScans.get(key) === scan) {
inFlightWorktreeScans.delete(key)
}
pruneWorktreeScanGeneration(repoPath)
})
inFlightWorktreeScans.set(key, scan)
return scan
+114
View File
@@ -118,6 +118,7 @@ import {
updatePRState,
updatePRTitle,
_getMergeQueueCacheSizeForTests,
_getTrackedUpstreamBranchCacheSizesForTests,
_resetOwnerRepoCache,
_resetMergeQueueCacheForTests,
__resetTrackedUpstreamBranchCacheForTests
@@ -1786,6 +1787,119 @@ describe('getPRForBranch', () => {
expect(trackedUpstreamCalls).toHaveLength(1)
})
it('releases tracked-upstream probe generations across runtime identities', async () => {
const sshGitProvider = {
exec: vi.fn().mockResolvedValue({ stdout: 'feature\0\n', stderr: '' })
}
getSshGitProviderMock.mockReturnValue(sshGitProvider)
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) })
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'feature\0\n', stderr: '' })
await getPRForBranch('/repo-root', 'feature')
await getPRForBranch('/repo-root', 'feature', null, null, null, {
localGitExecOptions: { wslDistro: 'Ubuntu' }
})
await getPRForBranch('/repo-root', 'feature', null, 'ssh-1')
expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({
snapshots: 3,
inFlight: 0,
generations: 0
})
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
expect(sshGitProvider.exec).toHaveBeenCalledTimes(1)
})
it('bounds unique tracked-upstream snapshots and sweeps expired identities', async () => {
vi.useFakeTimers()
try {
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) })
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'feature\0\n', stderr: '' })
for (let index = 0; index < 513; index += 1) {
await getPRForBranch(`/repo-root-${index}`, 'feature')
}
await getPRForBranch('/repo-root-512', 'feature')
expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({
snapshots: 512,
inFlight: 0,
generations: 0
})
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(513)
await vi.advanceTimersByTimeAsync(30_001)
await getPRForBranch('/repo-root-fresh', 'feature')
expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({
snapshots: 1,
inFlight: 0,
generations: 0
})
} finally {
vi.useRealTimers()
}
})
it('keeps stale probe cleanup from releasing a replacement generation', async () => {
let resolveOldProbe: (value: { stdout: string; stderr: string }) => void
let resolveCurrentProbe: (value: { stdout: string; stderr: string }) => void
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) })
gitExecFileAsyncMock
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveOldProbe = resolve
})
)
.mockResolvedValueOnce({ stdout: 'replacement\0\n', stderr: '' })
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveCurrentProbe = resolve
})
)
const oldLookup = getPRForBranch('/repo-root', 'old')
await vi.waitFor(() => expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1))
__resetTrackedUpstreamBranchCacheForTests()
await getPRForBranch('/repo-root', 'replacement')
const currentLookup = getPRForBranch('/repo-root', 'current')
await vi.waitFor(() => expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(3))
resolveOldProbe!({ stdout: 'old\0\n', stderr: '' })
await oldLookup
expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({
snapshots: 0,
inFlight: 1,
generations: 1
})
resolveCurrentProbe!({ stdout: 'current\0\n', stderr: '' })
await currentLookup
expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({
snapshots: 1,
inFlight: 0,
generations: 0
})
})
it('releases tracked-upstream probe state when setup rejects', async () => {
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) })
readLocalGitConfigSignatureMock.mockRejectedValue(new Error('git config unavailable'))
await expect(getPRForBranch('/repo-root', 'feature')).resolves.toBeNull()
expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({
snapshots: 0,
inFlight: 0,
generations: 0
})
})
it('does not fan out tracked-upstream probes after a transient for-each-ref failure', async () => {
resolvePRRepositoryCandidatesMock.mockResolvedValue({
candidates: [{ owner: 'acme', repo: 'widgets' }],
+45 -5
View File
@@ -2392,6 +2392,7 @@ type TrackedUpstreamBranch = {
}
const TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS = 30_000
const TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES = 512
type TrackedUpstreamSnapshotCacheEntry = {
expiresAt: number
@@ -2411,12 +2412,49 @@ const trackedUpstreamSnapshotInFlight = new Map<
string,
Promise<TrackedUpstreamSnapshotProbeResult>
>()
const trackedUpstreamSnapshotGenerations = new Map<string, number>()
const trackedUpstreamSnapshotGenerations = new Map<string, symbol>()
function beginTrackedUpstreamSnapshotProbe(cacheKey: string): number {
const nextGeneration = (trackedUpstreamSnapshotGenerations.get(cacheKey) ?? 0) + 1
trackedUpstreamSnapshotGenerations.set(cacheKey, nextGeneration)
return nextGeneration
function beginTrackedUpstreamSnapshotProbe(cacheKey: string): symbol {
const generation = Symbol()
trackedUpstreamSnapshotGenerations.set(cacheKey, generation)
return generation
}
function finishTrackedUpstreamSnapshotProbe(cacheKey: string, generation: symbol): void {
// Why: generations only guard an active probe; retaining completed repo keys
// leaks worktree/runtime identities after the short-lived snapshot TTL expires.
if (trackedUpstreamSnapshotGenerations.get(cacheKey) === generation) {
trackedUpstreamSnapshotGenerations.delete(cacheKey)
}
}
function pruneTrackedUpstreamSnapshotCache(now: number): void {
for (const [cacheKey, cached] of trackedUpstreamSnapshotCache) {
if (cached.expiresAt <= now) {
trackedUpstreamSnapshotCache.delete(cacheKey)
}
}
// Why: workspace/runtime churn can create unbounded unique keys within one
// TTL window, so expiry sweeping alone is not a memory bound.
while (trackedUpstreamSnapshotCache.size > TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES) {
const oldestKey = trackedUpstreamSnapshotCache.keys().next().value
if (oldestKey === undefined) {
break
}
trackedUpstreamSnapshotCache.delete(oldestKey)
}
}
export function _getTrackedUpstreamBranchCacheSizesForTests(): {
snapshots: number
inFlight: number
generations: number
} {
return {
snapshots: trackedUpstreamSnapshotCache.size,
inFlight: trackedUpstreamSnapshotInFlight.size,
generations: trackedUpstreamSnapshotGenerations.size
}
}
export function __resetTrackedUpstreamBranchCacheForTests(): void {
@@ -2510,6 +2548,7 @@ async function getTrackedUpstreamBranch(
upstreamsByBranchName: getCacheableTrackedUpstreamSnapshot(result.upstreamsByBranchName),
expiresAt: Date.now() + TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS
})
pruneTrackedUpstreamSnapshotCache(Date.now())
}
if (trackedUpstreamSnapshotGenerations.get(cacheKey) !== probeGeneration) {
const fresherCached = trackedUpstreamSnapshotCache.get(cacheKey)
@@ -2522,6 +2561,7 @@ async function getTrackedUpstreamBranch(
if (trackedUpstreamSnapshotInFlight.get(cacheKey) === probe) {
trackedUpstreamSnapshotInFlight.delete(cacheKey)
}
finishTrackedUpstreamSnapshotProbe(cacheKey, probeGeneration)
}
}
@@ -0,0 +1,228 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { handlers, listeners, subscribeTranscript } = vi.hoisted(() => ({
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
listeners: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
subscribeTranscript: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: {
handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => {
handlers.set(channel, handler)
}),
on: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => {
listeners.set(channel, handler)
})
}
}))
vi.mock('../native-chat/transcript-watch', () => ({
subscribeNativeChatTranscript: subscribeTranscript
}))
import {
_getNativeChatPendingSubscriptionCountForTest,
_getNativeChatSenderCleanupCountForTest,
clearNativeChatSubscriptions,
registerNativeChatHandlers
} from './native-chat'
type TestSubscription = {
unsubscribe: ReturnType<typeof vi.fn>
}
type DeferredSubscription = {
promise: Promise<TestSubscription>
reject: (error: Error) => void
resolve: () => void
unsubscribe: ReturnType<typeof vi.fn>
}
type SenderHarness = {
destroy: () => void
registeredCleanupCount: () => number
sender: {
id: number
isDestroyed: () => boolean
once: (event: string, callback: () => void) => void
send: ReturnType<typeof vi.fn>
}
}
beforeEach(() => {
clearNativeChatSubscriptions()
handlers.clear()
listeners.clear()
subscribeTranscript.mockReset()
registerNativeChatHandlers()
})
function deferredSubscription(): DeferredSubscription {
const unsubscribe = vi.fn()
let resolvePromise: (subscription: TestSubscription) => void = () => {}
let rejectPromise: (error: Error) => void = () => {}
const promise = new Promise<TestSubscription>((resolve, reject) => {
resolvePromise = resolve
rejectPromise = reject
})
return {
promise,
reject: rejectPromise,
resolve: () => resolvePromise({ unsubscribe }),
unsubscribe
}
}
function createSender(id: number): SenderHarness {
let destroyed = false
const destroyedCallbacks: (() => void)[] = []
return {
destroy: () => {
destroyed = true
for (const callback of destroyedCallbacks) {
callback()
}
},
registeredCleanupCount: () => destroyedCallbacks.length,
sender: {
id,
isDestroyed: () => destroyed,
once: (event, callback) => {
if (event === 'destroyed') {
destroyedCallbacks.push(callback)
}
},
send: vi.fn()
}
}
}
function subscribe(sender: SenderHarness['sender'], subscriptionId: string): void {
const listener = listeners.get('nativeChat:subscribe')
if (!listener) {
throw new Error('subscribe listener not registered')
}
listener({ sender }, { subscriptionId, agent: 'claude', sessionId: `session-${subscriptionId}` })
}
function unsubscribe(sender: SenderHarness['sender'], subscriptionId: string): void {
const listener = listeners.get('nativeChat:unsubscribe')
if (!listener) {
throw new Error('unsubscribe listener not registered')
}
listener({ sender }, { subscriptionId })
}
async function waitFor(predicate: () => boolean, timeoutMs = 1000): Promise<void> {
const startedAt = Date.now()
while (!predicate()) {
if (Date.now() - startedAt > timeoutMs) {
throw new Error('timed out waiting for lifecycle state')
}
await new Promise((resolve) => setTimeout(resolve, 0))
}
}
describe('nativeChat subscribe lifecycle', () => {
it('closes a watcher that resolves after renderer unsubscribe', async () => {
const pending = deferredSubscription()
subscribeTranscript.mockReturnValueOnce(pending.promise)
const renderer = createSender(1)
subscribe(renderer.sender, 'unmount')
expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(1)
unsubscribe(renderer.sender, 'unmount')
expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(0)
unsubscribe(renderer.sender, 'unmount')
expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(0)
pending.resolve()
await waitFor(() => pending.unsubscribe.mock.calls.length === 1)
unsubscribe(renderer.sender, 'unmount')
expect(pending.unsubscribe).toHaveBeenCalledOnce()
renderer.destroy()
expect(_getNativeChatSenderCleanupCountForTest()).toBe(0)
})
it('closes a watcher that resolves after renderer destruction', async () => {
const pending = deferredSubscription()
subscribeTranscript.mockReturnValueOnce(pending.promise)
const renderer = createSender(2)
subscribe(renderer.sender, 'destroy')
renderer.destroy()
expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(0)
expect(_getNativeChatSenderCleanupCountForTest()).toBe(0)
pending.resolve()
await waitFor(() => pending.unsubscribe.mock.calls.length === 1)
})
it('keeps the latest same-id subscribe when setup resolves in reverse order', async () => {
const older = deferredSubscription()
const newer = deferredSubscription()
subscribeTranscript.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise)
const renderer = createSender(3)
subscribe(renderer.sender, 'same-id')
subscribe(renderer.sender, 'same-id')
expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(1)
newer.resolve()
await waitFor(() => _getNativeChatPendingSubscriptionCountForTest() === 0)
expect(newer.unsubscribe).not.toHaveBeenCalled()
older.resolve()
await waitFor(() => older.unsubscribe.mock.calls.length === 1)
unsubscribe(renderer.sender, 'same-id')
expect(newer.unsubscribe).toHaveBeenCalledOnce()
renderer.destroy()
})
it('clears rejected setup without duplicating sender cleanup registration', async () => {
const failed = deferredSubscription()
const retry = deferredSubscription()
subscribeTranscript.mockReturnValueOnce(failed.promise).mockReturnValueOnce(retry.promise)
const renderer = createSender(4)
subscribe(renderer.sender, 'retry')
failed.reject(new Error('watch setup failed'))
await waitFor(() => _getNativeChatPendingSubscriptionCountForTest() === 0)
expect(_getNativeChatSenderCleanupCountForTest()).toBe(1)
subscribe(renderer.sender, 'retry')
expect(renderer.registeredCleanupCount()).toBe(1)
retry.resolve()
await waitFor(() => _getNativeChatPendingSubscriptionCountForTest() === 0)
unsubscribe(renderer.sender, 'retry')
expect(retry.unsubscribe).toHaveBeenCalledOnce()
renderer.destroy()
expect(_getNativeChatSenderCleanupCountForTest()).toBe(0)
})
it('isolates late setup from a replacement renderer reusing the sender id', async () => {
const oldPending = deferredSubscription()
const replacementPending = deferredSubscription()
subscribeTranscript
.mockReturnValueOnce(oldPending.promise)
.mockReturnValueOnce(replacementPending.promise)
const oldRenderer = createSender(41)
const replacementRenderer = createSender(41)
subscribe(oldRenderer.sender, 'remount')
oldRenderer.destroy()
subscribe(replacementRenderer.sender, 'remount')
expect(replacementRenderer.registeredCleanupCount()).toBe(1)
replacementPending.resolve()
await waitFor(() => _getNativeChatPendingSubscriptionCountForTest() === 0)
oldPending.resolve()
await waitFor(() => oldPending.unsubscribe.mock.calls.length === 1)
expect(replacementPending.unsubscribe).not.toHaveBeenCalled()
replacementRenderer.destroy()
expect(replacementPending.unsubscribe).toHaveBeenCalledOnce()
expect(_getNativeChatSenderCleanupCountForTest()).toBe(0)
})
})
+62
View File
@@ -20,6 +20,7 @@ vi.mock('electron', () => ({
}))
import {
_getNativeChatSenderCleanupCountForTest,
clearNativeChatSubscriptions,
clearNativeChatTranscriptCache,
registerNativeChatHandlers
@@ -238,6 +239,67 @@ describe('nativeChat:readSession handler', () => {
}
})
it('drops cleanup registration when sender is destroyed before subscribe completes', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-destroy-race-'))
tempRoots.push(root)
const projectDir = join(root, '.claude', 'projects', '-repo')
await mkdir(projectDir, { recursive: true })
await writeFile(
join(projectDir, 'sess-race.jsonl'),
`${jsonLines([
{
type: 'user',
uuid: 'u-race',
timestamp: '2026-06-01T10:00:00.000Z',
message: { role: 'user', content: 'Race' }
}
])}\n`
)
registerNativeChatHandlers()
const subscribe = listeners.get('nativeChat:subscribe')
expect(subscribe).toBeDefined()
let destroyed = false
let destroyedCb: (() => void) | undefined
const sender = {
id: 41,
isDestroyed: () => destroyed,
once: (event: string, cb: () => void) => {
if (event === 'destroyed') {
destroyedCb = cb
}
},
send: vi.fn()
}
const previousHome = process.env.HOME
process.env.HOME = root
try {
subscribe!(
{ sender },
{
subscriptionId: 'sub-race',
agent: 'claude',
sessionId: 'sess-race'
}
)
expect(destroyedCb).toBeDefined()
destroyed = true
destroyedCb!()
await waitFor(() => _getNativeChatSenderCleanupCountForTest() === 0)
expect(sender.send).not.toHaveBeenCalled()
} finally {
if (previousHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = previousHome
}
}
})
it('returns an error for an unknown session without throwing', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-missing-'))
tempRoots.push(root)
+70 -19
View File
@@ -71,9 +71,17 @@ type LiveSubscription = {
// same renderer can watch several panes, and a destroyed window tears down all
// of its watchers — strict teardown to avoid fd leaks (plan U4 risk).
const liveSubscriptions = new Map<number, Map<string, LiveSubscription>>()
// Why: unsubscribe and renderer destruction must invalidate async watcher setup
// before it can publish a late subscription into the live map.
const pendingSubscriptions = new Map<number, Map<string, symbol>>()
const senderCleanupRegistered = new Set<number>()
function teardownSubscription(senderId: number, subscriptionId: string): void {
const pendingBySubId = pendingSubscriptions.get(senderId)
pendingBySubId?.delete(subscriptionId)
if (pendingBySubId?.size === 0) {
pendingSubscriptions.delete(senderId)
}
const bySubId = liveSubscriptions.get(senderId)
const live = bySubId?.get(subscriptionId)
if (!live || !bySubId) {
@@ -87,6 +95,9 @@ function teardownSubscription(senderId: number, subscriptionId: string): void {
}
function teardownAllForSender(senderId: number): void {
// The destroyed event can arrive before async subscription setup stores a watcher.
senderCleanupRegistered.delete(senderId)
pendingSubscriptions.delete(senderId)
const bySubId = liveSubscriptions.get(senderId)
if (!bySubId) {
return
@@ -95,7 +106,6 @@ function teardownAllForSender(senderId: number): void {
live.subscription.unsubscribe()
}
liveSubscriptions.delete(senderId)
senderCleanupRegistered.delete(senderId)
}
function registerSenderCleanup(sender: WebContents): void {
@@ -107,6 +117,27 @@ function registerSenderCleanup(sender: WebContents): void {
sender.once('destroyed', () => teardownAllForSender(sender.id))
}
function beginPendingSubscription(senderId: number, subscriptionId: string): symbol {
teardownSubscription(senderId, subscriptionId)
const token = Symbol(subscriptionId)
const bySubId = pendingSubscriptions.get(senderId) ?? new Map<string, symbol>()
bySubId.set(subscriptionId, token)
pendingSubscriptions.set(senderId, bySubId)
return token
}
function takePendingSubscription(senderId: number, subscriptionId: string, token: symbol): boolean {
const bySubId = pendingSubscriptions.get(senderId)
if (bySubId?.get(subscriptionId) !== token) {
return false
}
bySubId.delete(subscriptionId)
if (bySubId.size === 0) {
pendingSubscriptions.delete(senderId)
}
return true
}
async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArgs): Promise<void> {
const sender = event.sender
if (sender.isDestroyed()) {
@@ -114,26 +145,32 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg
}
const { subscriptionId, agent, sessionId, transcriptPath } = args
// Replace any prior subscription under the same id (session change/resubscribe).
teardownSubscription(sender.id, subscriptionId)
const pendingToken = beginPendingSubscription(sender.id, subscriptionId)
registerSenderCleanup(sender)
const subscription = await subscribeNativeChatTranscript({
agent,
sessionId,
transcriptPath,
onAppend: (messages) => {
if (sender.isDestroyed()) {
return
let subscription: NativeChatTranscriptSubscription
try {
subscription = await subscribeNativeChatTranscript({
agent,
sessionId,
transcriptPath,
onAppend: (messages) => {
if (sender.isDestroyed()) {
return
}
const payload: NativeChatAppendedPayload = { subscriptionId, messages }
sender.send('nativeChat:appended', payload)
}
const payload: NativeChatAppendedPayload = { subscriptionId, messages }
sender.send('nativeChat:appended', payload)
}
})
})
} catch {
takePendingSubscription(sender.id, subscriptionId, pendingToken)
return
}
// The window may have gone away (or the subscription been replaced) while we
// resolved the file path — don't register a now-orphaned watcher.
const stillCurrent = !sender.isDestroyed()
if (!stillCurrent) {
// Why: unmount, destruction, or a newer same-id subscribe can invalidate setup
// while path resolution is pending; only the owning token may publish its watcher.
const stillCurrent = takePendingSubscription(sender.id, subscriptionId, pendingToken)
if (sender.isDestroyed() || !stillCurrent) {
subscription.unsubscribe()
return
}
@@ -147,12 +184,26 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg
liveSubscriptions.set(sender.id, bySubId)
}
/** Test-only: drop all live transcript subscriptions between runs. */
/** Test-only: drop all live and pending transcript subscriptions between runs. */
export function clearNativeChatSubscriptions(): void {
const senderIds = Array.from(liveSubscriptions.keys())
const senderIds = new Set([...liveSubscriptions.keys(), ...pendingSubscriptions.keys()])
for (const senderId of senderIds) {
teardownAllForSender(senderId)
}
pendingSubscriptions.clear()
senderCleanupRegistered.clear()
}
export function _getNativeChatSenderCleanupCountForTest(): number {
return senderCleanupRegistered.size
}
export function _getNativeChatPendingSubscriptionCountForTest(): number {
let count = 0
for (const bySubId of pendingSubscriptions.values()) {
count += bySubId.size
}
return count
}
export function registerNativeChatHandlers(): void {
+34
View File
@@ -3221,6 +3221,40 @@ describe('registerPtyHandlers', () => {
})
})
it('routes runtime foreground confirmation to the provider owning the captured PTY', async () => {
const confirmForegroundProcess = vi.fn(async () => 'codex')
registerSshPtyProvider('ssh-1', { confirmForegroundProcess } as never)
setPtyOwnership('remote-pty', 'ssh-1')
const runtime = { setPtyController: vi.fn() }
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
confirmForegroundProcess: (ptyId: string) => Promise<string | null>
}
await expect(controller.confirmForegroundProcess('remote-pty')).resolves.toBe('codex')
expect(confirmForegroundProcess).toHaveBeenCalledOnce()
expect(confirmForegroundProcess).toHaveBeenCalledWith('remote-pty')
deletePtyOwnership('remote-pty')
})
it('returns unavailable runtime confirmation for unsupported or missing providers', async () => {
registerSshPtyProvider('ssh-1', {} as never)
setPtyOwnership('unsupported-pty', 'ssh-1')
setPtyOwnership('missing-pty', 'missing-connection')
const runtime = { setPtyController: vi.fn() }
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
confirmForegroundProcess: (ptyId: string) => Promise<string | null>
}
await expect(controller.confirmForegroundProcess('unsupported-pty')).resolves.toBeNull()
await expect(controller.confirmForegroundProcess('missing-pty')).resolves.toBeNull()
deletePtyOwnership('unsupported-pty')
deletePtyOwnership('missing-pty')
})
it('rethrows non-not-found local provider shutdown failures', async () => {
setLocalPtyProvider({
spawn: vi.fn(),
+9
View File
@@ -3397,6 +3397,15 @@ export function registerPtyHandlers(
return null
}
},
confirmForegroundProcess: async (ptyId) => {
try {
const provider = getProviderForPty(ptyId)
// Why: cached foreground evidence cannot resolve a fresh shell conflict.
return (await provider.confirmForegroundProcess?.(ptyId)) ?? null
} catch {
return null
}
},
getCwd: async (ptyId) => {
try {
const cwd = await getProviderForPty(ptyId).getCwd(ptyId)
+3 -5
View File
@@ -1903,8 +1903,8 @@ export async function createRemoteWorktree(
})
const workspaceLineage = recordWorkspaceLineageForCreatedWorktree(store, args, worktree, now)
// Why: `experimentalWorktreeSymlinks` is intentionally not wired up for
// remote (SSH) worktrees. Creating symlinks on the remote host would
// Why: shared paths are intentionally not wired up for remote (SSH)
// worktrees. Creating symlinks on the remote host would
// require a new relay method and authorization surface; the feature is
// local-only until that protocol work is in scope. Remote repos with
// `symlinkPaths` configured have them silently ignored here.
@@ -2536,10 +2536,8 @@ export async function createLocalWorktree(
// Why: materialize user-configured paths from the primary checkout into the
// new worktree before any setup script runs, so scripts that reuse shared
// state (e.g. `node_modules`, `.env`) see those paths already in place.
// Gated on the experimental flag so disabling the feature globally skips
// the work even when a repo still has paths configured.
const symlinkPaths = repo.symlinkPaths ?? []
if (settings.experimentalWorktreeSymlinks && symlinkPaths.length > 0) {
if (symlinkPaths.length > 0) {
await timing.time('create_symlinks', async () => {
await createWorktreeLinkedPaths(repo.path, created.path, symlinkPaths)
})
+198 -1
View File
@@ -246,7 +246,11 @@ import {
notifyWorktreesChanged
} from './worktree-remote'
import { invalidateAuthorizedRootsCache, resolveRegisteredWorktreePath } from './filesystem-auth'
import { __resetDetectedWorktreeScanCacheForTests, registerWorktreeHandlers } from './worktrees'
import {
__getDetectedWorktreeScanCacheStatsForTests,
__resetDetectedWorktreeScanCacheForTests,
registerWorktreeHandlers
} from './worktrees'
type HandlerMap = Record<string, (_event: unknown, args: unknown) => unknown>
@@ -2633,6 +2637,199 @@ describe('registerWorktreeHandlers', () => {
expect(listWorktreesMock).toHaveBeenCalledTimes(1)
})
it('does not retain invalidated detected scans after they settle', async () => {
let resolveScan: (worktrees: GitWorktreeInfo[]) => void = () => {}
listWorktreesMock.mockImplementation(
() =>
new Promise((resolve) => {
resolveScan = resolve as (worktrees: GitWorktreeInfo[]) => void
})
)
const pendingList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
await Promise.resolve()
expect(__getDetectedWorktreeScanCacheStatsForTests()).toMatchObject({
cacheSize: 0,
inFlightSize: 1
})
notifyWorktreesChanged(mainWindow as never, 'repo-1')
expect(__getDetectedWorktreeScanCacheStatsForTests()).toMatchObject({
cacheSize: 0,
inFlightSize: 0
})
resolveScan([
{
path: '/workspace/repo',
head: 'main-head',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
])
await pendingList
expect(__getDetectedWorktreeScanCacheStatsForTests()).toMatchObject({
cacheSize: 0,
inFlightSize: 0
})
})
it('does not accumulate scan bookkeeping across prolonged repository churn', async () => {
store.getRepo.mockImplementation((repoId: string) => ({
id: repoId,
path: `/workspace/${repoId}`,
displayName: repoId,
badgeColor: '#000',
addedAt: 0,
worktreeBaseRef: null
}))
listWorktreesMock.mockImplementation(async (repoPath: string) => [
{
path: repoPath,
head: 'main-head',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
])
for (let index = 0; index < 128; index += 1) {
const repoId = `repo-${index}`
await handlers['worktrees:listDetected'](null, { repoId })
notifyWorktreesChanged(mainWindow as never, repoId)
}
expect(__getDetectedWorktreeScanCacheStatsForTests()).toEqual({
cacheSize: 0,
inFlightSize: 0
})
expect(listWorktreesMock).toHaveBeenCalledTimes(128)
})
it('keeps a replacement scan current after an older scan settles first', async () => {
const resolvers: ((worktrees: GitWorktreeInfo[]) => void)[] = []
listWorktreesMock.mockImplementation(
() =>
new Promise((resolve) => {
resolvers.push(resolve as (worktrees: GitWorktreeInfo[]) => void)
})
)
const result = [
{
path: '/workspace/repo',
head: 'main-head',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
]
const staleList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
await Promise.resolve()
notifyWorktreesChanged(mainWindow as never, 'repo-1')
const replacementList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
await Promise.resolve()
resolvers[0](result)
await staleList
expect(__getDetectedWorktreeScanCacheStatsForTests()).toEqual({
cacheSize: 0,
inFlightSize: 1
})
resolvers[1](result)
await replacementList
expect(__getDetectedWorktreeScanCacheStatsForTests()).toEqual({
cacheSize: 1,
inFlightSize: 0
})
})
it('does not let an older scan overwrite a replacement that settles first', async () => {
const resolvers: ((worktrees: GitWorktreeInfo[]) => void)[] = []
listWorktreesMock.mockImplementation(
() =>
new Promise((resolve) => {
resolvers.push(resolve as (worktrees: GitWorktreeInfo[]) => void)
})
)
store.getAllWorktreeLineage.mockReturnValue({
'repo-1::/workspace/fresh-worktree': {
worktreeId: 'repo-1::/workspace/fresh-worktree',
worktreeInstanceId: 'child-instance',
parentWorktreeId: 'repo-1::/workspace/repo',
parentWorktreeInstanceId: 'parent-instance',
origin: 'manual',
capture: {
source: 'manual-action',
confidence: 'explicit'
},
createdAt: 0
}
})
const mainWorktree: GitWorktreeInfo = {
path: '/workspace/repo',
head: 'main-head',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
const staleList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
await Promise.resolve()
notifyWorktreesChanged(mainWindow as never, 'repo-1')
const replacementList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
await Promise.resolve()
resolvers[1]([
mainWorktree,
{
path: '/workspace/fresh-worktree',
head: 'fresh-head',
branch: 'refs/heads/fresh-worktree',
isBare: false,
isMainWorktree: false
}
])
await replacementList
resolvers[0]([
mainWorktree,
{
path: '/workspace/stale-worktree',
head: 'stale-head',
branch: 'refs/heads/stale-worktree',
isBare: false,
isMainWorktree: false
}
])
await staleList
const cached = (await handlers['worktrees:listDetected'](null, {
repoId: 'repo-1'
})) as { worktrees: Worktree[] }
expect(cached.worktrees.map((worktree) => worktree.path)).toEqual([
'/workspace/repo',
'/workspace/fresh-worktree'
])
expect(store.removeWorktreeLineage).not.toHaveBeenCalled()
await expect(
resolveRegisteredWorktreePath('/workspace/fresh-worktree', store as never)
).resolves.toBe(resolve('/workspace/fresh-worktree'))
await expect(
resolveRegisteredWorktreePath('/workspace/stale-worktree', store as never)
).rejects.toThrow('Access denied: unknown repository or worktree path')
expect(listWorktreesMock).toHaveBeenCalledTimes(2)
expect(__getDetectedWorktreeScanCacheStatsForTests()).toEqual({
cacheSize: 1,
inFlightSize: 0
})
})
it('fetches the same-repo PR head via the SSH tracking-ref RPC, not git.exec', async () => {
const fetchRemoteTrackingRef = vi.fn(async () => {})
const exec = vi.fn(async (args: string[]) => {
+37 -14
View File
@@ -469,37 +469,59 @@ type DetectedWorktreeScanCacheEntry = {
worktrees: GitWorktreeInfo[]
}
type DetectedWorktreeScan = {
invalidated: boolean
promise: Promise<GitWorktreeInfo[]>
}
type DetectedWorktreeScanResult = {
gitWorktrees: GitWorktreeInfo[]
fresh: boolean
}
const detectedWorktreeScanCache = new Map<string, DetectedWorktreeScanCacheEntry>()
const detectedWorktreeScanInFlight = new Map<string, Promise<GitWorktreeInfo[]>>()
const detectedWorktreeScanGenerations = new Map<string, number>()
const detectedWorktreeScanInFlight = new Map<string, DetectedWorktreeScan>()
function invalidateDetectedWorktreeScanCache(repoId: string): void {
const keyPrefix = `${repoId}\0`
for (const key of new Set([
...detectedWorktreeScanCache.keys(),
...detectedWorktreeScanInFlight.keys(),
...detectedWorktreeScanGenerations.keys()
...detectedWorktreeScanInFlight.keys()
])) {
if (!key.startsWith(keyPrefix)) {
continue
}
detectedWorktreeScanCache.delete(key)
detectedWorktreeScanInFlight.delete(key)
detectedWorktreeScanGenerations.set(key, (detectedWorktreeScanGenerations.get(key) ?? 0) + 1)
const inFlight = detectedWorktreeScanInFlight.get(key)
if (inFlight) {
// Why: the detached scan keeps this token, so later scans can settle
// without making an older result fresh again.
inFlight.invalidated = true
detectedWorktreeScanInFlight.delete(key)
}
}
}
registerWorktreeChangeInvalidator(invalidateDetectedWorktreeScanCache)
export function __resetDetectedWorktreeScanCacheForTests(): void {
// Why: scans still pending across a test reset must not repopulate the
// cache afterward and leak state into the next test.
for (const scan of detectedWorktreeScanInFlight.values()) {
scan.invalidated = true
}
detectedWorktreeScanCache.clear()
detectedWorktreeScanInFlight.clear()
detectedWorktreeScanGenerations.clear()
}
export function __getDetectedWorktreeScanCacheStatsForTests(): {
cacheSize: number
inFlightSize: number
} {
return {
cacheSize: detectedWorktreeScanCache.size,
inFlightSize: detectedWorktreeScanInFlight.size
}
}
async function listDetectedGitWorktrees(
@@ -522,24 +544,25 @@ async function listDetectedGitWorktrees(
const inFlight = detectedWorktreeScanInFlight.get(cacheKey)
if (inFlight) {
return { gitWorktrees: await inFlight, fresh: false }
return { gitWorktrees: await inFlight.promise, fresh: false }
}
const scan = listRepoWorktrees(repo, localWorktreeGitOptions)
const generation = detectedWorktreeScanGenerations.get(cacheKey) ?? 0
const scan: DetectedWorktreeScan = {
invalidated: false,
promise: listRepoWorktrees(repo, localWorktreeGitOptions)
}
detectedWorktreeScanInFlight.set(cacheKey, scan)
try {
const gitWorktrees = await scan
const gitWorktrees = await scan.promise
// Why: a create/remove notification can invalidate while the git scan is
// still running. Do not let that stale scan repopulate the cache afterward.
const isCurrentGeneration = (detectedWorktreeScanGenerations.get(cacheKey) ?? 0) === generation
if (isCurrentGeneration) {
if (!scan.invalidated) {
detectedWorktreeScanCache.set(cacheKey, {
worktrees: gitWorktrees,
expiresAt: Date.now() + DETECTED_WORKTREE_SCAN_CACHE_TTL_MS
})
}
return { gitWorktrees, fresh: isCurrentGeneration }
return { gitWorktrees, fresh: !scan.invalidated }
} finally {
if (detectedWorktreeScanInFlight.get(cacheKey) === scan) {
detectedWorktreeScanInFlight.delete(cacheKey)
+103
View File
@@ -562,6 +562,50 @@ describe('Store', () => {
expect(ui.setupGuideSidebarDismissed).toBe(false)
expect(ui.setupGuideBrowserMilestoneMigrated).toBe(true)
expect(ui.setupGuideBrowserMilestoneLegacyComplete).toBe(false)
// Why: brand-new profiles never saw remaining-as-default.
expect(ui.usagePercentageDisplayChangeNoticeDismissed).toBe(true)
})
it('surfaces the usage percentage display change notice for upgraded profiles', async () => {
const persisted = getDefaultPersistedState(testState.dir)
writeDataFile({
...persisted,
onboarding: {
...persisted.onboarding,
closedAt: 1,
outcome: 'completed'
},
ui: {
...persisted.ui,
// Why: omit the notice key so load resolves eligibility for existing profiles.
usagePercentageDisplayChangeNoticeDismissed: undefined
}
})
const store = await createStore()
expect(store.getUI().usagePercentageDisplayChangeNoticeDismissed).toBe(false)
})
it('keeps the usage percentage display change notice dismissed when remaining was chosen', async () => {
const persisted = getDefaultPersistedState(testState.dir)
writeDataFile({
...persisted,
onboarding: {
...persisted.onboarding,
closedAt: 1,
outcome: 'completed'
},
ui: {
...persisted.ui,
usagePercentageDisplay: 'remaining',
usagePercentageDisplayChangeNoticeDismissed: undefined
}
})
const store = await createStore()
expect(store.getUI().usagePercentageDisplayChangeNoticeDismissed).toBe(true)
})
it('defaults minimizeToTrayOnClose to false when unset', async () => {
@@ -6017,6 +6061,65 @@ describe('Store', () => {
expect(store.getSettings().terminalMacOptionAsAltMigrated).toBe(true)
})
it('migrates inherited right-click paste to each platform default once', async () => {
for (const [platform, expected] of [
['win32', true],
['darwin', false],
['linux', false]
] as const) {
await withPlatform(platform, async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: { terminalRightClickToPaste: true },
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getSettings().terminalRightClickToPaste).toBe(expected)
expect(store.getSettings().terminalRightClickToPasteDefaultedForPlatform).toBe(true)
})
}
})
it('preserves an explicit Windows right-click paste opt-out during migration', async () => {
await withPlatform('win32', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: { terminalRightClickToPaste: false },
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getSettings().terminalRightClickToPaste).toBe(false)
expect(store.getSettings().terminalRightClickToPasteDefaultedForPlatform).toBe(true)
})
})
it('preserves right-click paste choices after the platform migration', async () => {
await withPlatform('darwin', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: {
terminalRightClickToPaste: true,
terminalRightClickToPasteDefaultedForPlatform: true
},
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getSettings().terminalRightClickToPaste).toBe(true)
})
})
it('migrates inherited terminal bar cursor defaults to block on first load', async () => {
writeDataFile({
schemaVersion: 1,
+43
View File
@@ -104,6 +104,9 @@ import {
ONBOARDING_FINAL_STEP
} from '../shared/constants'
import { parseWorkspaceSession } from '../shared/workspace-session-schema'
import { normalizeUsagePercentageDisplay } from '../shared/usage-percentage-display'
import { isExistingPersistedProfile } from '../shared/project-order-manual-default-notice'
import { resolveUsagePercentageDisplayChangeNoticeDismissed } from '../shared/usage-percentage-display-change-notice'
import {
LOCAL_EXECUTION_HOST_ID,
normalizeExecutionHostOrder,
@@ -2996,6 +2999,11 @@ export class Store {
const migratedTerminalLineHeight = normalizeTerminalLineHeight(
parsed.settings?.terminalLineHeight
)
const terminalRightClickToPasteDefaultedForPlatform =
parsed.settings?.terminalRightClickToPasteDefaultedForPlatform === true
if (!terminalRightClickToPasteDefaultedForPlatform) {
this.loadNeedsSave = true
}
if (
parsed.settings?.terminalLineHeight !== undefined &&
parsed.settings.terminalLineHeight !== migratedTerminalLineHeight
@@ -3135,6 +3143,15 @@ export class Store {
...migratedAutoRenameBranchFromWork,
...migratedTerminalCursorStyle,
terminalLineHeight: migratedTerminalLineHeight,
// Why: the old global true default was inherited, while false was
// always an explicit opt-out and must survive this one-shot reset.
terminalRightClickToPaste: terminalRightClickToPasteDefaultedForPlatform
? (parsed.settings?.terminalRightClickToPaste ??
defaults.settings.terminalRightClickToPaste)
: parsed.settings?.terminalRightClickToPaste === false
? false
: defaults.settings.terminalRightClickToPaste,
terminalRightClickToPasteDefaultedForPlatform: true,
...migratedTerminalTuiScrollSensitivity.settings,
experimentalActivity: migratedExperimentalActivity,
experimentalActivityDefaultedOffForAllUsers: true,
@@ -3305,6 +3322,25 @@ export class Store {
) {
this.loadNeedsSave = true
}
// Why: only upgraded profiles that still use the new default get
// the one-time usage-display change notice; brand-new profiles and
// users who already chose remaining stay quiet.
const usagePercentageDisplayChangeNoticeDismissed =
resolveUsagePercentageDisplayChangeNoticeDismissed({
rawDismissed: parsed.ui?.usagePercentageDisplayChangeNoticeDismissed,
rawUsagePercentageDisplay: parsed.ui?.usagePercentageDisplay,
isExistingProfile: isExistingPersistedProfile({
repoCount: parsed.repos?.length ?? 0,
onboardingClosedAt: normalizedOnboarding.closedAt,
ui: parsed.ui
})
})
if (
parsed.ui?.usagePercentageDisplayChangeNoticeDismissed !==
usagePercentageDisplayChangeNoticeDismissed
) {
this.loadNeedsSave = true
}
return {
...defaults.ui,
// Why: missing card properties should follow the persisted card
@@ -3318,6 +3354,7 @@ export class Store {
rightSidebarOpen,
rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab),
setupGuideSidebarDismissed,
usagePercentageDisplayChangeNoticeDismissed,
setupGuideBrowserMilestoneMigrated:
typeof parsed.ui?.setupGuideBrowserMilestoneMigrated === 'boolean'
? parsed.ui.setupGuideBrowserMilestoneMigrated
@@ -5333,6 +5370,9 @@ export class Store {
this.state.ui?.workspaceBoardColumnWidth
),
syncTaskStatusFromWorkspaceBoard: this.state.ui?.syncTaskStatusFromWorkspaceBoard === true,
usagePercentageDisplay: normalizeUsagePercentageDisplay(
this.state.ui?.usagePercentageDisplay
),
// Why: strict boolean coercion so a missing/legacy value reads as false
// (first-run notice still fires) rather than leaking a non-bool through.
trayMinimizeNoticeShown: this.state.ui?.trayMinimizeNoticeShown === true,
@@ -5412,6 +5452,9 @@ export class Store {
sanitizedUpdates.syncTaskStatusFromWorkspaceBoard !== undefined
? sanitizedUpdates.syncTaskStatusFromWorkspaceBoard === true
: this.state.ui?.syncTaskStatusFromWorkspaceBoard === true,
usagePercentageDisplay: normalizeUsagePercentageDisplay(
sanitizedUpdates.usagePercentageDisplay ?? this.state.ui?.usagePercentageDisplay
),
markdownTocPanelWidth: clampMarkdownTocPanelWidth(
sanitizedUpdates.markdownTocPanelWidth ?? this.state.ui?.markdownTocPanelWidth
),
+47 -1
View File
@@ -33,7 +33,11 @@ vi.mock('./macos-tcc-login-shell', () => ({
wrapShellSpawnForMacosTccAttribution: wrapSpawnMock
}))
import { spawnShellWithFallback, validateWorkingDirectory } from './local-pty-utils'
import {
resolveUnixShellPath,
spawnShellWithFallback,
validateWorkingDirectory
} from './local-pty-utils'
const WSL_UNC_DIR = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
const NATIVE_DIR = 'C:\\Users\\jin\\repo'
@@ -102,6 +106,48 @@ describe('validateWorkingDirectory', () => {
})
})
describe('resolveUnixShellPath', () => {
beforeEach(() => {
existsSyncMock.mockReset()
accessSyncMock.mockReset()
})
it('preserves non-absolute shells for execvp PATH and cwd resolution', () => {
expect(resolveUnixShellPath('bash')).toBe('bash')
expect(resolveUnixShellPath('./bin/custom-shell')).toBe('./bin/custom-shell')
expect(existsSyncMock).not.toHaveBeenCalled()
})
it('selects an executable fallback before node-pty can fork a missing shell', () => {
existsSyncMock.mockImplementation((path) => path === '/bin/sh')
accessSyncMock.mockReturnValue(undefined)
expect(resolveUnixShellPath('/missing/zsh')).toBe('/bin/sh')
expect(existsSyncMock.mock.calls.map(([path]) => path)).toEqual([
'/missing/zsh',
'/bin/zsh',
'/bin/bash',
'/bin/sh'
])
})
it('reports every attempted shell when no executable candidate exists', () => {
existsSyncMock.mockReturnValue(false)
expect(() => resolveUnixShellPath('/missing/zsh')).toThrow(
'No executable Unix shell found (tried: /missing/zsh, /bin/zsh, /bin/bash, /bin/sh)'
)
})
it('dedupes a preferred shell that is already a fallback candidate', () => {
existsSyncMock.mockImplementation((path) => path === '/bin/sh')
accessSyncMock.mockReturnValue(undefined)
expect(resolveUnixShellPath('/bin/sh')).toBe('/bin/sh')
expect(existsSyncMock.mock.calls.map(([path]) => path)).toEqual(['/bin/sh'])
})
})
describe('spawnShellWithFallback macOS TCC login wrapping', () => {
let origPlatform: PropertyDescriptor | undefined
+23 -2
View File
@@ -1,4 +1,4 @@
import { basename, join } from 'node:path'
import { basename, isAbsolute, join } from 'node:path'
import { existsSync, accessSync, statSync, chmodSync, constants as fsConstants } from 'node:fs'
import type * as pty from 'node-pty'
import { isWslUncPath } from '../../shared/wsl-paths'
@@ -7,6 +7,8 @@ import { wrapShellSpawnForMacosTccAttribution } from './macos-tcc-login-shell'
let didEnsureSpawnHelperExecutable = false
const UNIX_SHELL_FALLBACKS = ['/bin/zsh', '/bin/bash', '/bin/sh'] as const
function toUnpackedAsarPath(candidate: string): string {
return candidate
.replace(/app\.asar([/\\])/, 'app.asar.unpacked$1')
@@ -46,6 +48,25 @@ export function getShellValidationError(shellPath: string): string | null {
return null
}
/**
* Resolves an absolute Unix shell before node-pty forks. Bare commands and
* relative paths stay untouched so execvp can resolve them against PATH or cwd.
*/
export function resolveUnixShellPath(shellPath: string): string {
if (!isAbsolute(shellPath)) {
return shellPath
}
const candidates = [
shellPath,
...UNIX_SHELL_FALLBACKS.filter((candidate) => candidate !== shellPath)
]
const resolved = candidates.find((candidate) => getShellValidationError(candidate) === null)
if (resolved) {
return resolved
}
throw new Error(`No executable Unix shell found (tried: ${candidates.join(', ')})`)
}
/**
* Ensure the node-pty spawn-helper binary has the executable bit set.
*
@@ -252,7 +273,7 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu
// Try fallback shells on Unix
if (process.platform !== 'win32') {
const fallbackShells = ['/bin/zsh', '/bin/bash', '/bin/sh'].filter((s) => s !== shellPath)
const fallbackShells = UNIX_SHELL_FALLBACKS.filter((candidate) => candidate !== shellPath)
for (const fallback of fallbackShells) {
if (getShellValidationError(fallback)) {
continue
@@ -214,6 +214,87 @@ describe('fetchClaudeRateLimits', () => {
})
})
it('maps active Fable usage from the scoped OAuth limits array without a PTY read', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
envPatch: { CLAUDE_CONFIG_DIR: configDir },
stripAuthEnv: false,
provenance: 'managed:account-1'
}
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce(
JSON.stringify({ claudeAiOauth: { accessToken: 'oauth-token' } })
)
netFetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
five_hour: { utilization: 36 },
seven_day: { utilization: 73 },
fable_weekly: { utilization: 12 },
limits: [
{ kind: 'weekly_scoped', percent: 55, scope: null },
{
kind: 'weekly_scoped',
percent: 100,
resets_at: '2026-07-17T20:00:00.099908+00:00',
is_active: true,
scope: { model: { display_name: 'Fable' } }
}
]
}),
{ status: 200 }
)
)
await expect(
fetchClaudeRateLimits({ authPreparation, allowUsagePanelSupplement: true })
).resolves.toMatchObject({
provider: 'claude',
status: 'ok',
fableWeekly: {
usedPercent: 100,
resetsAt: Date.parse('2026-07-17T20:00:00.099908+00:00')
},
usageMetadata: { attemptedSources: ['oauth'] }
})
expect(fetchViaPty).not.toHaveBeenCalled()
})
it('ignores inactive scoped Fable usage and retains the legacy OAuth fallback', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
configDir,
envPatch: { CLAUDE_CONFIG_DIR: configDir },
stripAuthEnv: false,
provenance: 'system'
}
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce(
JSON.stringify({ claudeAiOauth: { accessToken: 'oauth-token' } })
)
netFetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
five_hour: { utilization: 11 },
seven_day: { utilization: 22 },
fable_weekly: { utilization: 33 },
limits: [
{
kind: 'weekly_scoped',
percent: 90,
is_active: false,
scope: { model: { display_name: 'fable' } }
}
]
}),
{ status: 200 }
)
)
await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({
fableWeekly: { usedPercent: 33 }
})
})
it('supplements managed-account OAuth usage with Fable from the CLI usage panel', async () => {
const configDir = '/Users/test/.claude'
const authPreparation: ClaudeRuntimeAuthPreparation = {
+24 -2
View File
@@ -300,12 +300,21 @@ type OAuthUsageWindow = {
resets_at?: string | number
}
type OAuthUsageLimit = {
kind?: string
percent?: number
resets_at?: string | number
is_active?: boolean
scope?: { model?: { display_name?: string } | null } | null
}
type OAuthUsageResponse = {
five_hour?: OAuthUsageWindow
seven_day?: OAuthUsageWindow
fable_weekly?: OAuthUsageWindow
fable_seven_day?: OAuthUsageWindow
seven_day_fable?: OAuthUsageWindow
limits?: OAuthUsageLimit[] | null
}
type ClaudeUsageAttemptState = {
@@ -391,9 +400,22 @@ function mapWindow(
}
function mapFableWeeklyWindow(data: OAuthUsageResponse): RateLimitWindow | null {
// Why: a bare "fable" field does not prove the window length. Only accept
// explicit weekly/seven-day names for the distinct Fable meter.
// Why: model quotas moved into structured scoped limits; prefer that current
// contract while retaining explicit legacy weekly fields for older responses.
const scoped = Array.isArray(data.limits)
? data.limits.find(
(limit) =>
limit?.kind === 'weekly_scoped' &&
limit.is_active !== false &&
Number.isFinite(limit.percent) &&
limit.scope?.model?.display_name?.trim().toLowerCase() === 'fable'
)
: undefined
return (
mapWindow(
scoped ? { used_percentage: scoped.percent, resets_at: scoped.resets_at } : undefined,
10080
) ??
mapWindow(data.fable_weekly, 10080) ??
mapWindow(data.fable_seven_day, 10080) ??
mapWindow(data.seven_day_fable, 10080)
+259 -4
View File
@@ -30,10 +30,7 @@ import {
removeWorktree
} from '../git/worktree'
import * as gitRunner from '../git/runner'
import {
clearSubmodulePathsCacheForTests,
listSubmodulePaths
} from '../git/status'
import { clearSubmodulePathsCacheForTests, listSubmodulePaths } from '../git/status'
import {
createSetupRunnerScript,
getEffectiveHooks,
@@ -1179,6 +1176,67 @@ const store = {
getProjects: () => []
}
async function createExplicitAgentStatusHarness(options: {
getForegroundProcess: (ptyId: string) => Promise<string | null>
confirmForegroundProcess?: (ptyId: string) => Promise<string | null>
title?: string
}): Promise<{
runtime: OrcaRuntimeService
handle: string
syncPty: (ptyId: string | null) => void
}> {
const leafId = '11111111-1111-4111-8111-111111111111'
const paneKey = makePaneKey('tab-1', leafId)
const runtime = new OrcaRuntimeService(store, undefined, {
getAgentStatusSnapshot: () => [
{
paneKey,
state: 'working',
prompt: '',
agentType: 'codex',
connectionId: null,
receivedAt: Date.now(),
stateStartedAt: Date.now(),
tabId: 'tab-1',
worktreeId: TEST_WORKTREE_ID
}
]
})
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }),
write: () => true,
kill: () => true,
getForegroundProcess: options.getForegroundProcess,
confirmForegroundProcess: options.confirmForegroundProcess
})
runtime.attachWindow(1)
const syncPty = (ptyId: string | null): void => {
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-1',
worktreeId: TEST_WORKTREE_ID,
title: options.title ?? 'repo terminal',
activeLeafId: leafId,
layout: null
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: TEST_WORKTREE_ID,
leafId,
paneRuntimeId: 1,
ptyId
}
]
})
}
syncPty('pty-1')
const [terminal] = (await runtime.listTerminals()).terminals
return { runtime, handle: terminal.handle, syncPty }
}
function makeHeadlessTerminalLayout(
ptyIdsByLeafId: Record<string, string | undefined>
): TerminalLayoutSnapshot {
@@ -8486,6 +8544,203 @@ describe('OrcaRuntimeService', () => {
})
})
it('uses strong provider confirmation to authorize fresh hook state over a shell foreground', async () => {
const getForegroundProcess = vi.fn(async () => 'powershell.exe')
const confirmForegroundProcess = vi.fn(async () => 'claude')
const { runtime, handle } = await createExplicitAgentStatusHarness({
getForegroundProcess,
confirmForegroundProcess
})
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toEqual({
handle,
isRunningAgent: true,
status: 'working'
})
expect(getForegroundProcess).toHaveBeenCalledOnce()
expect(getForegroundProcess).toHaveBeenCalledWith('pty-1')
expect(confirmForegroundProcess).toHaveBeenCalledOnce()
expect(confirmForegroundProcess).toHaveBeenCalledWith('pty-1')
})
it('calls foreground confirmation with its controller receiver', async () => {
const getForegroundProcess = vi.fn(async () => 'powershell.exe')
const confirmForegroundProcess = vi.fn(
async function (this: { getForegroundProcess: typeof getForegroundProcess }) {
return this.getForegroundProcess === getForegroundProcess ? 'codex' : null
}
)
const { runtime, handle } = await createExplicitAgentStatusHarness({
getForegroundProcess,
confirmForegroundProcess
})
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({
isRunningAgent: true,
status: 'working'
})
expect(confirmForegroundProcess).toHaveBeenCalledOnce()
})
it.each([
['shell', async () => 'pwsh.exe'],
['non-agent', async () => 'vim'],
['unavailable', async () => null],
[
'failure',
async () => {
throw new Error('provider unavailable')
}
]
])('fails closed when shell-conflict confirmation returns %s', async (_case, confirm) => {
const confirmForegroundProcess = vi.fn(confirm)
const { runtime, handle } = await createExplicitAgentStatusHarness({
getForegroundProcess: async () => 'zsh',
confirmForegroundProcess
})
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toEqual({
handle,
isRunningAgent: false,
status: null
})
expect(confirmForegroundProcess).toHaveBeenCalledOnce()
})
it('fails closed on a shell conflict when the controller cannot confirm it', async () => {
const { runtime, handle } = await createExplicitAgentStatusHarness({
getForegroundProcess: async () => 'zsh'
})
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toEqual({
handle,
isRunningAgent: false,
status: null
})
})
it('skips strong confirmation when ordinary foreground evidence recognizes an agent', async () => {
const getForegroundProcess = vi.fn(async () => 'codex')
const confirmForegroundProcess = vi.fn(async () => 'codex')
const { runtime, handle } = await createExplicitAgentStatusHarness({
getForegroundProcess,
confirmForegroundProcess
})
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({
isRunningAgent: true,
status: 'working'
})
expect(getForegroundProcess).toHaveBeenCalledOnce()
expect(confirmForegroundProcess).not.toHaveBeenCalled()
})
it('skips both foreground reads when current title evidence blocks explicit hook state', async () => {
const getForegroundProcess = vi.fn(async () => 'zsh')
const confirmForegroundProcess = vi.fn(async () => 'codex')
const { runtime, handle } = await createExplicitAgentStatusHarness({
getForegroundProcess,
confirmForegroundProcess,
title: 'zsh'
})
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({
isRunningAgent: false,
status: null
})
expect(getForegroundProcess).not.toHaveBeenCalled()
expect(confirmForegroundProcess).not.toHaveBeenCalled()
})
it('skips foreground reads for permission title and blocked wait evidence', async () => {
for (const blocked of ['title', 'wait'] as const) {
const getForegroundProcess = vi.fn(async () => 'zsh')
const confirmForegroundProcess = vi.fn(async () => 'codex')
const { runtime, handle } = await createExplicitAgentStatusHarness({
getForegroundProcess,
confirmForegroundProcess
})
runtime.onPtyData(
'pty-1',
blocked === 'title'
? '\x1b]0;Codex waiting for permission\x07'
: 'Hooks need review. Press enter to confirm\n',
Date.now() + 1000
)
getForegroundProcess.mockClear()
confirmForegroundProcess.mockClear()
await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({
isRunningAgent: true,
status: 'permission'
})
expect(getForegroundProcess).not.toHaveBeenCalled()
expect(confirmForegroundProcess).not.toHaveBeenCalled()
}
})
it('rejects foreground evidence when the handle rebinds during the ordinary read', async () => {
const foreground = deferred<string | null>()
const getForegroundProcess = vi.fn(() => foreground.promise)
const confirmForegroundProcess = vi.fn(async () => 'codex')
const { runtime, handle, syncPty } = await createExplicitAgentStatusHarness({
getForegroundProcess,
confirmForegroundProcess
})
const status = runtime.getTerminalAgentStatus(handle)
await vi.waitFor(() => expect(getForegroundProcess).toHaveBeenCalledWith('pty-1'))
syncPty('pty-2')
foreground.resolve('zsh')
await expect(status).rejects.toThrow('terminal_handle_stale')
expect(confirmForegroundProcess).not.toHaveBeenCalled()
})
it('rejects a handle rebind while a controller-less status check yields', async () => {
const { runtime, handle, syncPty } = await createExplicitAgentStatusHarness({
getForegroundProcess: async () => 'zsh'
})
runtime.setPtyController(null)
const status = runtime.getTerminalAgentStatus(handle)
syncPty('pty-2')
await expect(status).rejects.toThrow('terminal_handle_stale')
})
it('rejects confirmation evidence when the handle rebinds during the fresh read', async () => {
const confirmation = deferred<string | null>()
const confirmForegroundProcess = vi.fn(() => confirmation.promise)
const { runtime, handle, syncPty } = await createExplicitAgentStatusHarness({
getForegroundProcess: async () => 'powershell.exe',
confirmForegroundProcess
})
const status = runtime.getTerminalAgentStatus(handle)
await vi.waitFor(() => expect(confirmForegroundProcess).toHaveBeenCalledWith('pty-1'))
syncPty('pty-2')
confirmation.resolve('codex')
await expect(status).rejects.toThrow('terminal_handle_stale')
})
it('rejects confirmation evidence when the owning PTY exits', async () => {
const confirmation = deferred<string | null>()
const confirmForegroundProcess = vi.fn(() => confirmation.promise)
const { runtime, handle } = await createExplicitAgentStatusHarness({
getForegroundProcess: async () => 'powershell.exe',
confirmForegroundProcess
})
const status = runtime.getTerminalAgentStatus(handle)
await vi.waitFor(() => expect(confirmForegroundProcess).toHaveBeenCalledWith('pty-1'))
runtime.onPtyExit('pty-1', 0)
confirmation.resolve('codex')
await expect(status).rejects.toThrow('terminal_exited')
})
it('reports permission from a title-derived action-required agent state', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
+70 -25
View File
@@ -853,7 +853,6 @@ type RuntimeStore = {
minimaxGroupId?: GlobalSettings['minimaxGroupId']
minimaxUsageModels?: GlobalSettings['minimaxUsageModels']
gitlabProjects?: GlobalSettings['gitlabProjects']
experimentalWorktreeSymlinks?: boolean
mobileAutoRestoreFitMs?: number | null
mobileEmulatorEnabled?: boolean
mobileEmulatorDefaultDeviceUdid?: string | null
@@ -1206,6 +1205,7 @@ type RuntimePtyController = {
stopAndWait?(ptyId: string, opts?: { keepHistory?: boolean }): Promise<boolean>
getCwd?(ptyId: string): Promise<string | null>
getForegroundProcess(ptyId: string): Promise<string | null>
confirmForegroundProcess?(ptyId: string): Promise<string | null>
hasChildProcesses?(ptyId: string): Promise<boolean>
clearBuffer?(ptyId: string): Promise<void>
resize?(ptyId: string, cols: number, rows: number): boolean
@@ -10227,7 +10227,8 @@ export class OrcaRuntimeService {
}
async getTerminalAgentStatus(handle: string): Promise<RuntimeTerminalAgentStatus> {
const terminal = this.getTerminalAgentStatusSnapshot(handle)
const ptyId = this.getTerminalAgentStatusPtyId(handle)
const terminal = this.getTerminalAgentStatusSnapshot(handle, ptyId)
const explicitStatus = this.getFreshExplicitAgentStatusForHandle(handle)
const blockedByWaitText = detectTerminalWaitBlockedReason(terminal.waitText)
const liveTitleClearsBlockedText =
@@ -10251,7 +10252,8 @@ export class OrcaRuntimeService {
// Fresh hook state is tighter, but current shell/management evidence wins.
const isRunningAgent =
!terminalTitleBlocksExplicitAgentStatus(terminal.title) &&
!(await this.terminalHasShellForegroundProcess(handle))
!(await this.terminalHasShellForegroundProcess(handle, ptyId))
this.assertTerminalAgentStatusPtyBinding(handle, ptyId)
return {
handle,
isRunningAgent,
@@ -10262,14 +10264,42 @@ export class OrcaRuntimeService {
return { handle, isRunningAgent: true, status: terminal.titleStatus }
}
return {
handle,
isRunningAgent: await this.isTerminalRunningAgent(handle),
status: null
}
const isRunningAgent = await this.isTerminalRunningAgent(handle)
this.assertTerminalAgentStatusPtyBinding(handle, ptyId)
return { handle, isRunningAgent, status: null }
}
private getTerminalAgentStatusSnapshot(handle: string): {
private getTerminalAgentStatusPtyId(handle: string): string {
const pty = this.getLivePtyForHandle(handle)
if (pty) {
if (!pty.pty.connected) {
throw new Error('terminal_gone')
}
return pty.pty.ptyId
}
const { leaf } = this.getLiveLeafForHandle(handle)
if (getTerminalState(leaf) !== 'running') {
throw new Error('terminal_exited')
}
if (!leaf.ptyId) {
throw new Error('terminal_gone')
}
return leaf.ptyId
}
private assertTerminalAgentStatusPtyBinding(handle: string, expectedPtyId: string): void {
if (this.getTerminalAgentStatusPtyId(handle) === expectedPtyId) {
return
}
// Why: delayed process evidence belongs only to the PTY that started the
// read, while callers still rely on the established stale-handle contract.
throw new Error('terminal_handle_stale')
}
private getTerminalAgentStatusSnapshot(
handle: string,
expectedPtyId: string
): {
waitText: string
waitBlockedAt: number | null
title: string | null
@@ -10278,8 +10308,8 @@ export class OrcaRuntimeService {
} {
const pty = this.getLivePtyForHandle(handle)
if (pty) {
if (!pty.pty.connected) {
throw new Error('terminal_gone')
if (!pty.pty.connected || pty.pty.ptyId !== expectedPtyId) {
throw new Error('terminal_not_writable')
}
const leaf = this.getPrimaryLeafForPty(pty.pty.ptyId)
const leafTitle = leaf
@@ -10317,6 +10347,9 @@ export class OrcaRuntimeService {
if (!leaf.ptyId) {
throw new Error('terminal_gone')
}
if (leaf.ptyId !== expectedPtyId) {
throw new Error('terminal_not_writable')
}
const title = getLatestAgentCandidateTitleInfo(
{ title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt },
{ title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt },
@@ -10331,21 +10364,36 @@ export class OrcaRuntimeService {
}
}
private async terminalHasShellForegroundProcess(handle: string): Promise<boolean> {
private async terminalHasShellForegroundProcess(handle: string, ptyId: string): Promise<boolean> {
if (!this.ptyController) {
return false
}
let foregroundProcess: string | null
try {
const pty = this.getLivePtyForHandle(handle)
const ptyId = pty?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId
if (!ptyId) {
return false
}
const foregroundProcess = await this.ptyController.getForegroundProcess(ptyId)
return foregroundProcess !== null && isShellProcess(foregroundProcess)
foregroundProcess = await this.ptyController.getForegroundProcess(ptyId)
} catch {
this.assertTerminalAgentStatusPtyBinding(handle, ptyId)
return false
}
this.assertTerminalAgentStatusPtyBinding(handle, ptyId)
if (!foregroundProcess || !isShellProcess(foregroundProcess)) {
return false
}
const confirmationController = this.ptyController
if (!confirmationController?.confirmForegroundProcess) {
return true
}
let confirmedProcess: string | null
try {
confirmedProcess = await confirmationController.confirmForegroundProcess(ptyId)
} catch {
this.assertTerminalAgentStatusPtyBinding(handle, ptyId)
return true
}
this.assertTerminalAgentStatusPtyBinding(handle, ptyId)
// Why: hook identity is generic; strong provider evidence only needs to
// prove that some recognized agent still owns this exact PTY.
return recognizeAgentProcess(confirmedProcess) === null
}
private shouldDelayPtyBackedMobileSnapshotForForegroundAgent(
@@ -15142,11 +15190,7 @@ export class OrcaRuntimeService {
warnings: lineageWarnings
} = this.recordCreatedWorktreeLineage(worktree, lineageResolution)
if (
settings.experimentalWorktreeSymlinks &&
repo.symlinkPaths &&
repo.symlinkPaths.length > 0
) {
if (repo.symlinkPaths && repo.symlinkPaths.length > 0) {
await createWorktreeLinkedPaths(repo.path, created.path, repo.symlinkPaths)
}
@@ -20881,7 +20925,8 @@ export class OrcaRuntimeService {
// status without throwing on stale handles, so this returns null on any error.
getAgentStatusForHandle(handle: string): string | null {
try {
return this.getTerminalAgentStatusSnapshot(handle).titleStatus
const ptyId = this.getTerminalAgentStatusPtyId(handle)
return this.getTerminalAgentStatusSnapshot(handle, ptyId).titleStatus
} catch {
return null
}
@@ -208,6 +208,7 @@ const UiUpdate = z
_antigravityStatusBarDefaultAdded: z.boolean().optional(),
_grokStatusBarDefaultAdded: z.boolean().optional(),
statusBarVisible: z.boolean().optional(),
usagePercentageDisplay: z.enum(['used', 'remaining']).optional(),
dismissedUpdateVersion: NullableString.optional(),
lastUpdateCheckAt: z.number().finite().nullable().optional(),
pendingUpdateNudgeId: NullableString.optional(),
@@ -238,6 +239,7 @@ const UiUpdate = z
trustedOrcaHooks: z.record(z.string(), z.unknown()).optional(),
setupScriptPromptDismissedRepoIds: StringArray.optional(),
projectOrderManualDefaultNoticeDismissed: z.boolean().optional(),
usagePercentageDisplayChangeNoticeDismissed: z.boolean().optional(),
usageEmptyStateDismissed: z.boolean().optional(),
petVisible: z.boolean().optional(),
petId: z.string().optional(),
+38 -1
View File
@@ -348,6 +348,31 @@ function isTerminalSendGuardNotWritable(error: unknown): boolean {
return message.includes('terminal_guard_not_writable')
}
function isTerminalAgentStatusNotWritable(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return [
'terminal_not_writable',
'terminal_handle_stale',
'terminal_gone',
'terminal_exited'
].some((code) => message.includes(code))
}
function assertTerminalSendExactPtyBinding(
runtime: OrcaRuntimeService,
handle: string,
expectedPtyId: string | undefined
): void {
try {
if (expectedPtyId && runtime.resolveLiveLeafForHandle(handle)?.ptyId === expectedPtyId) {
return
}
} catch {
// Fall through to the stable guarded-send result below.
}
throw new Error('terminal_guard_not_writable')
}
function appendPendingMultiplexOutput(
stream: TerminalMultiplexStream,
data: string,
@@ -1148,10 +1173,22 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
const assertSendPreconditions =
params.requireAgentStatus === 'sendable'
? async (ptyId?: string): Promise<void> => {
assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId)
if (ptyId && isTerminalInputLockedForClient(runtime, ptyId, params.client)) {
throw new Error('terminal_guard_not_writable')
}
const agentStatus = await runtime.getTerminalAgentStatus(params.terminal)
let agentStatus
try {
agentStatus = await runtime.getTerminalAgentStatus(params.terminal)
} catch (error) {
if (isTerminalAgentStatusNotWritable(error)) {
throw new Error('terminal_guard_not_writable')
}
throw error
}
// Why: a send callback can race a pane reconnect; status evidence
// must never authorize the callback's replacement PTY.
assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId)
if (!agentStatus.isRunningAgent) {
throw new Error('terminal_guard_no_agent')
}
@@ -525,6 +525,84 @@ describe('terminal send RPC', () => {
)
})
it('writes zero bytes when a guarded callback observes a handle rebind during status', async () => {
let boundPtyId = 'pty-1'
let statusCalls = 0
const write = vi.fn()
const runtime = stubRuntime({
resolveLiveLeafForHandle: vi.fn(() => ({ ptyId: boundPtyId })),
getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }),
getTerminalAgentStatus: vi.fn().mockImplementation(async () => {
statusCalls += 1
if (statusCalls === 2) {
boundPtyId = 'pty-2'
}
return {
handle: 'terminal-1',
isRunningAgent: true,
status: 'working'
}
}),
sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => {
await options.beforeWrite('pty-1')
write('pty-1', '\r')
return { handle: 'terminal-1', accepted: true, bytesWritten: 1 }
})
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const response = await dispatcher.dispatch(
makeRequest('terminal.send', {
terminal: 'terminal-1',
enter: true,
requireAgentStatus: 'sendable',
client: { id: 'desktop-1', type: 'desktop' }
})
)
expect(response).toMatchObject({
ok: true,
result: { send: { accepted: false, bytesWritten: 0 } }
})
expect(runtime.getTerminalAgentStatus).toHaveBeenCalledTimes(2)
expect(write).not.toHaveBeenCalled()
})
it('rejects a guarded callback whose actual write PTY differs from the handle binding', async () => {
const write = vi.fn()
const runtime = stubRuntime({
resolveLiveLeafForHandle: vi.fn(() => ({ ptyId: 'pty-1' })),
getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }),
getTerminalAgentStatus: vi.fn().mockResolvedValue({
handle: 'terminal-1',
isRunningAgent: true,
status: 'working'
}),
sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => {
await options.beforeWrite('pty-2')
write('pty-2', '\r')
return { handle: 'terminal-1', accepted: true, bytesWritten: 1 }
})
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const response = await dispatcher.dispatch(
makeRequest('terminal.send', {
terminal: 'terminal-1',
enter: true,
requireAgentStatus: 'sendable',
client: { id: 'desktop-1', type: 'desktop' }
})
)
expect(response).toMatchObject({
ok: true,
result: { send: { accepted: false, bytesWritten: 0 } }
})
expect(runtime.getTerminalAgentStatus).toHaveBeenCalledTimes(1)
expect(write).not.toHaveBeenCalled()
})
it('refuses guarded combined text and submit sends before any PTY write', async () => {
const runtime = stubRuntime({
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
+25
View File
@@ -1209,6 +1209,31 @@
background: color-mix(in srgb, var(--worktree-sidebar-foreground) 14%, var(--worktree-sidebar));
}
/* Why: one-shot status-bar callout is portaled fixed above the meters.
Opaque popover surface (not glass) so titlebar/terminal chrome cannot
bleed through; use design tokens + the documented floating elevation. */
.status-bar-change-notice-card {
border: 1px solid var(--border);
background: var(--popover);
color: var(--popover-foreground);
/* STYLEGUIDE: floating elevation for popovers that escape the editor surface. */
box-shadow: 0 10px 24px rgb(0 0 0 / 0.18);
}
/* Why: caret points down at the usage meters this notice explains. */
.status-bar-change-notice-card::after {
content: '';
position: absolute;
left: 1.25rem;
bottom: -6px;
width: 12px;
height: 12px;
transform: rotate(45deg);
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
background: var(--popover);
}
/* Why: the first-run Mobile Emulator intro sits directly under that menu row;
an upward caret aligned to the phone icon reads as a callout, not a footer. */
.mobile-emulator-tab-intro-callout--menu {
@@ -1026,6 +1026,27 @@
color: #58a6ff;
}
.rich-markdown-editor sup[data-rich-markdown-html-superscript-link] {
line-height: 0;
}
.rich-markdown-editor .rich-markdown-html-superscript-link {
color: #0969da;
text-decoration: underline;
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
text-underline-offset: 2px;
}
.dark .rich-markdown-editor .rich-markdown-html-superscript-link {
color: #58a6ff;
}
.rich-markdown-mod-held
.rich-markdown-editor
.rich-markdown-html-superscript-link[data-actionable] {
cursor: pointer;
}
.rich-markdown-editor img {
max-width: 100%;
border-radius: 8px;
@@ -7,6 +7,10 @@ import { LoaderCircle } from 'lucide-react'
import { createRichMarkdownExtensions } from '@/components/editor/rich-markdown-extensions'
import { encodeRawMarkdownHtmlForRichEditor } from '@/components/editor/raw-markdown-html'
import {
createRichMarkdownEditorCodec,
type RichMarkdownEditorCodec
} from '@/components/editor/rich-markdown-source-transport'
import { LinearIssueMarkdownToolbar } from '@/components/LinearIssueMarkdownToolbar'
import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import { cn } from '@/lib/utils'
@@ -26,8 +30,8 @@ type LinearIssueMarkdownDescriptionEditorProps = {
submitShortcutLabel: string
}
function createLinearIssueMarkdownExtensions() {
const extensions = createRichMarkdownExtensions()
function createLinearIssueMarkdownExtensions(codec: RichMarkdownEditorCodec) {
const extensions = createRichMarkdownExtensions({ codec })
return [
...extensions,
Placeholder.configure({
@@ -54,18 +58,24 @@ export function LinearIssueMarkdownDescriptionEditor({
const richMarkdownSpellcheckEnabled = useAppStore(
(s) => s.settings?.richMarkdownSpellcheckEnabled ?? true
)
// Why: changing language recreates Tiptap and re-registers tokenizers, so it
// must also receive a fresh private Marked registry instead of growing one.
const codec = useMemo(() => {
void language
return createRichMarkdownEditorCodec()
}, [language])
const linearIssueMarkdownExtensions = useMemo(() => {
// Why: Tiptap freezes extension options when the editor is created; the
// language value is the recreation key for translated extension options.
void language
return createLinearIssueMarkdownExtensions()
}, [language])
return createLinearIssueMarkdownExtensions(codec)
}, [codec, language])
const editor = useEditor(
{
immediatelyRender: false,
extensions: linearIssueMarkdownExtensions,
content: encodeRawMarkdownHtmlForRichEditor(value),
content: encodeRawMarkdownHtmlForRichEditor(value, codec),
contentType: 'markdown',
editable: !disabled,
editorProps: {
@@ -99,7 +109,7 @@ export function LinearIssueMarkdownDescriptionEditor({
onChange(nextValue)
}
},
[language]
[codec, language]
)
useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled)
@@ -124,12 +134,12 @@ export function LinearIssueMarkdownDescriptionEditor({
// Why: Linear remains the source of truth when the selected issue changes
// or an optimistic save is reverted; keep the rich view aligned with it.
editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(value), {
editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(value, codec), {
contentType: 'markdown',
emitUpdate: false
})
lastEditorMarkdownRef.current = value
}, [editor, value])
}, [codec, editor, value])
return (
<div
@@ -15,6 +15,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const createBrowserTabMock = vi.fn()
const setActiveWorktreeMock = vi.fn()
const openUrlMock = vi.fn()
const openFileUriMock = vi.fn()
const pathExistsMock = vi.fn(async () => true)
const connectionOwner = vi.hoisted(() => ({ value: null as string | null | undefined }))
const targetConnectionOwners = vi.hoisted(() => new Map<string, string | null | undefined>())
const worktreeLookup = vi.hoisted(() => ({
value: [] as { id: string; path: string; diffComments: never[] }[]
}))
const statRuntimePathMock = vi.hoisted(() => vi.fn(async () => ({ isDirectory: false })))
// Minimal store: MarkdownPreview reads settings/worktreesByRepo plus a handful
// of action functions. None of the actions fire on the http path under test.
@@ -31,6 +39,9 @@ const storeState = {
clearDeliveredDiffComments: vi.fn(),
keybindings: {},
worktreesByRepo: {},
repos: [],
folderWorkspaces: [],
projectGroups: [],
openFiles: [],
activeFileIdByWorktree: {},
settings: { openLinksInApp: true },
@@ -44,14 +55,22 @@ vi.mock('@/store', () => {
)
return { useAppStore }
})
vi.mock('@/store/slices/worktree-helpers', () => ({ findWorktreeById: () => null }))
vi.mock('@/store/slices/worktree-helpers', () => ({
findWorktreeById: (_worktrees: unknown, id: string) =>
worktreeLookup.value.find((worktree) => worktree.id === id) ?? null
}))
vi.mock('@/runtime/runtime-rpc-client', () => ({
settingsForRuntimeOwner: (settings: unknown) => settings
}))
vi.mock('@/runtime/runtime-file-client', () => ({
statRuntimePath: vi.fn(async () => ({ isDirectory: false }))
statRuntimePath: statRuntimePathMock
}))
vi.mock('@/lib/connection-context', () => ({
getConnectionIdForFile: (worktreeId: string) => targetConnectionOwners.get(worktreeId)
}))
vi.mock('@/lib/connection-owner-resolution', () => ({
createConnectionIdForFileSelector: () => () => connectionOwner.value
}))
vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null }))
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
vi.mock('./useLocalImageSrc', () => ({ useLocalImageSrc: (src?: string) => src }))
vi.mock('./MermaidBlock', () => ({ default: () => null }))
@@ -77,8 +96,8 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => {
;(window as unknown as { api: unknown }).api = {
shell: {
openUrl: openUrlMock,
openFileUri: vi.fn(),
pathExists: vi.fn(async () => true)
openFileUri: openFileUriMock,
pathExists: pathExistsMock
},
ui: { writeClipboardText: vi.fn(async () => true) }
}
@@ -91,6 +110,14 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => {
createBrowserTabMock.mockClear()
setActiveWorktreeMock.mockClear()
openUrlMock.mockClear()
openFileUriMock.mockClear()
pathExistsMock.mockClear()
connectionOwner.value = null
targetConnectionOwners.clear()
worktreeLookup.value = []
storeState.worktreesByRepo = {}
storeState.openMarkdownPreview.mockClear()
statRuntimePathMock.mockClear()
})
afterEach(() => {
@@ -100,21 +127,28 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => {
container.remove()
})
function render(): HTMLAnchorElement {
function render(
content = '[example](https://example.com)',
expectedHref = 'https://example.com',
sourceWorktreeId = 'wt-1',
filePath = '/repo/docs/README.md'
): HTMLAnchorElement {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root.render(
<MarkdownPreview
content="[example](https://example.com)"
filePath="/repo/docs/README.md"
sourceWorktreeId="wt-1"
content={content}
filePath={filePath}
sourceWorktreeId={sourceWorktreeId}
scrollCacheKey="test-key"
/>
)
})
const anchor = container.querySelector<HTMLAnchorElement>('a[href="https://example.com"]')
const anchor = Array.from(container.querySelectorAll<HTMLAnchorElement>('a')).find(
(candidate) => candidate.getAttribute('href') === expectedHref
)
if (!anchor) {
throw new Error('expected a rendered http anchor')
}
@@ -144,4 +178,59 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => {
expect(openUrlMock).toHaveBeenCalledWith('https://example.com/')
expect(createBrowserTabMock).not.toHaveBeenCalled()
})
it('keeps system-browser HTTP and file links inert while ownership is unknown', () => {
connectionOwner.value = undefined
const httpAnchor = render()
click(httpAnchor, { metaKey: true, shiftKey: true })
expect(openUrlMock).not.toHaveBeenCalled()
expect(createBrowserTabMock).not.toHaveBeenCalled()
act(() => {
root.unmount()
})
container.remove()
const fileAnchor = render('[file](file:///tmp/example.md)', 'file:///tmp/example.md')
click(fileAnchor, { metaKey: true, shiftKey: true })
expect(pathExistsMock).not.toHaveBeenCalled()
expect(openFileUriMock).not.toHaveBeenCalled()
})
it('keeps same-path file links on the explicit SSH source worktree', async () => {
const localWorktree = { id: 'local-wt', path: '/srv/repo', diffComments: [] as never[] }
const sshWorktree = { id: 'ssh-wt', path: '/srv/repo', diffComments: [] as never[] }
worktreeLookup.value = [localWorktree, sshWorktree]
storeState.worktreesByRepo = { repo: [localWorktree, sshWorktree] }
connectionOwner.value = 'ssh-1'
targetConnectionOwners.set('local-wt', null)
targetConnectionOwners.set('ssh-wt', 'ssh-1')
const anchor = render('[child](child.md)', 'child.md', 'ssh-wt', '/srv/repo/README.md')
await act(async () => {
anchor.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true }))
})
expect(statRuntimePathMock).toHaveBeenCalledWith(
expect.objectContaining({ worktreeId: 'ssh-wt', connectionId: 'ssh-1' }),
'/srv/repo/child.md'
)
expect(storeState.openMarkdownPreview).toHaveBeenCalledWith(
expect.objectContaining({ worktreeId: 'ssh-wt', filePath: '/srv/repo/child.md' }),
{ anchor: null }
)
})
it('renders a raw HTML superscript citation and routes it like a Markdown link', () => {
const source = '<sup><a href="https://example.com">[12]</a></sup>'
const anchor = render(source)
expect(anchor.parentElement?.tagName).toBe('SUP')
expect(anchor.textContent).toBe('[12]')
expect(container.textContent).not.toContain('<sup>')
click(anchor, { metaKey: true })
expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', {
activate: true
})
expect(openUrlMock).not.toHaveBeenCalled()
})
})
@@ -28,6 +28,9 @@ const storeState = {
clearDeliveredDiffComments: vi.fn(),
keybindings: {},
worktreesByRepo: {},
repos: [],
folderWorkspaces: [],
projectGroups: [],
openFiles: [],
activeFileIdByWorktree: {},
settings: { openLinksInApp: true },
@@ -48,7 +51,10 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({
vi.mock('@/runtime/runtime-file-client', () => ({
statRuntimePath: vi.fn(async () => ({ isDirectory: false }))
}))
vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null }))
vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: () => null }))
vi.mock('@/lib/connection-owner-resolution', () => ({
createConnectionIdForFileSelector: () => () => null
}))
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
vi.mock('./useLocalImageSrc', () => ({ useLocalImageSrc: (src?: string) => src }))
vi.mock('./MermaidBlock', () => ({ default: () => null }))
@@ -38,7 +38,8 @@ import { Input } from '@/components/ui/input'
import { useAppStore } from '@/store'
import { toast } from 'sonner'
import { computeEditorFontSize } from '@/lib/editor-font-zoom'
import { getConnectionId } from '@/lib/connection-context'
import { getConnectionIdForFile } from '@/lib/connection-context'
import { createConnectionIdForFileSelector } from '@/lib/connection-owner-resolution'
import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache'
import { detectLanguage } from '@/lib/language-detect'
import type { DiffComment, MarkdownDocument, Worktree } from '../../../../shared/types'
@@ -68,7 +69,7 @@ import {
setActiveMarkdownPreviewSearchMatch
} from './markdown-preview-search'
import { usePreserveSectionDuringExternalEdit } from './usePreserveSectionDuringExternalEdit'
import { openHttpLink } from '@/lib/http-link-routing'
import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
import { markdownPreviewUrlTransform } from './markdown-preview-url-transform'
@@ -390,14 +391,18 @@ export function deriveMarkdownPreviewSourceRoot(
function findWorktreeForMarkdownPreviewPath(
worktreesByRepo: Record<string, Worktree[]>,
absolutePath: string
absolutePath: string,
acceptsWorktree: (worktree: Worktree) => boolean = () => true
): Worktree | null {
let bestMatch: Worktree | null = null
let bestMatchLength = -1
for (const worktrees of Object.values(worktreesByRepo)) {
for (const worktree of worktrees) {
if (relativePathInsideRoot(worktree.path, absolutePath) !== null) {
if (
acceptsWorktree(worktree) &&
relativePathInsideRoot(worktree.path, absolutePath) !== null
) {
const normalizedWorktreePathLength = normalizeMarkdownPreviewAbsolutePath(
worktree.path
).length
@@ -412,6 +417,27 @@ function findWorktreeForMarkdownPreviewPath(
return bestMatch
}
function findMarkdownPreviewTargetWorktree(
worktreesByRepo: Record<string, Worktree[]>,
absolutePath: string,
sourceWorktree: Worktree | null,
sourceOwner: HttpLinkSourceOwner
): Worktree | null {
if (sourceWorktree && relativePathInsideRoot(sourceWorktree.path, absolutePath) !== null) {
return sourceWorktree
}
return findWorktreeForMarkdownPreviewPath(worktreesByRepo, absolutePath, (worktree) => {
const connectionId = getConnectionIdForFile(worktree.id, absolutePath)
if (sourceOwner.kind === 'local') {
return connectionId === null
}
if (sourceOwner.kind === 'ssh') {
return connectionId === sourceOwner.connectionId
}
return false
})
}
export function resolveMarkdownPreviewSourceWorktree(
worktreesByRepo: Record<string, Worktree[]>,
sourceWorktreeId: string | null | undefined,
@@ -498,9 +524,26 @@ export default function MarkdownPreview({
)
const allDiffComments = sourceWorktree?.diffComments
const sourceRoutingWorktreeId = sourceWorktree?.id ?? resolvedSourceWorktreeId
const sourceConnectionId = sourceRoutingWorktreeId
? (getConnectionId(sourceRoutingWorktreeId) ?? null)
: null
const runtimeOwnerId = resolvedSourceRuntimeEnvironmentId?.trim()
const sourceConnectionIdSelector = useMemo(
() =>
createConnectionIdForFileSelector(sourceRoutingWorktreeId, filePath, {
skip: Boolean(runtimeOwnerId)
}),
[filePath, runtimeOwnerId, sourceRoutingWorktreeId]
)
const sourceConnectionId = useAppStore(sourceConnectionIdSelector)
const sourceOwner = useMemo<HttpLinkSourceOwner>(
() =>
runtimeOwnerId
? { kind: 'runtime', runtimeEnvironmentId: runtimeOwnerId }
: sourceConnectionId === undefined
? { kind: 'unknown' }
: sourceConnectionId === null
? { kind: 'local' }
: { kind: 'ssh', connectionId: sourceConnectionId },
[runtimeOwnerId, sourceConnectionId]
)
const worktreeRoot =
sourceWorktree?.path ??
(sourceRoutingWorktreeId
@@ -1254,6 +1297,9 @@ export default function MarkdownPreview({
// dangling in-worktree .md, pre-check existence so the user sees a
// toast instead of the silent no-op from shell.openFileUri.
if (isMarkdownPreviewSystemBrowserModifier(event, isMac)) {
if (sourceOwner.kind === 'unknown') {
return
}
const osTarget = getMarkdownPreviewLinkTarget(href, filePath)
if (!osTarget) {
return
@@ -1267,7 +1313,12 @@ export default function MarkdownPreview({
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
openHttpLink(
parsed.toString(),
resolveMarkdownPreviewHttpOpenOptions(event, isMac, sourceRoutingWorktreeId)
resolveMarkdownPreviewHttpOpenOptions(
event,
isMac,
sourceRoutingWorktreeId,
sourceOwner
)
)
return
}
@@ -1326,7 +1377,12 @@ export default function MarkdownPreview({
// handled above; this path only sees non-escape-hatch clicks.)
openHttpLink(
target.toString(),
resolveMarkdownPreviewHttpOpenOptions(event, isMac, sourceRoutingWorktreeId)
resolveMarkdownPreviewHttpOpenOptions(
event,
isMac,
sourceRoutingWorktreeId,
sourceOwner
)
)
return
}
@@ -1347,12 +1403,25 @@ export default function MarkdownPreview({
? { line: classifiedFileTarget.line, column: classifiedFileTarget.column }
: parseLineTarget(target.hash)
// Why: same-file anchors need no ownership/filesystem resolution (e.g.
// `./README.md#heading` when this file is README.md). Run before the
// unknown-ownership guard so ambiguous folder-workspace ownership still
// scrolls within the open document.
if (absolutePath === filePath && target.hash && !lineTarget) {
void scrollToAnchor(target.hash.slice(1))
return
}
const targetWorktree = findWorktreeForMarkdownPreviewPath(worktreesByRepo, absolutePath)
if (sourceOwner.kind === 'unknown') {
return
}
const targetWorktree = findMarkdownPreviewTargetWorktree(
worktreesByRepo,
absolutePath,
sourceWorktree,
sourceOwner
)
if (!targetWorktree) {
if (sourceRoutingWorktreeId && worktreeRoot) {
// Why: floating markdown files are owned by a synthetic workspace,
@@ -1362,7 +1431,8 @@ export default function MarkdownPreview({
sourceFilePath: filePath,
worktreeId: sourceRoutingWorktreeId,
worktreeRoot,
runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId
runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId,
sourceOwner
})
return
}
@@ -1384,8 +1454,15 @@ export default function MarkdownPreview({
return
}
const relativePath = absolutePath.slice(targetWorktree.path.length + 1)
const relativePath = relativePathInsideRoot(targetWorktree.path, absolutePath)
if (relativePath === null) {
return
}
const language = detectLanguage(absolutePath)
const targetConnectionId = getConnectionIdForFile(targetWorktree.id, absolutePath)
if (targetConnectionId === undefined) {
return
}
try {
const stats = await statRuntimePath(
{
@@ -1395,7 +1472,7 @@ export default function MarkdownPreview({
),
worktreeId: targetWorktree.id,
worktreePath: targetWorktree.path,
connectionId: getConnectionId(targetWorktree.id) ?? undefined
connectionId: targetConnectionId ?? undefined
},
absolutePath
)
@@ -1512,7 +1589,8 @@ export default function MarkdownPreview({
sourceFilePath: filePath,
worktreeId: sourceRoutingWorktreeId,
worktreeRoot,
runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId
runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId,
sourceOwner
})
}
@@ -1673,6 +1751,8 @@ export default function MarkdownPreview({
setMarkdownViewMode,
setPendingEditorReveal,
sourceConnectionId,
sourceOwner,
sourceWorktree,
resolvedSourceRuntimeEnvironmentId,
sourceRoutingWorktreeId,
worktreeRoot,
@@ -1,9 +1,8 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Editor } from '@tiptap/react'
import { useEditorState, type Editor } from '@tiptap/react'
import type { DiffComment, MarkdownDocument } from '../../../../shared/types'
import { useAppStore } from '@/store'
import { selectWorktreeDiffComments } from '@/store/worktree-diff-comments-selector'
import { getIndexedWorktreeById } from '@/store/worktree-repo-index'
import { useLocalImagePick } from './useLocalImagePick'
import { useRichMarkdownSearch } from './useRichMarkdownSearch'
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
@@ -25,6 +24,11 @@ import {
runRichMarkdownContextCommand
} from './rich-markdown-context-command-routing'
import { useRichMarkdownSpellcheckAttribute } from './rich-markdown-spellcheck'
import { useRichMarkdownSuperscriptLinkSetup } from './useRichMarkdownSuperscriptLinkSetup'
import {
formatSelectedHtmlSuperscriptLinkStatus,
getSelectedHtmlSuperscriptLinkStatus
} from './rich-markdown-selected-link-actions'
type RichMarkdownEditorProps = {
fileId: string
@@ -86,9 +90,11 @@ export default function RichMarkdownEditor({
const allDiffComments = useAppStore((s): DiffComment[] | undefined =>
selectWorktreeDiffComments(s, worktreeId)
)
const worktreeRoot = useAppStore(
(s) => getIndexedWorktreeById(s.worktreesByRepo, worktreeId)?.path ?? null
)
const { codec, htmlSuperscriptLinkContext, worktreeRoot } = useRichMarkdownSuperscriptLinkSetup({
filePath,
runtimeEnvironmentId,
worktreeId
})
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
const menu = useRichMarkdownMenuController({ markdownDocuments })
const isMac = navigator.userAgent.includes('Mac')
@@ -196,6 +202,8 @@ export default function RichMarkdownEditor({
)
const editor = useRichMarkdownEditorInstance({
codec,
htmlSuperscriptLinkContext,
content,
filePath,
worktreeId,
@@ -240,6 +248,13 @@ export default function RichMarkdownEditor({
setSlashMenu: menu.setSlashMenu,
setDocLinkMenu: menu.setDocLinkMenu
})
// Why: useEditor defaults shouldRerenderOnTransaction to false, so selection-only
// citation NodeSelections would leave aria status stale without useEditorState.
const selectedCitationStatus = useEditorState({
editor,
selector: (snapshot) =>
getSelectedHtmlSuperscriptLinkStatus(snapshot.editor, htmlSuperscriptLinkContext)
})
useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled)
// Why: use useLayoutEffect (synchronous cleanup) so the pending serialization
@@ -265,6 +280,7 @@ export default function RichMarkdownEditor({
})
useRichMarkdownProgrammaticSync({
codec,
content,
docLinkMenuSetter: menu.setDocLinkMenu,
editor,
@@ -289,12 +305,14 @@ export default function RichMarkdownEditor({
handleLinkRemove,
handleLinkEditCancel,
handleLinkOpen,
handleLinkCopy,
toggleLinkFromToolbar
} = useLinkBubble(editor, rootRef, linkBubble, setLinkBubble, setIsEditingLink, {
sourceFilePath: filePath,
worktreeId,
worktreeRoot,
runtimeEnvironmentId
runtimeEnvironmentId,
htmlSuperscriptLinkContext
})
useEffect(() => {
@@ -370,6 +388,12 @@ export default function RichMarkdownEditor({
showTableOfContents={showTableOfContents}
searchState={searchState}
searchActions={searchActions}
citationStatus={
selectedCitationStatus
? formatSelectedHtmlSuperscriptLinkStatus(selectedCitationStatus)
: ''
}
linkBubbleOwnerId={codec.transport.key}
linkBubbleActions={{
dismissLinkBubble: () => {
setLinkBubble(null)
@@ -379,6 +403,7 @@ export default function RichMarkdownEditor({
handleLinkRemove,
handleLinkEditCancel,
handleLinkOpen,
handleLinkCopy,
setIsEditingLink
}}
onToggleLink={toggleLinkFromToolbar}
@@ -76,6 +76,7 @@ type RichMarkdownEditorSurfaceProps = {
matchCase: boolean
matchCount: number
replaceQuery: string
replaceDisabled: boolean
searchQuery: string
searchInputRef: React.RefObject<HTMLInputElement | null>
wholeWord: boolean
@@ -91,12 +92,15 @@ type RichMarkdownEditorSurfaceProps = {
toggleReplaceMode: () => void
toggleWholeWord: () => void
}
citationStatus: string
linkBubbleOwnerId: string
linkBubbleActions: {
dismissLinkBubble: () => void
handleLinkSave: (href: string) => void
handleLinkRemove: () => void
handleLinkEditCancel: () => void
handleLinkOpen: () => void
handleLinkCopy: () => void
setIsEditingLink: (editing: boolean) => void
}
onToggleLink: () => void
@@ -155,6 +159,8 @@ export function RichMarkdownEditorSurface({
showTableOfContents,
searchState,
searchActions,
citationStatus,
linkBubbleOwnerId,
linkBubbleActions,
onToggleLink,
onImagePick,
@@ -237,6 +243,7 @@ export function RichMarkdownEditorSurface({
matchCount={searchState.matchCount}
query={searchState.searchQuery}
replaceQuery={searchState.replaceQuery}
replaceDisabled={searchState.replaceDisabled}
searchInputRef={searchState.searchInputRef}
wholeWord={searchState.wholeWord}
onClose={searchActions.closeSearch}
@@ -262,8 +269,13 @@ export function RichMarkdownEditorSurface({
onEditStart={() => linkBubbleActions.setIsEditingLink(true)}
onEditCancel={linkBubbleActions.handleLinkEditCancel}
onOpen={linkBubbleActions.handleLinkOpen}
onCopy={linkBubbleActions.handleLinkCopy}
ownerId={linkBubbleOwnerId}
/>
) : null}
<span className="sr-only" role="status" aria-live="polite">
{citationStatus}
</span>
{slashMenu ? (
<RichMarkdownSlashMenu
editor={editor}
@@ -1,13 +1,19 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import type { Editor } from '@tiptap/react'
import { ExternalLink, Pencil, Unlink } from 'lucide-react'
import { Copy, ExternalLink, Pencil, Unlink } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
export type LinkBubbleState = {
kind: 'markdown' | 'html-superscript'
href: string
left: number
top: number
openEnabled: boolean
copyEnabled: boolean
label?: string
}
const LINK_BUBBLE_VIEWPORT_MARGIN = 8
@@ -160,6 +166,8 @@ type RichMarkdownLinkBubbleProps = {
onEditStart: () => void
onEditCancel: () => void
onOpen: () => void
onCopy: () => void
ownerId?: string
portalToDocument?: boolean
}
@@ -173,6 +181,8 @@ export function RichMarkdownLinkBubble({
onEditStart,
onEditCancel,
onOpen,
onCopy,
ownerId,
portalToDocument = false
}: RichMarkdownLinkBubbleProps): React.JSX.Element {
const bubbleRef = useRef<HTMLDivElement | null>(null)
@@ -270,6 +280,8 @@ export function RichMarkdownLinkBubble({
<div
ref={bubbleRef}
className="rich-markdown-link-bubble"
data-rich-markdown-link-bubble=""
data-rich-markdown-link-bubble-owner={ownerId}
style={positionStyle}
onMouseDown={(e) => {
// Prevent editor blur when clicking bubble buttons, but let inputs
@@ -278,7 +290,28 @@ export function RichMarkdownLinkBubble({
e.preventDefault()
}
}}
onKeyDown={(e) => e.stopPropagation()}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === 'Escape') {
event.preventDefault()
onDismiss()
anchorElement?.querySelector<HTMLElement>('[contenteditable="true"]')?.focus()
return
}
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') {
return
}
const buttons = Array.from(
bubbleRef.current?.querySelectorAll<HTMLButtonElement>('button:not([disabled])') ?? []
)
const currentIndex = buttons.indexOf(document.activeElement as HTMLButtonElement)
if (currentIndex === -1 || buttons.length === 0) {
return
}
event.preventDefault()
const direction = event.key === 'ArrowRight' ? 1 : -1
buttons[(currentIndex + direction + buttons.length) % buttons.length]?.focus()
}}
>
{isEditing ? (
<LinkEditInput initialHref={linkBubble.href} onSave={onSave} onCancel={onEditCancel} />
@@ -287,39 +320,45 @@ export function RichMarkdownLinkBubble({
<span className="rich-markdown-link-url" title={linkBubble.href}>
{linkBubble.href.length > 40 ? `${linkBubble.href.slice(0, 40)}…` : linkBubble.href}
</span>
<button
type="button"
className="rich-markdown-link-button"
onClick={onOpen}
title={translate(
<LinkBubbleAction
label={translate(
'auto.components.editor.RichMarkdownLinkBubble.bfc813e909',
'Open link'
)}
disabled={!linkBubble.openEnabled}
onClick={onOpen}
>
<ExternalLink size={14} />
</button>
<button
type="button"
className="rich-markdown-link-button"
onClick={onEditStart}
title={translate(
'auto.components.editor.RichMarkdownLinkBubble.cdfe166f6f',
'Edit link'
)}
</LinkBubbleAction>
<LinkBubbleAction
label={translate('auto.components.editor.RichMarkdownLinkBubble.copyLink', 'Copy link')}
disabled={!linkBubble.copyEnabled}
onClick={onCopy}
>
<Pencil size={14} />
</button>
<button
type="button"
className="rich-markdown-link-button"
onClick={onRemove}
title={translate(
'auto.components.editor.RichMarkdownLinkBubble.1c99b726e0',
'Remove link'
)}
>
<Unlink size={14} />
</button>
<Copy size={14} />
</LinkBubbleAction>
{linkBubble.kind === 'markdown' ? (
<>
<LinkBubbleAction
label={translate(
'auto.components.editor.RichMarkdownLinkBubble.cdfe166f6f',
'Edit link'
)}
onClick={onEditStart}
>
<Pencil size={14} />
</LinkBubbleAction>
<LinkBubbleAction
label={translate(
'auto.components.editor.RichMarkdownLinkBubble.1c99b726e0',
'Remove link'
)}
onClick={onRemove}
>
<Unlink size={14} />
</LinkBubbleAction>
</>
) : null}
</>
)}
</div>
@@ -329,3 +368,36 @@ export function RichMarkdownLinkBubble({
// actions must portal to the app layer to remain above the right sidebar.
return portalToDocument ? createPortal(bubble, document.body) : bubble
}
function LinkBubbleAction({
children,
disabled = false,
label,
onClick
}: {
children: React.ReactNode
disabled?: boolean
label: string
onClick: () => void
}): React.JSX.Element {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="rich-markdown-link-button"
aria-label={label}
disabled={disabled}
onClick={onClick}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
)
}
@@ -22,6 +22,7 @@ type RichMarkdownSearchBarProps = {
matchCount: number
query: string
replaceQuery: string
replaceDisabled: boolean
searchInputRef: React.RefObject<HTMLInputElement | null>
wholeWord: boolean
onClose: () => void
@@ -43,6 +44,7 @@ export function RichMarkdownSearchBar({
matchCount,
query,
replaceQuery,
replaceDisabled,
searchInputRef,
wholeWord,
onClose,
@@ -58,6 +60,7 @@ export function RichMarkdownSearchBar({
// Why: surface the same replace shortcut the source editor uses so the toggle
// is discoverable; reads the user's effective binding, formatted per platform.
const replaceShortcut = useOptionalShortcutLabel('editor.replace')
const readOnlyExplanationId = React.useId()
if (!isOpen) {
return null
@@ -71,6 +74,10 @@ export function RichMarkdownSearchBar({
}
const noMatches = matchCount === 0
const readOnlyReplaceExplanation = translate(
'auto.components.editor.RichMarkdownSearchBar.preservedRichContentReadOnly',
'Preserved rich content is read-only in rich mode.'
)
const toggleReplaceLabel = isReplaceMode
? translate('auto.components.editor.RichMarkdownSearchBar.e8c147435f', 'Hide replace')
: translate('auto.components.editor.RichMarkdownSearchBar.9cdc38be33', 'Toggle replace')
@@ -259,6 +266,7 @@ export function RichMarkdownSearchBar({
'auto.components.editor.RichMarkdownSearchBar.44682b4159',
'Replace in rich markdown editor'
)}
aria-describedby={replaceDisabled ? readOnlyExplanationId : undefined}
/>
</div>
<Button
@@ -267,11 +275,12 @@ export function RichMarkdownSearchBar({
size="icon-xs"
onMouseDown={keepSearchFocus}
onClick={onReplaceCurrent}
disabled={noMatches}
title={translate(
'auto.components.editor.RichMarkdownSearchBar.fd97c7e585',
'Replace'
)}
disabled={noMatches || replaceDisabled}
title={
replaceDisabled
? readOnlyReplaceExplanation
: translate('auto.components.editor.RichMarkdownSearchBar.fd97c7e585', 'Replace')
}
aria-label={translate(
'auto.components.editor.RichMarkdownSearchBar.fd97c7e585',
'Replace'
@@ -286,11 +295,15 @@ export function RichMarkdownSearchBar({
size="icon-xs"
onMouseDown={keepSearchFocus}
onClick={onReplaceAll}
disabled={noMatches}
title={translate(
'auto.components.editor.RichMarkdownSearchBar.c2884f5e95',
'Replace all'
)}
disabled={noMatches || replaceDisabled}
title={
replaceDisabled
? readOnlyReplaceExplanation
: translate(
'auto.components.editor.RichMarkdownSearchBar.c2884f5e95',
'Replace all'
)
}
aria-label={translate(
'auto.components.editor.RichMarkdownSearchBar.c2884f5e95',
'Replace all'
@@ -299,6 +312,11 @@ export function RichMarkdownSearchBar({
>
<ReplaceAll size={14} />
</Button>
{replaceDisabled ? (
<span id={readOnlyExplanationId} className="sr-only" role="status">
{readOnlyReplaceExplanation}
</span>
) : null}
</div>
) : null}
</div>
@@ -0,0 +1,22 @@
export type HtmlAttributeQuote = '"' | "'" | null
export function decodeHtmlTextCharacterReferences(value: string): string {
// Why: a <template> parses markup inertly (no script/resource execution), so
// reading textContent safely decodes character references without XSS risk.
const template = document.createElement('template')
template.innerHTML = value
return template.content.textContent ?? ''
}
export function decodeHtmlAttributeCharacterReferences(
value: string,
quote: HtmlAttributeQuote
): string {
// Why: inert <template> decoding; attribute write uses the caller-supplied
// quote and parser-constrained value, so this is not an injection sink.
const template = document.createElement('template')
const delimiter = quote ?? ''
template.innerHTML = `<span data-orca-value=${delimiter}${value}${delimiter}></span>`
const element = template.content.firstElementChild
return element?.getAttribute('data-orca-value') ?? ''
}
@@ -0,0 +1,9 @@
import { Markdown } from '@tiptap/markdown'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
export function createIsolatedMarkdownExtensionForTests() {
return Markdown.configure({
marked: createRichMarkdownEditorCodec().marked,
markedOptions: { gfm: true }
})
}
@@ -7,7 +7,7 @@ import { Table } from '@tiptap/extension-table'
import { TableCell } from '@tiptap/extension-table-cell'
import { TableHeader } from '@tiptap/extension-table-header'
import { TableRow } from '@tiptap/extension-table-row'
import { Markdown } from '@tiptap/markdown'
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
import { normalizeEmptyListItems } from './rich-markdown-normalize'
const testExtensions = [
@@ -18,7 +18,7 @@ const testExtensions = [
TableRow,
TableHeader,
TableCell,
Markdown.configure({ markedOptions: { gfm: true } })
createIsolatedMarkdownExtensionForTests()
]
function createEditor(markdown: string): Editor {
@@ -3,7 +3,10 @@ import {
filesystemPathToFileUri,
fileUriToFilesystemPath
} from '../../../../shared/file-uri-path'
import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path'
import {
isWindowsAbsolutePathLike,
relativePathInsideRoot
} from '../../../../shared/cross-platform-path'
// Pure classifier for markdown link targets. Called by the link-activation
// dispatcher (activateMarkdownLink slice action) from three call sites —
@@ -31,13 +34,6 @@ export type MarkdownLinkTarget =
column?: number
}
// Why: renderer runs with sandbox + contextIsolation, so process.platform is
// unavailable. navigator.userAgent is the portable fallback (AGENTS.md).
const ua = typeof navigator === 'undefined' ? '' : navigator.userAgent
const isMacLike = ua.includes('Mac')
const isWindowsLike = ua.includes('Windows')
const caseInsensitiveFs = isMacLike || isWindowsLike
const MARKDOWN_EXTENSIONS = new Set(['.md', '.mdx', '.markdown'])
export function absolutePathToFileUri(filePath: string): string {
@@ -60,17 +56,6 @@ function normalizePathForCompare(p: string): string {
return np
}
function isDescendantOf(childAbs: string, parentAbs: string): boolean {
const child = normalizePathForCompare(childAbs)
const parent = normalizePathForCompare(parentAbs)
if (caseInsensitiveFs) {
const lc = child.toLowerCase()
const lp = parent.toLowerCase()
return lc === lp || lc.startsWith(`${lp}/`)
}
return child === parent || child.startsWith(`${parent}/`)
}
function hasMarkdownExtension(p: string): boolean {
const lastDot = p.lastIndexOf('.')
if (lastDot === -1) {
@@ -125,25 +110,7 @@ function resolveRelativeToSource(rawHref: string, sourceFilePath: string): URL |
}
function computeRelativePath(absolutePath: string, worktreeRoot: string): string {
const parent = normalizePathForCompare(worktreeRoot)
const child = normalizePathForCompare(absolutePath)
const prefix = `${parent}/`
if (caseInsensitiveFs) {
if (child.toLowerCase() === parent.toLowerCase()) {
return ''
}
if (child.toLowerCase().startsWith(prefix.toLowerCase())) {
return child.slice(prefix.length)
}
} else {
if (child === parent) {
return ''
}
if (child.startsWith(prefix)) {
return child.slice(prefix.length)
}
}
return child
return relativePathInsideRoot(worktreeRoot, absolutePath) ?? normalizePathForCompare(absolutePath)
}
export function resolveMarkdownLinkTarget(
@@ -195,7 +162,7 @@ export function resolveMarkdownLinkTarget(
if (
worktreeRoot !== null &&
hasMarkdownExtension(pathForClassification) &&
isDescendantOf(pathForClassification, worktreeRoot)
relativePathInsideRoot(worktreeRoot, pathForClassification) !== null
) {
const relativePath = computeRelativePath(pathForClassification, worktreeRoot)
return {
@@ -208,7 +175,7 @@ export function resolveMarkdownLinkTarget(
}
const relativePath =
worktreeRoot !== null && isDescendantOf(pathForClassification, worktreeRoot)
worktreeRoot !== null && relativePathInsideRoot(worktreeRoot, pathForClassification) !== null
? computeRelativePath(pathForClassification, worktreeRoot)
: undefined
@@ -226,3 +193,52 @@ export function resolveMarkdownLinkTarget(
column
}
}
const HTML_ATTRIBUTE_WHITESPACE = /^[\t\n\f\r ]+|[\t\n\f\r ]+$/g
export function projectMarkdownHrefForClipboard(href: string): string | null {
const projected = href.replace(HTML_ATTRIBUTE_WHITESPACE, '')
if (!projected || containsAsciiControl(projected)) {
return null
}
if (
isWindowsAbsolutePathLike(projected) ||
projected.startsWith('\\\\') ||
projected.startsWith('//')
) {
return null
}
if (projected.startsWith('#')) {
return projected
}
const scheme = /^([A-Za-z][A-Za-z\d+.-]*):/.exec(projected)?.[1]?.toLowerCase()
if (!scheme) {
return projected
}
if (scheme !== 'http' && scheme !== 'https' && scheme !== 'file') {
return null
}
try {
const parsed = new URL(projected)
if (parsed.protocol !== `${scheme}:`) {
return null
}
if (scheme === 'file' && !parsed.pathname) {
return null
}
return projected
} catch {
return null
}
}
function containsAsciiControl(value: string): boolean {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (code <= 31 || code === 127) {
return true
}
}
return false
}
@@ -149,9 +149,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
resolveMarkdownPreviewHttpOpenOptions(
{ metaKey: true, ctrlKey: false, shiftKey: true },
true,
'wt-1'
'wt-1',
{ kind: 'local' }
)
).toEqual({ forceSystemBrowser: true })
).toEqual({ forceSystemBrowser: true, sourceOwner: { kind: 'local' } })
})
it('forces the system browser on Ctrl+Shift-click on Linux/Windows', () => {
@@ -159,9 +160,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
resolveMarkdownPreviewHttpOpenOptions(
{ metaKey: false, ctrlKey: true, shiftKey: true },
false,
'wt-1'
'wt-1',
{ kind: 'local' }
)
).toEqual({ forceSystemBrowser: true })
).toEqual({ forceSystemBrowser: true, sourceOwner: { kind: 'local' } })
})
it('routes a plain Cmd-click through the worktree so it can open in Orca', () => {
@@ -169,9 +171,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
resolveMarkdownPreviewHttpOpenOptions(
{ metaKey: true, ctrlKey: false, shiftKey: false },
true,
'wt-1'
'wt-1',
{ kind: 'local' }
)
).toEqual({ worktreeId: 'wt-1' })
).toEqual({ worktreeId: 'wt-1', sourceOwner: { kind: 'local' } })
})
it('routes a plain click (no modifier) through the worktree', () => {
@@ -179,9 +182,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
resolveMarkdownPreviewHttpOpenOptions(
{ metaKey: false, ctrlKey: false, shiftKey: false },
true,
'wt-1'
'wt-1',
{ kind: 'local' }
)
).toEqual({ worktreeId: 'wt-1' })
).toEqual({ worktreeId: 'wt-1', sourceOwner: { kind: 'local' } })
})
it('does not force the system browser for Shift without the platform mod key', () => {
@@ -190,9 +194,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
resolveMarkdownPreviewHttpOpenOptions(
{ metaKey: false, ctrlKey: false, shiftKey: true },
true,
'wt-1'
'wt-1',
{ kind: 'local' }
)
).toEqual({ worktreeId: 'wt-1' })
).toEqual({ worktreeId: 'wt-1', sourceOwner: { kind: 'local' } })
})
it('does not treat Mac Ctrl+Shift-click as the escape hatch', () => {
@@ -201,9 +206,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
resolveMarkdownPreviewHttpOpenOptions(
{ metaKey: false, ctrlKey: true, shiftKey: true },
true,
'wt-1'
'wt-1',
{ kind: 'local' }
)
).toEqual({ worktreeId: 'wt-1' })
).toEqual({ worktreeId: 'wt-1', sourceOwner: { kind: 'local' } })
})
it('passes through a null worktree (openHttpLink then falls back to system browser)', () => {
@@ -211,9 +217,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => {
resolveMarkdownPreviewHttpOpenOptions(
{ metaKey: false, ctrlKey: false, shiftKey: false },
true,
null
null,
{ kind: 'local' }
)
).toEqual({ worktreeId: null })
).toEqual({ worktreeId: null, sourceOwner: { kind: 'local' } })
})
})
@@ -4,7 +4,7 @@ import {
fileUriToFilesystemPath
} from '../../../../shared/file-uri-path'
import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path'
import type { OpenHttpLinkOptions } from '@/lib/http-link-routing'
import type { HttpLinkSourceOwner, OpenHttpLinkOptions } from '@/lib/http-link-routing'
function toFileUrl(filePath: string): string {
return filesystemPathToFileUri(filePath)
@@ -120,12 +120,13 @@ export function isMarkdownPreviewSystemBrowserModifier(
export function resolveMarkdownPreviewHttpOpenOptions(
event: Pick<MouseEvent, 'metaKey' | 'ctrlKey' | 'shiftKey'>,
isMac: boolean,
worktreeId: string | null
worktreeId: string | null,
sourceOwner: HttpLinkSourceOwner
): OpenHttpLinkOptions {
if (isMarkdownPreviewSystemBrowserModifier(event, isMac)) {
return { forceSystemBrowser: true }
return { forceSystemBrowser: true, sourceOwner }
}
return { worktreeId }
return { worktreeId, sourceOwner }
}
/**
@@ -2,14 +2,16 @@ import { describe, expect, it } from 'vitest'
import { Editor } from '@tiptap/core'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import type { SlashCommandId } from './rich-markdown-slash-commands'
import { slashCommands } from './rich-markdown-slash-commands'
function roundTripMarkdown(content: string): string {
const codec = createRichMarkdownEditorCodec()
const editor = new Editor({
element: null,
extensions: createRichMarkdownExtensions(),
content: encodeRawMarkdownHtmlForRichEditor(content),
extensions: createRichMarkdownExtensions({ codec }),
content: encodeRawMarkdownHtmlForRichEditor(content, codec),
contentType: 'markdown'
})
@@ -21,10 +23,11 @@ function roundTripMarkdown(content: string): string {
}
function markdownAfterTextReplace(content: string, search: string, replacement: string): string {
const codec = createRichMarkdownEditorCodec()
const editor = new Editor({
element: null,
extensions: createRichMarkdownExtensions(),
content: encodeRawMarkdownHtmlForRichEditor(content),
extensions: createRichMarkdownExtensions({ codec }),
content: encodeRawMarkdownHtmlForRichEditor(content, codec),
contentType: 'markdown'
})
@@ -50,9 +53,10 @@ function markdownAfterTextReplace(content: string, search: string, replacement:
}
function slashCommandMarkdown(commandId: SlashCommandId): string {
const codec = createRichMarkdownEditorCodec()
const editor = new Editor({
element: null,
extensions: createRichMarkdownExtensions(),
extensions: createRichMarkdownExtensions({ codec }),
content: '',
contentType: 'markdown'
})
@@ -71,9 +75,10 @@ function slashCommandMarkdown(commandId: SlashCommandId): string {
}
function slashCommandSelectionParent(commandId: SlashCommandId): string {
const codec = createRichMarkdownEditorCodec()
const editor = new Editor({
element: null,
extensions: createRichMarkdownExtensions(),
extensions: createRichMarkdownExtensions({ codec }),
content: '',
contentType: 'markdown'
})
@@ -1,11 +1,9 @@
import { Editor } from '@tiptap/core'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
// Why: extensions are lazily created on first use to avoid eager instantiation
// at import time. A single shared instance is safe here because only one
// round-trip Editor is alive at a time (created and destroyed synchronously).
let roundTripExtensions: ReturnType<typeof createRichMarkdownExtensions> | null = null
const roundTripCache = new Map<string, string | null>()
const MAX_CACHE_ENTRIES = 20
@@ -23,13 +21,23 @@ export function getRichMarkdownRoundTripOutput(content: string): string | null {
let output: string | null = null
try {
if (!roundTripExtensions) {
roundTripExtensions = createRichMarkdownExtensions()
}
const codec = createRichMarkdownEditorCodec()
const context = createRichMarkdownHtmlSuperscriptLinkContext({
sourceFilePath: '',
worktreeId: '',
worktreeRoot: null,
sourceOwner: { kind: 'unknown' }
})
const editor = new Editor({
element: null,
extensions: roundTripExtensions,
content: encodeRawMarkdownHtmlForRichEditor(content),
extensions: createRichMarkdownExtensions({
codec,
htmlSuperscriptLinks: true,
htmlSuperscriptLinkContext: context
}),
content: encodeRawMarkdownHtmlForRichEditor(content, codec, {
htmlSuperscriptLinks: true
}),
contentType: 'markdown'
})
try {
@@ -2,53 +2,16 @@ import { Node, mergeAttributes } from '@tiptap/core'
import { isEditableDetailsHtmlBlock, matchDetailsHtmlBlock } from './details-markdown-html'
import { formatMarkdownDocLinkBody, parseMarkdownDocLink } from './markdown-doc-links'
import { normalizeMarkdownReferenceLinks } from './markdown-reference-link-normalization'
const INLINE_PLACEHOLDER_PREFIX = '[[ORCA_RAW_HTML_INLINE:'
const BLOCK_PLACEHOLDER_PREFIX = '[[ORCA_RAW_HTML_BLOCK:'
const DOC_LINK_PLACEHOLDER_PREFIX = '[[ORCA_DOC_LINK:'
const PLACEHOLDER_SUFFIX = ']]'
import type {
RichMarkdownEditorCodec,
RichMarkdownSourceKind,
RichMarkdownSourceTransport
} from './rich-markdown-source-transport'
import { isReservedRichMarkdownTransportBody } from './rich-markdown-source-transport'
import { matchHtmlSuperscriptLinkSource } from './rich-markdown-html-superscript-link-source'
const INLINE_HTML_PATTERN = /^<!--[\s\S]*?-->|^<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*?)?\/?>/
function encodeHtmlPayload(raw: string): string {
return encodeURIComponent(raw)
}
function decodeHtmlPayload(payload: string): string {
try {
return decodeURIComponent(payload)
} catch {
return ''
}
}
function createPlaceholder(kind: 'inline' | 'block', raw: string): string {
const prefix = kind === 'inline' ? INLINE_PLACEHOLDER_PREFIX : BLOCK_PLACEHOLDER_PREFIX
return `${prefix}${encodeHtmlPayload(raw)}${PLACEHOLDER_SUFFIX}`
}
function matchPlaceholder(
src: string,
kind: 'inline' | 'block'
): { placeholder: string; value: string } | null {
const prefix = kind === 'inline' ? INLINE_PLACEHOLDER_PREFIX : BLOCK_PLACEHOLDER_PREFIX
if (!src.startsWith(prefix)) {
return null
}
const endIndex = src.indexOf(PLACEHOLDER_SUFFIX, prefix.length)
if (endIndex === -1) {
return null
}
const placeholder = src.slice(0, endIndex + PLACEHOLDER_SUFFIX.length)
const payload = src.slice(prefix.length, endIndex)
return {
placeholder,
value: decodeHtmlPayload(payload)
}
}
function matchInlineHtml(src: string): string | null {
const match = src.match(INLINE_HTML_PATTERN)
return match?.[0] ?? null
@@ -90,8 +53,13 @@ function matchBlockHtml(content: string, start: number): string | null {
return line
}
export function encodeRawMarkdownHtmlForRichEditor(content: string): string {
export function encodeRawMarkdownHtmlForRichEditor(
content: string,
codec: RichMarkdownEditorCodec,
{ htmlSuperscriptLinks = false }: { htmlSuperscriptLinks?: boolean } = {}
): string {
const normalizedContent = normalizeMarkdownReferenceLinks(content)
const { transport } = codec
let index = 0
let isLineStart = true
let activeFence: '`' | '~' | null = null
@@ -170,47 +138,65 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string {
}
if (detailsHtml) {
result += createPlaceholder('block', detailsHtml.raw)
result += transport.create('block-html', detailsHtml.raw)
index += detailsHtml.raw.length
continue
}
const blockHtml = matchBlockHtml(normalizedContent, index)
if (blockHtml) {
result += createPlaceholder('block', blockHtml)
result += transport.create('block-html', blockHtml)
index += blockHtml.length
continue
}
}
// Why: authored text that happens to contain this editor's random envelope
// prefix must remain literal even in HTML-free documents and after edits.
if (normalizedContent.startsWith(transport.authoredPrefix, index)) {
const authoredEnd = normalizedContent.indexOf(']]', index + transport.authoredPrefix.length)
const authoredOccurrence =
authoredEnd === -1
? transport.authoredPrefix
: normalizedContent.slice(index, authoredEnd + 2)
result += transport.create('literal', authoredOccurrence)
index += authoredOccurrence.length
continue
}
if (normalizedContent[index] === '<' && !isEscaped(normalizedContent, index)) {
if (htmlSuperscriptLinks) {
const superscriptLink = matchHtmlSuperscriptLinkSource(normalizedContent, index)
if (superscriptLink) {
result += transport.create('html-superscript-link', JSON.stringify(superscriptLink.value))
index = superscriptLink.end
continue
}
}
const inlineHtml = matchInlineHtml(normalizedContent.slice(index))
if (inlineHtml) {
result += createPlaceholder('inline', inlineHtml)
result += transport.create('inline-html', inlineHtml)
index += inlineHtml.length
continue
}
}
// Why: doc link encoding runs inside the same while loop (not a separate
// pre-pass) so that fenced code blocks and backtick code spans are already
// skipped by the guards above. The [[ORCA_ prefix check prevents re-encoding
// sibling placeholders that were already emitted earlier in this pass.
// Why: doc link encoding runs inside this loop so fenced code and backtick
// spans have already been excluded from semantic preprocessing.
if (
normalizedContent[index] === '[' &&
normalizedContent[index + 1] === '[' &&
!normalizedContent.startsWith('[[ORCA_', index) &&
!isEscaped(normalizedContent, index)
) {
const closingIndex = normalizedContent.indexOf(']]', index + 2)
if (closingIndex !== -1) {
const rawTarget = normalizedContent.slice(index + 2, closingIndex)
const link = parseMarkdownDocLink(rawTarget)
if (link) {
result += `${DOC_LINK_PLACEHOLDER_PREFIX}${formatMarkdownDocLinkBody(
link.target,
link.alias
)}${PLACEHOLDER_SUFFIX}`
if (link && !isReservedRichMarkdownTransportBody(rawTarget)) {
result += transport.create(
'document-link',
formatMarkdownDocLinkBody(link.target, link.alias)
)
index = closingIndex + 2
continue
}
@@ -226,129 +212,123 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string {
return result
}
export const RawMarkdownHtmlInline = Node.create({
name: 'rawMarkdownHtmlInline',
inline: true,
group: 'inline',
atom: true,
selectable: true,
export function createRichMarkdownLiteral(transport: RichMarkdownSourceTransport) {
return createRawSourceNode({
name: 'richMarkdownLiteral',
kind: 'literal',
inline: true,
transport,
marker: 'data-rich-markdown-literal'
})
}
addAttributes() {
return {
value: {
default: ''
}
}
},
// Why: converting embedded HTML tags into placeholder tokens before the
// markdown parser runs keeps marked's built-in paragraph tokenization intact
// while still letting Orca round-trip the raw markup verbatim.
markdownTokenName: 'rawMarkdownHtmlInline',
markdownTokenizer: {
export function createRawMarkdownHtmlInline(transport: RichMarkdownSourceTransport) {
return createRawSourceNode({
name: 'rawMarkdownHtmlInline',
level: 'inline',
start: INLINE_PLACEHOLDER_PREFIX,
tokenize(src) {
const matched = matchPlaceholder(src, 'inline')
if (!matched) {
return undefined
}
kind: 'inline-html',
inline: true,
transport,
marker: 'data-raw-markdown-html-inline',
className: 'raw-markdown-html-inline'
})
}
function createRawSourceNode({
name,
kind,
inline,
transport,
marker,
className
}: {
name: string
kind: RichMarkdownSourceKind
inline: boolean
transport: RichMarkdownSourceTransport
marker: string
className?: string
}) {
return Node.create({
name,
inline,
group: inline ? 'inline' : 'block',
atom: true,
selectable: true,
addAttributes() {
return {
type: 'rawMarkdownHtmlInline',
raw: matched.placeholder,
text: matched.value
value: {
default: '',
rendered: false
}
}
}
},
parseMarkdown: (token, helpers) => {
if (token.type !== 'rawMarkdownHtmlInline') {
return []
}
},
return helpers.createNode('rawMarkdownHtmlInline', {
value: typeof token.text === 'string' ? token.text : ''
})
},
renderMarkdown: (node) => (typeof node.attrs?.value === 'string' ? node.attrs.value : ''),
// Why: converting embedded HTML tags into placeholder tokens before the
// markdown parser runs keeps marked's built-in paragraph tokenization intact
// while still letting Orca round-trip the raw markup verbatim.
markdownTokenName: name,
markdownTokenizer: {
name,
level: inline ? 'inline' : 'block',
start: transport.startFor(kind),
tokenize(src) {
const matched = transport.match(src, kind)
if (!matched) {
return undefined
}
parseHTML() {
return [{ tag: 'span[data-raw-markdown-html-inline]' }]
},
renderHTML({ HTMLAttributes, node }) {
const value = typeof node.attrs.value === 'string' ? node.attrs.value : ''
return [
'span',
mergeAttributes(HTMLAttributes, {
'data-raw-markdown-html-inline': '',
contenteditable: 'false',
class: 'raw-markdown-html-inline'
}),
value
]
}
})
export const RawMarkdownHtmlBlock = Node.create({
name: 'rawMarkdownHtmlBlock',
group: 'block',
atom: true,
selectable: true,
addAttributes() {
return {
value: {
default: ''
return {
type: name,
raw: matched.raw,
text: matched.value,
block: !inline
}
}
},
parseMarkdown: (token, helpers) => {
if (token.type !== name) {
return []
}
}
},
markdownTokenName: 'rawMarkdownHtmlBlock',
markdownTokenizer: {
return helpers.createNode(name, {
value: typeof token.text === 'string' ? token.text : ''
})
},
renderMarkdown: (node) => (typeof node.attrs?.value === 'string' ? node.attrs.value : ''),
renderText: ({ node }) => (typeof node.attrs.value === 'string' ? node.attrs.value : ''),
parseHTML() {
return [
{
tag: `${inline ? 'span' : 'div'}[${marker}]`,
getAttrs: (element: HTMLElement) => ({ value: element.textContent ?? '' })
}
]
},
renderHTML({ HTMLAttributes, node }) {
const value = typeof node.attrs.value === 'string' ? node.attrs.value : ''
return [
inline ? 'span' : 'div',
mergeAttributes(HTMLAttributes, {
[marker]: '',
contenteditable: 'false',
class: className
}),
inline ? value : ['pre', value]
]
}
})
}
export function createRawMarkdownHtmlBlock(transport: RichMarkdownSourceTransport) {
return createRawSourceNode({
name: 'rawMarkdownHtmlBlock',
level: 'block',
start: BLOCK_PLACEHOLDER_PREFIX,
tokenize(src) {
const matched = matchPlaceholder(src, 'block')
if (!matched) {
return undefined
}
return {
type: 'rawMarkdownHtmlBlock',
raw: matched.placeholder,
text: matched.value,
block: true
}
}
},
parseMarkdown: (token, helpers) => {
if (token.type !== 'rawMarkdownHtmlBlock') {
return []
}
return helpers.createNode('rawMarkdownHtmlBlock', {
value: typeof token.text === 'string' ? token.text : ''
})
},
renderMarkdown: (node) => (typeof node.attrs?.value === 'string' ? node.attrs.value : ''),
parseHTML() {
return [{ tag: 'div[data-raw-markdown-html-block]' }]
},
renderHTML({ HTMLAttributes, node }) {
const value = typeof node.attrs.value === 'string' ? node.attrs.value : ''
return [
'div',
mergeAttributes(HTMLAttributes, {
'data-raw-markdown-html-block': '',
contenteditable: 'false',
class: 'raw-markdown-html-block'
}),
['pre', value]
]
}
})
kind: 'block-html',
inline: false,
transport,
marker: 'data-raw-markdown-html-block',
className: 'raw-markdown-html-block'
})
}
@@ -0,0 +1,40 @@
import type { Editor } from '@tiptap/react'
import { NodeSelection } from '@tiptap/pm/state'
export function handleRichMarkdownCitationKey({
editor,
event,
linkBubbleOwnerId,
onOpen
}: {
editor: Editor | null
event: KeyboardEvent
linkBubbleOwnerId: string
onOpen?: () => boolean
}): boolean {
const selection = editor?.state?.selection
if (
!(selection instanceof NodeSelection) ||
selection.node.type.name !== 'richMarkdownHtmlSuperscriptLink' ||
event.isComposing ||
editor?.view.composing === true
) {
return false
}
if (event.key === 'Enter') {
event.preventDefault()
return onOpen?.() ?? true
}
if (event.key !== 'Tab' || event.shiftKey) {
return false
}
const firstAction = document.querySelector<HTMLButtonElement>(
`[data-rich-markdown-link-bubble-owner="${linkBubbleOwnerId}"] button:not([disabled])`
)
if (!firstAction) {
return false
}
event.preventDefault()
firstAction.focus()
return true
}
@@ -0,0 +1,17 @@
import { DOMSerializer, type Slice } from '@tiptap/pm/model'
import type { EditorView } from '@tiptap/pm/view'
export function serializeRichMarkdownSliceForClipboard(
view: EditorView,
slice: Slice
): { html: string } {
if (typeof view.serializeForClipboard === 'function') {
return { html: view.serializeForClipboard(slice).dom.innerHTML }
}
// Why: lightweight unit-test views predate ProseMirror's public clipboard
// serializer; production always takes the metadata-preserving branch above.
const fragment = DOMSerializer.fromSchema(view.state.schema).serializeFragment(slice.content)
const container = document.createElement('div')
container.appendChild(fragment)
return { html: container.innerHTML }
}
@@ -0,0 +1,37 @@
import type { Slice } from '@tiptap/pm/model'
import type { EditorView } from '@tiptap/pm/view'
import { serializeRichMarkdownSliceForClipboard } from './rich-markdown-clipboard-serialization'
import { inspectRichMarkdownSourceOwningSlice } from './rich-markdown-source-owning-slice'
import { showRichMarkdownSourceOwningCutLimitError } from './rich-markdown-source-owning-cut-feedback'
/**
* Writes a rich-markdown slice to the clipboard (HTML + plain), verifying
* readback. Returns false when the write must not be followed by a delete
* (source-owning limit or clipboard rejection).
*/
export function writeRichMarkdownSliceToClipboard(
clipboardData: DataTransfer,
view: EditorView,
slice: Slice,
visibleText: string
): boolean {
const status = inspectRichMarkdownSourceOwningSlice(slice)
if (status.containsSourceOwningNode && !status.canPreserve) {
showRichMarkdownSourceOwningCutLimitError()
return false
}
const serialized = serializeRichMarkdownSliceForClipboard(view, slice)
clipboardData.setData('text/html', serialized.html)
clipboardData.setData('text/plain', visibleText)
// Why: if the clipboard rejected the write we must not delete, and we must
// surface the same cut-limit feedback so the no-op is not silent.
if (
typeof clipboardData.getData === 'function' &&
(clipboardData.getData('text/html') !== serialized.html ||
clipboardData.getData('text/plain') !== visibleText)
) {
showRichMarkdownSourceOwningCutLimitError()
return false
}
return true
}
@@ -1,12 +1,13 @@
import { describe, expect, it } from 'vitest'
import { Editor } from '@tiptap/core'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import { runSlashCommand, slashCommands, type SlashCommandId } from './rich-markdown-slash-commands'
function createEditor(content = '/'): Editor {
return new Editor({
element: null,
extensions: createRichMarkdownExtensions(),
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
content,
contentType: 'markdown'
})
@@ -1,7 +1,10 @@
import { DOMSerializer } from '@tiptap/pm/model'
import { TextSelection } from '@tiptap/pm/state'
import type { EditorView } from '@tiptap/pm/view'
import { writeRichMarkdownSliceToClipboard } from './rich-markdown-clipboard-write'
import { cutVisualLine, getVisualLineRange } from './rich-markdown-visual-line'
import { createRichMarkdownVisibleTextMap } from './rich-markdown-visible-text-map'
import { inspectRichMarkdownSourceOwningSlice } from './rich-markdown-source-owning-slice'
import { showRichMarkdownSourceOwningCutLimitError } from './rich-markdown-source-owning-cut-feedback'
function deleteBlockAndRestoreSelection(view: EditorView, from: number, to: number): void {
let tr = view.state.tr.delete(from, to)
@@ -24,6 +27,12 @@ function deleteBlockAndRestoreSelection(view: EditorView, from: number, to: numb
export function handleRichMarkdownCut(view: EditorView, event: ClipboardEvent): boolean {
const { selection } = view.state
if (!selection.empty) {
const status = inspectRichMarkdownSourceOwningSlice(selection.content())
if (status.containsSourceOwningNode && !status.canPreserve) {
event.preventDefault()
showRichMarkdownSourceOwningCutLimitError()
return true
}
return false
}
@@ -54,7 +63,9 @@ export function handleRichMarkdownCut(view: EditorView, event: ClipboardEvent):
}
const cutNode = $from.node(cutDepth)
const text = cutNode.textContent
const contentFrom = $from.start(cutDepth)
const contentTo = $from.end(cutDepth)
const text = createRichMarkdownVisibleTextMap(view.state.doc, contentFrom, contentTo).text
// Why: for paragraphs that word-wrap across multiple visual lines, cut
// only the visual line the cursor is on rather than the entire paragraph.
@@ -85,15 +96,10 @@ export function handleRichMarkdownCut(view: EditorView, event: ClipboardEvent):
}
event.preventDefault()
// Why: writing both text/html and text/plain preserves inline formatting
// (bold, italic, links) on round-trip cut-then-paste, while still giving
// a plain-text fallback for external targets.
const serializer = DOMSerializer.fromSchema(view.state.schema)
const fragment = serializer.serializeFragment(cutNode.content)
const div = document.createElement('div')
div.appendChild(fragment)
event.clipboardData.setData('text/html', div.innerHTML)
event.clipboardData.setData('text/plain', text)
const slice = view.state.doc.slice($from.before(cutDepth), $from.after(cutDepth))
if (!writeRichMarkdownSliceToClipboard(event.clipboardData, view, slice, text)) {
return true
}
deleteBlockAndRestoreSelection(view, $from.before(cutDepth), $from.after(cutDepth))
@@ -10,10 +10,16 @@ import { Table } from '@tiptap/extension-table'
import { TableCell } from '@tiptap/extension-table-cell'
import { TableHeader } from '@tiptap/extension-table-header'
import { TableRow } from '@tiptap/extension-table-row'
import { Markdown } from '@tiptap/markdown'
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
import { handleRichMarkdownCut } from './rich-markdown-cut-handler'
import { normalizeEmptyListItems, normalizeSoftBreaks } from './rich-markdown-normalize'
const showCutLimitErrorMock = vi.hoisted(() => vi.fn())
vi.mock('./rich-markdown-source-owning-cut-feedback', () => ({
showRichMarkdownSourceOwningCutLimitError: showCutLimitErrorMock
}))
/**
* Minimal extensions matching the rich editor schema without UI dependencies.
*/
@@ -25,7 +31,7 @@ const testExtensions = [
TableRow,
TableHeader,
TableCell,
Markdown.configure({ markedOptions: { gfm: true } })
createIsolatedMarkdownExtensionForTests()
]
function createEditor(markdown: string): Editor {
@@ -38,6 +44,7 @@ function createEditor(markdown: string): Editor {
}
afterEach(() => {
showCutLimitErrorMock.mockReset()
vi.restoreAllMocks()
})
@@ -109,7 +116,7 @@ function countParagraphs(editor: Editor): number {
return count
}
function createClipboardEventMock(): {
function createClipboardEventMock(options?: { failReadback?: boolean }): {
data: Map<string, string>
event: ClipboardEvent
preventDefault: ReturnType<typeof vi.fn>
@@ -120,7 +127,10 @@ function createClipboardEventMock(): {
clipboardData: {
setData: vi.fn((type: string, value: string) => {
data.set(type, value)
})
}),
getData: options?.failReadback
? vi.fn(() => '')
: vi.fn((type: string) => data.get(type) ?? '')
},
preventDefault
} as unknown as ClipboardEvent
@@ -243,6 +253,38 @@ describe('rich markdown cut handler behavior', () => {
}
})
it('surfaces cut-limit feedback when clipboard readback fails', () => {
const editor = createEditor('Body text to cut.\n')
try {
const pos = 1
let viewState = editor.state.apply(
editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos))
)
const view = {
get state() {
return viewState
},
dispatch: vi.fn((tr) => {
viewState = viewState.apply(tr)
}),
domAtPos: vi.fn(() => ({ node: document.createElement('p'), offset: 0 })),
coordsAtPos: vi.fn(() => ({ top: 0, bottom: 20, left: 0, right: 20 })),
posAtCoords: vi.fn(() => null)
} as unknown as EditorView
const clipboard = createClipboardEventMock({ failReadback: true })
const handled = handleRichMarkdownCut(view, clipboard.event)
expect(handled).toBe(true)
expect(clipboard.preventDefault).toHaveBeenCalled()
expect(showCutLimitErrorMock).toHaveBeenCalledTimes(1)
expect(view.dispatch).not.toHaveBeenCalled()
expect(view.state.doc.textContent).toBe('Body text to cut.')
} finally {
editor.destroy()
}
})
it('paragraphs separated by blank lines are separate blocks', () => {
const editor = createEditor('First paragraph.\n\nSecond paragraph.\n\nThird paragraph.\n')
@@ -6,11 +6,12 @@ import {
moveFromEmptyDetailsBodyToSummary
} from './rich-markdown-details-extension'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
function createEditor(content: string | JSONContent) {
return new Editor({
element: null,
extensions: createRichMarkdownExtensions(),
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
content,
contentType: 'markdown'
})
@@ -0,0 +1,20 @@
import { mergeAttributes } from '@tiptap/core'
import type { DOMOutputSpec } from '@tiptap/pm/model'
export function renderRichMarkdownDocLinkHtml(
node: { attrs: Record<string, unknown> },
htmlAttributes: Record<string, unknown>
): DOMOutputSpec {
const target = typeof node.attrs.target === 'string' ? node.attrs.target : ''
const label = typeof node.attrs.label === 'string' ? node.attrs.label : null
return [
'span',
mergeAttributes(htmlAttributes, {
'data-doc-link-target': target,
...(label ? { 'data-doc-link-label': label } : {}),
contenteditable: 'false',
class: 'rich-markdown-doc-link'
}),
label ?? target
]
}
@@ -1,4 +1,4 @@
import { Node, mergeAttributes } from '@tiptap/core'
import { Node } from '@tiptap/core'
import { type EditorState, Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import type { MarkdownDocument } from '../../../../shared/types'
@@ -10,9 +10,11 @@ import {
resolveMarkdownDocLink
} from './markdown-doc-links'
import { isDocLinkLiteralCodeTextNode } from './rich-markdown-doc-link-code-context'
const DOC_LINK_PLACEHOLDER_PREFIX = '[[ORCA_DOC_LINK:'
const DOC_LINK_PLACEHOLDER_SUFFIX = ']]'
import {
isReservedRichMarkdownTransportBody,
type RichMarkdownSourceTransport
} from './rich-markdown-source-transport'
import { renderRichMarkdownDocLinkHtml } from './rich-markdown-doc-link-dom'
// Why: `.matchAll()` at each call site creates a fresh iterator so the shared
// `/g` regex never leaks `lastIndex` state across nested or concurrent scans.
@@ -55,7 +57,9 @@ function buildPreviewDecorations(state: EditorState, storage: DocLinkStorage): D
return
}
for (const match of node.text.matchAll(DOC_LINK_PATTERN)) {
const link = parseMarkdownDocLink(match[1])
const link = isReservedRichMarkdownTransportBody(match[1])
? null
: parseMarkdownDocLink(match[1])
if (!link || match.index === undefined) {
continue
}
@@ -96,262 +100,252 @@ function getDocLinkDisplayText(node: { attrs: Record<string, unknown> }): string
return getDocLinkAlias(node) ?? getDocLinkTarget(node)
}
export const MarkdownDocLink = Node.create({
name: 'markdownDocLink',
inline: true,
group: 'inline',
atom: true,
selectable: true,
addStorage() {
return {
documents: [] as MarkdownDocument[],
_cachedDocs: null as MarkdownDocument[] | null,
_cachedIndex: null as MarkdownDocumentIndex | null
}
},
addAttributes() {
return {
target: {
default: '',
parseHTML: (el: HTMLElement) => el.getAttribute('data-doc-link-target') ?? ''
},
label: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute('data-doc-link-label')
}
}
},
markdownTokenName: 'markdownDocLink',
markdownTokenizer: {
export function createMarkdownDocLink(transport: RichMarkdownSourceTransport) {
return Node.create({
name: 'markdownDocLink',
level: 'inline',
start: DOC_LINK_PLACEHOLDER_PREFIX,
tokenize(src: string) {
if (!src.startsWith(DOC_LINK_PLACEHOLDER_PREFIX)) {
return undefined
}
const endIndex = src.indexOf(DOC_LINK_PLACEHOLDER_SUFFIX, DOC_LINK_PLACEHOLDER_PREFIX.length)
if (endIndex === -1) {
return undefined
}
const placeholder = src.slice(0, endIndex + DOC_LINK_PLACEHOLDER_SUFFIX.length)
const link = parseMarkdownDocLink(src.slice(DOC_LINK_PLACEHOLDER_PREFIX.length, endIndex))
if (!link) {
return undefined
}
inline: true,
group: 'inline',
atom: true,
selectable: true,
addStorage() {
return {
type: 'markdownDocLink',
raw: placeholder,
text: link.target,
label: link.alias ?? undefined
documents: [] as MarkdownDocument[],
_cachedDocs: null as MarkdownDocument[] | null,
_cachedIndex: null as MarkdownDocumentIndex | null
}
}
},
parseMarkdown: (token, helpers) => {
if (token.type !== 'markdownDocLink') {
return []
}
return helpers.createNode('markdownDocLink', {
target: typeof token.text === 'string' ? token.text : '',
label:
typeof (token as { label?: unknown }).label === 'string'
? (token as { label: string }).label
: null
})
},
renderMarkdown: (node) =>
formatMarkdownDocLink(
typeof node.attrs?.target === 'string' ? node.attrs.target : '',
typeof node.attrs?.label === 'string' ? node.attrs.label : null
),
addNodeView() {
const storage = this.storage as DocLinkStorage
return ({ node }: { node: { type: { name: string }; attrs: Record<string, unknown> } }) => {
const target = getDocLinkTarget(node)
const dom = document.createElement('span')
dom.setAttribute('data-doc-link-target', target)
const alias = getDocLinkAlias(node)
if (alias) {
dom.setAttribute('data-doc-link-label', alias)
}
dom.setAttribute('contenteditable', 'false')
dom.textContent = getDocLinkDisplayText(node)
const applyResolutionClass = (t: string): void => {
const resolved = resolveAgainstIndex(t, getDocIndex(storage))
dom.className = resolved
? 'rich-markdown-doc-link'
: 'rich-markdown-doc-link rich-markdown-doc-link--missing'
}
applyResolutionClass(target)
},
addAttributes() {
return {
dom,
// Why: this fires on every transaction, including the no-op dispatched
// when the document list changes in storage. Re-checking resolution
// here keeps the blue/grey styling current without a full re-render.
update: (updatedNode: { type: { name: string }; attrs: Record<string, unknown> }) => {
if (updatedNode.type.name !== 'markdownDocLink') {
return false
}
const newTarget = getDocLinkTarget(updatedNode)
const newAlias = getDocLinkAlias(updatedNode)
dom.setAttribute('data-doc-link-target', newTarget)
if (newAlias) {
dom.setAttribute('data-doc-link-label', newAlias)
} else {
dom.removeAttribute('data-doc-link-label')
}
dom.textContent = getDocLinkDisplayText(updatedNode)
applyResolutionClass(newTarget)
return true
target: {
default: '',
parseHTML: (el: HTMLElement) => el.getAttribute('data-doc-link-target') ?? ''
},
label: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute('data-doc-link-label')
}
}
}
},
},
// Why: a ProseMirror plugin (not an input rule) so that [[target]] typed in
// any order — brackets first then target, paste, etc. — converts to a doc
// link node. Input rules only fire on sequential append at the cursor.
addProseMirrorPlugins() {
const nodeType = this.type
const storage = this.storage as DocLinkStorage
return [
// Why: when the cursor is adjacent to a doc link atom and the user presses
// an arrow key toward it, dissolve the atom back to editable [[target]] text.
// Without this, atom nodes are un-enterable — the cursor jumps over them.
// Uses handleKeyDown (not addKeyboardShortcuts) so we can check modifier
// keys and let Shift+Arrow extend the selection normally.
new Plugin({
key: docLinkDissolveKey,
props: {
handleKeyDown(view, event) {
if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey) {
markdownTokenName: 'markdownDocLink',
markdownTokenizer: {
name: 'markdownDocLink',
level: 'inline',
start: transport.startFor('document-link'),
tokenize(src: string) {
const matched = transport.match(src, 'document-link')
if (!matched) {
return undefined
}
const link = isReservedRichMarkdownTransportBody(matched.value)
? null
: parseMarkdownDocLink(matched.value)
if (!link) {
return undefined
}
return {
type: 'markdownDocLink',
raw: matched.raw,
text: link.target,
label: link.alias ?? undefined
}
}
},
parseMarkdown: (token, helpers) => {
if (token.type !== 'markdownDocLink') {
return []
}
return helpers.createNode('markdownDocLink', {
target: typeof token.text === 'string' ? token.text : '',
label:
typeof (token as { label?: unknown }).label === 'string'
? (token as { label: string }).label
: null
})
},
renderMarkdown: (node) =>
formatMarkdownDocLink(
typeof node.attrs?.target === 'string' ? node.attrs.target : '',
typeof node.attrs?.label === 'string' ? node.attrs.label : null
),
renderText: ({ node }) => getDocLinkDisplayText(node),
addNodeView() {
const storage = this.storage as DocLinkStorage
return ({ node }: { node: { type: { name: string }; attrs: Record<string, unknown> } }) => {
const target = getDocLinkTarget(node)
const dom = document.createElement('span')
dom.setAttribute('data-doc-link-target', target)
const alias = getDocLinkAlias(node)
if (alias) {
dom.setAttribute('data-doc-link-label', alias)
}
dom.setAttribute('contenteditable', 'false')
dom.textContent = getDocLinkDisplayText(node)
const applyResolutionClass = (t: string): void => {
const resolved = resolveAgainstIndex(t, getDocIndex(storage))
dom.className = resolved
? 'rich-markdown-doc-link'
: 'rich-markdown-doc-link rich-markdown-doc-link--missing'
}
applyResolutionClass(target)
return {
dom,
// Why: this fires on every transaction, including the no-op dispatched
// when the document list changes in storage. Re-checking resolution
// here keeps the blue/grey styling current without a full re-render.
update: (updatedNode: { type: { name: string }; attrs: Record<string, unknown> }) => {
if (updatedNode.type.name !== 'markdownDocLink') {
return false
}
let direction: 'left' | 'right'
if (event.key === 'ArrowLeft') {
direction = 'left'
} else if (event.key === 'ArrowRight') {
direction = 'right'
const newTarget = getDocLinkTarget(updatedNode)
const newAlias = getDocLinkAlias(updatedNode)
dom.setAttribute('data-doc-link-target', newTarget)
if (newAlias) {
dom.setAttribute('data-doc-link-label', newAlias)
} else {
return false
dom.removeAttribute('data-doc-link-label')
}
const { state } = view
if (!(state.selection instanceof TextSelection)) {
return false
}
const { $from } = state.selection
const adjacent = direction === 'left' ? $from.nodeBefore : $from.nodeAfter
if (!adjacent || adjacent.type.name !== 'markdownDocLink') {
return false
}
const target = getDocLinkTarget(adjacent)
const text = formatMarkdownDocLink(target, getDocLinkAlias(adjacent))
const nodeStart = direction === 'left' ? $from.pos - adjacent.nodeSize : $from.pos
const nodeEnd = nodeStart + adjacent.nodeSize
const tr = state.tr.replaceWith(nodeStart, nodeEnd, state.schema.text(text))
const cursorPos = direction === 'left' ? nodeStart + text.length - 2 : nodeStart + 2
tr.setSelection(TextSelection.create(tr.doc, cursorPos))
view.dispatch(tr)
dom.textContent = getDocLinkDisplayText(updatedNode)
applyResolutionClass(newTarget)
return true
}
}
}),
}
},
new Plugin({
key: docLinkAutoConvertKey,
appendTransaction(_transactions, _oldState, newState) {
const { tr } = newState
const cursor = newState.selection.from
let modified = false
newState.doc.descendants((node, pos, parent) => {
if (node.type.name !== 'text' || !node.text) {
return
}
if (isDocLinkLiteralCodeTextNode(node, parent)) {
return
// Why: a ProseMirror plugin (not an input rule) so that [[target]] typed in
// any order — brackets first then target, paste, etc. — converts to a doc
// link node. Input rules only fire on sequential append at the cursor.
addProseMirrorPlugins() {
const nodeType = this.type
const storage = this.storage as DocLinkStorage
return [
// Why: when the cursor is adjacent to a doc link atom and the user presses
// an arrow key toward it, dissolve the atom back to editable [[target]] text.
// Without this, atom nodes are un-enterable — the cursor jumps over them.
// Uses handleKeyDown (not addKeyboardShortcuts) so we can check modifier
// keys and let Shift+Arrow extend the selection normally.
new Plugin({
key: docLinkDissolveKey,
props: {
handleKeyDown(view, event) {
if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey) {
return false
}
let direction: 'left' | 'right'
if (event.key === 'ArrowLeft') {
direction = 'left'
} else if (event.key === 'ArrowRight') {
direction = 'right'
} else {
return false
}
const { state } = view
if (!(state.selection instanceof TextSelection)) {
return false
}
const { $from } = state.selection
const adjacent = direction === 'left' ? $from.nodeBefore : $from.nodeAfter
if (!adjacent || adjacent.type.name !== 'markdownDocLink') {
return false
}
const target = getDocLinkTarget(adjacent)
const text = formatMarkdownDocLink(target, getDocLinkAlias(adjacent))
const nodeStart = direction === 'left' ? $from.pos - adjacent.nodeSize : $from.pos
const nodeEnd = nodeStart + adjacent.nodeSize
const tr = state.tr.replaceWith(nodeStart, nodeEnd, state.schema.text(text))
const cursorPos = direction === 'left' ? nodeStart + text.length - 2 : nodeStart + 2
tr.setSelection(TextSelection.create(tr.doc, cursorPos))
view.dispatch(tr)
return true
}
}
}),
for (const match of node.text.matchAll(DOC_LINK_PATTERN)) {
const link = parseMarkdownDocLink(match[1])
if (!link || match.index === undefined) {
continue
new Plugin({
key: docLinkAutoConvertKey,
appendTransaction(_transactions, _oldState, newState) {
const { tr } = newState
const cursor = newState.selection.from
let modified = false
newState.doc.descendants((node, pos, parent) => {
if (node.type.name !== 'text' || !node.text) {
return
}
if (isDocLinkLiteralCodeTextNode(node, parent)) {
return
}
const from = pos + match.index
const to = from + match[0].length
for (const match of node.text.matchAll(DOC_LINK_PATTERN)) {
const link = isReservedRichMarkdownTransportBody(match[1])
? null
: parseMarkdownDocLink(match[1])
if (!link || match.index === undefined) {
continue
}
// Why: skip when the cursor is anywhere from just inside [[
// through the closing ]]. The inline preview decoration gives
// real-time resolution feedback while the user is still editing.
if (cursor > from && cursor <= to) {
continue
const from = pos + match.index
const to = from + match[0].length
// Why: skip when the cursor is anywhere from just inside [[
// through the closing ]]. The inline preview decoration gives
// real-time resolution feedback while the user is still editing.
if (cursor > from && cursor <= to) {
continue
}
const docLinkNode = nodeType.create({ target: link.target, label: link.alias })
tr.replaceWith(tr.mapping.map(from), tr.mapping.map(to), docLinkNode)
modified = true
}
})
const docLinkNode = nodeType.create({ target: link.target, label: link.alias })
tr.replaceWith(tr.mapping.map(from), tr.mapping.map(to), docLinkNode)
modified = true
return modified ? tr : null
}
}),
// Why: while the cursor is inside [[target]], the text hasn't converted
// to an atom node yet. This decoration gives real-time blue/grey feedback
// so the user knows whether the target resolves before moving the cursor out.
new Plugin({
key: docLinkInlinePreviewKey,
state: {
init(_, state) {
return buildPreviewDecorations(state, storage)
},
apply(tr, prev, oldState, newState) {
const selectionMoved = !oldState.selection.eq(newState.selection)
if (!tr.docChanged && !selectionMoved && !tr.getMeta('docLinksUpdated')) {
return prev
}
return buildPreviewDecorations(newState, storage)
}
})
return modified ? tr : null
}
}),
// Why: while the cursor is inside [[target]], the text hasn't converted
// to an atom node yet. This decoration gives real-time blue/grey feedback
// so the user knows whether the target resolves before moving the cursor out.
new Plugin({
key: docLinkInlinePreviewKey,
state: {
init(_, state) {
return buildPreviewDecorations(state, storage)
},
apply(tr, prev, oldState, newState) {
const selectionMoved = !oldState.selection.eq(newState.selection)
if (!tr.docChanged && !selectionMoved && !tr.getMeta('docLinksUpdated')) {
return prev
props: {
decorations(state) {
return docLinkInlinePreviewKey.getState(state)
}
return buildPreviewDecorations(newState, storage)
}
},
props: {
decorations(state) {
return docLinkInlinePreviewKey.getState(state)
}
}
})
]
},
})
]
},
parseHTML() {
return [{ tag: 'span[data-doc-link-target]' }]
},
parseHTML() {
return [{ tag: 'span[data-doc-link-target]' }]
},
renderHTML({ HTMLAttributes, node }) {
const target = typeof node.attrs.target === 'string' ? node.attrs.target : ''
const label = typeof node.attrs.label === 'string' ? node.attrs.label : null
return [
'span',
mergeAttributes(HTMLAttributes, {
'data-doc-link-target': target,
...(label ? { 'data-doc-link-label': label } : {}),
contenteditable: 'false',
class: 'rich-markdown-doc-link'
}),
label ?? target
]
}
})
renderHTML({ HTMLAttributes, node }) {
return renderRichMarkdownDocLinkHtml(node, HTMLAttributes)
}
})
}
@@ -2,8 +2,7 @@ import type { MutableRefObject } from 'react'
import type { Editor } from '@tiptap/react'
import type { EditorView } from '@tiptap/pm/view'
import { toast } from 'sonner'
import { getConnectionId } from '@/lib/connection-context'
import { openHttpLink } from '@/lib/http-link-routing'
import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing'
import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
import {
@@ -14,6 +13,10 @@ import { scrollToAnchorInEditor } from './markdown-anchor-scroll'
import { getRichMarkdownCommentAtPos } from './rich-markdown-review-annotations'
import type { DiffComment } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
import {
classifyHtmlSuperscriptLinkAction,
type RichMarkdownHtmlSuperscriptLinkContext
} from './rich-markdown-html-superscript-link-context'
export type ActivateMarkdownLink = (
href: string,
@@ -22,6 +25,7 @@ export type ActivateMarkdownLink = (
worktreeId: string
worktreeRoot: string | null
runtimeEnvironmentId?: string | null
sourceOwner?: HttpLinkSourceOwner
}
) => void | Promise<unknown>
@@ -33,6 +37,7 @@ type RichMarkdownEditorClickRoutingOptions = {
event: MouseEvent
filePath: string
isMac: boolean
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
markdownCommentsRef: MutableRefObject<DiffComment[]>
markdownSourceLineOffsetRef: MutableRefObject<number>
onOpenDocLinkRef: MutableRefObject<((target: string) => void) | undefined>
@@ -52,6 +57,7 @@ export function handleRichMarkdownEditorClick({
event,
filePath,
isMac,
htmlSuperscriptLinkContext,
markdownCommentsRef,
markdownSourceLineOffsetRef,
onOpenDocLinkRef,
@@ -65,6 +71,8 @@ export function handleRichMarkdownEditorClick({
worktreeRoot
}: RichMarkdownEditorClickRoutingOptions): boolean {
const editor = editorRef.current
const sourceSnapshot = htmlSuperscriptLinkContext.getSnapshot()
const sourceOwner = sourceSnapshot.sourceOwner
const modKey = isMac ? event.metaKey : event.ctrlKey
if (!editor) {
return false
@@ -88,6 +96,7 @@ export function handleRichMarkdownEditorClick({
filePath,
runtimeEnvironmentId,
src: (clickedNode.attrs.src as string | undefined) ?? '',
sourceOwner,
worktreeId,
worktreeRoot
})
@@ -96,7 +105,16 @@ export function handleRichMarkdownEditorClick({
onOpenDocLinkRef.current?.(clickedNode.attrs.target as string)
return true
}
const href = getClickedLinkHref(view, pos)
const href =
clickedNode?.type.name === 'richMarkdownHtmlSuperscriptLink'
? String(clickedNode.attrs.href ?? '')
: getClickedLinkHref(view, pos)
if (
clickedNode?.type.name === 'richMarkdownHtmlSuperscriptLink' &&
!classifyHtmlSuperscriptLinkAction(href, sourceSnapshot)
) {
return true
}
if (!href) {
return false
}
@@ -109,8 +127,8 @@ export function handleRichMarkdownEditorClick({
href,
filePath,
runtimeEnvironmentId,
sourceOwner,
settings,
worktreeId,
worktreeRoot
})
return true
@@ -119,7 +137,8 @@ export function handleRichMarkdownEditorClick({
sourceFilePath: filePath,
worktreeId,
worktreeRoot,
runtimeEnvironmentId
runtimeEnvironmentId,
sourceOwner
})
return true
}
@@ -128,6 +147,7 @@ function activateMarkdownImageClick({
activateMarkdownLink,
filePath,
runtimeEnvironmentId,
sourceOwner,
src,
worktreeId,
worktreeRoot
@@ -135,6 +155,7 @@ function activateMarkdownImageClick({
activateMarkdownLink: ActivateMarkdownLink
filePath: string
runtimeEnvironmentId?: string | null
sourceOwner?: HttpLinkSourceOwner
src: string
worktreeId: string
worktreeRoot: string | null
@@ -146,7 +167,8 @@ function activateMarkdownImageClick({
sourceFilePath: filePath,
worktreeId,
worktreeRoot,
runtimeEnvironmentId
runtimeEnvironmentId,
sourceOwner
})
return true
}
@@ -162,24 +184,27 @@ function getClickedLinkHref(view: EditorView, pos: number): string {
function openMarkdownLinkInClientOs({
href,
filePath,
worktreeId,
worktreeRoot,
runtimeEnvironmentId,
sourceOwner,
settings
}: {
href: string
filePath: string
worktreeId: string
worktreeRoot: string | null
runtimeEnvironmentId?: string | null
sourceOwner: HttpLinkSourceOwner
settings: RichMarkdownRuntimeSettings
}): void {
if (sourceOwner.kind === 'unknown') {
return
}
const classified = resolveMarkdownLinkTarget(href, filePath, worktreeRoot)
if (!classified) {
return
}
if (classified.kind === 'external') {
openHttpLink(classified.url, { forceSystemBrowser: true })
openHttpLink(classified.url, { forceSystemBrowser: true, sourceOwner })
return
}
if (classified.kind === 'anchor') {
@@ -187,7 +212,7 @@ function openMarkdownLinkInClientOs({
}
if (
isLocalPathOpenBlocked(settingsForRuntimeOwner(settings, runtimeEnvironmentId), {
connectionId: getConnectionId(worktreeId)
connectionId: sourceOwner.kind === 'ssh' ? sourceOwner.connectionId : undefined
})
) {
// Why: Shift-click opens through the client OS, which cannot safely resolve
@@ -8,6 +8,8 @@ import {
type EditorConfigParams
} from './rich-markdown-editor-config'
import type { SlashMenuState } from './rich-markdown-slash-commands'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
function ref<T>(current: T): MutableRefObject<T> {
return { current }
@@ -25,7 +27,15 @@ function getSpellcheckAttribute(config: ReturnType<typeof createRichMarkdownEdit
}
function createConfigParams(overrides: Partial<EditorConfigParams> = {}): EditorConfigParams {
const codec = createRichMarkdownEditorCodec()
return {
codec,
htmlSuperscriptLinkContext: createRichMarkdownHtmlSuperscriptLinkContext({
sourceFilePath: '/repo/README.md',
worktreeId: 'worktree-1',
worktreeRoot: '/repo',
sourceOwner: { kind: 'local' }
}),
content: '',
filePath: '/repo/README.md',
worktreeId: 'worktree-1',
@@ -15,7 +15,7 @@ import {
type DocLinkMenuState
} from './rich-markdown-commands'
import { isSingleEmptyTopLevelOrderedList } from './rich-markdown-list-continuation'
import { getLinkBubblePosition, type LinkBubbleState } from './RichMarkdownLinkBubble'
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
import {
handleRichMarkdownEditorClick,
type ActivateMarkdownLink,
@@ -29,8 +29,16 @@ import {
import { getRichMarkdownSpellcheckAttribute } from './rich-markdown-spellcheck'
import type { MutableRefObject, Dispatch, SetStateAction } from 'react'
import type { DiffComment } from '../../../../shared/types'
import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport'
import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
import {
getRichMarkdownSelectionLinkBubble,
openSelectedHtmlSuperscriptLink
} from './rich-markdown-selected-link-actions'
export type EditorConfigParams = {
codec: RichMarkdownEditorCodec
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
content: string
filePath: string
worktreeId: string
@@ -79,6 +87,8 @@ export type EditorConfigParams = {
export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseEditorOptions {
const {
content,
codec,
htmlSuperscriptLinkContext,
filePath,
worktreeId,
worktreeRoot,
@@ -125,7 +135,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
return {
immediatelyRender: false,
content: encodeRawMarkdownHtmlForRichEditor(content),
content: encodeRawMarkdownHtmlForRichEditor(content, codec, { htmlSuperscriptLinks: true }),
contentType: 'markdown' as const,
editorProps: {
attributes: {
@@ -135,13 +145,15 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
handleDOMEvents: {
cut: handleRichMarkdownCut
},
handlePaste: (_view, event) =>
handlePaste: (view, event, slice) =>
handleRichMarkdownPaste({
editor: editorRef.current,
event,
filePath,
worktreeId,
runtimeEnvironmentId
runtimeEnvironmentId,
slice,
view
}),
handleTextInput: (view, from, to, text) => {
typedEmptyOrderedListMarkerRef.current = false
@@ -172,12 +184,22 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
typedEmptyOrderedListMarkerRef,
flushPendingSerialization,
openSearchRef,
linkBubbleOwnerId: codec.transport.key,
htmlSuperscriptLinkContext,
setIsEditingLink,
setLinkBubble,
setSelectedCommandIndex,
setSelectedDocLinkIndex,
setSlashMenu,
setDocLinkMenu
setDocLinkMenu,
openSelectedHtmlSuperscriptLink: () =>
openSelectedHtmlSuperscriptLink({
activateMarkdownLink,
context: htmlSuperscriptLinkContext,
editor: editorRef.current,
root: rootRef.current,
runtimeEnvironmentId
})
}),
handleClick: (view, pos, event) => {
return handleRichMarkdownEditorClick({
@@ -185,6 +207,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
editorRef,
event,
filePath,
htmlSuperscriptLinkContext,
isMac,
markdownCommentsRef,
markdownSourceLineOffsetRef,
@@ -259,13 +282,9 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
syncDocLinkMenu(nextEditor, rootRef.current, setDocLinkMenu)
syncAnnotationTarget(nextEditor)
setIsEditingLink(false)
if (nextEditor.isActive('link')) {
const attrs = nextEditor.getAttributes('link')
const pos = getLinkBubblePosition(nextEditor, rootRef.current)
setLinkBubble(pos ? { href: (attrs.href as string) || '', ...pos } : null)
} else {
setLinkBubble(null)
}
setLinkBubble(
getRichMarkdownSelectionLinkBubble(nextEditor, rootRef.current, htmlSuperscriptLinkContext)
)
}
}
}
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest'
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { Markdown } from '@tiptap/markdown'
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
import { deleteAdjacentEmptyParagraph } from './rich-markdown-empty-paragraph-delete'
const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })]
const extensions = [StarterKit, createIsolatedMarkdownExtensionForTests()]
const hardWrappedMarkdown =
'Alpha owns launch-lifetime state keyed by tab id, while native\n' +
@@ -16,16 +16,23 @@ import { Markdown } from '@tiptap/markdown'
import { createLowlight, common } from 'lowlight'
import { loadLocalImageSrc, onImageCacheInvalidated } from './useLocalImageSrc'
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
import { RawMarkdownHtmlBlock, RawMarkdownHtmlInline } from './raw-markdown-html'
import {
createRawMarkdownHtmlBlock,
createRawMarkdownHtmlInline,
createRichMarkdownLiteral
} from './raw-markdown-html'
import {
createOrcaDetailsExtensions,
getRichMarkdownPlaceholder
} from './rich-markdown-details-extension'
import { MarkdownDocLink } from './rich-markdown-doc-link'
import { createMarkdownDocLink } from './rich-markdown-doc-link'
import { RichMarkdownCodeBlock } from './RichMarkdownCodeBlock'
import { safeReactNodeViewRenderer } from './safe-react-node-view-renderer'
import { DragSelectionGuard } from './drag-selection-guard'
import { createRichMarkdownAnnotationHighlightExtension } from './rich-markdown-annotation-highlight'
import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport'
import { createRichMarkdownHtmlSuperscriptLink } from './rich-markdown-html-superscript-link'
import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
const lowlight = createLowlight(common)
@@ -42,10 +49,19 @@ const RichMarkdownCode = Code.extend({
})
export function createRichMarkdownExtensions({
includePlaceholder = false
codec,
includePlaceholder = false,
htmlSuperscriptLinks = false,
htmlSuperscriptLinkContext
}: {
codec: RichMarkdownEditorCodec
includePlaceholder?: boolean
} = {}): AnyExtension[] {
htmlSuperscriptLinks?: boolean
htmlSuperscriptLinkContext?: RichMarkdownHtmlSuperscriptLinkContext
}): AnyExtension[] {
if (htmlSuperscriptLinks && !htmlSuperscriptLinkContext) {
throw new Error('HTML superscript links require a document interaction context')
}
const extensions: AnyExtension[] = [
// Why: rich-mode detection must use the exact same markdown extension set as
// the live editor. If these drift, Orca can claim a document is editable in
@@ -196,11 +212,16 @@ export function createRichMarkdownExtensions({
throwOnError: false
}
}),
RawMarkdownHtmlInline,
RawMarkdownHtmlBlock,
MarkdownDocLink,
createRichMarkdownLiteral(codec.transport),
...(htmlSuperscriptLinks
? [createRichMarkdownHtmlSuperscriptLink(codec.transport, htmlSuperscriptLinkContext!)]
: []),
createRawMarkdownHtmlInline(codec.transport),
createRawMarkdownHtmlBlock(codec.transport),
createMarkdownDocLink(codec.transport),
DragSelectionGuard,
Markdown.configure({
marked: codec.marked,
markedOptions: {
gfm: true
}
@@ -0,0 +1,73 @@
import type { HttpLinkSourceOwner } from '@/lib/http-link-routing'
import { resolveMarkdownLinkTarget } from './markdown-internal-links'
export type RichMarkdownHtmlSuperscriptLinkContextSnapshot = {
version: number
sourceFilePath: string
worktreeId: string
worktreeRoot: string | null
sourceOwner: HttpLinkSourceOwner
}
export type RichMarkdownHtmlSuperscriptLinkContext = {
getSnapshot: () => RichMarkdownHtmlSuperscriptLinkContextSnapshot
subscribe: (listener: () => void) => () => void
update: (snapshot: Omit<RichMarkdownHtmlSuperscriptLinkContextSnapshot, 'version'>) => void
}
export function createRichMarkdownHtmlSuperscriptLinkContext(
initial: Omit<RichMarkdownHtmlSuperscriptLinkContextSnapshot, 'version'>
): RichMarkdownHtmlSuperscriptLinkContext {
let snapshot = { ...initial, version: 0 }
const listeners = new Set<() => void>()
return {
getSnapshot: () => snapshot,
subscribe: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
update: (next) => {
if (
next.sourceFilePath === snapshot.sourceFilePath &&
next.worktreeId === snapshot.worktreeId &&
next.worktreeRoot === snapshot.worktreeRoot &&
sameOwner(next.sourceOwner, snapshot.sourceOwner)
) {
return
}
snapshot = { ...next, version: snapshot.version + 1 }
listeners.forEach((listener) => listener())
}
}
}
export function classifyHtmlSuperscriptLinkAction(
href: string,
snapshot: RichMarkdownHtmlSuperscriptLinkContextSnapshot
): boolean {
if (snapshot.sourceOwner.kind === 'unknown' || /^[\t\n\f\r ]*$/.test(href)) {
return false
}
const target = resolveMarkdownLinkTarget(href, snapshot.sourceFilePath, snapshot.worktreeRoot)
if (!target) {
return false
}
return !(
target.kind === 'file' &&
target.relativePath === undefined &&
(snapshot.sourceOwner.kind === 'runtime' || snapshot.sourceOwner.kind === 'ssh')
)
}
function sameOwner(left: HttpLinkSourceOwner, right: HttpLinkSourceOwner): boolean {
if (left.kind !== right.kind) {
return false
}
if (left.kind === 'runtime' && right.kind === 'runtime') {
return left.runtimeEnvironmentId === right.runtimeEnvironmentId
}
if (left.kind === 'ssh' && right.kind === 'ssh') {
return left.connectionId === right.connectionId
}
return true
}
@@ -0,0 +1,293 @@
import {
decodeHtmlAttributeCharacterReferences,
decodeHtmlTextCharacterReferences,
type HtmlAttributeQuote
} from './html-character-reference-decoder'
export const HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT = 16 * 1024
export const HTML_SUPERSCRIPT_LINK_HREF_LIMIT = 8 * 1024
export const HTML_SUPERSCRIPT_LINK_TEXT_LIMIT = 2 * 1024
export type HtmlSuperscriptLinkSource = {
source: string
href: string
label: string
title: string | null
}
export type HtmlSuperscriptLinkMatch = {
end: number
value: HtmlSuperscriptLinkSource
}
export type HtmlSuperscriptLinkParseStats = {
transitions: number
}
type ParsedAttribute = {
name: 'href' | 'title'
rawValue: string
quote: HtmlAttributeQuote
}
const encoder = new TextEncoder()
export function matchHtmlSuperscriptLinkSource(
input: string,
start = 0,
stats?: HtmlSuperscriptLinkParseStats
): HtmlSuperscriptLinkMatch | null {
const limit = Math.min(input.length, start + HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT + 1)
let index = matchSimpleTag(input, start, 'sup', false, limit, stats)
if (index === null) {
return null
}
const anchor = matchAnchorStart(input, index, limit, stats)
if (!anchor) {
return null
}
index = anchor.end
const labelStart = index
while (index < limit) {
step(stats)
const code = input.charCodeAt(index)
if (code === 60 || code === 10 || code === 13) {
break
}
index += 1
}
if (index === labelStart || index >= limit || input.charCodeAt(index) !== 60) {
return null
}
const rawLabel = input.slice(labelStart, index)
index = matchSimpleTag(input, index, 'a', true, limit, stats) ?? -1
if (index < 0) {
return null
}
index = matchSimpleTag(input, index, 'sup', true, limit, stats) ?? -1
if (index < 0 || index - start > HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT) {
return null
}
const hrefAttribute = anchor.attributes.find((attribute) => attribute.name === 'href')
const titleAttribute = anchor.attributes.find((attribute) => attribute.name === 'title')
if (!hrefAttribute) {
return null
}
const href = decodeHtmlAttributeCharacterReferences(hrefAttribute.rawValue, hrefAttribute.quote)
const label = decodeHtmlTextCharacterReferences(rawLabel)
const title = titleAttribute
? decodeHtmlAttributeCharacterReferences(titleAttribute.rawValue, titleAttribute.quote)
: null
const source = input.slice(start, index)
if (
byteLength(source) > HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT ||
byteLength(href) > HTML_SUPERSCRIPT_LINK_HREF_LIMIT ||
byteLength(label) > HTML_SUPERSCRIPT_LINK_TEXT_LIMIT ||
(title !== null && byteLength(title) > HTML_SUPERSCRIPT_LINK_TEXT_LIMIT)
) {
return null
}
return { end: index, value: { source, href, label, title } }
}
export function parseHtmlSuperscriptLinkSource(source: string): HtmlSuperscriptLinkSource | null {
const match = matchHtmlSuperscriptLinkSource(source)
return match?.end === source.length ? match.value : null
}
function matchAnchorStart(
input: string,
start: number,
limit: number,
stats?: HtmlSuperscriptLinkParseStats
): { end: number; attributes: ParsedAttribute[] } | null {
let index = start
if (input.charCodeAt(index) !== 60 || lowerCode(input.charCodeAt(index + 1)) !== 97) {
return null
}
step(stats, 2)
index += 2
if (!isSingleLineHtmlWhitespace(input.charCodeAt(index))) {
return null
}
const attributes: ParsedAttribute[] = []
while (index < limit) {
const whitespaceStart = index
while (isSingleLineHtmlWhitespace(input.charCodeAt(index))) {
step(stats)
index += 1
}
if (input.charCodeAt(index) === 62) {
step(stats)
return attributes.some((attribute) => attribute.name === 'href')
? { end: index + 1, attributes }
: null
}
if (index === whitespaceStart) {
return null
}
const nameStart = index
while (isAttributeNameCode(input.charCodeAt(index))) {
step(stats)
index += 1
}
if (index === nameStart) {
return null
}
const normalizedName = input.slice(nameStart, index).toLowerCase()
if (normalizedName !== 'href' && normalizedName !== 'title') {
return null
}
if (attributes.some((attribute) => attribute.name === normalizedName)) {
return null
}
while (isSingleLineHtmlWhitespace(input.charCodeAt(index))) {
step(stats)
index += 1
}
if (input.charCodeAt(index) !== 61) {
return null
}
step(stats)
index += 1
while (isSingleLineHtmlWhitespace(input.charCodeAt(index))) {
step(stats)
index += 1
}
const parsedValue = parseAttributeValue(input, index, limit, stats)
if (!parsedValue) {
return null
}
attributes.push({
name: normalizedName,
rawValue: parsedValue.rawValue,
quote: parsedValue.quote
})
index = parsedValue.end
}
return null
}
function parseAttributeValue(
input: string,
start: number,
limit: number,
stats?: HtmlSuperscriptLinkParseStats
): { end: number; rawValue: string; quote: HtmlAttributeQuote } | null {
const first = input[start]
if (first === '"' || first === "'") {
let index = start + 1
while (index < limit && input[index] !== first) {
step(stats)
const code = input.charCodeAt(index)
if (code === 10 || code === 13) {
return null
}
index += 1
}
if (index >= limit) {
return null
}
step(stats)
return {
end: index + 1,
rawValue: input.slice(start + 1, index),
quote: first
}
}
let index = start
while (index < limit) {
const code = input.charCodeAt(index)
if (isHtmlWhitespace(code) || code === 62) {
break
}
step(stats)
if (code === 34 || code === 39 || code === 60 || code === 61 || code === 96) {
return null
}
index += 1
}
return index === start ? null : { end: index, rawValue: input.slice(start, index), quote: null }
}
function matchSimpleTag(
input: string,
start: number,
name: 'a' | 'sup',
closing: boolean,
limit: number,
stats?: HtmlSuperscriptLinkParseStats
): number | null {
let index = start
if (input.charCodeAt(index) !== 60) {
return null
}
step(stats)
index += 1
if (closing) {
if (input.charCodeAt(index) !== 47) {
return null
}
step(stats)
index += 1
}
for (let nameIndex = 0; nameIndex < name.length; nameIndex += 1) {
step(stats)
if (lowerCode(input.charCodeAt(index)) !== name.charCodeAt(nameIndex)) {
return null
}
index += 1
}
while (index < limit && isSingleLineHtmlWhitespace(input.charCodeAt(index))) {
step(stats)
index += 1
}
if (input.charCodeAt(index) !== 62) {
return null
}
step(stats)
return index + 1
}
function isAttributeNameCode(code: number): boolean {
return (
(code >= 65 && code <= 90) ||
(code >= 97 && code <= 122) ||
(code >= 48 && code <= 57) ||
code === 95 ||
code === 46 ||
code === 58 ||
code === 45
)
}
function isHtmlWhitespace(code: number): boolean {
return code === 9 || code === 10 || code === 12 || code === 13 || code === 32
}
function isSingleLineHtmlWhitespace(code: number): boolean {
return code === 9 || code === 12 || code === 32
}
function lowerCode(code: number): number {
return code >= 65 && code <= 90 ? code + 32 : code
}
function byteLength(value: string): number {
return encoder.encode(value).byteLength
}
function step(stats: HtmlSuperscriptLinkParseStats | undefined, count = 1): void {
if (stats) {
stats.transitions += count
}
}
@@ -0,0 +1,593 @@
// @vitest-environment happy-dom
import { Editor } from '@tiptap/core'
import { NodeSelection, TextSelection } from '@tiptap/pm/state'
import { describe, expect, it, vi } from 'vitest'
import { marked } from 'marked'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import {
createRichMarkdownEditorCodec,
createRichMarkdownSourceTransport
} from './rich-markdown-source-transport'
import {
classifyHtmlSuperscriptLinkAction,
createRichMarkdownHtmlSuperscriptLinkContext
} from './rich-markdown-html-superscript-link-context'
import {
HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT,
matchHtmlSuperscriptLinkSource,
parseHtmlSuperscriptLinkSource
} from './rich-markdown-html-superscript-link-source'
import { projectMarkdownHrefForClipboard } from './markdown-internal-links'
import { findRichMarkdownSearchMatches } from './rich-markdown-search'
import { findRichMarkdownSelectedTextRanges } from './rich-markdown-review-text-ranges'
import { getRichMarkdownVisibleText } from './rich-markdown-visible-text-map'
import { handleRichMarkdownCut } from './rich-markdown-cut-handler'
import { getSelectedHtmlSuperscriptLinkStatus } from './rich-markdown-selected-link-actions'
import { handleRichMarkdownCitationKey } from './rich-markdown-citation-keyboard'
import { resolveRichMarkdownWorktreeRoot } from './useRichMarkdownSuperscriptLinkSetup'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import type { AppState } from '@/store/types'
import {
inspectRichMarkdownSourceOwningSlice,
RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT
} from './rich-markdown-source-owning-slice'
const TEST_KEY = '0123456789abcdef0123456789abcdef'
function createEditor(content: string, key = TEST_KEY, element: HTMLElement | null = null): Editor {
const codec = createRichMarkdownEditorCodec(key)
const context = createRichMarkdownHtmlSuperscriptLinkContext({
sourceFilePath: '/repo/README.md',
worktreeId: 'worktree-1',
worktreeRoot: '/repo',
sourceOwner: { kind: 'local' }
})
return new Editor({
element,
extensions: createRichMarkdownExtensions({
codec,
htmlSuperscriptLinks: true,
htmlSuperscriptLinkContext: context
}),
content: encodeRawMarkdownHtmlForRichEditor(content, codec, {
htmlSuperscriptLinks: true
}),
contentType: 'markdown'
})
}
function nodeNames(editor: Editor): string[] {
const names: string[] = []
editor.state.doc.descendants((node) => {
names.push(node.type.name)
})
return names
}
describe('rich Markdown HTML superscript links', () => {
it('parses the reported fragment and preserves its exact source', () => {
const source = '<sup><a href="https://example.com/source">[12]</a></sup>'
const editor = createEditor(`研究结果${source}。`)
try {
expect(nodeNames(editor)).toContain('richMarkdownHtmlSuperscriptLink')
expect(editor.getMarkdown()).toBe(`研究结果${source}。`)
const citation = editor.state.doc.firstChild?.child(1)
expect(citation?.attrs).toMatchObject({
source,
href: 'https://example.com/source',
label: '[12]',
title: null
})
} finally {
editor.destroy()
}
})
it('retains casing, whitespace, quote style, entities, title, and adjacency', () => {
const first =
"<SUP ><A title='A &amp; B' href='https://example.com/?a=1&amp;b=2'>[&notit;]</A ></SUP >"
const second = '<sup><a href=#section>[13]</a></sup>'
const editor = createEditor(`${first}${second}`)
try {
expect(
nodeNames(editor).filter((name) => name === 'richMarkdownHtmlSuperscriptLink')
).toHaveLength(2)
expect(editor.getMarkdown()).toBe(`${first}${second}`)
const citation = editor.state.doc.firstChild?.firstChild
expect(citation?.attrs.href).toBe('https://example.com/?a=1&b=2')
expect(citation?.attrs.title).toBe('A & B')
} finally {
editor.destroy()
}
})
it('leaves malformed and broadened HTML on the inert raw-source path', () => {
const rejected = [
'<sup><a href="">[12]<a></sup>',
'<sup><a href="x" onclick="go()">[12]</a></sup>',
'<sup><a href="x"><b>[12]</b></a></sup>',
'<sup class="x"><a href="x">[12]</a></sup>',
'<sup><a href="x">[12]\n</a></sup>',
'<sup\n><a href="x">[12]</a></sup>',
'<sup><a\n href="x">[12]</a></sup>',
'<sup><a href =\n "x">[12]</a></sup>',
'<sup><a href="x">[12]</a\n></sup>'
]
for (const source of rejected) {
const editor = createEditor(source)
try {
expect(nodeNames(editor)).not.toContain('richMarkdownHtmlSuperscriptLink')
if (!source.includes('\n')) {
expect(editor.getMarkdown()).toBe(source)
}
} finally {
editor.destroy()
}
}
})
it('does not recognize citations inside inline or fenced code', () => {
const source = '<sup><a href="https://example.com">[12]</a></sup>'
const markdown = `\`${source}\`\n\n\`\`\`html\n${source}\n\`\`\``
const editor = createEditor(markdown)
try {
expect(nodeNames(editor)).not.toContain('richMarkdownHtmlSuperscriptLink')
expect(editor.getMarkdown()).toBe(markdown)
} finally {
editor.destroy()
}
})
it('preserves authored current-key and legacy transport-looking text', () => {
const transport = createRichMarkdownSourceTransport(TEST_KEY)
const authored = `${transport.create('inline-html', '<b>authored</b>')} [[ORCA_RAW_HTML_INLINE:%3Ci%3Ex%3C%2Fi%3E]]`
const editor = createEditor(authored)
try {
editor.commands.insertContentAt(editor.state.doc.content.size - 1, '!')
expect(editor.getMarkdown()).toBe(`${authored}!`)
expect(nodeNames(editor)).not.toContain('markdownDocLink')
} finally {
editor.destroy()
}
})
it('keeps sequential codecs isolated and exposes the complete marked surface', () => {
const first = createRichMarkdownEditorCodec(TEST_KEY)
const second = createRichMarkdownEditorCodec('fedcba9876543210fedcba9876543210')
expect(Object.getOwnPropertyNames(first.marked).sort()).toEqual(
Object.getOwnPropertyNames(marked).sort()
)
const authoredFirstToken = first.transport.create('inline-html', '<b>K1</b>')
const editor = createEditor(authoredFirstToken, second.transport.key)
try {
expect(editor.getMarkdown()).toBe(authoredFirstToken)
expect(nodeNames(editor)).not.toContain('rawMarkdownHtmlInline')
expect(nodeNames(editor)).not.toContain('markdownDocLink')
} finally {
editor.destroy()
}
})
it('keeps source matching linear across many rejected candidates', () => {
const input = '<sup>'.repeat(2_000)
let transitions = 0
for (let index = 0; index < input.length; index += 5) {
const stats = { transitions: 0 }
expect(matchHtmlSuperscriptLinkSource(input, index, stats)).toBeNull()
transitions += stats.transitions
}
expect(transitions).toBeLessThan(input.length * 4)
})
it('projects only browser-safe clipboard hrefs', () => {
expect(projectMarkdownHrefForClipboard(' https://example.com ')).toBe('https://example.com')
expect(projectMarkdownHrefForClipboard('#section')).toBe('#section')
expect(projectMarkdownHrefForClipboard('./guide.md')).toBe('./guide.md')
expect(projectMarkdownHrefForClipboard('javascript:alert(1)')).toBeNull()
expect(projectMarkdownHrefForClipboard('java\nscript:alert(1)')).toBeNull()
expect(projectMarkdownHrefForClipboard('C:\\repo\\guide.md')).toBeNull()
expect(projectMarkdownHrefForClipboard('\\\\server\\share\\guide.md')).toBeNull()
expect(projectMarkdownHrefForClipboard('//example.com/path')).toBeNull()
})
it('rejects duplicate attributes and parses quoted greater-than characters', () => {
expect(
parseHtmlSuperscriptLinkSource('<sup><a href="https://example.com/?q=>">[1]</a></sup>')?.href
).toBe('https://example.com/?q=>')
expect(parseHtmlSuperscriptLinkSource('<sup><a href="a" href="b">[1]</a></sup>')).toBeNull()
})
it('renders inert live DOM while exposing link semantics through Orca context', () => {
const host = document.createElement('div')
document.body.appendChild(host)
const source = '<sup><a href="https://example.com">[12]</a></sup>'
const codec = createRichMarkdownEditorCodec(TEST_KEY)
const context = createRichMarkdownHtmlSuperscriptLinkContext({
sourceFilePath: '/repo/README.md',
worktreeId: 'worktree-1',
worktreeRoot: '/repo',
sourceOwner: { kind: 'local' }
})
const editor = new Editor({
element: host,
extensions: createRichMarkdownExtensions({
codec,
htmlSuperscriptLinks: true,
htmlSuperscriptLinkContext: context
}),
content: encodeRawMarkdownHtmlForRichEditor(source, codec, {
htmlSuperscriptLinks: true
}),
contentType: 'markdown'
})
try {
const liveLabel = host.querySelector<HTMLElement>(
'sup[data-rich-markdown-html-superscript-link] > span'
)
expect(liveLabel?.textContent).toBe('[12]')
expect(liveLabel?.getAttribute('role')).toBe('link')
expect(host.querySelector('sup a')).toBeNull()
editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, 1)))
expect(getSelectedHtmlSuperscriptLinkStatus(editor, context)).toEqual({
href: 'https://example.com',
label: '[12]',
openEnabled: true
})
context.update({
sourceFilePath: '/repo/README.md',
worktreeId: 'worktree-1',
worktreeRoot: '/repo',
sourceOwner: { kind: 'unknown' }
})
expect(liveLabel?.hasAttribute('role')).toBe(false)
expect(getSelectedHtmlSuperscriptLinkStatus(editor, context)?.openEnabled).toBe(false)
} finally {
editor.destroy()
host.remove()
}
})
it('keeps whitespace and remote outside-root destinations non-actionable', () => {
const local = {
version: 0,
sourceFilePath: '/repo/README.md',
worktreeId: 'worktree-1',
worktreeRoot: '/repo',
sourceOwner: { kind: 'local' as const }
}
expect(classifyHtmlSuperscriptLinkAction(' ', local)).toBe(false)
expect(classifyHtmlSuperscriptLinkAction('file:///etc/passwd', local)).toBe(true)
expect(
classifyHtmlSuperscriptLinkAction('file:///etc/passwd', {
...local,
sourceOwner: { kind: 'ssh', connectionId: 'ssh-1' }
})
).toBe(false)
expect(
classifyHtmlSuperscriptLinkAction('file:///etc/passwd', {
...local,
sourceOwner: { kind: 'runtime', runtimeEnvironmentId: 'runtime-1' }
})
).toBe(false)
})
it('scopes citation Tab focus to the selected editor bubble', () => {
const editor = createEditor('<sup><a href="https://example.com">[12]</a></sup>')
const firstBubble = document.createElement('div')
firstBubble.dataset.richMarkdownLinkBubbleOwner = 'first-owner'
const firstButton = document.createElement('button')
firstBubble.appendChild(firstButton)
const secondBubble = document.createElement('div')
secondBubble.dataset.richMarkdownLinkBubbleOwner = 'second-owner'
const secondButton = document.createElement('button')
secondBubble.appendChild(secondButton)
document.body.append(firstBubble, secondBubble)
try {
editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, 1)))
const event = {
key: 'Tab',
shiftKey: false,
isComposing: false,
preventDefault: () => {}
} as KeyboardEvent
expect(
handleRichMarkdownCitationKey({
editor,
event,
linkBubbleOwnerId: 'second-owner'
})
).toBe(true)
expect(document.activeElement).toBe(secondButton)
} finally {
editor.destroy()
firstBubble.remove()
secondBubble.remove()
}
})
it('uses a folder workspace path as the citation source root', () => {
const state = {
folderWorkspaces: [{ id: 'folder-1', folderPath: '/workspace/platform' }],
worktreesByRepo: {}
} as Pick<AppState, 'folderWorkspaces' | 'worktreesByRepo'>
expect(resolveRichMarkdownWorktreeRoot(state, folderWorkspaceKey('folder-1'))).toBe(
'/workspace/platform'
)
})
it('self-validates clipboard HTML and rejects a forged semantic mismatch', () => {
const source = '<sup><a title="Source" href="./guide.md">[12]</a></sup>'
const host = document.createElement('div')
const editor = createEditor(source, TEST_KEY, host)
try {
editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, 1)))
const html = editor.view.serializeForClipboard(editor.state.selection.content()).dom.innerHTML
const pasted = createEditor('')
try {
pasted.commands.setContent(html, { contentType: 'html' })
expect(pasted.getMarkdown()).toBe(source)
} finally {
pasted.destroy()
}
const template = document.createElement('template')
template.innerHTML = html
const anchor = template.content.querySelector('a')
if (!anchor) {
throw new Error('expected serialized citation anchor')
}
anchor.textContent = '[99]'
const forged = createEditor('')
try {
forged.commands.setContent(template.innerHTML, { contentType: 'html' })
expect(nodeNames(forged)).not.toContain('richMarkdownHtmlSuperscriptLink')
expect(forged.state.doc.textContent).toContain('[99]')
} finally {
forged.destroy()
}
anchor.textContent = '[12]'
anchor.before('hidden sibling')
const siblingForged = createEditor('')
try {
siblingForged.commands.setContent(template.innerHTML, { contentType: 'html' })
expect(nodeNames(siblingForged)).not.toContain('richMarkdownHtmlSuperscriptLink')
expect(siblingForged.state.doc.textContent).toContain('hidden sibling')
} finally {
siblingForged.destroy()
}
} finally {
editor.destroy()
host.remove()
}
})
it('rejects oversized clipboard source before UTF-8 encoding', () => {
const editor = createEditor('')
const oversizedSource = 'x'.repeat(HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT + 1)
const encodeSpy = vi.spyOn(TextEncoder.prototype, 'encode')
try {
editor.commands.setContent(
`<sup data-rich-markdown-html-superscript-link="1" data-orca-superscript-link-source="${oversizedSource}"><a>x</a></sup>`,
{ contentType: 'html' }
)
expect(nodeNames(editor)).not.toContain('richMarkdownHtmlSuperscriptLink')
expect(encodeSpy.mock.calls.some(([value]) => value === oversizedSource)).toBe(false)
} finally {
encodeSpy.mockRestore()
editor.destroy()
}
})
it('maps citation labels into search, review, and empty-selection Cut', () => {
const source = 'Before <sup><a href="https://example.com">[12]</a></sup> after'
const editor = createEditor(source)
try {
const matches = findRichMarkdownSearchMatches(editor.state.doc, 'e [12] a')
expect(matches).toHaveLength(1)
expect(matches[0]?.touchesReadOnlyAtom).toBe(true)
let citationPosition = -1
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'richMarkdownHtmlSuperscriptLink') {
citationPosition = pos
}
})
const reviewRanges = findRichMarkdownSelectedTextRanges({
editor,
selectedText: 'Before [12] after'
})
expect(
reviewRanges.some(
(range) => range.from <= citationPosition && range.to >= citationPosition + 1
)
).toBe(true)
const citationHost = document.createElement('div')
const citationOnly = createEditor(
'<sup><a href="https://example.com">[12]</a></sup>',
TEST_KEY,
citationHost
)
try {
citationOnly.view.dispatch(
citationOnly.state.tr.setSelection(TextSelection.create(citationOnly.state.doc, 1))
)
const clipboard = new Map<string, string>()
const event = {
clipboardData: {
setData: (type: string, value: string) => clipboard.set(type, value),
getData: (type: string) => clipboard.get(type) ?? ''
},
preventDefault: () => {}
} as unknown as ClipboardEvent
expect(handleRichMarkdownCut(citationOnly.view, event)).toBe(true)
expect(clipboard.get('text/plain')).toBe('[12]')
expect(clipboard.get('text/html')).toContain('data-orca-superscript-link-source')
expect(citationOnly.getMarkdown()).toBe('')
} finally {
citationOnly.destroy()
citationHost.remove()
}
} finally {
editor.destroy()
}
})
it('enforces aggregate UTF-8 bounds even when oversized text precedes the citation', () => {
const citation = '<sup><a href="https://example.com">[1]</a></sup>'
const exactText = 'a'.repeat(RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT - 3)
const exact = createEditor(`${exactText}${citation}`)
try {
expect(
inspectRichMarkdownSourceOwningSlice(exact.state.doc.slice(0, exact.state.doc.content.size))
).toEqual({ containsSourceOwningNode: true, canPreserve: true })
} finally {
exact.destroy()
}
const over = createEditor(`${exactText}a${citation}`)
try {
expect(
inspectRichMarkdownSourceOwningSlice(over.state.doc.slice(0, over.state.doc.content.size))
).toEqual({ containsSourceOwningNode: true, canPreserve: false })
} finally {
over.destroy()
}
const astralOver = createEditor(
`${'😀'.repeat(Math.floor(RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT / 4))}${citation}`
)
try {
expect(
inspectRichMarkdownSourceOwningSlice(
astralOver.state.doc.slice(0, astralOver.state.doc.content.size)
)
).toEqual({ containsSourceOwningNode: true, canPreserve: false })
} finally {
astralOver.destroy()
}
})
it('counts visible leaf serializers in source-owning slice bounds', () => {
const editor = createEditor('')
try {
const schema = editor.state.schema
const hardBreak = schema.nodes.hardBreak
const docLink = schema.nodes.markdownDocLink
const citation = schema.nodes.richMarkdownHtmlSuperscriptLink
if (!hardBreak || !docLink || !citation) {
throw new Error('Expected rich Markdown leaf node types')
}
const citationAttrs = {
source: '<sup><a href="x">[1]</a></sup>',
href: 'x',
label: '[1]',
title: null
}
const exactDoc = schema.node('doc', null, [
schema.node('paragraph', null, [
schema.text('a'.repeat(RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT - 4)),
hardBreak.create(),
citation.create(citationAttrs)
])
])
expect(
inspectRichMarkdownSourceOwningSlice(exactDoc.slice(0, exactDoc.content.size))
).toEqual({ containsSourceOwningNode: true, canPreserve: true })
const overHardBreakDoc = schema.node('doc', null, [
schema.node('paragraph', null, [
schema.text('a'.repeat(RICH_MARKDOWN_SOURCE_OWNING_PASTE_LIMIT - 3)),
hardBreak.create(),
citation.create(citationAttrs)
])
])
expect(
inspectRichMarkdownSourceOwningSlice(
overHardBreakDoc.slice(0, overHardBreakDoc.content.size)
)
).toEqual({ containsSourceOwningNode: true, canPreserve: false })
const oversizedLeafDoc = schema.node('doc', null, [
schema.node('paragraph', null, [
docLink.create({ target: 'Guide', label: 'x'.repeat(300_000) }),
citation.create(citationAttrs)
])
])
expect(
inspectRichMarkdownSourceOwningSlice(
oversizedLeafDoc.slice(0, oversizedLeafDoc.content.size)
)
).toEqual({ containsSourceOwningNode: true, canPreserve: false })
} finally {
editor.destroy()
}
})
it('remaps review text across adjacent citation atoms without inventing spaces', () => {
const editor = createEditor(
'<sup><a href="https://one.example">[1]</a></sup><sup><a href="https://two.example">[2]</a></sup>'
)
try {
const ranges = findRichMarkdownSelectedTextRanges({ editor, selectedText: '[1][2]' })
expect(ranges).toHaveLength(1)
expect(ranges[0]).toEqual({ from: 1, to: 3 })
} finally {
editor.destroy()
}
})
it('preserves block separators in visible text and review remapping', () => {
const editor = createEditor('foo\n\nbar')
try {
expect(getRichMarkdownVisibleText(editor.state.doc)).toBe('foo\nbar')
expect(findRichMarkdownSelectedTextRanges({ editor, selectedText: 'foo bar' })).toEqual([
{ from: 1, to: 4 },
{ from: 6, to: 9 }
])
} finally {
editor.destroy()
}
})
it('maps dense search matches with a monotonic segment walk', () => {
const citation = '<sup><a href="https://example.com">[1]</a></sup>'
const editor = createEditor(`x${citation}`.repeat(200))
try {
const stats = { segmentVisits: 0 }
expect(findRichMarkdownSearchMatches(editor.state.doc, 'x', undefined, stats)).toHaveLength(
200
)
expect(stats.segmentVisits).toBeLessThan(1_000)
} finally {
editor.destroy()
}
})
it('does not let search bridge omitted atoms or block boundaries', () => {
const docLink = createEditor('foo[[Guide]]bar')
try {
expect(findRichMarkdownSearchMatches(docLink.state.doc, 'foobar')).toEqual([])
expect(
findRichMarkdownSearchMatches(docLink.state.doc, 'Guide')[0]?.touchesReadOnlyAtom
).toBe(true)
} finally {
docLink.destroy()
}
const rawHtml = createEditor('foo<kbd>bar')
try {
expect(findRichMarkdownSearchMatches(rawHtml.state.doc, 'foobar')).toEqual([])
} finally {
rawHtml.destroy()
}
const blocks = createEditor('foo\n\nbar')
try {
expect(findRichMarkdownSearchMatches(blocks.state.doc, 'foobar')).toEqual([])
} finally {
blocks.destroy()
}
})
})
@@ -0,0 +1,218 @@
import { Node } from '@tiptap/core'
import type { RichMarkdownSourceTransport } from './rich-markdown-source-transport'
import {
HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT,
parseHtmlSuperscriptLinkSource,
type HtmlSuperscriptLinkSource
} from './rich-markdown-html-superscript-link-source'
import { projectMarkdownHrefForClipboard } from './markdown-internal-links'
import {
classifyHtmlSuperscriptLinkAction,
type RichMarkdownHtmlSuperscriptLinkContext
} from './rich-markdown-html-superscript-link-context'
import { translate } from '@/i18n/i18n'
const CLIPBOARD_VERSION = '1'
const MARKER_ATTRIBUTE = 'data-rich-markdown-html-superscript-link'
const SOURCE_ATTRIBUTE = 'data-orca-superscript-link-source'
const clipboardEncoder = new TextEncoder()
export function createRichMarkdownHtmlSuperscriptLink(
transport: RichMarkdownSourceTransport,
context: RichMarkdownHtmlSuperscriptLinkContext
) {
return Node.create({
name: 'richMarkdownHtmlSuperscriptLink',
inline: true,
group: 'inline',
atom: true,
selectable: true,
addAttributes() {
return {
source: { default: '', rendered: false },
href: { default: '', rendered: false },
label: { default: '', rendered: false },
title: { default: null, rendered: false }
}
},
markdownTokenName: 'richMarkdownHtmlSuperscriptLink',
markdownTokenizer: {
name: 'richMarkdownHtmlSuperscriptLink',
level: 'inline',
start: transport.startFor('html-superscript-link'),
tokenize(source) {
const matched = transport.match(source, 'html-superscript-link')
if (!matched) {
return undefined
}
const parsed = parseStructuredPayload(matched.value)
if (!parsed) {
return undefined
}
return {
type: 'richMarkdownHtmlSuperscriptLink',
raw: matched.raw,
citation: parsed
}
}
},
parseMarkdown: (token, helpers) => {
const citation = (token as { citation?: HtmlSuperscriptLinkSource }).citation
if (token.type !== 'richMarkdownHtmlSuperscriptLink' || !citation) {
return []
}
return helpers.createNode('richMarkdownHtmlSuperscriptLink', citation)
},
renderMarkdown: (node) => String(node.attrs?.source ?? ''),
renderText: ({ node }) => String(node.attrs.label ?? ''),
parseHTML() {
return [
{
tag: `sup[${MARKER_ATTRIBUTE}]`,
getAttrs: (element: HTMLElement) => validateClipboardElement(element)
}
]
},
renderHTML({ node }) {
const citation = node.attrs as HtmlSuperscriptLinkSource
const projectedHref = projectMarkdownHrefForClipboard(citation.href)
const anchorAttributes: Record<string, string> = {}
if (projectedHref !== null) {
anchorAttributes.href = projectedHref
}
if (citation.title !== null) {
anchorAttributes.title = citation.title
}
return [
'sup',
{
[MARKER_ATTRIBUTE]: CLIPBOARD_VERSION,
[SOURCE_ATTRIBUTE]: citation.source
},
['a', anchorAttributes, citation.label]
]
},
addNodeView() {
return ({ node }) => {
const dom = document.createElement('sup')
dom.setAttribute(MARKER_ATTRIBUTE, '')
dom.setAttribute('contenteditable', 'false')
const label = document.createElement('span')
label.className = 'rich-markdown-html-superscript-link'
label.textContent = String(node.attrs.label ?? '')
dom.appendChild(label)
const updateActionability = (): void => {
const href = String(node.attrs.href ?? '')
const actionable = classifyHtmlSuperscriptLinkAction(href, context.getSnapshot())
label.setAttribute(
'aria-label',
actionable
? translate(
'auto.components.editor.richMarkdownHtmlSuperscriptLink.availableAriaLabel',
'{{value0}}, link to {{value1}}',
{ value0: String(node.attrs.label ?? ''), value1: href }
)
: translate(
'auto.components.editor.richMarkdownHtmlSuperscriptLink.unavailableAriaLabel',
'{{value0}}, citation link unavailable',
{ value0: String(node.attrs.label ?? '') }
)
)
if (actionable) {
label.setAttribute('role', 'link')
} else {
label.removeAttribute('role')
}
label.toggleAttribute('data-actionable', actionable)
}
updateActionability()
const unsubscribe = context.subscribe(updateActionability)
return { dom, destroy: unsubscribe }
}
}
})
}
function parseStructuredPayload(value: string): HtmlSuperscriptLinkSource | null {
let candidate: unknown
try {
candidate = JSON.parse(value)
} catch {
return null
}
if (!isCitationShape(candidate)) {
return null
}
const parsed = parseHtmlSuperscriptLinkSource(candidate.source)
return parsed && sameCitation(parsed, candidate) ? parsed : null
}
function validateClipboardElement(element: HTMLElement): false | Record<string, unknown> {
if (
element.getAttribute(MARKER_ATTRIBUTE) !== CLIPBOARD_VERSION ||
!hasOnlyAttributes(element, [MARKER_ATTRIBUTE, SOURCE_ATTRIBUTE, 'data-pm-slice'])
) {
return false
}
const source = element.getAttribute(SOURCE_ATTRIBUTE)
if (
!source ||
source.length > HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT ||
clipboardEncoder.encode(source).byteLength > HTML_SUPERSCRIPT_LINK_SOURCE_LIMIT
) {
return false
}
const parsed = parseHtmlSuperscriptLinkSource(source)
const anchor = element.firstElementChild
if (
!parsed ||
element.childNodes.length !== 1 ||
element.children.length !== 1 ||
!anchor ||
element.firstChild !== anchor ||
anchor.tagName !== 'A' ||
!hasOnlyAttributes(anchor, ['href', 'title']) ||
anchor.childNodes.length !== 1 ||
anchor.firstChild?.nodeType !== window.Node.TEXT_NODE ||
anchor.textContent !== parsed.label ||
anchor.getAttribute('title') !== parsed.title ||
anchor.getAttribute('href') !== projectMarkdownHrefForClipboard(parsed.href)
) {
return false
}
return parsed
}
function hasOnlyAttributes(element: Element, allowed: string[]): boolean {
const allowedSet = new Set(allowed)
return Array.from(element.attributes).every((attribute) => allowedSet.has(attribute.name))
}
function isCitationShape(value: unknown): value is HtmlSuperscriptLinkSource {
if (!value || typeof value !== 'object') {
return false
}
const candidate = value as Record<string, unknown>
return (
Object.keys(candidate).length === 4 &&
typeof candidate.source === 'string' &&
typeof candidate.href === 'string' &&
typeof candidate.label === 'string' &&
(typeof candidate.title === 'string' || candidate.title === null)
)
}
function sameCitation(left: HtmlSuperscriptLinkSource, right: HtmlSuperscriptLinkSource): boolean {
return (
left.source === right.source &&
left.href === right.href &&
left.label === right.label &&
left.title === right.title
)
}
@@ -1,10 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { Markdown } from '@tiptap/markdown'
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
import { createRichMarkdownKeyHandler, type KeyHandlerContext } from './rich-markdown-key-handler'
const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })]
const extensions = [StarterKit, createIsolatedMarkdownExtensionForTests()]
function createEditor(content: object): Editor {
return new Editor({
@@ -67,6 +67,18 @@ function createContext(editor: Editor, typedMarker: boolean): KeyHandlerContext
typedEmptyOrderedListMarkerRef: { current: typedMarker },
flushPendingSerialization: vi.fn(),
openSearchRef: { current: vi.fn() },
linkBubbleOwnerId: 'test-owner',
htmlSuperscriptLinkContext: {
getSnapshot: () => ({
sourceFilePath: '/repo/README.md',
worktreeId: 'worktree-1',
worktreeRoot: '/repo',
sourceOwner: { kind: 'local' as const },
version: 0
}),
subscribe: () => () => {},
update: () => {}
},
setIsEditingLink: vi.fn(),
setLinkBubble: vi.fn(),
setSelectedCommandIndex: vi.fn(),
@@ -4,7 +4,7 @@ import { getShortcutPlatform } from '@/lib/shortcut-platform'
import { useAppStore } from '@/store'
import { isMarkdownPreviewFindShortcut } from './markdown-preview-search'
import { editorShortcutMatches } from './editor-shortcuts'
import { getLinkBubblePosition, type LinkBubbleState } from './RichMarkdownLinkBubble'
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
import { commitRow, type DocLinkMenuRow, type DocLinkMenuState } from './rich-markdown-commands'
import {
runSlashCommand,
@@ -18,6 +18,9 @@ import {
exitTrailingEmptyOrderedListItem
} from './rich-markdown-list-continuation'
import { deleteAdjacentEmptyParagraph } from './rich-markdown-empty-paragraph-delete'
import { handleRichMarkdownCitationKey } from './rich-markdown-citation-keyboard'
import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
import { handleRichMarkdownLinkShortcut } from './rich-markdown-link-shortcut'
export type KeyHandlerContext = {
isMac: boolean
@@ -44,6 +47,9 @@ export type KeyHandlerContext = {
setSelectedDocLinkIndex: Dispatch<SetStateAction<number>>
setSlashMenu: Dispatch<SetStateAction<SlashMenuState | null>>
setDocLinkMenu: (menu: DocLinkMenuState | null) => void
openSelectedHtmlSuperscriptLink?: () => boolean
linkBubbleOwnerId: string
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
}
function isComposingMarkdownInput(event: KeyboardEvent, editor: Editor | null): boolean {
@@ -100,6 +106,16 @@ export function createRichMarkdownKeyHandler(
): (_view: unknown, event: KeyboardEvent) => boolean {
return (_view, event) => {
const mod = ctx.isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
if (
handleRichMarkdownCitationKey({
editor: ctx.editorRef.current,
event,
linkBubbleOwnerId: ctx.linkBubbleOwnerId,
onOpen: ctx.openSelectedHtmlSuperscriptLink
})
) {
return true
}
if (
isMarkdownPreviewFindShortcut(
event,
@@ -131,29 +147,18 @@ export function createRichMarkdownKeyHandler(
return true
}
// Link: Cmd/Ctrl+K — insert or edit a hyperlink.
if (mod && event.key.toLowerCase() === 'k') {
event.preventDefault()
const ed = ctx.editorRef.current
if (!ed) {
return true
}
if (ctx.isEditingLinkRef.current) {
ctx.setIsEditingLink(false)
if (!ed.isActive('link')) {
ctx.setLinkBubble(null)
}
ed.commands.focus()
return true
}
const pos = getLinkBubblePosition(ed, ctx.rootRef.current)
if (pos) {
const href = ed.isActive('link') ? (ed.getAttributes('link').href as string) || '' : ''
ctx.setLinkBubble({ href, ...pos })
ctx.setIsEditingLink(true)
}
if (
handleRichMarkdownLinkShortcut({
editor: ctx.editorRef.current,
event,
htmlSuperscriptLinkContext: ctx.htmlSuperscriptLinkContext,
isEditing: ctx.isEditingLinkRef.current,
isMac: ctx.isMac,
root: ctx.rootRef.current,
setEditing: ctx.setIsEditingLink,
setLinkBubble: ctx.setLinkBubble
})
) {
return true
}
@@ -17,6 +17,8 @@ type RichMarkdownLargeTextPasteOptions = {
measureYieldAfterCodeUnits?: number
yieldToEventLoop?: () => Promise<void>
canContinue?: (editor: Editor) => boolean
plainTextOverride?: string
htmlTextOverride?: string
}
export type RichMarkdownLargeTextPasteResult =
@@ -188,8 +190,8 @@ export function handleRichMarkdownLargeTextPaste(
return false
}
const text = readPlainText(event)
const html = readHtmlText(event)
const text = options.plainTextOverride ?? readPlainText(event)
const html = options.htmlTextOverride ?? readHtmlText(event)
const directMaxBytes = options.directMaxBytes ?? RICH_MARKDOWN_PASTE_DIRECT_MAX_BYTES
const maxBytes = options.maxBytes ?? RICH_MARKDOWN_PASTE_MAX_BYTES
const ownershipMeasurement = measureTextControlPasteByteLength(text, {
@@ -0,0 +1,42 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { toastError, toastSuccess } = vi.hoisted(() => ({
toastSuccess: vi.fn(),
toastError: vi.fn()
}))
vi.mock('sonner', () => ({ toast: { success: toastSuccess, error: toastError } }))
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
import { copyRichMarkdownLink } from './rich-markdown-link-clipboard'
describe('copyRichMarkdownLink', () => {
const writeClipboardText = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
;(window as unknown as { api: unknown }).api = { ui: { writeClipboardText } }
})
it('reports success only after the clipboard write resolves', async () => {
let resolveWrite: (() => void) | undefined
writeClipboardText.mockReturnValue(
new Promise<void>((resolve) => {
resolveWrite = resolve
})
)
const copy = copyRichMarkdownLink('https://example.com')
expect(toastSuccess).not.toHaveBeenCalled()
resolveWrite?.()
await copy
expect(toastSuccess).toHaveBeenCalledWith('Copied link')
})
it('reports a rejected clipboard write without throwing', async () => {
writeClipboardText.mockRejectedValue(new Error('clipboard unavailable'))
await expect(copyRichMarkdownLink('https://example.com')).resolves.toBeUndefined()
expect(toastError).toHaveBeenCalledWith('Failed to copy link')
})
})
@@ -0,0 +1,18 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
export async function copyRichMarkdownLink(href: string): Promise<void> {
try {
await window.api.ui.writeClipboardText(href)
toast.success(
translate('auto.components.editor.richMarkdownLinkClipboard.copiedLink', 'Copied link')
)
} catch {
toast.error(
translate(
'auto.components.editor.richMarkdownLinkClipboard.copyLinkFailed',
'Failed to copy link'
)
)
}
}
@@ -0,0 +1,130 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Editor } from '@tiptap/core'
import { NodeSelection, TextSelection } from '@tiptap/pm/state'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import { handleRichMarkdownLinkShortcut } from './rich-markdown-link-shortcut'
const TEST_KEY = '0123456789abcdef0123456789abcdef'
function createEditor(content: string): {
editor: Editor
context: ReturnType<typeof createRichMarkdownHtmlSuperscriptLinkContext>
root: HTMLDivElement
} {
const codec = createRichMarkdownEditorCodec(TEST_KEY)
const context = createRichMarkdownHtmlSuperscriptLinkContext({
sourceFilePath: '/repo/README.md',
worktreeId: 'worktree-1',
worktreeRoot: '/repo',
sourceOwner: { kind: 'local' }
})
const root = document.createElement('div')
document.body.appendChild(root)
const editor = new Editor({
element: root,
extensions: createRichMarkdownExtensions({
codec,
htmlSuperscriptLinks: true,
htmlSuperscriptLinkContext: context
}),
content: encodeRawMarkdownHtmlForRichEditor(content, codec, {
htmlSuperscriptLinks: true
}),
contentType: 'markdown'
})
return { editor, context, root }
}
function keyEvent(): KeyboardEvent {
return {
key: 'k',
metaKey: true,
ctrlKey: false,
preventDefault: vi.fn()
} as unknown as KeyboardEvent
}
afterEach(() => {
vi.restoreAllMocks()
})
describe('handleRichMarkdownLinkShortcut', () => {
it('opens the html-superscript action bubble for a citation NodeSelection', () => {
const source = '<sup><a href="https://example.com">1</a></sup>'
const { editor, context, root } = createEditor(`See note ${source}.`)
try {
let citationPos = -1
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'richMarkdownHtmlSuperscriptLink' && citationPos < 0) {
citationPos = pos
}
})
expect(citationPos).toBeGreaterThanOrEqual(0)
editor.view.dispatch(
editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, citationPos))
)
const setLinkBubble = vi.fn()
const setEditing = vi.fn()
const handled = handleRichMarkdownLinkShortcut({
editor,
event: keyEvent(),
htmlSuperscriptLinkContext: context,
isEditing: false,
isMac: true,
root,
setEditing,
setLinkBubble
})
expect(handled).toBe(true)
expect(setEditing).toHaveBeenCalledWith(false)
expect(setLinkBubble).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'html-superscript',
href: 'https://example.com',
label: '1'
})
)
} finally {
editor.destroy()
root.remove()
}
})
it('opens an editable markdown bubble when there is no link selection', () => {
const { editor, context, root } = createEditor('plain text')
try {
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 1)))
const setLinkBubble = vi.fn()
const setEditing = vi.fn()
handleRichMarkdownLinkShortcut({
editor,
event: keyEvent(),
htmlSuperscriptLinkContext: context,
isEditing: false,
isMac: true,
root,
setEditing,
setLinkBubble
})
expect(setEditing).toHaveBeenCalledWith(true)
expect(setLinkBubble).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'markdown',
href: ''
})
)
} finally {
editor.destroy()
root.remove()
}
})
})
@@ -0,0 +1,64 @@
import type { Editor } from '@tiptap/react'
import { getLinkBubblePosition } from './RichMarkdownLinkBubble'
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
import {
createEditableMarkdownLinkBubble,
getRichMarkdownSelectionLinkBubble
} from './rich-markdown-selected-link-actions'
export function handleRichMarkdownLinkShortcut({
editor,
event,
htmlSuperscriptLinkContext,
isEditing,
isMac,
root,
setEditing,
setLinkBubble
}: {
editor: Editor | null
event: KeyboardEvent
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
isEditing: boolean
isMac: boolean
root: HTMLElement | null
setEditing: (editing: boolean) => void
setLinkBubble: (bubble: LinkBubbleState | null) => void
}): boolean {
const modifier = isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
if (!modifier || event.key.toLowerCase() !== 'k') {
return false
}
event.preventDefault()
if (!editor) {
return true
}
if (isEditing) {
setEditing(false)
if (!editor.isActive('link')) {
setLinkBubble(null)
}
editor.commands.focus()
return true
}
// Why: NodeSelection on an HTML citation still has a bubble position, but
// markdown setLink/unsetLink cannot edit that atom — open the citation
// action bubble instead of the markdown edit field.
const selectionBubble = getRichMarkdownSelectionLinkBubble(
editor,
root,
htmlSuperscriptLinkContext
)
if (selectionBubble) {
setLinkBubble(selectionBubble)
setEditing(selectionBubble.kind === 'markdown')
return true
}
const position = getLinkBubblePosition(editor, root)
if (position) {
setLinkBubble(createEditableMarkdownLinkBubble('', position))
setEditing(true)
}
return true
}
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { Markdown } from '@tiptap/markdown'
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
import {
collapseEmptyListContinuationParagraph,
commitEmptyOrderedListMarkerAsText,
@@ -10,12 +10,12 @@ import {
isSingleEmptyTopLevelOrderedList
} from './rich-markdown-list-continuation'
const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })]
function createEditor(content: object): Editor {
// Why: each Editor needs its own marked registry; sharing one module-scoped
// extension accumulates tokenizer state across tests.
return new Editor({
element: null,
extensions,
extensions: [StarterKit, createIsolatedMarkdownExtensionForTests()],
content
})
}
@@ -3,6 +3,7 @@
import { Editor } from '@tiptap/core'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import { resetLocalImageSrcStateForTests } from './useLocalImageSrc'
import { setRichMarkdownImageResolverContext } from './rich-markdown-image-context'
@@ -40,7 +41,7 @@ describe('rich markdown local images', () => {
document.body.appendChild(host)
const editor = new Editor({
element: host,
extensions: createRichMarkdownExtensions(),
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
content: '![](diagram.png)',
contentType: 'markdown'
})
@@ -1,10 +1,10 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { Markdown } from '@tiptap/markdown'
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
import { normalizeEmptyListItems, normalizeSoftBreaks } from './rich-markdown-normalize'
const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })]
const extensions = [StarterKit, createIsolatedMarkdownExtensionForTests()]
afterEach(() => {
vi.restoreAllMocks()
@@ -1,7 +1,11 @@
import type { Editor } from '@tiptap/react'
import type { Slice } from '@tiptap/pm/model'
import type { EditorView } from '@tiptap/pm/view'
import { handleRichMarkdownImagePaste } from './rich-markdown-paste-image'
import { handleRichMarkdownLargeTextPaste } from './rich-markdown-large-text-paste'
import { handleRichMarkdownTerminalPathPaste } from './rich-markdown-terminal-path-paste'
import { inspectRichMarkdownSourceOwningSlice } from './rich-markdown-source-owning-slice'
import { getRichMarkdownVisibleText } from './rich-markdown-visible-text-map'
export type RichMarkdownPasteHandlerArgs = {
editor: Editor | null
@@ -9,6 +13,8 @@ export type RichMarkdownPasteHandlerArgs = {
filePath: string
worktreeId: string
runtimeEnvironmentId?: string | null
slice?: Slice
view?: EditorView
}
export function handleRichMarkdownPaste({
@@ -16,7 +22,9 @@ export function handleRichMarkdownPaste({
event,
filePath,
worktreeId,
runtimeEnvironmentId
runtimeEnvironmentId,
slice,
view
}: RichMarkdownPasteHandlerArgs): boolean {
if (
handleRichMarkdownImagePaste({
@@ -30,6 +38,36 @@ export function handleRichMarkdownPaste({
return true
}
const sourceOwningStatus = slice ? inspectRichMarkdownSourceOwningSlice(slice) : null
if (sourceOwningStatus?.containsSourceOwningNode && slice && view) {
if (sourceOwningStatus.canPreserve) {
// Why: dispatch directly so the transaction can carry paste/uiEvent
// metadata that editor.commands.insertContent would omit.
view.dispatch(
view.state.tr
.replaceSelection(slice)
.setMeta('paste', true)
.setMeta('uiEvent', 'paste')
.scrollIntoView()
)
return true
}
const visibleText = getRichMarkdownVisibleText(slice.content)
if (
handleRichMarkdownLargeTextPaste(editor, event, {
plainTextOverride: visibleText,
htmlTextOverride: ''
})
) {
return true
}
if (visibleText && editor) {
event.preventDefault()
editor.commands.insertContent(visibleText)
}
return true
}
if (handleRichMarkdownTerminalPathPaste(editor, event)) {
return true
}
@@ -9,6 +9,9 @@ import {
} from './rich-markdown-range-bounds'
import type { RichMarkdownReviewNotePosition } from './rich-markdown-review-note-layout'
import { findRichMarkdownSelectedTextRanges } from './rich-markdown-review-text-ranges'
import { getRichMarkdownSelectionVisibleText } from './rich-markdown-visible-text-map'
import { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count'
export { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count'
const RICH_MARKDOWN_ANNOTATION_BUTTON_SIZE_PX = 24
const RICH_MARKDOWN_ANNOTATION_EDGE_PADDING_PX = 8
@@ -42,27 +45,6 @@ export type RichMarkdownAnnotationTarget = RichMarkdownComposerState & {
buttonLeft: number
}
export function countRichMarkdownReviewMarkdownLines(value: string): number {
if (value.length === 0) {
return 1
}
let lineCount = 1
for (let index = 0; index < value.length; index += 1) {
const charCode = value.charCodeAt(index)
if (charCode === 13) {
lineCount += 1
if (value.charCodeAt(index + 1) === 10) {
index += 1
}
continue
}
if (charCode === 10) {
lineCount += 1
}
}
return lineCount
}
function serializeRichMarkdownJson(editor: Editor, content: JSONContent[]): string {
return (editor.markdown?.serialize({ type: 'doc', content }) ?? '').trimEnd()
}
@@ -293,7 +275,7 @@ export function getRichMarkdownAnnotationTarget(
if (!rect) {
return null
}
const selectedText = window.getSelection()?.toString().trim() ?? ''
const selectedText = getRichMarkdownSelectionVisibleText(editor.state)
if (!selectedText) {
return null
}
@@ -0,0 +1,18 @@
export function countRichMarkdownReviewMarkdownLines(value: string): number {
if (value.length === 0) {
return 1
}
let lineCount = 1
for (let index = 0; index < value.length; index += 1) {
const charCode = value.charCodeAt(index)
if (charCode === 13) {
lineCount += 1
if (value.charCodeAt(index + 1) === 10) {
index += 1
}
} else if (charCode === 10) {
lineCount += 1
}
}
return lineCount
}
@@ -40,13 +40,13 @@ function buildSelectedText(wordCount: number): string {
}
describe('findRichMarkdownSelectedTextRanges', () => {
it('matches selected text across normalized whitespace and text nodes', () => {
it('matches selected text across adjacent text nodes without inventing whitespace', () => {
const editor = editorWithTextNodes([
{ isText: true, text: 'alpha' },
{ isText: true, text: 'beta' }
])
expect(findRichMarkdownSelectedTextRanges({ editor, selectedText: 'alpha\n beta' })).toEqual([
expect(findRichMarkdownSelectedTextRanges({ editor, selectedText: 'alphabeta' })).toEqual([
{ from: 0, to: 9 }
])
})
@@ -1,23 +1,25 @@
import type { Editor } from '@tiptap/react'
import type { RichMarkdownAnnotationHighlightRange } from './rich-markdown-annotation-highlight'
import { forEachRichMarkdownVisibleTextSegment } from './rich-markdown-visible-text-map'
type TextPosition = { from: number; to: number } | null
type NormalizedTextChar = {
value: string
pos: number | null
pos: TextPosition
}
type MatchState = {
readonly needle: string
readonly prefixTable: number[]
recentPositions: (number | null)[]
recentPositions: TextPosition[]
recentPositionWriteIndex: number
matchLength: number
positions: (number | null)[] | null
positions: TextPosition[] | null
}
type NormalizationState = {
previousWasWhitespace: boolean
emittedAnyText: boolean
}
export function findRichMarkdownSelectedTextRanges({
@@ -45,31 +47,32 @@ export function findRichMarkdownSelectedTextRanges({
positions: null
}
const normalizationState: NormalizationState = {
previousWasWhitespace: false,
emittedAnyText: false
previousWasWhitespace: false
}
// Why: review text can come from large pasted selections; stream the editor
// text instead of building per-character haystack/needle arrays.
editor.state.doc.nodesBetween(from ?? 0, to ?? editor.state.doc.content.size, (node, pos) => {
if (matchState.positions || !node.isText) {
return
forEachRichMarkdownVisibleTextSegment(
editor.state.doc,
from ?? 0,
to ?? editor.state.doc.content.size,
(segment) => {
for (let index = 0; index < segment.text.length && !matchState.positions; index += 1) {
processRawTextChar(
{
value: segment.text.charAt(index),
pos:
segment.kind === 'separator'
? null
: segment.kind === 'read-only-atom'
? { from: segment.from, to: segment.to }
: { from: segment.from + index, to: segment.from + index + 1 }
},
normalizationState,
matchState
)
}
return !matchState.positions
}
const nodeText = node.text
if (!nodeText) {
return
}
if (normalizationState.emittedAnyText) {
processRawTextChar({ value: ' ', pos: null }, normalizationState, matchState)
}
for (let index = 0; index < nodeText.length && !matchState.positions; index += 1) {
processRawTextChar(
{ value: nodeText.charAt(index), pos: pos + index },
normalizationState,
matchState
)
}
})
)
return matchState.positions ? positionsToRanges(matchState.positions) : []
}
@@ -101,7 +104,6 @@ function processRawTextChar(
if (isRichMarkdownWhitespace(code)) {
if (!normalizationState.previousWasWhitespace) {
processNormalizedTextChar({ value: ' ', pos: char.pos }, matchState)
normalizationState.emittedAnyText = true
}
normalizationState.previousWasWhitespace = true
return
@@ -109,7 +111,6 @@ function processRawTextChar(
processNormalizedTextChar(char, matchState)
normalizationState.previousWasWhitespace = false
normalizationState.emittedAnyText = true
}
function processNormalizedTextChar(char: NormalizedTextChar, matchState: MatchState): void {
@@ -128,7 +129,7 @@ function processNormalizedTextChar(char: NormalizedTextChar, matchState: MatchSt
}
}
function recordRecentPosition(pos: number | null, matchState: MatchState): void {
function recordRecentPosition(pos: TextPosition, matchState: MatchState): void {
if (matchState.recentPositions.length < matchState.needle.length) {
matchState.recentPositions.push(pos)
matchState.recentPositionWriteIndex =
@@ -141,8 +142,8 @@ function recordRecentPosition(pos: number | null, matchState: MatchState): void
(matchState.recentPositionWriteIndex + 1) % matchState.needle.length
}
function readRecentPositions(matchState: MatchState): (number | null)[] {
const positions: (number | null)[] = []
function readRecentPositions(matchState: MatchState): TextPosition[] {
const positions: TextPosition[] = []
for (let index = 0; index < matchState.needle.length; index += 1) {
const bufferIndex = (matchState.recentPositionWriteIndex + index) % matchState.needle.length
positions.push(matchState.recentPositions[bufferIndex] ?? null)
@@ -168,26 +169,26 @@ function buildPrefixTable(value: string): number[] {
return table
}
function positionsToRanges(positions: (number | null)[]): RichMarkdownAnnotationHighlightRange[] {
function positionsToRanges(positions: TextPosition[]): RichMarkdownAnnotationHighlightRange[] {
const ranges: RichMarkdownAnnotationHighlightRange[] = []
let rangeFrom: number | null = null
let rangeTo: number | null = null
for (const pos of positions) {
if (pos === null) {
for (const position of positions) {
if (position === null) {
continue
}
if (rangeFrom === null || rangeTo === null) {
rangeFrom = pos
rangeTo = pos + 1
rangeFrom = position.from
rangeTo = position.to
continue
}
if (pos === rangeTo) {
rangeTo += 1
if (position.from <= rangeTo) {
rangeTo = Math.max(rangeTo, position.to)
continue
}
ranges.push({ from: rangeFrom, to: rangeTo })
rangeFrom = pos
rangeTo = pos + 1
rangeFrom = position.from
rangeTo = position.to
}
if (rangeFrom !== null && rangeTo !== null) {
ranges.push({ from: rangeFrom, to: rangeTo })
@@ -6,7 +6,13 @@ import { findRichMarkdownSearchMatches } from './rich-markdown-search'
function docFromText(text: string, pos = 0): ProseMirrorNode {
return {
descendants(callback: (node: { isText: boolean; text?: string }, pos: number) => void) {
nodeSize: pos + text.length + 2,
content: { size: pos + text.length },
nodesBetween(
_from: number,
_to: number,
callback: (node: { isText: boolean; text?: string }, pos: number) => void
) {
callback({ isText: true, text }, pos)
}
} as unknown as ProseMirrorNode

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