mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
Merge remote-tracking branch 'origin/main' into remove-host-on-mobile-freeze-the-app
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+32
-163
@@ -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'
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }],
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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
|
||||
|
||||
+99
-10
@@ -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 & B' href='https://example.com/?a=1&b=2'>[¬it;]</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: '',
|
||||
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
Reference in New Issue
Block a user