mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
Merge branch 'main' of https://github.com/stablyai/orca into improve-agent-session
This commit is contained in:
+23
-12
@@ -81,16 +81,26 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- shard: '1/5'
|
||||
shard_name: 1-of-5
|
||||
- shard: '2/5'
|
||||
shard_name: 2-of-5
|
||||
- shard: '3/5'
|
||||
shard_name: 3-of-5
|
||||
- shard: '4/5'
|
||||
shard_name: 4-of-5
|
||||
- shard: '5/5'
|
||||
shard_name: 5-of-5
|
||||
- shard: '1/10'
|
||||
shard_name: 1-of-10
|
||||
- shard: '2/10'
|
||||
shard_name: 2-of-10
|
||||
- shard: '3/10'
|
||||
shard_name: 3-of-10
|
||||
- shard: '4/10'
|
||||
shard_name: 4-of-10
|
||||
- shard: '5/10'
|
||||
shard_name: 5-of-10
|
||||
- shard: '6/10'
|
||||
shard_name: 6-of-10
|
||||
- shard: '7/10'
|
||||
shard_name: 7-of-10
|
||||
- shard: '8/10'
|
||||
shard_name: 8-of-10
|
||||
- shard: '9/10'
|
||||
shard_name: 9-of-10
|
||||
- shard: '10/10'
|
||||
shard_name: 10-of-10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -142,8 +152,9 @@ jobs:
|
||||
path: out/
|
||||
|
||||
# Why: the Electron suite is wall-clock constrained on OSS runners, but
|
||||
# raising workers on one runner OOMs. Sharding keeps each VM at 4 workers
|
||||
# while splitting the 91-test headless suite across separate runners.
|
||||
# multiple Electron apps on one Xvfb VM contend on git/Chromium resources.
|
||||
# Sharding keeps each VM at one Playwright worker while splitting the
|
||||
# headless suite across separate runners.
|
||||
# SKIP_BUILD makes Playwright globalSetup reuse the single build job's
|
||||
# artifact instead of starting five concurrent electron-vite builds.
|
||||
# ORCA_E2E_FORWARD_APP_LOGS keeps startup failures visible when Electron
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<a href="https://github.com/stablyai/orca/releases"><img src="docs/assets/readme-downloads.svg" alt="Total downloads across all releases" /></a>
|
||||
<img src="https://badgen.net/github/license/stablyai/orca" alt="License" />
|
||||
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Join the Orca Discord" /></a>
|
||||
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="Follow Orca on X" /></a>
|
||||
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Supported platforms: macOS, Windows, and Linux" />
|
||||
</p>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="Descargas totales en todas las versiones" /></a>
|
||||
<img src="https://badgen.net/github/license/stablyai/orca" alt="Licencia" />
|
||||
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Únete al Discord de Orca" /></a>
|
||||
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="Sigue a Orca en X" /></a>
|
||||
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Plataformas compatibles: macOS, Windows y Linux" />
|
||||
</p>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="全リリースの合計ダウンロード数" /></a>
|
||||
<img src="https://badgen.net/github/license/stablyai/orca" alt="ライセンス" />
|
||||
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Orca の Discord に参加" /></a>
|
||||
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="X で Orca をフォロー" /></a>
|
||||
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="対応プラットフォーム: macOS、Windows、Linux" />
|
||||
</p>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="전체 릴리스 누적 다운로드 수" /></a>
|
||||
<img src="https://badgen.net/github/license/stablyai/orca" alt="라이선스" />
|
||||
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Orca Discord 참여" /></a>
|
||||
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="X에서 Orca 팔로우" /></a>
|
||||
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="지원 플랫폼: macOS, Windows, Linux" />
|
||||
</p>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="所有版本的总下载量" /></a>
|
||||
<img src="https://badgen.net/github/license/stablyai/orca" alt="许可证" />
|
||||
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="加入 Orca Discord" /></a>
|
||||
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="在 X 上关注 Orca" /></a>
|
||||
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="支持的平台:macOS、Windows 和 Linux" />
|
||||
</p>
|
||||
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
# Terminal Scroll Intent Architecture
|
||||
|
||||
## Problem
|
||||
|
||||
Terminal panes can jump or jitter when a user switches workspaces while a TUI
|
||||
pane is scrolled near, but not exactly at, the bottom. The most visible case is a
|
||||
Codex-style alternate-screen TUI in a split pane:
|
||||
|
||||
1. The user scrolls slightly up from the bottom.
|
||||
2. The user switches to another workspace.
|
||||
3. The user switches back.
|
||||
4. The terminal sometimes jumps to the top, jumps to the bottom, flashes at a
|
||||
wrong position, or restores an older viewport.
|
||||
|
||||
The current code tries to repair the viewport after layout, fit, split
|
||||
reparenting, hidden-output replay, and visibility resume. Those repairs compete
|
||||
with each other because none of them owns the user's scroll intent. A delayed
|
||||
restore can replay an old state after the user has already scrolled, while a
|
||||
follow-output path can force the bottom even when the user is reading scrollback.
|
||||
|
||||
## Reference Investigation Summary
|
||||
|
||||
The useful reference pattern is not a timer-based restore loop. It is an
|
||||
explicit pinned-to-bottom model owned by the terminal frontend:
|
||||
|
||||
- The frontend stores bottom-following state separately from xterm's transient
|
||||
`viewportY`.
|
||||
- `xterm.onScroll` is not used as user intent. In xterm, that event can be
|
||||
content-driven and can briefly report bottom during fast output or layout work.
|
||||
- User wheel and keyboard scroll commands update the intent.
|
||||
- `scrollToBottom()` is an explicit command that sets the intent back to
|
||||
follow-output.
|
||||
- Every write snapshots the intent before writing. If the terminal was following
|
||||
output, it scrolls to bottom after the write. If the user was pinned to a
|
||||
viewport, it preserves the prior viewport line.
|
||||
- Fit/resize uses the same rule: follow bottom if explicitly pinned, otherwise
|
||||
preserve the current viewport.
|
||||
- Alternate-screen state is tracked from `buffer.active.type`, but it is not a
|
||||
reason to infer user scroll intent from `onScroll`.
|
||||
|
||||
The reference implementation also patches private xterm internals to disable
|
||||
xterm's implicit scroll-to-bottom behavior and call the original bottom-scroll
|
||||
only from explicit owner paths. That gives a clean ownership boundary, but it is
|
||||
an escalation point for Orca because private internals increase xterm upgrade
|
||||
risk.
|
||||
|
||||
## Current Orca Risk Points
|
||||
|
||||
The current Orca branch already has several independent scroll actors:
|
||||
|
||||
- `src/renderer/src/lib/pane-manager/pane-scroll.ts` captures `viewportY`,
|
||||
`baseY`, bottom state, and sometimes a marker, then restores using immediate,
|
||||
rAF, and timeout paths.
|
||||
- `src/renderer/src/lib/pane-manager/pane-tree-ops.ts` captures and restores
|
||||
scroll around `safeFit()`.
|
||||
- `src/renderer/src/lib/pane-manager/pane-split-scroll.ts` schedules split
|
||||
reparent restores across rAF and timeout phases.
|
||||
- `src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts`
|
||||
listens to `terminal.onScroll` and stores snapshots while visible.
|
||||
- `src/renderer/src/components/terminal-pane/pty-connection.ts` writes PTY output
|
||||
through foreground, background, hidden-output skip, snapshot replay, and
|
||||
restore paths.
|
||||
- `src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts` can
|
||||
write immediately, enqueue foreground writes, coalesce synchronized output, or
|
||||
drain hidden/background chunks later.
|
||||
|
||||
The biggest architectural mismatch is the visibility memory hook's use of
|
||||
`terminal.onScroll` as a snapshot trigger. That event is not a reliable user
|
||||
scroll signal. During workspace switching, output parsing, hidden replay, or fit
|
||||
can make it persist transient positions that later appear as "older position"
|
||||
restores.
|
||||
|
||||
## Design Goal
|
||||
|
||||
Make terminal viewport movement a result of explicit scroll intent, not a side
|
||||
effect of visibility, output, or layout timing.
|
||||
|
||||
The terminal should have one active scroll intent per pane:
|
||||
|
||||
- `followOutput`: the terminal is logically pinned to the live output bottom.
|
||||
New output, explicit focus-follow requests, and fits may keep it at bottom.
|
||||
- `pinnedViewport`: the user is reading a specific viewport. New output,
|
||||
workspace switches, hidden-output replay, and fits must not move the viewport
|
||||
except when scrollback pruning or buffer replacement makes the exact line
|
||||
impossible.
|
||||
|
||||
## State Model
|
||||
|
||||
Add a focused module, for example:
|
||||
|
||||
`src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts`
|
||||
|
||||
Suggested state:
|
||||
|
||||
```ts
|
||||
export type TerminalScrollIntentKind = 'followOutput' | 'pinnedViewport'
|
||||
|
||||
export type TerminalScrollIntent = {
|
||||
kind: TerminalScrollIntentKind
|
||||
bufferType: 'normal' | 'alternate'
|
||||
viewportY: number
|
||||
baseY: number
|
||||
capturedAt: number
|
||||
}
|
||||
```
|
||||
|
||||
The state should be keyed by the live `Terminal` or by pane leaf identity where
|
||||
it needs to survive pane replacement. For normal workspace switches that keep
|
||||
the same xterm instance, the live terminal-keyed state should be authoritative.
|
||||
|
||||
## Intent Transitions
|
||||
|
||||
Only these events should change scroll intent:
|
||||
|
||||
- User wheel scrolls upward: set `pinnedViewport` immediately before xterm write
|
||||
callbacks or rAF can pull the viewport back down.
|
||||
- User wheel scrolls downward: after xterm applies the scroll, recompute whether
|
||||
the viewport reached bottom. If yes, set `followOutput`; otherwise keep
|
||||
`pinnedViewport`.
|
||||
- User keyboard scroll commands: apply the same rules as wheel.
|
||||
- Explicit `scrollToBottom()`, "follow output", or focus command with
|
||||
follow-output semantics: set `followOutput`.
|
||||
- Programmatic `scrollToTop()` or page/line scroll commands: set
|
||||
`pinnedViewport`, unless the resulting viewport is bottom.
|
||||
- Buffer change to alternate screen: record `bufferType`, but do not infer
|
||||
follow-output from `onScroll`.
|
||||
- Buffer return to normal screen: recompute from current viewport only if no
|
||||
stronger user intent exists for the normal buffer.
|
||||
- Scrollback prune: clamp a pinned viewport to the nearest valid line without
|
||||
changing intent.
|
||||
- Terminal remount/replay: restore from durable fallback only if the live xterm
|
||||
instance was actually replaced.
|
||||
|
||||
Do not use xterm `onScroll` to set intent. It may still be useful as a passive
|
||||
diagnostic signal, but it should not persist authoritative scroll state.
|
||||
|
||||
## Write Contract
|
||||
|
||||
All PTY output that reaches xterm should pass through one wrapper that enforces
|
||||
intent around the actual `terminal.write` call.
|
||||
|
||||
Suggested owner:
|
||||
|
||||
`src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts`
|
||||
|
||||
Contract:
|
||||
|
||||
1. Capture intent and current `viewportY` before the write is scheduled.
|
||||
2. Execute the write through the existing foreground/background/coalescing path.
|
||||
3. After the write has parsed enough for xterm buffer state to be meaningful:
|
||||
- If intent is `followOutput`, scroll to bottom.
|
||||
- If intent is `pinnedViewport`, clamp and restore the saved viewport line.
|
||||
4. Do not schedule multi-frame retries for normal output. A single post-write
|
||||
enforcement point should be enough for standard writes.
|
||||
|
||||
For foreground synchronized-output holds, the enforcement point should run after
|
||||
the held/coalesced frame is released, not for each partial cursor-hide chunk.
|
||||
|
||||
For background writes, preserve `pinnedViewport` if the pane is hidden or
|
||||
inactive. Hidden output should not silently repin the terminal.
|
||||
|
||||
## Fit And Resize Contract
|
||||
|
||||
`safeFit()` should stop using generic delayed scroll restoration as its normal
|
||||
behavior.
|
||||
|
||||
New rule:
|
||||
|
||||
1. If dimensions do not change, do nothing.
|
||||
2. Before fit, capture current intent and viewport.
|
||||
3. Fit.
|
||||
4. If intent is `followOutput`, scroll to bottom.
|
||||
5. If intent is `pinnedViewport`, restore the saved viewport line clamped to the
|
||||
new `baseY`.
|
||||
|
||||
Avoid fitting hidden or zero-geometry panes. Existing geometry guards should
|
||||
remain because fitting to transient workspace-switch geometry can send bad sizes
|
||||
to the PTY and trigger TUI redraw churn.
|
||||
|
||||
## Visibility And Workspace Switch Contract
|
||||
|
||||
Workspace switching should not be a scroll operation.
|
||||
|
||||
On hide:
|
||||
|
||||
- Capture the current intent once.
|
||||
- Do not run scroll restore.
|
||||
- Do not update intent from `onScroll`.
|
||||
- Continue hidden-output throttling/snapshot behavior as today.
|
||||
|
||||
On show:
|
||||
|
||||
- Reattach or refresh renderer resources as needed.
|
||||
- Flush only the bounded amount of hidden output required for the active pane.
|
||||
- Apply intent once after the visible output catch-up:
|
||||
- `followOutput` goes to bottom.
|
||||
- `pinnedViewport` stays pinned.
|
||||
- Do not run repeated rAF/timeout scroll restores.
|
||||
|
||||
If the underlying terminal instance was replaced, use durable fallback state.
|
||||
If the instance stayed alive, its live scroll intent is authoritative.
|
||||
|
||||
## Hidden Output And Snapshot Replay
|
||||
|
||||
Hidden-output snapshot replay is a true fallback path because it clears and
|
||||
reconstructs xterm content. It needs scroll handling, but it should still obey
|
||||
intent:
|
||||
|
||||
- Before snapshot replay, capture intent and viewport.
|
||||
- Replay serialized content.
|
||||
- Fit only if needed and only with valid visible geometry.
|
||||
- Reapply the captured intent once:
|
||||
- `followOutput`: bottom.
|
||||
- `pinnedViewport`: previous viewport line, clamped.
|
||||
- Do not let replay set follow-output just because the replayed buffer ends at
|
||||
bottom.
|
||||
|
||||
If hidden output arrives while the user is pinned, background catch-up should not
|
||||
move the visible viewport on activation.
|
||||
|
||||
## Split Reparenting
|
||||
|
||||
DOM reparenting can reset browser scroll state and WebGL resources. This is a
|
||||
legitimate place for a fallback restore, but it should be scoped:
|
||||
|
||||
- Capture intent before reparent.
|
||||
- Reparent.
|
||||
- Reattach renderer if required.
|
||||
- Apply intent once after DOM settles.
|
||||
- Avoid restoring alternate-screen scrollback, because alternate screen has no
|
||||
normal scrollback and a TUI owns its cursor.
|
||||
|
||||
The current split-specific rAF/timer code should become a local fallback for DOM
|
||||
reparenting only, not a general model copied into workspace switching.
|
||||
|
||||
## Performance Constraints
|
||||
|
||||
The design must preserve Orca's terminal performance priorities:
|
||||
|
||||
- Do not keep all hidden terminals hot-rendering.
|
||||
- Keep PTY/session/xterm state warm when feasible, but suspend hidden rendering
|
||||
work and throttle hidden output as today.
|
||||
- Avoid per-output layout reads. Intent enforcement should read xterm buffer
|
||||
fields, not DOM geometry.
|
||||
- Avoid multi-frame restore loops. They cause visible jitter and keep the
|
||||
renderer busy after activation.
|
||||
- Keep active-pane hidden-output catch-up bounded. Inactive visible split panes
|
||||
can catch up over later frames.
|
||||
- Do not send resize/SIGWINCH unless dimensions actually changed and the
|
||||
renderer is the authoritative size owner.
|
||||
|
||||
The intended steady-state cost is one small buffer-state capture per xterm write
|
||||
batch, not per byte and not per animation frame.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
1. Add `terminal-scroll-intent.ts`.
|
||||
- Track `followOutput` vs `pinnedViewport`.
|
||||
- Provide helpers for user scroll, explicit bottom, write capture, fit
|
||||
capture, and intent enforcement.
|
||||
|
||||
2. Replace visibility-memory `onScroll` ownership.
|
||||
- Remove authoritative snapshot updates from `terminal.onScroll`.
|
||||
- Add capture-phase wheel listeners and keyboard scroll command hooks.
|
||||
- Keep `onScroll` only for diagnostics if needed.
|
||||
|
||||
3. Route explicit scroll commands through intent helpers.
|
||||
- `scrollToBottom()` sets `followOutput`.
|
||||
- `scrollToTop()`, page up, and line up set `pinnedViewport`.
|
||||
- Downward commands recompute after the command.
|
||||
|
||||
4. Wrap output writes.
|
||||
- Add an intent-aware writer boundary in the output scheduler or in a narrow
|
||||
wrapper used by the scheduler.
|
||||
- Ensure foreground coalescing applies intent after the coalesced frame.
|
||||
- Ensure background drains preserve pinned viewports.
|
||||
|
||||
5. Convert `safeFit()` to intent-aware fit.
|
||||
- Capture intent before fit.
|
||||
- Apply once after fit.
|
||||
- Remove generic deferred scroll restore from normal fit.
|
||||
|
||||
6. Limit restore fallbacks.
|
||||
- Keep fallback restore only for true remount/replay/reparent cases.
|
||||
- Remove workspace-switch rAF/timeout restore loops once intent enforcement is
|
||||
in place.
|
||||
|
||||
7. Add focused tests.
|
||||
- User scroll up sets `pinnedViewport`.
|
||||
- Output while pinned preserves viewport.
|
||||
- Output while following scrolls to bottom.
|
||||
- Fit while pinned preserves viewport.
|
||||
- Fit while following stays at bottom.
|
||||
- Hidden-output replay while pinned does not repin.
|
||||
- Workspace hide/show does not change intent.
|
||||
- `onScroll` does not mutate intent.
|
||||
|
||||
8. Add an E2E reproduction.
|
||||
- Use the existing `scroll-primary` and `scroll-secondary` worktrees.
|
||||
- Top pane in `scroll-primary` contains the TUI with enough scrollback.
|
||||
- Scroll slightly above bottom, switch to `scroll-secondary`, then switch back.
|
||||
- Assert the terminal remains within a small viewport tolerance and does not
|
||||
visit top/bottom during the transition.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Switching away and back does not move a pinned TUI viewport.
|
||||
- No visible flash to top or bottom during activation.
|
||||
- Scrolling all the way to bottom re-enables follow-output and does not later
|
||||
restore an older pinned position.
|
||||
- Hidden output in inactive workspaces does not change visible scroll position
|
||||
until the user explicitly follows output or reaches bottom.
|
||||
- Alternate-screen TUIs do not receive extra restore/fill behavior that shifts
|
||||
their cursor or scroll region.
|
||||
- The active pane remains responsive during heavy hidden-output catch-up.
|
||||
|
||||
## Open Decisions
|
||||
|
||||
- Whether Orca should patch private xterm bottom-scroll internals. The reference
|
||||
pattern does this for strict ownership, but Orca should first try public API
|
||||
enforcement at write/fit boundaries and only escalate if xterm continues to
|
||||
auto-follow independently.
|
||||
- Whether scroll intent should be stored only by live `Terminal` instance or also
|
||||
mirrored by leaf ID for remount fallback. The initial implementation should
|
||||
use live instance state plus a leaf-keyed fallback for true replacement.
|
||||
- Whether alternate screen needs a separate intent record from normal screen.
|
||||
The first version can use one record with `bufferType`; a separate normal vs
|
||||
alternate record is justified only if testing shows mode switches overwrite
|
||||
user intent.
|
||||
@@ -192,7 +192,16 @@ describe('remote hook service installers', () => {
|
||||
})
|
||||
|
||||
it('installs remote Codex hooks with matching trust entries', async () => {
|
||||
const { sftp, fs } = createFakeSftp()
|
||||
const { sftp, fs } = createFakeSftp({
|
||||
'/home/dev/.codex/hooks.json': `${JSON.stringify({
|
||||
hooks: {},
|
||||
_managed: {
|
||||
'external-manager': {
|
||||
Stop: [0]
|
||||
}
|
||||
}
|
||||
})}\n`
|
||||
})
|
||||
|
||||
const status = await new CodexHookService().installRemote(sftp, '/home/dev/')
|
||||
|
||||
@@ -200,7 +209,9 @@ describe('remote hook service installers', () => {
|
||||
expect(status.configPath).toBe('/home/dev/.codex/hooks.json')
|
||||
const hooks = JSON.parse(fs.files.get('/home/dev/.codex/hooks.json')!) as {
|
||||
hooks: Record<string, { hooks: { command: string }[] }[]>
|
||||
_managed?: unknown
|
||||
}
|
||||
expect(hooks._managed).toEqual({ 'external-manager': { Stop: [0] } })
|
||||
for (const eventName of [
|
||||
'SessionStart',
|
||||
'UserPromptSubmit',
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { createReadStream } from 'fs'
|
||||
import { readdir, stat } from 'fs/promises'
|
||||
import { createInterface } from 'readline'
|
||||
import { extname, join } from 'path'
|
||||
import {
|
||||
isPathInsideOrEqual,
|
||||
normalizeRuntimePathForComparison
|
||||
} from '../../shared/cross-platform-path'
|
||||
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import { errorMessage, extractString, parseJsonObject } from './session-scanner-values'
|
||||
|
||||
// Reading a few lines of one transcript per project dir is enough to learn that
|
||||
// dir's cwd; cap both so a giant or cwd-less transcript can't stall the scan.
|
||||
const REPRESENTATIVE_CWD_LINE_LIMIT = 200
|
||||
const REPRESENTATIVE_FILE_LIMIT = 3
|
||||
const CLAUDE_EXTENSIONS = new Set(['.jsonl'])
|
||||
|
||||
/**
|
||||
* Fully include the transcripts of Claude project directories whose cwd falls
|
||||
* inside the active workspace/project paths.
|
||||
*
|
||||
* Why: Claude organizes `~/.claude/projects/<cwd-encoded>/` one directory per
|
||||
* cwd. The global scan is recency-capped, so a project the user hasn't touched
|
||||
* recently can drop off the list entirely even though `claude --resume` still
|
||||
* finds it. For scoped panel views we resolve each project dir's cwd cheaply and
|
||||
* bypass the cap for the ones that belong to the active scope.
|
||||
*/
|
||||
export async function discoverInScopeClaudeFiles(args: {
|
||||
rootDirs: readonly string[]
|
||||
scopePaths: readonly string[]
|
||||
limit: number
|
||||
excludedFilePaths: ReadonlySet<string>
|
||||
issues: AiVaultScanIssue[]
|
||||
}): Promise<FileWithMtime[]> {
|
||||
if (args.scopePaths.length === 0 || args.limit <= 0) {
|
||||
return []
|
||||
}
|
||||
const scopeProjectPrefixes = claudeProjectScopePrefixes(args.scopePaths)
|
||||
const collected = new Map<string, FileWithMtime>()
|
||||
for (const rootDir of args.rootDirs) {
|
||||
for (const projectDir of await listProjectDirs(rootDir, scopeProjectPrefixes)) {
|
||||
const cwd = await readProjectDirCwd(projectDir)
|
||||
if (!cwd || !args.scopePaths.some((scopePath) => isCwdInsideScopePath(scopePath, cwd))) {
|
||||
continue
|
||||
}
|
||||
await collectClaudeFiles({
|
||||
projectDir,
|
||||
issues: args.issues,
|
||||
collected,
|
||||
limit: args.limit,
|
||||
excludedFilePaths: args.excludedFilePaths
|
||||
})
|
||||
}
|
||||
}
|
||||
return [...collected.values()].sort((left, right) => right.mtimeMs - left.mtimeMs)
|
||||
}
|
||||
|
||||
function claudeProjectScopePrefixes(scopePaths: readonly string[]): Set<string> {
|
||||
const prefixes = new Set<string>()
|
||||
for (const scopePath of scopePaths) {
|
||||
for (const candidate of scopePathCandidates(scopePath)) {
|
||||
prefixes.add(encodeClaudeProjectPath(candidate))
|
||||
}
|
||||
}
|
||||
return prefixes
|
||||
}
|
||||
|
||||
function scopePathCandidates(scopePath: string): string[] {
|
||||
const wslScopePath = parseWslUncPath(scopePath)
|
||||
return wslScopePath ? [scopePath, wslScopePath.linuxPath] : [scopePath]
|
||||
}
|
||||
|
||||
function encodeClaudeProjectPath(pathValue: string): string {
|
||||
return normalizeRuntimePathForComparison(pathValue).replace(/[^a-zA-Z0-9]/g, '-')
|
||||
}
|
||||
|
||||
function isClaudeProjectDirInScope(projectDirName: string, scopePrefixes: ReadonlySet<string>) {
|
||||
for (const prefix of scopePrefixes) {
|
||||
if (projectDirName === prefix || projectDirName.startsWith(`${prefix}-`)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isCwdInsideScopePath(scopePath: string, cwd: string): boolean {
|
||||
if (isPathInsideOrEqual(scopePath, cwd)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const wslScopePath = parseWslUncPath(scopePath)
|
||||
if (!wslScopePath) {
|
||||
return false
|
||||
}
|
||||
|
||||
// WSL transcripts record Linux cwd values even when the renderer sends the
|
||||
// active worktree as a Windows UNC path.
|
||||
return isPathInsideOrEqual(wslScopePath.linuxPath, cwd)
|
||||
}
|
||||
|
||||
async function listProjectDirs(
|
||||
rootDir: string,
|
||||
scopeProjectPrefixes: ReadonlySet<string>
|
||||
): Promise<string[]> {
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(rootDir, { withFileTypes: true })
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
return entries
|
||||
.filter(
|
||||
(entry) => entry.isDirectory() && isClaudeProjectDirInScope(entry.name, scopeProjectPrefixes)
|
||||
)
|
||||
.map((entry) => join(rootDir, entry.name))
|
||||
}
|
||||
|
||||
async function readProjectDirCwd(projectDir: string): Promise<string | null> {
|
||||
const files = await newestClaudeFilesInDir(projectDir)
|
||||
for (const file of files.slice(0, REPRESENTATIVE_FILE_LIMIT)) {
|
||||
const cwd = await readFirstCwd(file)
|
||||
if (cwd) {
|
||||
return cwd
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function newestClaudeFilesInDir(projectDir: string): Promise<string[]> {
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(projectDir, { withFileTypes: true })
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const newest: { path: string; mtimeMs: number }[] = []
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !CLAUDE_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
|
||||
continue
|
||||
}
|
||||
const path = join(projectDir, entry.name)
|
||||
try {
|
||||
addBoundedPath(newest, REPRESENTATIVE_FILE_LIMIT, {
|
||||
path,
|
||||
mtimeMs: (await stat(path)).mtimeMs
|
||||
})
|
||||
} catch {
|
||||
// Best effort: unreadable candidates are ignored here and reported during
|
||||
// full collection if the project directory proves in-scope.
|
||||
}
|
||||
}
|
||||
return newest.sort((left, right) => right.mtimeMs - left.mtimeMs).map((value) => value.path)
|
||||
}
|
||||
|
||||
async function readFirstCwd(filePath: string): Promise<string | null> {
|
||||
const input = createReadStream(filePath, { encoding: 'utf-8' })
|
||||
const lines = createInterface({ input, crlfDelay: Infinity })
|
||||
let read = 0
|
||||
try {
|
||||
for await (const line of lines) {
|
||||
if (read++ >= REPRESENTATIVE_CWD_LINE_LIMIT) {
|
||||
break
|
||||
}
|
||||
const cwd = extractString(parseJsonObject(line)?.cwd)
|
||||
if (cwd) {
|
||||
return cwd
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
// readline.close() leaves the underlying stream open; destroy it so the early
|
||||
// break/catch paths don't leak a file descriptor (this runs per project dir).
|
||||
lines.close()
|
||||
input.destroy()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function collectClaudeFiles(args: {
|
||||
projectDir: string
|
||||
issues: AiVaultScanIssue[]
|
||||
collected: Map<string, FileWithMtime>
|
||||
limit: number
|
||||
excludedFilePaths: ReadonlySet<string>
|
||||
}): Promise<void> {
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(args.projectDir, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !CLAUDE_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
|
||||
continue
|
||||
}
|
||||
const path = join(args.projectDir, entry.name)
|
||||
if (args.collected.has(path) || args.excludedFilePaths.has(path)) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const fileStat = await stat(path)
|
||||
addBoundedFile(args.collected, args.limit, {
|
||||
path,
|
||||
mtimeMs: fileStat.mtimeMs,
|
||||
modifiedAt: fileStat.mtime.toISOString()
|
||||
})
|
||||
} catch (err) {
|
||||
args.issues.push({ agent: 'claude', path, message: errorMessage(err) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addBoundedFile(
|
||||
collected: Map<string, FileWithMtime>,
|
||||
limit: number,
|
||||
file: FileWithMtime
|
||||
): void {
|
||||
if (collected.size < limit) {
|
||||
collected.set(file.path, file)
|
||||
return
|
||||
}
|
||||
|
||||
let oldest: FileWithMtime | null = null
|
||||
for (const candidate of collected.values()) {
|
||||
if (!oldest || candidate.mtimeMs < oldest.mtimeMs) {
|
||||
oldest = candidate
|
||||
}
|
||||
}
|
||||
if (oldest && file.mtimeMs > oldest.mtimeMs) {
|
||||
collected.delete(oldest.path)
|
||||
collected.set(file.path, file)
|
||||
}
|
||||
}
|
||||
|
||||
function addBoundedPath<T extends { mtimeMs: number }>(items: T[], limit: number, item: T): void {
|
||||
if (items.length < limit) {
|
||||
items.push(item)
|
||||
return
|
||||
}
|
||||
|
||||
let oldestIndex = 0
|
||||
for (let index = 1; index < items.length; index++) {
|
||||
if (items[index].mtimeMs < items[oldestIndex].mtimeMs) {
|
||||
oldestIndex = index
|
||||
}
|
||||
}
|
||||
if (item.mtimeMs > items[oldestIndex].mtimeMs) {
|
||||
items[oldestIndex] = item
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { mkdtemp, mkdir, rm, utimes, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import type { AiVaultScanOptions } from './session-scanner-types'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
||||
tempRoots = []
|
||||
})
|
||||
|
||||
// Point every non-Claude source at a nonexistent dir so the scan only sees the
|
||||
// Claude fixtures created per test.
|
||||
function scopedScanOptions(claudeProjectsDir: string, extra: Partial<AiVaultScanOptions>) {
|
||||
return {
|
||||
claudeProjectsDir,
|
||||
codexSessionsDir: '/nonexistent/codex',
|
||||
geminiSessionsDir: '/nonexistent/gemini',
|
||||
copilotSessionsDir: '/nonexistent/copilot',
|
||||
cursorProjectsDir: '/nonexistent/cursor',
|
||||
opencodeStorageDir: '/nonexistent/opencode',
|
||||
opencodeDbPaths: [],
|
||||
grokSessionsDir: '/nonexistent/grok',
|
||||
devinTranscriptsDir: '/nonexistent/devin',
|
||||
hermesSessionsDir: '/nonexistent/hermes',
|
||||
rovoSessionsDir: '/nonexistent/rovo',
|
||||
openclawStateDir: '/nonexistent/openclaw',
|
||||
openclawLegacyStateDir: '/nonexistent/openclaw-legacy',
|
||||
piSessionsDir: '/nonexistent/pi',
|
||||
droidSessionsDir: '/nonexistent/droid',
|
||||
droidProjectsDir: '/nonexistent/droid-projects',
|
||||
kimiSessionsDir: '/nonexistent/kimi',
|
||||
...extra
|
||||
} satisfies AiVaultScanOptions
|
||||
}
|
||||
|
||||
async function writeClaudeSession(args: {
|
||||
claudeRoot: string
|
||||
dirName: string
|
||||
sessionId: string
|
||||
cwd: string
|
||||
iso: string
|
||||
leadingCwdlessLine?: boolean
|
||||
}): Promise<void> {
|
||||
const dir = join(args.claudeRoot, args.dirName)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const records: unknown[] = []
|
||||
if (args.leadingCwdlessLine) {
|
||||
records.push({ type: 'last-prompt', sessionId: args.sessionId })
|
||||
}
|
||||
records.push({
|
||||
type: 'user',
|
||||
sessionId: args.sessionId,
|
||||
timestamp: args.iso,
|
||||
cwd: args.cwd,
|
||||
message: { role: 'user', content: `work in ${args.cwd}` }
|
||||
})
|
||||
const filePath = join(dir, `${args.sessionId}.jsonl`)
|
||||
await writeFile(filePath, records.map((record) => JSON.stringify(record)).join('\n'))
|
||||
const time = new Date(args.iso)
|
||||
await utimes(filePath, time, time)
|
||||
}
|
||||
|
||||
describe('scanAiVaultSessions scope inclusion', () => {
|
||||
it('surfaces in-scope sessions older than the global recency cap', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-scope-'))
|
||||
tempRoots.push(root)
|
||||
const claudeRoot = join(root, 'claude-projects')
|
||||
|
||||
// One old in-scope session that the recency cap would otherwise drop.
|
||||
await writeClaudeSession({
|
||||
claudeRoot,
|
||||
dirName: '-repo-app',
|
||||
sessionId: 'old-in-scope',
|
||||
cwd: '/repo/app',
|
||||
iso: '2026-01-01T00:00:00.000Z',
|
||||
leadingCwdlessLine: true
|
||||
})
|
||||
// A session in a sub-cwd directory of the same workspace path.
|
||||
await writeClaudeSession({
|
||||
claudeRoot,
|
||||
dirName: '-repo-app-packages-ui',
|
||||
sessionId: 'old-in-scope-subdir',
|
||||
cwd: '/repo/app/packages/ui',
|
||||
iso: '2026-01-02T00:00:00.000Z'
|
||||
})
|
||||
// Recent out-of-scope sessions that fill the cap.
|
||||
for (let index = 0; index < 4; index++) {
|
||||
await writeClaudeSession({
|
||||
claudeRoot,
|
||||
dirName: `-other-${index}`,
|
||||
sessionId: `recent-${index}`,
|
||||
cwd: `/other/${index}`,
|
||||
iso: `2026-06-2${index}T00:00:00.000Z`
|
||||
})
|
||||
}
|
||||
|
||||
const withoutScope = await scanAiVaultSessions(scopedScanOptions(claudeRoot, { limit: 2 }))
|
||||
const withScope = await scanAiVaultSessions(
|
||||
scopedScanOptions(claudeRoot, { limit: 2, scopePaths: ['/repo/app'] })
|
||||
)
|
||||
|
||||
const ids = (result: { sessions: { sessionId: string }[] }) =>
|
||||
result.sessions.map((session) => session.sessionId)
|
||||
|
||||
// The cap hides the old in-scope sessions when no scope is provided.
|
||||
expect(ids(withoutScope)).not.toContain('old-in-scope')
|
||||
expect(ids(withoutScope)).not.toContain('old-in-scope-subdir')
|
||||
// Scope paths force them back in, including the sub-cwd directory.
|
||||
expect(ids(withScope)).toContain('old-in-scope')
|
||||
expect(ids(withScope)).toContain('old-in-scope-subdir')
|
||||
})
|
||||
|
||||
it('does not duplicate sessions already in the capped result', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-scope-'))
|
||||
tempRoots.push(root)
|
||||
const claudeRoot = join(root, 'claude-projects')
|
||||
|
||||
await writeClaudeSession({
|
||||
claudeRoot,
|
||||
dirName: '-repo-app',
|
||||
sessionId: 'recent-in-scope',
|
||||
cwd: '/repo/app',
|
||||
iso: '2026-06-24T00:00:00.000Z'
|
||||
})
|
||||
|
||||
const result = await scanAiVaultSessions(
|
||||
scopedScanOptions(claudeRoot, { limit: 50, scopePaths: ['/repo/app'] })
|
||||
)
|
||||
|
||||
const matches = result.sessions.filter((session) => session.sessionId === 'recent-in-scope')
|
||||
expect(matches).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('matches WSL UNC scope paths against Linux Claude cwd values', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-scope-'))
|
||||
tempRoots.push(root)
|
||||
const claudeRoot = join(root, 'claude-projects')
|
||||
|
||||
await writeClaudeSession({
|
||||
claudeRoot,
|
||||
dirName: '-home-ada-repo',
|
||||
sessionId: 'old-wsl-in-scope',
|
||||
cwd: '/home/ada/repo',
|
||||
iso: '2026-01-01T00:00:00.000Z'
|
||||
})
|
||||
for (let index = 0; index < 4; index++) {
|
||||
await writeClaudeSession({
|
||||
claudeRoot,
|
||||
dirName: `-other-wsl-${index}`,
|
||||
sessionId: `recent-wsl-${index}`,
|
||||
cwd: `/other/wsl/${index}`,
|
||||
iso: `2026-06-2${index}T00:00:00.000Z`
|
||||
})
|
||||
}
|
||||
|
||||
const result = await scanAiVaultSessions(
|
||||
scopedScanOptions(claudeRoot, {
|
||||
limit: 2,
|
||||
scopePaths: ['\\\\wsl.localhost\\Ubuntu\\home\\ada\\repo']
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.sessions.map((session) => session.sessionId)).toContain('old-wsl-in-scope')
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,9 @@ export type AiVaultScanOptions = {
|
||||
kimiSessionsDir?: string
|
||||
limit?: number
|
||||
limitPerAgent?: number
|
||||
// Active workspace/project paths whose sessions must be included regardless of
|
||||
// the recency cap (see discoverInScopeClaudeFiles).
|
||||
scopePaths?: readonly string[]
|
||||
platform?: NodeJS.Platform
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
import { sessionSortTime } from './session-scanner-accumulator'
|
||||
import { parseAgentSessionFile } from './session-scanner-agent-parser'
|
||||
import { codexHomeForSessionsDir } from './session-scanner-codex-paths'
|
||||
import { discoverInScopeClaudeFiles } from './session-scanner-scope-discovery'
|
||||
import {
|
||||
DEFAULT_CODEX_HOME_DIR,
|
||||
discoverAiVaultSessionSources
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import type {
|
||||
AiVaultScanOptions,
|
||||
SessionFileCandidate,
|
||||
SessionFileDiscovery,
|
||||
SessionParseResult
|
||||
} from './session-scanner-types'
|
||||
import { clampPositiveInteger, errorMessage } from './session-scanner-values'
|
||||
@@ -20,6 +22,9 @@ import { clampPositiveInteger, errorMessage } from './session-scanner-values'
|
||||
const DEFAULT_LIMIT = 1000
|
||||
const DEFAULT_SCAN_LIMIT_PER_AGENT = 1000
|
||||
const SESSION_PARSE_CONCURRENCY = 8
|
||||
// Upper bound on extra in-scope transcripts discovered and parsed past the
|
||||
// recency cap; guards against a pathological scoped history directory.
|
||||
const SCOPE_PARSE_LIMIT = 2000
|
||||
|
||||
/**
|
||||
* Scan all supported AI agent session stores and return a unified, sorted,
|
||||
@@ -61,17 +66,80 @@ export async function scanAiVaultSessions(
|
||||
issues
|
||||
})
|
||||
|
||||
const sessions = parsedSessions
|
||||
const cappedSessions = parsedSessions
|
||||
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
|
||||
.slice(0, limit)
|
||||
|
||||
const scopeSessions = await scanInScopeSessions({
|
||||
discoveries,
|
||||
scopePaths: options.scopePaths ?? [],
|
||||
alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)),
|
||||
platform,
|
||||
issues
|
||||
})
|
||||
|
||||
return {
|
||||
sessions,
|
||||
sessions: mergeSessions(cappedSessions, scopeSessions),
|
||||
issues,
|
||||
scannedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// In-scope sessions are guaranteed regardless of the recency cap, so the global
|
||||
// (already capped) result and the scope result are unioned and de-duplicated by
|
||||
// session id, then re-sorted DESC.
|
||||
function mergeSessions(
|
||||
cappedSessions: AiVaultSession[],
|
||||
scopeSessions: AiVaultSession[]
|
||||
): AiVaultSession[] {
|
||||
if (scopeSessions.length === 0) {
|
||||
return cappedSessions
|
||||
}
|
||||
const byId = new Map<string, AiVaultSession>()
|
||||
for (const session of cappedSessions) {
|
||||
byId.set(session.id, session)
|
||||
}
|
||||
for (const session of scopeSessions) {
|
||||
byId.set(session.id, session)
|
||||
}
|
||||
return [...byId.values()].sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
|
||||
}
|
||||
|
||||
async function scanInScopeSessions(args: {
|
||||
discoveries: SessionFileDiscovery[]
|
||||
scopePaths: readonly string[]
|
||||
alreadyParsedFilePaths: ReadonlySet<string>
|
||||
platform: NodeJS.Platform
|
||||
issues: AiVaultScanIssue[]
|
||||
}): Promise<AiVaultSession[]> {
|
||||
if (args.scopePaths.length === 0) {
|
||||
return []
|
||||
}
|
||||
const claudeRootDirs = args.discoveries
|
||||
.filter((discovery) => discovery.agent === 'claude')
|
||||
.map((discovery) => discovery.rootDir)
|
||||
const files = await discoverInScopeClaudeFiles({
|
||||
rootDirs: claudeRootDirs,
|
||||
scopePaths: args.scopePaths,
|
||||
limit: SCOPE_PARSE_LIMIT,
|
||||
excludedFilePaths: args.alreadyParsedFilePaths,
|
||||
issues: args.issues
|
||||
})
|
||||
const candidates = files.map(
|
||||
(file): SessionFileCandidate => ({ agent: 'claude', file, codexHome: null })
|
||||
)
|
||||
if (candidates.length === 0) {
|
||||
return []
|
||||
}
|
||||
// Parse every in-scope candidate (limit === candidate count never early-stops).
|
||||
return parseSessionCandidates({
|
||||
candidates,
|
||||
limit: candidates.length,
|
||||
platform: args.platform,
|
||||
issues: args.issues
|
||||
})
|
||||
}
|
||||
|
||||
async function parseSessionCandidates(args: {
|
||||
candidates: SessionFileCandidate[]
|
||||
limit: number
|
||||
|
||||
@@ -144,6 +144,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
terminalWindowsPowerShellImplementation: 'powershell.exe',
|
||||
enableGitHubAttribution: true,
|
||||
...overrides,
|
||||
diffWordWrap: overrides.diffWordWrap ?? false,
|
||||
localWindowsRuntimeDefault: overrides.localWindowsRuntimeDefault ?? { kind: 'windows-host' },
|
||||
leftSidebarAppearanceMode: overrides.leftSidebarAppearanceMode ?? 'default',
|
||||
appFontFamily,
|
||||
|
||||
@@ -148,6 +148,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
terminalWindowsPowerShellImplementation: 'powershell.exe',
|
||||
enableGitHubAttribution: true,
|
||||
...overrides,
|
||||
diffWordWrap: overrides.diffWordWrap ?? false,
|
||||
localWindowsRuntimeDefault: overrides.localWindowsRuntimeDefault ?? { kind: 'windows-host' },
|
||||
leftSidebarAppearanceMode: overrides.leftSidebarAppearanceMode ?? 'default',
|
||||
appFontFamily,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
computeTrustKey,
|
||||
computeTrustedHash,
|
||||
escapeTomlString,
|
||||
getCodexCanonicalTrustPath,
|
||||
parseTrustKey,
|
||||
readHookTrustEntries,
|
||||
removeHookTrustEntries,
|
||||
@@ -244,7 +245,31 @@ describe('computeTrustKey', () => {
|
||||
handlerIndex: 0,
|
||||
command: 'irrelevant'
|
||||
})
|
||||
).toBe(`${realpathSync.native(hooksPath)}:user_prompt_submit:0:0`)
|
||||
).toBe(`${realpathSync.native(hooksPath).replace(/\\/g, '/')}:user_prompt_submit:0:0`)
|
||||
})
|
||||
|
||||
it('normalizes Windows backslashes to forward slashes in the trust key', () => {
|
||||
// Why: getCodexCanonicalTrustPath must return a forward-slash key even when
|
||||
// the path uses native Windows separators, preventing separator mismatch
|
||||
// against Codex-written literal-string keys.
|
||||
const winPath = 'C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json'
|
||||
const key = computeTrustKey({
|
||||
sourcePath: winPath,
|
||||
eventLabel: 'session_start',
|
||||
groupIndex: 0,
|
||||
handlerIndex: 0,
|
||||
command: 'echo'
|
||||
})
|
||||
expect(key).not.toContain('\\')
|
||||
expect(key.startsWith('C:/Users/Rod/AppData/Roaming/orca/hooks.json:')).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves literal backslashes in non-Windows-style fallback paths', () => {
|
||||
// Why: SSH/POSIX paths can legally contain `\` as a filename character;
|
||||
// only Windows-style separators should be normalized.
|
||||
expect(getCodexCanonicalTrustPath('/tmp/with\\literal/hooks.json')).toBe(
|
||||
'/tmp/with\\literal/hooks.json'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -344,10 +369,11 @@ describe('upsertHookTrustEntries', () => {
|
||||
})
|
||||
|
||||
it('collapses duplicate blocks for the same hook key while preserving unrelated hook state', () => {
|
||||
const key =
|
||||
'C:\\Users\\me\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\hooks.json:session_start:0:0'
|
||||
const unrelatedKey =
|
||||
'C:\\Users\\me\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\hooks.json:stop:0:0'
|
||||
const sourcePath = 'C:\\Users\\me\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\hooks.json'
|
||||
const key = `${sourcePath}:session_start:0:0`
|
||||
const unrelatedSourcePath =
|
||||
'C:\\Users\\me\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\hooks.json'
|
||||
const unrelatedKey = `${unrelatedSourcePath}:stop:0:0`
|
||||
const original = [
|
||||
`[hooks.state."${escapeTomlString(key)}"]`,
|
||||
'enabled = true',
|
||||
@@ -365,7 +391,7 @@ describe('upsertHookTrustEntries', () => {
|
||||
writeFileSync(configPath, original, 'utf-8')
|
||||
|
||||
const entry: CodexTrustEntry = {
|
||||
sourcePath: 'C:\\Users\\me\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\hooks.json',
|
||||
sourcePath,
|
||||
eventLabel: 'session_start',
|
||||
groupIndex: 0,
|
||||
handlerIndex: 0,
|
||||
@@ -373,11 +399,15 @@ describe('upsertHookTrustEntries', () => {
|
||||
}
|
||||
upsertHookTrustEntries(configPath, [entry])
|
||||
|
||||
// Why: after normalization Orca writes the forward-slash form; the fixture's
|
||||
// backslash form is matched via normalizeHookTrustKeyForLookup and collapsed.
|
||||
const normalizedKey = key.replace(/\\/g, '/')
|
||||
const written = readFileSync(configPath, 'utf-8')
|
||||
const duplicateKeyOccurrences = written.match(
|
||||
new RegExp(`\\[hooks\\.state\\."${escapeRegex(escapeTomlString(key))}"\\]`, 'g')
|
||||
new RegExp(`\\[hooks\\.state\\."${escapeRegex(normalizedKey)}"\\]`, 'g')
|
||||
)
|
||||
expect(duplicateKeyOccurrences).toHaveLength(1)
|
||||
// The unrelated key was not upserted and stays in its original escaped form.
|
||||
expect(written).toContain(`[hooks.state."${escapeTomlString(unrelatedKey)}"]`)
|
||||
expect(written).toContain('trusted_hash = "sha256:KEEP"')
|
||||
expect(written).toContain('enabled = false')
|
||||
@@ -406,10 +436,12 @@ describe('upsertHookTrustEntries', () => {
|
||||
}
|
||||
upsertHookTrustEntries(configPath, [entry])
|
||||
|
||||
// Why: Orca writes the normalized forward-slash key form after replacement.
|
||||
const normalizedKey = key.replace(/\\/g, '/')
|
||||
const written = readFileSync(configPath, 'utf-8')
|
||||
expect(written).not.toContain(`[hooks.state.'${key}']`)
|
||||
expect(written.match(/\[hooks\.state\./g)).toHaveLength(1)
|
||||
expect(written).toContain(`[hooks.state."${escapeTomlString(key)}"]`)
|
||||
expect(written).toContain(`[hooks.state."${normalizedKey}"]`)
|
||||
expect(written).toContain('enabled = false')
|
||||
expect(written).toContain(`trusted_hash = "${computeTrustedHash(entry)}"`)
|
||||
})
|
||||
@@ -710,7 +742,9 @@ describe('upsertHookTrustEntries', () => {
|
||||
expect(written).toContain('[hooks.state."/x/hooks.json:pre_tool_use:0:0"]')
|
||||
})
|
||||
|
||||
it('escapes literal `"` and `\\` in the source path inside the trust block header', () => {
|
||||
it('escapes literal `"` and `\\` in non-Windows source paths inside the trust block header', () => {
|
||||
// Why: a backslash in a POSIX path can be a literal filename character, so
|
||||
// it must still be escaped instead of normalized away.
|
||||
const entry: CodexTrustEntry = {
|
||||
sourcePath: '/x/with"quote\\and\\back/hooks.json',
|
||||
eventLabel: 'pre_tool_use',
|
||||
@@ -821,6 +855,87 @@ describe('upsertHookTrustEntries', () => {
|
||||
expect(headerCount).toBe(1)
|
||||
expect(written).not.toContain('sha256:OLD')
|
||||
})
|
||||
|
||||
it('finds and replaces a Codex literal-string backslash block when Orca upserts with forward-slash key', () => {
|
||||
// Why: Codex writes literal-string keys with raw backslashes; Orca builds
|
||||
// keys with forward slashes after getCodexCanonicalTrustPath normalization.
|
||||
// normalizeHookTrustKeyForLookup must bridge that separator gap so upsert replaces
|
||||
// rather than appends.
|
||||
const backslashPath = 'C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json'
|
||||
const literalKey = `${backslashPath}:session_start:0:0`
|
||||
const original = [
|
||||
`[hooks.state.'${literalKey}']`,
|
||||
'enabled = true',
|
||||
'trusted_hash = "sha256:CODEX-WRITTEN"',
|
||||
''
|
||||
].join('\n')
|
||||
writeFileSync(configPath, original, 'utf-8')
|
||||
|
||||
const entry: CodexTrustEntry = {
|
||||
sourcePath: backslashPath,
|
||||
eventLabel: 'session_start',
|
||||
groupIndex: 0,
|
||||
handlerIndex: 0,
|
||||
command: 'echo session'
|
||||
}
|
||||
upsertHookTrustEntries(configPath, [entry])
|
||||
|
||||
const written = readFileSync(configPath, 'utf-8')
|
||||
expect((written.match(/\[hooks\.state\./g) ?? []).length).toBe(1)
|
||||
expect(written).not.toContain('sha256:CODEX-WRITTEN')
|
||||
expect(written).toContain(`trusted_hash = "${computeTrustedHash(entry)}"`)
|
||||
})
|
||||
|
||||
it('produces exactly one [hooks.state.*] header after two consecutive upserts on Windows path', () => {
|
||||
// Why: idempotency guard — repeated auto-install on app start must not
|
||||
// accumulate duplicate trust blocks and produce invalid TOML.
|
||||
const entry: CodexTrustEntry = {
|
||||
sourcePath: 'C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json',
|
||||
eventLabel: 'session_start',
|
||||
groupIndex: 0,
|
||||
handlerIndex: 0,
|
||||
command: 'echo session'
|
||||
}
|
||||
upsertHookTrustEntries(configPath, [entry])
|
||||
upsertHookTrustEntries(configPath, [entry])
|
||||
|
||||
const written = readFileSync(configPath, 'utf-8')
|
||||
expect((written.match(/\[hooks\.state\./g) ?? []).length).toBe(1)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'finds a Codex-written block with lowercased username when Orca key has mixed-case username',
|
||||
() => {
|
||||
// Why: realpathSync.native casing can differ between what Codex wrote
|
||||
// (C:\Users\rod\...) and what Orca resolves (C:\Users\Rod\...).
|
||||
// normalizeHookTrustKeyForLookup case-folds on Windows so the existing block is
|
||||
// replaced rather than a duplicate appended.
|
||||
const lowercasePath = 'C:\\Users\\rod\\AppData\\Roaming\\orca\\hooks.json'
|
||||
const mixedCasePath = 'C:\\Users\\Rod\\AppData\\Roaming\\orca\\hooks.json'
|
||||
const literalKey = `${lowercasePath}:session_start:0:0`
|
||||
const original = [
|
||||
`[hooks.state.'${literalKey}']`,
|
||||
'enabled = true',
|
||||
'trusted_hash = "sha256:LOWERCASE"',
|
||||
''
|
||||
].join('\n')
|
||||
writeFileSync(configPath, original, 'utf-8')
|
||||
|
||||
const entry: CodexTrustEntry = {
|
||||
sourcePath: mixedCasePath,
|
||||
eventLabel: 'session_start',
|
||||
groupIndex: 0,
|
||||
handlerIndex: 0,
|
||||
command: 'echo session'
|
||||
}
|
||||
upsertHookTrustEntries(configPath, [entry])
|
||||
|
||||
const written = readFileSync(configPath, 'utf-8')
|
||||
expect((written.match(/\[hooks\.state\./g) ?? []).length).toBe(1)
|
||||
expect(written).not.toContain('sha256:LOWERCASE')
|
||||
expect(written).toContain(`trusted_hash = "${computeTrustedHash(entry)}"`)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('upsertProjectTrustLevel', () => {
|
||||
@@ -836,8 +951,9 @@ describe('upsertProjectTrustLevel', () => {
|
||||
mkdirSync(nestedDir)
|
||||
mkdirSync(projectDir)
|
||||
const aliasedProjectPath = join(nestedDir, '..', 'project')
|
||||
const trustedPath = realpathSync.native(aliasedProjectPath)
|
||||
const trustedTomlPath = trustedPath.replaceAll('\\', '\\\\').replaceAll('"', '\\"')
|
||||
// Why: getCodexCanonicalTrustPath normalizes separators to forward slashes.
|
||||
const trustedPath = realpathSync.native(aliasedProjectPath).replace(/\\/g, '/')
|
||||
const trustedTomlPath = trustedPath.replaceAll('"', '\\"')
|
||||
|
||||
expect(upsertProjectTrustLevelInContent('', aliasedProjectPath, 'trusted')).toBe(
|
||||
[`[projects."${trustedTomlPath}"]`, 'trust_level = "trusted"', ''].join('\n')
|
||||
@@ -884,17 +1000,38 @@ describe('upsertProjectTrustLevel', () => {
|
||||
expect(updated).toContain('[other]\nvalue = 1')
|
||||
})
|
||||
|
||||
it('preserves CRLF endings and escapes the project path in the header', () => {
|
||||
it('preserves CRLF endings and normalizes Windows path separators in the header', () => {
|
||||
// Why: getCodexCanonicalTrustPath normalizes backslashes to forward slashes;
|
||||
// the project path header uses forward slashes even on Windows input.
|
||||
const original = ['[profiles.default]', 'model = "gpt-5"', ''].join('\r\n')
|
||||
|
||||
const updated = upsertProjectTrustLevelInContent(original, 'C:\\Users\\nw\\repo', 'trusted')
|
||||
|
||||
expect(updated).toContain(
|
||||
['[projects."C:\\\\Users\\\\nw\\\\repo"]', 'trust_level = "trusted"', ''].join('\r\n')
|
||||
['[projects."C:/Users/nw/repo"]', 'trust_level = "trusted"', ''].join('\r\n')
|
||||
)
|
||||
expect(updated).toContain('[profiles.default]\r\nmodel = "gpt-5"')
|
||||
})
|
||||
|
||||
it('updates an existing Windows backslash project block after separator normalization', () => {
|
||||
// Why: forward-slash writes must not strand an older backslash-form
|
||||
// project table and append a duplicate project trust block.
|
||||
const original = [
|
||||
'[projects."C:\\\\Users\\\\nw\\\\repo"]',
|
||||
'notes = "keep"',
|
||||
'trust_level = "untrusted"',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
const updated = upsertProjectTrustLevelInContent(original, 'C:\\Users\\nw\\repo', 'trusted')
|
||||
|
||||
expect(updated.match(/\[projects\./g)).toHaveLength(1)
|
||||
expect(updated).toContain('[projects."C:\\\\Users\\\\nw\\\\repo"]')
|
||||
expect(updated).toContain('notes = "keep"')
|
||||
expect(updated).toContain('trust_level = "trusted"')
|
||||
expect(updated).not.toContain('trust_level = "untrusted"')
|
||||
})
|
||||
|
||||
it('writes config.toml and avoids rewriting an already-trusted project', () => {
|
||||
upsertProjectTrustLevel(configPath, '/tmp/codex-ws', 'trusted')
|
||||
const firstWrite = readFileSync(configPath, 'utf-8')
|
||||
@@ -1181,10 +1318,11 @@ describe('readHookTrustEntries', () => {
|
||||
expect(result.get(key)).toEqual({ trustedHash: 'sha256:AAA', enabled: true })
|
||||
})
|
||||
|
||||
it('unescapes `\\\\` in the block key', () => {
|
||||
it('normalizes backslash block key to forward-slash at ingestion', () => {
|
||||
// Why: a real Windows path on disk like C:\foo gets written escaped as
|
||||
// `C:\\foo` inside the TOML key — the returned Map should expose the
|
||||
// original unescaped form.
|
||||
// `C:\\foo` inside the TOML key. The Map key is normalized (backslash ->
|
||||
// forward-slash) so computeTrustKey lookups match regardless of how Codex
|
||||
// encoded the path separator.
|
||||
const original = [
|
||||
'[hooks.state."C:\\\\foo\\\\hooks.json:pre_tool_use:0:0"]',
|
||||
'enabled = true',
|
||||
@@ -1194,13 +1332,13 @@ describe('readHookTrustEntries', () => {
|
||||
writeFileSync(configPath, original, 'utf-8')
|
||||
|
||||
const result = readHookTrustEntries(configPath)
|
||||
expect(result.get('C:\\foo\\hooks.json:pre_tool_use:0:0')?.trustedHash).toBe('sha256:WIN')
|
||||
expect(result.get('C:/foo/hooks.json:pre_tool_use:0:0')?.trustedHash).toBe('sha256:WIN')
|
||||
})
|
||||
|
||||
it('reads a literal-string hook table key', () => {
|
||||
const key = 'C:\\foo\\hooks.json:session_start:0:0'
|
||||
const rawKey = 'C:\\foo\\hooks.json:session_start:0:0'
|
||||
const original = [
|
||||
`[hooks.state.'${key}']`,
|
||||
`[hooks.state.'${rawKey}']`,
|
||||
'enabled = false',
|
||||
'trusted_hash = "sha256:LITERAL"',
|
||||
''
|
||||
@@ -1208,9 +1346,33 @@ describe('readHookTrustEntries', () => {
|
||||
writeFileSync(configPath, original, 'utf-8')
|
||||
|
||||
const result = readHookTrustEntries(configPath)
|
||||
expect(result.get(key)).toEqual({ trustedHash: 'sha256:LITERAL', enabled: false })
|
||||
expect(result.get('C:/foo/hooks.json:session_start:0:0')).toEqual({
|
||||
trustedHash: 'sha256:LITERAL',
|
||||
enabled: false
|
||||
})
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'supports case-insensitive lookups for Windows hook trust keys read from config',
|
||||
() => {
|
||||
// Why: Codex and realpathSync.native can disagree on user-path casing;
|
||||
// status checks still need Map.get(computeTrustKey(...)) to find the row.
|
||||
const rawKey = 'C:\\Users\\rod\\AppData\\Roaming\\orca\\hooks.json:session_start:0:0'
|
||||
const lookupKey = 'C:/Users/Rod/AppData/Roaming/orca/hooks.json:session_start:0:0'
|
||||
const original = [
|
||||
`[hooks.state.'${rawKey}']`,
|
||||
'enabled = true',
|
||||
'trusted_hash = "sha256:CASE"',
|
||||
''
|
||||
].join('\n')
|
||||
writeFileSync(configPath, original, 'utf-8')
|
||||
|
||||
const result = readHookTrustEntries(configPath)
|
||||
|
||||
expect(result.get(lookupKey)).toEqual({ trustedHash: 'sha256:CASE', enabled: true })
|
||||
}
|
||||
)
|
||||
|
||||
it('reads entries from a CRLF-terminated config', () => {
|
||||
const key = '/x/hooks.json:pre_tool_use:0:0'
|
||||
const original = [
|
||||
|
||||
@@ -52,6 +52,26 @@ export type CodexHookTrustState = {
|
||||
|
||||
export type CodexProjectTrustLevel = 'trusted' | 'untrusted'
|
||||
|
||||
// Why: callers use computeTrustKey() for lookups, while existing TOML can carry
|
||||
// Codex-written separator/casing variants. Normalize both sides at the Map edge.
|
||||
class HookTrustEntryMap extends Map<string, CodexHookTrustState> {
|
||||
override get(key: string): CodexHookTrustState | undefined {
|
||||
return super.get(normalizeHookTrustKeyForLookup(key))
|
||||
}
|
||||
|
||||
override has(key: string): boolean {
|
||||
return super.has(normalizeHookTrustKeyForLookup(key))
|
||||
}
|
||||
|
||||
override delete(key: string): boolean {
|
||||
return super.delete(normalizeHookTrustKeyForLookup(key))
|
||||
}
|
||||
|
||||
override set(key: string, value: CodexHookTrustState): this {
|
||||
return super.set(normalizeHookTrustKeyForLookup(key), value)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: matches Codex's canonical_json. Sorts object keys recursively before
|
||||
// SHA-256ing; arrays preserve order.
|
||||
function canonicalize(value: unknown): unknown {
|
||||
@@ -102,11 +122,23 @@ export function getCodexCanonicalTrustPath(sourcePath: string): string {
|
||||
try {
|
||||
// Why: Codex canonicalizes trust paths before building config keys. On
|
||||
// macOS, /var is a symlink to /private/var; trusting the raw path still
|
||||
// leaves the TUI in review/trust prompts.
|
||||
return realpathSync.native(sourcePath)
|
||||
// leaves the TUI in review/trust prompts. Forward-slash normalization
|
||||
// prevents separator mismatch against Codex-written literal-string keys on Windows.
|
||||
return normalizeWindowsPathSeparators(realpathSync.native(sourcePath))
|
||||
} catch {
|
||||
return normalizeWindowsPathSeparators(sourcePath)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWindowsPathSeparators(sourcePath: string): string {
|
||||
if (!usesWindowsPathSeparators(sourcePath)) {
|
||||
return sourcePath
|
||||
}
|
||||
return sourcePath.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
function usesWindowsPathSeparators(sourcePath: string): boolean {
|
||||
return /^[A-Za-z]:[\\/]/.test(sourcePath) || sourcePath.startsWith('\\\\')
|
||||
}
|
||||
|
||||
export function parseTrustKey(key: string): {
|
||||
@@ -342,6 +374,17 @@ type TrustBlockRange = {
|
||||
end: number
|
||||
}
|
||||
|
||||
// Why: separator and casing drift between Codex-written keys (raw backslash,
|
||||
// potentially lowercased) and Orca-built keys (forward-slash, realpathSync.native
|
||||
// casing) must not prevent findTrustBlockRanges from matching an existing block.
|
||||
export function normalizeHookTrustKeyForLookup(key: string): string {
|
||||
const parsed = parseTrustKey(key)
|
||||
const separated = parsed
|
||||
? `${normalizeWindowsPathSeparators(parsed.sourcePath)}:${parsed.eventLabel}:${parsed.groupIndex}:${parsed.handlerIndex}`
|
||||
: normalizeWindowsPathSeparators(key)
|
||||
return process.platform === 'win32' ? separated.toLowerCase() : separated
|
||||
}
|
||||
|
||||
function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] {
|
||||
const ranges: TrustBlockRange[] = []
|
||||
let cursor = 0
|
||||
@@ -355,7 +398,10 @@ function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] {
|
||||
const headerKey = isInsideTomlMultilineString(multilineState)
|
||||
? null
|
||||
: parseHookStateHeaderKey(line)
|
||||
if (headerKey === key) {
|
||||
if (
|
||||
headerKey !== null &&
|
||||
normalizeHookTrustKeyForLookup(headerKey) === normalizeHookTrustKeyForLookup(key)
|
||||
) {
|
||||
const headerLineEnd = rawLine.endsWith('\r') ? lineEnd - 1 : lineEnd
|
||||
const after = content.slice(headerLineEnd)
|
||||
const nextHeaderRel = findNextTableHeader(after)
|
||||
@@ -371,9 +417,13 @@ function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] {
|
||||
}
|
||||
|
||||
function buildProjectHeaderPattern(projectPath: string): RegExp {
|
||||
const escapedPath = escapeRegex(escapeTomlString(projectPath))
|
||||
const headerPaths = [escapeRegex(escapeTomlString(projectPath))]
|
||||
if (usesWindowsPathSeparators(projectPath)) {
|
||||
headerPaths.push(escapeRegex(escapeTomlString(projectPath.replace(/\//g, '\\'))))
|
||||
}
|
||||
return new RegExp(
|
||||
`(^|\\r?\\n)[ \\t]*\\[projects\\."${escapedPath}"\\][ \\t]*(?:#[^\\r\\n]*)?(?=\\r?\\n|$)`
|
||||
`(^|\\r?\\n)[ \\t]*\\[projects\\."(?:${headerPaths.join('|')})"\\][ \\t]*(?:#[^\\r\\n]*)?(?=\\r?\\n|$)`,
|
||||
process.platform === 'win32' ? 'i' : undefined
|
||||
)
|
||||
}
|
||||
|
||||
@@ -681,7 +731,7 @@ function removeTrustBlock(content: string, key: string): string {
|
||||
}
|
||||
|
||||
export function readHookTrustEntries(configPath: string): Map<string, CodexHookTrustState> {
|
||||
const result = new Map<string, CodexHookTrustState>()
|
||||
const result = new HookTrustEntryMap()
|
||||
if (!existsSync(configPath)) {
|
||||
return result
|
||||
}
|
||||
@@ -707,7 +757,7 @@ export function readHookTrustEntries(configPath: string): Map<string, CodexHookT
|
||||
// a line scan beats pulling in a full TOML value parser.
|
||||
const hashMatch = /^[ \t]*trusted_hash[ \t]*=[ \t]*"((?:[^"\\]|\\.)*)"/m.exec(block)
|
||||
const enabledMatch = /^[ \t]*enabled[ \t]*=[ \t]*(true|false)[ \t\r]*(?:#.*)?$/m.exec(block)
|
||||
result.set(key, {
|
||||
result.set(normalizeHookTrustKeyForLookup(key), {
|
||||
trustedHash: hashMatch ? unescapeTomlString(hashMatch[1]) : undefined,
|
||||
enabled: enabledMatch ? enabledMatch[1] === 'true' : undefined
|
||||
})
|
||||
|
||||
@@ -88,9 +88,11 @@ function canonicalizeHookTrustKeyForTest(key: string): string {
|
||||
}
|
||||
const sourcePath = key.slice(0, thirdLast)
|
||||
try {
|
||||
return `${realpathSync.native(sourcePath)}${key.slice(thirdLast)}`
|
||||
// Why: mirrors getCodexCanonicalTrustPath separator normalization so test
|
||||
// expectations match the forward-slash keys Orca writes on Windows.
|
||||
return `${realpathSync.native(sourcePath).replace(/\\/g, '/')}${key.slice(thirdLast)}`
|
||||
} catch {
|
||||
return key
|
||||
return key.replace(/\\/g, '/')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +135,32 @@ describe('CodexHookService', () => {
|
||||
expect(trustConfig).toContain(':permission_request:0:0')
|
||||
})
|
||||
|
||||
it('drops plugin manager metadata from runtime hooks.json during install', () => {
|
||||
const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home')
|
||||
mkdirSync(managedCodexHome, { recursive: true })
|
||||
writeFileSync(
|
||||
join(managedCodexHome, 'hooks.json'),
|
||||
`${JSON.stringify({
|
||||
hooks: {},
|
||||
_managed: {
|
||||
'compound-engineering': {
|
||||
Stop: [0]
|
||||
}
|
||||
}
|
||||
})}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
expect(new CodexHookService().install().state).toBe('installed')
|
||||
|
||||
const hooksConfig = JSON.parse(readFileSync(join(managedCodexHome, 'hooks.json'), 'utf-8')) as {
|
||||
hooks: Record<string, unknown>
|
||||
_managed?: unknown
|
||||
}
|
||||
expect(hooksConfig._managed).toBeUndefined()
|
||||
expect(Object.keys(hooksConfig)).toEqual(['hooks'])
|
||||
})
|
||||
|
||||
// Why: #6078 — a Windows user profile path like `C:\Users\Jane Doe` used to
|
||||
// be written verbatim as the hook command, so Codex split it at the space and
|
||||
// the hook exited with code 1. The managed command uses an encoded launcher
|
||||
@@ -720,6 +748,11 @@ describe('CodexHookService', () => {
|
||||
{ hooks: [{ type: 'command', command: legacyCommand }] }
|
||||
],
|
||||
SessionStart: [{ hooks: [{ type: 'command', command: legacyCommand }] }]
|
||||
},
|
||||
_managed: {
|
||||
'external-manager': {
|
||||
Stop: [0]
|
||||
}
|
||||
}
|
||||
},
|
||||
null,
|
||||
@@ -752,9 +785,11 @@ describe('CodexHookService', () => {
|
||||
|
||||
const systemHooks = JSON.parse(readFileSync(systemHooksPath, 'utf-8')) as {
|
||||
hooks: Record<string, { hooks?: { command?: string }[] }[]>
|
||||
_managed?: unknown
|
||||
}
|
||||
expect(systemHooks.hooks.Stop).toEqual([{ hooks: [{ type: 'command', command: 'user-hook' }] }])
|
||||
expect(systemHooks.hooks.SessionStart).toBeUndefined()
|
||||
expect(systemHooks._managed).toEqual({ 'external-manager': { Stop: [0] } })
|
||||
const systemToml = readFileSync(join(systemCodexHome, 'config.toml'), 'utf-8')
|
||||
expect(systemToml).toContain('model = "system-model"')
|
||||
expect(systemToml).not.toContain(':stop:1:0')
|
||||
@@ -913,6 +948,41 @@ describe('CodexHookService', () => {
|
||||
expect(existsSync(profilePath)).toBe(false)
|
||||
})
|
||||
|
||||
it('sanitizes runtime hooks.json metadata during remove even without managed hooks', () => {
|
||||
const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home')
|
||||
const managedHooksPath = join(managedCodexHome, 'hooks.json')
|
||||
mkdirSync(managedCodexHome, { recursive: true })
|
||||
writeFileSync(
|
||||
managedHooksPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
hooks: {
|
||||
Stop: [{ hooks: [{ type: 'command', command: 'user-hook' }] }]
|
||||
},
|
||||
_managed: {
|
||||
'compound-engineering': {
|
||||
Stop: [0]
|
||||
}
|
||||
}
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
const status = new CodexHookService().remove()
|
||||
|
||||
expect(status.state).toBe('not_installed')
|
||||
const hooksConfig = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as {
|
||||
hooks: Record<string, unknown>
|
||||
_managed?: unknown
|
||||
}
|
||||
expect(hooksConfig._managed).toBeUndefined()
|
||||
expect(Object.keys(hooksConfig)).toEqual(['hooks'])
|
||||
expect(hooksConfig.hooks.Stop).toEqual([{ hooks: [{ type: 'command', command: 'user-hook' }] }])
|
||||
})
|
||||
|
||||
it('cleans duplicate Codex hook representations while keeping status hooks in runtime CODEX_HOME', () => {
|
||||
const systemCodexHome = join(tmpHome, '.codex')
|
||||
const systemHooksPath = join(systemCodexHome, 'hooks.json')
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
computeTrustedHash,
|
||||
escapeTomlString,
|
||||
getCodexCanonicalTrustPath,
|
||||
normalizeHookTrustKeyForLookup,
|
||||
parseTrustKey,
|
||||
readHookTrustEntries,
|
||||
removeHookTrustEntries,
|
||||
@@ -62,6 +63,12 @@ function getConfigPath(): string {
|
||||
return join(getOrcaManagedCodexHomePath(), 'hooks.json')
|
||||
}
|
||||
|
||||
function writeCodexHooksJson(configPath: string, hooks: Record<string, HookDefinition[]>): void {
|
||||
// Why: Codex rejects unknown top-level hooks.json fields, so plugin manager
|
||||
// bookkeeping such as `_managed` must not survive Orca's rewrite.
|
||||
writeHooksJson(configPath, { hooks })
|
||||
}
|
||||
|
||||
function getCodexConfigTomlPath(): string {
|
||||
return join(getOrcaManagedCodexHomePath(), 'config.toml')
|
||||
}
|
||||
@@ -209,7 +216,10 @@ function removeStaleRuntimeHookTrustEntries(
|
||||
expectedEntries: readonly CodexTrustEntry[]
|
||||
): void {
|
||||
const expectedHashes = new Map(
|
||||
expectedEntries.map((entry) => [computeTrustKey(entry), computeTrustedHash(entry)])
|
||||
expectedEntries.map((entry) => [
|
||||
normalizeHookTrustKeyForLookup(computeTrustKey(entry)),
|
||||
computeTrustedHash(entry)
|
||||
])
|
||||
)
|
||||
const canonicalRuntimeHooksPath = getCodexCanonicalTrustPath(runtimeHooksPath)
|
||||
const staleKeys: string[] = []
|
||||
@@ -218,7 +228,7 @@ function removeStaleRuntimeHookTrustEntries(
|
||||
if (!parsed || getCodexCanonicalTrustPath(parsed.sourcePath) !== canonicalRuntimeHooksPath) {
|
||||
continue
|
||||
}
|
||||
if (expectedHashes.get(key) === state.trustedHash) {
|
||||
if (expectedHashes.get(normalizeHookTrustKeyForLookup(key)) === state.trustedHash) {
|
||||
continue
|
||||
}
|
||||
staleKeys.push(key)
|
||||
@@ -532,6 +542,8 @@ function cleanupLegacySystemManagedHooks(): void {
|
||||
// Why: Codex hooks moved to Orca's managed CODEX_HOME; old entries in
|
||||
// ~/.codex would keep external Codex sessions reporting into Orca.
|
||||
if (removedManagedHook) {
|
||||
// Why: this is the user's system hooks file, not Orca's runtime copy.
|
||||
// Remove only stale Orca hook entries and preserve other managers' metadata.
|
||||
writeHooksJson(legacyConfigPath, { ...config, hooks: nextHooks })
|
||||
}
|
||||
removeMatchingTrustEntries(getSystemCodexConfigTomlPath(), trustEntries)
|
||||
@@ -889,7 +901,7 @@ export class CodexHookService {
|
||||
|
||||
config.hooks = nextHooks
|
||||
writeManagedScript(scriptPath, getManagedScript())
|
||||
writeHooksJson(configPath, config)
|
||||
writeCodexHooksJson(configPath, nextHooks)
|
||||
// Why: trust entries write last so a half-write can't leave a hash
|
||||
// pointing at a hook that doesn't exist. Surface failures — without this,
|
||||
// getStatus would report green for a hook Codex won't actually fire.
|
||||
@@ -979,7 +991,9 @@ export class CodexHookService {
|
||||
// Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is
|
||||
// running on Windows; never derive remote script syntax from local OS.
|
||||
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
|
||||
await writeHooksJsonRemote(sftp, remoteConfigPath, config)
|
||||
// Why: SSH installs edit the user's remote ~/.codex/hooks.json directly.
|
||||
// Preserve non-Orca top-level metadata while replacing the hooks tree.
|
||||
await writeHooksJsonRemote(sftp, remoteConfigPath, { ...config, hooks: nextHooks })
|
||||
try {
|
||||
const existingToml = (await readTextFileRemote(sftp, remoteTomlPath)) ?? ''
|
||||
const updatedToml = upsertHookTrustEntriesInContent(existingToml, trustEntries)
|
||||
@@ -1035,7 +1049,7 @@ export class CodexHookService {
|
||||
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
|
||||
const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand)
|
||||
config.hooks = hookPlan.hooks
|
||||
writeHooksJson(configPath, config)
|
||||
writeCodexHooksJson(configPath, hookPlan.hooks)
|
||||
|
||||
try {
|
||||
const tomlPath = getCodexConfigTomlPath()
|
||||
@@ -1080,7 +1094,6 @@ export class CodexHookService {
|
||||
}
|
||||
|
||||
const nextHooks = { ...config.hooks }
|
||||
let removedManagedHooks = false
|
||||
// Why: same broad matcher as install(), so remove() also cleans up stale
|
||||
// entries from older builds even if the current scriptPath has moved.
|
||||
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
|
||||
@@ -1092,18 +1105,16 @@ export class CodexHookService {
|
||||
continue
|
||||
}
|
||||
const cleaned = removeManagedCommands(definitions, isManagedCommand)
|
||||
if (JSON.stringify(cleaned) !== JSON.stringify(definitions)) {
|
||||
removedManagedHooks = true
|
||||
}
|
||||
if (cleaned.length === 0) {
|
||||
delete nextHooks[eventName]
|
||||
} else {
|
||||
nextHooks[eventName] = cleaned
|
||||
}
|
||||
}
|
||||
if (configExists && removedManagedHooks) {
|
||||
config.hooks = nextHooks
|
||||
writeHooksJson(configPath, config)
|
||||
if (configExists) {
|
||||
// Why: remove() can be the only repair path for a parseable runtime file
|
||||
// whose top-level plugin metadata makes Codex reject hooks.json.
|
||||
writeCodexHooksJson(configPath, nextHooks)
|
||||
}
|
||||
|
||||
// Why: also drop our trust entries so config.toml doesn't accumulate dead
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
import { startDaemon, type DaemonHandle } from './daemon-main'
|
||||
import { createPtySubprocess } from './pty-subprocess'
|
||||
import { warmPwshAvailabilityCache } from '../pwsh'
|
||||
|
||||
export function parseArgs(argv: string[]): { socketPath: string; tokenPath: string } {
|
||||
let socketPath = ''
|
||||
@@ -32,6 +33,7 @@ export function parseArgs(argv: string[]): { socketPath: string; tokenPath: stri
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { socketPath, tokenPath } = parseArgs(process.argv.slice(2))
|
||||
void warmPwshAvailabilityCache()
|
||||
|
||||
// Why: node-pty can throw a C++ Napi::Error that escapes all JS try/catch
|
||||
// blocks (e.g. writing to a PTY whose fd was closed between the native
|
||||
|
||||
@@ -81,58 +81,211 @@ describe('getWorkItemDetails', () => {
|
||||
acquireMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('uses the collapsed GraphQL issue query as the hot path', async () => {
|
||||
it('uses the collapsed GraphQL issue query with timeline activity enrichment', async () => {
|
||||
getWorkItemMock.mockResolvedValueOnce({
|
||||
id: 'issue:923',
|
||||
type: 'issue',
|
||||
number: 923,
|
||||
title: 'Use upstream issues',
|
||||
state: 'open',
|
||||
url: 'https://github.com/stablyai/orca/issues/923',
|
||||
url: 'https://github.com/acme/widgets/issues/923',
|
||||
labels: [],
|
||||
updatedAt: '2026-04-01T00:00:00Z',
|
||||
author: 'octocat'
|
||||
author: 'issue-author'
|
||||
})
|
||||
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
data: {
|
||||
repository: {
|
||||
issue: {
|
||||
body: 'Issue body',
|
||||
assignees: { nodes: [{ login: 'jinjing' }] },
|
||||
participants: {
|
||||
nodes: [{ login: 'octocat', avatarUrl: 'https://x/y', name: 'Octo Cat' }]
|
||||
},
|
||||
comments: {
|
||||
nodes: [
|
||||
{
|
||||
databaseId: 7,
|
||||
body: 'first',
|
||||
createdAt: '2026-04-01T00:00:00Z',
|
||||
url: 'https://github.com/stablyai/orca/issues/923#issuecomment-7',
|
||||
author: { login: 'octocat', avatarUrl: 'https://x/y' }
|
||||
}
|
||||
]
|
||||
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
|
||||
const timelineEvents = [
|
||||
{
|
||||
id: 101,
|
||||
event: 'assigned',
|
||||
actor: { login: 'timeline-actor', avatar_url: 'https://x/timeline-actor' },
|
||||
assignee: { login: 'assigned-user' },
|
||||
created_at: '2026-04-01T01:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
event: 'cross-referenced',
|
||||
actor: { login: 'timeline-actor', avatar_url: 'https://x/timeline-actor' },
|
||||
created_at: '2026-04-01T02:00:00Z',
|
||||
source: {
|
||||
issue: {
|
||||
number: 6180,
|
||||
title: 'Synthetic reference PR',
|
||||
html_url: 'https://github.com/acme/widgets/pull/6180',
|
||||
repository: { owner: { login: 'acme' }, name: 'widgets' },
|
||||
pull_request: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
event: 'moved_columns_in_project',
|
||||
actor: { login: 'github-project-automation', avatar_url: 'https://x/bot' },
|
||||
created_at: '2026-04-01T03:00:00Z',
|
||||
previous_column_name: 'Doing',
|
||||
project_column_name: 'Complete',
|
||||
project: { name: 'Example Project' }
|
||||
},
|
||||
{
|
||||
id: 104,
|
||||
event: 'closed',
|
||||
actor: { login: 'timeline-actor', avatar_url: 'https://x/timeline-actor' },
|
||||
created_at: '2026-04-01T04:00:00Z',
|
||||
state_reason: 'completed',
|
||||
closer: {
|
||||
number: 6180,
|
||||
title: 'Synthetic reference PR',
|
||||
html_url: 'https://github.com/acme/widgets/pull/6180',
|
||||
repository: { owner: { login: 'acme' }, name: 'widgets' },
|
||||
pull_request: {}
|
||||
}
|
||||
}
|
||||
]
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
data: {
|
||||
repository: {
|
||||
issue: {
|
||||
body: 'Issue body',
|
||||
assignees: { nodes: [{ login: 'assigned-user' }] },
|
||||
participants: {
|
||||
nodes: [{ login: 'issue-author', avatarUrl: 'https://x/y', name: 'Issue Author' }]
|
||||
},
|
||||
comments: {
|
||||
nodes: [
|
||||
{
|
||||
databaseId: 7,
|
||||
body: 'first',
|
||||
createdAt: '2026-04-01T00:00:00Z',
|
||||
url: 'https://github.com/acme/widgets/issues/923#issuecomment-7',
|
||||
author: { login: 'issue-author', avatarUrl: 'https://x/y' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: timelineEvents.map((event) => JSON.stringify(event)).join('\n')
|
||||
})
|
||||
})
|
||||
|
||||
const details = await getWorkItemDetails('/repo-root', 923, 'issue')
|
||||
|
||||
expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 923, 'issue', undefined)
|
||||
// Why: a single gh subprocess call replaces the previous REST + REST + GraphQL fan-out.
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
expect(ghExecFileAsyncMock.mock.calls[0][0][0]).toBe('api')
|
||||
expect(ghExecFileAsyncMock.mock.calls[0][0][1]).toBe('graphql')
|
||||
expect(ghExecFileAsyncMock.mock.calls[1][0]).toEqual([
|
||||
'api',
|
||||
'--cache',
|
||||
'60s',
|
||||
'repos/acme/widgets/issues/923/timeline?per_page=100&page=1',
|
||||
'--jq',
|
||||
'.[] | @json'
|
||||
])
|
||||
expect(details?.body).toBe('Issue body')
|
||||
expect(details?.assignees).toEqual(['jinjing'])
|
||||
expect(details?.assignees).toEqual(['assigned-user'])
|
||||
expect(details?.comments).toHaveLength(1)
|
||||
expect(details?.comments[0].id).toBe(7)
|
||||
expect(details?.participants?.[0]?.login).toBe('octocat')
|
||||
expect(details?.timelineItems).toMatchObject([
|
||||
{ event: 'assigned', actor: 'timeline-actor', assignee: 'assigned-user' },
|
||||
{
|
||||
event: 'cross-referenced',
|
||||
source: {
|
||||
type: 'pr',
|
||||
number: 6180,
|
||||
repository: 'acme/widgets'
|
||||
}
|
||||
},
|
||||
{
|
||||
event: 'moved_columns_in_project',
|
||||
actor: 'github-project-automation',
|
||||
previousColumnName: 'Doing',
|
||||
columnName: 'Complete',
|
||||
projectName: 'Example Project'
|
||||
},
|
||||
{
|
||||
event: 'closed',
|
||||
stateReason: 'completed',
|
||||
closer: { type: 'pr', number: 6180 }
|
||||
}
|
||||
])
|
||||
expect(details?.participants?.[0]?.login).toBe('issue-author')
|
||||
})
|
||||
|
||||
it('caps issue timeline pagination by supported activity items', async () => {
|
||||
getWorkItemMock.mockResolvedValueOnce({
|
||||
id: 'issue:923',
|
||||
type: 'issue',
|
||||
number: 923,
|
||||
title: 'Use upstream issues',
|
||||
state: 'open',
|
||||
url: 'https://github.com/acme/widgets/issues/923',
|
||||
labels: [],
|
||||
updatedAt: '2026-04-01T00:00:00Z',
|
||||
author: 'issue-author'
|
||||
})
|
||||
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
|
||||
const makeTimelineEvent = (page: number, index: number, event: string): string =>
|
||||
JSON.stringify({
|
||||
id: `${page}:${index}`,
|
||||
event,
|
||||
actor: { login: 'issue-author', avatar_url: 'https://x/y' },
|
||||
assignee: { login: `assignee-${page}-${index}` },
|
||||
created_at: '2026-04-01T00:00:00Z'
|
||||
})
|
||||
const makeTimelinePage = (page: number, supportedCount: number): string =>
|
||||
Array.from({ length: 100 }, (_, index) =>
|
||||
makeTimelineEvent(page, index, index < supportedCount ? 'assigned' : 'subscribed')
|
||||
).join('\n')
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
data: {
|
||||
repository: {
|
||||
issue: {
|
||||
body: 'Issue body',
|
||||
assignees: { nodes: [] },
|
||||
participants: { nodes: [] },
|
||||
comments: { nodes: [] }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({ stdout: makeTimelinePage(1, 0) })
|
||||
.mockResolvedValueOnce({ stdout: makeTimelinePage(2, 0) })
|
||||
.mockResolvedValueOnce({ stdout: makeTimelinePage(3, 10) })
|
||||
.mockResolvedValueOnce({ stdout: makeTimelinePage(4, 100) })
|
||||
.mockResolvedValueOnce({ stdout: makeTimelinePage(5, 100) })
|
||||
.mockResolvedValueOnce({ stdout: makeTimelinePage(6, 100) })
|
||||
|
||||
const details = await getWorkItemDetails('/repo-root', 923, 'issue')
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(7)
|
||||
expect(ghExecFileAsyncMock.mock.calls[1][0]).toContain(
|
||||
'repos/acme/widgets/issues/923/timeline?per_page=100&page=1'
|
||||
)
|
||||
expect(ghExecFileAsyncMock.mock.calls[2][0]).toContain(
|
||||
'repos/acme/widgets/issues/923/timeline?per_page=100&page=2'
|
||||
)
|
||||
expect(ghExecFileAsyncMock.mock.calls[3][0]).toContain(
|
||||
'repos/acme/widgets/issues/923/timeline?per_page=100&page=3'
|
||||
)
|
||||
expect(ghExecFileAsyncMock.mock.calls[6][0]).toContain(
|
||||
'repos/acme/widgets/issues/923/timeline?per_page=100&page=6'
|
||||
)
|
||||
expect(
|
||||
ghExecFileAsyncMock.mock.calls.some((call) =>
|
||||
call[0].includes('repos/acme/widgets/issues/923/timeline?per_page=100&page=7')
|
||||
)
|
||||
).toBe(false)
|
||||
expect(details?.timelineItems).toHaveLength(300)
|
||||
expect(details?.timelineItems.at(0)).toMatchObject({ assignee: 'assignee-3-0' })
|
||||
expect(details?.timelineItems.at(-1)).toMatchObject({ assignee: 'assignee-6-89' })
|
||||
})
|
||||
|
||||
it('falls back to REST + GraphQL when the collapsed issue query fails', async () => {
|
||||
@@ -142,17 +295,18 @@ describe('getWorkItemDetails', () => {
|
||||
number: 923,
|
||||
title: 'Use upstream issues',
|
||||
state: 'open',
|
||||
url: 'https://github.com/stablyai/orca/issues/923',
|
||||
url: 'https://github.com/acme/widgets/issues/923',
|
||||
labels: [],
|
||||
updatedAt: '2026-04-01T00:00:00Z',
|
||||
author: 'octocat'
|
||||
author: 'issue-author'
|
||||
})
|
||||
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
|
||||
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
|
||||
// Collapsed GraphQL throws → fallback path picks up.
|
||||
ghExecFileAsyncMock
|
||||
.mockRejectedValueOnce(new Error('GraphQL error'))
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ body: 'Issue body' }) })
|
||||
.mockResolvedValueOnce({ stdout: '[]' })
|
||||
.mockResolvedValueOnce({ stdout: '[]' })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
data: { repository: { issue: { participants: { nodes: [] } } } }
|
||||
@@ -166,12 +320,24 @@ describe('getWorkItemDetails', () => {
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
['api', '--cache', '60s', 'repos/stablyai/orca/issues/923'],
|
||||
['api', '--cache', '60s', 'repos/acme/widgets/issues/923'],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
['api', '--cache', '60s', 'repos/stablyai/orca/issues/923/comments?per_page=100'],
|
||||
['api', '--cache', '60s', 'repos/acme/widgets/issues/923/comments?per_page=100'],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
[
|
||||
'api',
|
||||
'--cache',
|
||||
'60s',
|
||||
'repos/acme/widgets/issues/923/timeline?per_page=100&page=1',
|
||||
'--jq',
|
||||
'.[] | @json'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(details?.body).toBe('Issue body')
|
||||
@@ -190,19 +356,20 @@ describe('getWorkItemDetails', () => {
|
||||
number: 923,
|
||||
title: 'Use upstream issues',
|
||||
state: 'open',
|
||||
url: 'https://github.com/stablyai/orca/issues/923',
|
||||
url: 'https://github.com/acme/widgets/issues/923',
|
||||
labels: [],
|
||||
updatedAt: '2026-04-01T00:00:00Z',
|
||||
author: 'octocat'
|
||||
author: 'issue-author'
|
||||
})
|
||||
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
|
||||
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ body: 'Issue body', assignees: [] }) })
|
||||
.mockResolvedValueOnce({ stdout: '[]' })
|
||||
.mockResolvedValueOnce({ stdout: '[]' })
|
||||
|
||||
const details = await getWorkItemDetails('/repo-root', 923, 'issue')
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3)
|
||||
expect(ghExecFileAsyncMock.mock.calls.some((call) => call[0][1] === 'graphql')).toBe(false)
|
||||
expect(noteRateLimitSpendMock).not.toHaveBeenCalled()
|
||||
expect(details?.body).toBe('Issue body')
|
||||
@@ -216,31 +383,33 @@ describe('getWorkItemDetails', () => {
|
||||
number: 923,
|
||||
title: 'Use upstream issues',
|
||||
state: 'open',
|
||||
url: 'https://github.com/stablyai/orca/issues/923',
|
||||
url: 'https://github.com/acme/widgets/issues/923',
|
||||
labels: [],
|
||||
updatedAt: '2026-04-01T00:00:00Z',
|
||||
author: 'octocat'
|
||||
author: 'issue-author'
|
||||
})
|
||||
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
data: {
|
||||
repository: {
|
||||
issue: {
|
||||
body: 'Remote issue body',
|
||||
assignees: { nodes: [] },
|
||||
participants: { nodes: [] },
|
||||
comments: { nodes: [] }
|
||||
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
data: {
|
||||
repository: {
|
||||
issue: {
|
||||
body: 'Remote issue body',
|
||||
assignees: { nodes: [] },
|
||||
participants: { nodes: [] },
|
||||
comments: { nodes: [] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({ stdout: '[]' })
|
||||
|
||||
const details = await getWorkItemDetails('/home/jinwoo/orca', 923, 'issue', 'openclaw-2')
|
||||
const details = await getWorkItemDetails('/home/tester/widgets', 923, 'issue', 'ssh-test-1')
|
||||
|
||||
expect(getWorkItemMock).toHaveBeenCalledWith('/home/jinwoo/orca', 923, 'issue', 'openclaw-2')
|
||||
expect(getIssueOwnerRepoMock).toHaveBeenCalledWith('/home/jinwoo/orca', 'openclaw-2')
|
||||
expect(getWorkItemMock).toHaveBeenCalledWith('/home/tester/widgets', 923, 'issue', 'ssh-test-1')
|
||||
expect(getIssueOwnerRepoMock).toHaveBeenCalledWith('/home/tester/widgets', 'ssh-test-1')
|
||||
expect(ghExecFileAsyncMock.mock.calls[0][1]).toEqual({})
|
||||
expect(details?.body).toBe('Remote issue body')
|
||||
})
|
||||
@@ -253,17 +422,17 @@ describe('getWorkItemDetails', () => {
|
||||
number: 42,
|
||||
title: 'Review drawer WSL',
|
||||
state: 'open',
|
||||
url: 'https://github.com/stablyai/orca/pull/42',
|
||||
url: 'https://github.com/acme/widgets/pull/42',
|
||||
labels: [],
|
||||
updatedAt: '2026-04-01T00:00:00Z',
|
||||
author: 'octocat'
|
||||
author: 'pr-author'
|
||||
})
|
||||
getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
|
||||
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
|
||||
getPRCommentsMock.mockResolvedValue([])
|
||||
getPRChecksMock.mockResolvedValue([])
|
||||
ghExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
const target = args.at(-1)
|
||||
if (target === 'repos/stablyai/orca/pulls/42') {
|
||||
if (target === 'repos/acme/widgets/pulls/42') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
body: 'PR body',
|
||||
@@ -272,7 +441,7 @@ describe('getWorkItemDetails', () => {
|
||||
})
|
||||
}
|
||||
}
|
||||
if (target === 'repos/stablyai/orca/pulls/42/files?per_page=100') {
|
||||
if (target === 'repos/acme/widgets/pulls/42/files?per_page=100') {
|
||||
return { stdout: '[]' }
|
||||
}
|
||||
const query = args.find((arg) => arg.startsWith('query=')) ?? ''
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
GitHubPRFile,
|
||||
GitHubPRFileContents,
|
||||
GitHubPRFileViewedState,
|
||||
GitHubIssueTimelineItem,
|
||||
GitHubIssueTimelineTarget,
|
||||
GitHubWorkItem,
|
||||
GitHubWorkItemDetails,
|
||||
PRCheckDetail,
|
||||
@@ -30,6 +32,10 @@ import { isMaxBufferOverflowError } from '../git/max-buffer-overflow'
|
||||
// at 100 per page; we cap at a reasonable total so a massive PR cannot starve
|
||||
// the gh semaphore while we fetch file listings.
|
||||
const MAX_PR_FILES = 300
|
||||
// Why: issue timelines can be extremely noisy from automation and cross-links.
|
||||
// Bound drawer detail work so one huge issue cannot monopolize gh/API time.
|
||||
const MAX_ISSUE_TIMELINE_ITEMS = 300
|
||||
const GITHUB_REST_PAGE_SIZE = 100
|
||||
// Why: hosted PR files must exceed the renderer's large-diff threshold before
|
||||
// we give up on the raw fetch; otherwise the UI sees an empty diff instead of
|
||||
// the safety fallback.
|
||||
@@ -126,6 +132,197 @@ type GraphQLIssueDetailsResponse = {
|
||||
errors?: { message?: string }[]
|
||||
}
|
||||
|
||||
type GitHubOwnerRepoSlug = { owner: string; repo: string }
|
||||
|
||||
type RestTimelineUser = {
|
||||
login?: string | null
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
type RestTimelineIssue = {
|
||||
number?: number | null
|
||||
title?: string | null
|
||||
html_url?: string | null
|
||||
repository?: {
|
||||
name?: string | null
|
||||
owner?: { login?: string | null } | null
|
||||
} | null
|
||||
pull_request?: unknown
|
||||
}
|
||||
|
||||
type RestTimelineEvent = {
|
||||
id?: number | string | null
|
||||
node_id?: string | null
|
||||
event?: string | null
|
||||
actor?: RestTimelineUser | null
|
||||
user?: RestTimelineUser | null
|
||||
assignee?: RestTimelineUser | null
|
||||
created_at?: string | null
|
||||
source?: {
|
||||
issue?: RestTimelineIssue | null
|
||||
} | null
|
||||
closer?: RestTimelineIssue | null
|
||||
state_reason?: string | null
|
||||
project_card?: {
|
||||
column_name?: string | null
|
||||
previous_column_name?: string | null
|
||||
project_url?: string | null
|
||||
} | null
|
||||
project?: {
|
||||
name?: string | null
|
||||
} | null
|
||||
project_column_name?: string | null
|
||||
previous_column_name?: string | null
|
||||
}
|
||||
|
||||
function isSupportedTimelineEvent(
|
||||
eventName: string | null | undefined
|
||||
): eventName is GitHubIssueTimelineItem['event'] {
|
||||
return (
|
||||
eventName === 'assigned' ||
|
||||
eventName === 'unassigned' ||
|
||||
eventName === 'mentioned' ||
|
||||
eventName === 'cross-referenced' ||
|
||||
eventName === 'closed' ||
|
||||
eventName === 'reopened' ||
|
||||
eventName === 'moved_columns_in_project'
|
||||
)
|
||||
}
|
||||
|
||||
function mapTimelineTarget(
|
||||
issue: RestTimelineIssue | null | undefined
|
||||
): GitHubIssueTimelineTarget | undefined {
|
||||
if (!issue || typeof issue.number !== 'number' || !issue.html_url) {
|
||||
return undefined
|
||||
}
|
||||
const owner = issue.repository?.owner?.login
|
||||
const repo = issue.repository?.name
|
||||
return {
|
||||
type: issue.pull_request ? 'pr' : 'issue',
|
||||
number: issue.number,
|
||||
title: issue.title ?? '',
|
||||
url: issue.html_url,
|
||||
repository: owner && repo ? `${owner}/${repo}` : undefined
|
||||
}
|
||||
}
|
||||
|
||||
function getTimelineActor(event: RestTimelineEvent): { login: string; avatarUrl: string } {
|
||||
const actor = event.actor ?? event.user
|
||||
return {
|
||||
login: actor?.login ?? 'ghost',
|
||||
avatarUrl: actor?.avatar_url ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
function mapRestTimelineEvent(event: RestTimelineEvent): GitHubIssueTimelineItem | null {
|
||||
const eventName = event.event
|
||||
if (!isSupportedTimelineEvent(eventName)) {
|
||||
return null
|
||||
}
|
||||
if (!event.created_at) {
|
||||
return null
|
||||
}
|
||||
const actor = getTimelineActor(event)
|
||||
const id = String(event.node_id ?? event.id ?? `${eventName}:${event.created_at}`)
|
||||
const base = {
|
||||
id,
|
||||
event: eventName,
|
||||
actor: actor.login,
|
||||
actorAvatarUrl: actor.avatarUrl,
|
||||
createdAt: event.created_at
|
||||
}
|
||||
if (eventName === 'assigned' || eventName === 'unassigned') {
|
||||
return {
|
||||
...base,
|
||||
assignee: event.assignee?.login ?? undefined
|
||||
}
|
||||
}
|
||||
if (eventName === 'mentioned' || eventName === 'cross-referenced') {
|
||||
return {
|
||||
...base,
|
||||
source: mapTimelineTarget(event.source?.issue)
|
||||
}
|
||||
}
|
||||
if (eventName === 'closed') {
|
||||
return {
|
||||
...base,
|
||||
stateReason: event.state_reason ?? null,
|
||||
closer: mapTimelineTarget(event.closer ?? event.source?.issue)
|
||||
}
|
||||
}
|
||||
if (eventName === 'moved_columns_in_project') {
|
||||
return {
|
||||
...base,
|
||||
previousColumnName:
|
||||
event.previous_column_name ?? event.project_card?.previous_column_name ?? null,
|
||||
columnName: event.project_column_name ?? event.project_card?.column_name ?? null,
|
||||
projectName: event.project?.name ?? null
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
function parseRestTimelineEventLines(stdout: string): RestTimelineEvent[] {
|
||||
const events: RestTimelineEvent[] = []
|
||||
for (const line of stdout.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
events.push(parsed)
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed jq lines; timeline activity is auxiliary to issue details.
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
async function getIssueTimelineItems(
|
||||
ownerRepo: GitHubOwnerRepoSlug,
|
||||
issueNumber: number,
|
||||
ghOptions: ReturnType<typeof ghRepoExecOptions>
|
||||
): Promise<GitHubIssueTimelineItem[]> {
|
||||
try {
|
||||
const items: GitHubIssueTimelineItem[] = []
|
||||
for (let page = 1; items.length < MAX_ISSUE_TIMELINE_ITEMS; page += 1) {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
'--cache',
|
||||
'60s',
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}/timeline?per_page=${GITHUB_REST_PAGE_SIZE}&page=${page}`,
|
||||
'--jq',
|
||||
'.[] | @json'
|
||||
],
|
||||
ghOptions
|
||||
)
|
||||
// Why: --jq emits compact NDJSON while explicit pages let us stop once
|
||||
// supported activity reaches the drawer cap.
|
||||
const pageEvents = parseRestTimelineEventLines(stdout)
|
||||
for (const event of pageEvents) {
|
||||
const item = mapRestTimelineEvent(event)
|
||||
if (!item) {
|
||||
continue
|
||||
}
|
||||
items.push(item)
|
||||
if (items.length === MAX_ISSUE_TIMELINE_ITEMS) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (pageEvents.length < GITHUB_REST_PAGE_SIZE) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return items
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function getIssueDetailsViaGraphQL(
|
||||
repoPath: string,
|
||||
issueNumber: number,
|
||||
@@ -136,6 +333,7 @@ async function getIssueDetailsViaGraphQL(
|
||||
comments: PRComment[]
|
||||
assignees: string[]
|
||||
participants: GitHubAssignableUser[]
|
||||
timelineItems: GitHubIssueTimelineItem[]
|
||||
} | null> {
|
||||
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
|
||||
const ownerRepo = await getIssueOwnerRepo(
|
||||
@@ -198,11 +396,13 @@ async function getIssueDetailsViaGraphQL(
|
||||
name: u.name ?? null,
|
||||
avatarUrl: u.avatarUrl ?? ''
|
||||
}))
|
||||
const timelineItems = await getIssueTimelineItems(ownerRepo, issueNumber, ghOptions)
|
||||
return {
|
||||
body: issue.body ?? '',
|
||||
comments,
|
||||
assignees,
|
||||
participants
|
||||
participants,
|
||||
timelineItems
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
@@ -471,7 +671,12 @@ async function getIssueBodyAndComments(
|
||||
issueNumber: number,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<{ body: string; comments: PRComment[]; assignees: string[] }> {
|
||||
): Promise<{
|
||||
body: string
|
||||
comments: PRComment[]
|
||||
assignees: string[]
|
||||
timelineItems: GitHubIssueTimelineItem[]
|
||||
}> {
|
||||
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
|
||||
const ownerRepo = await getIssueOwnerRepo(
|
||||
repoPath,
|
||||
@@ -480,7 +685,7 @@ async function getIssueBodyAndComments(
|
||||
)
|
||||
try {
|
||||
if (ownerRepo) {
|
||||
const [issueResult, commentsResult] = await Promise.all([
|
||||
const [issueResult, commentsResult, timelineItems] = await Promise.all([
|
||||
ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
@@ -498,7 +703,8 @@ async function getIssueBodyAndComments(
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}/comments?per_page=100`
|
||||
],
|
||||
ghOptions
|
||||
)
|
||||
),
|
||||
getIssueTimelineItems(ownerRepo, issueNumber, ghOptions)
|
||||
])
|
||||
const issue = JSON.parse(issueResult.stdout) as {
|
||||
body?: string | null
|
||||
@@ -523,7 +729,7 @@ async function getIssueBodyAndComments(
|
||||
})
|
||||
)
|
||||
const assignees = (issue.assignees ?? []).map((a) => a.login)
|
||||
return { body: issue.body ?? '', comments, assignees }
|
||||
return { body: issue.body ?? '', comments, assignees, timelineItems }
|
||||
}
|
||||
// Fallback: non-GitHub remote
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
@@ -551,9 +757,9 @@ async function getIssueBodyAndComments(
|
||||
})
|
||||
)
|
||||
const fallbackAssignees = (data.assignees ?? []).map((a) => a.login)
|
||||
return { body: data.body ?? '', comments, assignees: fallbackAssignees }
|
||||
return { body: data.body ?? '', comments, assignees: fallbackAssignees, timelineItems: [] }
|
||||
} catch {
|
||||
return { body: '', comments: [], assignees: [] }
|
||||
return { body: '', comments: [], assignees: [], timelineItems: [] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -802,12 +1008,13 @@ export async function getWorkItemDetails(
|
||||
body: collapsed.body,
|
||||
comments: collapsed.comments,
|
||||
assignees: collapsed.assignees,
|
||||
participants: collapsed.participants
|
||||
participants: collapsed.participants,
|
||||
timelineItems: collapsed.timelineItems
|
||||
}
|
||||
}
|
||||
// Why: fall back to body/comments and GraphQL participants in parallel;
|
||||
// the mention-participant merge is a cheap local operation afterward.
|
||||
const [{ body, comments, assignees }, participants] = await Promise.all([
|
||||
const [{ body, comments, assignees, timelineItems }, participants] = await Promise.all([
|
||||
getIssueBodyAndComments(repoPath, item.number, connectionId, localGitOptions),
|
||||
getWorkItemParticipants(repoPath, item, connectionId, localGitOptions)
|
||||
])
|
||||
@@ -824,7 +1031,8 @@ export async function getWorkItemDetails(
|
||||
body,
|
||||
comments,
|
||||
assignees,
|
||||
participants: mentionParticipants
|
||||
participants: mentionParticipants,
|
||||
timelineItems
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,11 @@ let inflightKey: string | null = null
|
||||
let handlerOptions: AiVaultHandlerOptions = {}
|
||||
|
||||
async function listAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListResult> {
|
||||
const key = String(args?.limit ?? 'default')
|
||||
// Scope paths change the result set, so they must be part of the cache key.
|
||||
const key = JSON.stringify({
|
||||
limit: args?.limit ?? 'default',
|
||||
scopePaths: args?.scopePaths ?? []
|
||||
})
|
||||
const now = Date.now()
|
||||
// Why: opening this panel repeatedly should not re-parse hundreds of JSONL
|
||||
// transcripts; explicit refreshes bypass the cache but not an active scan.
|
||||
@@ -40,6 +44,7 @@ async function listAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListR
|
||||
inflightList = (async () =>
|
||||
scanAiVaultSessions({
|
||||
limit: args?.limit,
|
||||
scopePaths: args?.scopePaths,
|
||||
additionalCodexSessionsDirs,
|
||||
wslHomeDirs: await getAiVaultWslHomeDirs()
|
||||
}))()
|
||||
|
||||
+16
-2
@@ -81,6 +81,7 @@ import { track } from '../telemetry/client'
|
||||
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
|
||||
import type { RepoMethod } from '../../shared/telemetry-events'
|
||||
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
|
||||
import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment'
|
||||
import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-projection'
|
||||
import { normalizeExecutionHostId, parseExecutionHostId } from '../../shared/execution-host'
|
||||
import { joinRemotePath } from '../ssh/ssh-remote-platform'
|
||||
@@ -1137,10 +1138,18 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
ipcMain.removeHandler('sparsePresets:remove')
|
||||
|
||||
ipcMain.handle('repos:list', () => {
|
||||
enrichMissingRepoGitRemoteIdentities(store, {
|
||||
onChanged: () => notifyReposChanged(mainWindow)
|
||||
})
|
||||
return store.getRepos()
|
||||
})
|
||||
|
||||
ipcMain.handle('projects:list', () => store.getProjects())
|
||||
ipcMain.handle('projects:list', () => {
|
||||
enrichMissingRepoGitRemoteIdentities(store, {
|
||||
onChanged: () => notifyReposChanged(mainWindow)
|
||||
})
|
||||
return store.getProjects()
|
||||
})
|
||||
|
||||
ipcMain.handle('projects:update', (_event, rawArgs: ProjectUpdateArgs): Project | null => {
|
||||
const args = parseProjectGroupIpcArgs(
|
||||
@@ -1151,7 +1160,12 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
return store.updateProject(args.projectId, args.updates)
|
||||
})
|
||||
|
||||
ipcMain.handle('projectHostSetups:list', () => store.getProjectHostSetups())
|
||||
ipcMain.handle('projectHostSetups:list', () => {
|
||||
enrichMissingRepoGitRemoteIdentities(store, {
|
||||
onChanged: () => notifyReposChanged(mainWindow)
|
||||
})
|
||||
return store.getProjectHostSetups()
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'projectHostSetups:create',
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
normalizeProjectRuntimePreference
|
||||
} from '../shared/project-execution-runtime'
|
||||
import { projectHostSetupProjectionFromRepos } from '../shared/project-host-setup-projection'
|
||||
import type { GitRemoteIdentity } from '../shared/git-remote-identity'
|
||||
import {
|
||||
buildTaskSourceContextFromRepo,
|
||||
buildWorkspaceRunContext
|
||||
@@ -1081,6 +1082,24 @@ function sanitizeRepoUpstream(value: unknown): Repo['upstream'] | undefined {
|
||||
return owner && repo ? { owner, repo } : undefined
|
||||
}
|
||||
|
||||
function sanitizeGitRemoteIdentity(value: unknown): GitRemoteIdentity | undefined {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
const candidate = value as {
|
||||
canonicalKey?: unknown
|
||||
remoteName?: unknown
|
||||
remoteUrl?: unknown
|
||||
}
|
||||
const canonicalKey =
|
||||
typeof candidate.canonicalKey === 'string' ? candidate.canonicalKey.trim() : ''
|
||||
const remoteName = typeof candidate.remoteName === 'string' ? candidate.remoteName.trim() : ''
|
||||
const remoteUrl = typeof candidate.remoteUrl === 'string' ? candidate.remoteUrl.trim() : ''
|
||||
return canonicalKey && remoteName && remoteUrl
|
||||
? { canonicalKey, remoteName, remoteUrl }
|
||||
: undefined
|
||||
}
|
||||
|
||||
function sanitizeRepoProjectHostSetupMethod(
|
||||
value: unknown
|
||||
): RepoProjectHostSetupMethod | undefined {
|
||||
@@ -1098,6 +1117,7 @@ function sanitizeRepoUpdatesForPersistence<
|
||||
| 'badgeColor'
|
||||
| 'repoIcon'
|
||||
| 'upstream'
|
||||
| 'gitRemoteIdentity'
|
||||
| 'worktreeBasePath'
|
||||
| 'projectHostSetupMethod'
|
||||
| 'forkSyncMode'
|
||||
@@ -1130,6 +1150,14 @@ function sanitizeRepoUpdatesForPersistence<
|
||||
sanitized.upstream = upstream
|
||||
}
|
||||
}
|
||||
if ('gitRemoteIdentity' in sanitized) {
|
||||
const gitRemoteIdentity = sanitizeGitRemoteIdentity(sanitized.gitRemoteIdentity)
|
||||
if (gitRemoteIdentity === undefined) {
|
||||
delete sanitized.gitRemoteIdentity
|
||||
} else {
|
||||
sanitized.gitRemoteIdentity = gitRemoteIdentity
|
||||
}
|
||||
}
|
||||
if ('worktreeBasePath' in sanitized && sanitized.worktreeBasePath !== undefined) {
|
||||
if (typeof sanitized.worktreeBasePath === 'string') {
|
||||
sanitized.worktreeBasePath = sanitized.worktreeBasePath.trim() || undefined
|
||||
@@ -3720,6 +3748,7 @@ export class Store {
|
||||
| 'badgeColor'
|
||||
| 'repoIcon'
|
||||
| 'upstream'
|
||||
| 'gitRemoteIdentity'
|
||||
| 'hookSettings'
|
||||
| 'worktreeBaseRef'
|
||||
| 'worktreeBasePath'
|
||||
@@ -3895,6 +3924,7 @@ export class Store {
|
||||
const {
|
||||
repoIcon: rawRepoIcon,
|
||||
upstream: rawUpstream,
|
||||
gitRemoteIdentity: rawGitRemoteIdentity,
|
||||
sourceControlAi: rawSourceControlAi,
|
||||
projectHostSetupMethod: rawProjectHostSetupMethod,
|
||||
forkSyncMode: rawForkSyncMode,
|
||||
@@ -3902,6 +3932,7 @@ export class Store {
|
||||
} = repo
|
||||
const repoIcon = sanitizeRepoIcon(rawRepoIcon)
|
||||
const upstream = sanitizeRepoUpstream(rawUpstream)
|
||||
const gitRemoteIdentity = sanitizeGitRemoteIdentity(rawGitRemoteIdentity)
|
||||
const sourceControlAi = normalizeRepoSourceControlAiOverrides(rawSourceControlAi)
|
||||
const projectHostSetupMethod = sanitizeRepoProjectHostSetupMethod(rawProjectHostSetupMethod)
|
||||
const forkSyncMode = sanitizeForkSyncMode(rawForkSyncMode)
|
||||
@@ -3918,6 +3949,7 @@ export class Store {
|
||||
...repoWithoutIcon,
|
||||
...(repoIcon !== undefined ? { repoIcon } : {}),
|
||||
...(upstream !== undefined ? { upstream } : {}),
|
||||
...(gitRemoteIdentity !== undefined ? { gitRemoteIdentity } : {}),
|
||||
...(sourceControlAi !== undefined ? { sourceControlAi } : {}),
|
||||
...(projectHostSetupMethod !== undefined ? { projectHostSetupMethod } : {}),
|
||||
...(forkSyncMode !== undefined ? { forkSyncMode } : {}),
|
||||
|
||||
+71
-1
@@ -1,10 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { execFileSyncMock } = vi.hoisted(() => ({
|
||||
const { execFileMock, execFileSyncMock } = vi.hoisted(() => ({
|
||||
execFileMock: vi.fn(),
|
||||
execFileSyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
execFile: execFileMock,
|
||||
execFileSync: execFileSyncMock
|
||||
}))
|
||||
|
||||
@@ -26,6 +28,8 @@ function setPlatform(platform: NodeJS.Platform): () => void {
|
||||
describe('isPwshAvailable', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.useRealTimers()
|
||||
execFileMock.mockReset()
|
||||
execFileSyncMock.mockReset()
|
||||
})
|
||||
|
||||
@@ -84,4 +88,70 @@ describe('isPwshAvailable', () => {
|
||||
restorePlatform()
|
||||
}
|
||||
})
|
||||
|
||||
it('repro: does not keep a cold-start timeout cached for the daemon lifetime', async () => {
|
||||
const restorePlatform = setPlatform('win32')
|
||||
execFileSyncMock
|
||||
.mockImplementationOnce(() => {
|
||||
const error = Object.assign(new Error('spawnSync pwsh.exe ETIMEDOUT'), {
|
||||
code: 'ETIMEDOUT'
|
||||
})
|
||||
throw error
|
||||
})
|
||||
.mockReturnValue('PowerShell 7.5.0')
|
||||
|
||||
try {
|
||||
const { isPwshAvailable } = await import('./pwsh')
|
||||
expect(isPwshAvailable()).toBe(false)
|
||||
expect(isPwshAvailable()).toBe(true)
|
||||
expect(execFileSyncMock).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
restorePlatform()
|
||||
}
|
||||
})
|
||||
|
||||
it('warms pwsh availability asynchronously with a longer timeout', async () => {
|
||||
const restorePlatform = setPlatform('win32')
|
||||
execFileMock.mockImplementation((_file, _args, _options, callback) => {
|
||||
callback(null, 'PowerShell 7.5.0', '')
|
||||
})
|
||||
|
||||
try {
|
||||
const { isPwshAvailable, warmPwshAvailabilityCache } = await import('./pwsh')
|
||||
await expect(warmPwshAvailabilityCache()).resolves.toBe(true)
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
'pwsh.exe',
|
||||
['-Version'],
|
||||
{ timeout: 30_000 },
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(isPwshAvailable()).toBe(true)
|
||||
expect(execFileSyncMock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
restorePlatform()
|
||||
}
|
||||
})
|
||||
|
||||
it('retries non-timeout failures after the negative cache TTL', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
const restorePlatform = setPlatform('win32')
|
||||
execFileSyncMock
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('missing pwsh')
|
||||
})
|
||||
.mockReturnValue('PowerShell 7.5.0')
|
||||
|
||||
try {
|
||||
const { isPwshAvailable } = await import('./pwsh')
|
||||
expect(isPwshAvailable()).toBe(false)
|
||||
expect(isPwshAvailable()).toBe(false)
|
||||
vi.setSystemTime(31_001)
|
||||
expect(isPwshAvailable()).toBe(true)
|
||||
expect(execFileSyncMock).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
restorePlatform()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+73
-12
@@ -1,31 +1,92 @@
|
||||
import { execFileSync } from 'child_process'
|
||||
import { execFile, execFileSync } from 'child_process'
|
||||
|
||||
// Cached pwsh availability check — evaluated once per process lifetime
|
||||
let pwshAvailableCache: boolean | null = null
|
||||
const PWSH_SYNC_PROBE_TIMEOUT_MS = 5000
|
||||
const PWSH_WARMUP_PROBE_TIMEOUT_MS = 30_000
|
||||
const PWSH_NEGATIVE_CACHE_TTL_MS = 30_000
|
||||
|
||||
type PwshAvailabilityCache =
|
||||
| { available: true }
|
||||
| { available: false; cachedAt: number; retryable: boolean }
|
||||
|
||||
let pwshAvailableCache: PwshAvailabilityCache | null = null
|
||||
let pwshWarmupInFlight: Promise<boolean> | null = null
|
||||
|
||||
function isCacheFresh(cache: PwshAvailabilityCache): boolean {
|
||||
return (
|
||||
cache.available || !cache.retryable || Date.now() - cache.cachedAt < PWSH_NEGATIVE_CACHE_TTL_MS
|
||||
)
|
||||
}
|
||||
|
||||
function isTimeoutError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as NodeJS.ErrnoException).code === 'ETIMEDOUT'
|
||||
)
|
||||
}
|
||||
|
||||
function cachePwshProbeFailure(error: unknown): void {
|
||||
// Why: pwsh.exe cold starts can exceed the sync timeout; do not let one slow
|
||||
// .NET startup disable the user's PowerShell 7 preference for the daemon.
|
||||
if (isTimeoutError(error)) {
|
||||
pwshAvailableCache = null
|
||||
return
|
||||
}
|
||||
pwshAvailableCache = { available: false, cachedAt: Date.now(), retryable: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether pwsh.exe is available on this Windows machine.
|
||||
* Result is cached for the process lifetime.
|
||||
* Positive results are cached for the process lifetime; negative results are
|
||||
* retried so transient cold-start failures cannot outlive the daemon.
|
||||
*/
|
||||
export function isPwshAvailable(): boolean {
|
||||
if (pwshAvailableCache !== null) {
|
||||
return pwshAvailableCache
|
||||
if (pwshAvailableCache && isCacheFresh(pwshAvailableCache)) {
|
||||
return pwshAvailableCache.available
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
pwshAvailableCache = false
|
||||
pwshAvailableCache = { available: false, cachedAt: Date.now(), retryable: false }
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync('pwsh.exe', ['-Version'], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 5000
|
||||
timeout: PWSH_SYNC_PROBE_TIMEOUT_MS
|
||||
})
|
||||
pwshAvailableCache = true
|
||||
} catch {
|
||||
pwshAvailableCache = false
|
||||
pwshAvailableCache = { available: true }
|
||||
} catch (error) {
|
||||
cachePwshProbeFailure(error)
|
||||
}
|
||||
|
||||
return pwshAvailableCache
|
||||
return pwshAvailableCache?.available ?? false
|
||||
}
|
||||
|
||||
export function warmPwshAvailabilityCache(): Promise<boolean> {
|
||||
if (pwshAvailableCache?.available) {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
if (process.platform !== 'win32') {
|
||||
pwshAvailableCache = { available: false, cachedAt: Date.now(), retryable: false }
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
if (pwshWarmupInFlight) {
|
||||
return pwshWarmupInFlight
|
||||
}
|
||||
|
||||
pwshWarmupInFlight = new Promise((resolve) => {
|
||||
execFile('pwsh.exe', ['-Version'], { timeout: PWSH_WARMUP_PROBE_TIMEOUT_MS }, (error) => {
|
||||
pwshWarmupInFlight = null
|
||||
if (!error) {
|
||||
pwshAvailableCache = { available: true }
|
||||
resolve(true)
|
||||
return
|
||||
}
|
||||
cachePwshProbeFailure(error)
|
||||
resolve(false)
|
||||
})
|
||||
})
|
||||
return pwshWarmupInFlight
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GitRemoteIdentity } from '../shared/git-remote-identity'
|
||||
import type { Repo } from '../shared/types'
|
||||
import { detectGitRemoteIdentity } from './repo-git-remote-identity'
|
||||
import {
|
||||
enrichMissingRepoGitRemoteIdentities,
|
||||
flushRepoGitRemoteIdentityEnrichmentForTests,
|
||||
resetRepoGitRemoteIdentityEnrichmentForTests
|
||||
} from './repo-git-remote-identity-enrichment'
|
||||
|
||||
vi.mock('./repo-git-remote-identity', () => ({
|
||||
detectGitRemoteIdentity: vi.fn()
|
||||
}))
|
||||
|
||||
type RepoIdentityStore = {
|
||||
getRepos: () => Repo[]
|
||||
getRepo: (id: string) => Repo | undefined
|
||||
updateRepo: (id: string, updates: Pick<Partial<Repo>, 'gitRemoteIdentity'>) => Repo | null
|
||||
}
|
||||
|
||||
const remoteIdentity: GitRemoteIdentity = {
|
||||
canonicalKey: 'git.company.test/team/sample-app',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'git@git.company.test:team/sample-app.git'
|
||||
}
|
||||
|
||||
function makeRepo(overrides: Partial<Repo> = {}): Repo {
|
||||
return {
|
||||
id: 'repo-1',
|
||||
path: '/workspace/sample-app',
|
||||
displayName: 'sample-app',
|
||||
badgeColor: '#737373',
|
||||
addedAt: 1,
|
||||
kind: 'git',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeStore(repo: Repo): RepoIdentityStore & { updateRepo: ReturnType<typeof vi.fn> } {
|
||||
const repos = [repo]
|
||||
return {
|
||||
getRepos: () => repos,
|
||||
getRepo: (id) => repos.find((candidate) => candidate.id === id),
|
||||
updateRepo: vi.fn((id, updates) => {
|
||||
const target = repos.find((candidate) => candidate.id === id)
|
||||
if (!target) {
|
||||
return null
|
||||
}
|
||||
Object.assign(target, updates)
|
||||
return target
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
} {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
resetRepoGitRemoteIdentityEnrichmentForTests()
|
||||
})
|
||||
|
||||
describe('enrichMissingRepoGitRemoteIdentities', () => {
|
||||
it('schedules remote identity enrichment without blocking the caller', async () => {
|
||||
vi.mocked(detectGitRemoteIdentity).mockResolvedValue(remoteIdentity)
|
||||
const repo = makeRepo()
|
||||
const store = makeStore(repo)
|
||||
const onChanged = vi.fn()
|
||||
|
||||
enrichMissingRepoGitRemoteIdentities(store, { onChanged })
|
||||
|
||||
expect(repo.gitRemoteIdentity).toBeUndefined()
|
||||
expect(detectGitRemoteIdentity).toHaveBeenCalledWith('/workspace/sample-app', undefined)
|
||||
|
||||
await flushRepoGitRemoteIdentityEnrichmentForTests()
|
||||
|
||||
expect(repo.gitRemoteIdentity).toEqual(remoteIdentity)
|
||||
expect(onChanged).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('coalesces concurrent probes for the same repo location', async () => {
|
||||
const probe = deferred<GitRemoteIdentity | null>()
|
||||
vi.mocked(detectGitRemoteIdentity).mockReturnValue(probe.promise)
|
||||
const repo = makeRepo()
|
||||
const store = makeStore(repo)
|
||||
|
||||
enrichMissingRepoGitRemoteIdentities(store)
|
||||
enrichMissingRepoGitRemoteIdentities(store)
|
||||
|
||||
expect(detectGitRemoteIdentity).toHaveBeenCalledTimes(1)
|
||||
|
||||
probe.resolve(remoteIdentity)
|
||||
await flushRepoGitRemoteIdentityEnrichmentForTests()
|
||||
|
||||
expect(store.updateRepo).toHaveBeenCalledTimes(1)
|
||||
expect(repo.gitRemoteIdentity).toEqual(remoteIdentity)
|
||||
})
|
||||
|
||||
it('caches no-identity probes briefly so list calls do not retry every time', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
vi.mocked(detectGitRemoteIdentity).mockResolvedValue(null)
|
||||
const repo = makeRepo()
|
||||
const store = makeStore(repo)
|
||||
|
||||
enrichMissingRepoGitRemoteIdentities(store)
|
||||
await flushRepoGitRemoteIdentityEnrichmentForTests()
|
||||
enrichMissingRepoGitRemoteIdentities(store)
|
||||
await flushRepoGitRemoteIdentityEnrichmentForTests()
|
||||
|
||||
expect(detectGitRemoteIdentity).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not write stale identity data after the repo path changes', async () => {
|
||||
const probe = deferred<GitRemoteIdentity | null>()
|
||||
vi.mocked(detectGitRemoteIdentity).mockReturnValue(probe.promise)
|
||||
const repo = makeRepo()
|
||||
const store = makeStore(repo)
|
||||
|
||||
enrichMissingRepoGitRemoteIdentities(store)
|
||||
repo.path = '/workspace/renamed-sample-app'
|
||||
probe.resolve(remoteIdentity)
|
||||
await flushRepoGitRemoteIdentityEnrichmentForTests()
|
||||
|
||||
expect(store.updateRepo).not.toHaveBeenCalled()
|
||||
expect(repo.gitRemoteIdentity).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Repo } from '../shared/types'
|
||||
import { detectGitRemoteIdentity } from './repo-git-remote-identity'
|
||||
|
||||
const NO_IDENTITY_RETRY_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
type RepoIdentityStore = {
|
||||
getRepos(): Repo[]
|
||||
getRepo?(id: string): Repo | undefined
|
||||
updateRepo(id: string, updates: Pick<Partial<Repo>, 'gitRemoteIdentity'>): Repo | null
|
||||
}
|
||||
|
||||
type EnrichmentOptions = {
|
||||
onChanged?: () => void
|
||||
}
|
||||
|
||||
const inFlightProbesByLocation = new Map<string, Promise<boolean>>()
|
||||
const noIdentityRetryAfterByLocation = new Map<string, number>()
|
||||
|
||||
function getRepoLocationKey(repo: Pick<Repo, 'path' | 'connectionId'>): string {
|
||||
return `${repo.connectionId ?? 'local'}\0${repo.path}`
|
||||
}
|
||||
|
||||
function getCurrentRepo(store: RepoIdentityStore, id: string): Repo | undefined {
|
||||
return store.getRepo?.(id) ?? store.getRepos().find((repo) => repo.id === id)
|
||||
}
|
||||
|
||||
function isSameUnenrichedRepo(snapshot: Repo, current: Repo | undefined): boolean {
|
||||
return (
|
||||
!!current &&
|
||||
current.kind !== 'folder' &&
|
||||
!current.gitRemoteIdentity &&
|
||||
current.path === snapshot.path &&
|
||||
(current.connectionId ?? null) === (snapshot.connectionId ?? null)
|
||||
)
|
||||
}
|
||||
|
||||
async function enrichRepoGitRemoteIdentity(store: RepoIdentityStore, repo: Repo): Promise<boolean> {
|
||||
const locationKey = getRepoLocationKey(repo)
|
||||
const retryAfter = noIdentityRetryAfterByLocation.get(locationKey) ?? 0
|
||||
if (retryAfter > Date.now()) {
|
||||
return false
|
||||
}
|
||||
const inFlight = inFlightProbesByLocation.get(locationKey)
|
||||
if (inFlight) {
|
||||
return inFlight
|
||||
}
|
||||
const probe = (async () => {
|
||||
const identity = await detectGitRemoteIdentity(repo.path, repo.connectionId)
|
||||
if (!identity) {
|
||||
// Why: repos without a parseable remote are common; cache misses briefly so
|
||||
// list calls stay cheap while still allowing recent remote changes to land.
|
||||
noIdentityRetryAfterByLocation.set(locationKey, Date.now() + NO_IDENTITY_RETRY_TTL_MS)
|
||||
return false
|
||||
}
|
||||
|
||||
noIdentityRetryAfterByLocation.delete(locationKey)
|
||||
const current = getCurrentRepo(store, repo.id)
|
||||
if (!isSameUnenrichedRepo(repo, current)) {
|
||||
return false
|
||||
}
|
||||
return !!store.updateRepo(repo.id, { gitRemoteIdentity: identity })
|
||||
})().finally(() => {
|
||||
if (inFlightProbesByLocation.get(locationKey) === probe) {
|
||||
inFlightProbesByLocation.delete(locationKey)
|
||||
}
|
||||
})
|
||||
inFlightProbesByLocation.set(locationKey, probe)
|
||||
return probe
|
||||
}
|
||||
|
||||
async function enrichMissingRepoGitRemoteIdentitiesInBackground(
|
||||
store: RepoIdentityStore,
|
||||
options: EnrichmentOptions
|
||||
): Promise<void> {
|
||||
const candidates = store
|
||||
.getRepos()
|
||||
.filter((repo) => repo.kind !== 'folder' && !repo.gitRemoteIdentity)
|
||||
let changed = false
|
||||
for (const repo of candidates) {
|
||||
// Why: enrichment runs later; capture the location we probed so a mutable
|
||||
// store cannot make the stale-write guard compare against changed fields.
|
||||
if (await enrichRepoGitRemoteIdentity(store, { ...repo })) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
options.onChanged?.()
|
||||
}
|
||||
}
|
||||
|
||||
export function enrichMissingRepoGitRemoteIdentities(
|
||||
store: RepoIdentityStore,
|
||||
options: EnrichmentOptions = {}
|
||||
): void {
|
||||
void enrichMissingRepoGitRemoteIdentitiesInBackground(store, options).catch((error: unknown) => {
|
||||
console.error('[repo-identity] Failed to enrich git remote identities:', error)
|
||||
})
|
||||
}
|
||||
|
||||
export async function flushRepoGitRemoteIdentityEnrichmentForTests(): Promise<void> {
|
||||
await Promise.all(inFlightProbesByLocation.values())
|
||||
}
|
||||
|
||||
export function resetRepoGitRemoteIdentityEnrichmentForTests(): void {
|
||||
inFlightProbesByLocation.clear()
|
||||
noIdentityRetryAfterByLocation.clear()
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { deriveGitRemoteIdentity, type GitRemoteIdentity } from '../shared/git-remote-identity'
|
||||
import { gitExecFileAsync } from './git/runner'
|
||||
import { getSshGitProvider } from './providers/ssh-git-dispatch'
|
||||
|
||||
export async function detectGitRemoteIdentity(
|
||||
repoPath: string,
|
||||
connectionId?: string | null
|
||||
): Promise<GitRemoteIdentity | null> {
|
||||
try {
|
||||
const result = connectionId
|
||||
? await getSshGitProvider(connectionId)?.exec(['remote', '-v'], repoPath)
|
||||
: await gitExecFileAsync(['remote', '-v'], { cwd: repoPath })
|
||||
return result ? deriveGitRemoteIdentity(result.stdout) : null
|
||||
} catch {
|
||||
// Repo creation must not fail because a best-effort remote probe failed.
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -168,6 +168,11 @@ describe('detectRepoIcon', () => {
|
||||
})
|
||||
|
||||
await expect(detectRepoIconAndUpstream({ repoPath, kind: 'git' })).resolves.toEqual({
|
||||
gitRemoteIdentity: {
|
||||
canonicalKey: 'github.com/stablyai/orca',
|
||||
remoteName: 'upstream',
|
||||
remoteUrl: 'git@github.com:stablyai/orca.git'
|
||||
},
|
||||
repoIcon: {
|
||||
type: 'image',
|
||||
src: 'https://github.com/stablyai.png?size=64',
|
||||
@@ -177,4 +182,22 @@ describe('detectRepoIcon', () => {
|
||||
upstream: { owner: 'stablyai', repo: 'orca' }
|
||||
})
|
||||
})
|
||||
|
||||
it('detects a provider-neutral git remote identity for non-GitHub remotes', async () => {
|
||||
const repoPath = await makeTempRepoDir()
|
||||
await gitExecFileAsync(['init'], { cwd: repoPath })
|
||||
await gitExecFileAsync(
|
||||
['remote', 'add', 'origin', 'git@git.company.test:platform/tools/sample-app.git'],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
|
||||
await expect(detectRepoIconAndUpstream({ repoPath, kind: 'git' })).resolves.toMatchObject({
|
||||
gitRemoteIdentity: {
|
||||
canonicalKey: 'git.company.test/platform/tools/sample-app',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'git@git.company.test:platform/tools/sample-app.git'
|
||||
},
|
||||
upstream: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { getRepoSlug, getRepoUpstream } from './github/client'
|
||||
import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch'
|
||||
import type { IFilesystemProvider } from './providers/types'
|
||||
import { detectGitRemoteIdentity } from './repo-git-remote-identity'
|
||||
import { iconHrefCandidates } from './repo-icon-href-candidates'
|
||||
import { joinWorktreeRelativePath } from './runtime/runtime-relative-paths'
|
||||
|
||||
@@ -306,12 +307,8 @@ export async function detectRepoIcon({
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a repo's icon and its fork upstream together. The upstream is resolved
|
||||
* once and reused for the avatar so a fork shows the upstream owner's avatar.
|
||||
* Returns a spread-ready slice of `Repo`. For git repos, `upstream: null`
|
||||
* is a resolved "not a fork" marker and prevents repeated best-effort probes.
|
||||
*/
|
||||
// Why: `upstream: null` is a resolved "not a fork" marker and prevents
|
||||
// repeated best-effort probes.
|
||||
export async function detectRepoIconAndUpstream({
|
||||
repoPath,
|
||||
kind,
|
||||
@@ -320,11 +317,14 @@ export async function detectRepoIconAndUpstream({
|
||||
repoPath: string
|
||||
kind: RepoKind
|
||||
connectionId?: string | null
|
||||
}): Promise<{ repoIcon?: RepoIcon; upstream?: GitHubRepositoryIdentity | null }> {
|
||||
}) {
|
||||
const upstream = kind === 'git' ? await getRepoUpstream(repoPath, connectionId) : null
|
||||
const gitRemoteIdentity =
|
||||
kind === 'git' ? await detectGitRemoteIdentity(repoPath, connectionId) : null
|
||||
const repoIcon = await detectRepoIcon({ repoPath, kind, connectionId, upstream })
|
||||
return {
|
||||
...(repoIcon ? { repoIcon } : {}),
|
||||
...(gitRemoteIdentity ? { gitRemoteIdentity } : {}),
|
||||
...(kind === 'git' ? { upstream: upstream ?? null } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,6 +643,7 @@ import {
|
||||
} from '../project-groups/folder-workspace-path-status'
|
||||
import { getSshGitProvider, requireSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
|
||||
import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment'
|
||||
import { githubAvatarIcon } from '../../shared/repo-icon'
|
||||
import type { ClaudeAccountService } from '../claude-accounts/service'
|
||||
import type { CodexAccountService } from '../codex-accounts/service'
|
||||
@@ -8807,6 +8808,18 @@ export class OrcaRuntimeService {
|
||||
return this.store?.getRepos() ?? []
|
||||
}
|
||||
|
||||
enrichMissingRepoGitRemoteIdentities(): void {
|
||||
if (!this.store) {
|
||||
return
|
||||
}
|
||||
enrichMissingRepoGitRemoteIdentities(this.store, {
|
||||
onChanged: () => {
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
this.notifyReposChanged()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
listProjects(): Project[] {
|
||||
return this.store?.getProjects?.() ?? []
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable max-lines -- Why: coordinator tests cover dispatch, DAG ordering, escalation, decision gates, concurrency, and stop — splitting by category would scatter shared setup without improving clarity. */
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { OrchestrationDb } from './db'
|
||||
import { reconcileLifecycleMessage } from './lifecycle-reconciliation'
|
||||
import {
|
||||
Coordinator,
|
||||
DISPATCH_STALE_THRESHOLD,
|
||||
@@ -141,6 +142,68 @@ describe('Coordinator', () => {
|
||||
expect(runtime.sentMessages.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('records completedTasks when send reconciled worker_done before coordinator read', async () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
const runtime = createMockRuntime()
|
||||
|
||||
const task = db.createTask({ spec: 'send-driven completion' })
|
||||
const dispatch = db.createDispatchContext(task.id, 'term_a')
|
||||
const msg = db.insertMessage({
|
||||
from: 'term_a',
|
||||
to: 'coord',
|
||||
subject: 'Done',
|
||||
type: 'worker_done',
|
||||
payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id })
|
||||
})
|
||||
|
||||
reconcileLifecycleMessage(db, msg)
|
||||
|
||||
const coordinator = new Coordinator(db, runtime, {
|
||||
spec: 'go',
|
||||
coordinatorHandle: 'coord',
|
||||
pollIntervalMs: 20
|
||||
})
|
||||
const result = await coordinator.run()
|
||||
|
||||
expect(result.status).toBe('completed')
|
||||
expect(result.completedTasks).toContain(task.id)
|
||||
})
|
||||
|
||||
it('does not duplicate completedTasks for repeated completed worker_done messages', async () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
const runtime = createMockRuntime()
|
||||
|
||||
const task = db.createTask({ spec: 'duplicate completion' })
|
||||
const dispatch = db.createDispatchContext(task.id, 'term_a')
|
||||
const payload = JSON.stringify({ taskId: task.id, dispatchId: dispatch.id })
|
||||
const first = db.insertMessage({
|
||||
from: 'term_a',
|
||||
to: 'coord',
|
||||
subject: 'Done',
|
||||
type: 'worker_done',
|
||||
payload
|
||||
})
|
||||
db.insertMessage({
|
||||
from: 'term_a',
|
||||
to: 'coord',
|
||||
subject: 'Done again',
|
||||
type: 'worker_done',
|
||||
payload
|
||||
})
|
||||
|
||||
reconcileLifecycleMessage(db, first)
|
||||
|
||||
const coordinator = new Coordinator(db, runtime, {
|
||||
spec: 'go',
|
||||
coordinatorHandle: 'coord',
|
||||
pollIntervalMs: 20
|
||||
})
|
||||
const result = await coordinator.run()
|
||||
|
||||
expect(result.status).toBe('completed')
|
||||
expect(result.completedTasks.filter((id) => id === task.id)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('creates a terminal when none are available', async () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
const runtime = createMockRuntime()
|
||||
|
||||
@@ -277,7 +277,9 @@ export class Coordinator {
|
||||
private handleLifecycleMessage(msg: MessageRow): void {
|
||||
const result = reconcileLifecycleMessage(this.db, msg, this.opts.onLog)
|
||||
if (result.action === 'completed') {
|
||||
this.state.completedTasks.push(result.taskId)
|
||||
if (!this.state.completedTasks.includes(result.taskId)) {
|
||||
this.state.completedTasks.push(result.taskId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,11 @@ function reconcileWorkerDoneMessage(
|
||||
)
|
||||
return { action: 'ignored' }
|
||||
}
|
||||
// Why: `orchestration.send` can release the DB lock before waking the
|
||||
// coordinator; the later coordinator read still needs to observe completion.
|
||||
if (dispatch.status === 'completed' && task.status === 'completed') {
|
||||
return { action: 'completed', taskId, dispatchId }
|
||||
}
|
||||
if (dispatch.status !== 'dispatched') {
|
||||
onLog(`Warning: worker_done for inactive dispatch ${dispatchId} ignored`)
|
||||
return { action: 'ignored' }
|
||||
|
||||
@@ -374,6 +374,71 @@ describe('orchestration RPC methods', () => {
|
||||
})
|
||||
).rejects.toThrow('No recipients resolved for group address')
|
||||
})
|
||||
|
||||
it('releases dispatch lock before waking recipients when worker_done is sent via send', async () => {
|
||||
setup()
|
||||
const task = db.createTask({ spec: 'lock-release work' })
|
||||
const dispatch = db.createDispatchContext(task.id, 'term_worker')
|
||||
|
||||
// Why: assert lock is already gone at delivery time, not just after the call.
|
||||
vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {
|
||||
expect(db.getActiveDispatchForTerminal('term_worker')).toBeUndefined()
|
||||
})
|
||||
|
||||
const result = (await call('orchestration.send', {
|
||||
from: 'term_worker',
|
||||
to: 'term_coord',
|
||||
subject: 'done',
|
||||
type: 'worker_done',
|
||||
payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id })
|
||||
})) as { message: { type: string } }
|
||||
|
||||
expect(result.message.type).toBe('worker_done')
|
||||
expect(db.getTask(task.id)?.status).toBe('completed')
|
||||
expect(db.getDispatchContextById(dispatch.id)?.status).toBe('completed')
|
||||
expect(db.getActiveDispatchForTerminal('term_worker')).toBeUndefined()
|
||||
// Lock released — a new dispatch to the same terminal must succeed.
|
||||
const t2 = db.createTask({ spec: 'follow-up work' })
|
||||
expect(() => db.createDispatchContext(t2.id, 'term_worker')).not.toThrow()
|
||||
})
|
||||
|
||||
it('records heartbeat when heartbeat is sent via send', async () => {
|
||||
setup()
|
||||
const task = db.createTask({ spec: 'heartbeat work' })
|
||||
const dispatch = db.createDispatchContext(task.id, 'term_worker')
|
||||
vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {})
|
||||
|
||||
await call('orchestration.send', {
|
||||
from: 'term_worker',
|
||||
to: 'term_coord',
|
||||
subject: 'alive',
|
||||
type: 'heartbeat',
|
||||
payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id })
|
||||
})
|
||||
|
||||
expect(db.getTask(task.id)?.status).toBe('dispatched')
|
||||
expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched')
|
||||
expect(db.getDispatchContextById(dispatch.id)?.last_heartbeat_at).toBeTruthy()
|
||||
expect(db.getActiveDispatchForTerminal('term_worker')).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not release dispatch lock for non-lifecycle sends', async () => {
|
||||
setup()
|
||||
const task = db.createTask({ spec: 'in-flight work' })
|
||||
const dispatch = db.createDispatchContext(task.id, 'term_worker')
|
||||
vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {})
|
||||
|
||||
await call('orchestration.send', {
|
||||
from: 'term_coord',
|
||||
to: 'term_worker',
|
||||
subject: 'how is it going?',
|
||||
type: 'status'
|
||||
})
|
||||
|
||||
expect(db.getTask(task.id)?.status).toBe('dispatched')
|
||||
expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched')
|
||||
expect(db.getActiveDispatchForTerminal('term_worker')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('orchestration.check', () => {
|
||||
|
||||
@@ -197,6 +197,13 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
threadId: params.threadId,
|
||||
payload: params.payload
|
||||
})
|
||||
// Why: worker_done/heartbeat sent via `send` must release the dispatch
|
||||
// lock before waking recipients — a coordinator woken by delivery may
|
||||
// immediately dispatch to the same terminal, which fails if the lock
|
||||
// is still held.
|
||||
if (msg.type === 'worker_done' || msg.type === 'heartbeat') {
|
||||
reconcileLifecycleMessage(db, msg)
|
||||
}
|
||||
runtime.deliverPendingMessagesForHandle(params.to)
|
||||
runtime.notifyMessageArrived(params.to, msg.type)
|
||||
return { message: msg }
|
||||
|
||||
@@ -90,7 +90,10 @@ export const PROJECT_RUNTIME_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'project.list',
|
||||
params: null,
|
||||
handler: (_params, { runtime }) => ({ projects: runtime.listProjects() })
|
||||
handler: (_params, { runtime }) => {
|
||||
runtime.enrichMissingRepoGitRemoteIdentities?.()
|
||||
return { projects: runtime.listProjects() }
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'project.update',
|
||||
@@ -102,7 +105,10 @@ export const PROJECT_RUNTIME_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'projectHostSetup.list',
|
||||
params: null,
|
||||
handler: (_params, { runtime }) => ({ setups: runtime.listProjectHostSetups() })
|
||||
handler: (_params, { runtime }) => {
|
||||
runtime.enrichMissingRepoGitRemoteIdentities?.()
|
||||
return { setups: runtime.listProjectHostSetups() }
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'projectHostSetup.create',
|
||||
|
||||
@@ -158,7 +158,10 @@ export const REPO_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'repo.list',
|
||||
params: null,
|
||||
handler: (_params, { runtime }) => ({ repos: runtime.listRepos() })
|
||||
handler: (_params, { runtime }) => {
|
||||
runtime.enrichMissingRepoGitRemoteIdentities?.()
|
||||
return { repos: runtime.listRepos() }
|
||||
}
|
||||
}),
|
||||
...PROJECT_RUNTIME_METHODS,
|
||||
defineMethod({
|
||||
|
||||
@@ -234,7 +234,9 @@
|
||||
--worktree-sidebar-foreground: #fafafa;
|
||||
--worktree-sidebar-accent: #353535;
|
||||
--worktree-sidebar-accent-foreground: #fafafa;
|
||||
--worktree-sidebar-border: rgb(255 255 255 / 0.1);
|
||||
/* Match --border / --sidebar-border (7%) so the left sidebar's divider lines
|
||||
aren't brighter than the rest of the UI in dark mode (#5906). */
|
||||
--worktree-sidebar-border: rgb(255 255 255 / 0.07);
|
||||
--worktree-sidebar-ring: #737373;
|
||||
--sidebar: #171717;
|
||||
--sidebar-foreground: #fafafa;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Ban,
|
||||
Braces,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -33,9 +34,11 @@ import {
|
||||
GitPullRequest,
|
||||
GitPullRequestClosed,
|
||||
ListChecks,
|
||||
Link2,
|
||||
LoaderCircle,
|
||||
MessageSquare,
|
||||
MessageSquarePlus,
|
||||
MoveRight,
|
||||
PanelLeftOpen,
|
||||
Pencil,
|
||||
Plus,
|
||||
@@ -44,6 +47,8 @@ import {
|
||||
Send,
|
||||
Settings,
|
||||
UndoDot,
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
Wrench,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
@@ -140,7 +145,10 @@ import {
|
||||
type CommentCodeContextLineUpdate
|
||||
} from '@/components/comment-code-context-state'
|
||||
import { getPrCommentCodeContext } from '@/components/github/pr-comment-code-context'
|
||||
import { resolveCommentReplyTarget } from '@/components/comment-reply-target-state'
|
||||
import {
|
||||
getCommentReplyTargetCandidates,
|
||||
resolveCommentReplyTarget
|
||||
} from '@/components/comment-reply-target-state'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useAllWorktrees } from '@/store/selectors'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
@@ -181,6 +189,8 @@ import type {
|
||||
GitHubPRFileViewedState,
|
||||
GitHubWorkItem,
|
||||
GitHubWorkItemDetails,
|
||||
GitHubIssueTimelineItem,
|
||||
GitHubIssueTimelineTarget,
|
||||
GitHubAssignableUser,
|
||||
GitHubReaction,
|
||||
GitHubPRMergeMethod,
|
||||
@@ -2806,12 +2816,75 @@ function CommentCodeContext({
|
||||
)
|
||||
}
|
||||
|
||||
type IssueConversationEntry =
|
||||
| { kind: 'comment'; id: string; createdAt: string; comment: PRComment; index: number }
|
||||
| {
|
||||
kind: 'activity'
|
||||
id: string
|
||||
createdAt: string
|
||||
activity: GitHubIssueTimelineItem
|
||||
index: number
|
||||
}
|
||||
|
||||
const EMPTY_GITHUB_ISSUE_TIMELINE_ITEMS: GitHubIssueTimelineItem[] = []
|
||||
|
||||
function getTimelineSortValue(createdAt: string): number {
|
||||
const value = new Date(createdAt).getTime()
|
||||
return Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
function getIssueConversationEntries(
|
||||
comments: PRComment[],
|
||||
timelineItems: GitHubIssueTimelineItem[]
|
||||
): IssueConversationEntry[] {
|
||||
return [
|
||||
...comments.map(
|
||||
(comment, index): IssueConversationEntry => ({
|
||||
kind: 'comment',
|
||||
id: `comment:${comment.id}`,
|
||||
createdAt: comment.createdAt,
|
||||
comment,
|
||||
index
|
||||
})
|
||||
),
|
||||
...timelineItems.map(
|
||||
(activity, index): IssueConversationEntry => ({
|
||||
kind: 'activity',
|
||||
id: `activity:${activity.id}`,
|
||||
createdAt: activity.createdAt,
|
||||
activity,
|
||||
index: comments.length + index
|
||||
})
|
||||
)
|
||||
].sort((a, b) => {
|
||||
const diff = getTimelineSortValue(a.createdAt) - getTimelineSortValue(b.createdAt)
|
||||
return diff === 0 ? a.index - b.index : diff
|
||||
})
|
||||
}
|
||||
|
||||
function getTimelineTargetLabel(target: GitHubIssueTimelineTarget): string {
|
||||
const prefix = target.type === 'pr' ? 'PR' : 'issue'
|
||||
const title = target.title ? ` ${target.title}` : ''
|
||||
return `${prefix} #${target.number}${title}`
|
||||
}
|
||||
|
||||
function getTimelineStateReasonLabel(reason: string | null | undefined): string | null {
|
||||
if (reason === 'completed') {
|
||||
return translate('auto.components.GitHubItemDialog.timeline.completed', 'as completed')
|
||||
}
|
||||
if (reason === 'not_planned') {
|
||||
return translate('auto.components.GitHubItemDialog.timeline.notPlanned', 'as not planned')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function ConversationTab({
|
||||
item,
|
||||
repoPath,
|
||||
sourceContext,
|
||||
body,
|
||||
comments,
|
||||
timelineItems,
|
||||
files,
|
||||
headSha,
|
||||
baseSha,
|
||||
@@ -2833,6 +2906,7 @@ function ConversationTab({
|
||||
sourceContext?: TaskSourceContext | null
|
||||
body: string
|
||||
comments: PRComment[]
|
||||
timelineItems?: GitHubIssueTimelineItem[]
|
||||
files: GitHubPRFile[]
|
||||
headSha: string | undefined
|
||||
baseSha: string | undefined
|
||||
@@ -2860,7 +2934,13 @@ function ConversationTab({
|
||||
[commentFilter, comments]
|
||||
)
|
||||
const visibleCommentGroups = useMemo(() => groupPRComments(visibleComments), [visibleComments])
|
||||
const resolvedReplyingTo = resolveCommentReplyTarget(replyingTo, visibleComments)
|
||||
const resolvedTimelineItems = timelineItems ?? EMPTY_GITHUB_ISSUE_TIMELINE_ITEMS
|
||||
const issueConversationEntries = useMemo(
|
||||
() => getIssueConversationEntries(comments, resolvedTimelineItems),
|
||||
[comments, resolvedTimelineItems]
|
||||
)
|
||||
const replyTargetComments = getCommentReplyTargetCandidates(item.type, comments, visibleComments)
|
||||
const resolvedReplyingTo = resolveCommentReplyTarget(replyingTo, replyTargetComments)
|
||||
|
||||
if (resolvedReplyingTo !== replyingTo) {
|
||||
// Why: comment filters/refetches can hide the active reply target; clear it
|
||||
@@ -3192,6 +3272,147 @@ function ConversationTab({
|
||||
)
|
||||
}
|
||||
|
||||
const renderTimelineTarget = (target: GitHubIssueTimelineTarget | undefined): React.ReactNode => {
|
||||
if (!target) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={target.url}
|
||||
type="button"
|
||||
className="min-w-0 truncate font-medium text-foreground underline underline-offset-2 hover:text-muted-foreground"
|
||||
title={getTimelineTargetLabel(target)}
|
||||
onClick={() => window.api.shell.openUrl(target.url)}
|
||||
>
|
||||
{getTimelineTargetLabel(target)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const renderTimelineActivityMessage = (activity: GitHubIssueTimelineItem): React.ReactNode => {
|
||||
const assignee =
|
||||
activity.assignee ?? translate('auto.components.GitHubItemDialog.timeline.someone', 'someone')
|
||||
if (activity.event === 'assigned') {
|
||||
return (
|
||||
<>
|
||||
{translate('auto.components.GitHubItemDialog.timeline.assigned', 'assigned')}{' '}
|
||||
<span className="font-medium text-foreground">{assignee}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (activity.event === 'unassigned') {
|
||||
return (
|
||||
<>
|
||||
{translate('auto.components.GitHubItemDialog.timeline.unassigned', 'unassigned')}{' '}
|
||||
<span className="font-medium text-foreground">{assignee}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (activity.event === 'mentioned' || activity.event === 'cross-referenced') {
|
||||
return (
|
||||
<>
|
||||
{translate('auto.components.GitHubItemDialog.timeline.mentioned', 'mentioned this')}
|
||||
{activity.source ? (
|
||||
<>
|
||||
{' '}
|
||||
{translate('auto.components.GitHubItemDialog.timeline.in', 'in')}{' '}
|
||||
{renderTimelineTarget(activity.source)}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (activity.event === 'closed') {
|
||||
const stateReason = getTimelineStateReasonLabel(activity.stateReason)
|
||||
return (
|
||||
<>
|
||||
{translate('auto.components.GitHubItemDialog.timeline.closed', 'closed this')}
|
||||
{stateReason ? ` ${stateReason}` : ''}
|
||||
{activity.closer ? (
|
||||
<>
|
||||
{' '}
|
||||
{translate('auto.components.GitHubItemDialog.timeline.in', 'in')}{' '}
|
||||
{renderTimelineTarget(activity.closer)}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (activity.event === 'reopened') {
|
||||
return translate('auto.components.GitHubItemDialog.timeline.reopened', 'reopened this')
|
||||
}
|
||||
const hasFrom = Boolean(activity.previousColumnName)
|
||||
const hasTo = Boolean(activity.columnName)
|
||||
return (
|
||||
<>
|
||||
{translate('auto.components.GitHubItemDialog.timeline.moved', 'moved this')}
|
||||
{hasFrom ? (
|
||||
<>
|
||||
{' '}
|
||||
{translate('auto.components.GitHubItemDialog.timeline.from', 'from')}{' '}
|
||||
<span className="font-medium text-foreground">{activity.previousColumnName}</span>
|
||||
</>
|
||||
) : null}
|
||||
{hasTo ? (
|
||||
<>
|
||||
{' '}
|
||||
{translate('auto.components.GitHubItemDialog.timeline.to', 'to')}{' '}
|
||||
<span className="font-medium text-foreground">{activity.columnName}</span>
|
||||
</>
|
||||
) : null}
|
||||
{activity.projectName ? (
|
||||
<>
|
||||
{' '}
|
||||
{translate('auto.components.GitHubItemDialog.timeline.in', 'in')}{' '}
|
||||
<span className="font-medium text-foreground">{activity.projectName}</span>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const renderTimelineActivity = (activity: GitHubIssueTimelineItem): React.JSX.Element => {
|
||||
const Icon =
|
||||
activity.event === 'assigned'
|
||||
? UserPlus
|
||||
: activity.event === 'unassigned'
|
||||
? UserMinus
|
||||
: activity.event === 'closed'
|
||||
? CheckCircle2
|
||||
: activity.event === 'reopened'
|
||||
? CircleDot
|
||||
: activity.event === 'moved_columns_in_project'
|
||||
? MoveRight
|
||||
: Link2
|
||||
return (
|
||||
<div
|
||||
key={`activity-${activity.id}`}
|
||||
className="flex min-w-0 items-start gap-3 rounded-md px-1 py-1.5 text-[13px] text-muted-foreground"
|
||||
>
|
||||
<span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full border border-border/50 bg-muted/30 text-muted-foreground">
|
||||
<Icon className="size-3.5" />
|
||||
</span>
|
||||
{activity.actorAvatarUrl ? (
|
||||
<img src={activity.actorAvatarUrl} alt="" className="mt-1 size-5 shrink-0 rounded-full" />
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1">
|
||||
<span className="font-medium text-foreground">{activity.actor}</span>
|
||||
<span className="contents">{renderTimelineActivityMessage(activity)}</span>
|
||||
<span className="text-[12px] text-muted-foreground">
|
||||
{formatRelativeTime(activity.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderIssueConversationEntry = (entry: IssueConversationEntry): React.JSX.Element =>
|
||||
entry.kind === 'comment'
|
||||
? renderCommentCard(entry.comment)
|
||||
: renderTimelineActivity(entry.activity)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -3308,13 +3529,19 @@ function ConversationTab({
|
||||
{detailsLoaded ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<MessageSquare className="size-4 text-muted-foreground" />
|
||||
{item.type === 'issue' ? (
|
||||
<FolderKanban className="size-4 text-muted-foreground" />
|
||||
) : (
|
||||
<MessageSquare className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-[13px] font-medium text-foreground">
|
||||
{translate('auto.components.GitHubItemDialog.1506916c09', 'Comments')}
|
||||
{item.type === 'issue'
|
||||
? translate('auto.components.GitHubItemDialog.timeline.activity', 'Activity')
|
||||
: translate('auto.components.GitHubItemDialog.1506916c09', 'Comments')}
|
||||
</span>
|
||||
{comments.length > 0 && (
|
||||
{comments.length + (item.type === 'issue' ? resolvedTimelineItems.length : 0) > 0 && (
|
||||
<span className="rounded-full border border-border/50 bg-muted/30 px-1.5 py-0.5 text-[11px] tabular-nums text-muted-foreground">
|
||||
{comments.length}
|
||||
{comments.length + (item.type === 'issue' ? resolvedTimelineItems.length : 0)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -3342,7 +3569,20 @@ function ConversationTab({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{comments.length === 0 ? (
|
||||
{item.type === 'issue' ? (
|
||||
issueConversationEntries.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/50 px-3 py-6 text-left text-[13px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.GitHubItemDialog.timeline.noActivity',
|
||||
'No activity yet.'
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
{issueConversationEntries.map(renderIssueConversationEntry)}
|
||||
</div>
|
||||
)
|
||||
) : comments.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/50 px-3 py-6 text-left text-[13px] text-muted-foreground">
|
||||
{translate('auto.components.GitHubItemDialog.5a94f3d0e9', 'No comments yet.')}
|
||||
</div>
|
||||
@@ -6151,15 +6391,6 @@ export default function GitHubItemDialog({
|
||||
})
|
||||
}, [repoPath, effectiveRepoId, workItem, issueSourcePreference])
|
||||
|
||||
// Why: reset lifted edit state when the dialog switches items or when the
|
||||
// same item receives an optimistic cache patch from the surrounding table.
|
||||
useEffect(() => {
|
||||
if (workItemState && workItemLabels) {
|
||||
setLocalState(workItemState)
|
||||
setLocalLabels(workItemLabels)
|
||||
}
|
||||
}, [workItemId, workItemState, workItemLabels])
|
||||
|
||||
// Why: track comments added optimistically before the detail fetch resolves
|
||||
// so they can be merged into the fetch result instead of being overwritten.
|
||||
const optimisticCommentsRef = useRef<PRComment[]>([])
|
||||
@@ -6257,6 +6488,19 @@ export default function GitHubItemDialog({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cachedEntry, workItem, optimisticTick])
|
||||
|
||||
const resolvedWorkItemState = details?.item.state ?? workItemState
|
||||
|
||||
// Why: the list row that opens the dialog can be stale; the detail payload
|
||||
// carries the authoritative issue/PR state and should refresh local edit UI.
|
||||
useEffect(() => {
|
||||
if (resolvedWorkItemState) {
|
||||
setLocalState(resolvedWorkItemState)
|
||||
}
|
||||
if (workItemLabels) {
|
||||
setLocalLabels(workItemLabels)
|
||||
}
|
||||
}, [workItemId, resolvedWorkItemState, workItemLabels])
|
||||
|
||||
const loading = !!cachedEntry?.pending && !cachedEntry?.details
|
||||
const error = cachedEntry?.error && !cachedEntry?.details ? cachedEntry.error : null
|
||||
const detailsLoaded =
|
||||
@@ -6393,6 +6637,7 @@ export default function GitHubItemDialog({
|
||||
|
||||
const body = details?.body ?? ''
|
||||
const comments = details?.comments ?? []
|
||||
const timelineItems = details?.timelineItems ?? []
|
||||
const files = details?.files ?? []
|
||||
const checks = details?.checks ?? []
|
||||
const [pendingViewedPaths, setPendingViewedPaths] = useState<Set<string>>(() => new Set())
|
||||
@@ -6910,6 +7155,7 @@ export default function GitHubItemDialog({
|
||||
sourceContext={sourceContext}
|
||||
body={body}
|
||||
comments={comments}
|
||||
timelineItems={timelineItems}
|
||||
files={files}
|
||||
headSha={details?.headSha}
|
||||
baseSha={details?.baseSha}
|
||||
@@ -7030,6 +7276,7 @@ export default function GitHubItemDialog({
|
||||
sourceContext={sourceContext}
|
||||
body={body}
|
||||
comments={comments}
|
||||
timelineItems={timelineItems}
|
||||
files={files}
|
||||
headSha={details?.headSha}
|
||||
baseSha={details?.baseSha}
|
||||
|
||||
@@ -185,8 +185,8 @@ function QuickTabBody({
|
||||
const primaryActionLabel = isFolderWorkspaceTarget
|
||||
? getFolderWorkspacePrimaryActionLabel()
|
||||
: cardProps.selectedRepoIsGit
|
||||
? 'Create worktree'
|
||||
: 'Create workspace'
|
||||
? translate('auto.components.NewWorkspaceComposerModal.createWorktree', 'Create worktree')
|
||||
: translate('auto.components.NewWorkspaceComposerModal.createWorkspace', 'Create workspace')
|
||||
|
||||
// Cmd/Ctrl+Enter submits, Esc first blurs the focused input (like the full page).
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveCommentReplyTarget } from './comment-reply-target-state'
|
||||
import {
|
||||
getCommentReplyTargetCandidates,
|
||||
resolveCommentReplyTarget
|
||||
} from './comment-reply-target-state'
|
||||
|
||||
describe('comment reply target state', () => {
|
||||
it('preserves a visible reply target', () => {
|
||||
@@ -13,4 +16,17 @@ describe('comment reply target state', () => {
|
||||
it('keeps an empty reply target empty', () => {
|
||||
expect(resolveCommentReplyTarget(null, [{ id: 1 }])).toBeNull()
|
||||
})
|
||||
|
||||
it('uses filtered comments as reply targets for PRs', () => {
|
||||
expect(getCommentReplyTargetCandidates('pr', [{ id: 1 }, { id: 2 }], [{ id: 2 }])).toEqual([
|
||||
{ id: 2 }
|
||||
])
|
||||
})
|
||||
|
||||
it('uses all comments as reply targets for issues', () => {
|
||||
expect(getCommentReplyTargetCandidates('issue', [{ id: 1 }, { id: 2 }], [{ id: 2 }])).toEqual([
|
||||
{ id: 1 },
|
||||
{ id: 2 }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,16 @@ export type CommentReplyTargetComment = {
|
||||
id: number
|
||||
}
|
||||
|
||||
export function getCommentReplyTargetCandidates(
|
||||
itemType: 'issue' | 'pr',
|
||||
comments: readonly CommentReplyTargetComment[],
|
||||
visibleComments: readonly CommentReplyTargetComment[]
|
||||
): readonly CommentReplyTargetComment[] {
|
||||
// Why: the PR audience filter is hidden on issues, so stale PR filter state
|
||||
// should not constrain issue reply targets after switching items.
|
||||
return itemType === 'issue' ? comments : visibleComments
|
||||
}
|
||||
|
||||
export function resolveCommentReplyTarget(
|
||||
replyingTo: number | null,
|
||||
visibleComments: readonly CommentReplyTargetComment[]
|
||||
|
||||
@@ -46,7 +46,7 @@ import type {
|
||||
GitDiffResult,
|
||||
GitStatusEntry
|
||||
} from '../../../../shared/types'
|
||||
import { Check, Copy, MessageSquare, PanelLeftOpen, Sparkles, Trash2 } from 'lucide-react'
|
||||
import { Check, Copy, MessageSquare, PanelLeftOpen, Sparkles, Trash2, WrapText } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { DiffSectionItem } from './DiffSectionItem'
|
||||
import { DiffNotesSendMenu } from './DiffNotesSendMenu'
|
||||
@@ -55,10 +55,7 @@ import {
|
||||
createCombinedDiffSectionIndexMap,
|
||||
handleCombinedDiffFileTreeNavigation
|
||||
} from './CombinedDiffFileTree'
|
||||
import {
|
||||
getCombinedDiffFileTreeSectionKey,
|
||||
type CombinedDiffFileTreeMode
|
||||
} from './combined-diff-file-tree-model'
|
||||
import { getCombinedDiffFileTreeSectionKey } from './combined-diff-file-tree-model'
|
||||
import {
|
||||
ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT,
|
||||
type EditorPathMutationTarget
|
||||
@@ -77,6 +74,7 @@ import type { DiffSection } from './diff-section-types'
|
||||
import { getInitialCombinedDiffSectionLoadIndices } from './combined-diff-initial-section-load'
|
||||
import { removeDiffSectionMeasuredHeight } from './diff-section-height-cache'
|
||||
import { createCombinedDiffLoadScheduler } from './combined-diff-load-scheduler'
|
||||
import { combinedDiffSectionsMatchEntryMetadata } from './combined-diff-section-cache-match'
|
||||
import {
|
||||
beginCombinedDiffScrollbarDrag,
|
||||
type CombinedDiffScrollbarDragCleanup
|
||||
@@ -210,26 +208,6 @@ function getInitialCombinedDiffFileTreeCollapsed(
|
||||
return combinedDiffFileTreeCollapsedPreference ?? combinedDiffFileTreeVisibleByDefault !== true
|
||||
}
|
||||
|
||||
function cachedCombinedDiffSectionsMatchEntries({
|
||||
entries,
|
||||
sections,
|
||||
treeMode
|
||||
}: {
|
||||
entries: readonly (GitStatusEntry | GitBranchChangeEntry)[]
|
||||
sections: readonly DiffSection[]
|
||||
treeMode: CombinedDiffFileTreeMode
|
||||
}): boolean {
|
||||
return (
|
||||
sections.length === entries.length &&
|
||||
sections.every((section, index) => {
|
||||
const entry = entries[index]
|
||||
return (
|
||||
entry !== undefined && section.key === getCombinedDiffFileTreeSectionKey(treeMode, entry)
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export default function CombinedDiffViewer({
|
||||
file,
|
||||
viewStateKey
|
||||
@@ -251,6 +229,7 @@ export default function CombinedDiffViewer({
|
||||
const openCommitDiff = useAppStore((s) => s.openCommitDiff)
|
||||
const openConflictReview = useAppStore((s) => s.openConflictReview)
|
||||
const openBranchAllDiffs = useAppStore((s) => s.openBranchAllDiffs)
|
||||
const updateSettings = useAppStore((s) => s.updateSettings)
|
||||
const clearDiffComments = useAppStore((s) => s.clearDiffComments)
|
||||
const diffCommentsForWorktree = useAppStore((s) => s.getDiffComments(file.worktreeId))
|
||||
const activeGroupId = useAppStore((s) => s.activeGroupIdByWorktree[file.worktreeId])
|
||||
@@ -540,7 +519,7 @@ export default function CombinedDiffViewer({
|
||||
const canRestoreSnapshotSectionsByKey =
|
||||
hasUncommittedEntriesSnapshot &&
|
||||
cached !== undefined &&
|
||||
cachedCombinedDiffSectionsMatchEntries({
|
||||
combinedDiffSectionsMatchEntryMetadata({
|
||||
entries,
|
||||
sections: cached.sections,
|
||||
treeMode
|
||||
@@ -1129,6 +1108,10 @@ export default function CombinedDiffViewer({
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleDiffWordWrap = useCallback(() => {
|
||||
void updateSettings({ diffWordWrap: settings?.diffWordWrap !== true })
|
||||
}, [settings?.diffWordWrap, updateSettings])
|
||||
|
||||
const openSection = useCallback(
|
||||
(index: number) => {
|
||||
const section = sectionsRef.current[index]
|
||||
@@ -1838,6 +1821,20 @@ export default function CombinedDiffViewer({
|
||||
? translate('auto.components.editor.CombinedDiffViewer.f786fd54e1', 'Inline')
|
||||
: translate('auto.components.editor.CombinedDiffViewer.ec5053c7f5', 'Side by Side')}
|
||||
</button>
|
||||
<button
|
||||
className={`inline-flex h-6 items-center gap-1 rounded border border-border px-2 text-xs transition-colors hover:text-foreground ${
|
||||
settings?.diffWordWrap === true
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
onClick={toggleDiffWordWrap}
|
||||
aria-pressed={settings?.diffWordWrap === true}
|
||||
>
|
||||
<WrapText className="size-3.5" />
|
||||
{settings?.diffWordWrap === true
|
||||
? translate('auto.components.editor.CombinedDiffViewer.a4420ca1f7', 'Wrap On')
|
||||
: translate('auto.components.editor.CombinedDiffViewer.dde325ddfe', 'Wrap Off')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { combinedDiffSectionScrollbarOptions } from './diff-editor-scrollbar-opt
|
||||
import type { DiffSection } from './diff-section-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { LargeDiffFallback } from './LargeDiffFallback'
|
||||
import { buildDiffEditorWordWrapOptions } from './diff-editor-word-wrap-options'
|
||||
|
||||
const ImageDiffViewer = lazy(() => import('./ImageDiffViewer'))
|
||||
|
||||
@@ -34,6 +35,7 @@ type DiffSectionBodyProps = {
|
||||
modelPathBase: string
|
||||
isEditable: boolean
|
||||
diffEditorFontSize: number
|
||||
diffWordWrap?: boolean
|
||||
terminalFontFamily?: string
|
||||
onCancelComment: () => void
|
||||
onSubmitComment: (body: string) => Promise<void>
|
||||
@@ -58,6 +60,7 @@ export function DiffSectionBody({
|
||||
modelPathBase,
|
||||
isEditable,
|
||||
diffEditorFontSize,
|
||||
diffWordWrap,
|
||||
terminalFontFamily,
|
||||
onCancelComment,
|
||||
onSubmitComment,
|
||||
@@ -190,6 +193,7 @@ export function DiffSectionBody({
|
||||
fontSize: diffEditorFontSize,
|
||||
fontFamily: terminalFontFamily || 'monospace',
|
||||
lineNumbers: 'on',
|
||||
...buildDiffEditorWordWrapOptions(diffWordWrap),
|
||||
automaticLayout: true,
|
||||
renderOverviewRuler: false,
|
||||
scrollbar: combinedDiffSectionScrollbarOptions,
|
||||
|
||||
@@ -65,7 +65,11 @@ export function DiffSectionItem({
|
||||
isBranchMode: boolean
|
||||
sideBySide: boolean
|
||||
isDark: boolean
|
||||
settings: { terminalFontSize?: number; terminalFontFamily?: string } | null
|
||||
settings: {
|
||||
terminalFontSize?: number
|
||||
terminalFontFamily?: string
|
||||
diffWordWrap?: boolean
|
||||
} | null
|
||||
sectionHeight: number | undefined
|
||||
worktreeId?: string
|
||||
loadSection: (index: number) => void
|
||||
@@ -421,6 +425,7 @@ export function DiffSectionItem({
|
||||
modelPathBase={modelPathBase}
|
||||
isEditable={isEditable}
|
||||
diffEditorFontSize={diffEditorFontSize}
|
||||
diffWordWrap={settings?.diffWordWrap}
|
||||
terminalFontFamily={settings?.terminalFontFamily}
|
||||
onCancelComment={() => setPopover(null)}
|
||||
onSubmitComment={handleSubmitComment}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { getLargeDiffRenderLimit } from './large-diff-render-limit'
|
||||
import { useDiffViewerLargeDiffLifecycle } from './useDiffViewerLargeDiffLifecycle'
|
||||
import { getDiffViewerLargeDiffSaveAction } from './diff-viewer-large-diff-save-action'
|
||||
import type { DiffViewerProps } from './diff-viewer-props'
|
||||
import { buildDiffEditorWordWrapOptions } from './diff-editor-word-wrap-options'
|
||||
|
||||
export default function DiffViewer({
|
||||
modelKey,
|
||||
@@ -450,6 +451,7 @@ export default function DiffViewer({
|
||||
fontSize: diffEditorFontSize,
|
||||
fontFamily: settings?.terminalFontFamily || 'monospace',
|
||||
lineNumbers: 'on',
|
||||
...buildDiffEditorWordWrapOptions(settings?.diffWordWrap),
|
||||
automaticLayout: true,
|
||||
renderOverviewRuler: true,
|
||||
scrollbar: diffEditorScrollbarOptions,
|
||||
|
||||
@@ -83,6 +83,8 @@ export function EditorPanelHeader({
|
||||
}: EditorPanelHeaderProps): React.JSX.Element {
|
||||
const diffComments = useAppStore((s) => s.getDiffComments(activeFile.worktreeId))
|
||||
const activeGroupId = useAppStore((s) => s.activeGroupIdByWorktree[activeFile.worktreeId])
|
||||
const diffWordWrap = useAppStore((s) => s.settings?.diffWordWrap === true)
|
||||
const updateSettings = useAppStore((s) => s.updateSettings)
|
||||
const fileDiffComments = useMemo(
|
||||
() => diffComments.filter((comment) => comment.filePath === activeFile.relativePath),
|
||||
[activeFile.relativePath, diffComments]
|
||||
@@ -246,10 +248,13 @@ export function EditorPanelHeader({
|
||||
)}
|
||||
<EditorPanelMarkdownActionsMenu
|
||||
isMarkdown={isMarkdown}
|
||||
isDiffSurface={isDiffSurface}
|
||||
diffWordWrap={diffWordWrap}
|
||||
shouldShowMarkdownExportAction={shouldShowMarkdownExportAction}
|
||||
canExportMarkdownToPdf={canExportMarkdownToPdf}
|
||||
canShowMarkdownFrontmatterToggle={canShowMarkdownFrontmatterToggle}
|
||||
markdownFrontmatterVisible={markdownFrontmatterVisible}
|
||||
onToggleDiffWordWrap={() => void updateSettings({ diffWordWrap: !diffWordWrap })}
|
||||
onToggleMarkdownFrontmatter={onToggleMarkdownFrontmatter}
|
||||
onExportMarkdownToPdf={onExportMarkdownToPdf}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type React from 'react'
|
||||
import { MoreHorizontal } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
@@ -11,24 +12,32 @@ import { translate } from '@/i18n/i18n'
|
||||
|
||||
type EditorPanelMarkdownActionsMenuProps = {
|
||||
isMarkdown: boolean
|
||||
isDiffSurface: boolean
|
||||
diffWordWrap: boolean
|
||||
shouldShowMarkdownExportAction: boolean
|
||||
canExportMarkdownToPdf: boolean
|
||||
canShowMarkdownFrontmatterToggle: boolean
|
||||
markdownFrontmatterVisible: boolean
|
||||
onToggleDiffWordWrap: () => void
|
||||
onToggleMarkdownFrontmatter: () => void
|
||||
onExportMarkdownToPdf: () => void
|
||||
}
|
||||
|
||||
export function EditorPanelMarkdownActionsMenu({
|
||||
isMarkdown,
|
||||
isDiffSurface,
|
||||
diffWordWrap,
|
||||
shouldShowMarkdownExportAction,
|
||||
canExportMarkdownToPdf,
|
||||
canShowMarkdownFrontmatterToggle,
|
||||
markdownFrontmatterVisible,
|
||||
onToggleDiffWordWrap,
|
||||
onToggleMarkdownFrontmatter,
|
||||
onExportMarkdownToPdf
|
||||
}: EditorPanelMarkdownActionsMenuProps): React.JSX.Element | null {
|
||||
if (!isMarkdown || (!shouldShowMarkdownExportAction && !canShowMarkdownFrontmatterToggle)) {
|
||||
const hasMarkdownActions =
|
||||
isMarkdown && (shouldShowMarkdownExportAction || canShowMarkdownFrontmatterToggle)
|
||||
if (!isDiffSurface && !hasMarkdownActions) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -51,6 +60,17 @@ export function EditorPanelMarkdownActionsMenu({
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={4}>
|
||||
{isDiffSurface ? (
|
||||
<>
|
||||
<DropdownMenuCheckboxItem checked={diffWordWrap} onCheckedChange={onToggleDiffWordWrap}>
|
||||
{translate(
|
||||
'auto.components.editor.EditorPanelMarkdownActionsMenu.1eef809708',
|
||||
'Word Wrap'
|
||||
)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
{hasMarkdownActions ? <DropdownMenuSeparator /> : null}
|
||||
</>
|
||||
) : null}
|
||||
{canShowMarkdownFrontmatterToggle ? (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types'
|
||||
import type { DiffSection } from './diff-section-types'
|
||||
import { combinedDiffSectionsMatchEntryMetadata } from './combined-diff-section-cache-match'
|
||||
|
||||
function section(overrides: Partial<DiffSection>): DiffSection {
|
||||
return {
|
||||
key: 'unstaged:src/file.ts',
|
||||
path: 'src/file.ts',
|
||||
status: 'modified',
|
||||
area: 'unstaged',
|
||||
originalContent: '',
|
||||
modifiedContent: '',
|
||||
collapsed: false,
|
||||
loading: false,
|
||||
dirty: false,
|
||||
diffResult: null,
|
||||
largeDiffRenderLimit: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('combinedDiffSectionsMatchEntryMetadata', () => {
|
||||
it('matches sections when keys and entry metadata still match', () => {
|
||||
const entry: GitStatusEntry = {
|
||||
path: 'src/file.ts',
|
||||
status: 'modified',
|
||||
area: 'unstaged',
|
||||
added: 2,
|
||||
removed: 1
|
||||
}
|
||||
|
||||
expect(
|
||||
combinedDiffSectionsMatchEntryMetadata({
|
||||
entries: [entry],
|
||||
sections: [section({ added: 2, removed: 1 })],
|
||||
treeMode: 'uncommitted'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects cached sections when a same-path entry has new line counts', () => {
|
||||
const entry: GitStatusEntry = {
|
||||
path: 'src/file.ts',
|
||||
status: 'modified',
|
||||
area: 'unstaged',
|
||||
added: 177,
|
||||
removed: 175
|
||||
}
|
||||
|
||||
expect(
|
||||
combinedDiffSectionsMatchEntryMetadata({
|
||||
entries: [entry],
|
||||
sections: [section({ added: 2, removed: 1 })],
|
||||
treeMode: 'uncommitted'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps branch sections separate when old paths change for the same new path', () => {
|
||||
const entry: GitBranchChangeEntry = {
|
||||
path: 'src/new.ts',
|
||||
oldPath: 'src/old.ts',
|
||||
status: 'renamed',
|
||||
added: 4,
|
||||
removed: 3
|
||||
}
|
||||
|
||||
expect(
|
||||
combinedDiffSectionsMatchEntryMetadata({
|
||||
entries: [entry],
|
||||
sections: [
|
||||
section({
|
||||
key: 'combined-branch:src/new.ts',
|
||||
path: 'src/new.ts',
|
||||
status: 'renamed',
|
||||
area: undefined,
|
||||
oldPath: 'src/older.ts',
|
||||
added: 4,
|
||||
removed: 3
|
||||
})
|
||||
],
|
||||
treeMode: 'branch'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types'
|
||||
import type { CombinedDiffFileTreeMode } from './combined-diff-file-tree-model'
|
||||
import { getCombinedDiffFileTreeSectionKey } from './combined-diff-file-tree-model'
|
||||
import type { DiffSection } from './diff-section-types'
|
||||
|
||||
export function combinedDiffSectionsMatchEntryMetadata({
|
||||
entries,
|
||||
sections,
|
||||
treeMode
|
||||
}: {
|
||||
entries: readonly (GitStatusEntry | GitBranchChangeEntry)[]
|
||||
sections: readonly DiffSection[]
|
||||
treeMode: CombinedDiffFileTreeMode
|
||||
}): boolean {
|
||||
// Why: same-path combined sections can survive status refreshes; metadata
|
||||
// drift means restoring cached content would replay stale, partial diffs.
|
||||
return (
|
||||
sections.length === entries.length &&
|
||||
sections.every((section, index) => {
|
||||
const entry = entries[index]
|
||||
if (!entry) {
|
||||
return false
|
||||
}
|
||||
const entryArea = 'area' in entry ? entry.area : undefined
|
||||
const entryAdded = 'added' in entry ? entry.added : undefined
|
||||
const entryRemoved = 'removed' in entry ? entry.removed : undefined
|
||||
return (
|
||||
section.key === getCombinedDiffFileTreeSectionKey(treeMode, entry) &&
|
||||
section.status === entry.status &&
|
||||
section.area === entryArea &&
|
||||
section.oldPath === entry.oldPath &&
|
||||
section.added === entryAdded &&
|
||||
section.removed === entryRemoved
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildDiffEditorWordWrapOptions } from './diff-editor-word-wrap-options'
|
||||
|
||||
describe('buildDiffEditorWordWrapOptions', () => {
|
||||
it('keeps long diff lines unwrapped by default', () => {
|
||||
expect(buildDiffEditorWordWrapOptions(undefined)).toEqual({ wordWrap: 'off' })
|
||||
expect(buildDiffEditorWordWrapOptions(false)).toEqual({ wordWrap: 'off' })
|
||||
})
|
||||
|
||||
it('enables Monaco diff word wrapping when the diff preference is on', () => {
|
||||
expect(buildDiffEditorWordWrapOptions(true)).toEqual({ wordWrap: 'on' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { editor } from 'monaco-editor'
|
||||
|
||||
export function buildDiffEditorWordWrapOptions(
|
||||
diffWordWrap: boolean | undefined
|
||||
): Pick<editor.IStandaloneDiffEditorConstructionOptions, 'wordWrap'> {
|
||||
return {
|
||||
wordWrap: diffWordWrap === true ? 'on' : 'off'
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
useRepoById,
|
||||
useRepos
|
||||
} from '@/store/selectors'
|
||||
import { filterAiVaultSessions, groupAiVaultSessions } from './ai-vault-session-filters'
|
||||
import {
|
||||
deriveAiVaultWorkspaceScopePaths,
|
||||
filterAiVaultSessions,
|
||||
groupAiVaultSessions
|
||||
} from './ai-vault-session-filters'
|
||||
deriveAiVaultScopeSessionPaths,
|
||||
deriveAiVaultWorkspaceScopePaths
|
||||
} from './ai-vault-scope-paths'
|
||||
import {
|
||||
DEFAULT_AI_VAULT_SCOPE,
|
||||
getRestorableAiVaultScope,
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
AI_VAULT_AGENTS,
|
||||
type AiVaultAgent,
|
||||
type AiVaultGroup,
|
||||
type AiVaultListResult,
|
||||
type AiVaultScope,
|
||||
type AiVaultSession,
|
||||
type AiVaultSort
|
||||
@@ -40,8 +39,7 @@ import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { AiVaultPanelHeader } from './AiVaultPanelHeader'
|
||||
import { AiVaultSessionVirtualList } from './AiVaultSessionVirtualList'
|
||||
|
||||
const SESSION_LIMIT = 500
|
||||
import { useAiVaultSessionRefresh } from './ai-vault-session-refresh'
|
||||
|
||||
export default function AiVaultPanel(): React.JSX.Element {
|
||||
const activeWorktree = useActiveWorktree()
|
||||
@@ -60,14 +58,7 @@ export default function AiVaultPanel(): React.JSX.Element {
|
||||
const [group, setGroup] = useState<AiVaultGroup>('project')
|
||||
const [hideEmptySessions, setHideEmptySessions] = useState(true)
|
||||
const [agents, setAgents] = useState<AiVaultAgent[]>([...AI_VAULT_AGENTS])
|
||||
const [sessions, setSessions] = useState<AiVaultSession[]>([])
|
||||
const [scanResult, setScanResult] = useState<AiVaultListResult | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set())
|
||||
const refreshIdRef = useRef(0)
|
||||
const refreshInFlightRef = useRef(false)
|
||||
const mountedRef = useRef(true)
|
||||
const userChangedScopeRef = useRef(false)
|
||||
const preferredScopeRef = useRef<AiVaultScope>(DEFAULT_AI_VAULT_SCOPE)
|
||||
|
||||
@@ -78,7 +69,31 @@ export default function AiVaultPanel(): React.JSX.Element {
|
||||
() => deriveAiVaultWorkspaceScopePaths(activeWorktree ?? null, allWorktrees),
|
||||
[activeWorktree, allWorktrees]
|
||||
)
|
||||
const projectContext = useMemo(
|
||||
const projectScopeContext = useMemo(
|
||||
() =>
|
||||
buildAiVaultProjectContext({
|
||||
repos,
|
||||
worktrees: allWorktrees,
|
||||
projectHostSetupProjection,
|
||||
activeRepo,
|
||||
activeWorktree,
|
||||
sessions: []
|
||||
}),
|
||||
[activeRepo, activeWorktree, allWorktrees, projectHostSetupProjection, repos]
|
||||
)
|
||||
const activeProjectKey = projectScopeContext.activeProjectKey
|
||||
const projectLabelByKey = projectScopeContext.projectLabelByKey
|
||||
// Sent to the scanner so scoped views surface sessions older than the global cap.
|
||||
const scopePaths = useMemo(
|
||||
() =>
|
||||
deriveAiVaultScopeSessionPaths(activeWorktree ?? null, allWorktrees, {
|
||||
activeProjectKey,
|
||||
projectHostSetupProjection
|
||||
}),
|
||||
[activeProjectKey, activeWorktree, allWorktrees, projectHostSetupProjection]
|
||||
)
|
||||
const { error, loading, refresh, scanResult, sessions } = useAiVaultSessionRefresh(scopePaths)
|
||||
const sessionProjectById = useMemo(
|
||||
() =>
|
||||
buildAiVaultProjectContext({
|
||||
repos,
|
||||
@@ -87,12 +102,9 @@ export default function AiVaultPanel(): React.JSX.Element {
|
||||
activeRepo,
|
||||
activeWorktree,
|
||||
sessions
|
||||
}),
|
||||
}).sessionProjectById,
|
||||
[activeRepo, activeWorktree, allWorktrees, projectHostSetupProjection, repos, sessions]
|
||||
)
|
||||
const activeProjectKey = projectContext.activeProjectKey
|
||||
const projectLabelByKey = projectContext.projectLabelByKey
|
||||
const sessionProjectById = projectContext.sessionProjectById
|
||||
const sessionWorktreeById = useAiVaultSessionWorktreeMap({
|
||||
sessions,
|
||||
worktrees: allWorktrees,
|
||||
@@ -136,51 +148,6 @@ export default function AiVaultPanel(): React.JSX.Element {
|
||||
}
|
||||
}, [activeProjectKey, activeWorktreePath, scope])
|
||||
|
||||
const refresh = useCallback(async (args: { force?: boolean } = {}): Promise<void> => {
|
||||
if (refreshInFlightRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
refreshInFlightRef.current = true
|
||||
const refreshId = refreshIdRef.current + 1
|
||||
refreshIdRef.current = refreshId
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await window.api.aiVault.listSessions({
|
||||
limit: SESSION_LIMIT,
|
||||
force: args.force
|
||||
})
|
||||
if (!mountedRef.current || refreshIdRef.current !== refreshId) {
|
||||
return
|
||||
}
|
||||
setScanResult(result)
|
||||
setSessions(result.sessions)
|
||||
} catch (err) {
|
||||
if (mountedRef.current && refreshIdRef.current === refreshId) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
} finally {
|
||||
refreshInFlightRef.current = false
|
||||
if (mountedRef.current && refreshIdRef.current === refreshId) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
refreshIdRef.current += 1
|
||||
refreshInFlightRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
const filteredSessions = useMemo(
|
||||
() =>
|
||||
filterAiVaultSessions(sessions, {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
isRuntimePathAbsolute,
|
||||
normalizeRuntimePathForComparison
|
||||
} from '../../../../shared/cross-platform-path'
|
||||
import type { ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection'
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id'
|
||||
|
||||
export function deriveAiVaultWorkspaceScopePaths(
|
||||
activeWorktree: Pick<Worktree, 'id' | 'path' | 'priorWorktreeIds' | 'repoId'> | null,
|
||||
liveWorktrees: readonly Pick<Worktree, 'id' | 'path' | 'repoId'>[] = []
|
||||
): string[] {
|
||||
if (!activeWorktree) {
|
||||
return []
|
||||
}
|
||||
|
||||
const paths: string[] = []
|
||||
addAiVaultWorkspaceScopePath(paths, activeWorktree.path)
|
||||
|
||||
for (const priorWorktreeId of activeWorktree.priorWorktreeIds ?? []) {
|
||||
const parsed = splitWorktreeIdForFilesystem(priorWorktreeId)
|
||||
if (!parsed || parsed.repoId !== activeWorktree.repoId) {
|
||||
continue
|
||||
}
|
||||
if (isAiVaultWorkspaceScopePathClaimed(parsed.worktreePath, activeWorktree, liveWorktrees)) {
|
||||
continue
|
||||
}
|
||||
addAiVaultWorkspaceScopePath(paths, parsed.worktreePath)
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
/**
|
||||
* Paths sent to the scanner so a scoped panel view surfaces its own sessions
|
||||
* even when they are older than the global recency cap. Covers the active
|
||||
* workspace plus the active project's other worktrees (same repo), so both the
|
||||
* Workspace and Project scopes stay complete.
|
||||
*/
|
||||
export function deriveAiVaultScopeSessionPaths(
|
||||
activeWorktree: Pick<
|
||||
Worktree,
|
||||
'id' | 'path' | 'priorWorktreeIds' | 'projectId' | 'repoId'
|
||||
> | null,
|
||||
liveWorktrees: readonly Pick<Worktree, 'id' | 'path' | 'projectId' | 'repoId'>[] = [],
|
||||
options: {
|
||||
activeProjectKey?: string | null
|
||||
projectHostSetupProjection?: ProjectHostSetupProjection
|
||||
} = {}
|
||||
): string[] {
|
||||
const paths = deriveAiVaultWorkspaceScopePaths(activeWorktree, liveWorktrees)
|
||||
if (!activeWorktree) {
|
||||
return paths
|
||||
}
|
||||
const setupsByRepoId = buildProjectSetupsByRepoId(options.projectHostSetupProjection)
|
||||
for (const worktree of liveWorktrees) {
|
||||
if (
|
||||
worktree.repoId === activeWorktree.repoId ||
|
||||
worktreeProjectKey(worktree) === options.activeProjectKey ||
|
||||
(setupsByRepoId.get(worktree.repoId) ?? []).some(
|
||||
(setup) => worktreeProjectKey(setup, setup) === options.activeProjectKey
|
||||
)
|
||||
) {
|
||||
addAiVaultWorkspaceScopePath(paths, worktree.path)
|
||||
}
|
||||
}
|
||||
for (const setup of options.projectHostSetupProjection?.setups ?? []) {
|
||||
if (worktreeProjectKey(setup, setup) === options.activeProjectKey) {
|
||||
addAiVaultWorkspaceScopePath(paths, setup.path)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
function buildProjectSetupsByRepoId(
|
||||
projection?: ProjectHostSetupProjection
|
||||
): Map<string, ProjectHostSetupProjection['setups']> {
|
||||
const setupsByRepoId = new Map<string, ProjectHostSetupProjection['setups']>()
|
||||
for (const setup of projection?.setups ?? []) {
|
||||
const setups = setupsByRepoId.get(setup.repoId) ?? []
|
||||
setups.push(setup)
|
||||
setupsByRepoId.set(setup.repoId, setups)
|
||||
}
|
||||
return setupsByRepoId
|
||||
}
|
||||
|
||||
function worktreeProjectKey(
|
||||
entry: Pick<Worktree, 'projectId' | 'repoId'> | { projectId?: string | null; repoId?: string },
|
||||
setup?: { projectId?: string | null; repoId?: string }
|
||||
): string | null {
|
||||
const projectId = entry.projectId ?? setup?.projectId ?? null
|
||||
if (projectId) {
|
||||
return projectId.startsWith('repo:') ? projectId : `project:${projectId}`
|
||||
}
|
||||
return entry.repoId ? `repo:${entry.repoId}` : null
|
||||
}
|
||||
|
||||
function addAiVaultWorkspaceScopePath(paths: string[], pathValue: string): void {
|
||||
const trimmedPath = pathValue.trim()
|
||||
if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath)) {
|
||||
return
|
||||
}
|
||||
const comparisonPath = normalizeRuntimePathForComparison(trimmedPath)
|
||||
if (
|
||||
paths.some((existingPath) => normalizeRuntimePathForComparison(existingPath) === comparisonPath)
|
||||
) {
|
||||
return
|
||||
}
|
||||
paths.push(trimmedPath)
|
||||
}
|
||||
|
||||
function isAiVaultWorkspaceScopePathClaimed(
|
||||
pathValue: string,
|
||||
activeWorktree: Pick<Worktree, 'id'>,
|
||||
liveWorktrees: readonly Pick<Worktree, 'id' | 'path'>[]
|
||||
): boolean {
|
||||
const trimmedPath = pathValue.trim()
|
||||
if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath)) {
|
||||
return false
|
||||
}
|
||||
const comparisonPath = normalizeRuntimePathForComparison(trimmedPath)
|
||||
// AI Vault sessions are keyed by cwd only, so any live worktree now owning this path wins.
|
||||
return liveWorktrees.some(
|
||||
(worktree) =>
|
||||
worktree.id !== activeWorktree.id &&
|
||||
normalizeRuntimePathForComparison(worktree.path) === comparisonPath
|
||||
)
|
||||
}
|
||||
@@ -2,13 +2,16 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
|
||||
import {
|
||||
AI_VAULT_SESSION_FILTER_QUERY_MAX_BYTES,
|
||||
deriveAiVaultWorkspaceScopePaths,
|
||||
filterAiVaultSessions,
|
||||
folderLabel,
|
||||
groupAiVaultSessions,
|
||||
isAiVaultSessionFilterQueryTooLarge,
|
||||
parseVaultQuery
|
||||
} from './ai-vault-session-filters'
|
||||
import {
|
||||
deriveAiVaultScopeSessionPaths,
|
||||
deriveAiVaultWorkspaceScopePaths
|
||||
} from './ai-vault-scope-paths'
|
||||
|
||||
const baseSession: AiVaultSession = {
|
||||
id: 'claude:1',
|
||||
@@ -415,6 +418,176 @@ describe('deriveAiVaultWorkspaceScopePaths', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveAiVaultScopeSessionPaths', () => {
|
||||
it('adds same-repo sibling worktrees on top of the workspace paths', () => {
|
||||
expect(
|
||||
deriveAiVaultScopeSessionPaths(
|
||||
{
|
||||
id: 'repo1::/Users/ada/workspaces/orca/fix-agent-history',
|
||||
repoId: 'repo1',
|
||||
path: '/Users/ada/workspaces/orca/fix-agent-history',
|
||||
priorWorktreeIds: []
|
||||
},
|
||||
[
|
||||
{
|
||||
id: 'repo1::/Users/ada/workspaces/orca/fix-agent-history',
|
||||
repoId: 'repo1',
|
||||
path: '/Users/ada/workspaces/orca/fix-agent-history'
|
||||
},
|
||||
{
|
||||
id: 'repo1::/Users/ada/workspaces/orca/sibling',
|
||||
repoId: 'repo1',
|
||||
path: '/Users/ada/workspaces/orca/sibling'
|
||||
},
|
||||
{
|
||||
id: 'repo2::/Users/ada/workspaces/other/elsewhere',
|
||||
repoId: 'repo2',
|
||||
path: '/Users/ada/workspaces/other/elsewhere'
|
||||
}
|
||||
]
|
||||
)
|
||||
).toEqual([
|
||||
'/Users/ada/workspaces/orca/fix-agent-history',
|
||||
'/Users/ada/workspaces/orca/sibling'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns no paths without an active worktree', () => {
|
||||
expect(deriveAiVaultScopeSessionPaths(null, [])).toEqual([])
|
||||
})
|
||||
|
||||
it('adds active project setup paths across repos', () => {
|
||||
expect(
|
||||
deriveAiVaultScopeSessionPaths(
|
||||
{
|
||||
id: 'repo1::/Users/ada/workspaces/orca/app',
|
||||
repoId: 'repo1',
|
||||
path: '/Users/ada/workspaces/orca/app',
|
||||
priorWorktreeIds: []
|
||||
},
|
||||
[
|
||||
{
|
||||
id: 'repo1::/Users/ada/workspaces/orca/app',
|
||||
repoId: 'repo1',
|
||||
path: '/Users/ada/workspaces/orca/app'
|
||||
},
|
||||
{
|
||||
id: 'repo2::/Users/ada/workspaces/orca/docs',
|
||||
repoId: 'repo2',
|
||||
path: '/Users/ada/workspaces/orca/docs'
|
||||
}
|
||||
],
|
||||
{
|
||||
activeProjectKey: 'project:orca',
|
||||
projectHostSetupProjection: {
|
||||
projects: [
|
||||
{
|
||||
id: 'orca',
|
||||
displayName: 'Orca',
|
||||
badgeColor: '#2563eb',
|
||||
sourceRepoIds: ['repo1', 'repo2'],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
],
|
||||
setups: [
|
||||
{
|
||||
id: 'setup-1',
|
||||
projectId: 'orca',
|
||||
hostId: 'local',
|
||||
repoId: 'repo1',
|
||||
displayName: 'App',
|
||||
path: '/Users/ada/workspaces/orca/app',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'imported-existing-folder',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
{
|
||||
id: 'setup-2',
|
||||
projectId: 'orca',
|
||||
hostId: 'local',
|
||||
repoId: 'repo2',
|
||||
displayName: 'Docs',
|
||||
path: '/Users/ada/workspaces/orca/docs',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'imported-existing-folder',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
).toEqual(['/Users/ada/workspaces/orca/app', '/Users/ada/workspaces/orca/docs'])
|
||||
})
|
||||
|
||||
it('keeps live worktree paths when another setup shares the repo id', () => {
|
||||
expect(
|
||||
deriveAiVaultScopeSessionPaths(
|
||||
{
|
||||
id: 'repo1::/Users/ada/workspaces/orca/app',
|
||||
repoId: 'repo1',
|
||||
path: '/Users/ada/workspaces/orca/app',
|
||||
priorWorktreeIds: []
|
||||
},
|
||||
[
|
||||
{
|
||||
id: 'repo2::/Users/ada/workspaces/orca/docs-worktree',
|
||||
repoId: 'repo2',
|
||||
path: '/Users/ada/workspaces/orca/docs-worktree'
|
||||
}
|
||||
],
|
||||
{
|
||||
activeProjectKey: 'project:orca',
|
||||
projectHostSetupProjection: {
|
||||
projects: [
|
||||
{
|
||||
id: 'orca',
|
||||
displayName: 'Orca',
|
||||
badgeColor: '#2563eb',
|
||||
sourceRepoIds: ['repo1', 'repo2'],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
],
|
||||
setups: [
|
||||
{
|
||||
id: 'setup-1',
|
||||
projectId: 'orca',
|
||||
hostId: 'local',
|
||||
repoId: 'repo2',
|
||||
displayName: 'Docs',
|
||||
path: '/Users/ada/workspaces/orca/docs',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'imported-existing-folder',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
{
|
||||
id: 'setup-2',
|
||||
projectId: 'other',
|
||||
hostId: 'local',
|
||||
repoId: 'repo2',
|
||||
displayName: 'Other',
|
||||
path: '/Users/ada/workspaces/other',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'imported-existing-folder',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
).toEqual([
|
||||
'/Users/ada/workspaces/orca/app',
|
||||
'/Users/ada/workspaces/orca/docs-worktree',
|
||||
'/Users/ada/workspaces/orca/docs'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAiVaultSessionFilterQueryTooLarge', () => {
|
||||
it('counts UTF-8 bytes rather than UTF-16 code units', () => {
|
||||
expect(
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
isPathInsideOrEqual,
|
||||
isRuntimePathAbsolute,
|
||||
normalizeRuntimePathForComparison,
|
||||
normalizeRuntimePathSeparators
|
||||
} from '../../../../shared/cross-platform-path'
|
||||
import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text'
|
||||
@@ -14,8 +12,6 @@ import type {
|
||||
AiVaultSort
|
||||
} from '../../../../shared/ai-vault-types'
|
||||
import { aiVaultAgentLabel } from '../../../../shared/ai-vault-types'
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id'
|
||||
import { sessionPreviewSearchText } from './ai-vault-session-display'
|
||||
import type { AiVaultSessionProject } from './ai-vault-session-projects'
|
||||
|
||||
@@ -95,31 +91,6 @@ export function filterAiVaultSessions(
|
||||
.sort((left, right) => compareSessions(left, right, filters.sort))
|
||||
}
|
||||
|
||||
export function deriveAiVaultWorkspaceScopePaths(
|
||||
activeWorktree: Pick<Worktree, 'id' | 'path' | 'priorWorktreeIds' | 'repoId'> | null,
|
||||
liveWorktrees: readonly Pick<Worktree, 'id' | 'path' | 'repoId'>[] = []
|
||||
): string[] {
|
||||
if (!activeWorktree) {
|
||||
return []
|
||||
}
|
||||
|
||||
const paths: string[] = []
|
||||
addAiVaultWorkspaceScopePath(paths, activeWorktree.path)
|
||||
|
||||
for (const priorWorktreeId of activeWorktree.priorWorktreeIds ?? []) {
|
||||
const parsed = splitWorktreeIdForFilesystem(priorWorktreeId)
|
||||
if (!parsed || parsed.repoId !== activeWorktree.repoId) {
|
||||
continue
|
||||
}
|
||||
if (isAiVaultWorkspaceScopePathClaimed(parsed.worktreePath, activeWorktree, liveWorktrees)) {
|
||||
continue
|
||||
}
|
||||
addAiVaultWorkspaceScopePath(paths, parsed.worktreePath)
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
export function groupAiVaultSessions(
|
||||
sessions: readonly AiVaultSession[],
|
||||
group: AiVaultGroup,
|
||||
@@ -264,20 +235,6 @@ function getFolderGroupKey(pathValue: string | null): string {
|
||||
return pathValue ? normalizeRuntimePathSeparators(pathValue).toLowerCase() : 'unknown'
|
||||
}
|
||||
|
||||
function addAiVaultWorkspaceScopePath(paths: string[], pathValue: string): void {
|
||||
const trimmedPath = pathValue.trim()
|
||||
if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath)) {
|
||||
return
|
||||
}
|
||||
const comparisonPath = normalizeRuntimePathForComparison(trimmedPath)
|
||||
if (
|
||||
paths.some((existingPath) => normalizeRuntimePathForComparison(existingPath) === comparisonPath)
|
||||
) {
|
||||
return
|
||||
}
|
||||
paths.push(trimmedPath)
|
||||
}
|
||||
|
||||
function isAiVaultSessionInWorkspacePath(workspacePath: string, sessionCwd: string): boolean {
|
||||
if (isPathInsideOrEqual(workspacePath, sessionCwd)) {
|
||||
return true
|
||||
@@ -293,24 +250,6 @@ function isAiVaultSessionInWorkspacePath(workspacePath: string, sessionCwd: stri
|
||||
return isPathInsideOrEqual(workspaceWslPath.linuxPath, sessionCwd)
|
||||
}
|
||||
|
||||
function isAiVaultWorkspaceScopePathClaimed(
|
||||
pathValue: string,
|
||||
activeWorktree: Pick<Worktree, 'id'>,
|
||||
liveWorktrees: readonly Pick<Worktree, 'id' | 'path'>[]
|
||||
): boolean {
|
||||
const trimmedPath = pathValue.trim()
|
||||
if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath)) {
|
||||
return false
|
||||
}
|
||||
const comparisonPath = normalizeRuntimePathForComparison(trimmedPath)
|
||||
// AI Vault sessions are keyed by cwd only, so any live worktree now owning this path wins.
|
||||
return liveWorktrees.some(
|
||||
(worktree) =>
|
||||
worktree.id !== activeWorktree.id &&
|
||||
normalizeRuntimePathForComparison(worktree.path) === comparisonPath
|
||||
)
|
||||
}
|
||||
|
||||
function tokenizeQuery(query: string): string[] {
|
||||
const tokens: string[] = []
|
||||
const pattern = /"([^"]+)"|'([^']+)'|(\S+)/g
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types'
|
||||
|
||||
const SESSION_LIMIT = 500
|
||||
|
||||
export function useAiVaultSessionRefresh(scopePaths: readonly string[]): {
|
||||
error: string | null
|
||||
loading: boolean
|
||||
refresh: (args?: { force?: boolean }) => Promise<void>
|
||||
scanResult: AiVaultListResult | null
|
||||
sessions: AiVaultSession[]
|
||||
} {
|
||||
const [sessions, setSessions] = useState<AiVaultSession[]>([])
|
||||
const [scanResult, setScanResult] = useState<AiVaultListResult | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const refreshIdRef = useRef(0)
|
||||
const refreshInFlightRef = useRef(false)
|
||||
const pendingRefreshRef = useRef(false)
|
||||
const pendingForceRef = useRef(false)
|
||||
const mountedRef = useRef(true)
|
||||
const scopePathsKey = useMemo(() => scopePaths.join('\n'), [scopePaths])
|
||||
const scopePathsRef = useRef<readonly string[]>(scopePaths)
|
||||
scopePathsRef.current = scopePaths
|
||||
|
||||
const refresh = useCallback(async (args: { force?: boolean } = {}): Promise<void> => {
|
||||
// A scope change during an in-flight scan must not be dropped; queue one more
|
||||
// scan so the current scoped view is refreshed after the older scan settles.
|
||||
if (refreshInFlightRef.current) {
|
||||
pendingRefreshRef.current = true
|
||||
pendingForceRef.current ||= args.force === true
|
||||
return
|
||||
}
|
||||
|
||||
refreshInFlightRef.current = true
|
||||
const refreshId = refreshIdRef.current + 1
|
||||
refreshIdRef.current = refreshId
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await window.api.aiVault.listSessions({
|
||||
limit: SESSION_LIMIT,
|
||||
scopePaths: scopePathsRef.current,
|
||||
force: args.force
|
||||
})
|
||||
if (!mountedRef.current || refreshIdRef.current !== refreshId) {
|
||||
return
|
||||
}
|
||||
setScanResult(result)
|
||||
setSessions(result.sessions)
|
||||
} catch (err) {
|
||||
if (mountedRef.current && refreshIdRef.current === refreshId) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
} finally {
|
||||
refreshInFlightRef.current = false
|
||||
if (mountedRef.current && refreshIdRef.current === refreshId) {
|
||||
setLoading(false)
|
||||
}
|
||||
if (pendingRefreshRef.current && mountedRef.current) {
|
||||
pendingRefreshRef.current = false
|
||||
const force = pendingForceRef.current
|
||||
pendingForceRef.current = false
|
||||
void refresh({ force })
|
||||
}
|
||||
}
|
||||
// Deps are intentionally empty: refresh reads changing values through refs
|
||||
// and recurses on itself, so its identity must stay stable.
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
refreshIdRef.current += 1
|
||||
refreshInFlightRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Re-scan on mount and whenever the active scope changes, since the scanner
|
||||
// tailors its in-scope results to scopePaths.
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
}, [refresh, scopePathsKey])
|
||||
|
||||
return { error, loading, refresh, scanResult, sessions }
|
||||
}
|
||||
@@ -247,6 +247,58 @@ export function GeneralEditorSettingsSection({
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.GeneralEditorSettingsSection.8f1afdfbd8',
|
||||
'Diff Word Wrap'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.GeneralEditorSettingsSection.4aa4d9fb73',
|
||||
'Wrap long lines in diff editors instead of requiring horizontal scrolling.'
|
||||
)}
|
||||
keywords={['diff', 'word wrap', 'wrap', 'markdown', 'long lines']}
|
||||
className="flex items-center justify-between gap-4 py-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<Label>
|
||||
{translate(
|
||||
'auto.components.settings.GeneralEditorSettingsSection.8f1afdfbd8',
|
||||
'Diff Word Wrap'
|
||||
)}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.settings.GeneralEditorSettingsSection.4aa4d9fb73',
|
||||
'Wrap long lines in diff editors instead of requiring horizontal scrolling.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<SettingsSegmentedControl
|
||||
ariaLabel={translate(
|
||||
'auto.components.settings.GeneralEditorSettingsSection.8f1afdfbd8',
|
||||
'Diff Word Wrap'
|
||||
)}
|
||||
value={settings.diffWordWrap ? 'on' : 'off'}
|
||||
onChange={(option) => updateSettings({ diffWordWrap: option === 'on' })}
|
||||
options={[
|
||||
{
|
||||
value: 'off',
|
||||
label: translate(
|
||||
'auto.components.settings.GeneralEditorSettingsSection.bf16ef0af2',
|
||||
'Off'
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'on',
|
||||
label: translate(
|
||||
'auto.components.settings.GeneralEditorSettingsSection.3f6892f307',
|
||||
'On'
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.GeneralEditorSettingsSection.1de48ad940',
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
import { RepositoryWorktreeDefaultsSection } from './RepositoryWorktreeDefaultsSection'
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: (selector: (state: { settingsSearchQuery: string }) => unknown) =>
|
||||
selector({ settingsSearchQuery: '' })
|
||||
}))
|
||||
|
||||
vi.mock('./BaseRefPicker', () => ({
|
||||
BaseRefPicker: () => null
|
||||
}))
|
||||
|
||||
const BASE_REPO: Repo = {
|
||||
id: 'repo-1',
|
||||
path: '/home/user/project',
|
||||
displayName: 'My Project',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0
|
||||
}
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount()
|
||||
})
|
||||
container.remove()
|
||||
})
|
||||
|
||||
function render(repo: Repo, updateRepo: (repoId: string, updates: object) => void): void {
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(RepositoryWorktreeDefaultsSection, {
|
||||
repo,
|
||||
settings: null,
|
||||
updateRepo,
|
||||
forceVisible: true
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function getWorktreePathInput(): HTMLInputElement {
|
||||
const input = container.querySelector<HTMLInputElement>('input')
|
||||
if (!input) {
|
||||
throw new Error('worktree path input not found')
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
function setNativeValue(input: HTMLInputElement, text: string): void {
|
||||
// Why: React reads controlled-input changes via the native value setter;
|
||||
// assigning input.value directly is swallowed by React's value tracking.
|
||||
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
|
||||
setValue?.call(input, text)
|
||||
}
|
||||
|
||||
function typeText(input: HTMLInputElement, text: string): void {
|
||||
act(() => {
|
||||
setNativeValue(input, text)
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
}
|
||||
|
||||
function blurInput(input: HTMLInputElement): void {
|
||||
// Why: React delegates onBlur via focusout (which bubbles) not blur (which
|
||||
// doesn't), so dispatching focusout is required to trigger the React handler.
|
||||
act(() => {
|
||||
input.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
|
||||
})
|
||||
}
|
||||
|
||||
describe('RepositoryWorktreeDefaultsSection — worktree path', () => {
|
||||
it('does not call updateRepo while the user is typing', () => {
|
||||
const updateRepo = vi.fn()
|
||||
render(BASE_REPO, updateRepo)
|
||||
|
||||
const input = getWorktreePathInput()
|
||||
typeText(input, './w')
|
||||
typeText(input, './wo')
|
||||
typeText(input, './wor')
|
||||
typeText(input, './worktree')
|
||||
|
||||
expect(updateRepo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls updateRepo with the final value on blur', () => {
|
||||
const updateRepo = vi.fn()
|
||||
render(BASE_REPO, updateRepo)
|
||||
|
||||
const input = getWorktreePathInput()
|
||||
typeText(input, ' ./worktree ')
|
||||
blurInput(input)
|
||||
|
||||
expect(updateRepo).toHaveBeenCalledTimes(1)
|
||||
expect(updateRepo).toHaveBeenCalledWith('repo-1', { worktreeBasePath: './worktree' })
|
||||
})
|
||||
|
||||
it('does not call updateRepo when the normalized value is unchanged on blur', () => {
|
||||
const updateRepo = vi.fn()
|
||||
render({ ...BASE_REPO, worktreeBasePath: './worktree' }, updateRepo)
|
||||
|
||||
const input = getWorktreePathInput()
|
||||
typeText(input, ' ./worktree ')
|
||||
blurInput(input)
|
||||
|
||||
expect(updateRepo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls updateRepo with undefined when the field is cleared', () => {
|
||||
const updateRepo = vi.fn()
|
||||
render({ ...BASE_REPO, worktreeBasePath: '../worktrees' }, updateRepo)
|
||||
|
||||
const input = getWorktreePathInput()
|
||||
typeText(input, '')
|
||||
blurInput(input)
|
||||
|
||||
expect(updateRepo).toHaveBeenCalledWith('repo-1', { worktreeBasePath: undefined })
|
||||
})
|
||||
|
||||
it('calls updateRepo with undefined when the value is whitespace-only', () => {
|
||||
const updateRepo = vi.fn()
|
||||
render({ ...BASE_REPO, worktreeBasePath: '../worktrees' }, updateRepo)
|
||||
|
||||
const input = getWorktreePathInput()
|
||||
typeText(input, ' ')
|
||||
blurInput(input)
|
||||
|
||||
expect(updateRepo).toHaveBeenCalledWith('repo-1', { worktreeBasePath: undefined })
|
||||
})
|
||||
})
|
||||
@@ -83,9 +83,16 @@ export function RepositoryWorktreeDefaultsSection({
|
||||
repoId={repo.id}
|
||||
storeValue={repo.worktreeBasePath ?? ''}
|
||||
placeholder={settings?.workspaceDir ?? ''}
|
||||
onTextChange={(text) =>
|
||||
updateRepo(repo.id, { worktreeBasePath: text.trim() ? text : undefined })
|
||||
}
|
||||
onTextChange={() => {}}
|
||||
onBlur={(e) => {
|
||||
const worktreeBasePath = e.currentTarget.value.trim() || undefined
|
||||
// Why: even an unchanged worktreeBasePath update asks main to
|
||||
// prepare the root, which can touch the filesystem.
|
||||
if (worktreeBasePath === (repo.worktreeBasePath?.trim() || undefined)) {
|
||||
return
|
||||
}
|
||||
updateRepo(repo.id, { worktreeBasePath })
|
||||
}}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { AppState } from '@/store/types'
|
||||
import {
|
||||
getAllWorktreesFromState,
|
||||
useAllWorktrees,
|
||||
useProjectHostSetupProjection,
|
||||
useRepoMap,
|
||||
useWorktreeMap
|
||||
} from '@/store/selectors'
|
||||
@@ -5332,11 +5333,13 @@ const WorktreeList = React.memo(function WorktreeList({
|
||||
// Why: manual repo header order is bound to state.repos. Recent/Smart derive
|
||||
// header order from the sorted visible worktree stream instead.
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const projects = useAppStore((s) => s.projects)
|
||||
const projectHostSetups = useAppStore((s) => s.projectHostSetups)
|
||||
const projectHostSetupProjection = useProjectHostSetupProjection()
|
||||
const projectGrouping = useMemo(
|
||||
() => ({ projects, projectHostSetups }),
|
||||
[projectHostSetups, projects]
|
||||
() => ({
|
||||
projects: projectHostSetupProjection.projects,
|
||||
projectHostSetups: projectHostSetupProjection.setups
|
||||
}),
|
||||
[projectHostSetupProjection]
|
||||
)
|
||||
const projectGroups = useAppStore((s) => s.projectGroups ?? EMPTY_PROJECT_GROUPS)
|
||||
const folderWorkspaces = useAppStore((s) => s.folderWorkspaces)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getExecutionHostLabel } from '../../../../shared/execution-host'
|
||||
import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection'
|
||||
import {
|
||||
ALL_GROUP_META,
|
||||
buildRows,
|
||||
@@ -361,6 +362,105 @@ describe('buildRows with pinned worktrees', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('renders same-project records with git remote identity as one mixed-host project header', () => {
|
||||
const localRepo: Repo = {
|
||||
...repo,
|
||||
id: 'local-sample-app',
|
||||
path: '/Users/alice/work/sample-app',
|
||||
displayName: 'sample-app',
|
||||
gitRemoteIdentity: {
|
||||
canonicalKey: 'git.company.test/team/sample-app',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'git@git.company.test:team/sample-app.git'
|
||||
}
|
||||
}
|
||||
const sshRepo: Repo = {
|
||||
...repo,
|
||||
id: 'ssh-sample-app',
|
||||
path: '/home/alice/src/sample-app',
|
||||
displayName: 'sample-app',
|
||||
connectionId: 'build server',
|
||||
gitRemoteIdentity: {
|
||||
canonicalKey: 'git.company.test/team/sample-app',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'https://git.company.test/team/sample-app.git'
|
||||
}
|
||||
}
|
||||
const runtimeRepo: Repo = {
|
||||
...repo,
|
||||
id: 'runtime-sample-app',
|
||||
path: '/workspace/sample-app',
|
||||
displayName: 'sample-app',
|
||||
executionHostId: 'runtime:dev-container',
|
||||
gitRemoteIdentity: {
|
||||
canonicalKey: 'git.company.test/team/sample-app',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'ssh://git@git.company.test/team/sample-app.git'
|
||||
}
|
||||
}
|
||||
const localWorktree: Worktree = {
|
||||
...worktree,
|
||||
id: 'wt-local-sample-app',
|
||||
repoId: localRepo.id,
|
||||
path: '/Users/alice/work/sample-app-feature'
|
||||
}
|
||||
const sshWorktree: Worktree = {
|
||||
...worktree,
|
||||
id: 'wt-ssh-sample-app',
|
||||
repoId: sshRepo.id,
|
||||
path: '/home/alice/src/sample-app-feature'
|
||||
}
|
||||
const runtimeWorktree: Worktree = {
|
||||
...worktree,
|
||||
id: 'wt-runtime-sample-app',
|
||||
repoId: runtimeRepo.id,
|
||||
path: '/workspace/sample-app-feature'
|
||||
}
|
||||
const projection = projectHostSetupProjectionFromRepos([localRepo, sshRepo, runtimeRepo])
|
||||
const rows = buildRows(
|
||||
'repo',
|
||||
[localWorktree, sshWorktree, runtimeWorktree],
|
||||
new Map([
|
||||
[localRepo.id, localRepo],
|
||||
[sshRepo.id, sshRepo],
|
||||
[runtimeRepo.id, runtimeRepo]
|
||||
]),
|
||||
null,
|
||||
new Set(),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
new Map([
|
||||
[localWorktree.id, localWorktree],
|
||||
[sshWorktree.id, sshWorktree],
|
||||
[runtimeWorktree.id, runtimeWorktree]
|
||||
]),
|
||||
false,
|
||||
undefined,
|
||||
[],
|
||||
new Set(),
|
||||
new Map(),
|
||||
[],
|
||||
{
|
||||
projects: projection.projects,
|
||||
projectHostSetups: projection.setups
|
||||
}
|
||||
)
|
||||
|
||||
expect(rows).toMatchObject([
|
||||
{
|
||||
type: 'header',
|
||||
key: 'project:git:git.company.test/team/sample-app',
|
||||
label: 'sample-app',
|
||||
count: 3
|
||||
},
|
||||
{ type: 'item', worktree: { id: localWorktree.id }, hostContextLabel: LOCAL_HOST_LABEL },
|
||||
{ type: 'item', worktree: { id: sshWorktree.id }, hostContextLabel: 'build server' },
|
||||
{ type: 'item', worktree: { id: runtimeWorktree.id }, hostContextLabel: 'dev-container' }
|
||||
])
|
||||
})
|
||||
|
||||
it('orders project identity headers by the manual repo order anchor', () => {
|
||||
const analyticsProject: Project = {
|
||||
...project,
|
||||
@@ -2506,6 +2606,13 @@ describe('WorktreeList header styles', () => {
|
||||
expect(source).toContain('headerKey: row.key')
|
||||
expect(source).toContain('color={repoHeaderColor}')
|
||||
})
|
||||
|
||||
it('adapts projected setup rows for sidebar project grouping', () => {
|
||||
const source = readWorktreeListSource()
|
||||
|
||||
expect(source).toContain('const projectHostSetupProjection = useProjectHostSetupProjection()')
|
||||
expect(source).toContain('projectHostSetups: projectHostSetupProjection.setups')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRows pending creations', () => {
|
||||
|
||||
@@ -21,6 +21,11 @@ import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion
|
||||
import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd'
|
||||
import { useAppStore } from '@/store'
|
||||
import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
|
||||
import {
|
||||
markTerminalFollowOutput,
|
||||
markTerminalPinnedViewport,
|
||||
syncTerminalScrollIntentFromViewport
|
||||
} from '@/lib/pane-manager/terminal-scroll-intent'
|
||||
|
||||
export function recordKeyboardCreatedTerminalPaneSplit(
|
||||
createdPane: unknown,
|
||||
@@ -314,9 +319,13 @@ export function useTerminalKeyboardShortcuts({
|
||||
return
|
||||
}
|
||||
if (action.position === 'top') {
|
||||
markTerminalPinnedViewport(pane.terminal)
|
||||
pane.terminal.scrollToLine(0)
|
||||
syncTerminalScrollIntentFromViewport(pane.terminal)
|
||||
} else {
|
||||
markTerminalFollowOutput(pane.terminal)
|
||||
pane.terminal.scrollToBottom()
|
||||
syncTerminalScrollIntentFromViewport(pane.terminal)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6255,6 +6255,12 @@ describe('connectPanePty', () => {
|
||||
const pane = createPane(1)
|
||||
pane.terminal.buffer.active.viewportY = 42
|
||||
pane.terminal.buffer.active.baseY = 100
|
||||
pane.terminal.write.mockImplementation((data: string, callback?: () => void) => {
|
||||
if (data.includes('snapshot-state')) {
|
||||
pane.terminal.buffer.active.viewportY = 0
|
||||
}
|
||||
callback?.()
|
||||
})
|
||||
const manager = createManager(1)
|
||||
const deps = createDeps({
|
||||
isVisibleRef: { current: false }
|
||||
|
||||
@@ -58,8 +58,11 @@ import {
|
||||
} from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import { recordAgentHibernationPaneOutput } from '@/lib/agent-hibernation-output-activity'
|
||||
import { isLocalNativeWindowsConpty } from '@/lib/pane-manager/windows-pty-compatibility'
|
||||
import { recordTerminalOutput, restoreScrollStateAfterLayout } from '@/lib/pane-manager/pane-scroll'
|
||||
import type { ScrollState } from '@/lib/pane-manager/pane-manager-types'
|
||||
import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll'
|
||||
import {
|
||||
captureTerminalWriteScrollIntent,
|
||||
enforceTerminalWriteScrollIntent
|
||||
} from '@/lib/pane-manager/terminal-scroll-intent'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { createTerminalCommandLifecycle } from './terminal-command-lifecycle'
|
||||
@@ -3114,40 +3117,13 @@ export function connectPanePty(
|
||||
})
|
||||
}
|
||||
|
||||
function captureScrollStateForSnapshotReplay(): ScrollState | null {
|
||||
const buf = pane.terminal.buffer?.active
|
||||
if (!buf) {
|
||||
return null
|
||||
}
|
||||
const viewportY = buf.viewportY
|
||||
const baseY = buf.baseY
|
||||
if (!Number.isFinite(viewportY) || !Number.isFinite(baseY)) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
bufferType: buf.type,
|
||||
wasAtBottom: viewportY >= baseY,
|
||||
viewportY,
|
||||
baseY
|
||||
}
|
||||
}
|
||||
|
||||
function restoreScrollStateAfterSnapshotReplay(state: ScrollState | null): void {
|
||||
if (!state || state.wasAtBottom) {
|
||||
return
|
||||
}
|
||||
// Why: hidden-backlog replay clears xterm after visibility scroll restore;
|
||||
// re-apply a scrolled-up viewport so recovery does not jump to bottom.
|
||||
restoreScrollStateAfterLayout(pane.terminal, state)
|
||||
}
|
||||
|
||||
function applyMainBufferSnapshot(snapshot: {
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
}): void {
|
||||
const scrollState = captureScrollStateForSnapshotReplay()
|
||||
const scrollIntent = captureTerminalWriteScrollIntent(pane.terminal)
|
||||
const colsBeforeReplay = pane.terminal.cols
|
||||
const rowsBeforeReplay = pane.terminal.rows
|
||||
const hasSnapshotDimensions =
|
||||
@@ -3191,7 +3167,9 @@ export function connectPanePty(
|
||||
}
|
||||
scheduleReattachIdleAgentCursorReset()
|
||||
}
|
||||
restoreScrollStateAfterSnapshotReplay(scrollState)
|
||||
// Why: snapshot replay clears and rebuilds xterm state; re-apply the
|
||||
// user's scroll intent once so hidden catch-up cannot repin the viewport.
|
||||
enforceTerminalWriteScrollIntent(pane.terminal, scrollIntent)
|
||||
}
|
||||
|
||||
function requestHiddenOutputRestoreIfNeeded(opts?: { bypassScheduler?: boolean }): boolean {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
flushTerminalOutput,
|
||||
requestTerminalBacklogRecovery
|
||||
} from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import { restoreScrollStateAfterLayout } from '@/lib/pane-manager/pane-scroll'
|
||||
import { enforceTerminalCurrentScrollIntent } from '@/lib/pane-manager/terminal-scroll-intent'
|
||||
import { fitAndFocusPanes, fitPanes, focusActivePane } from './pane-helpers'
|
||||
|
||||
const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024
|
||||
@@ -47,7 +47,7 @@ export function resumeTerminalVisibility({
|
||||
// post-resume fit runs. Capture numeric viewport positions first; the
|
||||
// restore path avoids content matching so duplicate agent log lines do
|
||||
// not jump to the wrong history entry.
|
||||
const viewportPositions = captureViewportPositions(!wasVisible)
|
||||
captureViewportPositions(!wasVisible)
|
||||
withSuppressedScrollTracking(() => {
|
||||
if (shouldUseLightTabResume) {
|
||||
// Why: intra-worktree tab switches only toggle the overlay. Keeping
|
||||
@@ -61,7 +61,7 @@ export function resumeTerminalVisibility({
|
||||
} else {
|
||||
resumeTerminalVisibilityHeavy(manager, isActive)
|
||||
}
|
||||
restoreTerminalViewportPositions(manager, viewportPositions)
|
||||
enforceTerminalViewportIntents(manager)
|
||||
if (!shouldUseLightTabResume) {
|
||||
// Why: this clear wipes the glyph atlas shared with other same-config
|
||||
// terminals; the global reset rebuilds their render models too.
|
||||
@@ -137,14 +137,8 @@ function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean):
|
||||
}
|
||||
}
|
||||
|
||||
function restoreTerminalViewportPositions(
|
||||
manager: PaneManager,
|
||||
viewportPositions: Map<number, ScrollState>
|
||||
): void {
|
||||
function enforceTerminalViewportIntents(manager: PaneManager): void {
|
||||
for (const pane of manager.getPanes()) {
|
||||
const position = viewportPositions.get(pane.id)
|
||||
if (position) {
|
||||
restoreScrollStateAfterLayout(pane.terminal, position)
|
||||
}
|
||||
enforceTerminalCurrentScrollIntent(pane.terminal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({
|
||||
flushTerminalOutput: vi.fn(),
|
||||
getTerminalOutputEpoch: vi.fn(() => 0),
|
||||
handleTerminalFileDrop: vi.fn(),
|
||||
enforceTerminalCurrentScrollIntent: vi.fn(),
|
||||
pasteTerminalText: vi.fn(),
|
||||
recordTerminalUserInputForLeaf: vi.fn(),
|
||||
requestTerminalBacklogRecovery: vi.fn(),
|
||||
@@ -75,6 +76,10 @@ vi.mock('@/lib/pane-manager/pane-scroll', () => ({
|
||||
restoreScrollStateAfterLayout: mocks.restoreScrollStateAfterLayout
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({
|
||||
enforceTerminalCurrentScrollIntent: mocks.enforceTerminalCurrentScrollIntent
|
||||
}))
|
||||
|
||||
vi.mock('./terminal-drop-handler', () => ({
|
||||
handleTerminalFileDrop: mocks.handleTerminalFileDrop
|
||||
}))
|
||||
@@ -234,6 +239,9 @@ describe('useTerminalPaneGlobalEffects', () => {
|
||||
mocks.restoreScrollStateAfterLayout.mockImplementation((terminal: { name: string }) => {
|
||||
order.push(`restore:${terminal.name}`)
|
||||
})
|
||||
mocks.enforceTerminalCurrentScrollIntent.mockImplementation((terminal: { name: string }) => {
|
||||
order.push(`intent:${terminal.name}`)
|
||||
})
|
||||
mocks.fitAndFocusPanes.mockImplementation(() => order.push('fit-focus'))
|
||||
|
||||
// Why: the resume path resets atlases through the live-manager registry
|
||||
@@ -267,10 +275,11 @@ describe('useTerminalPaneGlobalEffects', () => {
|
||||
'flush:terminal-b',
|
||||
'resume',
|
||||
'fit-focus',
|
||||
'restore:terminal-a',
|
||||
'restore:terminal-b',
|
||||
'intent:terminal-a',
|
||||
'intent:terminal-b',
|
||||
'reset-atlas'
|
||||
])
|
||||
expect(mocks.restoreScrollStateAfterLayout).not.toHaveBeenCalled()
|
||||
expect(mocks.flushTerminalOutput).toHaveBeenNthCalledWith(1, terminalA, {
|
||||
maxChars: 256 * 1024
|
||||
})
|
||||
@@ -563,7 +572,7 @@ describe('useTerminalPaneGlobalEffects', () => {
|
||||
expect(window.api.pty.setActiveRendererPty).toHaveBeenCalledWith('pty-active', true)
|
||||
})
|
||||
|
||||
it('restores from the pre-hide scroll state when hidden layout changes the viewport', () => {
|
||||
it('enforces scroll intent after hidden layout changes the viewport', () => {
|
||||
const terminalA = { name: 'terminal-a' }
|
||||
const manager = {
|
||||
getPanes: vi.fn(() => [{ id: 1, terminal: terminalA }]),
|
||||
@@ -618,7 +627,8 @@ describe('useTerminalPaneGlobalEffects', () => {
|
||||
|
||||
expect(mocks.captureScrollState).toHaveBeenCalledTimes(2)
|
||||
expect(manager.suspendRendering).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.restoreScrollStateAfterLayout).toHaveBeenLastCalledWith(terminalA, preHideState)
|
||||
expect(mocks.restoreScrollStateAfterLayout).not.toHaveBeenCalled()
|
||||
expect(mocks.enforceTerminalCurrentScrollIntent).toHaveBeenLastCalledWith(terminalA)
|
||||
})
|
||||
|
||||
it('clears WebGL texture atlases when the active visible terminal regains focus', () => {
|
||||
|
||||
@@ -71,6 +71,10 @@ import { getConnectionId } from '@/lib/connection-context'
|
||||
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard'
|
||||
import { fitAndFocusPanes, fitPanes } from './pane-helpers'
|
||||
import {
|
||||
markTerminalPinnedViewport,
|
||||
syncTerminalScrollIntentSoon
|
||||
} from '@/lib/pane-manager/terminal-scroll-intent'
|
||||
import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
|
||||
import { e2eConfig } from '@/lib/e2e-config'
|
||||
import {
|
||||
@@ -720,6 +724,15 @@ export function useTerminalPaneLifecycle({
|
||||
return false
|
||||
}
|
||||
|
||||
if (e.type === 'keydown') {
|
||||
if (e.key === 'PageUp' || e.key === 'Home') {
|
||||
markTerminalPinnedViewport(pane.terminal)
|
||||
syncTerminalScrollIntentSoon(pane.terminal, { preservePinnedAtBottom: true })
|
||||
} else if (e.key === 'PageDown' || e.key === 'End') {
|
||||
syncTerminalScrollIntentSoon(pane.terminal)
|
||||
}
|
||||
}
|
||||
|
||||
return !shouldBypassXtermKeyboardEvent(e, {
|
||||
isMac,
|
||||
hasSelection: pane.terminal.hasSelection()
|
||||
|
||||
+45
-1
@@ -11,7 +11,9 @@ const mocks = vi.hoisted(() => ({
|
||||
baseY: 0
|
||||
})),
|
||||
flushTerminalOutput: vi.fn(),
|
||||
getTerminalOutputEpoch: vi.fn(() => 1)
|
||||
getTerminalOutputEpoch: vi.fn(() => 1),
|
||||
getTerminalScrollIntentKind: vi.fn(() => 'followOutput'),
|
||||
markTerminalFollowOutput: vi.fn()
|
||||
}))
|
||||
|
||||
const reactRefState = vi.hoisted(() => ({
|
||||
@@ -68,6 +70,11 @@ vi.mock('@/lib/pane-manager/pane-scroll', () => ({
|
||||
getTerminalOutputEpoch: mocks.getTerminalOutputEpoch
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({
|
||||
getTerminalScrollIntentKind: mocks.getTerminalScrollIntentKind,
|
||||
markTerminalFollowOutput: mocks.markTerminalFollowOutput
|
||||
}))
|
||||
|
||||
describe('useTerminalScrollVisibilityMemory', () => {
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame
|
||||
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame
|
||||
@@ -75,6 +82,7 @@ describe('useTerminalScrollVisibilityMemory', () => {
|
||||
beforeEach(() => {
|
||||
resetHookRefs()
|
||||
vi.clearAllMocks()
|
||||
mocks.getTerminalScrollIntentKind.mockReturnValue('followOutput')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -121,6 +129,42 @@ describe('useTerminalScrollVisibilityMemory', () => {
|
||||
maxChars: 256 * 1024
|
||||
})
|
||||
expect(terminal.scrollToBottom).toHaveBeenCalled()
|
||||
expect(mocks.markTerminalFollowOutput).toHaveBeenCalledWith(terminal)
|
||||
})
|
||||
|
||||
it('does not turn a pinned viewport into follow-output when pending focus requests catch up', () => {
|
||||
mocks.getTerminalScrollIntentKind.mockReturnValue('pinnedViewport')
|
||||
const terminal = {
|
||||
onScroll: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
scrollToBottom: vi.fn()
|
||||
}
|
||||
const manager = {
|
||||
getPanes: vi.fn(() => [{ id: 1, terminal }])
|
||||
}
|
||||
const animationFrames: FrameRequestCallback[] = []
|
||||
globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
|
||||
animationFrames.push(callback)
|
||||
return animationFrames.length
|
||||
})
|
||||
|
||||
beginHookRender()
|
||||
const visibilityMemory = useTerminalScrollVisibilityMemory({
|
||||
managerRef: { current: manager as never },
|
||||
isVisibleRef: { current: true },
|
||||
visibleResumeCompleteRef: { current: true },
|
||||
paneCount: 1
|
||||
})
|
||||
|
||||
visibilityMemory.scheduleFollowOutputIfNeeded(1)
|
||||
animationFrames.shift()?.(16)
|
||||
animationFrames.shift()?.(32)
|
||||
|
||||
expect(mocks.flushTerminalOutput).toHaveBeenCalledWith(terminal, {
|
||||
maxChars: 256 * 1024
|
||||
})
|
||||
expect(mocks.cancelDeferredScrollRestore).not.toHaveBeenCalled()
|
||||
expect(mocks.markTerminalFollowOutput).not.toHaveBeenCalled()
|
||||
expect(terminal.scrollToBottom).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels pending follow-output frames on cleanup', () => {
|
||||
|
||||
+13
-38
@@ -1,11 +1,15 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import type { IDisposable, Terminal } from '@xterm/xterm'
|
||||
import type { Terminal } from '@xterm/xterm'
|
||||
import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import {
|
||||
cancelDeferredScrollRestore,
|
||||
captureScrollState,
|
||||
getTerminalOutputEpoch
|
||||
} from '@/lib/pane-manager/pane-scroll'
|
||||
import {
|
||||
getTerminalScrollIntentKind,
|
||||
markTerminalFollowOutput
|
||||
} from '@/lib/pane-manager/terminal-scroll-intent'
|
||||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import type { ScrollState } from '@/lib/pane-manager/pane-manager-types'
|
||||
|
||||
@@ -37,7 +41,6 @@ export function useTerminalScrollVisibilityMemory({
|
||||
paneCount
|
||||
}: UseTerminalScrollVisibilityMemoryArgs): TerminalScrollVisibilityMemory {
|
||||
const visibleScrollSnapshotsRef = useRef<Map<number, VisibleScrollSnapshot>>(new Map())
|
||||
const scrollDisposablesRef = useRef<Map<number, IDisposable>>(new Map())
|
||||
const suppressScrollTrackingRef = useRef(false)
|
||||
const pendingFollowOutputPaneIdsRef = useRef<Set<number>>(new Set())
|
||||
const followOutputFrameIdsRef = useRef<number[]>([])
|
||||
@@ -117,10 +120,13 @@ export function useTerminalScrollVisibilityMemory({
|
||||
const currentEpoch = getTerminalOutputEpoch(pane.terminal)
|
||||
const hasNewOutput = previous ? currentEpoch > previous.outputEpoch : currentEpoch > 0
|
||||
if (hasNewOutput) {
|
||||
cancelDeferredScrollRestore(pane.terminal)
|
||||
pane.terminal.scrollToBottom()
|
||||
if (getTerminalScrollIntentKind(pane.terminal) === 'followOutput') {
|
||||
cancelDeferredScrollRestore(pane.terminal)
|
||||
markTerminalFollowOutput(pane.terminal)
|
||||
pane.terminal.scrollToBottom()
|
||||
didScroll = true
|
||||
}
|
||||
rememberVisibleScrollSnapshot(pane.id, pane.terminal)
|
||||
didScroll = true
|
||||
}
|
||||
pending.delete(pane.id)
|
||||
}
|
||||
@@ -164,46 +170,15 @@ export function useTerminalScrollVisibilityMemory({
|
||||
if (!manager) {
|
||||
return
|
||||
}
|
||||
const disposables = scrollDisposablesRef.current
|
||||
const panes = manager.getPanes()
|
||||
const livePaneIds = new Set(panes.map((pane) => pane.id))
|
||||
for (const [paneId, disposable] of disposables) {
|
||||
for (const paneId of visibleScrollSnapshotsRef.current.keys()) {
|
||||
if (!livePaneIds.has(paneId)) {
|
||||
disposable.dispose()
|
||||
disposables.delete(paneId)
|
||||
visibleScrollSnapshotsRef.current.delete(paneId)
|
||||
pendingFollowOutputPaneIdsRef.current.delete(paneId)
|
||||
}
|
||||
}
|
||||
for (const pane of panes) {
|
||||
if (disposables.has(pane.id)) {
|
||||
continue
|
||||
}
|
||||
const onScroll = (
|
||||
pane.terminal as Terminal & {
|
||||
onScroll?: (listener: (position: number) => void) => IDisposable
|
||||
}
|
||||
).onScroll
|
||||
if (typeof onScroll !== 'function') {
|
||||
continue
|
||||
}
|
||||
disposables.set(
|
||||
pane.id,
|
||||
onScroll.call(pane.terminal, () => {
|
||||
if (!isVisibleRef.current || suppressScrollTrackingRef.current) {
|
||||
return
|
||||
}
|
||||
rememberVisibleScrollSnapshot(pane.id, pane.terminal)
|
||||
})
|
||||
)
|
||||
}
|
||||
return () => {
|
||||
for (const disposable of disposables.values()) {
|
||||
disposable.dispose()
|
||||
}
|
||||
disposables.clear()
|
||||
}
|
||||
}, [isVisibleRef, managerRef, paneCount, rememberVisibleScrollSnapshot])
|
||||
}, [managerRef, paneCount])
|
||||
|
||||
return {
|
||||
captureViewportPositions,
|
||||
|
||||
@@ -240,6 +240,19 @@ describe('useComposerState host-context boundaries', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('selects a project by its own host instead of pinning the current host', () => {
|
||||
// Regression: passing the current host as a hard `hostId` made picking a
|
||||
// project set up only on a different host a silent no-op. The current host
|
||||
// must be a preference (focusedHostScope), with a fallback to any ready host.
|
||||
const handleProjectChange = sourceBetween(
|
||||
HOOK_SOURCE,
|
||||
'const handleProjectChange = useCallback',
|
||||
'const handleSmartGitHubItemSelect'
|
||||
)
|
||||
expect(handleProjectChange).toContain('focusedHostScope: preferredHostId ?? workspaceHostScope')
|
||||
expect(handleProjectChange).not.toContain('hostId: preferredHostId')
|
||||
})
|
||||
|
||||
it('clears GitLab-specific linked state when clearing smart-name selection', () => {
|
||||
const section = sourceBetween(
|
||||
HOOK_SOURCE,
|
||||
|
||||
@@ -2277,13 +2277,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
setSelectedProjectGroupId(null)
|
||||
const preferredHostId =
|
||||
selectedWorkspaceTarget.status === 'ready' ? selectedWorkspaceTarget.target.hostId : null
|
||||
// Why: prefer the host the user is currently on, but treat it as a
|
||||
// preference (focusedHostScope) rather than a hard hostId match. Pinning
|
||||
// hostId made selecting a project that is only set up on a different host
|
||||
// a silent no-op — the resolver returned '' (project-not-set-up-on-host)
|
||||
// and the early return below swallowed the click. Falling back to any
|
||||
// host the project is ready on lets cross-host selection work.
|
||||
const nextRepoId = resolveWorkspaceCreationRepoId({
|
||||
eligibleRepos,
|
||||
projects,
|
||||
projectHostSetups,
|
||||
projectId,
|
||||
hostId: preferredHostId,
|
||||
focusedHostScope: workspaceHostScope
|
||||
focusedHostScope: preferredHostId ?? workspaceHostScope
|
||||
})
|
||||
if (!nextRepoId) {
|
||||
return
|
||||
|
||||
@@ -822,7 +822,23 @@
|
||||
"ba8e329d92": "Unmark viewed",
|
||||
"3f79ffc8b7": "Open the PR details to view current reviewers.",
|
||||
"5c1c973855": "Remove reviewer",
|
||||
"commentTooLarge": "Comment is too large to submit safely."
|
||||
"commentTooLarge": "Comment is too large to submit safely.",
|
||||
"timeline": {
|
||||
"completed": "as completed",
|
||||
"notPlanned": "as not planned",
|
||||
"someone": "someone",
|
||||
"assigned": "assigned",
|
||||
"unassigned": "unassigned",
|
||||
"mentioned": "mentioned this",
|
||||
"in": "in",
|
||||
"closed": "closed this",
|
||||
"reopened": "reopened this",
|
||||
"moved": "moved this",
|
||||
"from": "from",
|
||||
"to": "to",
|
||||
"activity": "Activity",
|
||||
"noActivity": "No activity yet."
|
||||
}
|
||||
},
|
||||
"GitLabItemDialog": {
|
||||
"65e784c1f1": "Reopen",
|
||||
@@ -1107,6 +1123,8 @@
|
||||
"notePasteTooLarge": "Paste is too large for the note field."
|
||||
},
|
||||
"NewWorkspaceComposerModal": {
|
||||
"createWorktree": "Create worktree",
|
||||
"createWorkspace": "Create workspace",
|
||||
"fa90f739a5": "Choose the project, workspace name, and agent before creating the workspace."
|
||||
},
|
||||
"PullRequestPage": {
|
||||
@@ -4941,7 +4959,11 @@
|
||||
"70bb30feb1": "Save editor and editable diff changes automatically after a short pause.",
|
||||
"0df2e4fd12": "Auto Save Files",
|
||||
"d21136d9ef": "Configure how Orca persists file edits.",
|
||||
"45c6e85c4d": "Editor"
|
||||
"45c6e85c4d": "Editor",
|
||||
"8f1afdfbd8": "Diff Word Wrap",
|
||||
"4aa4d9fb73": "Wrap long lines in diff editors instead of requiring horizontal scrolling.",
|
||||
"bf16ef0af2": "Off",
|
||||
"3f6892f307": "On"
|
||||
},
|
||||
"AdvancedNetworkSettingsSection": {
|
||||
"3e431564b5": "localhost, 127.0.0.1, *.internal",
|
||||
@@ -10485,7 +10507,9 @@
|
||||
"8368d256ec": "combined-branch",
|
||||
"8f68ad9ca9": "Show {{value0}} AI {{value1}}",
|
||||
"724a13568d": " in {{value0}}",
|
||||
"6094135eec": " vs {{value0}}"
|
||||
"6094135eec": " vs {{value0}}",
|
||||
"a4420ca1f7": "Wrap On",
|
||||
"dde325ddfe": "Wrap Off"
|
||||
},
|
||||
"ConflictComponents": {
|
||||
"f338288514": "Loading conflict contents...",
|
||||
@@ -10573,7 +10597,8 @@
|
||||
"3e0ce48c24": "Export as PDF",
|
||||
"561251019a": "More actions",
|
||||
"8c8b7f5ff5": "Show front matter",
|
||||
"10c39d58c1": "Hide front matter"
|
||||
"10c39d58c1": "Hide front matter",
|
||||
"1eef809708": "Word Wrap"
|
||||
},
|
||||
"EditorPanelShell": {
|
||||
"e2c4dec350": "Loading editor..."
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -107,7 +107,7 @@
|
||||
"c9d6f98459": "최대화",
|
||||
"66f0a552e5": "복원",
|
||||
"bbb7f90669": "최소화",
|
||||
"d54e66004c": "terminal",
|
||||
"d54e66004c": "터미널",
|
||||
"9f0152563e": "모바일",
|
||||
"62ca9895a7": "스페이스",
|
||||
"844eb0f4f4": "활동",
|
||||
@@ -537,10 +537,10 @@
|
||||
"38c9f034ff": "드롭한 파일을 업로드하지 못했습니다.",
|
||||
"d720e2f855": "드롭한 일부 파일을 업로드할 수 없습니다.",
|
||||
"245faa95b9": "드롭한 파일에 사용할 수 있는 원격 워크스페이스 경로가 없습니다.",
|
||||
"nativeDropTooManyPathsDescription": "Drop {{value0}} or fewer files at a time.",
|
||||
"nativeDropTooManyPaths": "Drop contains too many files.",
|
||||
"nativeDropPathsTooLargeDescription": "Drop fewer files or use a shorter path list.",
|
||||
"nativeDropPathsTooLarge": "Drop path list is too large."
|
||||
"nativeDropTooManyPathsDescription": "한 번에 {{value0}}개 이하의 파일을 드롭하세요.",
|
||||
"nativeDropTooManyPaths": "드롭에 파일이 너무 많습니다.",
|
||||
"nativeDropPathsTooLargeDescription": "파일 수를 줄이거나 더 짧은 경로 목록을 사용하세요.",
|
||||
"nativeDropPathsTooLarge": "드롭 경로 목록이 너무 큽니다."
|
||||
},
|
||||
"useIpcEvents": {
|
||||
"0e3cf53060": "브라우저 탭 {{value0}}을(를) 찾을 수 없습니다",
|
||||
@@ -593,7 +593,7 @@
|
||||
"42ae40842f": "전역적으로 또는 프로젝트별로 범위가 지정된 저장된 terminal 명령입니다.",
|
||||
"3fc3db144f": "빠른 명령어",
|
||||
"c33bfd664c": "셸, 렌더러, 세션 및 terminal 동작.",
|
||||
"a9fb10afca": "Terminal",
|
||||
"a9fb10afca": "터미널",
|
||||
"5235c215ca": "작업 페이지와 사이드바에 표시할 작업 제공자를 선택합니다.",
|
||||
"85f4fd7710": "작업 소스",
|
||||
"ab4b21b58e": "브랜치 이름 지정, 기본 참조, 속성 및 Git AI Author.",
|
||||
@@ -618,13 +618,13 @@
|
||||
"b1c2f8b0ac": "Claude, Codex, Gemini 및 OpenCode Go에 대한 선택적 계정 전환.",
|
||||
"f70ac54d38": "AI 제공업체 계정",
|
||||
"4121f7a0a2": "AI agents를 관리하고, 기본값을 설정하고, 명령을 사용자 정의하세요.",
|
||||
"b49abbd2f7": "Agents"
|
||||
"b49abbd2f7": "에이전트"
|
||||
},
|
||||
"useAppMenuPaste": {
|
||||
"pasteTooLarge": "Paste is too large."
|
||||
"pasteTooLarge": "붙여넣기 내용이 너무 큽니다."
|
||||
},
|
||||
"useLargeTextControlPaste": {
|
||||
"pasteTooLarge": "Paste is too large."
|
||||
"pasteTooLarge": "붙여넣기 내용이 너무 큽니다."
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -632,10 +632,10 @@
|
||||
"9132779820": "닫기",
|
||||
"c72a5fb234": "다시 시작",
|
||||
"9263e75f49": "Codex는 이전 계정을 사용하고 있습니다",
|
||||
"a4c8e1b2f7": "Codex is still signed in as {{value0}}",
|
||||
"d3e8a1f4b2": "Account switched",
|
||||
"e9b2c7d1a5": "Restart this session to use {{value0}}. Collapse to keep working with the current account.",
|
||||
"f1a6c8e3b4": "Collapse"
|
||||
"a4c8e1b2f7": "Codex가 아직 {{value0}} 계정으로 로그인되어 있습니다",
|
||||
"d3e8a1f4b2": "계정 전환됨",
|
||||
"e9b2c7d1a5": "{{value0}}을(를) 사용하려면 이 세션을 다시 시작하세요. 현재 계정으로 계속 작업하려면 접으세요.",
|
||||
"f1a6c8e3b4": "접기"
|
||||
},
|
||||
"FirstLaunchBanner": {
|
||||
"b9e1b966c7": "알림 닫기",
|
||||
@@ -822,7 +822,23 @@
|
||||
"ba8e329d92": "본 것으로 표시 해제",
|
||||
"3f79ffc8b7": "현재 리뷰어를 보려면 PR 세부정보를 엽니다.",
|
||||
"5c1c973855": "리뷰어 삭제",
|
||||
"commentTooLarge": "Comment is too large to submit safely."
|
||||
"commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다.",
|
||||
"timeline": {
|
||||
"completed": "as completed",
|
||||
"notPlanned": "as not planned",
|
||||
"someone": "someone",
|
||||
"assigned": "assigned",
|
||||
"unassigned": "unassigned",
|
||||
"mentioned": "mentioned this",
|
||||
"in": "in",
|
||||
"closed": "closed this",
|
||||
"reopened": "reopened this",
|
||||
"moved": "moved this",
|
||||
"from": "from",
|
||||
"to": "to",
|
||||
"activity": "Activity",
|
||||
"noActivity": "No activity yet."
|
||||
}
|
||||
},
|
||||
"GitLabItemDialog": {
|
||||
"65e784c1f1": "다시 열기",
|
||||
@@ -887,7 +903,7 @@
|
||||
"3a051b8ade": "작업 항목",
|
||||
"32f8bef818": "로그 출력이 없습니다.",
|
||||
"4168eb2c51": "해결",
|
||||
"commentTooLarge": "Comment is too large to submit safely."
|
||||
"commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다."
|
||||
},
|
||||
"JiraIssueWorkspace": {
|
||||
"b0b92666c9": "댓글",
|
||||
@@ -922,7 +938,7 @@
|
||||
"2a829a2f00": "우선순위",
|
||||
"693be070d0": "전환",
|
||||
"ef21405c6d": "Jira 이슈",
|
||||
"commentTooLarge": "Comment is too large to submit safely."
|
||||
"commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다."
|
||||
},
|
||||
"Landing": {
|
||||
"76a95f7f47": "생성",
|
||||
@@ -1045,7 +1061,7 @@
|
||||
"39883467f4": "Linear 이슈",
|
||||
"fda549766e": "댓글을 달려면 {{value0}}",
|
||||
"d71cd3003e": "+ 담당자",
|
||||
"commentTooLarge": "Comment is too large to submit safely."
|
||||
"commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다."
|
||||
},
|
||||
"NewWorkspaceComposerCard": {
|
||||
"cbb47ee0dc": "로컬 Git 프로젝트에만 사용할 수 있습니다.",
|
||||
@@ -1057,7 +1073,7 @@
|
||||
"f0470c7383": "고급",
|
||||
"ba64270bdb": "agents 구성",
|
||||
"ab63f25397": "agent 설정 열기",
|
||||
"01d1e8f601": "Agent",
|
||||
"01d1e8f601": "에이전트",
|
||||
"0c5d6a479c": "[선택 사항]",
|
||||
"b5a0796911": "연결",
|
||||
"dccd26d4e4": "프로젝트 선택",
|
||||
@@ -1102,11 +1118,13 @@
|
||||
"importingHostSetup": "가져오는 중...",
|
||||
"importHostSetup": "가져오기",
|
||||
"notePasteTooLarge": "메모 필드에 붙여넣을 내용이 너무 큽니다.",
|
||||
"reuseExistingBranch": "Reuse branch",
|
||||
"reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it.",
|
||||
"createMultiple": "Create more"
|
||||
"reuseExistingBranch": "브랜치 재사용",
|
||||
"reuseExistingBranchHint": "기존 브랜치에서 새 브랜치를 만들지 않고 해당 브랜치를 체크아웃합니다.",
|
||||
"createMultiple": "더 만들기"
|
||||
},
|
||||
"NewWorkspaceComposerModal": {
|
||||
"createWorktree": "작업 트리 만들기",
|
||||
"createWorkspace": "워크스페이스 만들기",
|
||||
"fa90f739a5": "워크스페이스를 만들기 전에 프로젝트, 워크스페이스 이름, agent를 선택하세요."
|
||||
},
|
||||
"PullRequestPage": {
|
||||
@@ -1274,10 +1292,10 @@
|
||||
"2b4fdb880c": "본 표시 해제",
|
||||
"56ec6eafb7": "현재 리뷰어를 보려면 PR 세부정보를 엽니다.",
|
||||
"7f964a365a": "리뷰어 삭제",
|
||||
"commentTooLarge": "Comment is too large to submit safely.",
|
||||
"8ff5ae8866": "Assignees",
|
||||
"82c87eceb9": "Edit assignees",
|
||||
"1ff5d979df": "No one assigned"
|
||||
"commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다.",
|
||||
"8ff5ae8866": "담당자",
|
||||
"82c87eceb9": "담당자 편집",
|
||||
"1ff5d979df": "할당된 사람이 없음"
|
||||
},
|
||||
"QuickOpen": {
|
||||
"1dbd3f59ff": "이동",
|
||||
@@ -1607,25 +1625,25 @@
|
||||
"fe28c9821f": "보기",
|
||||
"8d1e17a3ef": "GitHub에서 {{value0}} 열기",
|
||||
"4ac8ff2275": "Jira에서 {{value0}} 열기",
|
||||
"40eaf2c27c": "Details",
|
||||
"closeAsCompleted": "Close as completed",
|
||||
"closeAsCompletedDescription": "Done, closed, fixed, resolved",
|
||||
"closeAsNotPlanned": "Close as not planned",
|
||||
"closeAsNotPlannedDescription": "Won't fix, can't repro, stale",
|
||||
"closeAsDuplicate": "Close as duplicate",
|
||||
"closeAsDuplicateDescription": "Duplicate of another issue in this repository",
|
||||
"duplicateIssueNumberPlaceholder": "Issue number",
|
||||
"closeAsDuplicateSubmit": "Close duplicate",
|
||||
"duplicateIssueMissing": "Enter an issue number in this repository.",
|
||||
"duplicateIssueNotInteger": "Use a whole issue number.",
|
||||
"duplicateIssueNotPositive": "Use a positive issue number.",
|
||||
"duplicateIssueSameIssue": "Choose a different issue.",
|
||||
"repository": "Repository",
|
||||
"backToCloseReasons": "Back",
|
||||
"searchIssues": "Search issues",
|
||||
"useIssueNumber": "Use issue #{{value0}}",
|
||||
"noMatchingIssuesLoaded": "No matching issues loaded.",
|
||||
"editReviewersWithCurrent": "Edit reviewers: {{value0}}"
|
||||
"40eaf2c27c": "세부 정보",
|
||||
"closeAsCompleted": "완료로 닫기",
|
||||
"closeAsCompletedDescription": "완료, 닫힘, 수정됨, 해결됨",
|
||||
"closeAsNotPlanned": "계획 없음으로 닫기",
|
||||
"closeAsNotPlannedDescription": "수정 안 함, 재현 불가, 오래됨",
|
||||
"closeAsDuplicate": "중복으로 닫기",
|
||||
"closeAsDuplicateDescription": "이 리포지토리의 다른 이슈와 중복",
|
||||
"duplicateIssueNumberPlaceholder": "이슈 번호",
|
||||
"closeAsDuplicateSubmit": "중복 닫기",
|
||||
"duplicateIssueMissing": "이 리포지토리의 이슈 번호를 입력하세요.",
|
||||
"duplicateIssueNotInteger": "정수 이슈 번호를 사용하세요.",
|
||||
"duplicateIssueNotPositive": "양수 이슈 번호를 사용하세요.",
|
||||
"duplicateIssueSameIssue": "다른 이슈를 선택하세요.",
|
||||
"repository": "리포지토리",
|
||||
"backToCloseReasons": "뒤로",
|
||||
"searchIssues": "이슈 검색",
|
||||
"useIssueNumber": "이슈 #{{value0}} 사용",
|
||||
"noMatchingIssuesLoaded": "일치하는 이슈를 불러오지 않았습니다.",
|
||||
"editReviewersWithCurrent": "리뷰어 편집: {{value0}}"
|
||||
},
|
||||
"Terminal": {
|
||||
"73768427cf": "닫기",
|
||||
@@ -1727,9 +1745,9 @@
|
||||
"actionBadge": "작업",
|
||||
"paletteHostBadge": "호스트: {{value0}}",
|
||||
"workspaceTabMissing": "탭이 더 이상 존재하지 않습니다",
|
||||
"projectsGroupsHeader": "Projects & Groups",
|
||||
"projectBadge": "Project",
|
||||
"repoGroupBadge": "Repo group"
|
||||
"projectsGroupsHeader": "프로젝트 및 그룹",
|
||||
"projectBadge": "프로젝트",
|
||||
"repoGroupBadge": "리포지토리 그룹"
|
||||
},
|
||||
"github": {
|
||||
"pr": {
|
||||
@@ -1873,9 +1891,9 @@
|
||||
"067119985c": "GitHub 검색, 예: assignee:@me is:open",
|
||||
"1850fceac8": "{{value0}}/{{value1}}이(가) Orca에 추가되어 있지 않습니다. 작업을 시작하려면 추가하거나 GitHub에서 여세요.",
|
||||
"1aa7c952b9": "프로젝트 보기",
|
||||
"f352abf7c3": "Repository list is updating.",
|
||||
"1ce21b8cff": "This item is outside the selected repositories.",
|
||||
"030de75bc5": "This item matches multiple selected repositories."
|
||||
"f352abf7c3": "리포지토리 목록을 업데이트 중입니다.",
|
||||
"1ce21b8cff": "이 항목은 선택한 리포지토리 밖에 있습니다.",
|
||||
"030de75bc5": "이 항목은 선택한 여러 리포지토리와 일치합니다."
|
||||
},
|
||||
"slug": {
|
||||
"dialog": {
|
||||
@@ -1892,7 +1910,7 @@
|
||||
"463d030ae4": "삭제",
|
||||
"8564f58542": "편집",
|
||||
"5f104bf855": "아직 댓글이 없습니다.",
|
||||
"commentTooLarge": "Comment is too large to submit safely."
|
||||
"commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다."
|
||||
},
|
||||
"LabelsEditor": {
|
||||
"34dd57d6c8": "로드 중…",
|
||||
@@ -1921,7 +1939,7 @@
|
||||
"8f1c2d4e6a": "미리 볼 내용이 없습니다",
|
||||
"c91f0a2b14": "작성",
|
||||
"d82b1e3f05": "미리보기",
|
||||
"imageUrlTooLarge": "Image URL is too large."
|
||||
"imageUrlTooLarge": "이미지 URL이 너무 큽니다."
|
||||
},
|
||||
"IssueSourceSelector": {
|
||||
"d6aeb2012b": "다음에서 이슈 표시",
|
||||
@@ -2158,7 +2176,7 @@
|
||||
"WorkspaceSpacePage": {
|
||||
"8d0048e1cb": "워크스페이스 디스크 사용량과 회수 가능한 워크트리 저장 공간.",
|
||||
"e8d6ba11ab": "베타",
|
||||
"45f6302dbc": "Space",
|
||||
"45f6302dbc": "공간",
|
||||
"ecf72fdc3b": "뒤로"
|
||||
}
|
||||
},
|
||||
@@ -2272,7 +2290,7 @@
|
||||
"577a342c7d": "agent에게 이 워크스페이스를 조사하도록 요청하세요.",
|
||||
"026cfb232a": "프롬프트 명령을 지원하지 않습니다",
|
||||
"346d409ab2": "agent 선택",
|
||||
"0adba8fa0c": "Agent",
|
||||
"0adba8fa0c": "에이전트",
|
||||
"ec8f081919": "작업",
|
||||
"ed04233b3e": "빠른 액세스를 위해 terminal 명령이나 agent 프롬프트를 저장하세요.",
|
||||
"ca414324ee": "명령 텍스트",
|
||||
@@ -2418,11 +2436,11 @@
|
||||
"29c031b49a": "{{value0}} 파일{{value1}}을 런타임에 업로드하는 중…",
|
||||
"0c77693641": "Worktree가 준비되지 않았습니다. 잠시 후에 다시 시도하세요.",
|
||||
"ce8248b835": "작업 트리 경로를 사용할 수 없습니다.",
|
||||
"internalTooManyPaths": "Drop contains too many paths for a safe terminal paste.",
|
||||
"internalPathsTooLarge": "Drop path list is too large for a safe terminal paste.",
|
||||
"b4cf68e889": "Skipped {{value0}} {{value1}}.",
|
||||
"writeTimeout": "File drop cancelled: terminal did not accept the path before the safety timeout.",
|
||||
"writeRejected": "File drop cancelled: terminal could not accept the path."
|
||||
"internalTooManyPaths": "터미널에 안전하게 붙여넣기에는 드롭된 경로가 너무 많습니다.",
|
||||
"internalPathsTooLarge": "터미널에 안전하게 붙여넣기에는 경로 목록이 너무 큽니다.",
|
||||
"b4cf68e889": "{{value0}} {{value1}}을(를) 건너뛰었습니다.",
|
||||
"writeTimeout": "파일 드롭이 취소되었습니다. 안전 시간 제한 전에 터미널이 경로를 받지 않았습니다.",
|
||||
"writeRejected": "파일 드롭이 취소되었습니다. 터미널이 경로를 받을 수 없습니다."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2466,7 +2484,7 @@
|
||||
"9acaf92093": "창 작업",
|
||||
"1bce81dba6": "시뮬레이터",
|
||||
"1ff1c77616": "브라우저",
|
||||
"586d2ac445": "terminal",
|
||||
"586d2ac445": "터미널",
|
||||
"addSplitPane": "분할 창 추가",
|
||||
"closePaneColumn": "분할 창 닫기"
|
||||
},
|
||||
@@ -2614,19 +2632,19 @@
|
||||
"5a9c83c04b": "모든 파일, URL, agent를 엽니다...",
|
||||
"90eb94dc48": "http:// 또는 https:// URL을 입력하세요.",
|
||||
"5553b283ce": "URL 또는 파일 경로를 입력하세요.",
|
||||
"queryTooLarge": "Search text is too large."
|
||||
"queryTooLarge": "검색 텍스트가 너무 큽니다."
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
"options": {
|
||||
"5501c2fb7a": "terminal",
|
||||
"5501c2fb7a": "터미널",
|
||||
"9630dd5494": "shell",
|
||||
"a094576900": "새 terminal",
|
||||
"4f23f4d01d": "새 shell",
|
||||
"4f2a91e15b": "browser",
|
||||
"4f2a91e15b": "브라우저",
|
||||
"6d0e6a4b7a": "새 브라우저",
|
||||
"c87ad57785": "브라우저 탭",
|
||||
"cce7ef1d2c": "web",
|
||||
"cce7ef1d2c": "웹",
|
||||
"5f17fb9d0c": "markdown",
|
||||
"44caaf7b36": "md",
|
||||
"fb50e3d874": "새 Markdown",
|
||||
@@ -2635,11 +2653,11 @@
|
||||
"37ff3ddca1": "Markdown 열기",
|
||||
"164c394bab": "파일 열기",
|
||||
"bbaf4f85a4": "모바일 에뮬레이터",
|
||||
"3784b83bd4": "emulator",
|
||||
"3784b83bd4": "에뮬레이터",
|
||||
"a63847a742": "시뮬레이터",
|
||||
"1baeb07c17": "iOS 시뮬레이터",
|
||||
"8a580f88cf": "iphone",
|
||||
"7ecdc5ef08": "ipad",
|
||||
"8a580f88cf": "iPhone",
|
||||
"7ecdc5ef08": "iPad",
|
||||
"14965cc123": "모바일"
|
||||
}
|
||||
}
|
||||
@@ -2847,7 +2865,7 @@
|
||||
"f4d2651498": "스캔됨",
|
||||
"6a5dc3c61a": "리뷰",
|
||||
"c361440dc0": "베타",
|
||||
"8ff597593d": "Space",
|
||||
"8ff597593d": "공간",
|
||||
"0582df6d2e": "스캔",
|
||||
"f5e1a84d79": "새로고침",
|
||||
"2af2174d6d": "취소",
|
||||
@@ -2898,8 +2916,8 @@
|
||||
"b9b4a3a25d": "브랜치",
|
||||
"c432278ec7": "편집기 버퍼",
|
||||
"0bc756efaf": "Git 변경사항",
|
||||
"e9528a89b3": "Terminals",
|
||||
"a8d9e0de79": "Agents",
|
||||
"e9528a89b3": "터미널",
|
||||
"a8d9e0de79": "에이전트",
|
||||
"d384a4ce9f": "삭제 판단",
|
||||
"7d7745bb8f": "삭제할 수 있음",
|
||||
"720870a18e": "유지: 연결됨",
|
||||
@@ -2966,20 +2984,20 @@
|
||||
"8418ec448d": "{{value0}} 사용량을 새로 고칠 수 없습니다. Agent 세션은 여전히 로그인되어 있을 수 있습니다.",
|
||||
"45198c7d95": "rate-limit 재설정 1회 사용 가능",
|
||||
"bce421cba3": "rate-limit 재설정 {{value0}}회 사용 가능",
|
||||
"7ec6e030a0": "Next expires now",
|
||||
"d1e442a9e5": "Expires now",
|
||||
"6cf9eaed10": "Next expires in {{value0}}",
|
||||
"20ad66aed1": "Expires in {{value0}}",
|
||||
"0d8d7cfe15": "Waiting for Claude session",
|
||||
"1804cd8c3f": "Refreshing sign-in",
|
||||
"f8f0f9d8cc": "Network issue",
|
||||
"bf2e739f18": "Sign-in unavailable",
|
||||
"f8b8dbed85": "Usage unavailable",
|
||||
"3d3c9c0c1f": "Claude usage will refresh after the live Claude terminal rotates its credentials.",
|
||||
"42fdd4da1d": "Claude sign-in is being refreshed. Agent sessions may still be signed in.",
|
||||
"c06c1d215d": "Claude usage could not be refreshed because the network request failed.",
|
||||
"cabdc2a9e0": "Claude sign-in credentials could not be read.",
|
||||
"a7517cccb6": "Claude usage is unavailable right now."
|
||||
"7ec6e030a0": "다음 항목이 지금 만료됨",
|
||||
"d1e442a9e5": "지금 만료됨",
|
||||
"6cf9eaed10": "다음 항목이 {{value0}} 후 만료됨",
|
||||
"20ad66aed1": "{{value0}} 후 만료됨",
|
||||
"0d8d7cfe15": "Claude 세션 대기 중",
|
||||
"1804cd8c3f": "로그인 새로 고치는 중",
|
||||
"f8f0f9d8cc": "네트워크 문제",
|
||||
"bf2e739f18": "로그인 사용할 수 없음",
|
||||
"f8b8dbed85": "사용량 사용할 수 없음",
|
||||
"3d3c9c0c1f": "실시간 Claude 터미널이 자격 증명을 교체한 후 Claude 사용량이 새로 고쳐집니다.",
|
||||
"42fdd4da1d": "Claude 로그인을 새로 고치는 중입니다. 에이전트 세션은 여전히 로그인되어 있을 수 있습니다.",
|
||||
"c06c1d215d": "네트워크 요청 실패로 Claude 사용량을 새로 고칠 수 없습니다.",
|
||||
"cabdc2a9e0": "Claude 로그인 자격 증명을 읽을 수 없습니다.",
|
||||
"a7517cccb6": "현재 Claude 사용량을 사용할 수 없습니다."
|
||||
},
|
||||
"SshTargetStatusRow": {
|
||||
"sshHost": "SSH 호스트"
|
||||
@@ -3215,7 +3233,7 @@
|
||||
"8efeae0b22": "추적",
|
||||
"5acbe1fdf2": "시간",
|
||||
"ef8bbf7739": "PR",
|
||||
"ce8533f02e": "agents",
|
||||
"ce8533f02e": "에이전트",
|
||||
"0bba8ca244": "통계",
|
||||
"0e2a0b6431": "사용량",
|
||||
"372debfac0": "통계",
|
||||
@@ -3265,11 +3283,11 @@
|
||||
"21ea00bfa8": "캐시",
|
||||
"e0b988599d": "합계",
|
||||
"01476891c7": "마지막 활동",
|
||||
"c17bed0416": "Project",
|
||||
"f6a2c8d019": "Model",
|
||||
"faf3444859": "Input",
|
||||
"a8b7487ff7": "Output",
|
||||
"cfe2282ffa": "Unknown"
|
||||
"c17bed0416": "프로젝트",
|
||||
"f6a2c8d019": "모델",
|
||||
"faf3444859": "입력",
|
||||
"a8b7487ff7": "출력",
|
||||
"cfe2282ffa": "알 수 없음"
|
||||
}
|
||||
},
|
||||
"sparse": {
|
||||
@@ -3551,7 +3569,7 @@
|
||||
"51001182e3": "빈 디렉토리",
|
||||
"971d85cc84": "이 호스트에서 프로젝트로 열립니다 · {{value0}}",
|
||||
"00c4235c10": "'{{value0}}'과(와) 일치하는 항목이 없습니다.",
|
||||
"largeInputNoMatches": "No matches for this long input"
|
||||
"largeInputNoMatches": "이 긴 입력과 일치하는 항목 없음"
|
||||
},
|
||||
"RemoveFolderDialog": {
|
||||
"4dc5b5065b": "제거",
|
||||
@@ -3645,7 +3663,7 @@
|
||||
"0c3395fd32": "작업 트리 및 브라우저 탭 검색",
|
||||
"c86d83b5c3": "새로 만들기",
|
||||
"1b5c41caee": "Orca 모바일",
|
||||
"9c95e1ce91": "Agents",
|
||||
"9c95e1ce91": "에이전트",
|
||||
"f323383e9a": "자동화",
|
||||
"e7ad3c540d": "Jira 작업 열기",
|
||||
"c39ab10000": "Linear 작업 열기",
|
||||
@@ -3748,7 +3766,7 @@
|
||||
"65a9820bd1": "Agent 상태",
|
||||
"219ebf1961": "브랜치 이름",
|
||||
"automation": "자동화",
|
||||
"folderPathIdentity": "Branch / folder path"
|
||||
"folderPathIdentity": "브랜치 / 폴더 경로"
|
||||
},
|
||||
"SshDisconnectedDialog": {
|
||||
"ca4a7892af": "연결 중...",
|
||||
@@ -3822,18 +3840,18 @@
|
||||
"0d224eff10": "기본 작업 트리",
|
||||
"ca74db7550": "SSH를 통한 원격 프로젝트",
|
||||
"021538e1d1": "SSH 연결이 끊어졌습니다.",
|
||||
"runtimeHostDisconnected": "Server disconnected",
|
||||
"runtimeHostProject": "Project on Orca server",
|
||||
"automationCreated": "Created by automation",
|
||||
"branchIdentity": "Branch",
|
||||
"branchFolderPathIdentity": "Branch or folder path"
|
||||
"runtimeHostDisconnected": "서버 연결 끊김",
|
||||
"runtimeHostProject": "Orca 서버의 프로젝트",
|
||||
"automationCreated": "자동화로 생성됨",
|
||||
"branchIdentity": "브랜치",
|
||||
"branchFolderPathIdentity": "브랜치 또는 폴더 경로"
|
||||
},
|
||||
"WorktreeCardAgents": {
|
||||
"1b0a156717": "Agents"
|
||||
"1b0a156717": "에이전트"
|
||||
},
|
||||
"WorktreeCardReviewDetailSection": {
|
||||
"reviewHeader": "{{value0}} #{{value1}}",
|
||||
"copyLink": "Copy link"
|
||||
"copyLink": "링크 복사"
|
||||
},
|
||||
"WorktreeCardMeta": {
|
||||
"3e65e11cc6": "워크스페이스 메타데이터",
|
||||
@@ -3861,12 +3879,12 @@
|
||||
"automationMissing": "자동화를 더 이상 사용할 수 없습니다.",
|
||||
"automationRunMissing": "실행 기록을 더 이상 사용할 수 없습니다.",
|
||||
"automationAvailabilityUnavailable": "자동화 사용 가능 여부를 확인할 수 없습니다.",
|
||||
"moreIssueActions": "More issue actions",
|
||||
"copyLink": "Copy link",
|
||||
"copyLinkSuccess": "{{value0}} copied",
|
||||
"copyLinkFailure": "Failed to copy link",
|
||||
"issueLinkLabel": "Issue link",
|
||||
"reviewLinkLabel": "{{value0}} link"
|
||||
"moreIssueActions": "추가 이슈 작업",
|
||||
"copyLink": "링크 복사",
|
||||
"copyLinkSuccess": "{{value0}} 복사됨",
|
||||
"copyLinkFailure": "링크 복사 실패",
|
||||
"issueLinkLabel": "이슈 링크",
|
||||
"reviewLinkLabel": "{{value0}} 링크"
|
||||
},
|
||||
"WorktreeCardMetadataStatusBadges": {
|
||||
"fe188062a1": "상태: 열림",
|
||||
@@ -3943,7 +3961,7 @@
|
||||
"045a8aed48": "하위",
|
||||
"2ca6e29a3c": "repo",
|
||||
"bb85cd86ba": "{{value0}}에 대한 워크스페이스 만들기",
|
||||
"ebadb7eadb": "{{value0}} {{value1}} child {{value2}}",
|
||||
"ebadb7eadb": "{{value0}} {{value1}} 하위 {{value2}}",
|
||||
"20bebf9c7f": "{{value0}}개 하위 워크스페이스 표시",
|
||||
"c1f4a31623": "{{value0}}개 하위 워크스페이스 표시",
|
||||
"e97297cb75": "{{value0}}개 하위 워크스페이스 숨기기",
|
||||
@@ -3955,9 +3973,9 @@
|
||||
"groupDeleteFailedDesc": "그룹을 삭제하는 중 문제가 발생했습니다. 제거된 프로젝트는 없습니다.",
|
||||
"7a8b9c0d1e": "업데이트 필요",
|
||||
"hostAuthNeeded": "인증 필요",
|
||||
"hostDisconnected": "Disconnected",
|
||||
"hostDisconnected": "연결 끊김",
|
||||
"failedNestWorkspace": "워크스페이스를 중첩하지 못했습니다",
|
||||
"sidebarRowMissing": "Target no longer exists"
|
||||
"sidebarRowMissing": "대상이 더 이상 존재하지 않습니다"
|
||||
},
|
||||
"WorktreeMetaDialog": {
|
||||
"3db0a2a593": "취소",
|
||||
@@ -4307,7 +4325,7 @@
|
||||
"empty": "일치하는 적격 워크트리가 없습니다."
|
||||
},
|
||||
"WorktreeCardStatusSlot": {
|
||||
"branchIdentity": "Branch"
|
||||
"branchIdentity": "브랜치"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
@@ -4444,23 +4462,23 @@
|
||||
"f97b986b7f": "wsl"
|
||||
},
|
||||
"AgentSkillSetupPanel": {
|
||||
"0b810ec59f": "Press Enter to run the command.",
|
||||
"0b810ec59f": "Enter를 눌러 명령을 실행하세요.",
|
||||
"ed197f59a2": "명령 복사",
|
||||
"817d3f9f18": "Copy command",
|
||||
"817d3f9f18": "명령 복사",
|
||||
"5289300939": "설치되지 않음",
|
||||
"9fcebceb2a": "설치됨",
|
||||
"68a468752e": "확인 중...",
|
||||
"c689392435": "재확인",
|
||||
"a31e2aa302": "Failed to copy command.",
|
||||
"378ad26865": "Copied command.",
|
||||
"copiedCommand": "Copied command.",
|
||||
"failedToCopyCommand": "Failed to copy command.",
|
||||
"copyCommandAria": "Copy command",
|
||||
"runCommandDescription": "Press Enter to run the command."
|
||||
"a31e2aa302": "명령 복사 실패.",
|
||||
"378ad26865": "명령 복사됨.",
|
||||
"copiedCommand": "명령 복사됨.",
|
||||
"failedToCopyCommand": "명령 복사 실패.",
|
||||
"copyCommandAria": "명령 복사",
|
||||
"runCommandDescription": "Enter를 눌러 명령을 실행하세요."
|
||||
},
|
||||
"AgentsPane": {
|
||||
"d83834f5e6": "설치된 agents 감지 중…",
|
||||
"024bd95089": "agents",
|
||||
"024bd95089": "에이전트",
|
||||
"e8da2af684": "설치 가능",
|
||||
"ed3e110e61": "감지됨",
|
||||
"02e0143be5": "설치됨",
|
||||
@@ -4488,17 +4506,17 @@
|
||||
"24e032fa34": "기본",
|
||||
"5f986a9b92": "기본값으로 설정",
|
||||
"d7625cf8b2": "기본 agent",
|
||||
"cfb3f35775": "Arguments",
|
||||
"cfb3f35775": "인수",
|
||||
"6f99bf5dd0": "기본 인수 없음",
|
||||
"8fbe1f37c1": "Environment",
|
||||
"8fbe1f37c1": "환경",
|
||||
"2d133152fa": "기본 환경 없음",
|
||||
"agentPermissions": "Agent 권한",
|
||||
"agentPermissionsInfo": "Agent 권한 정보",
|
||||
"agentPermissionsTooltip": "Custom agent arguments stay unchanged when switching modes.",
|
||||
"agentPermissionsDescription": "Orca가 agents를 실행할 때 권한 프롬프트를 줄일지, 수동 확인을 유지할지 선택합니다.",
|
||||
"agentPermissionsYolo": "Yolo",
|
||||
"agentPermissionsManual": "Manual",
|
||||
"3f1bdf3cb4": "Environment text is too large to parse safely."
|
||||
"agentPermissionsManual": "수동",
|
||||
"3f1bdf3cb4": "환경 텍스트가 너무 커서 안전하게 파싱할 수 없습니다."
|
||||
},
|
||||
"AppIconSelector": {
|
||||
"d5a112dc9b": "다음 아이콘",
|
||||
@@ -4644,7 +4662,7 @@
|
||||
"c0f85056d9": "이 Orca 서버의 브라우저 프로필입니다.",
|
||||
"86b7c83fee": "이 컴퓨터",
|
||||
"6480776a03": "선택한 호스트의 브라우저 프로필입니다.",
|
||||
"5e19a692f7": "Host"
|
||||
"5e19a692f7": "호스트"
|
||||
},
|
||||
"BrowserProfileRow": {
|
||||
"8e636cae25": "프로필 '{{value0}}'이(가) 삭제되었습니다.",
|
||||
@@ -4926,7 +4944,11 @@
|
||||
"70bb30feb1": "잠시 후에 편집기 및 편집 가능한 diff 변경 사항을 자동으로 저장합니다.",
|
||||
"0df2e4fd12": "자동 저장 파일",
|
||||
"d21136d9ef": "Orca가 파일 편집을 유지하는 방법을 구성합니다.",
|
||||
"45c6e85c4d": "편집기"
|
||||
"45c6e85c4d": "편집기",
|
||||
"8f1afdfbd8": "Diff Word Wrap",
|
||||
"4aa4d9fb73": "Wrap long lines in diff editors instead of requiring horizontal scrolling.",
|
||||
"bf16ef0af2": "Off",
|
||||
"3f6892f307": "On"
|
||||
},
|
||||
"AdvancedNetworkSettingsSection": {
|
||||
"3e431564b5": "로컬호스트, 127.0.0.1, *.internal",
|
||||
@@ -5456,7 +5478,7 @@
|
||||
"3edf3deaf8": "'{{value0}}'을 삭제하시겠습니까?",
|
||||
"9fcfc29519": "삽입",
|
||||
"9b3e338d62": "입력",
|
||||
"4ccc63da87": "Agent",
|
||||
"4ccc63da87": "에이전트",
|
||||
"0252ddd578": "명령 텍스트 없음",
|
||||
"7784912ed6": "repo",
|
||||
"2bb9e38e93": "제목 없음",
|
||||
@@ -5608,7 +5630,7 @@
|
||||
"hostSetupStateError": "오류",
|
||||
"hostSetupStateUnsupported": "지원되지 않음",
|
||||
"setupPathPending": "경로 대기 중",
|
||||
"openSetup": "Open",
|
||||
"openSetup": "열기",
|
||||
"removeSetup": "제거",
|
||||
"hostSetupBlockedVersion": "Orca 서버 버전이 호환되지 않습니다.",
|
||||
"hostSetupMissingCapability": "프로젝트를 설정하려면 이 호스트의 Orca를 업데이트하세요.",
|
||||
@@ -5634,7 +5656,7 @@
|
||||
"addProjectHost": "프로젝트를 호스트에 추가",
|
||||
"addProjectHostHelp": "이 프로젝트를 추가로 사용할 위치를 선택하세요.",
|
||||
"closeHostSetup": "닫기",
|
||||
"setupHostLabel": "Host",
|
||||
"setupHostLabel": "호스트",
|
||||
"browseFolder": "폴더 찾아보기",
|
||||
"browseFolderHelp": "이 호스트의 기존 체크아웃 또는 폴더를 사용합니다.",
|
||||
"otherWaysToAdd": "다른 추가 방법",
|
||||
@@ -5653,7 +5675,7 @@
|
||||
"7a3a8e431d": "CLI 인수",
|
||||
"2b2f38652b": "사용자 정의 명령",
|
||||
"0ffb081b3a": "기본 agent 사용",
|
||||
"f4310cf63f": "Agent",
|
||||
"f4310cf63f": "에이전트",
|
||||
"1cd88d470a": "사용자 정의",
|
||||
"403876bb48": "글로벌 사용",
|
||||
"f0aa2cfaea": "액션 레시피"
|
||||
@@ -5848,7 +5870,7 @@
|
||||
"6742c7932c": "전역 또는 프로젝트별로 적용되는 저장된 terminal 명령입니다.",
|
||||
"13d4fe30ad": "빠른 명령어",
|
||||
"b79b5b31e9": "셸, 렌더러, 세션, terminal 동작.",
|
||||
"3de4bbb841": "Terminal",
|
||||
"3de4bbb841": "터미널",
|
||||
"dd72ed437a": "작업 페이지와 사이드바에 표시할 작업 제공자를 선택합니다.",
|
||||
"11faa2f7dd": "작업 소스",
|
||||
"cfa34f4465": "브랜치 이름 지정, 기본 참조, 속성 및 Git AI Author.",
|
||||
@@ -5868,7 +5890,7 @@
|
||||
"21f09426ea": "선택 사항. Orca는 기존 공급자 로그인과 함께 작동합니다. Orca가 계정 간 전환을 돕도록 원하는 경우에만 계정을 추가하세요.",
|
||||
"ad6c529693": "AI 제공업체 계정",
|
||||
"ec1ba547f7": "AI agents를 관리하고, 기본값을 설정하고, 명령을 사용자 정의하세요.",
|
||||
"8afa676615": "Agents",
|
||||
"8afa676615": "에이전트",
|
||||
"add3b97ee6": "\"",
|
||||
"3c88ec55d6": "\"에 대한 설정을 찾을 수 없습니다.",
|
||||
"c7ad095d96": "설정 로드 중...",
|
||||
@@ -5900,9 +5922,9 @@
|
||||
"cb330ef7f8": "{{value0}} 중",
|
||||
"c822571b2e": "\"{{value0}}\"과(와) 일치",
|
||||
"3119c012a5": "문자열",
|
||||
"builtin_themes": "Built-in",
|
||||
"builtin_themes": "기본 제공",
|
||||
"imported_from": "{{value0}}에서 가져옴",
|
||||
"imported_themes": "Imported",
|
||||
"imported_themes": "가져옴",
|
||||
"search_terminal_themes": "terminal 테마 검색"
|
||||
},
|
||||
"SettingsSidebar": {
|
||||
@@ -5944,7 +5966,7 @@
|
||||
"3c0fac059a": "terminal에 키보드 포커스가 있는 동안에도 계속 실행됩니다.",
|
||||
"25b0004fbf": "Terminal 활성",
|
||||
"781cb74d22": "terminal 패널에서 실행됩니다.",
|
||||
"cb02e00202": "Terminal",
|
||||
"cb02e00202": "터미널",
|
||||
"d8c988dab4": "~/.orca/keybindings.json"
|
||||
},
|
||||
"SourceControlAiActionRecipeDefaults": {
|
||||
@@ -6390,7 +6412,7 @@
|
||||
"f9a9cf6928": "음성 받아쓰기를 활성화하려면 마이크 권한이 필요합니다.",
|
||||
"1eac933202": "macOS 개인 정보 보호 및 보안을 열었습니다. 액세스 권한을 부여한 후 받아쓰기를 다시 활성화하세요.",
|
||||
"cd9fe37556": "마이크 권한이 부여되었습니다.",
|
||||
"6fa734ed95": "Delete {{value0}}"
|
||||
"6fa734ed95": "{{value0}} 삭제"
|
||||
},
|
||||
"WorktreeSymlinksSection": {
|
||||
"1c1e35b219": "{{value0}} 제거",
|
||||
@@ -6516,7 +6538,7 @@
|
||||
"cbdd7f3b9e": "설치된 agents가 이 장치에서 감지되는지 아니면 WSL에서 감지되는지 선택합니다.",
|
||||
"ef804b7337": "Agent 위치",
|
||||
"01926b9d8c": "AI 코딩 agents, 기본 agents 및 명령 재정의를 구성합니다.",
|
||||
"bb9ad95777": "Agents",
|
||||
"bb9ad95777": "에이전트",
|
||||
"d8f3a8b8a0": "기본값",
|
||||
"167daeb5e9": "명령",
|
||||
"be59907510": "재정의",
|
||||
@@ -6628,7 +6650,7 @@
|
||||
"cf409b6c4d": "포트",
|
||||
"cb1cc62cf8": "스페이스",
|
||||
"90bdc043ea": "디스크",
|
||||
"96b4fb0064": "terminal",
|
||||
"96b4fb0064": "터미널",
|
||||
"4ddbde4999": "CPU",
|
||||
"4355f18ac6": "메모리",
|
||||
"9c4d5f0894": "관리자",
|
||||
@@ -6927,7 +6949,7 @@
|
||||
"edc49480a1": "패널",
|
||||
"268e99d957": "강조 표시",
|
||||
"01567f19ca": "주목",
|
||||
"9bb3bd5098": "terminal",
|
||||
"9bb3bd5098": "터미널",
|
||||
"11877246fc": "terminal 벨 및 agent 완료 이벤트에 대한 지속적인 패널 강조 표시입니다.",
|
||||
"9e4ddf776d": "Terminal 주의",
|
||||
"fe5688b761": "사이드바",
|
||||
@@ -6935,7 +6957,7 @@
|
||||
"d01b3882ba": "알림",
|
||||
"244a0ecd3d": "활동",
|
||||
"92a9357d1f": "agents 보기",
|
||||
"fa72e71f05": "agents",
|
||||
"fa72e71f05": "에이전트",
|
||||
"4d63251595": "agent 완료 및 차단 상태에 대한 스레드 왼쪽 사이드바 피드입니다.",
|
||||
"ccc5548ac5": "Agents 보기",
|
||||
"9af7a518db": "캐릭터",
|
||||
@@ -6949,11 +6971,11 @@
|
||||
"87d99e634b": "애완 동물",
|
||||
"agentHibernation": {
|
||||
"agent": "agent",
|
||||
"agents": "agents",
|
||||
"agents": "에이전트",
|
||||
"description": "설정된 유휴 시간이 지난 백그라운드 agent terminals을 중지하고, 다시 열 때 지원되는 세션을 재개합니다. Agent 최대 절전은 Orca에서 시작한 agent의 시작 옵션을 보존합니다. 수동으로 시작한 agent는 현재 Orca 기본값으로 재개될 수 있습니다.",
|
||||
"minutes": "분",
|
||||
"sleep": "절전",
|
||||
"terminal": "terminal",
|
||||
"terminal": "터미널",
|
||||
"title": "Agent 최대 절전"
|
||||
},
|
||||
"newWorktreeCardStyle": {
|
||||
@@ -6980,7 +7002,7 @@
|
||||
"156ffeee08": "메모",
|
||||
"884e5e6132": "markdown",
|
||||
"49db74a92d": "브라우저",
|
||||
"6410fe83d8": "terminal",
|
||||
"6410fe83d8": "터미널",
|
||||
"2b5efa55c9": "글로벌",
|
||||
"ebeedb2f6a": "빠른 terminal",
|
||||
"6f183fa1b9": "플로팅 terminal",
|
||||
@@ -7029,13 +7051,13 @@
|
||||
"79ff46776e": "앱 업데이트를 확인하고 최신 Orca 버전을 설치하세요.",
|
||||
"e15af4eb64": "업데이트 확인",
|
||||
"6382fe9724": "npx",
|
||||
"baa263d6d8": "agents",
|
||||
"baa263d6d8": "에이전트",
|
||||
"bda108e66c": "스킬",
|
||||
"244e3fb4c8": "agents가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.",
|
||||
"2d9f7b42df": "Agent 스킬",
|
||||
"0a00691c06": "셸 명령",
|
||||
"dbeb1f348e": "명령",
|
||||
"88d3df9ce9": "terminal",
|
||||
"88d3df9ce9": "터미널",
|
||||
"fb4f338a3d": "경로",
|
||||
"924a660a78": "CLI",
|
||||
"ca529079bf": "Orca CLI 명령을 등록하거나 제거합니다.",
|
||||
@@ -7321,7 +7343,7 @@
|
||||
"fadcbfdd99": "맞춤",
|
||||
"ad08035c5f": "핸드폰",
|
||||
"6cd2bfdb0e": "복원",
|
||||
"b34ad5b3a7": "terminal",
|
||||
"b34ad5b3a7": "터미널",
|
||||
"6db86f445f": "모바일",
|
||||
"707fc78052": "앱을 닫거나 다른 곳으로 전환한 후 모바일에서 보고 있던 terminals에 어떤 일이 발생하는지 선택하세요.",
|
||||
"1e711aca11": "모바일 앱을 나갈 때",
|
||||
@@ -7399,7 +7421,7 @@
|
||||
"96562a72c6": "집중하는 동안 억제",
|
||||
"a2ab73b325": "주목",
|
||||
"ae0487f8fd": "벨",
|
||||
"c638ae989d": "terminal",
|
||||
"c638ae989d": "터미널",
|
||||
"d3f1c48677": "백그라운드 terminal이 벨 문자를 내보낼 때 이를 알립니다.",
|
||||
"a5edee1d99": "Terminal 벨",
|
||||
"193e1f107c": "작업",
|
||||
@@ -7429,7 +7451,7 @@
|
||||
"eee028ae14": "디스패치",
|
||||
"9a5ebdca31": "메시징",
|
||||
"91fc8ab7e5": "코디네이션",
|
||||
"13ba5c6cbd": "agents",
|
||||
"13ba5c6cbd": "에이전트",
|
||||
"d86705ba77": "멀티 agent",
|
||||
"a7f76b4ca7": "오케스트레이션",
|
||||
"e05ff36753": "메시징, 작업 DAG, 디스패치 및 결정 게이트를 통해 여러 코딩 agents를 조정합니다.",
|
||||
@@ -7482,7 +7504,7 @@
|
||||
"8bf43c2dad": "글로벌",
|
||||
"a26ecdb77b": "스니펫",
|
||||
"d07d130849": "단축키",
|
||||
"0073cf8ce9": "terminal",
|
||||
"0073cf8ce9": "터미널",
|
||||
"cfffa6cdb6": "명령",
|
||||
"fecb031823": "명령",
|
||||
"236d4cfac8": "빠른",
|
||||
@@ -7640,7 +7662,7 @@
|
||||
"0f8cb15582": "agent",
|
||||
"f1adebbe8c": "셸",
|
||||
"7f1b38f59a": "TUI",
|
||||
"7e3fc707aa": "terminal",
|
||||
"7e3fc707aa": "터미널",
|
||||
"0ecba9aa5f": "키보드",
|
||||
"ebd7d81e1d": "단축키가 겹칠 때 Orca와 포커스된 terminal 중 어느 쪽이 우선할지 선택합니다.",
|
||||
"f052906167": "Terminal의 단축키"
|
||||
@@ -7761,7 +7783,7 @@
|
||||
"d4daf4f612": "녹이다",
|
||||
"88561b3499": "정지됨",
|
||||
"0a05629060": "복구",
|
||||
"f66a7cf715": "terminal",
|
||||
"f66a7cf715": "터미널",
|
||||
"6892fb1019": "다시 시작",
|
||||
"cde233f5da": "스크롤백",
|
||||
"3982d88725": "기록",
|
||||
@@ -7907,7 +7929,7 @@
|
||||
"fcfa53920b": "붙여넣기",
|
||||
"e55186fe2b": "오른쪽 클릭",
|
||||
"28ff08ed35": "창",
|
||||
"e7d2793b03": "terminal",
|
||||
"e7d2793b03": "터미널",
|
||||
"8ba875c132": "Windows에서는 마우스 오른쪽 버튼을 클릭하여 클립보드를 terminal에 붙여넣습니다. 컨텍스트 메뉴를 열려면 Ctrl+오른쪽 클릭을 사용하세요.",
|
||||
"f0b8448570": "붙여넣으려면 마우스 오른쪽 버튼을 클릭하세요.",
|
||||
"04994f6929": "기본값",
|
||||
@@ -8775,7 +8797,7 @@
|
||||
"fe119187bb": "--model sonnet",
|
||||
"bc8dc39f4b": "CLI 인수",
|
||||
"b99c33cec5": "설정",
|
||||
"15c5d85706": "Agent",
|
||||
"15c5d85706": "에이전트",
|
||||
"3e8f21954f": "오류",
|
||||
"74168d7ada": "idle",
|
||||
"1d47db9bf0": "활성화된 agents가 없습니다.",
|
||||
@@ -8806,7 +8828,7 @@
|
||||
"4eab815004": "CLI 인수",
|
||||
"914c8f6ac2": "사용자 정의 명령",
|
||||
"cce2cbd01d": "agent 선택",
|
||||
"9c14186dd2": "Agent"
|
||||
"9c14186dd2": "에이전트"
|
||||
},
|
||||
"activity": {
|
||||
"bar": {
|
||||
@@ -8900,7 +8922,7 @@
|
||||
"b8c4e2a1f7": "전체 로그 보기",
|
||||
"f8a2c91d04": "Queue for agent",
|
||||
"b4e8a1c902": "Queued",
|
||||
"7c1f0a2b11": "Open",
|
||||
"7c1f0a2b11": "열기",
|
||||
"e8b4c1a903": "Resolved · {{value0}}",
|
||||
"c3a8e5d710": "Needs review · {{value0}}",
|
||||
"a7f0c7e8d1": "Queue",
|
||||
@@ -8969,7 +8991,7 @@
|
||||
"6306b48afd": "소스 제어",
|
||||
"ef182dcb12": "검색",
|
||||
"fc3095d2ed": "탐색기",
|
||||
"aiVaultSessionHistory": "Agents",
|
||||
"aiVaultSessionHistory": "에이전트",
|
||||
"folderWorkspaces": "연결된 워크트리",
|
||||
"parentPrChecks": "PR 검사"
|
||||
},
|
||||
@@ -8984,7 +9006,7 @@
|
||||
"542bf6a7e2": "기울임",
|
||||
"256300f8ea": "굵게",
|
||||
"87aff03d63": "전송 중...",
|
||||
"commentTooLarge": "Comment is too large to submit safely."
|
||||
"commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9147,10 +9169,10 @@
|
||||
"noSessionsMatchFilters": "현재 필터와 일치하는 세션이 없습니다",
|
||||
"sessionId": "세션 ID",
|
||||
"logPath": "로그 경로",
|
||||
"agents": "Agents",
|
||||
"agents": "에이전트",
|
||||
"sessionsShownCompact": "{{value0}} 표시됨",
|
||||
"originalPaneUnavailable": "Original pane is no longer available.",
|
||||
"worktreeUnavailable": "Worktree is no longer available."
|
||||
"originalPaneUnavailable": "원래 창은 더 이상 사용할 수 없습니다.",
|
||||
"worktreeUnavailable": "워크트리는 더 이상 사용할 수 없습니다."
|
||||
},
|
||||
"AiVaultPanelControls": {
|
||||
"scanningSessions": "세션 스캔 중",
|
||||
@@ -9165,13 +9187,13 @@
|
||||
"allSessions": "모든 세션",
|
||||
"viewOptionsAriaLabel": "세션 기록 보기 옵션",
|
||||
"viewOptions": "보기 옵션",
|
||||
"agents": "Agents",
|
||||
"agents": "에이전트",
|
||||
"sort": "정렬",
|
||||
"lastUpdated": "마지막 업데이트",
|
||||
"created": "생성됨",
|
||||
"group": "그룹",
|
||||
"folder": "폴더",
|
||||
"agent": "Agent",
|
||||
"agent": "에이전트",
|
||||
"resetView": "보기 재설정",
|
||||
"hideEmptySessions": "빈 세션 숨기기",
|
||||
"workspaceScope": "워크스페이스",
|
||||
@@ -9353,8 +9375,8 @@
|
||||
"3c5a593bc8": "웹",
|
||||
"477b28c948": "데이터베이스",
|
||||
"787490e9bd": "패키지",
|
||||
"07012dc113": "Agent",
|
||||
"3eba7387ab": "Terminal",
|
||||
"07012dc113": "에이전트",
|
||||
"3eba7387ab": "터미널",
|
||||
"65b437c381": "코드",
|
||||
"bed2674f9d": "폴더"
|
||||
}
|
||||
@@ -9828,7 +9850,7 @@
|
||||
"8b14ba6c17": "새 브라우저 탭",
|
||||
"b085fb58b5": "이 파일에는 저장되지 않은 변경사항이 있습니다.",
|
||||
"5ddc688c52": "'{{value0}}'에 저장되지 않은 변경사항이 있습니다. 닫기 전에 저장하시겠습니까?",
|
||||
"25d7817f79": "terminal"
|
||||
"25d7817f79": "터미널"
|
||||
},
|
||||
"FloatingTerminalToggleButton": {
|
||||
"3b04b065b5": "플로팅 워크스페이스 표시",
|
||||
@@ -9871,7 +9893,7 @@
|
||||
"be8917699e": "모델",
|
||||
"4d9b6d84df": "지원되지 않습니다. Claude, Codex 또는 Custom을 선택하세요.",
|
||||
"560d4feb00": "Custom",
|
||||
"29d119fe95": "Agent",
|
||||
"29d119fe95": "에이전트",
|
||||
"f9382b48a1": "AI author 활성화",
|
||||
"1c0cb4fabb": "AI 작성자",
|
||||
"bd14e9c42a": "구성되지 않음",
|
||||
@@ -10243,7 +10265,7 @@
|
||||
"step": {
|
||||
"0f47ff17c6": "변경",
|
||||
"5538eb6743": "완료",
|
||||
"open_step": "Open",
|
||||
"open_step": "열기",
|
||||
"close_step": "닫기"
|
||||
}
|
||||
}
|
||||
@@ -10485,7 +10507,9 @@
|
||||
"8368d256ec": "결합 브랜치",
|
||||
"8f68ad9ca9": "{{value0}} AI {{value1}} 표시",
|
||||
"724a13568d": "{{value0}}에서",
|
||||
"6094135eec": "대 {{value0}}"
|
||||
"6094135eec": "대 {{value0}}",
|
||||
"a4420ca1f7": "Wrap On",
|
||||
"dde325ddfe": "Wrap Off"
|
||||
},
|
||||
"ConflictComponents": {
|
||||
"f338288514": "충돌 콘텐츠 로드 중...",
|
||||
@@ -10573,7 +10597,8 @@
|
||||
"3e0ce48c24": "PDF로 내보내기",
|
||||
"561251019a": "추가 작업",
|
||||
"8c8b7f5ff5": "머리말 표시",
|
||||
"10c39d58c1": "머리말 숨기기"
|
||||
"10c39d58c1": "머리말 숨기기",
|
||||
"1eef809708": "Word Wrap"
|
||||
},
|
||||
"EditorPanelShell": {
|
||||
"e2c4dec350": "편집기 로드 중..."
|
||||
@@ -10682,7 +10707,7 @@
|
||||
"MonacoEditor": {
|
||||
"68cb83f4a7": "선택한 텍스트에 메모 추가",
|
||||
"fd68ae03b3": "파일에서 검색",
|
||||
"largePasteTooLarge": "Paste is too large."
|
||||
"largePasteTooLarge": "붙여넣기 내용이 너무 큽니다."
|
||||
},
|
||||
"MonacoGutterContextMenu": {
|
||||
"7b57b1b468": "원격 URL 복사",
|
||||
@@ -10965,7 +10990,7 @@
|
||||
}
|
||||
},
|
||||
"richMarkdownLargeTextPaste": {
|
||||
"tooLarge": "Paste is too large."
|
||||
"tooLarge": "붙여넣기 내용이 너무 큽니다."
|
||||
}
|
||||
},
|
||||
"diff": {
|
||||
@@ -10983,7 +11008,7 @@
|
||||
"2b3ce6d394": "취소",
|
||||
"e05063cfc1": "라인 {{value0}}",
|
||||
"c845170b3b": "라인 {{value0}}-{{value1}}",
|
||||
"commentTooLarge": "Comment is too large to submit safely."
|
||||
"commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다."
|
||||
},
|
||||
"useDiffCommentDecorator": {
|
||||
"995fa28b50": "이 메모"
|
||||
@@ -11112,8 +11137,8 @@
|
||||
"palette": {
|
||||
"project": {
|
||||
"results": {
|
||||
"repoGroup": "Repo group",
|
||||
"project": "Project"
|
||||
"repoGroup": "리포지토리 그룹",
|
||||
"project": "프로젝트"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11229,7 +11254,7 @@
|
||||
"5c3d530a68": "Downloaded",
|
||||
"4bb7424d6b": "Canceled",
|
||||
"6e776f9ef9": "Download failed",
|
||||
"756bfc25c9": "Open",
|
||||
"756bfc25c9": "열기",
|
||||
"09a9489aa5": "Show"
|
||||
},
|
||||
"BrowserToolbarMenu": {
|
||||
@@ -11297,7 +11322,7 @@
|
||||
"449fc83bf7": "토큰",
|
||||
"401f40ae79": "예상 경비",
|
||||
"a7c312430d": "마지막 실행",
|
||||
"2df8970cd5": "Agent",
|
||||
"2df8970cd5": "에이전트",
|
||||
"e353ab9516": "사전 확인",
|
||||
"620b22145e": "유예 시간",
|
||||
"15ea446b93": "세션",
|
||||
@@ -11320,7 +11345,7 @@
|
||||
},
|
||||
"AutomationEditorDialog": {
|
||||
"fb1896a5e7": "취소",
|
||||
"57b722cbba": "Agent",
|
||||
"57b722cbba": "에이전트",
|
||||
"6ff66f9012": "새로운 실행",
|
||||
"a2e688226d": "워크트리",
|
||||
"6f9610e667": "작업 트리는 선택한 워크스페이스에서 실행됩니다. 새로 실행하면 매번 선택한 브랜치에서 새 워크스페이스가 생성됩니다.",
|
||||
@@ -11596,7 +11621,7 @@
|
||||
},
|
||||
"AgentSettingsDialog": {
|
||||
"50cdb57c03": "AI agents를 관리하고, 기본값을 설정하고, 명령을 사용자 정의하세요.",
|
||||
"fc0268e4ed": "Agents"
|
||||
"fc0268e4ed": "에이전트"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
@@ -11614,7 +11639,7 @@
|
||||
"a472a14700": "추가 옵션",
|
||||
"db8a1878b5": "스레드 목록 옵션",
|
||||
"d1a88df9a8": "읽지 않은 스레드만 표시",
|
||||
"f6396e1f85": "Agent",
|
||||
"f6396e1f85": "에이전트",
|
||||
"b29191b3e0": "워크트리",
|
||||
"8c3b621ddf": "프로젝트",
|
||||
"4a3986b200": "상태",
|
||||
@@ -11629,7 +11654,7 @@
|
||||
},
|
||||
"ActivityTitlebarControls": {
|
||||
"f915168c8e": "읽지 않음",
|
||||
"d6a8de3934": "agents",
|
||||
"d6a8de3934": "에이전트",
|
||||
"dc708f3eff": "agents 닫기"
|
||||
}
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -53,7 +53,9 @@ function buildSurfaceVariables(args: {
|
||||
}): LeftSidebarStyleVariables {
|
||||
const { background, foreground, overrideTextTokens = false } = args
|
||||
const accent = `color-mix(in srgb, ${foreground} 9%, ${background})`
|
||||
const border = `color-mix(in srgb, ${foreground} 14%, ${background})`
|
||||
// 7% keeps the sidebar divider at the same prominence as the global --border
|
||||
// (7% in dark mode) so it reads like the rest of the UI; 14% rendered brighter (#5906).
|
||||
const border = `color-mix(in srgb, ${foreground} 7%, ${background})`
|
||||
const ring = `color-mix(in srgb, ${foreground} 44%, ${background})`
|
||||
const vars: LeftSidebarStyleVariables = {
|
||||
'--worktree-sidebar': background,
|
||||
@@ -81,7 +83,8 @@ function buildSurfaceVariables(args: {
|
||||
vars['--accent-foreground'] = foreground
|
||||
vars['--muted'] = `color-mix(in srgb, ${foreground} 7%, ${background})`
|
||||
vars['--muted-foreground'] = `color-mix(in srgb, ${foreground} 62%, ${background})`
|
||||
vars['--border'] = `color-mix(in srgb, ${foreground} 14%, ${background})`
|
||||
// Match the global --border (7%) so sidebar-scoped dividers aren't brighter (#5906).
|
||||
vars['--border'] = `color-mix(in srgb, ${foreground} 7%, ${background})`
|
||||
}
|
||||
return vars
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { SearchAddon } from '@xterm/addon-search'
|
||||
import { SerializeAddon } from '@xterm/addon-serialize'
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import type { ITerminalOptions } from '@xterm/xterm'
|
||||
import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
|
||||
import type { DragReorderCallbacks, DragReorderState } from './pane-drag-reorder'
|
||||
import { attachPaneDrag } from './pane-drag-pointer'
|
||||
import type { ManagedPaneInternal, PaneManagerOptions } from './pane-manager-types'
|
||||
import { buildDefaultTerminalOptions } from './pane-terminal-options'
|
||||
import { shouldFocusTerminalFromPanePointerDown } from './pane-pointer-focus'
|
||||
import { ENABLE_WEBGL_RENDERER } from './pane-webgl-renderer'
|
||||
|
||||
function getTerminalUrlOpenHint(): string {
|
||||
return navigator.userAgent.includes('Mac')
|
||||
? 'click to open or ⇧+click for system browser'
|
||||
: 'click to open or Shift+click for system browser'
|
||||
}
|
||||
|
||||
export function createPaneDOM(
|
||||
id: number,
|
||||
leafId: TerminalLeafId,
|
||||
options: PaneManagerOptions,
|
||||
dragState: DragReorderState,
|
||||
dragCallbacks: DragReorderCallbacks,
|
||||
onPointerDown: (id: number, options?: { focusTerminal?: boolean }) => void,
|
||||
onMouseEnter: (id: number, event: MouseEvent) => void
|
||||
): ManagedPaneInternal {
|
||||
const container = document.createElement('div')
|
||||
container.className = 'pane'
|
||||
container.dataset.paneId = String(id)
|
||||
container.dataset.leafId = leafId
|
||||
|
||||
// Why: CSS owns baseline xterm geometry so title offsets do not race safeFit().
|
||||
const xtermContainer = document.createElement('div')
|
||||
xtermContainer.className = 'xterm-container'
|
||||
container.appendChild(xtermContainer)
|
||||
|
||||
const userOpts = options.terminalOptions?.(id) ?? {}
|
||||
const terminalOpts: ITerminalOptions = {
|
||||
...buildDefaultTerminalOptions(),
|
||||
...userOpts
|
||||
}
|
||||
|
||||
const terminal = new Terminal(terminalOpts)
|
||||
const fitAddon = new FitAddon()
|
||||
const searchAddon = new SearchAddon()
|
||||
const unicode11Addon = new Unicode11Addon()
|
||||
const openLinkHint = getTerminalUrlOpenHint()
|
||||
|
||||
const linkTooltip = document.createElement('div')
|
||||
linkTooltip.className = 'pane-link-tooltip'
|
||||
linkTooltip.classList.add('xterm-hover')
|
||||
linkTooltip.style.cssText =
|
||||
'display:none;position:absolute;bottom:4px;left:8px;z-index:40;' +
|
||||
'padding:5px 8px;border-radius:4px;font-size:11px;font-family:inherit;' +
|
||||
'color:#a1a1aa;background:rgba(24,24,27,0.85);border:1px solid rgba(63,63,70,0.6);' +
|
||||
'pointer-events:none;max-width:80%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
|
||||
|
||||
const dragHandle = document.createElement('div')
|
||||
dragHandle.className = 'pane-drag-handle'
|
||||
container.appendChild(dragHandle)
|
||||
const paneDragCleanup = attachPaneDrag(dragHandle, id, dragState, dragCallbacks)
|
||||
|
||||
const webLinksAddon = new WebLinksAddon(
|
||||
options.onLinkClick ? (event, uri) => options.onLinkClick!(event, uri) : undefined,
|
||||
{
|
||||
hover: (_event, uri) => {
|
||||
if (uri) {
|
||||
linkTooltip.textContent = `${uri} (${openLinkHint})`
|
||||
linkTooltip.style.display = ''
|
||||
}
|
||||
},
|
||||
leave: () => {
|
||||
linkTooltip.style.display = 'none'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const panePointerDownHandler = (event: PointerEvent): void => {
|
||||
onPointerDown(id, {
|
||||
focusTerminal: shouldFocusTerminalFromPanePointerDown(event.target)
|
||||
})
|
||||
}
|
||||
const paneMouseEnterHandler = (event: MouseEvent): void => onMouseEnter(id, event)
|
||||
|
||||
const pane: ManagedPaneInternal = {
|
||||
id,
|
||||
leafId,
|
||||
stablePaneId: leafId,
|
||||
terminal,
|
||||
container,
|
||||
xtermContainer,
|
||||
linkTooltip,
|
||||
terminalTuiScrollSensitivity: options.terminalTuiScrollSensitivity,
|
||||
terminalGpuAcceleration: options.terminalGpuAcceleration ?? 'auto',
|
||||
gpuRenderingEnabled: ENABLE_WEBGL_RENDERER,
|
||||
webglAttachmentDeferred: false,
|
||||
webglDisabledAfterContextLoss: false,
|
||||
hasComplexScriptOutput: false,
|
||||
fitAddon,
|
||||
fitResizeObserver: null,
|
||||
pendingInitialFitRafId: null,
|
||||
pendingWebglRefreshRafId: null,
|
||||
pendingObservedFitRafId: null,
|
||||
searchAddon,
|
||||
serializeAddon: new SerializeAddon(),
|
||||
unicode11Addon,
|
||||
webLinksAddon,
|
||||
webglAddon: null,
|
||||
ligaturesAddon: null,
|
||||
panePointerDownHandler,
|
||||
paneMouseEnterHandler,
|
||||
paneDragCleanup,
|
||||
compositionHandler: null,
|
||||
focusClassSyncCleanup: null,
|
||||
terminalScrollIntentDisposable: null,
|
||||
pendingSplitScrollState: null,
|
||||
pendingSplitScrollRafIds: [],
|
||||
pendingSplitScrollTimerId: null,
|
||||
pendingSplitScrollBufferDisposable: null,
|
||||
debugLabel: options.debugLabel ?? null
|
||||
}
|
||||
|
||||
container.addEventListener('pointerdown', panePointerDownHandler)
|
||||
container.addEventListener('mouseenter', paneMouseEnterHandler)
|
||||
|
||||
return pane
|
||||
}
|
||||
@@ -1,179 +1,28 @@
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import type { ITerminalOptions } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
// Upstream packaging bug: @xterm/addon-ligatures declares `"main":
|
||||
// "lib/addon-ligatures.js"` but ships only the `.mjs` entry, so Vite fails to
|
||||
// resolve the bare import. Fixed locally via config/patches/@xterm__addon-ligatures*.
|
||||
// Tracking upstream: https://github.com/xtermjs/xterm.js/issues/5822 and
|
||||
// https://github.com/xtermjs/xterm.js/pull/5828 — drop the patch once that lands.
|
||||
import { LigaturesAddon } from '@xterm/addon-ligatures'
|
||||
import { SearchAddon } from '@xterm/addon-search'
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import { SerializeAddon } from '@xterm/addon-serialize'
|
||||
|
||||
import type { PaneManagerOptions, ManagedPaneInternal } from './pane-manager-types'
|
||||
import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
|
||||
import type { DragReorderState } from './pane-drag-reorder'
|
||||
import type { DragReorderCallbacks } from './pane-drag-reorder'
|
||||
import { attachPaneDrag } from './pane-drag-pointer'
|
||||
import type { ManagedPaneInternal } from './pane-manager-types'
|
||||
import { safeFit } from './pane-tree-ops'
|
||||
import {
|
||||
attachPaneFitResizeObserver,
|
||||
detachPaneFitResizeObserver
|
||||
} from './pane-fit-resize-observer'
|
||||
import { clearPendingSplitScrollRestore } from './pane-split-scroll'
|
||||
import { buildDefaultTerminalOptions } from './pane-terminal-options'
|
||||
import { activateOrcaTerminalUnicodeProvider } from './pane-terminal-unicode-provider'
|
||||
import { attachTerminalMouseWheelMultiplier } from './pane-terminal-mouse-wheel'
|
||||
import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent'
|
||||
import { attachDomRendererFocusClassSync } from './pane-dom-focus-class-sync'
|
||||
import {
|
||||
ENABLE_WEBGL_RENDERER,
|
||||
attachWebgl,
|
||||
cancelPendingWebglRefresh,
|
||||
disposeWebgl
|
||||
} from './pane-webgl-renderer'
|
||||
import { shouldFocusTerminalFromPanePointerDown } from './pane-pointer-focus'
|
||||
import { attachWebgl, cancelPendingWebglRefresh, disposeWebgl } from './pane-webgl-renderer'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pane creation, terminal open/close, addon management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getTerminalUrlOpenHint(): string {
|
||||
return navigator.userAgent.includes('Mac')
|
||||
? 'click to open or ⇧+click for system browser'
|
||||
: 'click to open or Shift+click for system browser'
|
||||
}
|
||||
|
||||
export function createPaneDOM(
|
||||
id: number,
|
||||
leafId: TerminalLeafId,
|
||||
options: PaneManagerOptions,
|
||||
dragState: DragReorderState,
|
||||
dragCallbacks: DragReorderCallbacks,
|
||||
onPointerDown: (id: number, options?: { focusTerminal?: boolean }) => void,
|
||||
onMouseEnter: (id: number, event: MouseEvent) => void
|
||||
): ManagedPaneInternal {
|
||||
// Create .pane container
|
||||
const container = document.createElement('div')
|
||||
container.className = 'pane'
|
||||
container.dataset.paneId = String(id)
|
||||
container.dataset.leafId = leafId
|
||||
|
||||
// Create .xterm-container — baseline layout (position, width, height, margin)
|
||||
// is CSS-driven (see main.css .xterm-container) so that the data-has-title
|
||||
// attribute override can shift the terminal down without racing safeFit().
|
||||
const xtermContainer = document.createElement('div')
|
||||
xtermContainer.className = 'xterm-container'
|
||||
container.appendChild(xtermContainer)
|
||||
|
||||
// Build terminal options
|
||||
const userOpts = options.terminalOptions?.(id) ?? {}
|
||||
const terminalOpts: ITerminalOptions = {
|
||||
...buildDefaultTerminalOptions(),
|
||||
...userOpts
|
||||
}
|
||||
|
||||
const terminal = new Terminal(terminalOpts)
|
||||
const fitAddon = new FitAddon()
|
||||
const searchAddon = new SearchAddon()
|
||||
const unicode11Addon = new Unicode11Addon()
|
||||
const openLinkHint = getTerminalUrlOpenHint()
|
||||
|
||||
// URL tooltip element — Ghostty-style bottom-left hint on hover
|
||||
const linkTooltip = document.createElement('div')
|
||||
linkTooltip.className = 'pane-link-tooltip'
|
||||
linkTooltip.classList.add('xterm-hover')
|
||||
linkTooltip.style.cssText =
|
||||
'display:none;position:absolute;bottom:4px;left:8px;z-index:40;' +
|
||||
'padding:5px 8px;border-radius:4px;font-size:11px;font-family:inherit;' +
|
||||
'color:#a1a1aa;background:rgba(24,24,27,0.85);border:1px solid rgba(63,63,70,0.6);' +
|
||||
'pointer-events:none;max-width:80%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
|
||||
|
||||
// Ghostty-style drag handle — appears at top of pane on hover when 2+ panes
|
||||
const dragHandle = document.createElement('div')
|
||||
dragHandle.className = 'pane-drag-handle'
|
||||
container.appendChild(dragHandle)
|
||||
const paneDragCleanup = attachPaneDrag(dragHandle, id, dragState, dragCallbacks)
|
||||
|
||||
const webLinksAddon = new WebLinksAddon(
|
||||
options.onLinkClick ? (event, uri) => options.onLinkClick!(event, uri) : undefined,
|
||||
{
|
||||
hover: (_event, uri) => {
|
||||
if (uri) {
|
||||
linkTooltip.textContent = `${uri} (${openLinkHint})`
|
||||
linkTooltip.style.display = ''
|
||||
}
|
||||
},
|
||||
leave: () => {
|
||||
linkTooltip.style.display = 'none'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const serializeAddon = new SerializeAddon()
|
||||
|
||||
const panePointerDownHandler = (event: PointerEvent): void => {
|
||||
onPointerDown(id, {
|
||||
focusTerminal: shouldFocusTerminalFromPanePointerDown(event.target)
|
||||
})
|
||||
}
|
||||
|
||||
const paneMouseEnterHandler = (event: MouseEvent): void => {
|
||||
onMouseEnter(id, event)
|
||||
}
|
||||
|
||||
const pane: ManagedPaneInternal = {
|
||||
id,
|
||||
leafId,
|
||||
stablePaneId: leafId,
|
||||
terminal,
|
||||
container,
|
||||
xtermContainer,
|
||||
linkTooltip,
|
||||
terminalTuiScrollSensitivity: options.terminalTuiScrollSensitivity,
|
||||
terminalGpuAcceleration: options.terminalGpuAcceleration ?? 'auto',
|
||||
gpuRenderingEnabled: ENABLE_WEBGL_RENDERER,
|
||||
webglAttachmentDeferred: false,
|
||||
webglDisabledAfterContextLoss: false,
|
||||
hasComplexScriptOutput: false,
|
||||
fitAddon,
|
||||
fitResizeObserver: null,
|
||||
pendingInitialFitRafId: null,
|
||||
pendingWebglRefreshRafId: null,
|
||||
pendingObservedFitRafId: null,
|
||||
searchAddon,
|
||||
serializeAddon,
|
||||
unicode11Addon,
|
||||
webLinksAddon,
|
||||
webglAddon: null,
|
||||
ligaturesAddon: null,
|
||||
panePointerDownHandler,
|
||||
paneMouseEnterHandler,
|
||||
paneDragCleanup,
|
||||
compositionHandler: null,
|
||||
focusClassSyncCleanup: null,
|
||||
pendingSplitScrollState: null,
|
||||
pendingSplitScrollRafIds: [],
|
||||
pendingSplitScrollTimerId: null,
|
||||
pendingSplitScrollBufferDisposable: null,
|
||||
debugLabel: options.debugLabel ?? null
|
||||
}
|
||||
|
||||
// Focus handler: clicking a pane makes it active and explicitly focuses
|
||||
// the terminal. We must call focus: true here because after DOM reparenting
|
||||
// (e.g. splitPane moves the original pane into a flex container), xterm.js's
|
||||
// native click-to-focus on its internal textarea may not fire reliably.
|
||||
container.addEventListener('pointerdown', panePointerDownHandler)
|
||||
|
||||
// Focus-follows-mouse handler: when the setting is enabled, hovering a
|
||||
// pane makes it active. All gating (feature flag, drag-in-progress,
|
||||
// window focus, etc.) lives in the PaneManager callback — this layer
|
||||
// just forwards the event.
|
||||
container.addEventListener('mouseenter', paneMouseEnterHandler)
|
||||
|
||||
return pane
|
||||
}
|
||||
export { createPaneDOM } from './pane-dom-creation'
|
||||
|
||||
/** Open terminal into its container and load addons. Must be called after the container is in the DOM. */
|
||||
export function openTerminal(pane: ManagedPaneInternal): void {
|
||||
@@ -203,6 +52,11 @@ export function openTerminal(pane: ManagedPaneInternal): void {
|
||||
attachTerminalMouseWheelMultiplier(terminal, {
|
||||
getTuiMouseWheelMultiplier: terminalTuiScrollSensitivity
|
||||
})
|
||||
pane.terminalScrollIntentDisposable = attachTerminalScrollIntentTracking(
|
||||
terminal,
|
||||
xtermContainer,
|
||||
pane.leafId
|
||||
)
|
||||
|
||||
// Activate Orca's Unicode 11 width shim *before* any caller-driven write. CJK / emoji /
|
||||
// ZWJ codepoints get baked into the buffer at the active unicode version on
|
||||
@@ -343,6 +197,8 @@ export function disposePane(
|
||||
pane.paneDragCleanup = null
|
||||
pane.focusClassSyncCleanup?.()
|
||||
pane.focusClassSyncCleanup = null
|
||||
pane.terminalScrollIntentDisposable?.dispose()
|
||||
pane.terminalScrollIntentDisposable = null
|
||||
if (pane.compositionHandler) {
|
||||
pane.terminal.element?.removeEventListener('compositionstart', pane.compositionHandler, true)
|
||||
pane.compositionHandler = null
|
||||
|
||||
@@ -135,6 +135,8 @@ export type ManagedPaneInternal = {
|
||||
compositionHandler: (() => void) | null
|
||||
// Stored so disposePane() can remove DOM-renderer focus synchronization.
|
||||
focusClassSyncCleanup?: (() => void) | null
|
||||
// Stored so disposePane() can remove user-scroll intent listeners.
|
||||
terminalScrollIntentDisposable?: IDisposable | null
|
||||
// Why: splitPane reparents DOM; its delayed restore owns scroll until the
|
||||
// browser settles, so intermediate fits must not compete with it.
|
||||
pendingSplitScrollState: ScrollState | null
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
writeForegroundTerminalChunk,
|
||||
type ForegroundTerminalOutputTarget
|
||||
} from './pane-terminal-foreground-render-settle'
|
||||
import {
|
||||
captureTerminalWriteScrollIntent,
|
||||
enforceTerminalWriteScrollIntent
|
||||
} from './terminal-scroll-intent'
|
||||
|
||||
type TerminalOutputTarget = ForegroundTerminalOutputTarget
|
||||
|
||||
@@ -600,6 +604,38 @@ function hasDrainableBacklog(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
function writeBackgroundTerminalChunk(terminal: TerminalOutputTarget, data: string): void {
|
||||
const scrollIntent = captureTerminalWriteScrollIntent(terminal)
|
||||
if (!scrollIntent) {
|
||||
terminal.write(data)
|
||||
return
|
||||
}
|
||||
if (terminal.write.length < 2) {
|
||||
terminal.write(data)
|
||||
enforceTerminalWriteScrollIntent(terminal, scrollIntent)
|
||||
return
|
||||
}
|
||||
terminal.write(data, () => {
|
||||
enforceTerminalWriteScrollIntent(terminal, scrollIntent)
|
||||
})
|
||||
}
|
||||
|
||||
function writeForegroundTerminalChunkWithIntent(
|
||||
terminal: TerminalOutputTarget,
|
||||
data: string,
|
||||
options: {
|
||||
forceViewportRefresh: boolean
|
||||
followupViewportRefresh: boolean
|
||||
}
|
||||
): void {
|
||||
const scrollIntent = captureTerminalWriteScrollIntent(terminal)
|
||||
writeForegroundTerminalChunk(terminal, data, {
|
||||
forceViewportRefresh: options.forceViewportRefresh,
|
||||
followupViewportRefresh: options.followupViewportRefresh,
|
||||
onParsed: () => enforceTerminalWriteScrollIntent(terminal, scrollIntent)
|
||||
})
|
||||
}
|
||||
|
||||
function takeNextDrainableEntry(): QueueEntry | null {
|
||||
for (const entry of queuedByTerminal.values()) {
|
||||
if (!isEntryDrainable(entry)) {
|
||||
@@ -619,7 +655,7 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null
|
||||
try {
|
||||
entry.beforeWrite?.(queuedWrite.data)
|
||||
if (queuedWrite.foreground) {
|
||||
writeForegroundTerminalChunk(
|
||||
writeForegroundTerminalChunkWithIntent(
|
||||
entry.terminal,
|
||||
queuedWrite.stripTransientCursorShows
|
||||
? removeTransientCursorShowSequences(queuedWrite.data)
|
||||
@@ -630,7 +666,7 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null
|
||||
}
|
||||
)
|
||||
} else {
|
||||
entry.terminal.write(queuedWrite.data)
|
||||
writeBackgroundTerminalChunk(entry.terminal, queuedWrite.data)
|
||||
}
|
||||
} catch {
|
||||
// Why: pane.terminal.dispose() can race with a queued late-arriving PTY ping;
|
||||
@@ -823,12 +859,12 @@ export function writeTerminalOutput(
|
||||
debugState.foregroundWriteCount++
|
||||
}
|
||||
options.beforeWrite?.(data)
|
||||
writeForegroundTerminalChunk(
|
||||
writeForegroundTerminalChunkWithIntent(
|
||||
terminal,
|
||||
options.stripTransientCursorShows ? removeTransientCursorShowSequences(data) : data,
|
||||
{
|
||||
forceViewportRefresh: options.forceForegroundRefresh,
|
||||
followupViewportRefresh: options.followupForegroundRefresh
|
||||
forceViewportRefresh: options.forceForegroundRefresh === true,
|
||||
followupViewportRefresh: options.followupForegroundRefresh === true
|
||||
}
|
||||
)
|
||||
return
|
||||
@@ -896,7 +932,7 @@ export function flushTerminalOutput(
|
||||
try {
|
||||
entry.beforeWrite?.(queuedWrite.data)
|
||||
if (queuedWrite.foreground) {
|
||||
writeForegroundTerminalChunk(
|
||||
writeForegroundTerminalChunkWithIntent(
|
||||
terminal,
|
||||
queuedWrite.stripTransientCursorShows
|
||||
? removeTransientCursorShowSequences(queuedWrite.data)
|
||||
@@ -907,7 +943,7 @@ export function flushTerminalOutput(
|
||||
}
|
||||
)
|
||||
} else {
|
||||
terminal.write(queuedWrite.data)
|
||||
writeBackgroundTerminalChunk(terminal, queuedWrite.data)
|
||||
}
|
||||
} catch {
|
||||
// Why: pane.terminal.dispose() can race with a queued late-arriving PTY ping;
|
||||
|
||||
@@ -3,13 +3,15 @@ import type {
|
||||
DropZone,
|
||||
ManagedPane,
|
||||
ManagedPaneInternal,
|
||||
PaneStyleOptions,
|
||||
ScrollState
|
||||
PaneStyleOptions
|
||||
} from './pane-manager-types'
|
||||
import { createDivider, disposeDivider } from './pane-divider'
|
||||
import { getFitOverrideForPty } from './mobile-fit-overrides'
|
||||
import { disposeWebgl, attachWebgl } from './pane-webgl-renderer'
|
||||
import { captureScrollState, restoreScrollStateAfterLayout } from './pane-scroll'
|
||||
import {
|
||||
captureTerminalWriteScrollIntent,
|
||||
enforceTerminalWriteScrollIntent
|
||||
} from './terminal-scroll-intent'
|
||||
|
||||
export { captureScrollState, restoreScrollState } from './pane-scroll'
|
||||
|
||||
@@ -58,18 +60,18 @@ function canMeasurePaneForFit(pane: ManagedPane): boolean {
|
||||
return dims.cols >= MIN_PANE_FIT_COLS && dims.rows >= MIN_PANE_FIT_ROWS
|
||||
}
|
||||
|
||||
function captureScrollStateForFit(pane: ManagedPane): ScrollState | null {
|
||||
function canPreserveScrollIntentForFit(pane: ManagedPane): boolean {
|
||||
// Why: split reparent has its own delayed restore; restoring here can fight that timer.
|
||||
return 'pendingSplitScrollState' in pane && (pane as ManagedPaneInternal).pendingSplitScrollState
|
||||
? null
|
||||
: captureScrollState(pane.terminal)
|
||||
return !(
|
||||
'pendingSplitScrollState' in pane && (pane as ManagedPaneInternal).pendingSplitScrollState
|
||||
)
|
||||
}
|
||||
|
||||
export function safeFit(pane: ManagedPane): void {
|
||||
if (!canMeasurePaneForFit(pane)) {
|
||||
return
|
||||
}
|
||||
let scrollState: ScrollState | null = null
|
||||
let scrollIntent = null as ReturnType<typeof captureTerminalWriteScrollIntent>
|
||||
let shouldRestoreScroll = false
|
||||
try {
|
||||
// Why: when a mobile client has resized this PTY to phone dimensions,
|
||||
@@ -81,8 +83,10 @@ export function safeFit(pane: ManagedPane): void {
|
||||
const override = ptyId ? getFitOverrideForPty(ptyId) : null
|
||||
if (override) {
|
||||
if (pane.terminal.cols !== override.cols || pane.terminal.rows !== override.rows) {
|
||||
scrollState = captureScrollStateForFit(pane)
|
||||
shouldRestoreScroll = true
|
||||
if (canPreserveScrollIntentForFit(pane)) {
|
||||
scrollIntent = captureTerminalWriteScrollIntent(pane.terminal)
|
||||
shouldRestoreScroll = true
|
||||
}
|
||||
pane.terminal.resize(override.cols, override.rows)
|
||||
}
|
||||
return
|
||||
@@ -95,15 +99,17 @@ export function safeFit(pane: ManagedPane): void {
|
||||
// churn, which was causing visible terminal blinking while resizing.
|
||||
return
|
||||
}
|
||||
scrollState = captureScrollStateForFit(pane)
|
||||
shouldRestoreScroll = true
|
||||
if (canPreserveScrollIntentForFit(pane)) {
|
||||
scrollIntent = captureTerminalWriteScrollIntent(pane.terminal)
|
||||
shouldRestoreScroll = true
|
||||
}
|
||||
pane.fitAddon.fit()
|
||||
} catch {
|
||||
// Container may not have dimensions yet
|
||||
} finally {
|
||||
if (shouldRestoreScroll && scrollState) {
|
||||
if (shouldRestoreScroll) {
|
||||
try {
|
||||
restoreScrollStateAfterLayout(pane.terminal, scrollState)
|
||||
enforceTerminalWriteScrollIntent(pane.terminal, scrollIntent)
|
||||
} catch {
|
||||
// Why: xterm can temporarily expose a terminal whose renderer has not
|
||||
// initialized dimensions yet during SSH reattach/layout. Fit is best-effort.
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
attachTerminalScrollIntentTracking,
|
||||
captureTerminalWriteScrollIntent,
|
||||
enforceTerminalCurrentScrollIntent,
|
||||
enforceTerminalWriteScrollIntent,
|
||||
getTerminalScrollIntentKind,
|
||||
markTerminalFollowOutput,
|
||||
markTerminalPinnedViewport,
|
||||
syncTerminalScrollIntentFromViewport,
|
||||
syncTerminalScrollIntentSoon
|
||||
} from './terminal-scroll-intent'
|
||||
|
||||
function createTerminal({
|
||||
viewportY,
|
||||
baseY,
|
||||
type = 'normal'
|
||||
}: {
|
||||
viewportY: number
|
||||
baseY: number
|
||||
type?: 'normal' | 'alternate'
|
||||
}) {
|
||||
const terminal = {
|
||||
buffer: {
|
||||
active: {
|
||||
type,
|
||||
viewportY,
|
||||
baseY
|
||||
}
|
||||
},
|
||||
scrollToBottom: vi.fn(() => {
|
||||
terminal.buffer.active.viewportY = terminal.buffer.active.baseY
|
||||
}),
|
||||
scrollToLine: vi.fn((line: number) => {
|
||||
terminal.buffer.active.viewportY = line
|
||||
})
|
||||
}
|
||||
return terminal
|
||||
}
|
||||
|
||||
class TestElement extends EventTarget {
|
||||
parentElement: TestElement | null = null
|
||||
readonly classList = {
|
||||
contains: (className: string): boolean => this.className.split(/\s+/).includes(className)
|
||||
}
|
||||
|
||||
constructor(public className = '') {
|
||||
super()
|
||||
}
|
||||
|
||||
append(child: TestElement): void {
|
||||
child.parentElement = this
|
||||
}
|
||||
|
||||
closest(selector: string): TestElement | null {
|
||||
if (!selector.startsWith('.')) {
|
||||
return null
|
||||
}
|
||||
const className = selector.slice(1)
|
||||
if (this.classList.contains(className)) {
|
||||
return this
|
||||
}
|
||||
return this.parentElement?.closest(selector) ?? null
|
||||
}
|
||||
|
||||
dispatchEvent(event: Event): boolean {
|
||||
if (!event.target) {
|
||||
Object.defineProperty(event, 'target', {
|
||||
configurable: true,
|
||||
value: this
|
||||
})
|
||||
}
|
||||
const result = super.dispatchEvent(event)
|
||||
if (event.bubbles && this.parentElement) {
|
||||
this.parentElement.dispatchEvent(event)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
describe('terminal scroll intent', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('infers followOutput when the viewport is at the bottom', () => {
|
||||
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
|
||||
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput')
|
||||
})
|
||||
|
||||
it('infers pinnedViewport when the viewport is above the bottom', () => {
|
||||
const terminal = createTerminal({ viewportY: 42, baseY: 100 })
|
||||
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
|
||||
})
|
||||
|
||||
it('preserves a pinned viewport after output moves xterm to bottom', () => {
|
||||
const terminal = createTerminal({ viewportY: 42, baseY: 100 })
|
||||
markTerminalPinnedViewport(terminal)
|
||||
const snapshot = captureTerminalWriteScrollIntent(terminal)
|
||||
|
||||
terminal.buffer.active.baseY = 125
|
||||
terminal.buffer.active.viewportY = 125
|
||||
enforceTerminalWriteScrollIntent(terminal, snapshot)
|
||||
|
||||
expect(terminal.scrollToLine).toHaveBeenCalledWith(42)
|
||||
expect(terminal.buffer.active.viewportY).toBe(42)
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
|
||||
})
|
||||
|
||||
it('follows output after output advances while following', () => {
|
||||
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
|
||||
markTerminalFollowOutput(terminal)
|
||||
const snapshot = captureTerminalWriteScrollIntent(terminal)
|
||||
|
||||
terminal.buffer.active.baseY = 125
|
||||
terminal.buffer.active.viewportY = 0
|
||||
enforceTerminalWriteScrollIntent(terminal, snapshot)
|
||||
|
||||
expect(terminal.scrollToBottom).toHaveBeenCalledTimes(1)
|
||||
expect(terminal.buffer.active.viewportY).toBe(125)
|
||||
})
|
||||
|
||||
it('does not preserve across buffer type changes', () => {
|
||||
const terminal = createTerminal({ viewportY: 42, baseY: 100 })
|
||||
markTerminalPinnedViewport(terminal)
|
||||
const snapshot = captureTerminalWriteScrollIntent(terminal)
|
||||
|
||||
terminal.buffer.active.type = 'alternate'
|
||||
terminal.buffer.active.viewportY = 0
|
||||
enforceTerminalWriteScrollIntent(terminal, snapshot)
|
||||
|
||||
expect(terminal.scrollToLine).not.toHaveBeenCalled()
|
||||
expect(terminal.buffer.active.viewportY).toBe(0)
|
||||
})
|
||||
|
||||
it('syncs intent from the current viewport after user scroll settles', () => {
|
||||
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
|
||||
|
||||
terminal.buffer.active.viewportY = 50
|
||||
syncTerminalScrollIntentFromViewport(terminal)
|
||||
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
|
||||
})
|
||||
|
||||
it('tracks upward wheel immediately and records the settled viewport', async () => {
|
||||
const frameCallbacks: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frameCallbacks.push(callback)
|
||||
return frameCallbacks.length
|
||||
})
|
||||
vi.stubGlobal('Element', TestElement)
|
||||
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
|
||||
const host = new TestElement() as unknown as HTMLElement
|
||||
const disposable = attachTerminalScrollIntentTracking(terminal, host)
|
||||
|
||||
const wheelUp = new Event('wheel') as WheelEvent
|
||||
Object.defineProperty(wheelUp, 'deltaY', { value: -10 })
|
||||
host.dispatchEvent(wheelUp)
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
|
||||
|
||||
terminal.buffer.active.viewportY = 80
|
||||
await Promise.resolve()
|
||||
terminal.buffer.active.viewportY = 0
|
||||
enforceTerminalCurrentScrollIntent(terminal)
|
||||
expect(terminal.scrollToLine).toHaveBeenLastCalledWith(80)
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('returns to followOutput after a downward wheel settles at the bottom', async () => {
|
||||
const frameCallbacks: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frameCallbacks.push(callback)
|
||||
return frameCallbacks.length
|
||||
})
|
||||
vi.stubGlobal('Element', TestElement)
|
||||
const terminal = createTerminal({ viewportY: 50, baseY: 100 })
|
||||
const host = new TestElement() as unknown as HTMLElement
|
||||
const disposable = attachTerminalScrollIntentTracking(terminal, host)
|
||||
|
||||
const wheelDown = new Event('wheel') as WheelEvent
|
||||
Object.defineProperty(wheelDown, 'deltaY', { value: 10 })
|
||||
host.dispatchEvent(wheelDown)
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
|
||||
|
||||
terminal.buffer.active.viewportY = 100
|
||||
await Promise.resolve()
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput')
|
||||
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('keeps sampling briefly after wheel so delayed xterm scroll updates win', async () => {
|
||||
const frameCallbacks: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frameCallbacks.push(callback)
|
||||
return frameCallbacks.length
|
||||
})
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('Element', TestElement)
|
||||
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
|
||||
const host = new TestElement() as unknown as HTMLElement
|
||||
const disposable = attachTerminalScrollIntentTracking(terminal, host)
|
||||
|
||||
const wheelUp = new Event('wheel') as WheelEvent
|
||||
Object.defineProperty(wheelUp, 'deltaY', { value: -10 })
|
||||
host.dispatchEvent(wheelUp)
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
|
||||
|
||||
await Promise.resolve()
|
||||
frameCallbacks.shift()?.(16)
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
|
||||
|
||||
terminal.buffer.active.viewportY = 76
|
||||
frameCallbacks.shift()?.(32)
|
||||
frameCallbacks.shift()?.(48)
|
||||
terminal.buffer.active.viewportY = 100
|
||||
enforceTerminalCurrentScrollIntent(terminal)
|
||||
expect(terminal.scrollToLine).toHaveBeenLastCalledWith(76)
|
||||
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('keeps a pane-keyed pinned viewport across a remounted empty terminal', () => {
|
||||
vi.stubGlobal('Element', TestElement)
|
||||
const firstTerminal = createTerminal({ viewportY: 76, baseY: 100 })
|
||||
const firstHost = new TestElement() as unknown as HTMLElement
|
||||
const firstDisposable = attachTerminalScrollIntentTracking(firstTerminal, firstHost, 'leaf-1')
|
||||
markTerminalPinnedViewport(firstTerminal)
|
||||
|
||||
const remountedTerminal = createTerminal({ viewportY: 0, baseY: 0 })
|
||||
const remountedHost = new TestElement() as unknown as HTMLElement
|
||||
const remountedDisposable = attachTerminalScrollIntentTracking(
|
||||
remountedTerminal,
|
||||
remountedHost,
|
||||
'leaf-1'
|
||||
)
|
||||
|
||||
syncTerminalScrollIntentFromViewport(remountedTerminal)
|
||||
remountedTerminal.buffer.active.baseY = 100
|
||||
remountedTerminal.buffer.active.viewportY = 100
|
||||
enforceTerminalCurrentScrollIntent(remountedTerminal)
|
||||
|
||||
expect(remountedTerminal.scrollToLine).toHaveBeenCalledWith(76)
|
||||
expect(getTerminalScrollIntentKind(remountedTerminal)).toBe('pinnedViewport')
|
||||
|
||||
firstDisposable.dispose()
|
||||
remountedDisposable.dispose()
|
||||
})
|
||||
|
||||
it('tracks pointer-driven scrollbar scrolls without using output scroll as intent', () => {
|
||||
vi.stubGlobal('Element', TestElement)
|
||||
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
|
||||
const hostElement = new TestElement()
|
||||
const viewport = new TestElement('xterm-viewport')
|
||||
hostElement.append(viewport)
|
||||
const host = hostElement as unknown as HTMLElement
|
||||
const disposable = attachTerminalScrollIntentTracking(terminal, host)
|
||||
|
||||
terminal.buffer.active.viewportY = 50
|
||||
host.dispatchEvent(new Event('scroll'))
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput')
|
||||
|
||||
viewport.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
viewport.dispatchEvent(new Event('scroll', { bubbles: true }))
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
|
||||
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('does not treat terminal body pointer activity as scrollbar intent', () => {
|
||||
vi.stubGlobal('Element', TestElement)
|
||||
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
|
||||
const hostElement = new TestElement()
|
||||
const body = new TestElement()
|
||||
hostElement.append(body)
|
||||
const host = hostElement as unknown as HTMLElement
|
||||
const disposable = attachTerminalScrollIntentTracking(terminal, host)
|
||||
|
||||
body.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
terminal.buffer.active.viewportY = 50
|
||||
host.dispatchEvent(new Event('scroll'))
|
||||
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput')
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('updates a manually pinned intent after xterm-handled keyboard scrolling settles', async () => {
|
||||
const frameCallbacks: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frameCallbacks.push(callback)
|
||||
return frameCallbacks.length
|
||||
})
|
||||
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
|
||||
|
||||
markTerminalPinnedViewport(terminal)
|
||||
terminal.buffer.active.viewportY = 75
|
||||
syncTerminalScrollIntentSoon(terminal)
|
||||
|
||||
await Promise.resolve()
|
||||
terminal.buffer.active.viewportY = 0
|
||||
enforceTerminalCurrentScrollIntent(terminal)
|
||||
expect(terminal.scrollToLine).toHaveBeenLastCalledWith(75)
|
||||
})
|
||||
|
||||
it('enforces current intent once for visibility resume', () => {
|
||||
const terminal = createTerminal({ viewportY: 40, baseY: 100 })
|
||||
markTerminalPinnedViewport(terminal)
|
||||
|
||||
terminal.buffer.active.viewportY = 0
|
||||
enforceTerminalCurrentScrollIntent(terminal)
|
||||
|
||||
expect(terminal.scrollToLine).toHaveBeenCalledWith(40)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,293 @@
|
||||
import type { IDisposable } from '@xterm/xterm'
|
||||
|
||||
type TerminalScrollIntentKind = 'followOutput' | 'pinnedViewport'
|
||||
|
||||
type BufferType = 'normal' | 'alternate'
|
||||
|
||||
type TerminalScrollIntentTarget = {
|
||||
buffer?: {
|
||||
active?: {
|
||||
type?: string
|
||||
viewportY?: number
|
||||
baseY?: number
|
||||
}
|
||||
}
|
||||
scrollToBottom?: () => void
|
||||
scrollToLine?: (line: number) => void
|
||||
}
|
||||
|
||||
type TerminalScrollIntentKey = string
|
||||
|
||||
type TerminalScrollIntent = {
|
||||
kind: TerminalScrollIntentKind
|
||||
bufferType: BufferType
|
||||
viewportY: number
|
||||
baseY: number
|
||||
}
|
||||
|
||||
type TerminalScrollIntentWriteSnapshot = {
|
||||
kind: TerminalScrollIntentKind
|
||||
bufferType: BufferType
|
||||
viewportY: number
|
||||
}
|
||||
|
||||
const terminalScrollIntentByTerminal = new WeakMap<
|
||||
TerminalScrollIntentTarget,
|
||||
TerminalScrollIntent
|
||||
>()
|
||||
const terminalScrollIntentKeyByTerminal = new WeakMap<
|
||||
TerminalScrollIntentTarget,
|
||||
TerminalScrollIntentKey
|
||||
>()
|
||||
const terminalScrollIntentByKey = new Map<TerminalScrollIntentKey, TerminalScrollIntent>()
|
||||
|
||||
const BOTTOM_TOLERANCE_ROWS = 1
|
||||
|
||||
function readBufferSnapshot(
|
||||
terminal: TerminalScrollIntentTarget
|
||||
): { bufferType: BufferType; viewportY: number; baseY: number } | null {
|
||||
const buffer = terminal.buffer?.active
|
||||
const viewportY = buffer?.viewportY
|
||||
const baseY = buffer?.baseY
|
||||
if (typeof viewportY !== 'number' || typeof baseY !== 'number') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
bufferType: buffer?.type === 'alternate' ? 'alternate' : 'normal',
|
||||
viewportY,
|
||||
baseY
|
||||
}
|
||||
}
|
||||
|
||||
function isAtBottom(viewportY: number, baseY: number): boolean {
|
||||
return viewportY >= baseY - BOTTOM_TOLERANCE_ROWS
|
||||
}
|
||||
|
||||
function writeIntent(
|
||||
terminal: TerminalScrollIntentTarget,
|
||||
kind: TerminalScrollIntentKind
|
||||
): TerminalScrollIntent | null {
|
||||
const snapshot = readBufferSnapshot(terminal)
|
||||
if (!snapshot) {
|
||||
return null
|
||||
}
|
||||
const intent = { kind, ...snapshot }
|
||||
terminalScrollIntentByTerminal.set(terminal, intent)
|
||||
const key = terminalScrollIntentKeyByTerminal.get(terminal)
|
||||
if (key) {
|
||||
terminalScrollIntentByKey.set(key, intent)
|
||||
}
|
||||
return intent
|
||||
}
|
||||
|
||||
function readStoredIntent(terminal: TerminalScrollIntentTarget): TerminalScrollIntent | undefined {
|
||||
const terminalIntent = terminalScrollIntentByTerminal.get(terminal)
|
||||
if (terminalIntent) {
|
||||
return terminalIntent
|
||||
}
|
||||
const key = terminalScrollIntentKeyByTerminal.get(terminal)
|
||||
return key ? terminalScrollIntentByKey.get(key) : undefined
|
||||
}
|
||||
|
||||
function bindTerminalScrollIntentKey(
|
||||
terminal: TerminalScrollIntentTarget,
|
||||
key: TerminalScrollIntentKey | undefined
|
||||
): TerminalScrollIntent | undefined {
|
||||
if (!key) {
|
||||
return terminalScrollIntentByTerminal.get(terminal)
|
||||
}
|
||||
terminalScrollIntentKeyByTerminal.set(terminal, key)
|
||||
const existing = terminalScrollIntentByKey.get(key)
|
||||
if (existing) {
|
||||
terminalScrollIntentByTerminal.set(terminal, existing)
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
function clampViewportY(viewportY: number, baseY: number): number {
|
||||
return Math.max(0, Math.min(viewportY, baseY))
|
||||
}
|
||||
|
||||
function safeScrollCall(fn: () => void): boolean {
|
||||
try {
|
||||
fn()
|
||||
return true
|
||||
} catch (err) {
|
||||
if (err instanceof TypeError && /dimensions/.test(err.message)) {
|
||||
return false
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export function markTerminalFollowOutput(terminal: TerminalScrollIntentTarget): void {
|
||||
writeIntent(terminal, 'followOutput')
|
||||
}
|
||||
|
||||
export function markTerminalPinnedViewport(terminal: TerminalScrollIntentTarget): void {
|
||||
writeIntent(terminal, 'pinnedViewport')
|
||||
}
|
||||
|
||||
export function syncTerminalScrollIntentFromViewport(
|
||||
terminal: TerminalScrollIntentTarget,
|
||||
options: { preservePinnedAtBottom?: boolean } = {}
|
||||
): void {
|
||||
const snapshot = readBufferSnapshot(terminal)
|
||||
if (!snapshot) {
|
||||
return
|
||||
}
|
||||
const existing = readStoredIntent(terminal)
|
||||
// Why: a remounted/replayed terminal can briefly report an empty or shorter
|
||||
// scrollback. That transient state must not erase a durable pinned viewport.
|
||||
if (existing?.kind === 'pinnedViewport' && snapshot.baseY < existing.baseY) {
|
||||
terminalScrollIntentByTerminal.set(terminal, existing)
|
||||
return
|
||||
}
|
||||
if (
|
||||
options.preservePinnedAtBottom &&
|
||||
existing?.kind === 'pinnedViewport' &&
|
||||
isAtBottom(snapshot.viewportY, snapshot.baseY)
|
||||
) {
|
||||
return
|
||||
}
|
||||
writeIntent(
|
||||
terminal,
|
||||
isAtBottom(snapshot.viewportY, snapshot.baseY) ? 'followOutput' : 'pinnedViewport'
|
||||
)
|
||||
}
|
||||
|
||||
export function syncTerminalScrollIntentSoon(
|
||||
terminal: TerminalScrollIntentTarget,
|
||||
options: { preservePinnedAtBottom?: boolean } = {}
|
||||
): void {
|
||||
const sync = (): void => syncTerminalScrollIntentFromViewport(terminal, options)
|
||||
queueMicrotask(sync)
|
||||
requestAnimationFrame(sync)
|
||||
requestAnimationFrame(() => requestAnimationFrame(sync))
|
||||
setTimeout(sync, 80)
|
||||
}
|
||||
|
||||
export function getTerminalScrollIntentKind(
|
||||
terminal: TerminalScrollIntentTarget
|
||||
): TerminalScrollIntentKind {
|
||||
const existing = readStoredIntent(terminal)
|
||||
if (existing) {
|
||||
return existing.kind
|
||||
}
|
||||
const snapshot = readBufferSnapshot(terminal)
|
||||
if (!snapshot) {
|
||||
return 'followOutput'
|
||||
}
|
||||
return isAtBottom(snapshot.viewportY, snapshot.baseY) ? 'followOutput' : 'pinnedViewport'
|
||||
}
|
||||
|
||||
export function captureTerminalWriteScrollIntent(
|
||||
terminal: TerminalScrollIntentTarget
|
||||
): TerminalScrollIntentWriteSnapshot | null {
|
||||
const snapshot = readBufferSnapshot(terminal)
|
||||
if (!snapshot) {
|
||||
return null
|
||||
}
|
||||
const existing = readStoredIntent(terminal)
|
||||
const kind =
|
||||
existing?.kind ??
|
||||
(isAtBottom(snapshot.viewportY, snapshot.baseY) ? 'followOutput' : 'pinnedViewport')
|
||||
return {
|
||||
kind,
|
||||
bufferType: snapshot.bufferType,
|
||||
viewportY: snapshot.viewportY
|
||||
}
|
||||
}
|
||||
|
||||
export function enforceTerminalWriteScrollIntent(
|
||||
terminal: TerminalScrollIntentTarget,
|
||||
snapshot: TerminalScrollIntentWriteSnapshot | null
|
||||
): void {
|
||||
if (!snapshot) {
|
||||
return
|
||||
}
|
||||
const current = readBufferSnapshot(terminal)
|
||||
if (!current || current.bufferType !== snapshot.bufferType) {
|
||||
return
|
||||
}
|
||||
if (snapshot.kind === 'followOutput') {
|
||||
if (safeScrollCall(() => terminal.scrollToBottom?.())) {
|
||||
writeIntent(terminal, 'followOutput')
|
||||
}
|
||||
return
|
||||
}
|
||||
const targetY = clampViewportY(snapshot.viewportY, current.baseY)
|
||||
if (current.viewportY !== targetY) {
|
||||
safeScrollCall(() => terminal.scrollToLine?.(targetY))
|
||||
}
|
||||
writeIntent(terminal, 'pinnedViewport')
|
||||
}
|
||||
|
||||
export function enforceTerminalCurrentScrollIntent(terminal: TerminalScrollIntentTarget): void {
|
||||
const existing = readStoredIntent(terminal)
|
||||
const snapshot = existing
|
||||
? {
|
||||
kind: existing.kind,
|
||||
bufferType: existing.bufferType,
|
||||
viewportY: existing.viewportY
|
||||
}
|
||||
: captureTerminalWriteScrollIntent(terminal)
|
||||
enforceTerminalWriteScrollIntent(terminal, snapshot)
|
||||
}
|
||||
|
||||
export function attachTerminalScrollIntentTracking(
|
||||
terminal: TerminalScrollIntentTarget,
|
||||
host: HTMLElement,
|
||||
intentKey?: TerminalScrollIntentKey
|
||||
): IDisposable {
|
||||
if (!bindTerminalScrollIntentKey(terminal, intentKey)) {
|
||||
syncTerminalScrollIntentFromViewport(terminal)
|
||||
}
|
||||
let pointerScrollActive = false
|
||||
|
||||
const onWheel = (event: WheelEvent): void => {
|
||||
if (event.deltaY < 0) {
|
||||
markTerminalPinnedViewport(terminal)
|
||||
syncTerminalScrollIntentSoon(terminal, { preservePinnedAtBottom: true })
|
||||
return
|
||||
}
|
||||
syncTerminalScrollIntentSoon(terminal)
|
||||
}
|
||||
|
||||
const onPointerDown = (event: PointerEvent): void => {
|
||||
const target = event.target
|
||||
pointerScrollActive =
|
||||
typeof Element !== 'undefined' &&
|
||||
target instanceof Element &&
|
||||
(target.classList.contains('xterm-viewport') || target.closest('.xterm-viewport') !== null)
|
||||
}
|
||||
|
||||
const onPointerDone = (): void => {
|
||||
if (!pointerScrollActive) {
|
||||
return
|
||||
}
|
||||
pointerScrollActive = false
|
||||
syncTerminalScrollIntentFromViewport(terminal)
|
||||
}
|
||||
|
||||
const onScroll = (): void => {
|
||||
if (pointerScrollActive) {
|
||||
syncTerminalScrollIntentFromViewport(terminal)
|
||||
}
|
||||
}
|
||||
|
||||
host.addEventListener('wheel', onWheel, { capture: true, passive: true })
|
||||
host.addEventListener('pointerdown', onPointerDown, true)
|
||||
host.addEventListener('scroll', onScroll, true)
|
||||
globalThis.addEventListener?.('pointerup', onPointerDone, true)
|
||||
globalThis.addEventListener?.('pointercancel', onPointerDone, true)
|
||||
return {
|
||||
dispose: () => {
|
||||
host.removeEventListener('wheel', onWheel, true)
|
||||
host.removeEventListener('pointerdown', onPointerDown, true)
|
||||
host.removeEventListener('scroll', onScroll, true)
|
||||
globalThis.removeEventListener?.('pointerup', onPointerDone, true)
|
||||
globalThis.removeEventListener?.('pointercancel', onPointerDone, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,4 +200,84 @@ describe('project-host workspace target resolution', () => {
|
||||
reason: 'setup-not-ready'
|
||||
})
|
||||
})
|
||||
|
||||
// Regression: selecting a project in the new-workspace dropdown must not be
|
||||
// pinned to the host of the currently-active workspace. Each project below is
|
||||
// set up on exactly one (different) host; picking the other project while the
|
||||
// current host is given only as a preference must resolve to that project's
|
||||
// own host instead of returning '' (the silent no-op the dropdown showed).
|
||||
describe('cross-host project selection', () => {
|
||||
const repos = [makeRepo('local-repo'), makeRepo('remote-repo', { connectionId: 'remote-1' })]
|
||||
const projects = [
|
||||
makeProject('repo:local-repo', ['local-repo']),
|
||||
makeProject('repo:remote-repo', ['remote-repo'])
|
||||
]
|
||||
const projectHostSetups = [
|
||||
makeSetup('local-repo', 'repo:local-repo', 'local', 'local-repo'),
|
||||
makeSetup('remote-repo', 'repo:remote-repo', 'ssh:remote-1', 'remote-repo')
|
||||
]
|
||||
|
||||
it('resolves a remote-only project while the current host is local', () => {
|
||||
expect(
|
||||
resolveWorkspaceCreationRepoId({
|
||||
eligibleRepos: repos,
|
||||
projects,
|
||||
projectHostSetups,
|
||||
projectId: 'repo:remote-repo',
|
||||
focusedHostScope: 'local'
|
||||
})
|
||||
).toBe('remote-repo')
|
||||
})
|
||||
|
||||
it('resolves a local-only project while the current host is remote', () => {
|
||||
expect(
|
||||
resolveWorkspaceCreationRepoId({
|
||||
eligibleRepos: repos,
|
||||
projects,
|
||||
projectHostSetups,
|
||||
projectId: 'repo:local-repo',
|
||||
focusedHostScope: 'ssh:remote-1'
|
||||
})
|
||||
).toBe('local-repo')
|
||||
})
|
||||
|
||||
it('still prefers the current host when the project is set up on it', () => {
|
||||
const multiHostProjects = [makeProject('repo:multi', ['multi-local', 'multi-remote'])]
|
||||
const multiHostSetups = [
|
||||
makeSetup('multi-local', 'repo:multi', 'local', 'multi-local'),
|
||||
makeSetup('multi-remote', 'repo:multi', 'ssh:remote-1', 'multi-remote')
|
||||
]
|
||||
|
||||
expect(
|
||||
resolveWorkspaceCreationRepoId({
|
||||
eligibleRepos: [
|
||||
makeRepo('multi-local'),
|
||||
makeRepo('multi-remote', { connectionId: 'remote-1' })
|
||||
],
|
||||
projects: multiHostProjects,
|
||||
projectHostSetups: multiHostSetups,
|
||||
projectId: 'repo:multi',
|
||||
focusedHostScope: 'local'
|
||||
})
|
||||
).toBe('multi-local')
|
||||
})
|
||||
|
||||
it('still reports unavailable for an explicit project+host with no ready setup', () => {
|
||||
// The strict projectId+hostId path (used by the explicit "Run on" host
|
||||
// picker) keeps its hard match — only the project-dropdown call site
|
||||
// changed to pass the host as a preference.
|
||||
expect(
|
||||
resolveWorkspaceCreationTarget({
|
||||
eligibleRepos: repos,
|
||||
projects,
|
||||
projectHostSetups,
|
||||
projectId: 'repo:remote-repo',
|
||||
hostId: 'local'
|
||||
})
|
||||
).toEqual({
|
||||
status: 'unavailable',
|
||||
reason: 'project-not-set-up-on-host'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -119,6 +119,30 @@ describe('resumeSleepingAgentSessionsForWorktree', () => {
|
||||
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record)
|
||||
})
|
||||
|
||||
it('skips hibernated stable panes after their live PTY binding is cleared', () => {
|
||||
const paneKey = makePaneKey('tab-1', LEAF_ID)
|
||||
const record = makeRecord({ paneKey, origin: 'worktree-sleep', state: 'done' })
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1', 'wt-1')] },
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null
|
||||
}
|
||||
},
|
||||
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
|
||||
} as never)
|
||||
|
||||
const launched = resumeSleepingAgentSessionsForWorktree('wt-1')
|
||||
|
||||
const state = useAppStore.getState()
|
||||
expect(launched).toBe(0)
|
||||
expect(state.tabsByWorktree['wt-1']).toHaveLength(1)
|
||||
expect(state.pendingStartupByTabId).toEqual({})
|
||||
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record)
|
||||
})
|
||||
|
||||
it('skips legacy numeric pane-key records owned by a preserved tab wake hint', () => {
|
||||
const record = makeRecord({ paneKey: 'tab-1:0', origin: 'worktree-sleep' })
|
||||
useAppStore.setState({
|
||||
|
||||
@@ -11,7 +11,11 @@ import {
|
||||
resolveTuiAgentLaunchEnv
|
||||
} from '../../../shared/tui-agent-launch-defaults'
|
||||
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
|
||||
import type { TerminalTab } from '../../../shared/types'
|
||||
import type {
|
||||
TerminalLayoutSnapshot,
|
||||
TerminalPaneLayoutNode,
|
||||
TerminalTab
|
||||
} from '../../../shared/types'
|
||||
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../shared/agent-status-types'
|
||||
@@ -141,13 +145,27 @@ function hasRestorableLegacyTabPty(
|
||||
return Boolean(tab.ptyId) || (ptyIdsByTabId[tab.id]?.length ?? 0) > 0
|
||||
}
|
||||
|
||||
function layoutContainsLeaf(
|
||||
node: TerminalPaneLayoutNode | null | undefined,
|
||||
leafId: string
|
||||
): boolean {
|
||||
if (!node) {
|
||||
return false
|
||||
}
|
||||
if (node.type === 'leaf') {
|
||||
return node.leafId === leafId
|
||||
}
|
||||
return layoutContainsLeaf(node.first, leafId) || layoutContainsLeaf(node.second, leafId)
|
||||
}
|
||||
|
||||
function hasMatchingStablePaneLayout(
|
||||
tabId: string,
|
||||
leafId: string,
|
||||
terminalLayoutsByTabId: ReturnType<typeof useAppStore.getState>['terminalLayoutsByTabId']
|
||||
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot | undefined>
|
||||
): boolean {
|
||||
const ptyIdsByLeafId = terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId
|
||||
return Boolean(ptyIdsByLeafId && Object.hasOwn(ptyIdsByLeafId, leafId))
|
||||
// Why: hibernation intentionally clears the live PTY binding after the pane
|
||||
// exits, but the preserved leaf still owns cold-restore for its session.
|
||||
return layoutContainsLeaf(terminalLayoutsByTabId[tabId]?.root, leafId)
|
||||
}
|
||||
|
||||
function findSameWorktreeTab(
|
||||
|
||||
@@ -74,6 +74,9 @@ export type AiVaultScanIssue = {
|
||||
export type AiVaultListArgs = {
|
||||
limit?: number
|
||||
force?: boolean
|
||||
// Active workspace/project paths. The global result is recency-capped, so these
|
||||
// guarantee a scoped view still surfaces its own (possibly older) sessions.
|
||||
scopePaths?: readonly string[]
|
||||
}
|
||||
|
||||
export type AiVaultListResult = {
|
||||
|
||||
@@ -286,6 +286,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
|
||||
floatingTerminalTriggerLocation: 'floating-button',
|
||||
notifications: getDefaultNotificationSettings(),
|
||||
diffDefaultView: 'inline',
|
||||
diffWordWrap: false,
|
||||
combinedDiffFileTreeVisibleByDefault: false,
|
||||
promptCacheTimerEnabled: false,
|
||||
promptCacheTtlMs: 300_000,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { deriveGitRemoteIdentity, normalizeGitRemoteUrl } from './git-remote-identity'
|
||||
|
||||
describe('normalizeGitRemoteUrl', () => {
|
||||
it('normalizes HTTPS and SSH GitHub remotes to the same canonical key', () => {
|
||||
expect(normalizeGitRemoteUrl('https://github.com/example/sample-app.git')).toBe(
|
||||
'github.com/example/sample-app'
|
||||
)
|
||||
expect(normalizeGitRemoteUrl('git@github.com:example/sample-app.git')).toBe(
|
||||
'github.com/example/sample-app'
|
||||
)
|
||||
expect(normalizeGitRemoteUrl('ssh://git@github.com/example/sample-app.git')).toBe(
|
||||
'github.com/example/sample-app'
|
||||
)
|
||||
expect(normalizeGitRemoteUrl('https://GitHub.com/example/sample-app.git')).toBe(
|
||||
'github.com/example/sample-app'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves nested GitLab/self-hosted paths', () => {
|
||||
expect(normalizeGitRemoteUrl('git@gitlab.company.test:platform/tools/sample-app.git')).toBe(
|
||||
'gitlab.company.test/platform/tools/sample-app'
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores explicit URL ports in canonical keys', () => {
|
||||
expect(normalizeGitRemoteUrl('ssh://git@git.company.test:2222/team/sample-app.git')).toBe(
|
||||
'git.company.test/team/sample-app'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves path case for case-sensitive hosted remotes', () => {
|
||||
expect(normalizeGitRemoteUrl('git@Git.Company.Test:Team/Sample-App.git')).toBe(
|
||||
'git.company.test/Team/Sample-App'
|
||||
)
|
||||
expect(normalizeGitRemoteUrl('https://git.company.test/Team/Sample-App.git')).toBe(
|
||||
'git.company.test/Team/Sample-App'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects Windows local filesystem remotes', () => {
|
||||
expect(normalizeGitRemoteUrl('C:\\Repos\\sample-app.git')).toBeNull()
|
||||
expect(normalizeGitRemoteUrl('C:/Repos/sample-app.git')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveGitRemoteIdentity', () => {
|
||||
it('prefers upstream, then origin, then the first named remote', () => {
|
||||
expect(
|
||||
deriveGitRemoteIdentity(
|
||||
[
|
||||
'origin\tgit@git.company.test:forks/sample-app.git (fetch)',
|
||||
'origin\tgit@git.company.test:forks/sample-app.git (push)',
|
||||
'upstream\thttps://git.company.test/team/sample-app.git (fetch)',
|
||||
'upstream\thttps://git.company.test/team/sample-app.git (push)'
|
||||
].join('\n')
|
||||
)
|
||||
).toEqual({
|
||||
canonicalKey: 'git.company.test/team/sample-app',
|
||||
remoteName: 'upstream',
|
||||
remoteUrl: 'https://git.company.test/team/sample-app.git'
|
||||
})
|
||||
|
||||
expect(
|
||||
deriveGitRemoteIdentity('origin\tgit@git.company.test:team/sample-app.git (fetch)')
|
||||
).toMatchObject({
|
||||
canonicalKey: 'git.company.test/team/sample-app',
|
||||
remoteName: 'origin'
|
||||
})
|
||||
|
||||
expect(
|
||||
deriveGitRemoteIdentity('mirror\tgit@git.company.test:team/sample-app.git (fetch)')
|
||||
).toMatchObject({
|
||||
canonicalKey: 'git.company.test/team/sample-app',
|
||||
remoteName: 'mirror'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
export type GitRemoteIdentity = {
|
||||
canonicalKey: string
|
||||
remoteName: string
|
||||
remoteUrl: string
|
||||
}
|
||||
|
||||
type GitRemoteEntry = {
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
|
||||
function stripGitSuffix(path: string): string {
|
||||
return path.endsWith('.git') ? path.slice(0, -4) : path
|
||||
}
|
||||
|
||||
function normalizeRemotePath(path: string): string {
|
||||
return stripGitSuffix(path.replace(/^\/+/, '').replace(/\/+$/, ''))
|
||||
}
|
||||
|
||||
function normalizeRemoteHost(host: string): string {
|
||||
return host.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function isLocalFilesystemRemote(remoteUrl: string): boolean {
|
||||
return /^[A-Za-z]:[\\/]/.test(remoteUrl)
|
||||
}
|
||||
|
||||
export function normalizeGitRemoteUrl(remoteUrl: string): string | null {
|
||||
const trimmed = remoteUrl.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
if (isLocalFilesystemRemote(trimmed)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const scpMatch = trimmed.includes('://') ? null : /^([^@\s:]+@)?([^:\s]+):(.+)$/.exec(trimmed)
|
||||
if (scpMatch) {
|
||||
const host = normalizeRemoteHost(scpMatch[2] ?? '')
|
||||
const path = normalizeRemotePath(scpMatch[3] ?? '')
|
||||
return host && path ? `${host}/${path}` : null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed)
|
||||
const host = normalizeRemoteHost(parsed.hostname)
|
||||
const path = normalizeRemotePath(parsed.pathname)
|
||||
return host && path ? `${host}/${path}` : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function parseGitRemoteVerboseOutput(stdout: string): GitRemoteEntry[] {
|
||||
const entries: GitRemoteEntry[] = []
|
||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||
const line = rawLine.trim()
|
||||
if (!line.endsWith('(fetch)')) {
|
||||
continue
|
||||
}
|
||||
const match = /^(\S+)\s+(.+?)\s+\(fetch\)$/.exec(line)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
const name = match[1]?.trim()
|
||||
const url = match[2]?.trim()
|
||||
if (name && url) {
|
||||
entries.push({ name, url })
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
function primaryRemoteSortKey(entry: GitRemoteEntry): number {
|
||||
if (entry.name === 'upstream') {
|
||||
return 0
|
||||
}
|
||||
if (entry.name === 'origin') {
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
export function deriveGitRemoteIdentity(stdout: string): GitRemoteIdentity | null {
|
||||
const entries = parseGitRemoteVerboseOutput(stdout)
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
canonicalKey: normalizeGitRemoteUrl(entry.url)
|
||||
}))
|
||||
.filter((entry): entry is GitRemoteEntry & { canonicalKey: string } => !!entry.canonicalKey)
|
||||
.sort((left, right) => {
|
||||
const priority = primaryRemoteSortKey(left) - primaryRemoteSortKey(right)
|
||||
return priority === 0 ? left.name.localeCompare(right.name) : priority
|
||||
})
|
||||
const selected = entries[0]
|
||||
return selected
|
||||
? {
|
||||
canonicalKey: selected.canonicalKey,
|
||||
remoteName: selected.name,
|
||||
remoteUrl: selected.url
|
||||
}
|
||||
: null
|
||||
}
|
||||
@@ -168,6 +168,119 @@ describe('project host setup projection', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('groups same-project records across local, SSH, and runtime hosts by git remote identity', () => {
|
||||
const projection = projectHostSetupProjectionFromRepos([
|
||||
repo({
|
||||
id: 'local-sample-app',
|
||||
path: '/Users/alice/work/sample-app',
|
||||
displayName: 'sample-app',
|
||||
gitRemoteIdentity: {
|
||||
canonicalKey: 'git.company.test/team/sample-app',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'git@git.company.test:team/sample-app.git'
|
||||
}
|
||||
}),
|
||||
repo({
|
||||
id: 'ssh-sample-app',
|
||||
path: '/home/alice/src/sample-app',
|
||||
displayName: 'sample-app',
|
||||
connectionId: 'build server',
|
||||
gitRemoteIdentity: {
|
||||
canonicalKey: 'git.company.test/team/sample-app',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'https://git.company.test/team/sample-app.git'
|
||||
}
|
||||
}),
|
||||
repo({
|
||||
id: 'runtime-sample-app',
|
||||
path: '/workspace/sample-app',
|
||||
displayName: 'sample-app',
|
||||
executionHostId: 'runtime:dev-container',
|
||||
gitRemoteIdentity: {
|
||||
canonicalKey: 'git.company.test/team/sample-app',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'ssh://git@git.company.test/team/sample-app.git'
|
||||
}
|
||||
})
|
||||
])
|
||||
|
||||
expect(projection.projects).toHaveLength(1)
|
||||
expect(projection.projects[0]).toMatchObject({
|
||||
id: 'git:git.company.test/team/sample-app',
|
||||
displayName: 'sample-app',
|
||||
sourceRepoIds: ['local-sample-app', 'ssh-sample-app', 'runtime-sample-app'],
|
||||
gitRemoteIdentity: {
|
||||
canonicalKey: 'git.company.test/team/sample-app',
|
||||
remoteName: 'origin'
|
||||
}
|
||||
})
|
||||
expect(projection.setups.map((setup) => setup.hostId)).toEqual([
|
||||
'local',
|
||||
'ssh:build%20server',
|
||||
'runtime:dev-container'
|
||||
])
|
||||
expect(
|
||||
getProjectHostSetupsForProject(projection.setups, 'git:git.company.test/team/sample-app')
|
||||
).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('keeps same-named cross-host records separate when there is no shared repo identity', () => {
|
||||
const projection = projectHostSetupProjectionFromRepos([
|
||||
repo({
|
||||
id: 'local-sample-app',
|
||||
path: '/Users/alice/work/sample-app',
|
||||
displayName: 'sample-app'
|
||||
}),
|
||||
repo({
|
||||
id: 'ssh-sample-app',
|
||||
path: '/srv/unrelated/sample-app',
|
||||
displayName: 'sample-app',
|
||||
connectionId: 'staging server'
|
||||
}),
|
||||
repo({
|
||||
id: 'runtime-sample-app',
|
||||
path: '/workspace/sample-app',
|
||||
displayName: 'sample-app',
|
||||
executionHostId: 'runtime:preview'
|
||||
})
|
||||
])
|
||||
|
||||
// Why: display names are labels, not identity. A future fix needs a
|
||||
// normalized git remote identity or an explicit user link before merging.
|
||||
expect(projection.projects).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('does not collapse case-distinct remote paths for self-hosted git remotes', () => {
|
||||
const projection = projectHostSetupProjectionFromRepos([
|
||||
repo({
|
||||
id: 'uppercase-repo',
|
||||
path: '/Users/alice/work/sample-app',
|
||||
displayName: 'sample-app',
|
||||
gitRemoteIdentity: {
|
||||
canonicalKey: 'git.company.test/Team/Sample-App',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'git@git.company.test:Team/Sample-App.git'
|
||||
}
|
||||
}),
|
||||
repo({
|
||||
id: 'lowercase-repo',
|
||||
path: '/home/alice/src/sample-app',
|
||||
displayName: 'sample-app',
|
||||
connectionId: 'build server',
|
||||
gitRemoteIdentity: {
|
||||
canonicalKey: 'git.company.test/team/sample-app',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'git@git.company.test:team/sample-app.git'
|
||||
}
|
||||
})
|
||||
])
|
||||
|
||||
expect(projection.projects.map((project) => project.id)).toEqual([
|
||||
'git:git.company.test/Team/Sample-App',
|
||||
'git:git.company.test/team/sample-app'
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores malformed provider identity values', () => {
|
||||
const projection = projectHostSetupProjectionFromRepos([
|
||||
repo({
|
||||
|
||||
@@ -41,6 +41,17 @@ function getProjectProviderIdentity(
|
||||
: null
|
||||
}
|
||||
|
||||
function getProjectGitRemoteIdentity(
|
||||
repo: Pick<Repo, 'gitRemoteIdentity'>
|
||||
): NonNullable<Repo['gitRemoteIdentity']> | null {
|
||||
const identity = repo.gitRemoteIdentity
|
||||
const canonicalKey =
|
||||
typeof identity?.canonicalKey === 'string' ? identity.canonicalKey.trim() : ''
|
||||
const remoteName = typeof identity?.remoteName === 'string' ? identity.remoteName.trim() : ''
|
||||
const remoteUrl = typeof identity?.remoteUrl === 'string' ? identity.remoteUrl.trim() : ''
|
||||
return canonicalKey && remoteName && remoteUrl ? { canonicalKey, remoteName, remoteUrl } : null
|
||||
}
|
||||
|
||||
/** True when the repo resolves to a GitHub provider identity (via explicit
|
||||
* upstream or a GitHub-sourced avatar icon). Used to scope GitHub-CLI setup
|
||||
* prompts to users who actually have GitHub-backed projects. */
|
||||
@@ -48,20 +59,29 @@ export function isGitHubBackedRepo(repo: Pick<Repo, 'upstream' | 'repoIcon'>): b
|
||||
return getProjectProviderIdentity(repo) !== null
|
||||
}
|
||||
|
||||
export function getProjectIdentityKey(repo: Pick<Repo, 'id' | 'upstream' | 'repoIcon'>): string {
|
||||
export function getProjectIdentityKey(
|
||||
repo: Pick<Repo, 'id' | 'upstream' | 'repoIcon' | 'gitRemoteIdentity'>
|
||||
): string {
|
||||
const identity = getProjectProviderIdentity(repo)
|
||||
if (!identity) {
|
||||
return `repo:${repo.id}`
|
||||
if (identity) {
|
||||
return `github:${normalizeIdentityPart(identity.owner)}/${normalizeIdentityPart(identity.repo)}`
|
||||
}
|
||||
return `github:${normalizeIdentityPart(identity.owner)}/${normalizeIdentityPart(identity.repo)}`
|
||||
const gitRemoteIdentity = getProjectGitRemoteIdentity(repo)
|
||||
if (gitRemoteIdentity) {
|
||||
return `git:${gitRemoteIdentity.canonicalKey}`
|
||||
}
|
||||
return `repo:${repo.id}`
|
||||
}
|
||||
|
||||
function getProjectId(repo: Pick<Repo, 'id' | 'upstream' | 'repoIcon'>): string {
|
||||
function getProjectId(
|
||||
repo: Pick<Repo, 'id' | 'upstream' | 'repoIcon' | 'gitRemoteIdentity'>
|
||||
): string {
|
||||
return getProjectIdentityKey(repo)
|
||||
}
|
||||
|
||||
function createProjectFromRepo(repo: Repo, now: number): Project {
|
||||
const identity = getProjectProviderIdentity(repo)
|
||||
const gitRemoteIdentity = getProjectGitRemoteIdentity(repo)
|
||||
return {
|
||||
id: getProjectId(repo),
|
||||
displayName: repo.displayName,
|
||||
@@ -69,6 +89,7 @@ function createProjectFromRepo(repo: Repo, now: number): Project {
|
||||
...(repo.repoIcon !== undefined ? { repoIcon: repo.repoIcon } : {}),
|
||||
...(repo.kind ? { kind: repo.kind } : {}),
|
||||
...(identity ? { providerIdentity: identity } : {}),
|
||||
...(gitRemoteIdentity ? { gitRemoteIdentity } : {}),
|
||||
sourceRepoIds: [repo.id],
|
||||
createdAt: repo.addedAt || now,
|
||||
updatedAt: repo.addedAt || now
|
||||
|
||||
@@ -35,6 +35,7 @@ import type { ClaudeAgentTeamsMode } from './claude-agent-teams-tmux-compat'
|
||||
import type { TerminalCustomTheme } from './terminal-custom-themes'
|
||||
import type { UiLanguage } from './ui-language'
|
||||
import type { ForkSyncMode } from './git-fork-sync'
|
||||
import type { GitRemoteIdentity } from './git-remote-identity'
|
||||
import type {
|
||||
GlobalWindowsRuntimeDefault,
|
||||
LocalWindowsRuntimePreference
|
||||
@@ -109,6 +110,7 @@ export type Project = {
|
||||
repoIcon?: RepoIcon | null
|
||||
kind?: RepoKind
|
||||
providerIdentity?: ProjectProviderIdentity
|
||||
gitRemoteIdentity?: GitRemoteIdentity
|
||||
/** Local Windows projects inherit the global runtime default unless this override is set. */
|
||||
localWindowsRuntimePreference?: LocalWindowsRuntimePreference
|
||||
sourceRepoIds: string[]
|
||||
@@ -235,6 +237,7 @@ export type Repo = {
|
||||
* default avatar (upstream owner, not the personal fork) and the fork
|
||||
* indicator. Absent = not a fork, or fork status not yet resolved. */
|
||||
upstream?: GitHubRepositoryIdentity | null
|
||||
gitRemoteIdentity?: GitRemoteIdentity | null
|
||||
addedAt: number
|
||||
kind?: RepoKind
|
||||
gitUsername?: string
|
||||
@@ -1310,6 +1313,36 @@ export type PRComment = {
|
||||
isBot?: boolean
|
||||
}
|
||||
|
||||
export type GitHubIssueTimelineTarget = {
|
||||
type: 'issue' | 'pr'
|
||||
number: number
|
||||
title: string
|
||||
url: string
|
||||
repository?: string
|
||||
}
|
||||
|
||||
export type GitHubIssueTimelineItem = {
|
||||
id: string
|
||||
event:
|
||||
| 'assigned'
|
||||
| 'unassigned'
|
||||
| 'mentioned'
|
||||
| 'cross-referenced'
|
||||
| 'closed'
|
||||
| 'reopened'
|
||||
| 'moved_columns_in_project'
|
||||
actor: string
|
||||
actorAvatarUrl: string
|
||||
createdAt: string
|
||||
assignee?: string
|
||||
source?: GitHubIssueTimelineTarget
|
||||
closer?: GitHubIssueTimelineTarget
|
||||
stateReason?: string | null
|
||||
previousColumnName?: string | null
|
||||
columnName?: string | null
|
||||
projectName?: string | null
|
||||
}
|
||||
|
||||
export type GitHubCommentResult = { ok: true; comment: PRComment } | { ok: false; error: string }
|
||||
|
||||
export type IssueInfo = {
|
||||
@@ -1428,6 +1461,8 @@ export type GitHubWorkItemDetails = {
|
||||
item: Omit<GitHubWorkItem, 'repoId'>
|
||||
body: string
|
||||
comments: PRComment[]
|
||||
/** Issue-only provider activity such as assignment, references, project moves, and state changes. */
|
||||
timelineItems?: GitHubIssueTimelineItem[]
|
||||
/** Only set for PRs. Head/base SHAs used by the Files tab to fetch per-file content. */
|
||||
headSha?: string
|
||||
baseSha?: string
|
||||
@@ -2556,6 +2591,7 @@ export type GlobalSettings = {
|
||||
* ~/.orca/keybindings.json; main migrates this once when present. */
|
||||
keybindings?: KeybindingOverrides
|
||||
diffDefaultView: 'inline' | 'side-by-side'
|
||||
diffWordWrap: boolean
|
||||
combinedDiffFileTreeVisibleByDefault: boolean
|
||||
notifications: NotificationSettings
|
||||
/** When true, a countdown timer is shown after a Claude agent becomes idle,
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
export const EMOJI_TABLE_FIXTURE = readFileSync(
|
||||
path.join(__dirname, 'fixtures', 'terminal-emoji-table.md'),
|
||||
'utf8'
|
||||
)
|
||||
export const NARROW_TERMINAL_MAX_COLS = 120
|
||||
|
||||
export function longMarkdownTableScript(runId: string): string {
|
||||
const names = [
|
||||
['Sam Syntax', 'Compiler', 'Online', '😀', '9200', 'Semicolons are optional (rage ensues)'],
|
||||
['Tori Token', 'Auth', 'Idle', '🚀', '4800', 'JWT expires during their standup'],
|
||||
['Uma Unpin', 'Frontend', 'Online', '🔥', '3500', 'Absolute positioning enjoyer'],
|
||||
['Vic Variable', 'Types', 'AFK', '💡', '6700', 'any is not a type, it is a cry for help'],
|
||||
['Wally Watchdog', 'Security', 'Online', '📦', '8200', 'Found a vuln in your vuln scanner'],
|
||||
['Xena XPath', 'DB', 'Idle', '🔐', '7300', 'Indexes everything, including the fridge'],
|
||||
['Yuki Yank', 'CLI', 'Online', '🎯', '5900', 'rm -rf / is not a party trick'],
|
||||
['Zane Zealot', 'OSS', 'Offline', '🤖', '10000', 'Contributor to 47 repos, sleeps never'],
|
||||
['Artie ASCII', 'Docs', 'Online', '🧠', '2900', 'Wrote a novel in README comments'],
|
||||
['Bianca Batch', 'ML', 'AFK', '💾', '9400', 'Training a model to write PR descriptions'],
|
||||
['Carlos Cache', 'CDN', 'Idle', '⚙', '4900', 'Stale data is still data'],
|
||||
['Diana Draft', 'Planning', 'Online', '📚', '1800', 'Needs 3 more sprints to estimate'],
|
||||
['Edgar Exit', 'Ops', 'Online', '🔧', '7600', 'Graceful shutdown specialist'],
|
||||
['Fiona Fallback', 'Resilience', 'Idle', '🧲', '5500', 'Circuit breaker connoisseur'],
|
||||
['Gabe Garbage', 'GC', 'Offline', '🧹', '4100', 'Stop-the-world is my catchphrase'],
|
||||
['Holly Hotfix', 'Release', 'Online', '🧪', '6300', 'Friday deploy champion'],
|
||||
['Ira Idempotent', 'API', 'AFK', '🔁', '6900', 'PUT me in coach'],
|
||||
['Jules Jitter', 'Mobile', 'Idle', '📱', '3200', 'Offline-first, coffee-second'],
|
||||
['Ken Kafka', 'Streams', 'Online', '📡', '7100', 'Rebalancing is a lifestyle'],
|
||||
['Luna Latency', 'Edge', 'Offline', '🧭', '4400', 'Response time measured in business days'],
|
||||
['Max Marshal', 'Memory', 'Online', '🧩', '8700', "Leak-free since '24"],
|
||||
['Nora Null', 'Safety', 'AFK', '❓', '3800', 'null is a person, not a value'],
|
||||
['Otto Offset', 'Cursors', 'Idle', '👆', '2600', 'Infinite scroll for the infinite soul'],
|
||||
['Pam Payload', 'Serialization', 'Online', '📦', '5800', 'JSON.stringify is my yoga'],
|
||||
['Reed Regex', 'Matching', 'Offline', '🔍', '6800', 'Now I have two problems']
|
||||
]
|
||||
return `
|
||||
const rows = ${JSON.stringify(names)}
|
||||
const widths = [16, 14, 12, 6, 7, 42]
|
||||
function isCombiningMark(codePoint) {
|
||||
return (codePoint >= 0x0300 && codePoint <= 0x036f) ||
|
||||
(codePoint >= 0xfe00 && codePoint <= 0xfe0f)
|
||||
}
|
||||
function isWideCodePoint(codePoint) {
|
||||
return codePoint > 0xffff ||
|
||||
(codePoint >= 0x1100 && codePoint <= 0x115f) ||
|
||||
(codePoint >= 0x2e80 && codePoint <= 0xa4cf) ||
|
||||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
|
||||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
||||
(codePoint >= 0xfe10 && codePoint <= 0xfe6f) ||
|
||||
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
|
||||
(codePoint >= 0xffe0 && codePoint <= 0xffe6)
|
||||
}
|
||||
function cellWidth(text) {
|
||||
let width = 0
|
||||
for (const char of String(text)) {
|
||||
const codePoint = char.codePointAt(0)
|
||||
if (codePoint === undefined || isCombiningMark(codePoint)) continue
|
||||
width += isWideCodePoint(codePoint) ? 2 : 1
|
||||
}
|
||||
return width
|
||||
}
|
||||
function cell(value, width) {
|
||||
const text = String(value)
|
||||
return text + ' '.repeat(Math.max(1, width - cellWidth(text)))
|
||||
}
|
||||
function line(parts) {
|
||||
return '| ' + parts.map((part, index) => cell(part, widths[index])).join(' | ') + ' |'
|
||||
}
|
||||
const outputRows = []
|
||||
outputRows.push(line(['Name', 'Team', 'Status', 'Icon', 'Score', 'Notes']))
|
||||
outputRows.push('|-' + widths.map((width) => '-'.repeat(width)).join('-|-') + '-|')
|
||||
for (let repeat = 0; repeat < 4; repeat += 1) {
|
||||
for (const row of rows) outputRows.push(line(row))
|
||||
}
|
||||
async function writeStdout(chunk) {
|
||||
await new Promise((resolve) => process.stdout.write(chunk, resolve))
|
||||
if (process.platform === 'win32') await new Promise((resolve) => setTimeout(resolve, 8))
|
||||
}
|
||||
await writeStdout('\\x1b[?2026h\\x1b[2J\\x1b[H')
|
||||
for (const row of outputRows) {
|
||||
await writeStdout(row + '\\n')
|
||||
}
|
||||
await writeStdout('\\x1b[?2026l')
|
||||
await writeStdout('LONG_TABLE_SCROLL_RESTORE_${runId}\\n')
|
||||
`
|
||||
}
|
||||
|
||||
export function emojiFixtureMarkdownTableScript(table: string, runId: string): string {
|
||||
const marker = `EMOJI_FIXTURE_TABLE_RESTORE_${runId}`
|
||||
const widthMarker = `EMOJI_FIXTURE_TABLE_WIDTH_${runId}`
|
||||
return `
|
||||
const table = ${JSON.stringify(table)}
|
||||
const minimumWidths = [2, 5, 4, 7, 7, 4, 3, 4]
|
||||
const preferredWidths = [5, 17, 10, 18, 30, 12, 10, 10]
|
||||
const tableOverhead = preferredWidths.length * 3 + 1
|
||||
const widthBudget = Math.max(
|
||||
minimumWidths.reduce((sum, width) => sum + width, 0),
|
||||
Math.min(
|
||||
preferredWidths.reduce((sum, width) => sum + width, 0),
|
||||
(process.stdout.columns || 100) - tableOverhead - 1
|
||||
)
|
||||
)
|
||||
let remaining = widthBudget
|
||||
let remainingPreferred = preferredWidths.reduce((sum, width) => sum + width, 0)
|
||||
const widths = preferredWidths.map((preferred, index) => {
|
||||
const minimum = minimumWidths[index]
|
||||
const width = Math.max(minimum, Math.floor((remaining * preferred) / remainingPreferred))
|
||||
remaining -= width
|
||||
remainingPreferred -= preferred
|
||||
return width
|
||||
})
|
||||
const generatedTableWidth = widths.reduce((sum, width) => sum + width, 0) + tableOverhead
|
||||
const border = {
|
||||
top: ['┌', '┬', '┐'],
|
||||
middle: ['├', '┼', '┤'],
|
||||
bottom: ['└', '┴', '┘'],
|
||||
vertical: '│',
|
||||
horizontal: '─'
|
||||
}
|
||||
function splitMarkdownRow(row) {
|
||||
return row.trim().slice(1, -1).split('|').map((cell) => cell.trim())
|
||||
}
|
||||
function isSeparatorRow(row) {
|
||||
return /^\\|(?:\\s*:?-+:?\\s*\\|)+\\s*$/.test(row)
|
||||
}
|
||||
function cellWidth(text) {
|
||||
let width = 0
|
||||
for (const char of String(text)) {
|
||||
const codePoint = char.codePointAt(0)
|
||||
if (codePoint === undefined || (codePoint >= 0x0300 && codePoint <= 0x036f)) continue
|
||||
if (codePoint === 0xfe0f || codePoint === 0x200d) continue
|
||||
width += codePoint > 0xffff || (codePoint >= 0x1100 && codePoint <= 0x115f) ? 2 : 1
|
||||
}
|
||||
return width
|
||||
}
|
||||
function padCell(value, width) {
|
||||
const text = String(value)
|
||||
return text + ' '.repeat(Math.max(0, width - cellWidth(text)))
|
||||
}
|
||||
function splitToWidth(text, width) {
|
||||
const parts = []
|
||||
let line = ''
|
||||
for (const char of String(text)) {
|
||||
const next = line + char
|
||||
if (line && cellWidth(next) > width) {
|
||||
parts.push(line)
|
||||
line = char
|
||||
} else {
|
||||
line = next
|
||||
}
|
||||
}
|
||||
if (line) parts.push(line)
|
||||
return parts
|
||||
}
|
||||
function wrapCell(value, width) {
|
||||
const words = String(value).split(/\\s+/)
|
||||
const lines = []
|
||||
let line = ''
|
||||
for (const word of words) {
|
||||
if (cellWidth(word) > width) {
|
||||
if (line) {
|
||||
lines.push(line)
|
||||
line = ''
|
||||
}
|
||||
lines.push(...splitToWidth(word, width))
|
||||
continue
|
||||
}
|
||||
const next = line ? line + ' ' + word : word
|
||||
if (line && cellWidth(next) > width) {
|
||||
lines.push(line)
|
||||
line = word
|
||||
} else {
|
||||
line = next
|
||||
}
|
||||
}
|
||||
if (line) lines.push(line)
|
||||
return lines.length ? lines : ['']
|
||||
}
|
||||
function rule(parts) {
|
||||
return parts[0] + widths.map((width) => border.horizontal.repeat(width + 2)).join(parts[1]) + parts[2]
|
||||
}
|
||||
function renderRow(cells) {
|
||||
const wrappedCells = widths.map((width, index) => wrapCell(cells[index] ?? '', width))
|
||||
const height = Math.max(...wrappedCells.map((cell) => cell.length))
|
||||
const rows = []
|
||||
for (let line = 0; line < height; line += 1) {
|
||||
rows.push(
|
||||
border.vertical +
|
||||
widths
|
||||
.map((width, index) => ' ' + padCell(wrappedCells[index][line] ?? '', width) + ' ')
|
||||
.join(border.vertical) +
|
||||
border.vertical
|
||||
)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
async function writeStdout(chunk) {
|
||||
await new Promise((resolve) => process.stdout.write(chunk, resolve))
|
||||
if (process.platform === 'win32') await new Promise((resolve) => setTimeout(resolve, 8))
|
||||
}
|
||||
const parsedRows = table
|
||||
.split(/\\r?\\n/)
|
||||
.filter((row) => row.trim().startsWith('|') && !isSeparatorRow(row))
|
||||
.map(splitMarkdownRow)
|
||||
const rendered = [rule(border.top)]
|
||||
for (const [index, row] of parsedRows.entries()) {
|
||||
rendered.push(...renderRow(row))
|
||||
rendered.push(rule(index === parsedRows.length - 1 ? border.bottom : border.middle))
|
||||
}
|
||||
await writeStdout('\\x1b[?2026h\\x1b[2J\\x1b[H')
|
||||
for (const line of rendered) {
|
||||
await writeStdout(line + '\\r\\n')
|
||||
}
|
||||
await writeStdout('\\x1b[?2026l')
|
||||
await writeStdout('${widthMarker}:' + generatedTableWidth + '\\r\\n')
|
||||
await writeStdout('${marker}\\r\\n')
|
||||
`
|
||||
}
|
||||
|
||||
export function emojiFixtureTableWidthMarker(runId: string): string {
|
||||
return `EMOJI_FIXTURE_TABLE_WIDTH_${runId}:`
|
||||
}
|
||||
|
||||
export function narrowSignerMarkdownTableScript(runId: string): string {
|
||||
const marker = `NARROW_SIGNER_TABLE_RESTORE_${runId}`
|
||||
const rows = [
|
||||
'| # | Status | Signer | Action |',
|
||||
'| ---: | --- | --- | --- |',
|
||||
'| 1 | signed | did:key:z6Mkuw5kQqz1QvZ9f3d2aB7f19f0cAC7B4F3c9E725aD19cD12e6A8B3F4c5D6e7F8a9B0c1D2e3F4a5B6c7D8e9F0a1B2c3D4e5F6a7B8c9D0e1F2 | approve deployment |',
|
||||
'| 2 | waiting | did:web:example.signing.service:teams:release:prod:primary-key-2026-06-08-with-extra-qualifiers-and-long-human-readable-suffix | counter-sign |',
|
||||
'| 3 | signed | 0x742d35Cc6634C0532925a3b844Bc454e4438f44e9E8F12A7C4D9B6530F9D2C8E7A6B5C4D3E2F1A0B998877665544332211 | archive receipt |'
|
||||
]
|
||||
const repeatedRows = Array.from({ length: 8 }, (_, index) =>
|
||||
rows.concat(`| ${index + 4} | signed | signer-row-${index}-${'a'.repeat(96)} | verify |`)
|
||||
).flat()
|
||||
return `
|
||||
const rows = ${JSON.stringify(repeatedRows)}
|
||||
async function writeStdout(chunk) {
|
||||
await new Promise((resolve) => process.stdout.write(chunk, resolve))
|
||||
if (process.platform === 'win32') await new Promise((resolve) => setTimeout(resolve, 8))
|
||||
}
|
||||
await writeStdout('\\x1b[?2026h\\x1b[2J\\x1b[H')
|
||||
for (const row of rows) {
|
||||
await writeStdout(row + '\\r\\n')
|
||||
}
|
||||
await writeStdout('\\x1b[?2026l')
|
||||
await writeStdout('${marker}\\r\\n')
|
||||
`
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { rmSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { Page, TestInfo } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
@@ -23,6 +23,14 @@ import {
|
||||
} from './terminal-column-probes'
|
||||
import { nodeTerminalCommand } from './terminal-node-command'
|
||||
import { waitForPtyShellEcho } from './terminal-pty-readiness'
|
||||
import {
|
||||
EMOJI_TABLE_FIXTURE,
|
||||
NARROW_TERMINAL_MAX_COLS,
|
||||
emojiFixtureMarkdownTableScript,
|
||||
emojiFixtureTableWidthMarker,
|
||||
longMarkdownTableScript,
|
||||
narrowSignerMarkdownTableScript
|
||||
} from './terminal-long-table-fixtures'
|
||||
|
||||
type TerminalRenderDiagnostics = {
|
||||
cols: number
|
||||
@@ -54,255 +62,6 @@ type LongTableDebugWindow = Window & {
|
||||
}
|
||||
}
|
||||
|
||||
const EMOJI_TABLE_FIXTURE = readFileSync(
|
||||
path.join(__dirname, 'fixtures', 'terminal-emoji-table.md'),
|
||||
'utf8'
|
||||
)
|
||||
const NARROW_TERMINAL_MAX_COLS = 120
|
||||
|
||||
function longMarkdownTableScript(runId: string): string {
|
||||
const names = [
|
||||
['Sam Syntax', 'Compiler', 'Online', '😀', '9200', 'Semicolons are optional (rage ensues)'],
|
||||
['Tori Token', 'Auth', 'Idle', '🚀', '4800', 'JWT expires during their standup'],
|
||||
['Uma Unpin', 'Frontend', 'Online', '🔥', '3500', 'Absolute positioning enjoyer'],
|
||||
['Vic Variable', 'Types', 'AFK', '💡', '6700', 'any is not a type, it is a cry for help'],
|
||||
['Wally Watchdog', 'Security', 'Online', '📦', '8200', 'Found a vuln in your vuln scanner'],
|
||||
['Xena XPath', 'DB', 'Idle', '🔐', '7300', 'Indexes everything, including the fridge'],
|
||||
['Yuki Yank', 'CLI', 'Online', '🎯', '5900', 'rm -rf / is not a party trick'],
|
||||
['Zane Zealot', 'OSS', 'Offline', '🤖', '10000', 'Contributor to 47 repos, sleeps never'],
|
||||
['Artie ASCII', 'Docs', 'Online', '🧠', '2900', 'Wrote a novel in README comments'],
|
||||
['Bianca Batch', 'ML', 'AFK', '💾', '9400', 'Training a model to write PR descriptions'],
|
||||
['Carlos Cache', 'CDN', 'Idle', '⚙', '4900', 'Stale data is still data'],
|
||||
['Diana Draft', 'Planning', 'Online', '📚', '1800', 'Needs 3 more sprints to estimate'],
|
||||
['Edgar Exit', 'Ops', 'Online', '🔧', '7600', 'Graceful shutdown specialist'],
|
||||
['Fiona Fallback', 'Resilience', 'Idle', '🧲', '5500', 'Circuit breaker connoisseur'],
|
||||
['Gabe Garbage', 'GC', 'Offline', '🧹', '4100', 'Stop-the-world is my catchphrase'],
|
||||
['Holly Hotfix', 'Release', 'Online', '🧪', '6300', 'Friday deploy champion'],
|
||||
['Ira Idempotent', 'API', 'AFK', '🔁', '6900', 'PUT me in coach'],
|
||||
['Jules Jitter', 'Mobile', 'Idle', '📱', '3200', 'Offline-first, coffee-second'],
|
||||
['Ken Kafka', 'Streams', 'Online', '📡', '7100', 'Rebalancing is a lifestyle'],
|
||||
['Luna Latency', 'Edge', 'Offline', '🧭', '4400', 'Response time measured in business days'],
|
||||
['Max Marshal', 'Memory', 'Online', '🧩', '8700', "Leak-free since '24"],
|
||||
['Nora Null', 'Safety', 'AFK', '❓', '3800', 'null is a person, not a value'],
|
||||
['Otto Offset', 'Cursors', 'Idle', '👆', '2600', 'Infinite scroll for the infinite soul'],
|
||||
['Pam Payload', 'Serialization', 'Online', '📦', '5800', 'JSON.stringify is my yoga'],
|
||||
['Reed Regex', 'Matching', 'Offline', '🔍', '6800', 'Now I have two problems']
|
||||
]
|
||||
return `
|
||||
const rows = ${JSON.stringify(names)}
|
||||
const widths = [16, 14, 12, 6, 7, 42]
|
||||
function isCombiningMark(codePoint) {
|
||||
return (codePoint >= 0x0300 && codePoint <= 0x036f) ||
|
||||
(codePoint >= 0xfe00 && codePoint <= 0xfe0f)
|
||||
}
|
||||
function isWideCodePoint(codePoint) {
|
||||
return codePoint > 0xffff ||
|
||||
(codePoint >= 0x1100 && codePoint <= 0x115f) ||
|
||||
(codePoint >= 0x2e80 && codePoint <= 0xa4cf) ||
|
||||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
|
||||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
||||
(codePoint >= 0xfe10 && codePoint <= 0xfe6f) ||
|
||||
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
|
||||
(codePoint >= 0xffe0 && codePoint <= 0xffe6)
|
||||
}
|
||||
function cellWidth(text) {
|
||||
let width = 0
|
||||
for (const char of String(text)) {
|
||||
const codePoint = char.codePointAt(0)
|
||||
if (codePoint === undefined || isCombiningMark(codePoint)) continue
|
||||
width += isWideCodePoint(codePoint) ? 2 : 1
|
||||
}
|
||||
return width
|
||||
}
|
||||
function cell(value, width) {
|
||||
const text = String(value)
|
||||
return text + ' '.repeat(Math.max(1, width - cellWidth(text)))
|
||||
}
|
||||
function line(parts) {
|
||||
return '| ' + parts.map((part, index) => cell(part, widths[index])).join(' | ') + ' |'
|
||||
}
|
||||
const outputRows = []
|
||||
outputRows.push(line(['Name', 'Team', 'Status', 'Icon', 'Score', 'Notes']))
|
||||
outputRows.push('|-' + widths.map((width) => '-'.repeat(width)).join('-|-') + '-|')
|
||||
for (let repeat = 0; repeat < 4; repeat += 1) {
|
||||
for (const row of rows) outputRows.push(line(row))
|
||||
}
|
||||
async function writeStdout(chunk) {
|
||||
await new Promise((resolve) => process.stdout.write(chunk, resolve))
|
||||
if (process.platform === 'win32') await new Promise((resolve) => setTimeout(resolve, 8))
|
||||
}
|
||||
await writeStdout('\\x1b[?2026h\\x1b[2J\\x1b[H')
|
||||
for (const row of outputRows) {
|
||||
await writeStdout(row + '\\n')
|
||||
}
|
||||
await writeStdout('\\x1b[?2026l')
|
||||
await writeStdout('LONG_TABLE_SCROLL_RESTORE_${runId}\\n')
|
||||
`
|
||||
}
|
||||
|
||||
function emojiFixtureMarkdownTableScript(table: string, runId: string): string {
|
||||
const marker = `EMOJI_FIXTURE_TABLE_RESTORE_${runId}`
|
||||
const widthMarker = `EMOJI_FIXTURE_TABLE_WIDTH_${runId}`
|
||||
return `
|
||||
const table = ${JSON.stringify(table)}
|
||||
const minimumWidths = [2, 5, 4, 7, 7, 4, 3, 4]
|
||||
const preferredWidths = [5, 17, 10, 18, 30, 12, 10, 10]
|
||||
const tableOverhead = preferredWidths.length * 3 + 1
|
||||
const widthBudget = Math.max(
|
||||
minimumWidths.reduce((sum, width) => sum + width, 0),
|
||||
Math.min(
|
||||
preferredWidths.reduce((sum, width) => sum + width, 0),
|
||||
(process.stdout.columns || 100) - tableOverhead - 1
|
||||
)
|
||||
)
|
||||
let remaining = widthBudget
|
||||
let remainingPreferred = preferredWidths.reduce((sum, width) => sum + width, 0)
|
||||
const widths = preferredWidths.map((preferred, index) => {
|
||||
const minimum = minimumWidths[index]
|
||||
const width = Math.max(minimum, Math.floor((remaining * preferred) / remainingPreferred))
|
||||
remaining -= width
|
||||
remainingPreferred -= preferred
|
||||
return width
|
||||
})
|
||||
const generatedTableWidth = widths.reduce((sum, width) => sum + width, 0) + tableOverhead
|
||||
const border = {
|
||||
top: ['┌', '┬', '┐'],
|
||||
middle: ['├', '┼', '┤'],
|
||||
bottom: ['└', '┴', '┘'],
|
||||
vertical: '│',
|
||||
horizontal: '─'
|
||||
}
|
||||
function splitMarkdownRow(row) {
|
||||
return row.trim().slice(1, -1).split('|').map((cell) => cell.trim())
|
||||
}
|
||||
function isSeparatorRow(row) {
|
||||
return /^\\|(?:\\s*:?-+:?\\s*\\|)+\\s*$/.test(row)
|
||||
}
|
||||
function cellWidth(text) {
|
||||
let width = 0
|
||||
for (const char of String(text)) {
|
||||
const codePoint = char.codePointAt(0)
|
||||
if (codePoint === undefined || (codePoint >= 0x0300 && codePoint <= 0x036f)) continue
|
||||
if (codePoint === 0xfe0f || codePoint === 0x200d) continue
|
||||
width += codePoint > 0xffff || (codePoint >= 0x1100 && codePoint <= 0x115f) ? 2 : 1
|
||||
}
|
||||
return width
|
||||
}
|
||||
function padCell(value, width) {
|
||||
const text = String(value)
|
||||
return text + ' '.repeat(Math.max(0, width - cellWidth(text)))
|
||||
}
|
||||
function splitToWidth(text, width) {
|
||||
const parts = []
|
||||
let line = ''
|
||||
for (const char of String(text)) {
|
||||
const next = line + char
|
||||
if (line && cellWidth(next) > width) {
|
||||
parts.push(line)
|
||||
line = char
|
||||
} else {
|
||||
line = next
|
||||
}
|
||||
}
|
||||
if (line) parts.push(line)
|
||||
return parts
|
||||
}
|
||||
function wrapCell(value, width) {
|
||||
const words = String(value).split(/\\s+/)
|
||||
const lines = []
|
||||
let line = ''
|
||||
for (const word of words) {
|
||||
if (cellWidth(word) > width) {
|
||||
if (line) {
|
||||
lines.push(line)
|
||||
line = ''
|
||||
}
|
||||
lines.push(...splitToWidth(word, width))
|
||||
continue
|
||||
}
|
||||
const next = line ? line + ' ' + word : word
|
||||
if (line && cellWidth(next) > width) {
|
||||
lines.push(line)
|
||||
line = word
|
||||
} else {
|
||||
line = next
|
||||
}
|
||||
}
|
||||
if (line) lines.push(line)
|
||||
return lines.length ? lines : ['']
|
||||
}
|
||||
function rule(parts) {
|
||||
return parts[0] + widths.map((width) => border.horizontal.repeat(width + 2)).join(parts[1]) + parts[2]
|
||||
}
|
||||
function renderRow(cells) {
|
||||
const wrappedCells = widths.map((width, index) => wrapCell(cells[index] ?? '', width))
|
||||
const height = Math.max(...wrappedCells.map((cell) => cell.length))
|
||||
const rows = []
|
||||
for (let line = 0; line < height; line += 1) {
|
||||
rows.push(
|
||||
border.vertical +
|
||||
widths
|
||||
.map((width, index) => ' ' + padCell(wrappedCells[index][line] ?? '', width) + ' ')
|
||||
.join(border.vertical) +
|
||||
border.vertical
|
||||
)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
async function writeStdout(chunk) {
|
||||
await new Promise((resolve) => process.stdout.write(chunk, resolve))
|
||||
if (process.platform === 'win32') await new Promise((resolve) => setTimeout(resolve, 8))
|
||||
}
|
||||
const parsedRows = table
|
||||
.split(/\\r?\\n/)
|
||||
.filter((row) => row.trim().startsWith('|') && !isSeparatorRow(row))
|
||||
.map(splitMarkdownRow)
|
||||
const rendered = [rule(border.top)]
|
||||
for (const [index, row] of parsedRows.entries()) {
|
||||
rendered.push(...renderRow(row))
|
||||
rendered.push(rule(index === parsedRows.length - 1 ? border.bottom : border.middle))
|
||||
}
|
||||
await writeStdout('\\x1b[?2026h\\x1b[2J\\x1b[H')
|
||||
for (const line of rendered) {
|
||||
await writeStdout(line + '\\r\\n')
|
||||
}
|
||||
await writeStdout('\\x1b[?2026l')
|
||||
await writeStdout('${widthMarker}:' + generatedTableWidth + '\\r\\n')
|
||||
await writeStdout('${marker}\\r\\n')
|
||||
`
|
||||
}
|
||||
|
||||
function emojiFixtureTableWidthMarker(runId: string): string {
|
||||
return `EMOJI_FIXTURE_TABLE_WIDTH_${runId}:`
|
||||
}
|
||||
|
||||
function narrowSignerMarkdownTableScript(runId: string): string {
|
||||
const marker = `NARROW_SIGNER_TABLE_RESTORE_${runId}`
|
||||
const rows = [
|
||||
'| # | Status | Signer | Action |',
|
||||
'| ---: | --- | --- | --- |',
|
||||
'| 1 | signed | did:key:z6Mkuw5kQqz1QvZ9f3d2aB7f19f0cAC7B4F3c9E725aD19cD12e6A8B3F4c5D6e7F8a9B0c1D2e3F4a5B6c7D8e9F0a1B2c3D4e5F6a7B8c9D0e1F2 | approve deployment |',
|
||||
'| 2 | waiting | did:web:example.signing.service:teams:release:prod:primary-key-2026-06-08-with-extra-qualifiers-and-long-human-readable-suffix | counter-sign |',
|
||||
'| 3 | signed | 0x742d35Cc6634C0532925a3b844Bc454e4438f44e9E8F12A7C4D9B6530F9D2C8E7A6B5C4D3E2F1A0B998877665544332211 | archive receipt |'
|
||||
]
|
||||
const repeatedRows = Array.from({ length: 8 }, (_, index) =>
|
||||
rows.concat(`| ${index + 4} | signed | signer-row-${index}-${'a'.repeat(96)} | verify |`)
|
||||
).flat()
|
||||
return `
|
||||
const rows = ${JSON.stringify(repeatedRows)}
|
||||
async function writeStdout(chunk) {
|
||||
await new Promise((resolve) => process.stdout.write(chunk, resolve))
|
||||
if (process.platform === 'win32') await new Promise((resolve) => setTimeout(resolve, 8))
|
||||
}
|
||||
await writeStdout('\\x1b[?2026h\\x1b[2J\\x1b[H')
|
||||
for (const row of rows) {
|
||||
await writeStdout(row + '\\r\\n')
|
||||
}
|
||||
await writeStdout('\\x1b[?2026l')
|
||||
await writeStdout('${marker}\\r\\n')
|
||||
`
|
||||
}
|
||||
|
||||
async function setNarrowTerminalViewport(page: Page): Promise<void> {
|
||||
await page.setViewportSize({ width: 900, height: 820 })
|
||||
await page.waitForTimeout(250)
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { rmSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import {
|
||||
ensureTerminalVisible,
|
||||
getAllWorktreeIds,
|
||||
switchToWorktree,
|
||||
waitForActiveWorktree,
|
||||
waitForSessionReady
|
||||
} from './helpers/store'
|
||||
import {
|
||||
getTerminalContent,
|
||||
sendToTerminal,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager
|
||||
} from './helpers/terminal'
|
||||
import { nodeTerminalCommand } from './terminal-node-command'
|
||||
import { waitForPtyShellEcho } from './terminal-pty-readiness'
|
||||
|
||||
type ViewportSample = { at: number; viewportY: number; baseY: number }
|
||||
|
||||
function scrollbackFixtureScript(runId: string): string {
|
||||
return `
|
||||
async function writeStdout(chunk) {
|
||||
await new Promise((resolve) => process.stdout.write(chunk, resolve))
|
||||
if (process.platform === 'win32') await new Promise((resolve) => setTimeout(resolve, 8))
|
||||
}
|
||||
await writeStdout('\\x1b[?2026h\\x1b[2J\\x1b[H')
|
||||
for (let index = 0; index < 180; index += 1) {
|
||||
await writeStdout('PINNED_VIEWPORT_SWITCH_${runId}_ROW_' + String(index).padStart(3, '0') + '\\n')
|
||||
}
|
||||
await writeStdout('\\x1b[?2026l')
|
||||
await writeStdout('PINNED_VIEWPORT_SWITCH_${runId}_DONE\\n')
|
||||
`
|
||||
}
|
||||
|
||||
async function closeFeatureTips(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const store = window.__store
|
||||
store?.getState().markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation'])
|
||||
if (store?.getState().activeModal === 'feature-tips') {
|
||||
store.getState().closeModal()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function pinActiveTerminalNearBottom(page: Page): Promise<{
|
||||
tabId: string
|
||||
targetViewportY: number
|
||||
baseY: number
|
||||
}> {
|
||||
return page.evaluate(() => {
|
||||
const store = window.__store
|
||||
const state = store?.getState()
|
||||
const worktreeId = state?.activeWorktreeId
|
||||
const tabId =
|
||||
state?.activeTabType === 'terminal'
|
||||
? state.activeTabId
|
||||
: worktreeId
|
||||
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
|
||||
: null
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
if (!tabId || !pane) {
|
||||
throw new Error('Active terminal pane unavailable')
|
||||
}
|
||||
const target = pane.container.querySelector<HTMLElement>('.xterm') ?? pane.container
|
||||
target.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
|
||||
deltaY: -240
|
||||
})
|
||||
)
|
||||
const buffer = pane.terminal.buffer.active
|
||||
const targetViewportY = Math.max(0, buffer.baseY - 6)
|
||||
pane.terminal.scrollToLine(targetViewportY)
|
||||
pane.container
|
||||
.querySelector<HTMLElement>('.xterm-viewport')
|
||||
?.dispatchEvent(new Event('scroll', { bubbles: true }))
|
||||
return { tabId, targetViewportY, baseY: buffer.baseY }
|
||||
})
|
||||
}
|
||||
|
||||
async function sampleTerminalViewportDuringReturn(
|
||||
page: Page,
|
||||
tabId: string,
|
||||
durationMs: number
|
||||
): Promise<ViewportSample[]> {
|
||||
return page.evaluate(
|
||||
({ tabId, durationMs }) =>
|
||||
new Promise<ViewportSample[]>((resolve) => {
|
||||
const samples: ViewportSample[] = []
|
||||
const startedAt = performance.now()
|
||||
const sample = (): void => {
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
const buffer = pane?.terminal?.buffer?.active
|
||||
if (buffer) {
|
||||
samples.push({
|
||||
at: Math.round(performance.now() - startedAt),
|
||||
viewportY: buffer.viewportY,
|
||||
baseY: buffer.baseY
|
||||
})
|
||||
}
|
||||
if (performance.now() - startedAt >= durationMs) {
|
||||
resolve(samples)
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
}),
|
||||
{ tabId, durationMs }
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('Terminal pinned viewport worktree switch', () => {
|
||||
test('does not jump or flash when returning to a viewport pinned just above bottom', async ({
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await closeFeatureTips(orcaPage)
|
||||
const firstWorktreeId = await waitForActiveWorktree(orcaPage)
|
||||
const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find(
|
||||
(id) => id !== firstWorktreeId
|
||||
)
|
||||
test.skip(!secondWorktreeId, 'pinned viewport repro needs the seeded secondary worktree')
|
||||
if (!secondWorktreeId) {
|
||||
return
|
||||
}
|
||||
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
await waitForPtyShellEcho(orcaPage, ptyId, 15_000)
|
||||
const runId = randomUUID()
|
||||
const scriptPath = path.join(testRepoPath, `.orca-pinned-viewport-${runId}.mjs`)
|
||||
writeFileSync(scriptPath, scrollbackFixtureScript(runId))
|
||||
|
||||
try {
|
||||
await sendToTerminal(orcaPage, ptyId, `${nodeTerminalCommand([scriptPath])}\r`)
|
||||
await expect
|
||||
.poll(() => getTerminalContent(orcaPage, 30_000), {
|
||||
timeout: 10_000,
|
||||
message: 'pinned viewport fixture did not reach terminal scrollback'
|
||||
})
|
||||
.toContain(`PINNED_VIEWPORT_SWITCH_${runId}_DONE`)
|
||||
|
||||
const pinned = await pinActiveTerminalNearBottom(orcaPage)
|
||||
expect(pinned.baseY).toBeGreaterThan(20)
|
||||
await orcaPage.waitForTimeout(50)
|
||||
await switchToWorktree(orcaPage, secondWorktreeId)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
await orcaPage.waitForTimeout(250)
|
||||
|
||||
const samplesPromise = sampleTerminalViewportDuringReturn(orcaPage, pinned.tabId, 450)
|
||||
await switchToWorktree(orcaPage, firstWorktreeId)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const samples = await samplesPromise
|
||||
expect(samples.length).toBeGreaterThan(0)
|
||||
expect(samples.filter((sample) => sample.viewportY <= 1)).toEqual([])
|
||||
expect(samples.filter((sample) => sample.viewportY >= sample.baseY - 1)).toEqual([])
|
||||
expect(
|
||||
samples.filter((sample) => Math.abs(sample.viewportY - pinned.targetViewportY) > 1)
|
||||
).toEqual([])
|
||||
} finally {
|
||||
rmSync(scriptPath, { force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user