mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 08:03:09 +00:00
Merge origin/main into folder style PR
This commit is contained in:
@@ -101,6 +101,17 @@ describe('Electron runtime package contract', () => {
|
||||
expect(publishLinuxStep.with.command).toBe('${{ matrix.release_command }}')
|
||||
})
|
||||
|
||||
it('keeps Linux postinstall repairing Chromium sandbox permissions', () => {
|
||||
const afterInstallScript = readFileSync(
|
||||
join(projectDir, 'resources/linux/packaging/after-install.sh'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(afterInstallScript).toContain('chrome-sandbox')
|
||||
expect(afterInstallScript).toContain('chmod 4755 "$sandbox"')
|
||||
expect(afterInstallScript).not.toContain('chmod 0755 "$sandbox"')
|
||||
})
|
||||
|
||||
it('lets release-cut tag a version that is already present on main', () => {
|
||||
const releaseWorkflow = readFileSync(
|
||||
join(projectDir, '.github/workflows/release-cut.yml'),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 1.4m">
|
||||
<title>downloads: 1.4m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 1.5m">
|
||||
<title>downloads: 1.5m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">1.4m</text>
|
||||
<text x="93.5" y="14">1.4m</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">1.5m</text>
|
||||
<text x="93.5" y="14">1.5m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 943 B After Width: | Height: | Size: 943 B |
@@ -0,0 +1,137 @@
|
||||
# GitHub Tasks Close Reason Parity
|
||||
|
||||
## Problem
|
||||
|
||||
- [TaskPage.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/TaskPage.tsx:1025) renders `GHStatusCell` with only `Open` and `Closed`.
|
||||
- [TaskPage.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/TaskPage.tsx:1064) sends `{ state: 'closed' }`, so the list path cannot choose GitHub close reasons.
|
||||
- [TaskPage.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/TaskPage.tsx:1137) styles closed issues with rose/destructive color, while GitHub treats closing as a neutral/purple completion action rather than a delete/error action.
|
||||
- [GitHubItemDialog.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/GitHubItemDialog.tsx:4960) has a separate issue-detail status popover; it must expose the same close reasons so opening an issue from Tasks does not fall back to the old Open/Closed-only UI.
|
||||
- [GitHubIssueCommentComposer.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/github/GitHubIssueCommentComposer.tsx:188) already supports close reasons in the detail composer, but the Tasks list does not.
|
||||
|
||||
## Goal
|
||||
|
||||
Bring the GitHub Tasks list status menu closer to GitHub:
|
||||
|
||||
1. Closed issue UI must stop using destructive red styling.
|
||||
2. Open issues must expose close-as-completed, close-as-not-planned, and close-as-duplicate from the status menu.
|
||||
3. Duplicate closes must require a target issue number and pass it as `duplicateOf`.
|
||||
4. Reopening still works from the same status menu.
|
||||
5. GitHub issue detail metadata must use the same close-reason and duplicate-picker behavior as the Tasks row.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No provider-generic changes for GitLab, Linear, or Jira.
|
||||
- No network-backed GitHub issue search picker in this pass; the duplicate picker uses the loaded Tasks cache plus an exact issue-number fallback.
|
||||
- No schema changes or cache migration.
|
||||
- No change to PR status behavior.
|
||||
- No automatic cross-window broadcast beyond the existing cache/list refresh mechanics; this pass updates the current renderer optimistically and relies on normal refetch in other windows.
|
||||
|
||||
## Design
|
||||
|
||||
1. Add a small TaskPage GitHub status menu model in a named module, including option metadata, duplicate-target validation, and payload construction for close reasons.
|
||||
2. Update `GHStatusCell` so `handleStateChange` accepts close options, optimistically patches only the issue `state`, and sends `stateReason` plus optional `duplicateOf`.
|
||||
3. Route issue-state mutations by issue identity:
|
||||
- If the row URL parses to `owner/repo`, use `github.project.updateIssueBySlug` / runtime `github.project.updateIssueBySlug`. This avoids closing the wrong repository for GitHub project/custom-source rows whose issue repo can differ from the caller repo.
|
||||
- Otherwise keep the existing `github.updateIssue` path with `repoPath`/`repoId`/`sourceContext`.
|
||||
4. Render the status popover with `Open`, `Close as completed`, `Close as not planned`, and `Close as duplicate` in both the Tasks row and issue-detail metadata sidebar. The duplicate row advances to a second-step picker with a back button, search field, loaded issue candidates, and an exact-number fallback.
|
||||
5. Replace rose closed-state classes with token-based primary/ring color-mix classes so the closed pill is completion-like, not destructive. Keep the open-state green treatment for parity with the existing Tasks page unless a broader status-color pass changes it.
|
||||
6. Keep the payload serializable for IPC/runtime/SSH: `{ state, stateReason, duplicateOf }` only contains strings and numbers.
|
||||
|
||||
## API Notes
|
||||
|
||||
- The local path in [issues.ts](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/main/github/issues.ts:231) already closes issues with `gh issue close`. Preserve that model and pass:
|
||||
- `--reason completed` for `stateReason: 'completed'`
|
||||
- `--reason "not planned"` for `stateReason: 'not_planned'`
|
||||
- `--duplicate-of <number>` for `stateReason: 'duplicate'` with `duplicateOf`
|
||||
- The slug path in [mutations.ts](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/main/github/project-view/mutations.ts:160) must not implement duplicates by PATCHing `duplicate_of` on `repos/{owner}/{repo}/issues/{n}`. `gh issue close --duplicate-of` is the supported CLI surface already used by the path-based mutation, so slug-addressed state changes should use `gh issue close/reopen --repo owner/repo` for state and reserve REST PATCH for title/body.
|
||||
- When `stateReason: 'duplicate'` is sent without a valid `duplicateOf`, reject it before dispatch. Do not silently downgrade to plain closed or `--reason duplicate`, because GitHub's duplicate UX expects the target issue to be recorded.
|
||||
- Keep `state_reason` REST PATCH only for non-duplicate close reasons if the implementation deliberately stays on REST for completed/not-planned. The simpler consistency target is to route all slug-addressed state changes through `gh issue close/reopen`.
|
||||
|
||||
## Data Flow
|
||||
|
||||
- User opens a GitHub issue status pill.
|
||||
- Completed/not-planned click -> `handleStateChange('closed', { stateReason })`.
|
||||
- Duplicate click -> second-step picker -> select a loaded issue or exact target number -> `handleStateChange('closed', { stateReason: 'duplicate', duplicateOf })`.
|
||||
- Cell optimistically patches local state and store row state.
|
||||
- Mutation goes through local IPC or runtime RPC with the same repo/source context.
|
||||
- Failure reverts the local/store state and shows the existing error toast.
|
||||
- Success records the existing `github-tasks` feature interaction and should invalidate or refresh the relevant work-items cache entry if the current filter can hide the row after the state changes (for example, an `is:open` filter). Without that, a closed row can remain visible until a later refetch.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- Duplicate target is blank, zero, negative, decimal, or the same issue number: keep the picker open and show inline validation after Enter.
|
||||
- Duplicate target can be a different repository's issue only when the implementation accepts a URL; this pass takes a loaded same-repository issue or number, so validation and copy should describe it as an issue in the same repository.
|
||||
- The row refreshes while the menu is open: reconcile the optimistic draft before paint as the current status cell already does.
|
||||
- Rapid repeated choices: retain the existing request id guard so stale responses cannot revert a newer choice.
|
||||
- SSH/runtime target: payload must remain serializable and use the existing runtime method.
|
||||
- Multi-window consistency: do not assume another window's in-memory `workItemsCache` observes the optimistic patch. Ensure the mutation path updates GitHub authoritatively and that normal cache invalidation/refetch behavior eventually corrects other windows.
|
||||
- Project/custom-source rows: prefer the parsed issue URL slug over the selected local repo when available, because a GitHub Project can contain issues from repositories other than the current worktree.
|
||||
- PR rows and rows without a repo continue to render a non-editable status pill.
|
||||
- Rows without a parsed slug and without a local repo/source context should stay non-editable; do not attempt a mutation from only an issue number.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Unit: menu model builds `{ state: 'closed', stateReason: 'completed' }`, `{ state: 'closed', stateReason: 'not_planned' }`, and duplicate payloads only for valid duplicate targets.
|
||||
- Unit: duplicate validation rejects missing/self/non-positive/non-integer targets.
|
||||
- Existing unit: optimistic status draft reconciliation remains unchanged.
|
||||
- Main unit: `updateIssue` emits `gh issue close --reason completed`, `gh issue close --reason "not planned"`, `gh issue close --duplicate-of <n>`, and `gh issue reopen`.
|
||||
- Main unit: `updateIssueBySlug` uses `gh issue close/reopen --repo owner/repo` for state changes, rejects duplicate without `duplicateOf`, and does not send unsupported `duplicate_of` REST fields.
|
||||
- Renderer unit: `GHStatusCell` uses slug-addressed mutation when `item.url` contains an owner/repo and falls back to `github.updateIssue` only when slug parsing is unavailable.
|
||||
- Store/cache unit: successful close from an open-filtered Tasks list invalidates or refreshes the affected work-items cache so hidden-by-filter rows do not linger indefinitely.
|
||||
- Electron validation: Tasks GitHub issue row status menu shows the new close options and closed issue pill is not red.
|
||||
- Electron validation: duplicate close picker appears after the duplicate action and supports issue search/exact-number fallback. Do not submit a real close mutation against user data.
|
||||
- Electron validation: GitHub issue detail status sidebar shows the same close options and duplicate picker as the list row.
|
||||
- Electron/runtime smoke: same menu opens and validation works when the active runtime target is an SSH/environment source; avoid a real close mutation unless using disposable test data.
|
||||
|
||||
## UI Quality Bar
|
||||
|
||||
- Popover should feel like an action menu, not an error/destructive confirmation.
|
||||
- Closed issue pill must be visually distinct from open but not red/destructive.
|
||||
- Duplicate picker must fit in the popover at desktop and narrow widths without clipping.
|
||||
- Hover, focus, disabled, and inline validation states use existing shadcn primitives/tokens.
|
||||
|
||||
## Review Screenshots
|
||||
|
||||
1. GitHub Tasks issue row with status popover open showing all close reasons.
|
||||
2. Duplicate close second-step picker with search and issue candidates.
|
||||
3. Closed issue row/pill showing non-red completion styling.
|
||||
4. GitHub issue detail sidebar status popover showing all close reasons.
|
||||
5. GitHub issue detail duplicate close second-step picker.
|
||||
6. Adjacent smoke: GitHub Tasks header/search area still renders normally.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add status menu model and tests.
|
||||
2. Update `GHStatusCell` UI and mutation payload.
|
||||
3. Run focused tests, typecheck, and lint.
|
||||
4. Run UI quality review.
|
||||
5. Validate in Electron and capture screenshots.
|
||||
|
||||
## Lightweight Eng Review
|
||||
|
||||
- Scope: kept to the GitHub Tasks status cell plus a small model module; no provider-wide or network-backed search-picker work.
|
||||
- Architecture/data flow: preserves current renderer -> IPC/runtime mutation boundaries, but must route rows with parseable GitHub slugs through `updateIssueBySlug` so project/custom-source issues mutate the issue's own repository rather than the selected worktree repo.
|
||||
- API feasibility: path-based mutations already use the supported `gh issue close --duplicate-of` CLI path; slug-addressed mutations need the same CLI close/reopen treatment instead of REST `duplicate_of`.
|
||||
- Failure modes covered:
|
||||
- Invalid duplicate targets stay local and do not mutate.
|
||||
- Failed mutations revert optimistic state using the existing request id guard.
|
||||
- Runtime/SSH remains on the existing serializable RPC method.
|
||||
- Unsupported duplicate API shape is avoided by keeping duplicate close on `gh issue close --duplicate-of`.
|
||||
- Rows filtered by open/closed status do not rely solely on optimistic patching; the relevant work-items cache must be invalidated/refetched after success.
|
||||
- Test coverage required:
|
||||
- Unit tests for close update payloads and duplicate validation.
|
||||
- Main-process tests for both `updateIssue` and `updateIssueBySlug` close/reopen command generation.
|
||||
- Renderer routing tests for slug-addressed versus repo-path-addressed issue rows.
|
||||
- Cache invalidation/refetch coverage for status-filtered Tasks lists.
|
||||
- Existing status draft tests for optimistic reconciliation.
|
||||
- Electron screenshots for menu, duplicate validation, closed styling, and adjacent header smoke.
|
||||
- Performance/blast radius: no material concern; one extra tiny model import and no new polling/IPC.
|
||||
- UI quality bar: compact GitHub-like action menu, token-based non-destructive closed styling, no clipping or overlap.
|
||||
- Required review screenshots:
|
||||
1. Status popover with close reasons.
|
||||
2. Duplicate second-step picker with search and issue candidates.
|
||||
3. Non-red closed status pill.
|
||||
4. Tasks header/search smoke.
|
||||
- Residual risks:
|
||||
- Duplicate target search is limited to loaded Tasks cache candidates, with exact-number fallback for unloaded same-repository issues.
|
||||
- Other open Orca windows may show stale status until their normal GitHub Tasks data refresh runs.
|
||||
@@ -4915,12 +4915,14 @@ export default function SessionScreen() {
|
||||
autoCorrect={false}
|
||||
spellCheck={false}
|
||||
smartInsertDelete={false}
|
||||
// Why: iOS textContentType wins over autoComplete and can
|
||||
// narrow the keyboard surface; keep IME switching available.
|
||||
autoComplete="off"
|
||||
keyboardType={getTerminalLiveInputKeyboardType(Platform.OS)}
|
||||
returnKeyType="default"
|
||||
blurOnSubmit={false}
|
||||
editable={canSend}
|
||||
importantForAutofill="no"
|
||||
textContentType="none"
|
||||
/>
|
||||
</Pressable>
|
||||
) : (
|
||||
@@ -4946,6 +4948,9 @@ export default function SessionScreen() {
|
||||
autoCorrect={autocompleteEnabled}
|
||||
spellCheck={autocompleteEnabled}
|
||||
smartInsertDelete={false}
|
||||
// Why: terminal commands are not autofill content, but the
|
||||
// keyboard must stay default so non-Latin IMEs remain selectable.
|
||||
autoComplete="off"
|
||||
keyboardType={getTerminalCommandKeyboardType(
|
||||
Platform.OS,
|
||||
autocompleteEnabled
|
||||
|
||||
@@ -11,4 +11,9 @@ describe('terminal iOS IME keyboard', () => {
|
||||
expect(sessionRouteSource).not.toContain("'ascii-capable'")
|
||||
expect(sessionRouteSource).not.toContain('"ascii-capable"')
|
||||
})
|
||||
|
||||
it('does not put terminal keyboard capture behind iOS textContentType semantics', () => {
|
||||
expect(sessionRouteSource).not.toContain('textContentType="none"')
|
||||
expect(sessionRouteSource).toContain('autoComplete="off"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1508,6 +1508,7 @@ private final class TreeRenderer {
|
||||
var focusedElementId: Int?
|
||||
var truncated = false
|
||||
var maxDepthReached = false
|
||||
private let reader = AXSnapshotReader()
|
||||
private var nextIndex = 0
|
||||
static let maxNodes = 1200
|
||||
static let maxDepth = 64
|
||||
@@ -1530,22 +1531,26 @@ private final class TreeRenderer {
|
||||
}
|
||||
guard !ancestors.contains(where: { CFEqual($0, element) }) else { return }
|
||||
|
||||
let role = stringAttribute(element, kAXRoleAttribute as String) ?? "AXUnknown"
|
||||
let children = primaryChildren(element, role: role, windowBounds: windowBounds)
|
||||
let value = valueString(element)
|
||||
let placeholder = placeholderString(element)
|
||||
let rawActions = actions(element)
|
||||
let rowSummary = rowTextSummary(element, role: role)
|
||||
let linkText = role == "AXLink" ? descendantTextSnippets(element, limit: 2, maxDepth: 3).first : nil
|
||||
let role = reader.stringAttribute(element, kAXRoleAttribute as String) ?? "AXUnknown"
|
||||
let children = reader.primaryChildren(element, role: role, windowBounds: windowBounds)
|
||||
let value = reader.valueString(element, role: role)
|
||||
let placeholder = reader.placeholderString(element)
|
||||
let rawActions = reader.actions(element)
|
||||
let rowSummary = reader.rowTextSummary(element, role: role)
|
||||
let roleDescription = reader.stringAttribute(element, kAXRoleDescriptionAttribute as String)
|
||||
let title = reader.stringAttribute(element, kAXTitleAttribute as String)
|
||||
let label = reader.stringAttribute(element, kAXDescriptionAttribute as String)
|
||||
let url = reader.stringAttribute(element, kAXURLAttribute as String)
|
||||
let linkText = role == "AXLink" ? reader.descendantTextSnippets(element, limit: 2, maxDepth: 3).first : nil
|
||||
let baseNode = SnapshotRenderNode(
|
||||
role: role,
|
||||
roleDescription: stringAttribute(element, kAXRoleDescriptionAttribute as String),
|
||||
title: stringAttribute(element, kAXTitleAttribute as String),
|
||||
label: stringAttribute(element, kAXDescriptionAttribute as String),
|
||||
roleDescription: roleDescription,
|
||||
title: title,
|
||||
label: label,
|
||||
linkText: linkText,
|
||||
value: value,
|
||||
placeholder: placeholder,
|
||||
url: stringAttribute(element, kAXURLAttribute as String),
|
||||
url: url,
|
||||
traits: [],
|
||||
rawActions: rawActions,
|
||||
childCount: children.count,
|
||||
@@ -1553,19 +1558,19 @@ private final class TreeRenderer {
|
||||
)
|
||||
let name = SnapshotRenderHeuristics.displayName(baseNode)
|
||||
let meaningful = SnapshotRenderHeuristics.meaningfulActions(rawActions, role: role)
|
||||
let localFrame = frame(element, windowBounds: windowBounds)
|
||||
let traits = traitsFor(element, role: role)
|
||||
let webAreaDepth = webAreaDepth(role: role, ancestors: ancestors)
|
||||
let summary = genericTextSummary(element, role: role, name: name, actions: meaningful, traits: traits)
|
||||
let localFrame = reader.frame(element, windowBounds: windowBounds)
|
||||
let traits = reader.traitsFor(element, role: role)
|
||||
let webAreaDepth = reader.webAreaDepth(role: role, ancestors: ancestors)
|
||||
let summary = reader.genericTextSummary(element, role: role, name: name, actions: meaningful, traits: traits)
|
||||
let node = SnapshotRenderNode(
|
||||
role: role,
|
||||
roleDescription: stringAttribute(element, kAXRoleDescriptionAttribute as String),
|
||||
title: stringAttribute(element, kAXTitleAttribute as String),
|
||||
label: stringAttribute(element, kAXDescriptionAttribute as String),
|
||||
roleDescription: roleDescription,
|
||||
title: title,
|
||||
label: label,
|
||||
linkText: linkText,
|
||||
value: value,
|
||||
placeholder: placeholder,
|
||||
url: stringAttribute(element, kAXURLAttribute as String),
|
||||
url: url,
|
||||
traits: traits,
|
||||
rawActions: rawActions,
|
||||
childCount: children.count,
|
||||
@@ -1617,6 +1622,23 @@ private final class TreeRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
private func tabStripCompaction(parent: SnapshotRenderNode, children: [AXUIElement]) -> SnapshotTabStripCompaction? {
|
||||
let childNodes = children.map { child in
|
||||
let role = reader.stringAttribute(child, kAXRoleAttribute as String) ?? "AXUnknown"
|
||||
return SnapshotRenderNode(
|
||||
role: role,
|
||||
roleDescription: reader.stringAttribute(child, kAXRoleDescriptionAttribute as String),
|
||||
title: reader.stringAttribute(child, kAXTitleAttribute as String),
|
||||
label: reader.stringAttribute(child, kAXDescriptionAttribute as String),
|
||||
value: reader.valueString(child, role: role),
|
||||
traits: reader.traitsFor(child, role: role)
|
||||
)
|
||||
}
|
||||
// Why: browsers expose every open tab through AX; retaining only the active
|
||||
// tab keeps snapshots focused on the current page instead of stale tab titles.
|
||||
return SnapshotRenderHeuristics.tabStripCompaction(parent: parent, children: childNodes)
|
||||
}
|
||||
|
||||
private func compactRenderedBrowserTabs(parent: SnapshotRenderNode, startLine: Int, depth: Int) {
|
||||
guard SnapshotRenderHeuristics.roleText(parent) == "scroll area" else { return }
|
||||
let indent = String(repeating: "\t", count: depth)
|
||||
@@ -1647,44 +1669,301 @@ private final class TreeRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
private func valueString(_ element: AXUIElement) -> String? {
|
||||
if isSecureTextElement(element) {
|
||||
return "[redacted]"
|
||||
private final class AXSnapshotReader {
|
||||
private enum CachedAttribute {
|
||||
case missing
|
||||
case found(CFTypeRef)
|
||||
}
|
||||
if let string = stringAttribute(element, kAXValueAttribute as String) {
|
||||
return string
|
||||
|
||||
private final class ElementCache {
|
||||
let element: AXUIElement
|
||||
var loadedAttributeNames = false
|
||||
var advertisedAttributes: Set<String>?
|
||||
var attributes: [String: CachedAttribute] = [:]
|
||||
var actions: [String]?
|
||||
var settable: [String: Bool] = [:]
|
||||
|
||||
init(element: AXUIElement) {
|
||||
self.element = element
|
||||
}
|
||||
}
|
||||
if let number = numberAttribute(element, kAXValueAttribute as String) {
|
||||
return number.stringValue
|
||||
|
||||
private var elementsByHash: [CFHashCode: [ElementCache]] = [:]
|
||||
|
||||
func stringAttribute(_ element: AXUIElement, _ attribute: String) -> String? {
|
||||
guard let value = copyAttribute(element, attribute) else { return nil }
|
||||
if CFGetTypeID(value) == CFStringGetTypeID(), let string = value as? String {
|
||||
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
if CFGetTypeID(value) == CFURLGetTypeID(), let url = value as? URL {
|
||||
return url.absoluteString
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func isSecureTextElement(_ element: AXUIElement) -> Bool {
|
||||
let role = stringAttribute(element, kAXRoleAttribute as String)?.lowercased() ?? ""
|
||||
let subrole = stringAttribute(element, kAXSubroleAttribute as String)?.lowercased() ?? ""
|
||||
let title = stringAttribute(element, kAXTitleAttribute as String)?.lowercased() ?? ""
|
||||
let description = stringAttribute(element, kAXDescriptionAttribute as String)?.lowercased() ?? ""
|
||||
let placeholder = placeholderString(element)?.lowercased() ?? ""
|
||||
let haystack = [role, subrole, title, description, placeholder].joined(separator: " ")
|
||||
return haystack.contains("secure") ||
|
||||
haystack.contains("password") ||
|
||||
haystack.contains("passcode") ||
|
||||
haystack.contains("verification code") ||
|
||||
haystack.contains("one-time code")
|
||||
}
|
||||
func boolAttribute(_ element: AXUIElement, _ attribute: String) -> Bool? {
|
||||
copyAttribute(element, attribute) as? Bool
|
||||
}
|
||||
|
||||
private func placeholderString(_ element: AXUIElement) -> String? {
|
||||
stringAttribute(element, "AXPlaceholderValue") ?? stringAttribute(element, "AXPlaceholder")
|
||||
}
|
||||
func numberAttribute(_ element: AXUIElement, _ attribute: String) -> NSNumber? {
|
||||
copyAttribute(element, attribute) as? NSNumber
|
||||
}
|
||||
|
||||
private func traitsFor(_ element: AXUIElement, role: String) -> [String] {
|
||||
var traits: [String] = []
|
||||
if boolAttribute(element, kAXSelectedAttribute as String) == true { traits.append("selected") }
|
||||
if boolAttribute(element, kAXExpandedAttribute as String) == true { traits.append("expanded") }
|
||||
if boolAttribute(element, kAXEnabledAttribute as String) == false { traits.append("disabled") }
|
||||
if valueSettableRoles.contains(role), isSettable(element, kAXValueAttribute as String) { traits.append("settable") }
|
||||
return traits
|
||||
func copyArray(_ element: AXUIElement, _ attribute: String) -> [AXUIElement]? {
|
||||
copyAttribute(element, attribute) as? [AXUIElement]
|
||||
}
|
||||
|
||||
func actions(_ element: AXUIElement) -> [String] {
|
||||
let cache = cache(for: element)
|
||||
if let actions = cache.actions {
|
||||
return actions
|
||||
}
|
||||
var value: CFArray?
|
||||
let actions = AXUIElementCopyActionNames(element, &value) == .success ? value as? [String] ?? [] : []
|
||||
cache.actions = actions
|
||||
return actions
|
||||
}
|
||||
|
||||
func isSettable(_ element: AXUIElement, _ attribute: String) -> Bool {
|
||||
let cache = cache(for: element)
|
||||
if let cached = cache.settable[attribute] {
|
||||
return cached
|
||||
}
|
||||
var settable = DarwinBoolean(false)
|
||||
let value = AXUIElementIsAttributeSettable(element, attribute as CFString, &settable) == .success && settable.boolValue
|
||||
cache.settable[attribute] = value
|
||||
return value
|
||||
}
|
||||
|
||||
func frame(_ element: AXUIElement, windowBounds: CGRect) -> CGRect? {
|
||||
guard let absolute = absoluteFrame(element) else { return nil }
|
||||
return CGRect(
|
||||
x: absolute.minX - windowBounds.minX,
|
||||
y: absolute.minY - windowBounds.minY,
|
||||
width: absolute.width,
|
||||
height: absolute.height
|
||||
)
|
||||
}
|
||||
|
||||
func primaryChildren(_ element: AXUIElement, role: String, windowBounds: CGRect) -> [AXUIElement] {
|
||||
if usesRowsAsPrimaryChildren(role: role), let rows = copyArray(element, kAXRowsAttribute as String), !rows.isEmpty {
|
||||
return visibleRows(rows, parent: element, windowBounds: windowBounds)
|
||||
}
|
||||
return copyArray(element, kAXChildrenAttribute as String) ?? []
|
||||
}
|
||||
|
||||
func valueString(_ element: AXUIElement, role: String) -> String? {
|
||||
if isSecureTextElement(element, role: role) {
|
||||
return "[redacted]"
|
||||
}
|
||||
if let string = stringAttribute(element, kAXValueAttribute as String) {
|
||||
return string
|
||||
}
|
||||
if let number = numberAttribute(element, kAXValueAttribute as String) {
|
||||
return number.stringValue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func placeholderString(_ element: AXUIElement) -> String? {
|
||||
stringAttribute(element, "AXPlaceholderValue") ?? stringAttribute(element, "AXPlaceholder")
|
||||
}
|
||||
|
||||
func traitsFor(_ element: AXUIElement, role: String) -> [String] {
|
||||
var traits: [String] = []
|
||||
if boolAttribute(element, kAXSelectedAttribute as String) == true { traits.append("selected") }
|
||||
if boolAttribute(element, kAXExpandedAttribute as String) == true { traits.append("expanded") }
|
||||
if boolAttribute(element, kAXEnabledAttribute as String) == false { traits.append("disabled") }
|
||||
if valueSettableRoles.contains(role), isSettable(element, kAXValueAttribute as String) { traits.append("settable") }
|
||||
return traits
|
||||
}
|
||||
|
||||
func genericTextSummary(
|
||||
_ element: AXUIElement,
|
||||
role: String,
|
||||
name: String?,
|
||||
actions: [String],
|
||||
traits: [String]
|
||||
) -> String? {
|
||||
guard (role == kAXGroupRole as String || role == kAXUnknownRole as String),
|
||||
name == nil,
|
||||
actions.isEmpty,
|
||||
traits.isEmpty,
|
||||
isPlainTextSubtree(element, maxDepth: 4)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let texts = descendantTextSnippets(element, limit: 8, maxDepth: 4)
|
||||
guard texts.count >= 2 else { return nil }
|
||||
let summary = texts.joined(separator: " ")
|
||||
guard summary.count <= 220 else { return nil }
|
||||
return summary
|
||||
}
|
||||
|
||||
func rowTextSummary(_ element: AXUIElement, role: String) -> String? {
|
||||
guard ["AXRow", "AXCell", "AXOutlineRow"].contains(role) else { return nil }
|
||||
let texts = descendantTextSnippets(element, limit: 6, maxDepth: 3)
|
||||
guard !texts.isEmpty else { return nil }
|
||||
return texts.joined(separator: " ")
|
||||
}
|
||||
|
||||
func descendantTextSnippets(_ element: AXUIElement, limit: Int, maxDepth: Int) -> [String] {
|
||||
var values: [String] = []
|
||||
var seen = Set<String>()
|
||||
|
||||
func collect(_ node: AXUIElement, depth: Int) {
|
||||
guard values.count < limit, depth <= maxDepth else { return }
|
||||
let role = stringAttribute(node, kAXRoleAttribute as String) ?? ""
|
||||
if role == kAXStaticTextRole as String || role == "AXLink" {
|
||||
for candidate in [
|
||||
stringAttribute(node, kAXValueAttribute as String),
|
||||
stringAttribute(node, kAXTitleAttribute as String),
|
||||
stringAttribute(node, kAXDescriptionAttribute as String),
|
||||
] {
|
||||
guard let candidate else { continue }
|
||||
let text = preview(candidate, maxLength: 80)
|
||||
guard !text.isEmpty, seen.insert(text).inserted else { continue }
|
||||
values.append(text)
|
||||
if values.count >= limit { return }
|
||||
}
|
||||
}
|
||||
for child in copyArray(node, kAXChildrenAttribute as String) ?? [] {
|
||||
collect(child, depth: depth + 1)
|
||||
if values.count >= limit { return }
|
||||
}
|
||||
}
|
||||
|
||||
collect(element, depth: 0)
|
||||
return values
|
||||
}
|
||||
|
||||
func webAreaDepth(role: String, ancestors: [AXUIElement]) -> Int? {
|
||||
if role == "AXWebArea" { return 0 }
|
||||
guard let index = ancestors.firstIndex(where: { stringAttribute($0, kAXRoleAttribute as String) == "AXWebArea" }) else {
|
||||
return nil
|
||||
}
|
||||
return ancestors.count - index
|
||||
}
|
||||
|
||||
private func copyAttribute(_ element: AXUIElement, _ attribute: String) -> CFTypeRef? {
|
||||
let cache = cache(for: element)
|
||||
if let cached = cache.attributes[attribute] {
|
||||
switch cached {
|
||||
case .missing:
|
||||
return nil
|
||||
case let .found(value):
|
||||
return value
|
||||
}
|
||||
}
|
||||
if let advertisedAttributes = advertisedAttributes(cache),
|
||||
!SnapshotRenderHeuristics.supportsAttribute(attribute, advertisedAttributes: advertisedAttributes) {
|
||||
cache.attributes[attribute] = .missing
|
||||
return nil
|
||||
}
|
||||
var value: CFTypeRef?
|
||||
guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success, let value else {
|
||||
cache.attributes[attribute] = .missing
|
||||
return nil
|
||||
}
|
||||
cache.attributes[attribute] = .found(value)
|
||||
return value
|
||||
}
|
||||
|
||||
private func advertisedAttributes(_ cache: ElementCache) -> Set<String>? {
|
||||
if cache.loadedAttributeNames {
|
||||
return cache.advertisedAttributes
|
||||
}
|
||||
cache.loadedAttributeNames = true
|
||||
var value: CFArray?
|
||||
guard AXUIElementCopyAttributeNames(cache.element, &value) == .success, let attributes = value as? [String] else {
|
||||
return nil
|
||||
}
|
||||
cache.advertisedAttributes = Set(attributes)
|
||||
return cache.advertisedAttributes
|
||||
}
|
||||
|
||||
private func absoluteFrame(_ element: AXUIElement) -> CGRect? {
|
||||
guard let positionValue = copyAttribute(element, kAXPositionAttribute as String),
|
||||
let sizeValue = copyAttribute(element, kAXSizeAttribute as String)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
var point = CGPoint.zero
|
||||
var size = CGSize.zero
|
||||
guard AXValueGetValue(positionValue as! AXValue, .cgPoint, &point),
|
||||
AXValueGetValue(sizeValue as! AXValue, .cgSize, &size)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return CGRect(origin: point, size: size)
|
||||
}
|
||||
|
||||
private func visibleRows(_ rows: [AXUIElement], parent: AXUIElement, windowBounds: CGRect) -> [AXUIElement] {
|
||||
guard let parentFrame = frame(parent, windowBounds: windowBounds) else {
|
||||
return Array(rows.prefix(20))
|
||||
}
|
||||
let visible = rows.filter { row in
|
||||
guard let rowFrame = frame(row, windowBounds: windowBounds) else { return false }
|
||||
return rowFrame.intersects(parentFrame)
|
||||
}
|
||||
return Array((visible.isEmpty ? rows : visible).prefix(20))
|
||||
}
|
||||
|
||||
private func isSecureTextElement(_ element: AXUIElement, role: String) -> Bool {
|
||||
guard SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: role) else {
|
||||
return false
|
||||
}
|
||||
let haystack = [
|
||||
role,
|
||||
stringAttribute(element, kAXSubroleAttribute as String) ?? "",
|
||||
stringAttribute(element, kAXTitleAttribute as String) ?? "",
|
||||
stringAttribute(element, kAXDescriptionAttribute as String) ?? "",
|
||||
placeholderString(element) ?? "",
|
||||
].joined(separator: " ").lowercased()
|
||||
return haystack.contains("secure") ||
|
||||
haystack.contains("password") ||
|
||||
haystack.contains("passcode") ||
|
||||
haystack.contains("verification code") ||
|
||||
haystack.contains("one-time code")
|
||||
}
|
||||
|
||||
private func isPlainTextSubtree(_ element: AXUIElement, maxDepth: Int) -> Bool {
|
||||
var sawText = false
|
||||
let allowedContainerRoles: Set<String> = [
|
||||
kAXGroupRole as String,
|
||||
kAXUnknownRole as String,
|
||||
kAXStaticTextRole as String,
|
||||
"AXLink",
|
||||
"AXImage",
|
||||
]
|
||||
|
||||
func visit(_ node: AXUIElement, depth: Int) -> Bool {
|
||||
guard depth <= maxDepth else { return false }
|
||||
let role = stringAttribute(node, kAXRoleAttribute as String) ?? "AXUnknown"
|
||||
guard allowedContainerRoles.contains(role) else { return false }
|
||||
if role == kAXStaticTextRole as String || role == "AXLink" {
|
||||
sawText = true
|
||||
}
|
||||
guard SnapshotRenderHeuristics.meaningfulActions(actions(node), role: role).isEmpty else { return false }
|
||||
for child in copyArray(node, kAXChildrenAttribute as String) ?? [] {
|
||||
guard visit(child, depth: depth + 1) else { return false }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return visit(element, depth: 0) && sawText
|
||||
}
|
||||
|
||||
private func cache(for element: AXUIElement) -> ElementCache {
|
||||
let hash = CFHash(element)
|
||||
if let cache = elementsByHash[hash]?.first(where: { CFEqual($0.element, element) }) {
|
||||
return cache
|
||||
}
|
||||
let cache = ElementCache(element: element)
|
||||
elementsByHash[hash, default: []].append(cache)
|
||||
return cache
|
||||
}
|
||||
}
|
||||
|
||||
private let valueSettableRoles: Set<String> = [
|
||||
@@ -1697,24 +1976,6 @@ private let valueSettableRoles: Set<String> = [
|
||||
kAXTextFieldRole as String,
|
||||
]
|
||||
|
||||
private func primaryChildren(_ element: AXUIElement, role: String, windowBounds: CGRect) -> [AXUIElement] {
|
||||
if usesRowsAsPrimaryChildren(role: role), let rows = copyArray(element, kAXRowsAttribute as String), !rows.isEmpty {
|
||||
return visibleRows(rows, parent: element, windowBounds: windowBounds)
|
||||
}
|
||||
return copyArray(element, kAXChildrenAttribute as String) ?? []
|
||||
}
|
||||
|
||||
private func visibleRows(_ rows: [AXUIElement], parent: AXUIElement, windowBounds: CGRect) -> [AXUIElement] {
|
||||
guard let parentFrame = frame(parent, windowBounds: windowBounds) else {
|
||||
return Array(rows.prefix(20))
|
||||
}
|
||||
let visible = rows.filter { row in
|
||||
guard let rowFrame = frame(row, windowBounds: windowBounds) else { return false }
|
||||
return rowFrame.intersects(parentFrame)
|
||||
}
|
||||
return Array((visible.isEmpty ? rows : visible).prefix(20))
|
||||
}
|
||||
|
||||
private func usesRowsAsPrimaryChildren(role: String) -> Bool {
|
||||
[
|
||||
kAXBrowserRole as String,
|
||||
@@ -1724,23 +1985,6 @@ private func usesRowsAsPrimaryChildren(role: String) -> Bool {
|
||||
].contains(role)
|
||||
}
|
||||
|
||||
private func tabStripCompaction(parent: SnapshotRenderNode, children: [AXUIElement]) -> SnapshotTabStripCompaction? {
|
||||
let childNodes = children.map { child in
|
||||
let role = stringAttribute(child, kAXRoleAttribute as String) ?? "AXUnknown"
|
||||
return SnapshotRenderNode(
|
||||
role: role,
|
||||
roleDescription: stringAttribute(child, kAXRoleDescriptionAttribute as String),
|
||||
title: stringAttribute(child, kAXTitleAttribute as String),
|
||||
label: stringAttribute(child, kAXDescriptionAttribute as String),
|
||||
value: valueString(child),
|
||||
traits: traitsFor(child, role: role)
|
||||
)
|
||||
}
|
||||
// Why: browsers expose every open tab through AX; retaining only the active
|
||||
// tab keeps snapshots focused on the current page instead of stale tab titles.
|
||||
return SnapshotRenderHeuristics.tabStripCompaction(parent: parent, children: childNodes)
|
||||
}
|
||||
|
||||
private func isDirectRenderedBrowserTabLine(_ line: String, indent: String) -> Bool {
|
||||
guard line.hasPrefix(indent), !line.dropFirst(indent.count).hasPrefix("\t") else {
|
||||
return false
|
||||
@@ -1761,100 +2005,6 @@ private func renderedElementIndex(_ line: String, indent: String) -> Int? {
|
||||
return Int(digits)
|
||||
}
|
||||
|
||||
private func genericTextSummary(
|
||||
_ element: AXUIElement,
|
||||
role: String,
|
||||
name: String?,
|
||||
actions: [String],
|
||||
traits: [String]
|
||||
) -> String? {
|
||||
guard (role == kAXGroupRole as String || role == kAXUnknownRole as String),
|
||||
name == nil,
|
||||
actions.isEmpty,
|
||||
traits.isEmpty,
|
||||
isPlainTextSubtree(element, maxDepth: 4)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let texts = descendantTextSnippets(element, limit: 8, maxDepth: 4)
|
||||
guard texts.count >= 2 else { return nil }
|
||||
let summary = texts.joined(separator: " ")
|
||||
guard summary.count <= 220 else { return nil }
|
||||
return summary
|
||||
}
|
||||
|
||||
private func rowTextSummary(_ element: AXUIElement, role: String) -> String? {
|
||||
guard ["AXRow", "AXCell", "AXOutlineRow"].contains(role) else { return nil }
|
||||
let texts = descendantTextSnippets(element, limit: 6, maxDepth: 3)
|
||||
guard !texts.isEmpty else { return nil }
|
||||
return texts.joined(separator: " ")
|
||||
}
|
||||
|
||||
private func isPlainTextSubtree(_ element: AXUIElement, maxDepth: Int) -> Bool {
|
||||
var sawText = false
|
||||
let allowedContainerRoles: Set<String> = [
|
||||
kAXGroupRole as String,
|
||||
kAXUnknownRole as String,
|
||||
kAXStaticTextRole as String,
|
||||
"AXLink",
|
||||
"AXImage",
|
||||
]
|
||||
|
||||
func visit(_ node: AXUIElement, depth: Int) -> Bool {
|
||||
guard depth <= maxDepth else { return false }
|
||||
let role = stringAttribute(node, kAXRoleAttribute as String) ?? "AXUnknown"
|
||||
guard allowedContainerRoles.contains(role) else { return false }
|
||||
if role == kAXStaticTextRole as String || role == "AXLink" {
|
||||
sawText = true
|
||||
}
|
||||
guard SnapshotRenderHeuristics.meaningfulActions(actions(node), role: role).isEmpty else { return false }
|
||||
for child in copyArray(node, kAXChildrenAttribute as String) ?? [] {
|
||||
guard visit(child, depth: depth + 1) else { return false }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return visit(element, depth: 0) && sawText
|
||||
}
|
||||
|
||||
private func descendantTextSnippets(_ element: AXUIElement, limit: Int, maxDepth: Int) -> [String] {
|
||||
var values: [String] = []
|
||||
var seen = Set<String>()
|
||||
|
||||
func collect(_ node: AXUIElement, depth: Int) {
|
||||
guard values.count < limit, depth <= maxDepth else { return }
|
||||
let role = stringAttribute(node, kAXRoleAttribute as String) ?? ""
|
||||
if role == kAXStaticTextRole as String || role == "AXLink" {
|
||||
for candidate in [
|
||||
stringAttribute(node, kAXValueAttribute as String),
|
||||
stringAttribute(node, kAXTitleAttribute as String),
|
||||
stringAttribute(node, kAXDescriptionAttribute as String),
|
||||
] {
|
||||
guard let candidate else { continue }
|
||||
let text = preview(candidate, maxLength: 80)
|
||||
guard !text.isEmpty, seen.insert(text).inserted else { continue }
|
||||
values.append(text)
|
||||
if values.count >= limit { return }
|
||||
}
|
||||
}
|
||||
for child in copyArray(node, kAXChildrenAttribute as String) ?? [] {
|
||||
collect(child, depth: depth + 1)
|
||||
if values.count >= limit { return }
|
||||
}
|
||||
}
|
||||
|
||||
collect(element, depth: 0)
|
||||
return values
|
||||
}
|
||||
|
||||
private func webAreaDepth(role: String, ancestors: [AXUIElement]) -> Int? {
|
||||
if role == "AXWebArea" { return 0 }
|
||||
guard let index = ancestors.firstIndex(where: { stringAttribute($0, kAXRoleAttribute as String) == "AXWebArea" }) else {
|
||||
return nil
|
||||
}
|
||||
return ancestors.count - index
|
||||
}
|
||||
|
||||
private func sanitize(_ value: String) -> String {
|
||||
value.replacingOccurrences(of: "\n", with: " ").replacingOccurrences(of: "\r", with: " ")
|
||||
}
|
||||
|
||||
@@ -60,6 +60,20 @@ public struct SnapshotTabStripCompaction: Equatable {
|
||||
}
|
||||
|
||||
public enum SnapshotRenderHeuristics {
|
||||
public static func shouldProbeSecureTextMetadata(role: String) -> Bool {
|
||||
let normalized = role.lowercased()
|
||||
return normalized.contains("text") ||
|
||||
normalized.contains("field") ||
|
||||
normalized.contains("password") ||
|
||||
normalized.contains("search") ||
|
||||
normalized.contains("combo")
|
||||
}
|
||||
|
||||
public static func supportsAttribute(_ attribute: String, advertisedAttributes: Set<String>?) -> Bool {
|
||||
guard let advertisedAttributes else { return true }
|
||||
return advertisedAttributes.contains(attribute)
|
||||
}
|
||||
|
||||
public static func displayName(_ node: SnapshotRenderNode) -> String? {
|
||||
if let title = clean(node.title) {
|
||||
return title
|
||||
|
||||
+18
@@ -2,6 +2,24 @@ import XCTest
|
||||
@testable import OrcaComputerUseMacOSCore
|
||||
|
||||
final class SnapshotRenderingTests: XCTestCase {
|
||||
func testSkipsUnsupportedAdvertisedAttributes() {
|
||||
let advertised: Set<String> = ["AXRole", "AXChildren"]
|
||||
|
||||
XCTAssertTrue(SnapshotRenderHeuristics.supportsAttribute("AXRole", advertisedAttributes: advertised))
|
||||
XCTAssertFalse(SnapshotRenderHeuristics.supportsAttribute("AXTitle", advertisedAttributes: advertised))
|
||||
}
|
||||
|
||||
func testUnknownAttributeAdvertisementsStayPermissive() {
|
||||
XCTAssertTrue(SnapshotRenderHeuristics.supportsAttribute("AXTitle", advertisedAttributes: nil))
|
||||
}
|
||||
|
||||
func testSecureTextMetadataOnlyProbesTextLikeRoles() {
|
||||
XCTAssertTrue(SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: "AXTextField"))
|
||||
XCTAssertTrue(SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: "AXSearchField"))
|
||||
XCTAssertFalse(SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: "AXGroup"))
|
||||
XCTAssertFalse(SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: "AXButton"))
|
||||
}
|
||||
|
||||
func testElidesAnonymousWrappers() {
|
||||
let node = SnapshotRenderNode(role: "AXGroup", childCount: 1)
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.92-rc.0",
|
||||
"version": "1.4.95-rc.0",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
|
||||
@@ -12,6 +12,13 @@ set -e
|
||||
link="/usr/bin/orca-ide"
|
||||
|
||||
for dir in /opt/Orca /opt/orca-ide /opt/orca; do
|
||||
sandbox="$dir/chrome-sandbox"
|
||||
if [ -f "$sandbox" ]; then
|
||||
# Why: packaged Linux installs must leave Chromium's sandbox helper usable
|
||||
# on hosts where unprivileged user namespaces are unavailable.
|
||||
chmod 4755 "$sandbox" || true
|
||||
fi
|
||||
|
||||
shim="$dir/resources/bin/orca-ide"
|
||||
if [ -x "$shim" ]; then
|
||||
# Only manage our own symlink; never clobber an unrelated /usr/bin/orca-ide.
|
||||
|
||||
@@ -4,7 +4,7 @@ description: >-
|
||||
Use the public `orca` CLI to operate Orca-managed worktrees, folder contexts,
|
||||
terminals, repos, automations, worktree comments, and the browser embedded
|
||||
inside the Orca app. Use when the user says "$orca-cli", "use orca cli",
|
||||
"Orca worktree", "child worktree", "spawn codex/claude in a worktree",
|
||||
"Orca worktree", "child worktree", "cardStatus", "spawn codex/claude in a worktree",
|
||||
"read/wait/send Orca terminal", "terminal send", "Orca browser", or "control
|
||||
the browser inside Orca". Prefer this over raw `git worktree`, ad hoc PTYs,
|
||||
Playwright, or Computer Use when the task touches Orca-managed state. Use
|
||||
@@ -61,6 +61,7 @@ orca worktree create --name child-task --agent codex --prompt "hi" --json
|
||||
orca worktree create --name independent-task --no-parent --json
|
||||
orca worktree set --worktree id:<worktreeId> --display-name "My Task" --json
|
||||
orca worktree set --worktree active --comment "reproduced bug; testing fix" --json
|
||||
orca worktree set --worktree active --workspace-status in-review --json
|
||||
orca worktree rm --worktree id:<worktreeId> --force --json
|
||||
```
|
||||
|
||||
@@ -108,6 +109,8 @@ orca worktree set --worktree active --comment "fix implemented; running integrat
|
||||
|
||||
Update after meaningful state changes such as repro, fix, validation, handoff, or blocker. Keep comments short/current; failures are best-effort unless Orca state was requested.
|
||||
|
||||
Card status uses `--workspace-status <id>`; defaults are `todo`, `in-progress`, `in-review`, `completed`.
|
||||
|
||||
## Terminals
|
||||
|
||||
Common commands:
|
||||
|
||||
@@ -7,11 +7,10 @@ import {
|
||||
parseRovoSessionFile
|
||||
} from './session-scanner-graph-parsers'
|
||||
import { parseKimiSessionFile } from './session-scanner-kimi-parser'
|
||||
import {
|
||||
parseClaudeSessionFile,
|
||||
parseCodexSessionFile,
|
||||
parseGeminiSessionFile
|
||||
} from './session-scanner-primary-parsers'
|
||||
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
|
||||
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
|
||||
import { parseClaudeSessionFile, parseGeminiSessionFile } from './session-scanner-primary-parsers'
|
||||
import { parseCodexSessionFile } from './session-scanner-codex-parser'
|
||||
import {
|
||||
parseCopilotSessionFile,
|
||||
parseCursorSessionFile,
|
||||
@@ -20,6 +19,15 @@ import {
|
||||
} from './session-scanner-secondary-parsers'
|
||||
import type { SessionFileCandidate } from './session-scanner-types'
|
||||
|
||||
/**
|
||||
* Parse a single agent session file into an `AiVaultSession`. Routes to the
|
||||
* appropriate agent-specific parser based on `candidate.agent`. For OpenCode
|
||||
* SQLite candidates (synthetic `db#id` paths), routes to
|
||||
* `parseOpenCodeSqliteSession` instead of the legacy JSON parser.
|
||||
* @param candidate - The session file candidate to parse.
|
||||
* @param platform - The platform to use for resume command generation.
|
||||
* @returns The parsed `AiVaultSession`, or `null` if parsing fails.
|
||||
*/
|
||||
export async function parseAgentSessionFile(
|
||||
candidate: SessionFileCandidate,
|
||||
platform: NodeJS.Platform
|
||||
@@ -35,8 +43,20 @@ export async function parseAgentSessionFile(
|
||||
return parseCopilotSessionFile(candidate.file, platform)
|
||||
case 'cursor':
|
||||
return parseCursorSessionFile(candidate.file, platform)
|
||||
case 'opencode':
|
||||
case 'opencode': {
|
||||
// Why: OpenCode 1.17.x sessions are read from SQLite via a synthetic
|
||||
// <dbPath>#<sessionId> candidate path. Legacy file-based sessions use
|
||||
// real filesystem paths and fall through to the JSON parser.
|
||||
const sqliteCandidate = splitOpenCodeSqliteCandidate(candidate.file.path)
|
||||
if (sqliteCandidate) {
|
||||
return parseOpenCodeSqliteSession({
|
||||
dbPath: sqliteCandidate.dbPath,
|
||||
sessionId: sqliteCandidate.sessionId,
|
||||
platform
|
||||
})
|
||||
}
|
||||
return parseOpenCodeSessionFile(candidate.file, platform)
|
||||
}
|
||||
case 'grok':
|
||||
return parseGrokSessionFile(candidate.file, platform)
|
||||
case 'hermes':
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { mkdir, mkdtemp, rm, stat, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { dirname, join } from 'path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { parseCodexSessionFile } from './session-scanner-codex-parser'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
||||
tempRoots = []
|
||||
})
|
||||
|
||||
function jsonLines(records: unknown[]): string {
|
||||
return records.map((record) => JSON.stringify(record)).join('\n')
|
||||
}
|
||||
|
||||
describe('parseCodexSessionFile', () => {
|
||||
it('does not double-count usage when token count formats switch', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-token-switch-'))
|
||||
tempRoots.push(root)
|
||||
const sessionPath = join(root, 'sessions', '2026', '06', '18', 'rollout-token-switch.jsonl')
|
||||
await mkdir(dirname(sessionPath), { recursive: true })
|
||||
|
||||
await writeFile(
|
||||
sessionPath,
|
||||
jsonLines([
|
||||
{
|
||||
timestamp: '2026-06-18T10:00:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: { id: 'token-format-switch', cwd: '/repo/app' }
|
||||
},
|
||||
{
|
||||
timestamp: '2026-06-18T10:00:01.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Measure Codex token totals' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
timestamp: '2026-06-18T10:00:02.000Z',
|
||||
type: 'event_msg',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: {
|
||||
last_token_usage: {
|
||||
input_tokens: 70,
|
||||
cached_input_tokens: 20,
|
||||
output_tokens: 30,
|
||||
total_tokens: 100
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
timestamp: '2026-06-18T10:00:03.000Z',
|
||||
type: 'event_msg',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: {
|
||||
total_token_usage: {
|
||||
input_tokens: 90,
|
||||
cached_input_tokens: 25,
|
||||
output_tokens: 60,
|
||||
total_tokens: 150
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const sessionStat = await stat(sessionPath)
|
||||
const session = await parseCodexSessionFile(
|
||||
{
|
||||
path: sessionPath,
|
||||
mtimeMs: sessionStat.mtimeMs,
|
||||
modifiedAt: sessionStat.mtime.toISOString()
|
||||
},
|
||||
'darwin',
|
||||
root
|
||||
)
|
||||
|
||||
expect(session?.totalTokens).toBe(150)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,273 @@
|
||||
import { createReadStream } from 'fs'
|
||||
import { stat } from 'fs/promises'
|
||||
import { basename, dirname, join } from 'path'
|
||||
import { createInterface } from 'readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import {
|
||||
addPreviewContent,
|
||||
createAccumulator,
|
||||
finalizeSession,
|
||||
sessionIdFromFileName,
|
||||
updateTimeline
|
||||
} from './session-scanner-accumulator'
|
||||
import type { CodexUsageSnapshot, FileWithMtime } from './session-scanner-types'
|
||||
import {
|
||||
asRecord,
|
||||
extractContentText,
|
||||
extractGitBranch,
|
||||
extractModel,
|
||||
extractString,
|
||||
normalizeCodexUsage,
|
||||
normalizeTitleText,
|
||||
parseJsonObject,
|
||||
subtractCodexUsage
|
||||
} from './session-scanner-values'
|
||||
|
||||
const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl'
|
||||
|
||||
type CodexSessionIndexTitleCacheEntry = {
|
||||
signature: string
|
||||
titles: Map<string, string>
|
||||
}
|
||||
|
||||
const codexSessionIndexTitleCache = new Map<string, Promise<CodexSessionIndexTitleCacheEntry>>()
|
||||
|
||||
export async function parseCodexSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
codexHome: string | null = null
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'codex',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
let previousTotals: CodexUsageSnapshot | null = null
|
||||
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
|
||||
for await (const line of lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
|
||||
updateTimeline(accumulator, extractString(record.timestamp))
|
||||
|
||||
const payload = asRecord(record.payload)
|
||||
if (record.type === 'session_meta' && payload) {
|
||||
if (isCodexWorkerSession(payload)) {
|
||||
// Why: Codex writes internal worker/sub-agent transcripts into the same
|
||||
// history tree; AI Vault should show user-started sessions only.
|
||||
return null
|
||||
}
|
||||
const sessionId = extractString(payload.id)
|
||||
if (sessionId) {
|
||||
accumulator.sessionId = sessionId
|
||||
}
|
||||
const indexedTitle =
|
||||
extractCodexSessionMetadataTitle(payload) ??
|
||||
(await readCodexSessionIndexTitle(file.path, codexHome, accumulator.sessionId))
|
||||
if (indexedTitle) {
|
||||
accumulator.title = indexedTitle
|
||||
}
|
||||
const cwd = extractString(payload.cwd)
|
||||
if (cwd) {
|
||||
accumulator.cwd = cwd
|
||||
}
|
||||
accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch
|
||||
continue
|
||||
}
|
||||
|
||||
if (record.type === 'turn_context' && payload) {
|
||||
const cwd = extractString(payload.cwd)
|
||||
if (cwd) {
|
||||
accumulator.cwd = cwd
|
||||
}
|
||||
const model = extractModel(payload)
|
||||
if (model) {
|
||||
accumulator.model = model
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (record.type === 'response_item' && payload.type === 'message') {
|
||||
accumulator.messageCount++
|
||||
if (payload.role === 'user' && !accumulator.title) {
|
||||
accumulator.title = extractContentText(payload.content)
|
||||
}
|
||||
addPreviewContent(
|
||||
accumulator,
|
||||
payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown',
|
||||
payload.content,
|
||||
record.timestamp
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (record.type !== 'event_msg') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (payload.type === 'user_message') {
|
||||
accumulator.messageCount++
|
||||
if (!accumulator.title) {
|
||||
accumulator.title = extractContentText(payload.message)
|
||||
}
|
||||
addPreviewContent(accumulator, 'user', payload.message, record.timestamp)
|
||||
continue
|
||||
}
|
||||
|
||||
if (payload.type === 'agent_message') {
|
||||
accumulator.messageCount++
|
||||
addPreviewContent(accumulator, 'assistant', payload.message, record.timestamp)
|
||||
continue
|
||||
}
|
||||
|
||||
if (payload.type !== 'token_count') {
|
||||
continue
|
||||
}
|
||||
|
||||
const info = asRecord(payload.info)
|
||||
if (!info) {
|
||||
continue
|
||||
}
|
||||
const totalUsage = normalizeCodexUsage(info.total_token_usage)
|
||||
const lastUsage = normalizeCodexUsage(info.last_token_usage)
|
||||
let delta: CodexUsageSnapshot | null = null
|
||||
if (totalUsage) {
|
||||
delta = subtractCodexUsage(totalUsage, previousTotals)
|
||||
previousTotals = totalUsage
|
||||
} else if (lastUsage) {
|
||||
delta = lastUsage
|
||||
previousTotals = previousTotals ? addCodexUsage(previousTotals, lastUsage) : lastUsage
|
||||
}
|
||||
if (delta) {
|
||||
accumulator.totalTokens += delta.totalTokens
|
||||
}
|
||||
const model = extractModel(payload)
|
||||
if (model) {
|
||||
accumulator.model = model
|
||||
}
|
||||
}
|
||||
|
||||
return finalizeSession(accumulator, platform, { codexHome })
|
||||
}
|
||||
|
||||
function addCodexUsage(
|
||||
base: CodexUsageSnapshot,
|
||||
increment: CodexUsageSnapshot
|
||||
): CodexUsageSnapshot {
|
||||
return {
|
||||
inputTokens: base.inputTokens + increment.inputTokens,
|
||||
cachedInputTokens: base.cachedInputTokens + increment.cachedInputTokens,
|
||||
outputTokens: base.outputTokens + increment.outputTokens,
|
||||
reasoningOutputTokens: base.reasoningOutputTokens + increment.reasoningOutputTokens,
|
||||
totalTokens: base.totalTokens + increment.totalTokens
|
||||
}
|
||||
}
|
||||
|
||||
function extractCodexThreadSource(payload: Record<string, unknown>): string | null {
|
||||
return extractString(payload.thread_source) ?? extractString(payload.threadSource)
|
||||
}
|
||||
|
||||
function isCodexWorkerSession(payload: Record<string, unknown>): boolean {
|
||||
const threadSource = extractCodexThreadSource(payload)
|
||||
if (threadSource) {
|
||||
return threadSource.toLowerCase() !== 'user'
|
||||
}
|
||||
|
||||
const source = asRecord(payload.source)
|
||||
return Boolean(asRecord(source?.subagent))
|
||||
}
|
||||
|
||||
function extractCodexSessionMetadataTitle(payload: Record<string, unknown>): string | null {
|
||||
return (
|
||||
normalizeTitleText(extractString(payload.title) ?? '') ??
|
||||
normalizeTitleText(extractString(payload.thread_name) ?? '') ??
|
||||
normalizeTitleText(extractString(payload.threadName) ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
async function readCodexSessionIndexTitle(
|
||||
sessionFilePath: string,
|
||||
codexHome: string | null,
|
||||
sessionId: string
|
||||
): Promise<string | null> {
|
||||
const resolvedCodexHome = codexHome ?? codexHomeFromSessionFilePath(sessionFilePath)
|
||||
if (!resolvedCodexHome) {
|
||||
return null
|
||||
}
|
||||
const titleBySessionId = await readCodexSessionIndexTitles(resolvedCodexHome)
|
||||
return titleBySessionId.get(sessionId) ?? null
|
||||
}
|
||||
|
||||
function codexHomeFromSessionFilePath(sessionFilePath: string): string | null {
|
||||
let currentDir = dirname(sessionFilePath)
|
||||
while (currentDir && dirname(currentDir) !== currentDir) {
|
||||
if (basename(currentDir) === 'sessions') {
|
||||
return dirname(currentDir)
|
||||
}
|
||||
currentDir = dirname(currentDir)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function readCodexSessionIndexTitles(codexHome: string): Promise<Map<string, string>> {
|
||||
const indexPath = join(codexHome, CODEX_SESSION_INDEX_FILE)
|
||||
let signature: string
|
||||
try {
|
||||
const indexStat = await stat(indexPath)
|
||||
signature = `${indexStat.size}:${indexStat.mtimeMs}`
|
||||
} catch {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
const cached = codexSessionIndexTitleCache.get(codexHome)
|
||||
if (cached) {
|
||||
const entry = await cached
|
||||
if (entry.signature === signature) {
|
||||
return entry.titles
|
||||
}
|
||||
}
|
||||
|
||||
const pending = readCodexSessionIndexTitlesFromDisk(indexPath).then((titles) => ({
|
||||
signature,
|
||||
titles
|
||||
}))
|
||||
codexSessionIndexTitleCache.set(codexHome, pending)
|
||||
return (await pending).titles
|
||||
}
|
||||
|
||||
async function readCodexSessionIndexTitlesFromDisk(
|
||||
indexPath: string
|
||||
): Promise<Map<string, string>> {
|
||||
const titleBySessionId = new Map<string, string>()
|
||||
try {
|
||||
const lines = createInterface({
|
||||
input: createReadStream(indexPath, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
for await (const line of lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
const sessionId = extractString(record.id)
|
||||
const title = normalizeTitleText(extractString(record.thread_name) ?? '')
|
||||
if (sessionId && title) {
|
||||
titleBySessionId.set(sessionId, title)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Codex creates the index opportunistically; older homes may only have raw transcripts.
|
||||
}
|
||||
return titleBySessionId
|
||||
}
|
||||
@@ -109,6 +109,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => {
|
||||
copilotSessionsDir: join(root, 'copilot-sessions'),
|
||||
cursorProjectsDir: join(root, 'cursor-projects'),
|
||||
opencodeStorageDir: join(root, 'opencode-storage'),
|
||||
opencodeDbPaths: [],
|
||||
grokSessionsDir: join(root, 'grok-sessions'),
|
||||
devinTranscriptsDir: join(root, 'devin-transcripts'),
|
||||
hermesSessionsDir: join(root, 'hermes-sessions'),
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { readdir } from 'fs/promises'
|
||||
import { homedir } from 'os'
|
||||
import { dirname, join } from 'path'
|
||||
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
import { listOpenCodeDatabases } from '../opencode-usage/scanner'
|
||||
import { discoverOpenCodeSessions } from './session-scanner-opencode-sqlite-discovery'
|
||||
import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types'
|
||||
|
||||
const OPENCODE_STORAGE_DIR = join(
|
||||
process.env.OPENCODE_CONFIG_DIR?.trim() || join(homedir(), '.local', 'share', 'opencode'),
|
||||
'storage'
|
||||
)
|
||||
|
||||
export function opencodeDiscoveries(
|
||||
options: AiVaultScanOptions,
|
||||
wslHomeDirs: readonly string[],
|
||||
limit: number,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<SessionFileDiscovery>[] {
|
||||
const storageDirs = opencodeStorageDirs(options, wslHomeDirs)
|
||||
return storageDirs.map(async (storageDir, index) =>
|
||||
discoverOpenCodeSessions({
|
||||
storageDir,
|
||||
dbPaths: await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index),
|
||||
limitPerAgent: limit,
|
||||
issues
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function opencodeStorageDirs(
|
||||
options: AiVaultScanOptions,
|
||||
wslHomeDirs: readonly string[]
|
||||
): string[] {
|
||||
return [
|
||||
options.opencodeStorageDir ?? OPENCODE_STORAGE_DIR,
|
||||
...wslHomeDirs.map((homeDir) => join(homeDir, '.local', 'share', 'opencode', 'storage'))
|
||||
]
|
||||
}
|
||||
|
||||
async function opencodeDbPathsForSource(
|
||||
options: AiVaultScanOptions,
|
||||
wslHomeDirs: readonly string[],
|
||||
storageDir: string,
|
||||
sourceIndex: number
|
||||
): Promise<readonly string[]> {
|
||||
if (options.opencodeDbPaths) {
|
||||
return sourceIndex === 0 ? options.opencodeDbPaths : []
|
||||
}
|
||||
// Why: custom OpenCode storage roots still keep SQLite DBs in the parent data dir.
|
||||
if (sourceIndex === 0 && options.opencodeStorageDir) {
|
||||
return listOpenCodeDatabasesInDirectory(dirname(storageDir))
|
||||
}
|
||||
if (sourceIndex === 0) {
|
||||
return listOpenCodeDatabases()
|
||||
}
|
||||
const wslHomeDir = wslHomeDirs[sourceIndex - 1]
|
||||
return wslHomeDir
|
||||
? listOpenCodeDatabasesInDirectory(join(wslHomeDir, '.local', 'share', 'opencode'))
|
||||
: []
|
||||
}
|
||||
|
||||
async function listOpenCodeDatabasesInDirectory(dataDir: string): Promise<string[]> {
|
||||
try {
|
||||
const entries = await readdir(dataDir, { withFileTypes: true })
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name))
|
||||
.map((entry) => join(dataDir, entry.name))
|
||||
.sort()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import Database from '../sqlite/sync-database'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
let tempDbDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
||||
for (const dir of tempDbDirs) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
tempRoots = []
|
||||
tempDbDirs = []
|
||||
})
|
||||
|
||||
function isolatedScanRoots(root: string) {
|
||||
return {
|
||||
claudeProjectsDir: join(root, 'claude-projects'),
|
||||
codexSessionsDir: join(root, 'codex-sessions'),
|
||||
geminiSessionsDir: join(root, 'gemini-sessions'),
|
||||
copilotSessionsDir: join(root, 'copilot-sessions'),
|
||||
cursorProjectsDir: join(root, 'cursor-projects'),
|
||||
opencodeStorageDir: join(root, 'opencode-storage'),
|
||||
opencodeDbPaths: [] as readonly string[],
|
||||
grokSessionsDir: join(root, 'grok-sessions'),
|
||||
devinTranscriptsDir: join(root, 'devin-transcripts'),
|
||||
hermesSessionsDir: join(root, 'hermes-sessions'),
|
||||
rovoSessionsDir: join(root, 'rovo-sessions'),
|
||||
openclawStateDir: join(root, 'openclaw-state'),
|
||||
openclawLegacyStateDir: join(root, 'openclaw-legacy-state'),
|
||||
piSessionsDir: join(root, 'pi-sessions'),
|
||||
droidSessionsDir: join(root, 'droid-sessions'),
|
||||
droidProjectsDir: join(root, 'droid-projects'),
|
||||
kimiSessionsDir: join(root, 'kimi-sessions')
|
||||
}
|
||||
}
|
||||
|
||||
function createTempOpenCodeDb(): { db: Database.Database; path: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-ai-vault-sqlite-'))
|
||||
tempDbDirs.push(dir)
|
||||
const path = join(dir, 'opencode.db')
|
||||
return { db: new Database(path), path }
|
||||
}
|
||||
|
||||
function applyOpenCodeSchema(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE session (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
slug TEXT NOT NULL,
|
||||
directory TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL,
|
||||
time_archived INTEGER,
|
||||
model TEXT,
|
||||
agent TEXT,
|
||||
cost REAL DEFAULT 0 NOT NULL,
|
||||
tokens_input INTEGER DEFAULT 0 NOT NULL,
|
||||
tokens_output INTEGER DEFAULT 0 NOT NULL,
|
||||
tokens_reasoning INTEGER DEFAULT 0 NOT NULL,
|
||||
tokens_cache_read INTEGER DEFAULT 0 NOT NULL,
|
||||
tokens_cache_write INTEGER DEFAULT 0 NOT NULL,
|
||||
metadata TEXT
|
||||
);
|
||||
CREATE TABLE message (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE part (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
`)
|
||||
}
|
||||
|
||||
describe('scanAiVaultSessions — OpenCode SQLite + legacy file coexistence', () => {
|
||||
it('discovers SQLite sessions next to a custom OpenCode storage directory', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-custom-opencode-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
const opencodeDataDir = join(root, 'custom-opencode')
|
||||
const opencodeStorageDir = join(opencodeDataDir, 'storage')
|
||||
await mkdir(opencodeStorageDir, { recursive: true })
|
||||
|
||||
const dbPath = join(opencodeDataDir, 'opencode.db')
|
||||
const db = new Database(dbPath)
|
||||
applyOpenCodeSchema(db)
|
||||
db.prepare(
|
||||
`INSERT INTO session (id, project_id, slug, directory, title, version,
|
||||
time_created, time_updated, model, agent, cost,
|
||||
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write)
|
||||
VALUES ('custom-db-session', 'proj-1', 'slug', '/tmp/custom-opencode',
|
||||
'Custom SQLite session', '1.0.0',
|
||||
1777634010000, 1777634011000, NULL, 'build', 0,
|
||||
8, 13, 21, 34, 0)`
|
||||
).run()
|
||||
db.close()
|
||||
|
||||
const result = await scanAiVaultSessions({
|
||||
...roots,
|
||||
opencodeStorageDir,
|
||||
opencodeDbPaths: undefined,
|
||||
platform: 'darwin',
|
||||
limit: 50
|
||||
})
|
||||
|
||||
const session = result.sessions.find((s) => s.sessionId === 'custom-db-session')
|
||||
expect(session).toBeDefined()
|
||||
expect(session!.agent).toBe('opencode')
|
||||
expect(session!.title).toBe('Custom SQLite session')
|
||||
expect(session!.filePath).toBe(dbPath)
|
||||
expect(session!.totalTokens).toBe(42)
|
||||
})
|
||||
|
||||
it('surfaces SQLite sessions alongside legacy file sessions and dedups by sessionId', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-mixed-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
|
||||
// Legacy file session under storage/session/<projectId>/<sessionId>.json
|
||||
await mkdir(join(roots.opencodeStorageDir, 'session', 'project'), { recursive: true })
|
||||
await mkdir(join(roots.opencodeStorageDir, 'message', 'legacy-session'), { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.opencodeStorageDir, 'session', 'project', 'legacy-session.json'),
|
||||
JSON.stringify({
|
||||
id: 'legacy-session',
|
||||
directory: '/tmp/legacy',
|
||||
title: 'Legacy file session',
|
||||
time: { created: 1_777_634_000_000, updated: 1_777_634_001_000 }
|
||||
})
|
||||
)
|
||||
await writeFile(
|
||||
join(roots.opencodeStorageDir, 'message', 'legacy-session', 'msg_1.json'),
|
||||
JSON.stringify({
|
||||
role: 'user',
|
||||
summary: { title: 'Legacy file session' },
|
||||
time: { created: 1_777_634_000_000 },
|
||||
tokens: { input: 5, output: 2 }
|
||||
})
|
||||
)
|
||||
|
||||
// SQLite session — same sessionId as the legacy file (dedup should keep SQLite)
|
||||
const { db, path: dbPath } = createTempOpenCodeDb()
|
||||
applyOpenCodeSchema(db)
|
||||
db.prepare(
|
||||
`INSERT INTO session (id, project_id, slug, directory, title, version,
|
||||
time_created, time_updated, model, agent, cost,
|
||||
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write)
|
||||
VALUES ('legacy-session', 'proj-1', 'slug', '/tmp/sqlite', 'SQLite session', '1.0.0',
|
||||
1777634002000, 1777634003000, ?, 'build', 0,
|
||||
100, 40, 10, 5, 0)`
|
||||
).run(JSON.stringify({ id: 'glm-5.2', providerID: 'zai-coding-plan' }))
|
||||
db.prepare(
|
||||
`INSERT INTO message (id, session_id, time_created, time_updated, data)
|
||||
VALUES ('msg_sql_1', 'legacy-session', 1777634002500, 1777634002500, ?)`
|
||||
).run(JSON.stringify({ role: 'user', time: { created: 1_777_634_002_500 } }))
|
||||
db.prepare(
|
||||
`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data)
|
||||
VALUES ('prt_sql_1', 'msg_sql_1', 'legacy-session', 1777634002500, 1777634002500, ?)`
|
||||
).run(JSON.stringify({ type: 'text', text: 'sqlite hello' }))
|
||||
db.close()
|
||||
|
||||
// A second SQLite-only session
|
||||
const { db: db2, path: dbPath2 } = createTempOpenCodeDb()
|
||||
applyOpenCodeSchema(db2)
|
||||
db2
|
||||
.prepare(
|
||||
`INSERT INTO session (id, project_id, slug, directory, title, version,
|
||||
time_created, time_updated, model, agent, cost,
|
||||
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write)
|
||||
VALUES ('sqlite-only', 'proj-1', 'slug2', '/tmp/sqlite-only', 'SQLite only', '1.0.0',
|
||||
1777634004000, 1777634005000, NULL, 'build', 0,
|
||||
50, 20, 0, 0, 0)`
|
||||
)
|
||||
.run()
|
||||
db2.close()
|
||||
|
||||
const result = await scanAiVaultSessions({
|
||||
...roots,
|
||||
opencodeDbPaths: [dbPath, dbPath2],
|
||||
platform: 'darwin',
|
||||
limit: 50
|
||||
})
|
||||
|
||||
const opencodeSessions = result.sessions.filter((s) => s.agent === 'opencode')
|
||||
const sessionIds = opencodeSessions.map((s) => s.sessionId).sort()
|
||||
expect(sessionIds).toEqual(['legacy-session', 'sqlite-only'])
|
||||
|
||||
// Why: dedup keeps the SQLite entry (newer time_updated, source of truth)
|
||||
const legacyEntry = opencodeSessions.find((s) => s.sessionId === 'legacy-session')
|
||||
expect(legacyEntry).toBeDefined()
|
||||
expect(legacyEntry!.title).toBe('SQLite session')
|
||||
expect(legacyEntry!.cwd).toBe('/tmp/sqlite')
|
||||
expect(legacyEntry!.filePath).toBe(dbPath)
|
||||
expect(legacyEntry!.totalTokens).toBe(150)
|
||||
expect(legacyEntry!.resumeCommand).toBe(
|
||||
"cd '/tmp/sqlite' && opencode --session 'legacy-session'"
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,242 @@
|
||||
import { basename, extname, join } from 'path'
|
||||
import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
import { discoverFiles } from './session-scanner-discovery'
|
||||
import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths'
|
||||
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
|
||||
import type {
|
||||
FileWithMtime,
|
||||
SessionFileCandidate,
|
||||
SessionFileDiscovery
|
||||
} from './session-scanner-types'
|
||||
import { errorMessage } from './session-scanner-values'
|
||||
import SyncDatabase from '../sqlite/sync-database'
|
||||
import { columnExists, tableExists } from '../opencode-usage/schema-helpers'
|
||||
|
||||
// Why: keep the SQLite discovery + dedup layer separate from the parser so
|
||||
// each file stays under the max-lines lint rule and the discovery layer can
|
||||
// be tested in isolation.
|
||||
|
||||
type SessionRow = {
|
||||
id: string
|
||||
title: string | null
|
||||
directory: string | null
|
||||
time_created: number
|
||||
time_updated: number
|
||||
model_json: string | null
|
||||
agent: string | null
|
||||
tokens_input: number
|
||||
tokens_output: number
|
||||
tokens_reasoning: number
|
||||
tokens_cache_read: number
|
||||
cost: number
|
||||
message_count: number
|
||||
}
|
||||
|
||||
function openReadonlyDatabase(dbPath: string): SyncDatabase {
|
||||
const db = new SyncDatabase(dbPath, { readonly: true, fileMustExist: true })
|
||||
db.pragma('query_only = ON')
|
||||
return db
|
||||
}
|
||||
|
||||
function canReadOpenCodeSessions(db: SyncDatabase): boolean {
|
||||
return (
|
||||
tableExists(db, 'session') &&
|
||||
columnExists(db, 'session', 'time_created') &&
|
||||
columnExists(db, 'session', 'time_updated')
|
||||
)
|
||||
}
|
||||
|
||||
function sessionColumnSelect(db: SyncDatabase, columnName: string): string {
|
||||
return columnExists(db, 'session', columnName) ? `s.${columnName}` : 'NULL'
|
||||
}
|
||||
|
||||
function canCountOpenCodeMessages(db: SyncDatabase): boolean {
|
||||
return (
|
||||
tableExists(db, 'message') &&
|
||||
columnExists(db, 'message', 'session_id') &&
|
||||
columnExists(db, 'message', 'data')
|
||||
)
|
||||
}
|
||||
|
||||
function buildSessionListQuery(db: SyncDatabase): string {
|
||||
const modelSelect = sessionColumnSelect(db, 'model')
|
||||
const agentSelect = sessionColumnSelect(db, 'agent')
|
||||
const tokenColumns = ['tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read']
|
||||
const tokenSelects = tokenColumns
|
||||
.map((col) => `${columnExists(db, 'session', col) ? `s.${col}` : '0'} AS ${col}`)
|
||||
.join(', ')
|
||||
const costSelect = columnExists(db, 'session', 'cost') ? 's.cost' : '0'
|
||||
const parentIdPredicate = columnExists(db, 'session', 'parent_id')
|
||||
? 'AND s.parent_id IS NULL'
|
||||
: ''
|
||||
const archivedPredicate = columnExists(db, 'session', 'time_archived')
|
||||
? 'AND s.time_archived IS NULL'
|
||||
: ''
|
||||
const messageCountSubquery = canCountOpenCodeMessages(db)
|
||||
? `(SELECT COUNT(*) FROM message m
|
||||
WHERE m.session_id = s.id
|
||||
AND json_extract(m.data, '$.role') IN ('user','assistant'))`
|
||||
: '0'
|
||||
|
||||
return `SELECT s.id,
|
||||
${sessionColumnSelect(db, 'title')} AS title,
|
||||
${sessionColumnSelect(db, 'directory')} AS directory,
|
||||
s.time_created,
|
||||
s.time_updated,
|
||||
${modelSelect} AS model_json, ${agentSelect} AS agent,
|
||||
${tokenSelects}, ${costSelect} AS cost,
|
||||
${messageCountSubquery} AS message_count
|
||||
FROM session s
|
||||
WHERE 1=1 ${parentIdPredicate} ${archivedPredicate}
|
||||
ORDER BY s.time_updated DESC
|
||||
LIMIT ?`
|
||||
}
|
||||
|
||||
function rowToCandidate(row: SessionRow, dbPath: string): SessionFileCandidate {
|
||||
const mtimeMs =
|
||||
typeof row.time_updated === 'number' && row.time_updated > 0
|
||||
? row.time_updated
|
||||
: row.time_created
|
||||
return {
|
||||
agent: 'opencode' as AiVaultAgent,
|
||||
file: {
|
||||
path: buildOpenCodeSqliteCandidatePath(dbPath, row.id),
|
||||
mtimeMs,
|
||||
modifiedAt: new Date(mtimeMs).toISOString()
|
||||
},
|
||||
codexHome: null
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeAndSortSqliteCandidates(candidates: SessionFileCandidate[]): SessionFileCandidate[] {
|
||||
const candidatesBySessionId = new Map<string, SessionFileCandidate>()
|
||||
for (const candidate of candidates) {
|
||||
const parsed = splitOpenCodeSqliteCandidate(candidate.file.path)
|
||||
if (!parsed) {
|
||||
continue
|
||||
}
|
||||
const previous = candidatesBySessionId.get(parsed.sessionId)
|
||||
if (!previous || candidate.file.mtimeMs > previous.file.mtimeMs) {
|
||||
candidatesBySessionId.set(parsed.sessionId, candidate)
|
||||
}
|
||||
}
|
||||
return [...candidatesBySessionId.values()].sort((left, right) => {
|
||||
return right.file.mtimeMs - left.file.mtimeMs
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* List OpenCode sessions from one or more SQLite databases as synthetic
|
||||
* `SessionFileCandidate` entries. Each candidate's file path is a synthetic
|
||||
* `<dbPath>#<sessionId>` string that the parser dispatcher routes to
|
||||
* `parseOpenCodeSqliteSession`. Databases that lack the `session` table are
|
||||
* silently skipped; errors are recorded as scan issues.
|
||||
* @param args.dbPaths - Absolute paths to opencode.db files to scan.
|
||||
* @param args.limit - Maximum number of sessions to return per database.
|
||||
* @param args.issues - Collected scan issues to append errors to.
|
||||
* @returns Array of synthetic candidates sorted by `time_updated` DESC.
|
||||
*/
|
||||
export async function listOpenCodeSqliteSessions(args: {
|
||||
dbPaths: readonly string[]
|
||||
limit: number
|
||||
issues: AiVaultScanIssue[]
|
||||
}): Promise<SessionFileCandidate[]> {
|
||||
const candidates: SessionFileCandidate[] = []
|
||||
for (const dbPath of args.dbPaths) {
|
||||
let db: SyncDatabase | null = null
|
||||
try {
|
||||
db = openReadonlyDatabase(dbPath)
|
||||
if (!canReadOpenCodeSessions(db)) {
|
||||
continue
|
||||
}
|
||||
const rows = db.prepare(buildSessionListQuery(db)).all(args.limit) as SessionRow[]
|
||||
for (const row of rows) {
|
||||
candidates.push(rowToCandidate(row, dbPath))
|
||||
}
|
||||
} catch (err) {
|
||||
args.issues.push({
|
||||
agent: 'opencode',
|
||||
path: dbPath,
|
||||
message: errorMessage(err)
|
||||
})
|
||||
} finally {
|
||||
db?.close()
|
||||
}
|
||||
}
|
||||
return dedupeAndSortSqliteCandidates(candidates)
|
||||
}
|
||||
|
||||
// Why: extract the sessionId from a legacy file path like
|
||||
// storage/session/<projectId>/<sessionId>.json. Falls back to the filename
|
||||
// without extension when the opencode id format doesn't match a UUID.
|
||||
function sessionIdFromLegacyFilePath(filePath: string): string {
|
||||
return basename(filePath, extname(filePath))
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover OpenCode sessions from both the legacy file layout and the SQLite
|
||||
* DB, deduplicating at the file level before parsing. On mixed installs the
|
||||
* same session may appear once via a stale legacy JSON file and once via the
|
||||
* SQLite DB; SQLite is the source of truth on 1.17.x, so file-based entries
|
||||
* whose sessionId matches a SQLite entry are dropped. Legacy installs without
|
||||
* the `session` table fall through to the file scanner unchanged.
|
||||
* @param args.storageDir - Root of the OpenCode storage directory (contains `session/` and `message/`).
|
||||
* @param args.dbPaths - Absolute paths to opencode.db files to scan.
|
||||
* @param args.limitPerAgent - Maximum number of candidates per source.
|
||||
* @param args.issues - Collected scan issues to append errors to.
|
||||
* @returns A `SessionFileDiscovery` with deduplicated file entries.
|
||||
*/
|
||||
export async function discoverOpenCodeSessions(args: {
|
||||
storageDir: string
|
||||
dbPaths: readonly string[]
|
||||
limitPerAgent: number
|
||||
issues: AiVaultScanIssue[]
|
||||
}): Promise<SessionFileDiscovery> {
|
||||
const [fileDiscovery, sqliteCandidates] = await Promise.all([
|
||||
discoverFiles({
|
||||
rootDir: join(args.storageDir, 'session'),
|
||||
limit: args.limitPerAgent,
|
||||
agent: 'opencode',
|
||||
issues: args.issues,
|
||||
extensions: ['.json']
|
||||
}),
|
||||
listOpenCodeSqliteSessions({
|
||||
dbPaths: args.dbPaths,
|
||||
limit: args.limitPerAgent,
|
||||
issues: args.issues
|
||||
})
|
||||
])
|
||||
|
||||
const sqliteFiles = sqliteCandidates.map((c) => c.file)
|
||||
// Why: on mixed installs the same OpenCode session may appear once via the
|
||||
// SQLite DB and once via a stale legacy JSON file. SQLite is the source of
|
||||
// truth on 1.17.x, so drop file-based duplicates when a SQLite entry with
|
||||
// the same sessionId already exists. Deduping at the file level also avoids
|
||||
// parsing the same session twice.
|
||||
if (sqliteFiles.length === 0) {
|
||||
return {
|
||||
agent: 'opencode' as const,
|
||||
rootDir: fileDiscovery.rootDir,
|
||||
files: fileDiscovery.files
|
||||
}
|
||||
}
|
||||
const sqliteSessionIds = new Set<string>()
|
||||
for (const file of sqliteFiles) {
|
||||
const parsed = splitOpenCodeSqliteCandidate(file.path)
|
||||
if (parsed) {
|
||||
sqliteSessionIds.add(parsed.sessionId)
|
||||
}
|
||||
}
|
||||
const dedupedFileDiscovery: FileWithMtime[] = []
|
||||
for (const file of fileDiscovery.files) {
|
||||
if (!sqliteSessionIds.has(sessionIdFromLegacyFilePath(file.path))) {
|
||||
dedupedFileDiscovery.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agent: 'opencode' as const,
|
||||
rootDir: fileDiscovery.rootDir,
|
||||
files: [...dedupedFileDiscovery, ...sqliteFiles]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildOpenCodeSqliteCandidatePath,
|
||||
looksLikeOpenCodeSqliteCandidate,
|
||||
splitOpenCodeSqliteCandidate
|
||||
} from './session-scanner-opencode-sqlite-paths'
|
||||
|
||||
describe('splitOpenCodeSqliteCandidate', () => {
|
||||
it('splits a synthetic db#sessionId path', () => {
|
||||
const result = splitOpenCodeSqliteCandidate('/data/opencode.db#ses_abc')
|
||||
expect(result).toEqual({ dbPath: '/data/opencode.db', sessionId: 'ses_abc' })
|
||||
})
|
||||
|
||||
it('splits a stable-db path', () => {
|
||||
const result = splitOpenCodeSqliteCandidate('/data/opencode-stable.db#ses_xyz')
|
||||
expect(result).toEqual({ dbPath: '/data/opencode-stable.db', sessionId: 'ses_xyz' })
|
||||
})
|
||||
|
||||
it('rejects a path whose db basename is not opencode*.db', () => {
|
||||
expect(splitOpenCodeSqliteCandidate('/data/random.db#ses_abc')).toBeNull()
|
||||
expect(splitOpenCodeSqliteCandidate('/data/notes.txt#ses_abc')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a path without a separator', () => {
|
||||
expect(splitOpenCodeSqliteCandidate('/data/opencode.db')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects an empty sessionId', () => {
|
||||
expect(splitOpenCodeSqliteCandidate('/data/opencode.db#')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('looksLikeOpenCodeSqliteCandidate', () => {
|
||||
it('returns true for a synthetic path', () => {
|
||||
expect(looksLikeOpenCodeSqliteCandidate('/x/opencode.db#ses_1')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for a real filesystem path', () => {
|
||||
expect(looksLikeOpenCodeSqliteCandidate('/x/storage/session/proj/ses_1.json')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildOpenCodeSqliteCandidatePath', () => {
|
||||
it('joins dbPath and sessionId with #', () => {
|
||||
expect(buildOpenCodeSqliteCandidatePath('/d/opencode.db', 'ses_1')).toBe('/d/opencode.db#ses_1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { basename } from 'path'
|
||||
|
||||
// Why: keep the synthetic candidate-path helpers separate from the SQLite
|
||||
// discovery/parser so both the scanner and the agent-parser dispatcher can
|
||||
// import them without pulling in the SyncDatabase dependency.
|
||||
|
||||
const OPENCODE_SQLITE_PATH_SEPARATOR = '#'
|
||||
|
||||
/**
|
||||
* Build a synthetic candidate path that encodes the SQLite DB path and session ID
|
||||
* as `<dbPath>#<sessionId>`. Used by the discovery layer so SQLite-backed
|
||||
* sessions flow through the same FileWithMtime pipeline as file-backed ones.
|
||||
* @param dbPath - Absolute path to the opencode.db file.
|
||||
* @param sessionId - The OpenCode session ID (primary key in the session table).
|
||||
* @returns The synthetic candidate path string.
|
||||
*/
|
||||
export function buildOpenCodeSqliteCandidatePath(dbPath: string, sessionId: string): string {
|
||||
return `${dbPath}${OPENCODE_SQLITE_PATH_SEPARATOR}${sessionId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a synthetic candidate path back into its DB path and session ID parts.
|
||||
* Validates that the DB basename matches `opencode*.db` so real filesystem paths
|
||||
* that happen to contain `#` are never misrouted to the SQLite parser.
|
||||
* @param candidatePath - The synthetic path to parse.
|
||||
* @returns `{ dbPath, sessionId }` if the path is a valid synthetic candidate, `null` otherwise.
|
||||
*/
|
||||
export function splitOpenCodeSqliteCandidate(
|
||||
candidatePath: string
|
||||
): { dbPath: string; sessionId: string } | null {
|
||||
const separatorIndex = candidatePath.lastIndexOf(OPENCODE_SQLITE_PATH_SEPARATOR)
|
||||
if (separatorIndex <= 0 || separatorIndex === candidatePath.length - 1) {
|
||||
return null
|
||||
}
|
||||
const dbPath = candidatePath.slice(0, separatorIndex)
|
||||
const sessionId = candidatePath.slice(separatorIndex + 1)
|
||||
if (!dbPath || !sessionId) {
|
||||
return null
|
||||
}
|
||||
// Why: OpenCode DB files are named opencode*.db; reject anything else so we
|
||||
// never misroute a real filesystem path that happens to contain '#'.
|
||||
if (!/^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/i.test(basename(dbPath))) {
|
||||
return null
|
||||
}
|
||||
return { dbPath, sessionId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard: returns `true` if the path is a valid synthetic OpenCode SQLite
|
||||
* candidate path (i.e. `splitOpenCodeSqliteCandidate` would return non-null).
|
||||
* @param candidatePath - The path to test.
|
||||
* @returns `true` if the path is a synthetic SQLite candidate, `false` otherwise.
|
||||
*/
|
||||
export function looksLikeOpenCodeSqliteCandidate(candidatePath: string): boolean {
|
||||
return splitOpenCodeSqliteCandidate(candidatePath) !== null
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import Database from '../sqlite/sync-database'
|
||||
import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths'
|
||||
import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-discovery'
|
||||
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
|
||||
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
|
||||
let tempDirs: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
tempDirs = []
|
||||
})
|
||||
|
||||
function createTempDb(): { db: Database.Database; path: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-opencode-sqlite-'))
|
||||
tempDirs.push(dir)
|
||||
const path = join(dir, 'opencode.db')
|
||||
return { db: new Database(path), path }
|
||||
}
|
||||
|
||||
function applyOpenCodeSchema(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE session (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
slug TEXT NOT NULL,
|
||||
directory TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
share_url TEXT,
|
||||
summary_additions INTEGER,
|
||||
summary_deletions INTEGER,
|
||||
summary_files INTEGER,
|
||||
summary_diffs TEXT,
|
||||
revert TEXT,
|
||||
permission TEXT,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL,
|
||||
time_compacting INTEGER,
|
||||
time_archived INTEGER,
|
||||
workspace_id TEXT,
|
||||
path TEXT,
|
||||
agent TEXT,
|
||||
model TEXT,
|
||||
cost REAL DEFAULT 0 NOT NULL,
|
||||
tokens_input INTEGER DEFAULT 0 NOT NULL,
|
||||
tokens_output INTEGER DEFAULT 0 NOT NULL,
|
||||
tokens_reasoning INTEGER DEFAULT 0 NOT NULL,
|
||||
tokens_cache_read INTEGER DEFAULT 0 NOT NULL,
|
||||
tokens_cache_write INTEGER DEFAULT 0 NOT NULL,
|
||||
metadata TEXT
|
||||
);
|
||||
CREATE TABLE message (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE part (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE project (
|
||||
id TEXT PRIMARY KEY,
|
||||
worktree TEXT NOT NULL,
|
||||
vcs TEXT,
|
||||
name TEXT,
|
||||
icon_url TEXT,
|
||||
icon_color TEXT,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL,
|
||||
time_initialized INTEGER,
|
||||
sandboxes TEXT NOT NULL,
|
||||
commands TEXT,
|
||||
icon_url_override TEXT
|
||||
);
|
||||
`)
|
||||
}
|
||||
|
||||
function applyMinimalOpenCodeSchema(db: Database.Database): void {
|
||||
db.exec(`CREATE TABLE session (
|
||||
id TEXT PRIMARY KEY,
|
||||
time_created INTEGER NOT NULL,
|
||||
time_updated INTEGER NOT NULL
|
||||
);`)
|
||||
}
|
||||
|
||||
function insertSession(
|
||||
db: Database.Database,
|
||||
args: {
|
||||
id: string
|
||||
title?: string
|
||||
directory?: string
|
||||
timeCreated: number
|
||||
timeUpdated: number
|
||||
parentId?: string | null
|
||||
timeArchived?: number | null
|
||||
model?: string | null
|
||||
agent?: string | null
|
||||
tokensInput?: number
|
||||
tokensOutput?: number
|
||||
tokensReasoning?: number
|
||||
tokensCacheRead?: number
|
||||
cost?: number
|
||||
}
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT INTO session (id, project_id, parent_id, slug, directory, title, version,
|
||||
time_created, time_updated, time_archived, model, agent, cost,
|
||||
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write)
|
||||
VALUES (?, 'proj-1', ?, ?, ?, ?, '1.0.0',
|
||||
?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, 0)`
|
||||
).run(
|
||||
args.id,
|
||||
args.parentId ?? null,
|
||||
`slug-${args.id}`,
|
||||
args.directory ?? '/tmp/opencode',
|
||||
args.title ?? 'OpenCode title',
|
||||
args.timeCreated,
|
||||
args.timeUpdated,
|
||||
args.timeArchived ?? null,
|
||||
args.model ?? JSON.stringify({ id: 'glm-5.2', providerID: 'zai-coding-plan' }),
|
||||
args.agent ?? 'build',
|
||||
args.cost ?? 0,
|
||||
args.tokensInput ?? 100,
|
||||
args.tokensOutput ?? 40,
|
||||
args.tokensReasoning ?? 10,
|
||||
args.tokensCacheRead ?? 5
|
||||
)
|
||||
}
|
||||
|
||||
function insertMessage(
|
||||
db: Database.Database,
|
||||
args: {
|
||||
id: string
|
||||
sessionId: string
|
||||
role: 'user' | 'assistant'
|
||||
timeCreated: number
|
||||
summaryTitle?: string | null
|
||||
summaryBody?: string | null
|
||||
}
|
||||
): void {
|
||||
const data = JSON.stringify({
|
||||
role: args.role,
|
||||
time: { created: args.timeCreated },
|
||||
agent: 'build',
|
||||
summary:
|
||||
args.summaryTitle || args.summaryBody
|
||||
? { title: args.summaryTitle ?? null, body: args.summaryBody ?? null }
|
||||
: undefined
|
||||
})
|
||||
db.prepare(
|
||||
`INSERT INTO message (id, session_id, time_created, time_updated, data)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
).run(args.id, args.sessionId, args.timeCreated, args.timeCreated, data)
|
||||
}
|
||||
|
||||
function insertPart(
|
||||
db: Database.Database,
|
||||
args: {
|
||||
id: string
|
||||
messageId: string
|
||||
sessionId: string
|
||||
timeCreated: number
|
||||
type?: 'text' | 'tool' | 'reasoning'
|
||||
text?: string
|
||||
}
|
||||
): void {
|
||||
const data = JSON.stringify({
|
||||
type: args.type ?? 'text',
|
||||
text: args.text ?? 'hello world'
|
||||
})
|
||||
db.prepare(
|
||||
`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
).run(args.id, args.messageId, args.sessionId, args.timeCreated, args.timeCreated, data)
|
||||
}
|
||||
describe('listOpenCodeSqliteSessions', () => {
|
||||
it('returns candidates sorted by time_updated desc via the synthesized mtimeMs', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
applyOpenCodeSchema(db)
|
||||
insertSession(db, {
|
||||
id: 'ses_old',
|
||||
title: 'Old',
|
||||
timeCreated: 1_777_634_000_000,
|
||||
timeUpdated: 1_777_634_001_000
|
||||
})
|
||||
insertSession(db, {
|
||||
id: 'ses_new',
|
||||
title: 'New',
|
||||
timeCreated: 1_777_634_002_000,
|
||||
timeUpdated: 1_777_634_003_000
|
||||
})
|
||||
db.close()
|
||||
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
const candidates = await listOpenCodeSqliteSessions({
|
||||
dbPaths: [path],
|
||||
limit: 10,
|
||||
issues
|
||||
})
|
||||
expect(issues).toEqual([])
|
||||
expect(candidates).toHaveLength(2)
|
||||
expect(candidates[0].agent).toBe('opencode')
|
||||
expect(candidates[0].file.mtimeMs).toBe(1_777_634_003_000)
|
||||
expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_new'))
|
||||
expect(candidates[1].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_old'))
|
||||
})
|
||||
|
||||
it('dedups matching session ids across databases and keeps the newest row', async () => {
|
||||
const { db: oldDb, path: oldPath } = createTempDb()
|
||||
applyOpenCodeSchema(oldDb)
|
||||
insertSession(oldDb, {
|
||||
id: 'ses_duplicate',
|
||||
title: 'Old duplicate',
|
||||
timeCreated: 1_777_634_000_000,
|
||||
timeUpdated: 1_777_634_001_000
|
||||
})
|
||||
oldDb.close()
|
||||
|
||||
const { db: newDb, path: newPath } = createTempDb()
|
||||
applyOpenCodeSchema(newDb)
|
||||
insertSession(newDb, {
|
||||
id: 'ses_duplicate',
|
||||
title: 'New duplicate',
|
||||
timeCreated: 1_777_634_002_000,
|
||||
timeUpdated: 1_777_634_003_000
|
||||
})
|
||||
newDb.close()
|
||||
|
||||
const candidates = await listOpenCodeSqliteSessions({
|
||||
dbPaths: [oldPath, newPath],
|
||||
limit: 10,
|
||||
issues: []
|
||||
})
|
||||
expect(candidates).toHaveLength(1)
|
||||
expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(newPath, 'ses_duplicate'))
|
||||
})
|
||||
it('excludes archived and child sessions', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
applyOpenCodeSchema(db)
|
||||
insertSession(db, {
|
||||
id: 'ses_normal',
|
||||
timeCreated: 1_777_634_000_000,
|
||||
timeUpdated: 1_777_634_001_000
|
||||
})
|
||||
insertSession(db, {
|
||||
id: 'ses_archived',
|
||||
timeCreated: 1_777_634_000_000,
|
||||
timeUpdated: 1_777_634_002_000,
|
||||
timeArchived: 1_777_634_002_500
|
||||
})
|
||||
insertSession(db, {
|
||||
id: 'ses_child',
|
||||
timeCreated: 1_777_634_000_000,
|
||||
timeUpdated: 1_777_634_003_000,
|
||||
parentId: 'ses_normal'
|
||||
})
|
||||
db.close()
|
||||
|
||||
const candidates = await listOpenCodeSqliteSessions({
|
||||
dbPaths: [path],
|
||||
limit: 10,
|
||||
issues: []
|
||||
})
|
||||
expect(candidates.map((c) => c.file.path)).toEqual([
|
||||
buildOpenCodeSqliteCandidatePath(path, 'ses_normal')
|
||||
])
|
||||
})
|
||||
|
||||
it('returns [] when the session table is missing (legacy install)', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
db.exec('CREATE TABLE other (id TEXT)')
|
||||
db.close()
|
||||
const candidates = await listOpenCodeSqliteSessions({
|
||||
dbPaths: [path],
|
||||
limit: 10,
|
||||
issues: []
|
||||
})
|
||||
expect(candidates).toEqual([])
|
||||
})
|
||||
|
||||
it('records an issue when the DB file does not exist', async () => {
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
const candidates = await listOpenCodeSqliteSessions({
|
||||
dbPaths: ['/nonexistent/opencode.db'],
|
||||
limit: 10,
|
||||
issues
|
||||
})
|
||||
expect(candidates).toEqual([])
|
||||
expect(issues).toHaveLength(1)
|
||||
expect(issues[0].agent).toBe('opencode')
|
||||
expect(issues[0].path).toBe('/nonexistent/opencode.db')
|
||||
})
|
||||
|
||||
it('lists sessions from a minimal readable session table', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
applyMinimalOpenCodeSchema(db)
|
||||
db.prepare(`INSERT INTO session VALUES ('ses_minimal', 1777634000000, 1777634001000)`).run()
|
||||
db.close()
|
||||
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
const candidates = await listOpenCodeSqliteSessions({
|
||||
dbPaths: [path],
|
||||
limit: 10,
|
||||
issues
|
||||
})
|
||||
expect(issues).toEqual([])
|
||||
expect(candidates).toHaveLength(1)
|
||||
expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_minimal'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseOpenCodeSqliteSession', () => {
|
||||
it('builds an AiVaultSession with title, cwd, model, tokens, and resume command', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
applyOpenCodeSchema(db)
|
||||
insertSession(db, {
|
||||
id: 'ses_1',
|
||||
title: 'OpenCode title',
|
||||
directory: '/tmp/opencode',
|
||||
timeCreated: 1_777_634_000_000,
|
||||
timeUpdated: 1_777_634_001_000,
|
||||
tokensInput: 100,
|
||||
tokensOutput: 40,
|
||||
tokensReasoning: 10,
|
||||
tokensCacheRead: 5,
|
||||
cost: 0.01
|
||||
})
|
||||
insertMessage(db, {
|
||||
id: 'msg_1',
|
||||
sessionId: 'ses_1',
|
||||
role: 'user',
|
||||
timeCreated: 1_777_634_000_500,
|
||||
summaryTitle: 'OpenCode title'
|
||||
})
|
||||
insertPart(db, {
|
||||
id: 'prt_1',
|
||||
messageId: 'msg_1',
|
||||
sessionId: 'ses_1',
|
||||
timeCreated: 1_777_634_000_500,
|
||||
text: 'Plan the work'
|
||||
})
|
||||
insertMessage(db, {
|
||||
id: 'msg_2',
|
||||
sessionId: 'ses_1',
|
||||
role: 'assistant',
|
||||
timeCreated: 1_777_634_000_900
|
||||
})
|
||||
insertPart(db, {
|
||||
id: 'prt_2',
|
||||
messageId: 'msg_2',
|
||||
sessionId: 'ses_1',
|
||||
timeCreated: 1_777_634_001_000,
|
||||
text: 'Done'
|
||||
})
|
||||
db.close()
|
||||
|
||||
const session = await parseOpenCodeSqliteSession({
|
||||
dbPath: path,
|
||||
sessionId: 'ses_1',
|
||||
platform: 'darwin'
|
||||
})
|
||||
expect(session).not.toBeNull()
|
||||
expect(session!.agent).toBe('opencode')
|
||||
expect(session!.sessionId).toBe('ses_1')
|
||||
expect(session!.filePath).toBe(path)
|
||||
expect(session!.title).toBe('OpenCode title')
|
||||
expect(session!.cwd).toBe('/tmp/opencode')
|
||||
expect(session!.model).toBe('glm-5.2')
|
||||
expect(session!.totalTokens).toBe(150)
|
||||
expect(session!.messageCount).toBe(2)
|
||||
expect(session!.createdAt).toBe(new Date(1_777_634_000_000).toISOString())
|
||||
expect(session!.updatedAt).toBe(new Date(1_777_634_001_000).toISOString())
|
||||
expect(session!.resumeCommand).toBe("cd '/tmp/opencode' && opencode --session 'ses_1'")
|
||||
expect(session!.previewMessages).toHaveLength(2)
|
||||
expect(session!.previewMessages[0].text).toBe('Plan the work')
|
||||
expect(session!.previewMessages[0].role).toBe('user')
|
||||
expect(session!.previewMessages[1].text).toBe('Done')
|
||||
expect(session!.previewMessages[1].role).toBe('assistant')
|
||||
})
|
||||
|
||||
it('falls back to summary.body for title when session.title is empty', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
applyOpenCodeSchema(db)
|
||||
insertSession(db, {
|
||||
id: 'ses_2',
|
||||
title: '',
|
||||
timeCreated: 1_777_634_000_000,
|
||||
timeUpdated: 1_777_634_001_000
|
||||
})
|
||||
insertMessage(db, {
|
||||
id: 'msg_1',
|
||||
sessionId: 'ses_2',
|
||||
role: 'user',
|
||||
timeCreated: 1_777_634_000_500,
|
||||
summaryBody: 'fallback title from summary'
|
||||
})
|
||||
insertPart(db, {
|
||||
id: 'prt_1',
|
||||
messageId: 'msg_1',
|
||||
sessionId: 'ses_2',
|
||||
timeCreated: 1_777_634_000_500,
|
||||
text: 'hello'
|
||||
})
|
||||
db.close()
|
||||
|
||||
const session = await parseOpenCodeSqliteSession({
|
||||
dbPath: path,
|
||||
sessionId: 'ses_2',
|
||||
platform: 'darwin'
|
||||
})
|
||||
expect(session).not.toBeNull()
|
||||
expect(session!.title).toBe('fallback title from summary')
|
||||
})
|
||||
|
||||
it('returns null when the session id is not found', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
applyOpenCodeSchema(db)
|
||||
insertSession(db, {
|
||||
id: 'ses_real',
|
||||
timeCreated: 1_777_634_000_000,
|
||||
timeUpdated: 1_777_634_001_000
|
||||
})
|
||||
db.close()
|
||||
const session = await parseOpenCodeSqliteSession({
|
||||
dbPath: path,
|
||||
sessionId: 'ses_missing',
|
||||
platform: 'darwin'
|
||||
})
|
||||
expect(session).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the DB has no session table', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
db.exec('CREATE TABLE other (id TEXT)')
|
||||
db.close()
|
||||
const session = await parseOpenCodeSqliteSession({
|
||||
dbPath: path,
|
||||
sessionId: 'ses_1',
|
||||
platform: 'darwin'
|
||||
})
|
||||
expect(session).toBeNull()
|
||||
})
|
||||
|
||||
it('parses a minimal readable session table without optional columns or messages', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
applyMinimalOpenCodeSchema(db)
|
||||
db.prepare(`INSERT INTO session VALUES ('ses_minimal', 1777634000000, 1777634001000)`).run()
|
||||
db.close()
|
||||
|
||||
const session = await parseOpenCodeSqliteSession({
|
||||
dbPath: path,
|
||||
sessionId: 'ses_minimal',
|
||||
platform: 'darwin'
|
||||
})
|
||||
expect(session).not.toBeNull()
|
||||
expect(session!.sessionId).toBe('ses_minimal')
|
||||
expect(session!.filePath).toBe(path)
|
||||
expect(session!.title).toBe('OpenCode ses_mini')
|
||||
expect(session!.cwd).toBeNull()
|
||||
expect(session!.model).toBeNull()
|
||||
expect(session!.messageCount).toBe(0)
|
||||
expect(session!.totalTokens).toBe(0)
|
||||
expect(session!.previewMessages).toEqual([])
|
||||
})
|
||||
|
||||
it('extracts model from older modelID schema', async () => {
|
||||
const { db, path } = createTempDb()
|
||||
applyOpenCodeSchema(db)
|
||||
insertSession(db, {
|
||||
id: 'ses_3',
|
||||
timeCreated: 1_777_634_000_000,
|
||||
timeUpdated: 1_777_634_001_000,
|
||||
model: JSON.stringify({ modelID: 'claude-sonnet-4-5' })
|
||||
})
|
||||
db.close()
|
||||
const session = await parseOpenCodeSqliteSession({
|
||||
dbPath: path,
|
||||
sessionId: 'ses_3',
|
||||
platform: 'darwin'
|
||||
})
|
||||
expect(session).not.toBeNull()
|
||||
expect(session!.model).toBe('claude-sonnet-4-5')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,263 @@
|
||||
import type { AiVaultSession, AiVaultSessionPreviewMessage } from '../../shared/ai-vault-types'
|
||||
import {
|
||||
addPreviewMessage,
|
||||
createAccumulator,
|
||||
finalizeSession,
|
||||
updateTimeline
|
||||
} from './session-scanner-accumulator'
|
||||
import { normalizeTitleText } from './session-scanner-values'
|
||||
import SyncDatabase from '../sqlite/sync-database'
|
||||
import { columnExists, tableExists } from '../opencode-usage/schema-helpers'
|
||||
|
||||
// Why: OpenCode 1.17.x migrated session storage from per-session JSON files
|
||||
// to a single SQLite DB at ~/.local/share/opencode/opencode.db. This module
|
||||
// parses individual sessions from the DB into AiVaultSession objects. The
|
||||
// discovery layer (listing candidates) lives in
|
||||
// session-scanner-opencode-sqlite-discovery.ts.
|
||||
|
||||
const OPENCODE_SQLITE_PREVIEW_LIMIT = 5
|
||||
|
||||
type SessionRow = {
|
||||
id: string
|
||||
title: string | null
|
||||
directory: string | null
|
||||
time_created: number
|
||||
time_updated: number
|
||||
model_json: string | null
|
||||
agent: string | null
|
||||
tokens_input: number
|
||||
tokens_output: number
|
||||
tokens_reasoning: number
|
||||
tokens_cache_read: number
|
||||
cost: number
|
||||
message_count: number
|
||||
}
|
||||
|
||||
type PreviewRow = {
|
||||
role: string | null
|
||||
part_data: string
|
||||
time_created: number
|
||||
summary_title: string | null
|
||||
summary_body: string | null
|
||||
}
|
||||
|
||||
function openReadonlyDatabase(dbPath: string): SyncDatabase {
|
||||
const db = new SyncDatabase(dbPath, { readonly: true, fileMustExist: true })
|
||||
// Why: belt-and-suspenders guard so a bug in the SELECT list can never
|
||||
// mutate the user's opencode.db.
|
||||
db.pragma('query_only = ON')
|
||||
return db
|
||||
}
|
||||
|
||||
function canReadOpenCodeSessions(db: SyncDatabase): boolean {
|
||||
return (
|
||||
tableExists(db, 'session') &&
|
||||
columnExists(db, 'session', 'time_created') &&
|
||||
columnExists(db, 'session', 'time_updated')
|
||||
)
|
||||
}
|
||||
|
||||
function sessionColumnSelect(db: SyncDatabase, columnName: string): string {
|
||||
return columnExists(db, 'session', columnName) ? `s.${columnName}` : 'NULL'
|
||||
}
|
||||
|
||||
function sessionNumberColumnSelect(db: SyncDatabase, columnName: string): string {
|
||||
return columnExists(db, 'session', columnName) ? `s.${columnName}` : '0'
|
||||
}
|
||||
|
||||
function canCountOpenCodeMessages(db: SyncDatabase): boolean {
|
||||
return (
|
||||
tableExists(db, 'message') &&
|
||||
columnExists(db, 'message', 'session_id') &&
|
||||
columnExists(db, 'message', 'data')
|
||||
)
|
||||
}
|
||||
|
||||
function buildSessionQuery(db: SyncDatabase): string {
|
||||
const messageCountSubquery = canCountOpenCodeMessages(db)
|
||||
? `(SELECT COUNT(*) FROM message m
|
||||
WHERE m.session_id = s.id
|
||||
AND json_extract(m.data, '$.role') IN ('user','assistant'))`
|
||||
: '0'
|
||||
return `SELECT s.id,
|
||||
${sessionColumnSelect(db, 'title')} AS title,
|
||||
${sessionColumnSelect(db, 'directory')} AS directory,
|
||||
s.time_created,
|
||||
s.time_updated,
|
||||
${sessionColumnSelect(db, 'model')} AS model_json,
|
||||
${sessionColumnSelect(db, 'agent')} AS agent,
|
||||
${sessionNumberColumnSelect(db, 'tokens_input')} AS tokens_input,
|
||||
${sessionNumberColumnSelect(db, 'tokens_output')} AS tokens_output,
|
||||
${sessionNumberColumnSelect(db, 'tokens_reasoning')} AS tokens_reasoning,
|
||||
${sessionNumberColumnSelect(db, 'tokens_cache_read')} AS tokens_cache_read,
|
||||
${sessionNumberColumnSelect(db, 'cost')} AS cost,
|
||||
${messageCountSubquery} AS message_count
|
||||
FROM session s
|
||||
WHERE s.id = ?
|
||||
LIMIT 1`
|
||||
}
|
||||
|
||||
function extractModelId(modelJson: string | null): string | null {
|
||||
if (!modelJson) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(modelJson) as unknown
|
||||
const record =
|
||||
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
// Why: OpenCode 1.17.x stores model as {"id":"glm-5.2","providerID":"..."}.
|
||||
// Older schemas used {"modelID":"..."}; accept both.
|
||||
return (
|
||||
(typeof record.id === 'string' && record.id.trim()) ||
|
||||
(typeof record.modelID === 'string' && record.modelID.trim()) ||
|
||||
null
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function mapPreviewRole(role: string | null): AiVaultSessionPreviewMessage['role'] {
|
||||
if (role === 'user' || role === 'assistant' || role === 'system' || role === 'tool') {
|
||||
return role
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function extractPartText(partData: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(partData) as unknown
|
||||
const record =
|
||||
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
if (typeof record.text === 'string') {
|
||||
return record.text
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function buildPreviewQuery(db: SyncDatabase): string | null {
|
||||
if (
|
||||
!canCountOpenCodeMessages(db) ||
|
||||
!tableExists(db, 'part') ||
|
||||
!columnExists(db, 'message', 'id') ||
|
||||
!columnExists(db, 'part', 'message_id') ||
|
||||
!columnExists(db, 'part', 'time_created') ||
|
||||
!columnExists(db, 'part', 'data')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return `SELECT json_extract(m.data, '$.role') AS role,
|
||||
p.data AS part_data,
|
||||
p.time_created,
|
||||
json_extract(m.data, '$.summary.title') AS summary_title,
|
||||
json_extract(m.data, '$.summary.body') AS summary_body
|
||||
FROM message m
|
||||
JOIN part p ON p.message_id = m.id
|
||||
WHERE m.session_id = ?
|
||||
AND json_extract(m.data, '$.role') IN ('user','assistant')
|
||||
AND json_extract(p.data, '$.type') = 'text'
|
||||
ORDER BY p.time_created DESC
|
||||
LIMIT ?`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single OpenCode session from the SQLite database into an
|
||||
* `AiVaultSession`. Reads session metadata (title, cwd, model, tokens, cost)
|
||||
* and up to 5 preview messages by joining the `message` and `part` tables.
|
||||
* The database is opened read-only with `PRAGMA query_only = ON` as a
|
||||
* belt-and-suspenders guard against mutations.
|
||||
* @param args.dbPath - Absolute path to the opencode.db file.
|
||||
* @param args.sessionId - The session ID (primary key in the `session` table).
|
||||
* @param args.platform - The platform to use for resume command generation.
|
||||
* @returns The parsed `AiVaultSession`, or `null` if the session does not exist
|
||||
* or the database lacks the required schema.
|
||||
*/
|
||||
export async function parseOpenCodeSqliteSession(args: {
|
||||
dbPath: string
|
||||
sessionId: string
|
||||
platform: NodeJS.Platform
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const { dbPath, sessionId, platform } = args
|
||||
let db: SyncDatabase | null = null
|
||||
try {
|
||||
db = openReadonlyDatabase(dbPath)
|
||||
if (!canReadOpenCodeSessions(db)) {
|
||||
return null
|
||||
}
|
||||
const row = db.prepare(buildSessionQuery(db)).get(sessionId) as SessionRow | undefined
|
||||
if (!row || row.id !== sessionId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const mtimeMs =
|
||||
typeof row.time_updated === 'number' && row.time_updated > 0
|
||||
? row.time_updated
|
||||
: row.time_created
|
||||
// Why: discovery uses a synthetic db#session path only for parser routing.
|
||||
// The UI's log open/reveal actions need a real filesystem path.
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'opencode',
|
||||
file: {
|
||||
path: dbPath,
|
||||
mtimeMs,
|
||||
modifiedAt: new Date(mtimeMs).toISOString()
|
||||
},
|
||||
sessionId
|
||||
})
|
||||
accumulator.title = normalizeTitleText(row.title ?? '')
|
||||
accumulator.cwd = row.directory
|
||||
accumulator.model = extractModelId(row.model_json)
|
||||
accumulator.totalTokens =
|
||||
(row.tokens_input ?? 0) + (row.tokens_output ?? 0) + (row.tokens_reasoning ?? 0)
|
||||
accumulator.messageCount = row.message_count ?? 0
|
||||
updateTimeline(accumulator, row.time_created)
|
||||
updateTimeline(accumulator, row.time_updated)
|
||||
|
||||
const previewSql = buildPreviewQuery(db)
|
||||
if (previewSql) {
|
||||
const previewRows = db
|
||||
.prepare(previewSql)
|
||||
.all(sessionId, OPENCODE_SQLITE_PREVIEW_LIMIT) as PreviewRow[]
|
||||
// Why: query returns newest-first; push in chronological order so the
|
||||
// accumulator's ring buffer keeps the newest OPENCODE_SQLITE_PREVIEW_LIMIT
|
||||
// messages.
|
||||
for (let i = previewRows.length - 1; i >= 0; i--) {
|
||||
const previewRow = previewRows[i]
|
||||
if (!previewRow) {
|
||||
continue
|
||||
}
|
||||
const text = extractPartText(previewRow.part_data)
|
||||
if (!text) {
|
||||
continue
|
||||
}
|
||||
addPreviewMessage(accumulator, {
|
||||
role: mapPreviewRole(previewRow.role),
|
||||
text,
|
||||
timestamp: previewRow.time_created
|
||||
})
|
||||
if (previewRow.role === 'user' && !accumulator.title) {
|
||||
accumulator.title =
|
||||
normalizeTitleText(previewRow.summary_title ?? '') ||
|
||||
normalizeTitleText(previewRow.summary_body ?? '')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return finalizeSession(accumulator, platform)
|
||||
} finally {
|
||||
db?.close()
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { createReadStream } from 'fs'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { createInterface } from 'readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { CodexUsageSnapshot, FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
import {
|
||||
addPreviewContent,
|
||||
createAccumulator,
|
||||
@@ -16,14 +16,10 @@ import {
|
||||
asRecord,
|
||||
claudeUsageTotal,
|
||||
extractContentText,
|
||||
extractGitBranch,
|
||||
extractMessageText,
|
||||
extractModel,
|
||||
extractString,
|
||||
normalizeCodexUsage,
|
||||
normalizeTitleText,
|
||||
parseJsonObject,
|
||||
subtractCodexUsage,
|
||||
tokenTotal
|
||||
} from './session-scanner-values'
|
||||
|
||||
@@ -99,139 +95,6 @@ export async function parseClaudeSessionFile(
|
||||
return finalizeSession(accumulator, platform)
|
||||
}
|
||||
|
||||
export async function parseCodexSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
codexHome: string | null = null
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'codex',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
let previousTotals: CodexUsageSnapshot | null = null
|
||||
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
|
||||
for await (const line of lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
|
||||
updateTimeline(accumulator, extractString(record.timestamp))
|
||||
|
||||
const payload = asRecord(record.payload)
|
||||
if (record.type === 'session_meta' && payload) {
|
||||
if (isCodexWorkerSession(payload)) {
|
||||
// Why: Codex writes internal worker/sub-agent transcripts into the same
|
||||
// history tree; AI Vault should show user-started sessions only.
|
||||
return null
|
||||
}
|
||||
const sessionId = extractString(payload.id)
|
||||
if (sessionId) {
|
||||
accumulator.sessionId = sessionId
|
||||
}
|
||||
const cwd = extractString(payload.cwd)
|
||||
if (cwd) {
|
||||
accumulator.cwd = cwd
|
||||
}
|
||||
accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch
|
||||
continue
|
||||
}
|
||||
|
||||
if (record.type === 'turn_context' && payload) {
|
||||
const cwd = extractString(payload.cwd)
|
||||
if (cwd) {
|
||||
accumulator.cwd = cwd
|
||||
}
|
||||
const model = extractModel(payload)
|
||||
if (model) {
|
||||
accumulator.model = model
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (record.type === 'response_item' && payload.type === 'message') {
|
||||
accumulator.messageCount++
|
||||
if (payload.role === 'user' && !accumulator.title) {
|
||||
accumulator.title = extractContentText(payload.content)
|
||||
}
|
||||
addPreviewContent(
|
||||
accumulator,
|
||||
payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown',
|
||||
payload.content,
|
||||
record.timestamp
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (record.type !== 'event_msg') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (payload.type === 'user_message') {
|
||||
accumulator.messageCount++
|
||||
if (!accumulator.title) {
|
||||
accumulator.title = extractContentText(payload.message)
|
||||
}
|
||||
addPreviewContent(accumulator, 'user', payload.message, record.timestamp)
|
||||
continue
|
||||
}
|
||||
|
||||
if (payload.type === 'agent_message') {
|
||||
accumulator.messageCount++
|
||||
addPreviewContent(accumulator, 'assistant', payload.message, record.timestamp)
|
||||
continue
|
||||
}
|
||||
|
||||
if (payload.type !== 'token_count') {
|
||||
continue
|
||||
}
|
||||
|
||||
const info = asRecord(payload.info)
|
||||
if (!info) {
|
||||
continue
|
||||
}
|
||||
const totalUsage = normalizeCodexUsage(info.total_token_usage)
|
||||
const lastUsage = normalizeCodexUsage(info.last_token_usage)
|
||||
const delta = totalUsage ? subtractCodexUsage(totalUsage, previousTotals) : lastUsage
|
||||
if (totalUsage) {
|
||||
previousTotals = totalUsage
|
||||
}
|
||||
if (delta) {
|
||||
accumulator.totalTokens += delta.totalTokens
|
||||
}
|
||||
const model = extractModel(payload)
|
||||
if (model) {
|
||||
accumulator.model = model
|
||||
}
|
||||
}
|
||||
|
||||
return finalizeSession(accumulator, platform, { codexHome })
|
||||
}
|
||||
|
||||
function extractCodexThreadSource(payload: Record<string, unknown>): string | null {
|
||||
return extractString(payload.thread_source) ?? extractString(payload.threadSource)
|
||||
}
|
||||
|
||||
function isCodexWorkerSession(payload: Record<string, unknown>): boolean {
|
||||
const threadSource = extractCodexThreadSource(payload)
|
||||
if (threadSource) {
|
||||
return threadSource.toLowerCase() !== 'user'
|
||||
}
|
||||
|
||||
const source = asRecord(payload.source)
|
||||
return Boolean(asRecord(source?.subagent))
|
||||
}
|
||||
|
||||
export async function parseGeminiSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
import { uniqueCodexSessionsDirs } from './session-scanner-codex-paths'
|
||||
import { discoverFiles, discoverOpenClawFiles } from './session-scanner-discovery'
|
||||
import { droidDiscoveries, kimiDiscoveries } from './session-scanner-droid-kimi-sources'
|
||||
import { opencodeDiscoveries } from './session-scanner-opencode-sources'
|
||||
import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types'
|
||||
import { normalizePiSessionsDir } from './session-scanner-values'
|
||||
|
||||
@@ -17,10 +18,6 @@ const COPILOT_SESSIONS_DIR = join(
|
||||
'session-state'
|
||||
)
|
||||
const CURSOR_PROJECTS_DIR = join(homedir(), '.cursor', 'projects')
|
||||
const OPENCODE_STORAGE_DIR = join(
|
||||
process.env.OPENCODE_CONFIG_DIR?.trim() || join(homedir(), '.local', 'share', 'opencode'),
|
||||
'storage'
|
||||
)
|
||||
const GROK_SESSIONS_DIR = join(
|
||||
process.env.GROK_HOME?.trim() || join(homedir(), '.grok'),
|
||||
'sessions'
|
||||
@@ -55,7 +52,11 @@ export async function discoverAiVaultSessionSources(args: {
|
||||
...(options.additionalCodexSessionsDirs ?? [])
|
||||
])
|
||||
|
||||
return Promise.all<SessionFileDiscovery>([
|
||||
return Promise.all([
|
||||
// Why: OpenCode 1.17.x migrated sessions from per-session JSON files to a
|
||||
// SQLite DB. discoverOpenCodeSessions runs both the file scanner (legacy)
|
||||
// and the SQLite scanner (1.17.x); dedup by sessionId happens inside.
|
||||
...opencodeDiscoveries(options, wslHomeDirs, limitPerAgent, issues),
|
||||
...claudeDiscoveries(options, wslHomeDirs, limitPerAgent, issues),
|
||||
...codexDiscoveries(codexSessionsDirs, limitPerAgent, issues),
|
||||
...standardDiscoveries(options, wslHomeDirs, limitPerAgent, issues),
|
||||
@@ -109,7 +110,6 @@ function standardDiscoveries(
|
||||
discoverFiles({ rootDir, limit, agent: 'copilot', issues, extensions: ['.jsonl'] })
|
||||
),
|
||||
...cursorDiscoveries(options, wslHomeDirs, limit, issues),
|
||||
...opencodeDiscoveries(options, wslHomeDirs, limit, issues),
|
||||
...grokDiscoveries(options, wslHomeDirs, limit, issues),
|
||||
...devinDiscoveries(options, wslHomeDirs, limit, issues),
|
||||
...hermesDiscoveries(options, wslHomeDirs, limit, issues),
|
||||
@@ -139,21 +139,6 @@ function cursorDiscoveries(
|
||||
)
|
||||
}
|
||||
|
||||
function opencodeDiscoveries(
|
||||
options: AiVaultScanOptions,
|
||||
wslHomeDirs: readonly string[],
|
||||
limit: number,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<SessionFileDiscovery>[] {
|
||||
return sessionRootDirs(
|
||||
join(options.opencodeStorageDir ?? OPENCODE_STORAGE_DIR, 'session'),
|
||||
wslHomeDirs,
|
||||
['.local', 'share', 'opencode', 'storage', 'session']
|
||||
).map((rootDir) =>
|
||||
discoverFiles({ rootDir, limit, agent: 'opencode', issues, extensions: ['.json'] })
|
||||
)
|
||||
}
|
||||
|
||||
function grokDiscoveries(
|
||||
options: AiVaultScanOptions,
|
||||
wslHomeDirs: readonly string[],
|
||||
|
||||
@@ -5,7 +5,7 @@ const HIDDEN_BLOCK_CLOSE_SCAN_LIMIT = 256 * 1024
|
||||
const HIDDEN_BLOCK_OPEN_TAG_SCAN_LIMIT = 512
|
||||
const FIELD_BLANK_SCAN_LIMIT = 1024
|
||||
|
||||
const AGENTS_INSTRUCTIONS_PREFIX = '# AGENTS.md instructions for'
|
||||
const AGENTS_INSTRUCTIONS_PREFIX = '# AGENTS.md instructions'
|
||||
const XML_INSTRUCTIONS_PREFIX = '<INSTRUCTIONS>'
|
||||
|
||||
const HIDDEN_TEXT_BLOCKS = [
|
||||
|
||||
@@ -14,6 +14,9 @@ export type AiVaultScanOptions = {
|
||||
copilotSessionsDir?: string
|
||||
cursorProjectsDir?: string
|
||||
opencodeStorageDir?: string
|
||||
// Why: OpenCode 1.17.x stores sessions in SQLite; tests inject a temp DB
|
||||
// here so they don't depend on the real ~/.local/share/opencode.
|
||||
opencodeDbPaths?: readonly string[]
|
||||
grokSessionsDir?: string
|
||||
devinTranscriptsDir?: string
|
||||
hermesSessionsDir?: string
|
||||
|
||||
@@ -21,6 +21,9 @@ function isolatedScanRoots(root: string) {
|
||||
copilotSessionsDir: join(root, 'copilot-sessions'),
|
||||
cursorProjectsDir: join(root, 'cursor-projects'),
|
||||
opencodeStorageDir: join(root, 'opencode-storage'),
|
||||
// Why: prevent the SQLite scanner from picking up the real
|
||||
// ~/.local/share/opencode/opencode.db during tests.
|
||||
opencodeDbPaths: [] as readonly string[],
|
||||
grokSessionsDir: join(root, 'grok-sessions'),
|
||||
devinTranscriptsDir: join(root, 'devin-transcripts'),
|
||||
hermesSessionsDir: join(root, 'hermes-sessions'),
|
||||
@@ -110,7 +113,7 @@ describe('scanAiVaultSessions', () => {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: '# AGENTS.md instructions for /repo/app <INSTRUCTIONS>' }
|
||||
{ type: 'text', text: '# AGENTS.md instructions\n\n<INSTRUCTIONS>repo policy' }
|
||||
]
|
||||
}
|
||||
}),
|
||||
@@ -162,6 +165,15 @@ describe('scanAiVaultSessions', () => {
|
||||
})
|
||||
].join('\n')
|
||||
)
|
||||
await writeFile(
|
||||
join(root, 'session_index.jsonl'),
|
||||
jsonLines([
|
||||
{
|
||||
id: '019f0000-1111-7222-8333-444444444444',
|
||||
thread_name: 'Indexed Codex resume picker title'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const result = await scanAiVaultSessions({
|
||||
...roots,
|
||||
@@ -171,7 +183,7 @@ describe('scanAiVaultSessions', () => {
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(2)
|
||||
expect(result.sessions.map((session) => session.title).sort()).toEqual([
|
||||
'Fix the resume picker filters',
|
||||
'Indexed Codex resume picker title',
|
||||
'Vault polish pass'
|
||||
])
|
||||
|
||||
|
||||
@@ -21,6 +21,15 @@ const DEFAULT_LIMIT = 1000
|
||||
const DEFAULT_SCAN_LIMIT_PER_AGENT = 1000
|
||||
const SESSION_PARSE_CONCURRENCY = 8
|
||||
|
||||
/**
|
||||
* Scan all supported AI agent session stores and return a unified, sorted,
|
||||
* deduplicated list of sessions for the AI Vault panel. Discovers sessions
|
||||
* from file-based stores (Claude, Codex, Gemini, etc.) and SQLite-based
|
||||
* stores (OpenCode 1.17.x). Results are sorted by session sort time DESC
|
||||
* and truncated to `limit`.
|
||||
* @param options - Optional scan configuration (limits, custom dirs, platform).
|
||||
* @returns The list of sessions, scan issues, and a timestamp.
|
||||
*/
|
||||
export async function scanAiVaultSessions(
|
||||
options: AiVaultScanOptions = {}
|
||||
): Promise<AiVaultListResult> {
|
||||
|
||||
@@ -1424,6 +1424,38 @@ describe('createPtySubprocess', () => {
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('normalizes MSYS drive cwd before spawning daemon PowerShell on Windows', () => {
|
||||
const proc = mockPtyProcess()
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
spawnMock.mockImplementation((_shell, _args, options) => {
|
||||
if (options.cwd === '/c/Users/alice/project') {
|
||||
throw new Error('Cannot create process, error code: 267')
|
||||
}
|
||||
return proc
|
||||
})
|
||||
|
||||
try {
|
||||
createPtySubprocess({
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/c/Users/alice/project',
|
||||
shellOverride: 'powershell.exe'
|
||||
})
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'powershell.exe',
|
||||
POWERSHELL_OSC133_COMMAND_ARGS,
|
||||
expect.objectContaining({ cwd: 'C:\\Users\\alice\\project' })
|
||||
)
|
||||
})
|
||||
|
||||
it('validates the requested Windows cwd before launching WSL on Windows', () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { existsSyncMock, gitExecFileAsyncMock, readFileMock } = vi.hoisted(() => ({
|
||||
existsSyncMock: vi.fn(),
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
readFileMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./runner', () => ({
|
||||
gitExecFileAsync: gitExecFileAsyncMock,
|
||||
gitStreamStdout: async (
|
||||
args: string[],
|
||||
options: { onStdout: (chunk: string) => boolean | void }
|
||||
) => {
|
||||
const { stdout } = await gitExecFileAsyncMock(args)
|
||||
const stoppedEarly = options.onStdout(stdout ?? '') === true
|
||||
return { stoppedEarly }
|
||||
},
|
||||
gitOptionalLocksDisabledEnv: (env: NodeJS.ProcessEnv = process.env) => ({
|
||||
...env,
|
||||
GIT_OPTIONAL_LOCKS: '0'
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: readFileMock
|
||||
}))
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: existsSyncMock
|
||||
}))
|
||||
|
||||
import {
|
||||
clearEffectiveUpstreamNegativeStatusCache,
|
||||
clearEffectiveUpstreamStatusCacheForTests,
|
||||
getEffectiveUpstreamStatusCacheCountForTests,
|
||||
getEffectiveUpstreamStatusGenerationCountForTests,
|
||||
getStatus
|
||||
} from './status'
|
||||
|
||||
describe('local upstream negative cache', () => {
|
||||
beforeEach(() => {
|
||||
clearEffectiveUpstreamStatusCacheForTests()
|
||||
existsSyncMock.mockReset()
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
readFileMock.mockReset()
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
|
||||
})
|
||||
|
||||
it('bypasses a cached negative result for strict status reads', async () => {
|
||||
let originBranchExists = false
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args.includes('status')) {
|
||||
return {
|
||||
stdout: '# branch.oid abcdef1234567890\n# branch.head feature\n'
|
||||
}
|
||||
}
|
||||
if (args[0] === 'symbolic-ref' && args.includes('HEAD')) {
|
||||
return { stdout: 'feature\n' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
|
||||
throw new Error('fatal: no upstream configured for branch feature')
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) {
|
||||
if (originBranchExists) {
|
||||
return { stdout: 'abc123\n' }
|
||||
}
|
||||
throw new Error('missing remote branch')
|
||||
}
|
||||
if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) {
|
||||
return { stdout: '0\t1\n' }
|
||||
}
|
||||
throw new Error(`unexpected git command: ${args.join(' ')}`)
|
||||
})
|
||||
|
||||
const first = await getStatus('/repo')
|
||||
originBranchExists = true
|
||||
const automatic = await getStatus('/repo')
|
||||
const strict = await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true })
|
||||
|
||||
expect(first.upstreamStatus).toEqual({ hasUpstream: false, ahead: 0, behind: 0 })
|
||||
expect(automatic.upstreamStatus).toEqual(first.upstreamStatus)
|
||||
expect(strict.upstreamStatus).toEqual({
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/feature',
|
||||
ahead: 0,
|
||||
behind: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an older automatic negative probe from overwriting a strict positive result', async () => {
|
||||
let originBranchExists = false
|
||||
let deferredOriginReject: ((error: Error) => void) | null = null
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args.includes('status')) {
|
||||
return {
|
||||
stdout: '# branch.oid abcdef1234567890\n# branch.head feature\n'
|
||||
}
|
||||
}
|
||||
if (args[0] === 'symbolic-ref' && args.includes('HEAD')) {
|
||||
return { stdout: 'feature\n' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
|
||||
throw new Error('fatal: no upstream configured for branch feature')
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) {
|
||||
if (originBranchExists) {
|
||||
return { stdout: 'abc123\n' }
|
||||
}
|
||||
return await new Promise<{ stdout: string }>((_, reject) => {
|
||||
deferredOriginReject = reject
|
||||
})
|
||||
}
|
||||
if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) {
|
||||
return { stdout: '0\t1\n' }
|
||||
}
|
||||
throw new Error(`unexpected git command: ${args.join(' ')}`)
|
||||
})
|
||||
|
||||
const automatic = getStatus('/repo')
|
||||
await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy())
|
||||
|
||||
originBranchExists = true
|
||||
const strict = await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true })
|
||||
if (!deferredOriginReject) {
|
||||
throw new Error('expected deferred origin reject')
|
||||
}
|
||||
;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch'))
|
||||
const staleAutomatic = await automatic
|
||||
const nextAutomatic = await getStatus('/repo')
|
||||
|
||||
expect(strict.upstreamStatus).toEqual({
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/feature',
|
||||
ahead: 0,
|
||||
behind: 1
|
||||
})
|
||||
expect(staleAutomatic.upstreamStatus).toEqual({ hasUpstream: false, ahead: 0, behind: 0 })
|
||||
expect(nextAutomatic.upstreamStatus).toEqual(strict.upstreamStatus)
|
||||
})
|
||||
|
||||
it('does not trim generation for an unresolved automatic probe', async () => {
|
||||
let originBranchExists = false
|
||||
let deferredOriginReject: ((error: Error) => void) | null = null
|
||||
const branchQueue = [
|
||||
'feature',
|
||||
'feature',
|
||||
...Array.from({ length: 512 }, (_, index) => `other-${index}`),
|
||||
'feature'
|
||||
]
|
||||
let currentBranch = 'feature'
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args.includes('status')) {
|
||||
currentBranch = branchQueue.shift() ?? currentBranch
|
||||
return {
|
||||
stdout: `# branch.oid abcdef1234567890\n# branch.head ${currentBranch}\n`
|
||||
}
|
||||
}
|
||||
if (args[0] === 'symbolic-ref' && args.includes('HEAD')) {
|
||||
return { stdout: `${currentBranch}\n` }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
|
||||
throw new Error(`fatal: no upstream configured for branch ${currentBranch}`)
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/origin/'))) {
|
||||
if (originBranchExists) {
|
||||
return { stdout: 'abc123\n' }
|
||||
}
|
||||
return await new Promise<{ stdout: string }>((_, reject) => {
|
||||
deferredOriginReject = reject
|
||||
})
|
||||
}
|
||||
if (args[0] === 'rev-list' && args.some((arg) => arg.startsWith('HEAD...origin/'))) {
|
||||
return { stdout: '0\t1\n' }
|
||||
}
|
||||
throw new Error(`unexpected git command: ${args.join(' ')}`)
|
||||
})
|
||||
|
||||
const automatic = getStatus('/repo')
|
||||
await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy())
|
||||
|
||||
originBranchExists = true
|
||||
const strict = await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true })
|
||||
for (let index = 0; index < 512; index += 1) {
|
||||
await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true })
|
||||
}
|
||||
if (!deferredOriginReject) {
|
||||
throw new Error('expected deferred origin reject')
|
||||
}
|
||||
;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch'))
|
||||
await automatic
|
||||
const nextAutomatic = await getStatus('/repo')
|
||||
|
||||
expect(strict.upstreamStatus).toEqual({
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/feature',
|
||||
ahead: 0,
|
||||
behind: 1
|
||||
})
|
||||
expect(nextAutomatic.upstreamStatus).toEqual(strict.upstreamStatus)
|
||||
expect(getEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512)
|
||||
})
|
||||
|
||||
it('does not trim generation for a cleared automatic probe before it settles', async () => {
|
||||
let originBranchExists = false
|
||||
let deferredOriginReject: ((error: Error) => void) | null = null
|
||||
const branchQueue = [
|
||||
'feature',
|
||||
...Array.from({ length: 512 }, (_, index) => `other-${index}`),
|
||||
'feature'
|
||||
]
|
||||
let currentBranch = 'feature'
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args.includes('status')) {
|
||||
currentBranch = branchQueue.shift() ?? currentBranch
|
||||
return {
|
||||
stdout: `# branch.oid abcdef1234567890\n# branch.head ${currentBranch}\n`
|
||||
}
|
||||
}
|
||||
if (args[0] === 'symbolic-ref' && args.includes('HEAD')) {
|
||||
return { stdout: `${currentBranch}\n` }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
|
||||
throw new Error(`fatal: no upstream configured for branch ${currentBranch}`)
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/origin/'))) {
|
||||
if (originBranchExists) {
|
||||
return { stdout: 'abc123\n' }
|
||||
}
|
||||
return await new Promise<{ stdout: string }>((_, reject) => {
|
||||
deferredOriginReject = reject
|
||||
})
|
||||
}
|
||||
if (args[0] === 'rev-list' && args.some((arg) => arg.startsWith('HEAD...origin/'))) {
|
||||
return { stdout: '0\t1\n' }
|
||||
}
|
||||
throw new Error(`unexpected git command: ${args.join(' ')}`)
|
||||
})
|
||||
|
||||
const automatic = getStatus('/repo')
|
||||
await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy())
|
||||
|
||||
originBranchExists = true
|
||||
clearEffectiveUpstreamNegativeStatusCache({ worktreePath: '/repo', branchName: 'feature' })
|
||||
for (let index = 0; index < 512; index += 1) {
|
||||
await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true })
|
||||
}
|
||||
if (!deferredOriginReject) {
|
||||
throw new Error('expected deferred origin reject')
|
||||
}
|
||||
;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch'))
|
||||
await automatic
|
||||
const nextAutomatic = await getStatus('/repo')
|
||||
|
||||
expect(nextAutomatic.upstreamStatus).toEqual({
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/feature',
|
||||
ahead: 0,
|
||||
behind: 1
|
||||
})
|
||||
expect(getEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512)
|
||||
})
|
||||
|
||||
it('bounds effective-upstream negative entries', async () => {
|
||||
let branchIndex = 0
|
||||
let currentBranch = 'feature-0'
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args.includes('status')) {
|
||||
currentBranch = `feature-${branchIndex}`
|
||||
branchIndex += 1
|
||||
return {
|
||||
stdout: `# branch.oid abcdef1234567890\n# branch.head ${currentBranch}\n`
|
||||
}
|
||||
}
|
||||
if (args[0] === 'symbolic-ref' && args.includes('HEAD')) {
|
||||
return { stdout: `${currentBranch}\n` }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
|
||||
throw new Error(`fatal: no upstream configured for branch ${currentBranch}`)
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/origin/'))) {
|
||||
throw new Error('missing remote branch')
|
||||
}
|
||||
throw new Error(`unexpected git command: ${args.join(' ')}`)
|
||||
})
|
||||
|
||||
for (let index = 0; index < 513; index += 1) {
|
||||
await getStatus('/repo')
|
||||
}
|
||||
|
||||
expect(getEffectiveUpstreamStatusCacheCountForTests()).toBe(512)
|
||||
expect(getEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512)
|
||||
})
|
||||
|
||||
it('bounds write-generation entries from positive strict probes', async () => {
|
||||
let branchIndex = 0
|
||||
let currentBranch = 'feature-0'
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args.includes('status')) {
|
||||
currentBranch = `feature-${branchIndex}`
|
||||
branchIndex += 1
|
||||
return {
|
||||
stdout: `# branch.oid abcdef1234567890\n# branch.head ${currentBranch}\n`
|
||||
}
|
||||
}
|
||||
if (args[0] === 'symbolic-ref' && args.includes('HEAD')) {
|
||||
return { stdout: `${currentBranch}\n` }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
|
||||
throw new Error(`fatal: no upstream configured for branch ${currentBranch}`)
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${currentBranch}`)) {
|
||||
return { stdout: 'abc123\n' }
|
||||
}
|
||||
if (args[0] === 'rev-list' && args.includes(`HEAD...origin/${currentBranch}`)) {
|
||||
return { stdout: '0\t1\n' }
|
||||
}
|
||||
throw new Error(`unexpected git command: ${args.join(' ')}`)
|
||||
})
|
||||
|
||||
for (let index = 0; index < 513; index += 1) {
|
||||
await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true })
|
||||
}
|
||||
|
||||
expect(getEffectiveUpstreamStatusCacheCountForTests()).toBe(0)
|
||||
expect(getEffectiveUpstreamStatusGenerationCountForTests()).toBe(512)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Repro command:
|
||||
// pnpm exec vitest run --config config/vitest.config.ts src/main/git/status-upstream-probe-churn.test.ts -t "missing-upstream polling churn"
|
||||
@@ -51,6 +51,10 @@ describe('getStatus missing-upstream polling churn', () => {
|
||||
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/Initi-Project\n')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('does not repeat failed effective-upstream probes for a branch with no upstream', async () => {
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args.includes('status')) {
|
||||
@@ -87,6 +91,39 @@ describe('getStatus missing-upstream polling churn', () => {
|
||||
expect(sameNameOriginProbeCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps failed effective-upstream probes cached beyond thirty seconds', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args.includes('status')) {
|
||||
return {
|
||||
stdout: '# branch.oid abcdef1234567890\n# branch.head Initi-Project\n'
|
||||
}
|
||||
}
|
||||
if (args[0] === 'symbolic-ref' && args.includes('HEAD')) {
|
||||
return { stdout: 'Initi-Project\n' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
|
||||
throw new Error("fatal: no upstream configured for branch 'Initi-Project'")
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/Initi-Project')) {
|
||||
throw new Error('missing remote branch')
|
||||
}
|
||||
throw new Error(`unexpected git command: ${args.join(' ')}`)
|
||||
})
|
||||
|
||||
await getStatus('/repo')
|
||||
vi.setSystemTime(31_000)
|
||||
await getStatus('/repo')
|
||||
|
||||
const upstreamProbeCalls = gitExecFileAsyncMock.mock.calls.filter((call) => {
|
||||
const args = getGitArgs(call)
|
||||
return args[0] === 'rev-parse' && args.includes('HEAD@{u}')
|
||||
})
|
||||
|
||||
expect(upstreamProbeCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('coalesces concurrent effective-upstream probes for a branch with no upstream', async () => {
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args.includes('status')) {
|
||||
|
||||
+109
-13
@@ -52,7 +52,8 @@ import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output'
|
||||
const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024
|
||||
const MAX_STAGED_COMMIT_CONTEXT_BYTES = MAX_GIT_SHOW_BYTES
|
||||
const BULK_CHUNK_SIZE = 100
|
||||
const EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_TTL_MS = 30_000
|
||||
const EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_TTL_MS = 5 * 60_000
|
||||
const MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES = 512
|
||||
|
||||
type EffectiveUpstreamStatusCacheEntry = {
|
||||
expiresAt: number
|
||||
@@ -61,10 +62,22 @@ type EffectiveUpstreamStatusCacheEntry = {
|
||||
|
||||
const effectiveUpstreamStatusCache = new Map<string, EffectiveUpstreamStatusCacheEntry>()
|
||||
const effectiveUpstreamStatusInFlight = new Map<string, Promise<GitUpstreamStatus>>()
|
||||
const retiredEffectiveUpstreamStatusInFlight = new Map<string, Promise<GitUpstreamStatus>>()
|
||||
const effectiveUpstreamStatusWriteGeneration = new Map<string, number>()
|
||||
|
||||
export function clearEffectiveUpstreamStatusCacheForTests(): void {
|
||||
effectiveUpstreamStatusCache.clear()
|
||||
effectiveUpstreamStatusInFlight.clear()
|
||||
retiredEffectiveUpstreamStatusInFlight.clear()
|
||||
effectiveUpstreamStatusWriteGeneration.clear()
|
||||
}
|
||||
|
||||
export function getEffectiveUpstreamStatusCacheCountForTests(): number {
|
||||
return effectiveUpstreamStatusCache.size
|
||||
}
|
||||
|
||||
export function getEffectiveUpstreamStatusGenerationCountForTests(): number {
|
||||
return effectiveUpstreamStatusWriteGeneration.size
|
||||
}
|
||||
|
||||
export type GetStatusOptions = GitRuntimeOptions & {
|
||||
@@ -74,6 +87,7 @@ export type GetStatusOptions = GitRuntimeOptions & {
|
||||
* `didHitLimit`. Defaults to DEFAULT_GIT_STATUS_LIMIT; 0 disables the cap.
|
||||
*/
|
||||
limit?: number
|
||||
bypassEffectiveUpstreamNegativeCache?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,7 +182,8 @@ export async function getStatus(
|
||||
cacheKey,
|
||||
worktreePath,
|
||||
branchName,
|
||||
options
|
||||
options,
|
||||
options.bypassEffectiveUpstreamNegativeCache === true
|
||||
)
|
||||
} catch {
|
||||
// Why: git status polling should not fail just because the richer
|
||||
@@ -282,6 +297,64 @@ function getEffectiveUpstreamStatusCacheKey(
|
||||
return [worktreePath, options.wslDistro ?? 'host', branchName, upstreamName ?? ''].join('\0')
|
||||
}
|
||||
|
||||
export function clearEffectiveUpstreamNegativeStatusCache(identity: {
|
||||
worktreePath: string
|
||||
branchName: string
|
||||
upstreamName?: string
|
||||
options?: GitRuntimeOptions
|
||||
}): void {
|
||||
const cacheKey = getEffectiveUpstreamStatusCacheKey(
|
||||
identity.worktreePath,
|
||||
identity.branchName,
|
||||
identity.upstreamName,
|
||||
identity.options
|
||||
)
|
||||
retireEffectiveUpstreamStatusProbe(cacheKey)
|
||||
effectiveUpstreamStatusCache.delete(cacheKey)
|
||||
effectiveUpstreamStatusInFlight.delete(cacheKey)
|
||||
effectiveUpstreamStatusWriteGeneration.set(
|
||||
cacheKey,
|
||||
(effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0) + 1
|
||||
)
|
||||
}
|
||||
|
||||
function retireEffectiveUpstreamStatusProbe(cacheKey: string): void {
|
||||
const retiredProbe = effectiveUpstreamStatusInFlight.get(cacheKey)
|
||||
if (!retiredProbe) {
|
||||
return
|
||||
}
|
||||
retiredEffectiveUpstreamStatusInFlight.set(cacheKey, retiredProbe)
|
||||
void retiredProbe
|
||||
.finally(() => {
|
||||
if (retiredEffectiveUpstreamStatusInFlight.get(cacheKey) === retiredProbe) {
|
||||
retiredEffectiveUpstreamStatusInFlight.delete(cacheKey)
|
||||
trimEffectiveUpstreamStatusGeneration()
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
function hasPendingEffectiveUpstreamStatusProbe(cacheKey: string): boolean {
|
||||
return (
|
||||
effectiveUpstreamStatusInFlight.has(cacheKey) ||
|
||||
retiredEffectiveUpstreamStatusInFlight.has(cacheKey)
|
||||
)
|
||||
}
|
||||
|
||||
function trimEffectiveUpstreamStatusGeneration(): void {
|
||||
for (const cacheKey of effectiveUpstreamStatusWriteGeneration.keys()) {
|
||||
if (
|
||||
effectiveUpstreamStatusWriteGeneration.size <= MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES
|
||||
) {
|
||||
break
|
||||
}
|
||||
if (hasPendingEffectiveUpstreamStatusProbe(cacheKey)) {
|
||||
continue
|
||||
}
|
||||
effectiveUpstreamStatusWriteGeneration.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
function readCachedEffectiveUpstreamStatus(
|
||||
cacheKey: string,
|
||||
now: number
|
||||
@@ -301,12 +374,18 @@ function rememberEffectiveUpstreamStatus(
|
||||
cacheKey: string,
|
||||
status: GitUpstreamStatus,
|
||||
now: number,
|
||||
probedSameNameOriginRef: boolean
|
||||
probedSameNameOriginRef: boolean,
|
||||
writeGeneration: number
|
||||
): void {
|
||||
// Why: hasConfiguredPushTarget gates a write action. Re-probe it each poll
|
||||
// rather than keeping a stale positive target after branch config changes.
|
||||
if (status.hasUpstream || status.hasConfiguredPushTarget) {
|
||||
effectiveUpstreamStatusCache.delete(cacheKey)
|
||||
effectiveUpstreamStatusWriteGeneration.set(cacheKey, writeGeneration + 1)
|
||||
trimEffectiveUpstreamStatusGeneration()
|
||||
return
|
||||
}
|
||||
if ((effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0) !== writeGeneration) {
|
||||
return
|
||||
}
|
||||
if (!probedSameNameOriginRef) {
|
||||
@@ -318,41 +397,58 @@ function rememberEffectiveUpstreamStatus(
|
||||
status,
|
||||
expiresAt: now + EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_TTL_MS
|
||||
})
|
||||
while (effectiveUpstreamStatusCache.size > MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES) {
|
||||
const oldest = effectiveUpstreamStatusCache.keys().next()
|
||||
if (oldest.done) {
|
||||
break
|
||||
}
|
||||
effectiveUpstreamStatusCache.delete(oldest.value)
|
||||
effectiveUpstreamStatusWriteGeneration.delete(oldest.value)
|
||||
}
|
||||
trimEffectiveUpstreamStatusGeneration()
|
||||
}
|
||||
|
||||
async function readOrProbeEffectiveUpstreamStatus(
|
||||
cacheKey: string,
|
||||
worktreePath: string,
|
||||
branchName: string,
|
||||
options: GitRuntimeOptions = {}
|
||||
options: GitRuntimeOptions = {},
|
||||
bypassCache = false
|
||||
): Promise<GitUpstreamStatus> {
|
||||
const cached = readCachedEffectiveUpstreamStatus(cacheKey, Date.now())
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
if (!bypassCache) {
|
||||
const cached = readCachedEffectiveUpstreamStatus(cacheKey, Date.now())
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const inFlight = effectiveUpstreamStatusInFlight.get(cacheKey)
|
||||
if (inFlight) {
|
||||
return inFlight
|
||||
const inFlight = effectiveUpstreamStatusInFlight.get(cacheKey)
|
||||
if (inFlight) {
|
||||
return inFlight
|
||||
}
|
||||
}
|
||||
|
||||
// Why: source-control mount and root git refresh can overlap during startup.
|
||||
// Coalesce the richer upstream probe so a stable missing ref fails once.
|
||||
const writeGeneration = effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0
|
||||
const probe = probeEffectiveUpstreamStatus(worktreePath, branchName, options).then((result) => {
|
||||
rememberEffectiveUpstreamStatus(
|
||||
cacheKey,
|
||||
result.status,
|
||||
Date.now(),
|
||||
result.probedSameNameOriginRef
|
||||
result.probedSameNameOriginRef,
|
||||
writeGeneration
|
||||
)
|
||||
return result.status
|
||||
})
|
||||
effectiveUpstreamStatusInFlight.set(cacheKey, probe)
|
||||
if (!bypassCache) {
|
||||
effectiveUpstreamStatusInFlight.set(cacheKey, probe)
|
||||
}
|
||||
try {
|
||||
return await probe
|
||||
} finally {
|
||||
if (effectiveUpstreamStatusInFlight.get(cacheKey) === probe) {
|
||||
effectiveUpstreamStatusInFlight.delete(cacheKey)
|
||||
trimEffectiveUpstreamStatusGeneration()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +110,8 @@ import {
|
||||
updatePRTitle,
|
||||
_getMergeQueueCacheSizeForTests,
|
||||
_resetOwnerRepoCache,
|
||||
_resetMergeQueueCacheForTests
|
||||
_resetMergeQueueCacheForTests,
|
||||
__resetTrackedUpstreamBranchCacheForTests
|
||||
} from './client'
|
||||
|
||||
describe('checkOrcaStarred', () => {
|
||||
@@ -179,6 +180,7 @@ describe('getPRForBranch', () => {
|
||||
acquireMock.mockResolvedValue(undefined)
|
||||
_resetOwnerRepoCache()
|
||||
_resetMergeQueueCacheForTests()
|
||||
__resetTrackedUpstreamBranchCacheForTests()
|
||||
})
|
||||
|
||||
it('queries GitHub by head branch when the remote is on github.com', async () => {
|
||||
@@ -1160,6 +1162,104 @@ describe('getPRForBranch', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not repeat missing tracked-upstream probes during PR refresh polling', async () => {
|
||||
resolvePRRepositoryCandidatesMock.mockResolvedValue({
|
||||
candidates: [{ owner: 'acme', repo: 'widgets' }],
|
||||
headRepo: { owner: 'acme', repo: 'widgets' }
|
||||
})
|
||||
ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) })
|
||||
gitExecFileAsyncMock.mockRejectedValue(
|
||||
new Error("fatal: no upstream configured for branch 'no-pr-branch'")
|
||||
)
|
||||
|
||||
await getPRForBranch('/repo-root', 'no-pr-branch')
|
||||
await getPRForBranch('/repo-root', 'no-pr-branch')
|
||||
await getPRForBranch('/repo-root', 'no-pr-branch')
|
||||
|
||||
const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) =>
|
||||
(args as string[]).includes('no-pr-branch@{upstream}')
|
||||
)
|
||||
expect(trackedUpstreamCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('coalesces concurrent missing tracked-upstream probes', async () => {
|
||||
resolvePRRepositoryCandidatesMock.mockResolvedValue({
|
||||
candidates: [{ owner: 'acme', repo: 'widgets' }],
|
||||
headRepo: { owner: 'acme', repo: 'widgets' }
|
||||
})
|
||||
ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) })
|
||||
gitExecFileAsyncMock.mockImplementation(async () => {
|
||||
await Promise.resolve()
|
||||
throw new Error("fatal: no upstream configured for branch 'no-pr-branch'")
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
getPRForBranch('/repo-root', 'no-pr-branch'),
|
||||
getPRForBranch('/repo-root', 'no-pr-branch'),
|
||||
getPRForBranch('/repo-root', 'no-pr-branch')
|
||||
])
|
||||
|
||||
const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) =>
|
||||
(args as string[]).includes('no-pr-branch@{upstream}')
|
||||
)
|
||||
expect(trackedUpstreamCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps missing tracked-upstream probes separate for host and WSL runtimes', async () => {
|
||||
resolvePRRepositoryCandidatesMock.mockResolvedValue({
|
||||
candidates: [{ owner: 'acme', repo: 'widgets' }],
|
||||
headRepo: { owner: 'acme', repo: 'widgets' }
|
||||
})
|
||||
ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) })
|
||||
gitExecFileAsyncMock.mockRejectedValue(
|
||||
new Error("fatal: no upstream configured for branch 'no-pr-branch'")
|
||||
)
|
||||
|
||||
await getPRForBranch('/repo-root', 'no-pr-branch')
|
||||
await getPRForBranch('/repo-root', 'no-pr-branch', null, null, null, {
|
||||
localGitExecOptions: { wslDistro: 'Ubuntu' }
|
||||
})
|
||||
await getPRForBranch('/repo-root', 'no-pr-branch')
|
||||
await getPRForBranch('/repo-root', 'no-pr-branch', null, null, null, {
|
||||
localGitExecOptions: { wslDistro: 'Ubuntu' }
|
||||
})
|
||||
|
||||
const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) =>
|
||||
(args as string[]).includes('no-pr-branch@{upstream}')
|
||||
)
|
||||
expect(trackedUpstreamCalls).toHaveLength(2)
|
||||
expect(trackedUpstreamCalls[0][1]).toEqual({ cwd: '/repo-root' })
|
||||
expect(trackedUpstreamCalls[1][1]).toEqual({
|
||||
cwd: '/repo-root',
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
})
|
||||
|
||||
it('rechecks missing tracked-upstream probes after the null-cache TTL expires', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
resolvePRRepositoryCandidatesMock.mockResolvedValue({
|
||||
candidates: [{ owner: 'acme', repo: 'widgets' }],
|
||||
headRepo: { owner: 'acme', repo: 'widgets' }
|
||||
})
|
||||
ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) })
|
||||
gitExecFileAsyncMock
|
||||
.mockRejectedValueOnce(new Error("fatal: no upstream configured for branch 'feature'"))
|
||||
.mockResolvedValueOnce({ stdout: 'origin/contributor/original\n', stderr: '' })
|
||||
|
||||
await getPRForBranch('/repo-root', 'feature')
|
||||
await vi.advanceTimersByTimeAsync(30_001)
|
||||
await getPRForBranch('/repo-root', 'feature')
|
||||
|
||||
const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) =>
|
||||
(args as string[]).includes('feature@{upstream}')
|
||||
)
|
||||
expect(trackedUpstreamCalls).toHaveLength(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the tracked upstream remote owner for fork branch lookup', async () => {
|
||||
resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({
|
||||
candidates: [
|
||||
|
||||
@@ -2273,6 +2273,20 @@ type TrackedUpstreamBranch = {
|
||||
branchName: string
|
||||
}
|
||||
|
||||
const TRACKED_UPSTREAM_NULL_CACHE_TTL_MS = 30_000
|
||||
|
||||
type TrackedUpstreamNullCacheEntry = {
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
const trackedUpstreamNullCache = new Map<string, TrackedUpstreamNullCacheEntry>()
|
||||
const trackedUpstreamInFlight = new Map<string, Promise<TrackedUpstreamBranch | null>>()
|
||||
|
||||
export function __resetTrackedUpstreamBranchCacheForTests(): void {
|
||||
trackedUpstreamNullCache.clear()
|
||||
trackedUpstreamInFlight.clear()
|
||||
}
|
||||
|
||||
function parseTrackedUpstreamBranch(
|
||||
upstreamRef: string,
|
||||
branchName: string
|
||||
@@ -2289,6 +2303,65 @@ async function getTrackedUpstreamBranch(
|
||||
branchName: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: { wslDistro?: string } = {}
|
||||
): Promise<TrackedUpstreamBranch | null> {
|
||||
// Why: branches without configured upstreams are stable misses during PR
|
||||
// polling; cache only nulls so positive PR discovery stays fresh.
|
||||
const cacheKey = getTrackedUpstreamBranchCacheKey(
|
||||
repoPath,
|
||||
branchName,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
const now = Date.now()
|
||||
const cachedNull = trackedUpstreamNullCache.get(cacheKey)
|
||||
if (cachedNull && cachedNull.expiresAt > now) {
|
||||
return null
|
||||
}
|
||||
if (cachedNull) {
|
||||
trackedUpstreamNullCache.delete(cacheKey)
|
||||
}
|
||||
|
||||
const inFlight = trackedUpstreamInFlight.get(cacheKey)
|
||||
if (inFlight) {
|
||||
return inFlight
|
||||
}
|
||||
|
||||
const probe = probeTrackedUpstreamBranch(repoPath, branchName, connectionId, localGitOptions)
|
||||
trackedUpstreamInFlight.set(cacheKey, probe)
|
||||
try {
|
||||
const result = await probe
|
||||
if (result) {
|
||||
trackedUpstreamNullCache.delete(cacheKey)
|
||||
} else {
|
||||
trackedUpstreamNullCache.set(cacheKey, {
|
||||
expiresAt: now + TRACKED_UPSTREAM_NULL_CACHE_TTL_MS
|
||||
})
|
||||
}
|
||||
return result
|
||||
} finally {
|
||||
if (trackedUpstreamInFlight.get(cacheKey) === probe) {
|
||||
trackedUpstreamInFlight.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getTrackedUpstreamBranchCacheKey(
|
||||
repoPath: string,
|
||||
branchName: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: { wslDistro?: string } = {}
|
||||
): string {
|
||||
const runtimeKey = connectionId
|
||||
? `ssh:${connectionId}`
|
||||
: `local:${localGitOptions.wslDistro ?? 'host'}`
|
||||
return [runtimeKey, repoPath, branchName].join('\0')
|
||||
}
|
||||
|
||||
async function probeTrackedUpstreamBranch(
|
||||
repoPath: string,
|
||||
branchName: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: { wslDistro?: string } = {}
|
||||
): Promise<TrackedUpstreamBranch | null> {
|
||||
const args = ['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${branchName}@{upstream}`]
|
||||
try {
|
||||
|
||||
@@ -251,4 +251,52 @@ describe('issue source operations', () => {
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
})
|
||||
|
||||
it('closes issues with completed, not planned, and duplicate reasons', async () => {
|
||||
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
|
||||
ghExecFileAsyncMock.mockResolvedValue({ stdout: '' })
|
||||
|
||||
await expect(
|
||||
updateIssue('/repo-root', 924, { state: 'closed', stateReason: 'completed' })
|
||||
).resolves.toEqual({ ok: true })
|
||||
await expect(
|
||||
updateIssue('/repo-root', 925, { state: 'closed', stateReason: 'not_planned' })
|
||||
).resolves.toEqual({ ok: true })
|
||||
await expect(
|
||||
updateIssue('/repo-root', 926, {
|
||||
state: 'closed',
|
||||
stateReason: 'duplicate',
|
||||
duplicateOf: 99
|
||||
})
|
||||
).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
['issue', 'close', '924', '--repo', 'stablyai/orca', '--reason', 'completed'],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
['issue', 'close', '925', '--repo', 'stablyai/orca', '--reason', 'not planned'],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
['issue', 'close', '926', '--repo', 'stablyai/orca', '--duplicate-of', '99'],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
})
|
||||
|
||||
it('reopens issues through gh issue reopen', async () => {
|
||||
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
|
||||
|
||||
await expect(updateIssue('/repo-root', 924, { state: 'open' })).resolves.toEqual({
|
||||
ok: true
|
||||
})
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['issue', 'reopen', '924', '--repo', 'stablyai/orca'],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
} from '../../shared/types'
|
||||
import { getPRForBranchOutcome, type GitHubPRBranchLookupOptions } from './client'
|
||||
import { getRateLimit, noteRateLimitSpend, rateLimitGuard } from './rate-limit'
|
||||
import { recordCoalescedCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store'
|
||||
|
||||
type QueueEntry = {
|
||||
key: string
|
||||
@@ -64,6 +65,7 @@ const BACKGROUND_BUDGET_MAX = 20
|
||||
const POST_PUSH_DELAY_MS = 2_500
|
||||
const BACKOFF_BASE_MS = 60_000
|
||||
const BACKOFF_MAX_MS = 15 * 60_000
|
||||
const DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS = 30_000
|
||||
|
||||
let sequence = 0
|
||||
let draining = false
|
||||
@@ -74,6 +76,12 @@ const errorBackoff = new Map<string, { failures: number; retryAt: number }>()
|
||||
let lastBackgroundStartAt = 0
|
||||
const visibleByWindow = new Map<number, { generation: number; keys: Set<string> }>()
|
||||
let outcomeObserver: PRRefreshOutcomeObserver | null = null
|
||||
const diagnosticsCounters = {
|
||||
enqueued: 0,
|
||||
coalesced: 0,
|
||||
skipped: 0,
|
||||
backgroundPauses: 0
|
||||
}
|
||||
|
||||
export function setPRRefreshOutcomeObserver(observer: PRRefreshOutcomeObserver | null): void {
|
||||
outcomeObserver = observer
|
||||
@@ -94,6 +102,27 @@ function removeInvisibleVisibleRefreshes(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function recordPRRefreshQueueDiagnostic(
|
||||
event: 'enqueued' | 'coalesced' | 'skipped' | 'background-pause',
|
||||
reason: GitHubPRRefreshReason,
|
||||
skippedReason?: GitHubPRRefreshSkippedReason
|
||||
): void {
|
||||
recordCoalescedCrashBreadcrumb({
|
||||
name: 'pr_refresh_queue',
|
||||
coalesceKey: `pr-refresh-queue:${event}:${reason}:${skippedReason ?? ''}`,
|
||||
minIntervalMs: DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS,
|
||||
data: {
|
||||
event,
|
||||
reason,
|
||||
...(skippedReason ? { skippedReason } : {}),
|
||||
enqueued: diagnosticsCounters.enqueued,
|
||||
coalesced: diagnosticsCounters.coalesced,
|
||||
skipped: diagnosticsCounters.skipped,
|
||||
backgroundPauses: diagnosticsCounters.backgroundPauses
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function clearVisiblePRRefreshWindow(windowId: number): void {
|
||||
if (!visibleByWindow.delete(windowId)) {
|
||||
return
|
||||
@@ -480,6 +509,8 @@ async function drainQueue(): Promise<void> {
|
||||
|
||||
const budgetDelay = isBudgetedQueueEntry(next) ? nextBudgetDelay() : 0
|
||||
if (budgetDelay > 0) {
|
||||
diagnosticsCounters.backgroundPauses += 1
|
||||
recordPRRefreshQueueDiagnostic('background-pause', next.reason)
|
||||
scheduleDrain(budgetDelay)
|
||||
return
|
||||
}
|
||||
@@ -488,6 +519,8 @@ async function drainQueue(): Promise<void> {
|
||||
const aliases = Array.from(next.aliases.values())
|
||||
const skippedReason = validateCandidate(next.candidate)
|
||||
if (skippedReason) {
|
||||
diagnosticsCounters.skipped += 1
|
||||
recordPRRefreshQueueDiagnostic('skipped', next.reason, skippedReason)
|
||||
broadcast({ aliases, reason: next.reason, status: 'skipped', skippedReason })
|
||||
continue
|
||||
}
|
||||
@@ -578,6 +611,8 @@ export function enqueuePRRefresh(
|
||||
const skippedReason = validateCandidate(candidate)
|
||||
if (skippedReason) {
|
||||
removeQueuedAliasForInvalidCandidate(key, alias)
|
||||
diagnosticsCounters.skipped += 1
|
||||
recordPRRefreshQueueDiagnostic('skipped', reason, skippedReason)
|
||||
broadcast({
|
||||
aliases: [alias],
|
||||
reason,
|
||||
@@ -592,6 +627,8 @@ export function enqueuePRRefresh(
|
||||
const dueAt = freshDueAt ?? Date.now() + (reason === 'post-push' ? POST_PUSH_DELAY_MS : 0)
|
||||
if (existing) {
|
||||
existing.aliases.set(alias.cacheKey, alias)
|
||||
diagnosticsCounters.coalesced += 1
|
||||
recordPRRefreshQueueDiagnostic('coalesced', reason)
|
||||
const shouldPromoteExisting =
|
||||
priority > existing.priority ||
|
||||
isManual(reason) ||
|
||||
@@ -604,6 +641,8 @@ export function enqueuePRRefresh(
|
||||
existing.windowId = windowId ?? existing.windowId
|
||||
}
|
||||
} else {
|
||||
diagnosticsCounters.enqueued += 1
|
||||
recordPRRefreshQueueDiagnostic('enqueued', reason)
|
||||
queue.set(key, {
|
||||
key,
|
||||
candidate,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { recordCoalescedCrashBreadcrumbMock } = vi.hoisted(() => ({
|
||||
recordCoalescedCrashBreadcrumbMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../crash-reporting/crash-breadcrumb-store', () => ({
|
||||
recordCoalescedCrashBreadcrumb: recordCoalescedCrashBreadcrumbMock
|
||||
}))
|
||||
|
||||
import {
|
||||
clearPRRefreshValidationBackoffForTests,
|
||||
getPRRefreshValidationBackoffCountForTests,
|
||||
notePRRefreshValidationDenial
|
||||
} from './pr-refresh-validation-backoff'
|
||||
|
||||
describe('PR refresh validation backoff', () => {
|
||||
beforeEach(() => {
|
||||
clearPRRefreshValidationBackoffForTests()
|
||||
recordCoalescedCrashBreadcrumbMock.mockReset()
|
||||
})
|
||||
|
||||
it('backs off repeated automatic validation denials until the TTL expires', () => {
|
||||
const identity = {
|
||||
repoId: 'repo-1',
|
||||
repoPath: '/workspace/missing',
|
||||
reason: 'unknown-repo' as const
|
||||
}
|
||||
|
||||
expect(notePRRefreshValidationDenial(identity, 0)).toBe('validation-denied')
|
||||
expect(notePRRefreshValidationDenial(identity, 60_000)).toBe('validation-backoff')
|
||||
expect(notePRRefreshValidationDenial(identity, 5 * 60_000 + 1)).toBe('validation-denied')
|
||||
})
|
||||
|
||||
it('bounds validation backoff entries', () => {
|
||||
for (let index = 0; index < 257; index += 1) {
|
||||
notePRRefreshValidationDenial(
|
||||
{
|
||||
repoId: `repo-${index}`,
|
||||
repoPath: `/workspace/missing-${index}`,
|
||||
reason: 'unknown-repo'
|
||||
},
|
||||
index
|
||||
)
|
||||
}
|
||||
|
||||
expect(getPRRefreshValidationBackoffCountForTests()).toBe(256)
|
||||
})
|
||||
|
||||
it('records path-safe diagnostic breadcrumbs', () => {
|
||||
notePRRefreshValidationDenial(
|
||||
{
|
||||
repoId: 'repo-1',
|
||||
repoPath: '/Users/alice/private/project',
|
||||
reason: 'repo-path-mismatch'
|
||||
},
|
||||
0
|
||||
)
|
||||
|
||||
expect(recordCoalescedCrashBreadcrumbMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'pr_refresh_validation_skip',
|
||||
data: expect.objectContaining({
|
||||
reason: 'repo-path-mismatch',
|
||||
result: 'recorded',
|
||||
token: expect.any(String)
|
||||
})
|
||||
})
|
||||
)
|
||||
const payload = recordCoalescedCrashBreadcrumbMock.mock.calls[0]?.[0]
|
||||
expect(JSON.stringify(payload)).not.toContain('/Users/alice/private/project')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { createHash } from 'crypto'
|
||||
import { resolve } from 'path'
|
||||
import { recordCoalescedCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store'
|
||||
|
||||
const VALIDATION_BACKOFF_TTL_MS = 5 * 60_000
|
||||
const MAX_VALIDATION_BACKOFF_ENTRIES = 256
|
||||
const VALIDATION_BREADCRUMB_MIN_INTERVAL_MS = 30_000
|
||||
|
||||
export type PRRefreshValidationDenialReason =
|
||||
| 'unknown-repo'
|
||||
| 'repo-path-mismatch'
|
||||
| 'host-mismatch'
|
||||
|
||||
type ValidationBackoffIdentity = {
|
||||
repoId?: string | null
|
||||
repoPath: string
|
||||
reason: PRRefreshValidationDenialReason
|
||||
}
|
||||
|
||||
type ValidationBackoffEntry = {
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
type ValidationBackoffCounters = {
|
||||
recorded: number
|
||||
skipped: number
|
||||
expired: number
|
||||
}
|
||||
|
||||
const validationBackoff = new Map<string, ValidationBackoffEntry>()
|
||||
const counters: ValidationBackoffCounters = {
|
||||
recorded: 0,
|
||||
skipped: 0,
|
||||
expired: 0
|
||||
}
|
||||
|
||||
function validationIdentityKey(identity: ValidationBackoffIdentity): string {
|
||||
return [identity.repoId ?? '', resolve(identity.repoPath), identity.reason].join('\0')
|
||||
}
|
||||
|
||||
function validationIdentityToken(key: string): string {
|
||||
return createHash('sha256').update(key).digest('hex').slice(0, 12)
|
||||
}
|
||||
|
||||
function evictOldestValidationBackoffEntries(): void {
|
||||
while (validationBackoff.size > MAX_VALIDATION_BACKOFF_ENTRIES) {
|
||||
const oldest = validationBackoff.keys().next()
|
||||
if (oldest.done) {
|
||||
break
|
||||
}
|
||||
validationBackoff.delete(oldest.value)
|
||||
}
|
||||
}
|
||||
|
||||
function recordValidationBreadcrumb(
|
||||
reason: PRRefreshValidationDenialReason,
|
||||
result: 'recorded' | 'backoff',
|
||||
token: string
|
||||
): void {
|
||||
recordCoalescedCrashBreadcrumb({
|
||||
name: 'pr_refresh_validation_skip',
|
||||
coalesceKey: `pr-refresh-validation:${reason}:${token}`,
|
||||
minIntervalMs: VALIDATION_BREADCRUMB_MIN_INTERVAL_MS,
|
||||
data: {
|
||||
reason,
|
||||
result,
|
||||
token,
|
||||
recorded: counters.recorded,
|
||||
skipped: counters.skipped,
|
||||
expired: counters.expired
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function notePRRefreshValidationDenial(
|
||||
identity: ValidationBackoffIdentity,
|
||||
nowMs = Date.now()
|
||||
): 'validation-denied' | 'validation-backoff' {
|
||||
const key = validationIdentityKey(identity)
|
||||
const existing = validationBackoff.get(key)
|
||||
const token = validationIdentityToken(key)
|
||||
if (existing && existing.expiresAt > nowMs) {
|
||||
counters.skipped += 1
|
||||
recordValidationBreadcrumb(identity.reason, 'backoff', token)
|
||||
return 'validation-backoff'
|
||||
}
|
||||
if (existing) {
|
||||
counters.expired += 1
|
||||
validationBackoff.delete(key)
|
||||
}
|
||||
counters.recorded += 1
|
||||
validationBackoff.set(key, { expiresAt: nowMs + VALIDATION_BACKOFF_TTL_MS })
|
||||
evictOldestValidationBackoffEntries()
|
||||
recordValidationBreadcrumb(identity.reason, 'recorded', token)
|
||||
return 'validation-denied'
|
||||
}
|
||||
|
||||
export function clearPRRefreshValidationBackoffForTests(): void {
|
||||
validationBackoff.clear()
|
||||
counters.recorded = 0
|
||||
counters.skipped = 0
|
||||
counters.expired = 0
|
||||
}
|
||||
|
||||
export function getPRRefreshValidationBackoffCountForTests(): number {
|
||||
return validationBackoff.size
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { acquireMock, ghExecFileAsyncMock, releaseMock, runRestMock } = vi.hoisted(() => ({
|
||||
acquireMock: vi.fn(),
|
||||
ghExecFileAsyncMock: vi.fn(),
|
||||
releaseMock: vi.fn(),
|
||||
runRestMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./internals', () => ({
|
||||
acquire: acquireMock,
|
||||
release: releaseMock,
|
||||
extractExecError: (err: unknown) => ({
|
||||
stderr: err instanceof Error ? err.message : String(err),
|
||||
stdout: ''
|
||||
}),
|
||||
ghExecFileAsync: ghExecFileAsyncMock,
|
||||
rateLimitGuard: () => ({ blocked: false }),
|
||||
noteRateLimitSpend: vi.fn(),
|
||||
classifyProjectError: (stderr: string) => ({ type: 'unknown', message: stderr }),
|
||||
rateLimitedError: () => ({ type: 'rate_limited', message: 'rate limited' }),
|
||||
runGraphql: vi.fn(),
|
||||
runRest: runRestMock,
|
||||
validateSlugArgs: (owner: string, repo: string) =>
|
||||
owner && repo ? { ok: true } : { ok: false, error: { type: 'validation_error' } },
|
||||
assertPositiveInt: (value: number, name: string) =>
|
||||
Number.isInteger(value) && value > 0
|
||||
? { ok: true, value }
|
||||
: { ok: false, error: { type: 'validation_error', message: `${name} invalid` } }
|
||||
}))
|
||||
|
||||
import { updateIssueBySlug } from './mutations'
|
||||
|
||||
describe('updateIssueBySlug', () => {
|
||||
beforeEach(() => {
|
||||
acquireMock.mockReset().mockResolvedValue(undefined)
|
||||
ghExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '', stderr: '' })
|
||||
releaseMock.mockReset()
|
||||
runRestMock.mockReset().mockResolvedValue({ ok: true, data: {} })
|
||||
})
|
||||
|
||||
it('closes slug-addressed issues with completed and not planned reasons via gh issue close', async () => {
|
||||
await expect(
|
||||
updateIssueBySlug({
|
||||
owner: 'acme',
|
||||
repo: 'widgets',
|
||||
number: 12,
|
||||
updates: { state: 'closed', stateReason: 'completed' }
|
||||
})
|
||||
).resolves.toEqual({ ok: true })
|
||||
await expect(
|
||||
updateIssueBySlug({
|
||||
owner: 'acme',
|
||||
repo: 'widgets',
|
||||
number: 13,
|
||||
updates: { state: 'closed', stateReason: 'not_planned' }
|
||||
})
|
||||
).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
['issue', 'close', '12', '--repo', 'acme/widgets', '--reason', 'completed'],
|
||||
{ encoding: 'utf-8' }
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
['issue', 'close', '13', '--repo', 'acme/widgets', '--reason', 'not planned'],
|
||||
{ encoding: 'utf-8' }
|
||||
)
|
||||
expect(runRestMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes slug-addressed duplicate issues with --duplicate-of', async () => {
|
||||
await expect(
|
||||
updateIssueBySlug({
|
||||
owner: 'acme',
|
||||
repo: 'widgets',
|
||||
number: 12,
|
||||
updates: { state: 'closed', stateReason: 'duplicate', duplicateOf: 99 }
|
||||
})
|
||||
).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['issue', 'close', '12', '--repo', 'acme/widgets', '--duplicate-of', '99'],
|
||||
{ encoding: 'utf-8' }
|
||||
)
|
||||
expect(runRestMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects duplicate closes without a target before spawning gh', async () => {
|
||||
const result = await updateIssueBySlug({
|
||||
owner: 'acme',
|
||||
repo: 'widgets',
|
||||
number: 12,
|
||||
updates: { state: 'closed', stateReason: 'duplicate' }
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { type: 'validation_error' }
|
||||
})
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(runRestMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reopens slug-addressed issues via gh issue reopen', async () => {
|
||||
await expect(
|
||||
updateIssueBySlug({
|
||||
owner: 'acme',
|
||||
repo: 'widgets',
|
||||
number: 12,
|
||||
updates: { state: 'open' }
|
||||
})
|
||||
).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['issue', 'reopen', '12', '--repo', 'acme/widgets'],
|
||||
{ encoding: 'utf-8' }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -183,17 +183,63 @@ export async function updateIssueBySlug(
|
||||
removeAssignees
|
||||
} = args.updates
|
||||
|
||||
// Title / body / state go through PATCH /repos/{owner}/{repo}/issues/{n}.
|
||||
if (duplicateOf !== undefined && (state !== 'closed' || stateReason !== 'duplicate')) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
type: 'validation_error',
|
||||
message: 'Duplicate target is only valid when closing as duplicate.'
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state === 'closed' && stateReason === 'duplicate' && duplicateOf === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
type: 'validation_error',
|
||||
message: 'Duplicate target issue number is required.'
|
||||
}
|
||||
}
|
||||
}
|
||||
if (duplicateOf !== undefined) {
|
||||
const duplicate = assertPositiveInt(duplicateOf, 'duplicateOf')
|
||||
if (!duplicate.ok) {
|
||||
return { ok: false, error: duplicate.error }
|
||||
}
|
||||
}
|
||||
|
||||
// Title/body go through PATCH /repos/{owner}/{repo}/issues/{n}.
|
||||
// State uses gh issue close/reopen so duplicate closes can record a target.
|
||||
// Labels/assignees go through their dedicated endpoints.
|
||||
const base = `repos/${args.owner}/${args.repo}/issues/${args.number}`
|
||||
|
||||
if (state !== undefined) {
|
||||
const stateArgs =
|
||||
state === 'closed'
|
||||
? ['issue', 'close', String(args.number), '--repo', `${args.owner}/${args.repo}`]
|
||||
: ['issue', 'reopen', String(args.number), '--repo', `${args.owner}/${args.repo}`]
|
||||
if (state === 'closed') {
|
||||
if (stateReason === 'completed') {
|
||||
stateArgs.push('--reason', 'completed')
|
||||
} else if (stateReason === 'not_planned') {
|
||||
stateArgs.push('--reason', 'not planned')
|
||||
} else if (stateReason === 'duplicate') {
|
||||
stateArgs.push('--duplicate-of', String(duplicateOf))
|
||||
}
|
||||
}
|
||||
await acquire()
|
||||
try {
|
||||
await ghExecFileAsync(stateArgs, { encoding: 'utf-8' })
|
||||
} catch (err) {
|
||||
const { stderr, stdout } = extractExecError(err)
|
||||
return { ok: false, error: classifyProjectError(stderr, stdout) }
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
// 1) PATCH body
|
||||
if (
|
||||
title !== undefined ||
|
||||
body !== undefined ||
|
||||
state !== undefined ||
|
||||
stateReason !== undefined
|
||||
) {
|
||||
if (title !== undefined || body !== undefined) {
|
||||
const patchArgs: string[] = ['-X', 'PATCH', base]
|
||||
if (title !== undefined) {
|
||||
patchArgs.push('--raw-field', `title=${title}`)
|
||||
@@ -201,15 +247,6 @@ export async function updateIssueBySlug(
|
||||
if (body !== undefined) {
|
||||
patchArgs.push('--raw-field', `body=${body}`)
|
||||
}
|
||||
if (state !== undefined) {
|
||||
patchArgs.push('--raw-field', `state=${state}`)
|
||||
}
|
||||
if (stateReason !== undefined) {
|
||||
patchArgs.push('--raw-field', `state_reason=${stateReason}`)
|
||||
}
|
||||
if (duplicateOf !== undefined) {
|
||||
patchArgs.push('--raw-field', `duplicate_of=${duplicateOf}`)
|
||||
}
|
||||
const r = await runRest<unknown>(patchArgs)
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path'
|
||||
import { getPreflightWslTarget, type PreflightRuntimeContext } from './preflight-runtime-target'
|
||||
|
||||
export async function hydrateShellPathForAgentDetection(
|
||||
context?: PreflightRuntimeContext
|
||||
): Promise<void> {
|
||||
if (getPreflightWslTarget(context)) {
|
||||
return
|
||||
}
|
||||
// Why: remote runtime servers may inherit a sparse daemon/SSH PATH even
|
||||
// though the user's shell can run the agents.
|
||||
const hydration = await hydrateShellPath()
|
||||
if (hydration.ok) {
|
||||
mergePathSegments(hydration.segments)
|
||||
}
|
||||
}
|
||||
@@ -885,6 +885,36 @@ describe('registerFilesystemHandlers', () => {
|
||||
expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', { includeIgnored: true })
|
||||
})
|
||||
|
||||
it('forwards upstream-negative-cache bypass through local and SSH git status IPC', async () => {
|
||||
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH])
|
||||
getStatusMock.mockResolvedValue({ entries: [], conflictOperation: 'unknown' })
|
||||
const sshProvider = {
|
||||
getStatus: vi.fn().mockResolvedValue({ entries: [], conflictOperation: 'unknown' })
|
||||
}
|
||||
getSshGitProviderMock.mockReturnValue(sshProvider)
|
||||
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
await handlers.get('git:status')!(null, {
|
||||
worktreePath: WORKTREE_FEATURE_PATH,
|
||||
bypassEffectiveUpstreamNegativeCache: true
|
||||
})
|
||||
await handlers.get('git:status')!(null, {
|
||||
worktreePath: '/remote/repo',
|
||||
connectionId: 'ssh-1',
|
||||
bypassEffectiveUpstreamNegativeCache: true
|
||||
})
|
||||
|
||||
expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, {
|
||||
includeIgnored: false,
|
||||
bypassEffectiveUpstreamNegativeCache: true
|
||||
})
|
||||
expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', {
|
||||
includeIgnored: false,
|
||||
bypassEffectiveUpstreamNegativeCache: true
|
||||
})
|
||||
})
|
||||
|
||||
it('checks ignored paths through local and SSH git providers', async () => {
|
||||
registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH])
|
||||
checkIgnoredPathsMock.mockResolvedValue(['dist/bundle.js'])
|
||||
|
||||
@@ -829,9 +829,19 @@ export function registerFilesystemHandlers(
|
||||
'git:status',
|
||||
async (
|
||||
_event,
|
||||
args: { worktreePath: string; connectionId?: string; includeIgnored?: boolean }
|
||||
args: {
|
||||
worktreePath: string
|
||||
connectionId?: string
|
||||
includeIgnored?: boolean
|
||||
bypassEffectiveUpstreamNegativeCache?: boolean
|
||||
}
|
||||
): Promise<GitStatusResult> => {
|
||||
const options = { includeIgnored: args.includeIgnored ?? false }
|
||||
const options = {
|
||||
includeIgnored: args.includeIgnored ?? false,
|
||||
...(args.bypassEffectiveUpstreamNegativeCache === true
|
||||
? { bypassEffectiveUpstreamNegativeCache: true }
|
||||
: {})
|
||||
}
|
||||
if (args.connectionId) {
|
||||
const provider = getSshGitProvider(args.connectionId)
|
||||
if (!provider) {
|
||||
|
||||
@@ -158,6 +158,7 @@ vi.mock('../telemetry/cohort-classifier', () => ({
|
||||
}))
|
||||
|
||||
import { registerGitHubHandlers } from './github'
|
||||
import { clearPRRefreshValidationBackoffForTests } from '../github/pr-refresh-validation-backoff'
|
||||
|
||||
type HandlerMap = Record<string, (_event: unknown, args: unknown) => unknown>
|
||||
|
||||
@@ -237,6 +238,7 @@ describe('registerGitHubHandlers', () => {
|
||||
refreshPRNowMock.mockReset()
|
||||
reportVisiblePRRefreshCandidatesMock.mockReset()
|
||||
setPRRefreshOutcomeObserverMock.mockReset()
|
||||
clearPRRefreshValidationBackoffForTests()
|
||||
for (const key of Object.keys(handlers)) {
|
||||
delete handlers[key]
|
||||
}
|
||||
@@ -293,6 +295,156 @@ describe('registerGitHubHandlers', () => {
|
||||
expect(getIssueMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns typed automatic PR refresh validation skips without enqueueing', async () => {
|
||||
registerGitHubHandlers(store as never, stats as never)
|
||||
const candidate = {
|
||||
cacheKey: 'missing::feature/test',
|
||||
repoPath: '/workspace/missing',
|
||||
repoId: 'missing-repo',
|
||||
branch: 'feature/test',
|
||||
repoKind: 'git' as const
|
||||
}
|
||||
|
||||
const first = await handlers['gh:enqueuePRRefresh'](null, {
|
||||
candidate,
|
||||
reason: 'active',
|
||||
priority: 80
|
||||
})
|
||||
const second = await handlers['gh:enqueuePRRefresh'](null, {
|
||||
candidate,
|
||||
reason: 'active',
|
||||
priority: 80
|
||||
})
|
||||
|
||||
expect(first).toEqual({ kind: 'skipped', skippedReason: 'validation-denied' })
|
||||
expect(second).toEqual({ kind: 'skipped', skippedReason: 'validation-backoff' })
|
||||
expect(first).not.toBe(false)
|
||||
expect(second).not.toBe(false)
|
||||
expect(enqueuePRRefreshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses registered repo routing fields for automatic PR refresh candidates', async () => {
|
||||
repos = [
|
||||
{
|
||||
id: 'repo-ssh',
|
||||
path: '/workspace/remote-repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'ssh-real',
|
||||
executionHostId: 'ssh:ssh-real'
|
||||
}
|
||||
]
|
||||
registerGitHubHandlers(store as never, stats as never)
|
||||
|
||||
await handlers['gh:enqueuePRRefresh'](null, {
|
||||
candidate: {
|
||||
cacheKey: 'remote::feature/test',
|
||||
repoPath: '/workspace/remote-repo',
|
||||
repoId: 'repo-ssh',
|
||||
branch: 'feature/test',
|
||||
repoKind: 'git',
|
||||
connectionId: 'ssh-stale',
|
||||
executionHostId: 'runtime:stale',
|
||||
connectionState: 'disconnected',
|
||||
localGitOptions: { wslDistro: 'Stale' }
|
||||
},
|
||||
reason: 'active',
|
||||
priority: 80
|
||||
})
|
||||
|
||||
const candidate = enqueuePRRefreshMock.mock.calls[0]?.[0]
|
||||
expect(candidate).toEqual(
|
||||
expect.objectContaining({
|
||||
repoPath: '/workspace/remote-repo',
|
||||
repoId: 'repo-ssh',
|
||||
connectionId: 'ssh-real',
|
||||
executionHostId: 'ssh:ssh-real',
|
||||
connectionState: 'connected'
|
||||
})
|
||||
)
|
||||
expect(candidate).not.toHaveProperty('localGitOptions')
|
||||
})
|
||||
|
||||
it('keeps manual PR refresh validation strict', async () => {
|
||||
registerGitHubHandlers(store as never, stats as never)
|
||||
|
||||
await expect(
|
||||
handlers['gh:refreshPRNow'](null, {
|
||||
candidate: {
|
||||
cacheKey: 'missing::feature/test',
|
||||
repoPath: '/workspace/missing',
|
||||
repoId: 'missing-repo',
|
||||
branch: 'feature/test',
|
||||
repoKind: 'git'
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('Access denied: unknown repository path')
|
||||
|
||||
expect(refreshPRNowMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('filters invalid visible PR refresh candidates while keeping valid candidates', async () => {
|
||||
registerGitHubHandlers(store as never, stats as never)
|
||||
|
||||
await handlers['gh:reportVisiblePRRefreshCandidates'](
|
||||
{ sender: { id: 7, once: vi.fn() } },
|
||||
{
|
||||
generation: 1,
|
||||
candidates: [
|
||||
{
|
||||
cacheKey: 'valid::feature/test',
|
||||
repoPath: '/workspace/repo',
|
||||
repoId: 'repo-1',
|
||||
branch: 'feature/test',
|
||||
repoKind: 'git'
|
||||
},
|
||||
{
|
||||
cacheKey: 'missing::feature/old',
|
||||
repoPath: '/workspace/missing',
|
||||
repoId: 'missing-repo',
|
||||
branch: 'feature/old',
|
||||
repoKind: 'git'
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
expect(reportVisiblePRRefreshCandidatesMock).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
cacheKey: 'valid::feature/test',
|
||||
repoPath: '/workspace/repo',
|
||||
repoId: 'repo-1'
|
||||
})
|
||||
],
|
||||
1,
|
||||
7
|
||||
)
|
||||
})
|
||||
|
||||
it('clears a sender visible PR refresh set when all current candidates are invalid', async () => {
|
||||
registerGitHubHandlers(store as never, stats as never)
|
||||
|
||||
await handlers['gh:reportVisiblePRRefreshCandidates'](
|
||||
{ sender: { id: 8, once: vi.fn() } },
|
||||
{
|
||||
generation: 2,
|
||||
candidates: [
|
||||
{
|
||||
cacheKey: 'missing::feature/old',
|
||||
repoPath: '/workspace/missing',
|
||||
repoId: 'missing-repo',
|
||||
branch: 'feature/old',
|
||||
repoKind: 'git'
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
expect(reportVisiblePRRefreshCandidatesMock).toHaveBeenCalledWith([], 2, 8)
|
||||
})
|
||||
|
||||
it('rejects GitHub source context from a different host', async () => {
|
||||
registerGitHubHandlers(store as never, stats as never)
|
||||
|
||||
|
||||
+99
-45
@@ -11,6 +11,7 @@ import type {
|
||||
GitHubOwnerRepo,
|
||||
GitHubPullRequestStateUpdate,
|
||||
GitHubPRRefreshCandidate,
|
||||
GitHubPRRefreshEnqueueResult,
|
||||
GitHubPRRefreshReason,
|
||||
PRRefreshOutcome
|
||||
} from '../../shared/types'
|
||||
@@ -62,6 +63,10 @@ import {
|
||||
import { getWorkItemDetails, getPRFileContents } from '../github/work-item-details'
|
||||
import { getRateLimit } from '../github/rate-limit'
|
||||
import { diagnoseGhAuth } from '../github/auth-diagnose'
|
||||
import {
|
||||
notePRRefreshValidationDenial,
|
||||
type PRRefreshValidationDenialReason
|
||||
} from '../github/pr-refresh-validation-backoff'
|
||||
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
|
||||
import type { GitHubPRFile } from '../../shared/types'
|
||||
import { dispatchWorkItem, type WorkItemArgs } from './github-work-item-args'
|
||||
@@ -140,27 +145,53 @@ type RepoScopedArgs = {
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}
|
||||
|
||||
function assertRegisteredRepo(args: string | RepoScopedArgs, store: Store): Repo {
|
||||
type RegisteredRepoValidationResult =
|
||||
| { kind: 'ok'; repo: Repo }
|
||||
| { kind: 'denied'; reason: PRRefreshValidationDenialReason; message: string }
|
||||
|
||||
function validateRegisteredRepo(
|
||||
args: string | RepoScopedArgs,
|
||||
store: Store,
|
||||
repos = store.getRepos()
|
||||
): RegisteredRepoValidationResult {
|
||||
const repoPath = typeof args === 'string' ? args : args.repoPath
|
||||
const repoId = typeof args === 'string' ? undefined : args.repoId
|
||||
const resolvedRepoPath = resolve(repoPath)
|
||||
const repo = store
|
||||
.getRepos()
|
||||
.find((r) => (repoId ? r.id === repoId : resolve(r.path) === resolvedRepoPath))
|
||||
const repo = repos.find((r) => (repoId ? r.id === repoId : resolve(r.path) === resolvedRepoPath))
|
||||
if (!repo) {
|
||||
throw new Error('Access denied: unknown repository path')
|
||||
return {
|
||||
kind: 'denied',
|
||||
reason: 'unknown-repo',
|
||||
message: 'Access denied: unknown repository path'
|
||||
}
|
||||
}
|
||||
if (repoId && resolve(repo.path) !== resolvedRepoPath) {
|
||||
throw new Error('Access denied: repository path does not match repo id')
|
||||
return {
|
||||
kind: 'denied',
|
||||
reason: 'repo-path-mismatch',
|
||||
message: 'Access denied: repository path does not match repo id'
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof args !== 'string' &&
|
||||
args.sourceContext?.provider === 'github' &&
|
||||
args.sourceContext.hostId !== getRepoExecutionHostId(repo)
|
||||
) {
|
||||
throw new Error('Access denied: GitHub source host does not match repository host')
|
||||
return {
|
||||
kind: 'denied',
|
||||
reason: 'host-mismatch',
|
||||
message: 'Access denied: GitHub source host does not match repository host'
|
||||
}
|
||||
}
|
||||
return repo
|
||||
return { kind: 'ok', repo }
|
||||
}
|
||||
|
||||
function assertRegisteredRepo(args: string | RepoScopedArgs, store: Store): Repo {
|
||||
const result = validateRegisteredRepo(args, store)
|
||||
if (result.kind === 'denied') {
|
||||
throw new Error(result.message)
|
||||
}
|
||||
return result.repo
|
||||
}
|
||||
|
||||
function repoConnectionId(repo: Repo): string | null {
|
||||
@@ -172,6 +203,50 @@ function localGitOptionArgs(store: Store, repo: Repo): [] | [{ wslDistro?: strin
|
||||
return Object.keys(localGitOptions).length > 0 ? [localGitOptions] : []
|
||||
}
|
||||
|
||||
function applyRepoToPRRefreshCandidate(
|
||||
store: Store,
|
||||
repo: Repo,
|
||||
candidate: GitHubPRRefreshCandidate
|
||||
): GitHubPRRefreshCandidate {
|
||||
const localGitOptions = localGitOptionArgs(store, repo)[0]
|
||||
const appliedCandidate = { ...candidate }
|
||||
delete appliedCandidate.localGitOptions
|
||||
delete appliedCandidate.connectionId
|
||||
delete appliedCandidate.executionHostId
|
||||
delete appliedCandidate.connectionState
|
||||
return {
|
||||
...appliedCandidate,
|
||||
repoPath: repo.path,
|
||||
repoId: repo.id,
|
||||
...(localGitOptions ? { localGitOptions } : {}),
|
||||
connectionId: repoConnectionId(repo),
|
||||
executionHostId: repo.executionHostId ?? null,
|
||||
connectionState: repo.connectionId ? 'connected' : 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
function validateAutomaticPRRefreshCandidate(
|
||||
candidate: GitHubPRRefreshCandidate,
|
||||
store: Store,
|
||||
repos = store.getRepos()
|
||||
):
|
||||
| { kind: 'ok'; candidate: GitHubPRRefreshCandidate }
|
||||
| {
|
||||
kind: 'skipped'
|
||||
result: Extract<GitHubPRRefreshEnqueueResult, { kind: 'skipped' }>
|
||||
} {
|
||||
const result = validateRegisteredRepo(candidate, store, repos)
|
||||
if (result.kind === 'denied') {
|
||||
const skippedReason = notePRRefreshValidationDenial({
|
||||
repoId: candidate.repoId,
|
||||
repoPath: candidate.repoPath,
|
||||
reason: result.reason
|
||||
})
|
||||
return { kind: 'skipped', result: { kind: 'skipped', skippedReason } }
|
||||
}
|
||||
return { kind: 'ok', candidate: applyRepoToPRRefreshCandidate(store, result.repo, candidate) }
|
||||
}
|
||||
|
||||
export function registerGitHubHandlers(store: Store, stats: StatsCollector): void {
|
||||
function recordPRIfNeeded(repo: Repo, outcome: PRRefreshOutcome): void {
|
||||
if (outcome.kind === 'found' && !stats.hasCountedPR(outcome.pr.url)) {
|
||||
@@ -246,16 +321,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
||||
ipcMain.handle(
|
||||
'gh:refreshPRNow',
|
||||
async (_event, args: { candidate: GitHubPRRefreshCandidate }) => {
|
||||
const repo = assertRegisteredRepo(args.candidate.repoPath, store)
|
||||
const localGitOptions = localGitOptionArgs(store, repo)[0]
|
||||
const outcome = await refreshPRNow({
|
||||
...args.candidate,
|
||||
repoPath: repo.path,
|
||||
repoId: repo.id,
|
||||
...(localGitOptions ? { localGitOptions } : {}),
|
||||
connectionId: repo.connectionId ?? args.candidate.connectionId,
|
||||
connectionState: repo.connectionId ? 'connected' : args.candidate.connectionState
|
||||
})
|
||||
const repo = assertRegisteredRepo(args.candidate, store)
|
||||
const outcome = await refreshPRNow(applyRepoToPRRefreshCandidate(store, repo, args.candidate))
|
||||
recordPRIfNeeded(repo, outcome)
|
||||
return outcome
|
||||
}
|
||||
@@ -270,22 +337,13 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
||||
reason: GitHubPRRefreshReason
|
||||
priority?: number
|
||||
}
|
||||
) => {
|
||||
const repo = assertRegisteredRepo(args.candidate.repoPath, store)
|
||||
const localGitOptions = localGitOptionArgs(store, repo)[0]
|
||||
enqueuePRRefresh(
|
||||
{
|
||||
...args.candidate,
|
||||
repoPath: repo.path,
|
||||
repoId: repo.id,
|
||||
...(localGitOptions ? { localGitOptions } : {}),
|
||||
connectionId: repo.connectionId ?? args.candidate.connectionId,
|
||||
connectionState: repo.connectionId ? 'connected' : args.candidate.connectionState
|
||||
},
|
||||
args.reason,
|
||||
args.priority ?? 0
|
||||
)
|
||||
return true
|
||||
): GitHubPRRefreshEnqueueResult => {
|
||||
const validation = validateAutomaticPRRefreshCandidate(args.candidate, store)
|
||||
if (validation.kind === 'skipped') {
|
||||
return validation.result
|
||||
}
|
||||
enqueuePRRefresh(validation.candidate, args.reason, args.priority ?? 0)
|
||||
return { kind: 'queued' }
|
||||
}
|
||||
)
|
||||
|
||||
@@ -300,18 +358,14 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
|
||||
clearVisiblePRRefreshWindow(senderId)
|
||||
})
|
||||
}
|
||||
const candidates = args.candidates.map((candidate) => {
|
||||
const repo = assertRegisteredRepo(candidate.repoPath, store)
|
||||
const localGitOptions = localGitOptionArgs(store, repo)[0]
|
||||
return {
|
||||
...candidate,
|
||||
repoPath: repo.path,
|
||||
repoId: repo.id,
|
||||
...(localGitOptions ? { localGitOptions } : {}),
|
||||
connectionId: repo.connectionId ?? candidate.connectionId,
|
||||
connectionState: repo.connectionId ? 'connected' : candidate.connectionState
|
||||
const candidates: GitHubPRRefreshCandidate[] = []
|
||||
const repos = store.getRepos()
|
||||
for (const candidate of args.candidates) {
|
||||
const validation = validateAutomaticPRRefreshCandidate(candidate, store, repos)
|
||||
if (validation.kind === 'ok') {
|
||||
candidates.push(validation.candidate)
|
||||
}
|
||||
})
|
||||
}
|
||||
reportVisiblePRRefreshCandidates(candidates, args.generation, senderId)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ vi.mock('../gitea/client', () => ({
|
||||
import {
|
||||
_resetPreflightCache,
|
||||
detectInstalledAgents,
|
||||
detectInstalledAgentsWithShellPathHydration,
|
||||
registerPreflightHandlers,
|
||||
runPreflightCheck
|
||||
} from './preflight'
|
||||
@@ -105,6 +106,7 @@ describe('preflight', () => {
|
||||
handleMock.mockReset()
|
||||
execFileAsyncMock.mockReset()
|
||||
hydrateShellPathMock.mockReset()
|
||||
hydrateShellPathMock.mockResolvedValue({ segments: [], ok: false, failureReason: 'no_shell' })
|
||||
mergePathSegmentsMock.mockReset()
|
||||
getActiveMultiplexerMock.mockReset()
|
||||
getBitbucketAuthStatusMock.mockReset()
|
||||
@@ -575,6 +577,63 @@ describe('preflight', () => {
|
||||
await expect(handlers['preflight:detectAgents']()).resolves.toEqual(['openclaude', 'cursor'])
|
||||
})
|
||||
|
||||
it('hydrates shell PATH before user-facing agent detection', async () => {
|
||||
const originalPath = process.env.PATH
|
||||
process.env.PATH = '/usr/bin'
|
||||
hydrateShellPathMock.mockResolvedValueOnce({
|
||||
segments: ['/home/test/.local/bin'],
|
||||
ok: true,
|
||||
failureReason: 'none'
|
||||
})
|
||||
mergePathSegmentsMock.mockImplementationOnce((segments: string[]) => {
|
||||
process.env.PATH = [...segments, '/usr/bin'].join(':')
|
||||
return segments
|
||||
})
|
||||
execFileAsyncMock.mockImplementation(async (command, args) => {
|
||||
if (command !== 'which') {
|
||||
throw new Error(`unexpected command ${String(command)}`)
|
||||
}
|
||||
if (String(args[0]) === 'codex' && process.env.PATH?.startsWith('/home/test/.local/bin')) {
|
||||
return { stdout: '/home/test/.local/bin/codex\n' }
|
||||
}
|
||||
throw new Error('not found')
|
||||
})
|
||||
|
||||
try {
|
||||
await expect(detectInstalledAgentsWithShellPathHydration()).resolves.toEqual(['codex'])
|
||||
} finally {
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH
|
||||
} else {
|
||||
process.env.PATH = originalPath
|
||||
}
|
||||
}
|
||||
expect(hydrateShellPathMock).toHaveBeenCalledWith()
|
||||
expect(mergePathSegmentsMock).toHaveBeenCalledWith(['/home/test/.local/bin'])
|
||||
})
|
||||
|
||||
it('does not run host shell hydration for WSL agent detection', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
execFileAsyncMock.mockImplementation(async (command, args) => {
|
||||
if (command !== 'wsl.exe') {
|
||||
throw new Error(`unexpected command ${String(command)}`)
|
||||
}
|
||||
const script = String(args[5])
|
||||
if (script.includes("'claude'")) {
|
||||
return { stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n' }
|
||||
}
|
||||
throw new Error('not found')
|
||||
})
|
||||
|
||||
await expect(
|
||||
detectInstalledAgentsWithShellPathHydration({ wslDistro: 'Ubuntu' })
|
||||
).resolves.toEqual(['claude'])
|
||||
expect(hydrateShellPathMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('detects Mistral Vibe from the installed vibe executable', async () => {
|
||||
execFileAsyncMock.mockImplementation(async (command, args) => {
|
||||
if (command !== 'which') {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { runPreflightCommandInWsl } from './preflight-wsl-command'
|
||||
import { detectCommandsInInstallDirs } from './local-agent-install-dir-detection'
|
||||
import { buildLocalPreflightEnv } from './preflight-local-env'
|
||||
import { getPreflightWslTarget, type PreflightRuntimeContext } from './preflight-runtime-target'
|
||||
import { hydrateShellPathForAgentDetection } from './agent-detection-shell-path'
|
||||
const execFileAsync = promisify(execFile)
|
||||
const PREFLIGHT_COMMAND_TIMEOUT_MS = 5000
|
||||
|
||||
@@ -195,6 +196,13 @@ export async function detectInstalledAgents(context?: PreflightRuntimeContext):
|
||||
return uniqueAgentIds(checks.filter((c) => c.installed).map((c) => c.id))
|
||||
}
|
||||
|
||||
export async function detectInstalledAgentsWithShellPathHydration(
|
||||
context?: PreflightRuntimeContext
|
||||
): Promise<string[]> {
|
||||
await hydrateShellPathForAgentDetection(context)
|
||||
return detectInstalledAgents(context)
|
||||
}
|
||||
|
||||
export type RefreshAgentsResult = {
|
||||
/** Agents detected after hydrating PATH from the user's login shell. */
|
||||
agents: string[]
|
||||
@@ -352,11 +360,8 @@ export function registerPreflightHandlers(): void {
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'preflight:detectAgents',
|
||||
async (_event, args?: PreflightRuntimeContext): Promise<string[]> => {
|
||||
return detectInstalledAgents(args)
|
||||
}
|
||||
ipcMain.handle('preflight:detectAgents', async (_event, args?: PreflightRuntimeContext) =>
|
||||
detectInstalledAgentsWithShellPathHydration(args)
|
||||
)
|
||||
|
||||
ipcMain.handle('preflight:refreshAgents', async (_event, args?: PreflightRuntimeContext) => {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
const worktreeChangeInvalidators = new Set<(repoId: string) => void>()
|
||||
|
||||
export function registerWorktreeChangeInvalidator(
|
||||
invalidator: (repoId: string) => void
|
||||
): () => void {
|
||||
worktreeChangeInvalidators.add(invalidator)
|
||||
return () => {
|
||||
worktreeChangeInvalidators.delete(invalidator)
|
||||
}
|
||||
}
|
||||
|
||||
export function runWorktreeChangeInvalidators(repoId: string): void {
|
||||
const invalidators = Array.from(worktreeChangeInvalidators)
|
||||
for (const invalidator of invalidators) {
|
||||
try {
|
||||
invalidator(repoId)
|
||||
} catch (error) {
|
||||
console.warn('[worktrees] worktree change invalidator failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function __resetWorktreeChangeInvalidatorsForTests(): void {
|
||||
worktreeChangeInvalidators.clear()
|
||||
}
|
||||
@@ -57,6 +57,7 @@ import type { SshGitProvider } from '../providers/ssh-git-provider'
|
||||
import { TUI_AGENT_CONFIG, isTuiAgent } from '../../shared/tui-agent-config'
|
||||
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
|
||||
import { getSshGitUsername } from '../git/git-username'
|
||||
import { runWorktreeChangeInvalidators } from './worktree-change-invalidators'
|
||||
|
||||
type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & {
|
||||
automationProvenance?: AutomationWorkspaceProvenance
|
||||
@@ -1328,6 +1329,9 @@ async function getRemoteLocalBaseRefUpdateSuggestionForWorktreeCreate(
|
||||
}
|
||||
|
||||
export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string): void {
|
||||
// Why: invalidate detected-worktree caches before renderer observers react,
|
||||
// so follow-up listDetected reads post-change state.
|
||||
runWorktreeChangeInvalidators(repoId)
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('worktrees:changed', { repoId })
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { lstat, mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join, resolve } from 'path'
|
||||
import type { CreateWorktreeResult } from '../../shared/types'
|
||||
import type { CreateWorktreeResult, GitWorktreeInfo, Worktree } from '../../shared/types'
|
||||
|
||||
const ORIGINAL_PLATFORM = process.platform
|
||||
|
||||
@@ -226,9 +226,12 @@ vi.mock('./pty', () => ({
|
||||
getLocalPtyProvider: getLocalPtyProviderMock
|
||||
}))
|
||||
|
||||
import { __resetSshWorktreeCreateFetchCacheForTests } from './worktree-remote'
|
||||
import {
|
||||
__resetSshWorktreeCreateFetchCacheForTests,
|
||||
notifyWorktreesChanged
|
||||
} from './worktree-remote'
|
||||
import { invalidateAuthorizedRootsCache, resolveRegisteredWorktreePath } from './filesystem-auth'
|
||||
import { registerWorktreeHandlers } from './worktrees'
|
||||
import { __resetDetectedWorktreeScanCacheForTests, registerWorktreeHandlers } from './worktrees'
|
||||
|
||||
type HandlerMap = Record<string, (_event: unknown, args: unknown) => unknown>
|
||||
|
||||
@@ -272,6 +275,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
beforeEach(() => {
|
||||
setPlatform(ORIGINAL_PLATFORM)
|
||||
__resetSshWorktreeCreateFetchCacheForTests()
|
||||
__resetDetectedWorktreeScanCacheForTests()
|
||||
invalidateAuthorizedRootsCache()
|
||||
for (const m of [
|
||||
handleMock,
|
||||
@@ -2033,6 +2037,313 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reuses a recent authoritative detected worktree scan', async () => {
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main-head',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
])
|
||||
|
||||
const first = await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||||
const second = await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||||
|
||||
expect(first).toEqual(second)
|
||||
expect(listWorktreesMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('coalesces concurrent authoritative detected worktree scans', async () => {
|
||||
listWorktreesMock.mockImplementation(async () => {
|
||||
await Promise.resolve()
|
||||
return [
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main-head',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }),
|
||||
handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }),
|
||||
handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||||
])
|
||||
|
||||
expect(listWorktreesMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rechecks detected worktree metadata while reusing a cached raw scan', async () => {
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main-head',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
])
|
||||
|
||||
let currentMeta = makeWorktreeMeta({ isPinned: false })
|
||||
store.getWorktreeMeta.mockImplementation(() => currentMeta)
|
||||
store.setWorktreeMeta.mockImplementation(() => currentMeta)
|
||||
const first = (await handlers['worktrees:listDetected'](null, {
|
||||
repoId: 'repo-1'
|
||||
})) as { worktrees: Worktree[] }
|
||||
currentMeta = makeWorktreeMeta({ isPinned: true })
|
||||
const second = (await handlers['worktrees:listDetected'](null, {
|
||||
repoId: 'repo-1'
|
||||
})) as { worktrees: Worktree[] }
|
||||
|
||||
expect(first.worktrees[0].isPinned).toBe(false)
|
||||
expect(second.worktrees[0].isPinned).toBe(true)
|
||||
expect(listWorktreesMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rescans detected worktrees after the scan cache TTL expires', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
listWorktreesMock
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main-head',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main-head',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
},
|
||||
{
|
||||
path: '/workspace/new-worktree',
|
||||
head: 'feature-head',
|
||||
branch: 'refs/heads/feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||||
await vi.advanceTimersByTimeAsync(5_001)
|
||||
const second = (await handlers['worktrees:listDetected'](null, {
|
||||
repoId: 'repo-1'
|
||||
})) as { worktrees: Worktree[] }
|
||||
|
||||
expect(second.worktrees.map((worktree) => worktree.path)).toEqual([
|
||||
'/workspace/repo',
|
||||
'/workspace/new-worktree'
|
||||
])
|
||||
expect(listWorktreesMock).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('starts the detected scan cache TTL after a slow scan completes', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
listWorktreesMock
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main-head',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
]),
|
||||
6_000
|
||||
)
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main-head',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
},
|
||||
{
|
||||
path: '/workspace/new-worktree',
|
||||
head: 'feature-head',
|
||||
branch: 'refs/heads/feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
const first = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||||
await vi.advanceTimersByTimeAsync(6_000)
|
||||
await first
|
||||
const second = (await handlers['worktrees:listDetected'](null, {
|
||||
repoId: 'repo-1'
|
||||
})) as { worktrees: Worktree[] }
|
||||
|
||||
expect(second.worktrees.map((worktree) => worktree.path)).toEqual(['/workspace/repo'])
|
||||
expect(listWorktreesMock).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('invalidates the detected scan cache before worktree change notifications', async () => {
|
||||
listWorktreesMock
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main-head',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main-head',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
},
|
||||
{
|
||||
path: '/workspace/new-worktree',
|
||||
head: 'feature-head',
|
||||
branch: 'refs/heads/feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||||
notifyWorktreesChanged(mainWindow as never, 'repo-1')
|
||||
const second = (await handlers['worktrees:listDetected'](null, {
|
||||
repoId: 'repo-1'
|
||||
})) as { worktrees: Worktree[] }
|
||||
|
||||
expect(second.worktrees).toHaveLength(2)
|
||||
expect(listWorktreesMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('rescans detected worktrees after the local create flow notifies worktree changes', async () => {
|
||||
listWorktreesMock
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main-head',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main-head',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
},
|
||||
{
|
||||
path: '/workspace/improve-dashboard',
|
||||
head: 'feature-head',
|
||||
branch: 'refs/heads/improve-dashboard',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main-head',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
},
|
||||
{
|
||||
path: '/workspace/improve-dashboard',
|
||||
head: 'feature-head',
|
||||
branch: 'refs/heads/improve-dashboard',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||||
await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'improve-dashboard'
|
||||
})
|
||||
const detected = (await handlers['worktrees:listDetected'](null, {
|
||||
repoId: 'repo-1'
|
||||
})) as { worktrees: Worktree[] }
|
||||
|
||||
expect(detected.worktrees.map((worktree) => worktree.path)).toEqual([
|
||||
'/workspace/repo',
|
||||
'/workspace/improve-dashboard'
|
||||
])
|
||||
expect(listWorktreesMock).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('does not run fresh-scan side effects from a detected scan invalidated while in flight', async () => {
|
||||
let resolveScan: (worktrees: GitWorktreeInfo[]) => void = () => {}
|
||||
listWorktreesMock.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveScan = resolve as (worktrees: GitWorktreeInfo[]) => void
|
||||
})
|
||||
)
|
||||
store.getAllWorktreeLineage.mockReturnValue({
|
||||
'repo-1::/workspace/new-worktree': {
|
||||
worktreeId: 'repo-1::/workspace/new-worktree',
|
||||
worktreeInstanceId: 'child-instance',
|
||||
parentWorktreeId: 'repo-1::/workspace/repo',
|
||||
parentWorktreeInstanceId: 'parent-instance',
|
||||
origin: 'manual',
|
||||
capture: {
|
||||
source: 'manual-action',
|
||||
confidence: 'explicit'
|
||||
},
|
||||
createdAt: 0
|
||||
}
|
||||
})
|
||||
|
||||
const pendingList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' })
|
||||
await Promise.resolve()
|
||||
notifyWorktreesChanged(mainWindow as never, 'repo-1')
|
||||
resolveScan([
|
||||
{
|
||||
path: '/workspace/repo',
|
||||
head: 'main-head',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
])
|
||||
|
||||
await pendingList
|
||||
|
||||
expect(store.removeWorktreeLineage).not.toHaveBeenCalled()
|
||||
expect(listWorktreesMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('fetches the same-repo PR head via the SSH tracking-ref RPC, not git.exec', async () => {
|
||||
const fetchRemoteTrackingRef = vi.fn(async () => {})
|
||||
const exec = vi.fn(async (args: string[]) => {
|
||||
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
cleanupUnusedWorktreePushTargetRemoteSsh,
|
||||
notifyWorktreesChanged
|
||||
} from './worktree-remote'
|
||||
import { registerWorktreeChangeInvalidator } from './worktree-change-invalidators'
|
||||
import {
|
||||
invalidateAuthorizedRootsCache,
|
||||
isENOENT,
|
||||
@@ -387,6 +388,83 @@ function getPreservedBranchCleanupTarget(
|
||||
const loggedUnavailableSshGitProviders = new Set<string>()
|
||||
const loggedWorktreeListFailures = new Set<string>()
|
||||
const loggedMalformedWorktreeMetaKeys = new Set<string>()
|
||||
// Why: absorb renderer polling bursts while keeping external worktree-change
|
||||
// lag bounded to one short refresh window.
|
||||
const DETECTED_WORKTREE_SCAN_CACHE_TTL_MS = 5_000
|
||||
|
||||
type DetectedWorktreeScanCacheEntry = {
|
||||
expiresAt: number
|
||||
worktrees: GitWorktreeInfo[]
|
||||
}
|
||||
|
||||
type DetectedWorktreeScanResult = {
|
||||
gitWorktrees: GitWorktreeInfo[]
|
||||
fresh: boolean
|
||||
}
|
||||
|
||||
const detectedWorktreeScanCache = new Map<string, DetectedWorktreeScanCacheEntry>()
|
||||
const detectedWorktreeScanInFlight = new Map<string, Promise<GitWorktreeInfo[]>>()
|
||||
const detectedWorktreeScanGenerations = new Map<string, number>()
|
||||
|
||||
function invalidateDetectedWorktreeScanCache(repoId: string): void {
|
||||
detectedWorktreeScanCache.delete(repoId)
|
||||
detectedWorktreeScanInFlight.delete(repoId)
|
||||
detectedWorktreeScanGenerations.set(
|
||||
repoId,
|
||||
(detectedWorktreeScanGenerations.get(repoId) ?? 0) + 1
|
||||
)
|
||||
}
|
||||
|
||||
registerWorktreeChangeInvalidator(invalidateDetectedWorktreeScanCache)
|
||||
|
||||
export function __resetDetectedWorktreeScanCacheForTests(): void {
|
||||
detectedWorktreeScanCache.clear()
|
||||
detectedWorktreeScanInFlight.clear()
|
||||
detectedWorktreeScanGenerations.clear()
|
||||
}
|
||||
|
||||
async function listDetectedGitWorktrees(
|
||||
store: Store,
|
||||
repo: Repo
|
||||
): Promise<DetectedWorktreeScanResult> {
|
||||
if (repo.connectionId || isFolderRepo(repo)) {
|
||||
return {
|
||||
gitWorktrees: await listRepoWorktrees(repo, getLocalProjectWorktreeGitOptions(store, repo)),
|
||||
fresh: true
|
||||
}
|
||||
}
|
||||
|
||||
const cached = detectedWorktreeScanCache.get(repo.id)
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return { gitWorktrees: cached.worktrees, fresh: false }
|
||||
}
|
||||
|
||||
const inFlight = detectedWorktreeScanInFlight.get(repo.id)
|
||||
if (inFlight) {
|
||||
return { gitWorktrees: await inFlight, fresh: false }
|
||||
}
|
||||
|
||||
const scan = listRepoWorktrees(repo, getLocalProjectWorktreeGitOptions(store, repo))
|
||||
const generation = detectedWorktreeScanGenerations.get(repo.id) ?? 0
|
||||
detectedWorktreeScanInFlight.set(repo.id, scan)
|
||||
try {
|
||||
const gitWorktrees = await scan
|
||||
// Why: a create/remove notification can invalidate while the git scan is
|
||||
// still running. Do not let that stale scan repopulate the cache afterward.
|
||||
const isCurrentGeneration = (detectedWorktreeScanGenerations.get(repo.id) ?? 0) === generation
|
||||
if (isCurrentGeneration) {
|
||||
detectedWorktreeScanCache.set(repo.id, {
|
||||
worktrees: gitWorktrees,
|
||||
expiresAt: Date.now() + DETECTED_WORKTREE_SCAN_CACHE_TTL_MS
|
||||
})
|
||||
}
|
||||
return { gitWorktrees, fresh: isCurrentGeneration }
|
||||
} finally {
|
||||
if (detectedWorktreeScanInFlight.get(repo.id) === scan) {
|
||||
detectedWorktreeScanInFlight.delete(repo.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function warnOnce(keySet: Set<string>, key: string, message: string, error?: unknown): void {
|
||||
if (keySet.has(key)) {
|
||||
@@ -946,6 +1024,7 @@ export function registerWorktreeHandlers(
|
||||
|
||||
try {
|
||||
let gitWorktrees: GitWorktreeInfo[]
|
||||
let freshScan = true
|
||||
if (isFolderRepo(repo)) {
|
||||
return {
|
||||
repoId: repo.id,
|
||||
@@ -966,13 +1045,14 @@ export function registerWorktreeHandlers(
|
||||
}
|
||||
gitWorktrees = await provider.listWorktrees(repo.path)
|
||||
} else {
|
||||
gitWorktrees = await listRepoWorktrees(
|
||||
repo,
|
||||
getLocalProjectWorktreeGitOptions(store, repo)
|
||||
)
|
||||
const scan = await listDetectedGitWorktrees(store, repo)
|
||||
gitWorktrees = scan.gitWorktrees
|
||||
freshScan = scan.fresh
|
||||
}
|
||||
if (freshScan) {
|
||||
rememberLocalWorktreeRoots(store, repo, gitWorktrees)
|
||||
pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees)
|
||||
}
|
||||
rememberLocalWorktreeRoots(store, repo, gitWorktrees)
|
||||
pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees)
|
||||
loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`)
|
||||
return {
|
||||
repoId: repo.id,
|
||||
|
||||
@@ -18,10 +18,10 @@ vi.mock('./client', () => ({
|
||||
jiraRequest: (...args: unknown[]) => jiraRequestMock(...args)
|
||||
}))
|
||||
|
||||
function makeEntry(): JiraClientForSite {
|
||||
function makeEntry(id = 'site-1'): JiraClientForSite {
|
||||
return {
|
||||
site: {
|
||||
id: 'site-1',
|
||||
id,
|
||||
siteUrl: 'https://example.atlassian.net',
|
||||
email: 'ada@example.com',
|
||||
displayName: 'Example Jira',
|
||||
@@ -60,6 +60,71 @@ describe('Jira issue operations', () => {
|
||||
).rejects.toThrow(error.message)
|
||||
})
|
||||
|
||||
it('rejects single-site search failures so the UI can surface them', async () => {
|
||||
getClientsMock.mockReturnValue([makeEntry('site-1')])
|
||||
jiraRequestMock.mockRejectedValueOnce(new Error('Forbidden'))
|
||||
const { searchIssues } = await import('./issues')
|
||||
|
||||
await expect(searchIssues('project = ALP', 20, 'site-1')).rejects.toThrow('Forbidden')
|
||||
})
|
||||
|
||||
it('includes Jira status codes in surfaced single-site search failures', async () => {
|
||||
const error = Object.assign(new Error('Forbidden'), { status: 403 })
|
||||
getClientsMock.mockReturnValue([makeEntry('site-1')])
|
||||
jiraRequestMock.mockRejectedValueOnce(error)
|
||||
const { searchIssues } = await import('./issues')
|
||||
|
||||
await expect(searchIssues('project = ALP', 20, 'site-1')).rejects.toThrow(
|
||||
'Error 403: Forbidden'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps healthy sites when one site fails under an "all" search', async () => {
|
||||
getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')])
|
||||
jiraRequestMock.mockRejectedValueOnce(new Error('Forbidden')).mockResolvedValueOnce({
|
||||
issues: [{ id: '1', key: 'BRV-1', fields: { summary: 'Healthy' } }]
|
||||
})
|
||||
const { searchIssues } = await import('./issues')
|
||||
|
||||
await expect(searchIssues('project = ALP', 20, 'all')).resolves.toMatchObject([
|
||||
{ key: 'BRV-1', title: 'Healthy' }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps healthy sites when the saved selection fans out without an explicit site', async () => {
|
||||
getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')])
|
||||
jiraRequestMock.mockRejectedValueOnce(new Error('Forbidden')).mockResolvedValueOnce({
|
||||
issues: [{ id: '1', key: 'BRV-1', fields: { summary: 'Healthy' } }]
|
||||
})
|
||||
const { searchIssues } = await import('./issues')
|
||||
|
||||
await expect(searchIssues('project = ALP', 20)).resolves.toMatchObject([
|
||||
{ key: 'BRV-1', title: 'Healthy' }
|
||||
])
|
||||
})
|
||||
|
||||
it('surfaces an error when every site fails under an "all" search', async () => {
|
||||
getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')])
|
||||
jiraRequestMock
|
||||
.mockRejectedValueOnce(new Error('Forbidden'))
|
||||
.mockRejectedValueOnce(new Error('Service Unavailable'))
|
||||
const { searchIssues } = await import('./issues')
|
||||
|
||||
await expect(searchIssues('project = ALP', 20, 'all')).rejects.toThrow('Forbidden')
|
||||
})
|
||||
|
||||
it('prefers operational failures when every "all" search site fails', async () => {
|
||||
const authError = new Error('Unauthorized')
|
||||
const operationalError = new Error('Service Unavailable')
|
||||
getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')])
|
||||
isAuthErrorMock.mockImplementation((error) => error === authError)
|
||||
jiraRequestMock.mockRejectedValueOnce(authError).mockRejectedValueOnce(operationalError)
|
||||
const { searchIssues } = await import('./issues')
|
||||
|
||||
await expect(searchIssues('project = ALP', 20, 'all')).rejects.toThrow('Service Unavailable')
|
||||
expect(clearTokenMock).toHaveBeenCalledWith('site-1')
|
||||
})
|
||||
|
||||
it('paginates Jira project search results before sorting them', async () => {
|
||||
jiraRequestMock
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
+53
-12
@@ -68,8 +68,38 @@ function clampLimit(limit: number | undefined, fallback = 30): number {
|
||||
return Math.min(Math.max(1, Number.isFinite(limit) ? Number(limit) : fallback), 100)
|
||||
}
|
||||
|
||||
function shouldThrowAuthError(selection: JiraSiteSelection | null | undefined): boolean {
|
||||
return selection !== 'all'
|
||||
type JiraIssueSearchFailure = {
|
||||
error: unknown
|
||||
auth: boolean
|
||||
}
|
||||
|
||||
function getErrorStatus(error: unknown): number | null {
|
||||
if (!error || typeof error !== 'object' || !('status' in error)) {
|
||||
return null
|
||||
}
|
||||
const status = (error as { status?: unknown }).status
|
||||
return typeof status === 'number' && Number.isFinite(status) ? status : null
|
||||
}
|
||||
|
||||
function toIssueSearchFailureError(error: unknown): unknown {
|
||||
const status = getErrorStatus(error)
|
||||
if (
|
||||
status === null ||
|
||||
!(error instanceof Error) ||
|
||||
error.message.startsWith(`Error ${status}:`)
|
||||
) {
|
||||
return error
|
||||
}
|
||||
return new Error(`Error ${status}: ${error.message}`)
|
||||
}
|
||||
|
||||
function shouldSurfaceSiteFailure(
|
||||
selection: JiraSiteSelection | null | undefined,
|
||||
entryCount: number
|
||||
): boolean {
|
||||
// getClients can resolve an omitted selection to the persisted 'all' choice;
|
||||
// multi-entry reads need the same resilient fan-out policy as explicit 'all'.
|
||||
return selection !== 'all' && entryCount <= 1
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): JiraRecord {
|
||||
@@ -345,26 +375,37 @@ export async function searchIssues(
|
||||
return []
|
||||
}
|
||||
const safeLimit = clampLimit(limit)
|
||||
const failures: (JiraIssueSearchFailure | undefined)[] = Array.from({ length: entries.length })
|
||||
const surfaceSiteFailure = shouldSurfaceSiteFailure(siteId, entries.length)
|
||||
const results = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
entries.map(async (entry, index) => {
|
||||
await acquire()
|
||||
try {
|
||||
return await searchIssuesForClient(entry, jql.trim(), safeLimit)
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
const authFailure = isAuthError(error)
|
||||
if (authFailure) {
|
||||
clearToken(entry.site.id)
|
||||
if (shouldThrowAuthError(siteId)) {
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
console.warn('[jira] searchIssues failed:', error)
|
||||
}
|
||||
return []
|
||||
if (surfaceSiteFailure) {
|
||||
throw toIssueSearchFailureError(error)
|
||||
}
|
||||
console.warn('[jira] searchIssues failed:', error)
|
||||
failures[index] = { error: toIssueSearchFailureError(error), auth: authFailure }
|
||||
return [] as JiraIssue[]
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
})
|
||||
)
|
||||
// 'all' fan-out: only surface an error when every connected site failed, so a
|
||||
// partial success (or a genuinely empty result) is not reported as an error.
|
||||
const recordedFailures = failures.filter(
|
||||
(failure): failure is JiraIssueSearchFailure => failure !== undefined
|
||||
)
|
||||
if (recordedFailures.length === entries.length) {
|
||||
throw (recordedFailures.find((failure) => !failure.auth) ?? recordedFailures[0]).error
|
||||
}
|
||||
return entries.length === 1
|
||||
? results.flat().slice(0, safeLimit)
|
||||
: sortAndLimitIssues(results.flat(), safeLimit)
|
||||
@@ -388,7 +429,7 @@ export async function getIssue(
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.site.id)
|
||||
if (shouldThrowAuthError(siteId)) {
|
||||
if (shouldSurfaceSiteFailure(siteId, entries.length)) {
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
@@ -590,7 +631,7 @@ export async function listProjects(siteId?: JiraSiteSelection | null): Promise<J
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.site.id)
|
||||
if (shouldThrowAuthError(siteId)) {
|
||||
if (shouldSurfaceSiteFailure(siteId, entries.length)) {
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { isAbsolute, join, posix, win32 } from 'path'
|
||||
import type { Repo } from '../../shared/types'
|
||||
import { areWorktreePathsEqual } from '../ipc/worktree-logic'
|
||||
import Database from '../sqlite/sync-database'
|
||||
import { columnExists, tableExists } from './schema-helpers'
|
||||
import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer'
|
||||
import type {
|
||||
OpenCodeUsageAttributedEvent,
|
||||
@@ -135,18 +136,6 @@ async function yieldToEventLoop(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function tableExists(db: Database.Database, tableName: string): boolean {
|
||||
const row = db
|
||||
.prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(tableName) as { found?: number } | undefined
|
||||
return row?.found === 1
|
||||
}
|
||||
|
||||
function columnExists(db: Database.Database, tableName: string, columnName: string): boolean {
|
||||
const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as { name?: string }[]
|
||||
return rows.some((row) => row.name === columnName)
|
||||
}
|
||||
|
||||
function getProjectJoin(db: Database.Database): string {
|
||||
return tableExists(db, 'project') && columnExists(db, 'session', 'project_id')
|
||||
? 'LEFT JOIN project p ON p.id = s.project_id'
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type SyncDatabase from '../sqlite/sync-database'
|
||||
|
||||
// Why: OpenCode's usage scanner and the AI Vault session scanner both need to
|
||||
// probe the opencode.db schema shape across multiple DB generations. Centralizing
|
||||
// the probes here avoids two private copies and keeps the contract testable.
|
||||
type Database = SyncDatabase.Database
|
||||
|
||||
/**
|
||||
* Check whether a table exists in the given SQLite database.
|
||||
* @param db - A readonly or read-write SyncDatabase instance.
|
||||
* @param tableName - The table name to look up in sqlite_master.
|
||||
* @returns `true` if the table exists, `false` otherwise.
|
||||
*/
|
||||
export function tableExists(db: Database, tableName: string): boolean {
|
||||
const row = db
|
||||
.prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(tableName) as { found?: number } | undefined
|
||||
return row?.found === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a column exists on a table in the given SQLite database.
|
||||
* @param db - A readonly or read-write SyncDatabase instance.
|
||||
* @param tableName - The table to inspect via PRAGMA table_info.
|
||||
* @param columnName - The column name to find.
|
||||
* @returns `true` if the column exists on the table, `false` otherwise.
|
||||
*/
|
||||
export function columnExists(db: Database, tableName: string, columnName: string): boolean {
|
||||
const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as { name?: string }[]
|
||||
return rows.some((row) => row.name === columnName)
|
||||
}
|
||||
@@ -69,6 +69,22 @@ describe('SshGitProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('getStatus forwards upstream-negative-cache bypass only when requested', async () => {
|
||||
const statusResult = { entries: [], conflictOperation: 'unknown' }
|
||||
mux.request.mockResolvedValue(statusResult)
|
||||
|
||||
await provider.getStatus('/home/user/repo', { bypassEffectiveUpstreamNegativeCache: true })
|
||||
await provider.getStatus('/home/user/repo', { bypassEffectiveUpstreamNegativeCache: false })
|
||||
|
||||
expect(mux.request).toHaveBeenNthCalledWith(1, 'git.status', {
|
||||
worktreePath: '/home/user/repo',
|
||||
bypassEffectiveUpstreamNegativeCache: true
|
||||
})
|
||||
expect(mux.request).toHaveBeenNthCalledWith(2, 'git.status', {
|
||||
worktreePath: '/home/user/repo'
|
||||
})
|
||||
})
|
||||
|
||||
it('checkIgnoredPaths sends git.checkIgnored request', async () => {
|
||||
mux.request.mockResolvedValue(['dist/bundle.js'])
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
indirection — every method is a 1:1 forwarder to a relay RPC plus a
|
||||
small amount of param plumbing. */
|
||||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
import type { IGitProvider } from './types'
|
||||
import type { GitProviderStatusOptions, IGitProvider } from './types'
|
||||
import type {
|
||||
GitStatusResult,
|
||||
GitDiffResult,
|
||||
@@ -82,12 +82,16 @@ export class SshGitProvider implements IGitProvider {
|
||||
|
||||
async getStatus(
|
||||
worktreePath: string,
|
||||
options?: { includeIgnored?: boolean }
|
||||
options?: GitProviderStatusOptions
|
||||
): Promise<GitStatusResult> {
|
||||
const includeIgnoredArgs = options?.includeIgnored ? { includeIgnored: true } : {}
|
||||
const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache
|
||||
? { bypassEffectiveUpstreamNegativeCache: true }
|
||||
: {}
|
||||
return (await this.mux.request('git.status', {
|
||||
worktreePath,
|
||||
...includeIgnoredArgs
|
||||
...includeIgnoredArgs,
|
||||
...upstreamCacheBypassArgs
|
||||
})) as GitStatusResult
|
||||
}
|
||||
|
||||
|
||||
@@ -165,8 +165,13 @@ export type IFilesystemProvider = {
|
||||
|
||||
// ─── Git Provider ───────────────────────────────────────────────────
|
||||
|
||||
export type GitProviderStatusOptions = {
|
||||
includeIgnored?: boolean
|
||||
bypassEffectiveUpstreamNegativeCache?: boolean
|
||||
}
|
||||
|
||||
export type IGitProvider = {
|
||||
getStatus(worktreePath: string, options?: { includeIgnored?: boolean }): Promise<GitStatusResult>
|
||||
getStatus(worktreePath: string, options?: GitProviderStatusOptions): Promise<GitStatusResult>
|
||||
checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise<string[]>
|
||||
getHistory(worktreePath: string, options?: GitHistoryOptions): Promise<GitHistoryResult>
|
||||
commit(worktreePath: string, message: string): Promise<{ success: boolean; error?: string }>
|
||||
|
||||
@@ -89,6 +89,17 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
expect(command).not.toContain('`e]133')
|
||||
})
|
||||
|
||||
it('normalizes MSYS drive cwd before spawning native PowerShell', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'powershell.exe',
|
||||
'/c/Users/alice/project',
|
||||
'C:\\Users\\alice'
|
||||
)
|
||||
|
||||
expect(result.effectiveCwd).toBe('C:\\Users\\alice\\project')
|
||||
expect(result.validationCwd).toBe('C:\\Users\\alice\\project')
|
||||
})
|
||||
|
||||
it('embeds short PowerShell startup commands after the OSC 133 bootstrap', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'powershell.exe',
|
||||
@@ -189,6 +200,33 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
expect(result.validationCwd).toBe('C:\\Users\\alice\\code')
|
||||
})
|
||||
|
||||
it('translates MSYS drive cwd to /mnt/<drive>/... for wsl.exe', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'wsl.exe',
|
||||
'/c/Users/alice/project',
|
||||
'C:\\Users\\alice',
|
||||
undefined,
|
||||
'codex'
|
||||
)
|
||||
|
||||
expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/project'))
|
||||
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
|
||||
expect(result.validationCwd).toBe('C:\\Users\\alice\\project')
|
||||
})
|
||||
|
||||
it('does not treat MSYS drive cwd as a WSL POSIX cwd', () => {
|
||||
const result = resolveWindowsShellLaunchArgs(
|
||||
'wsl.exe',
|
||||
'/c/Users/alice/project',
|
||||
'C:\\Users\\alice',
|
||||
{ distro: 'Ubuntu', treatPosixCwdAsWsl: true }
|
||||
)
|
||||
|
||||
expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/project', 'Ubuntu'))
|
||||
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
|
||||
expect(result.validationCwd).toBe('C:\\Users\\alice\\project')
|
||||
})
|
||||
|
||||
it('escapes single quotes when translating a WSL cwd', () => {
|
||||
const result = resolveWindowsShellLaunchArgs('wsl.exe', "C:\\weird'path", 'C:\\Users\\alice')
|
||||
// The injected sh cmd must not break out of the surrounding single quotes
|
||||
|
||||
@@ -105,6 +105,19 @@ function buildWslShellArgs(linuxCwd: string, distro?: string): string[] {
|
||||
return distro ? ['-d', distro, ...shellArgs] : shellArgs
|
||||
}
|
||||
|
||||
function normalizeMsysDrivePath(cwd: string): string {
|
||||
const match = cwd.match(/^\/([A-Za-z])(?:\/(.*))?$/)
|
||||
if (!match) {
|
||||
return cwd
|
||||
}
|
||||
|
||||
// Why: Git Bash/MSYS launch contexts can hand Windows terminals `/d/repo`;
|
||||
// ConPTY requires the equivalent native drive path as its cwd.
|
||||
const driveLetter = match[1].toUpperCase()
|
||||
const rest = match[2]?.replace(/\//g, '\\') ?? ''
|
||||
return rest ? `${driveLetter}:\\${rest}` : `${driveLetter}:\\`
|
||||
}
|
||||
|
||||
/** Build the argv + effective cwd for a Windows shell launch.
|
||||
*
|
||||
* - cmd.exe: `/K chcp 65001 > nul` so multi-byte CJK output renders correctly.
|
||||
@@ -122,6 +135,8 @@ export function resolveWindowsShellLaunchArgs(
|
||||
startupCommand?: string
|
||||
): WindowsShellLaunchArgs {
|
||||
const shellBasename = pathWin32.basename(shellPath).toLowerCase()
|
||||
const nativeCwd = normalizeMsysDrivePath(cwd)
|
||||
const isMsysDriveCwd = nativeCwd !== cwd
|
||||
|
||||
if (shellBasename === 'cmd.exe') {
|
||||
const shellArgStartupCommand = getCmdShellArgStartupCommand(startupCommand)
|
||||
@@ -133,8 +148,8 @@ export function resolveWindowsShellLaunchArgs(
|
||||
: CMD_UTF8_SETUP_COMMAND
|
||||
],
|
||||
...(shellArgStartupCommand ? { startupCommandDeliveredInShellArgs: true } : {}),
|
||||
effectiveCwd: cwd,
|
||||
validationCwd: cwd
|
||||
effectiveCwd: nativeCwd,
|
||||
validationCwd: nativeCwd
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,16 +162,16 @@ export function resolveWindowsShellLaunchArgs(
|
||||
...(powerShellCommand.startupCommandDeliveredInShellArgs
|
||||
? { startupCommandDeliveredInShellArgs: true }
|
||||
: {}),
|
||||
effectiveCwd: cwd,
|
||||
validationCwd: cwd
|
||||
effectiveCwd: nativeCwd,
|
||||
validationCwd: nativeCwd
|
||||
}
|
||||
}
|
||||
|
||||
if (isWindowsGitBashShellPath(shellPath)) {
|
||||
return {
|
||||
shellArgs: ['--login', '-i'],
|
||||
effectiveCwd: cwd,
|
||||
validationCwd: cwd
|
||||
effectiveCwd: nativeCwd,
|
||||
validationCwd: nativeCwd
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,25 +184,25 @@ export function resolveWindowsShellLaunchArgs(
|
||||
validationCwd: cwd
|
||||
}
|
||||
}
|
||||
if (wslContext?.treatPosixCwdAsWsl && cwd.startsWith('/')) {
|
||||
if (wslContext?.treatPosixCwdAsWsl && cwd.startsWith('/') && !isMsysDriveCwd) {
|
||||
return {
|
||||
shellArgs: buildWslShellArgs(cwd, wslContext.distro),
|
||||
effectiveCwd: defaultCwd,
|
||||
validationCwd: toWindowsWslPath(cwd, wslContext.distro)
|
||||
}
|
||||
}
|
||||
const driveMatch = cwd.replace(/\\/g, '/').match(/^([A-Za-z]):\/?(.*)$/)
|
||||
const linuxCwd = driveMatch ? toLinuxPath(cwd) : '/mnt/c'
|
||||
const driveMatch = nativeCwd.replace(/\\/g, '/').match(/^([A-Za-z]):\/?(.*)$/)
|
||||
const linuxCwd = driveMatch ? toLinuxPath(nativeCwd) : '/mnt/c'
|
||||
return {
|
||||
shellArgs: buildWslShellArgs(linuxCwd, wslContext?.distro),
|
||||
effectiveCwd: defaultCwd,
|
||||
validationCwd: cwd
|
||||
validationCwd: nativeCwd
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
shellArgs: [],
|
||||
effectiveCwd: cwd,
|
||||
validationCwd: cwd
|
||||
effectiveCwd: nativeCwd,
|
||||
validationCwd: nativeCwd
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ describe('fetchClaudeRateLimits', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses legacy Keychain credentials for host system default without an explicit config dir', async () => {
|
||||
it('falls back to legacy Keychain credentials for host system default without an explicit config dir', async () => {
|
||||
const configDir = '/Users/test/.claude'
|
||||
const authPreparation: ClaudeRuntimeAuthPreparation = {
|
||||
configDir,
|
||||
@@ -178,22 +178,16 @@ describe('fetchClaudeRateLimits', () => {
|
||||
stripAuthEnv: false,
|
||||
provenance: 'system'
|
||||
}
|
||||
vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'legacy-oauth-token',
|
||||
expiresAt: Date.now() + 60_000
|
||||
}
|
||||
})
|
||||
)
|
||||
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue(
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'stale-scoped-oauth-token',
|
||||
expiresAt: Date.now() + 60_000
|
||||
}
|
||||
})
|
||||
)
|
||||
vi.mocked(readActiveClaudeKeychainCredentialsStrict)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'legacy-oauth-token',
|
||||
expiresAt: Date.now() + 60_000
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({
|
||||
provider: 'claude',
|
||||
@@ -202,8 +196,9 @@ describe('fetchClaudeRateLimits', () => {
|
||||
weekly: { usedPercent: 34 }
|
||||
})
|
||||
|
||||
expect(readActiveClaudeKeychainCredentials).toHaveBeenCalledWith(undefined)
|
||||
expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalled()
|
||||
expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(1, configDir)
|
||||
expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2, undefined)
|
||||
expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled()
|
||||
expect(netFetchMock).toHaveBeenCalledWith(
|
||||
'https://api.anthropic.com/api/oauth/usage',
|
||||
expect.objectContaining({
|
||||
@@ -214,6 +209,46 @@ describe('fetchClaudeRateLimits', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('reads scoped Keychain credentials for host system default without an explicit config dir', async () => {
|
||||
const configDir = '/Users/test/.claude'
|
||||
const authPreparation: ClaudeRuntimeAuthPreparation = {
|
||||
configDir,
|
||||
runtime: 'host',
|
||||
envPatch: {},
|
||||
stripAuthEnv: false,
|
||||
provenance: 'system'
|
||||
}
|
||||
vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'scoped-oauth-token',
|
||||
expiresAt: Date.now() + 60_000
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false })
|
||||
).resolves.toMatchObject({
|
||||
provider: 'claude',
|
||||
status: 'ok',
|
||||
session: { usedPercent: 12 },
|
||||
weekly: { usedPercent: 34 }
|
||||
})
|
||||
|
||||
expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(configDir)
|
||||
expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled()
|
||||
expect(fetchViaPty).not.toHaveBeenCalled()
|
||||
expect(netFetchMock).toHaveBeenCalledWith(
|
||||
'https://api.anthropic.com/api/oauth/usage',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer scoped-oauth-token'
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the credentials file when Keychain access fails', async () => {
|
||||
const configDir = '/Users/test/.claude'
|
||||
const authPreparation: ClaudeRuntimeAuthPreparation = {
|
||||
|
||||
@@ -249,13 +249,11 @@ function resolveOAuthCredentialReadOptions(
|
||||
if (!authPreparation) {
|
||||
return undefined
|
||||
}
|
||||
// Why: Claude Code 2.1+ can scope even the default config dir's macOS
|
||||
// Keychain item. Try scoped first, with legacy still handled as fallback.
|
||||
const readOptions: OAuthCredentialReadOptions = {
|
||||
credentialsFileConfigDir: authPreparation.configDir
|
||||
}
|
||||
// Why: host system-default launches do not inject CLAUDE_CONFIG_DIR, so
|
||||
// their Keychain lookup must mirror Claude's legacy service ordering.
|
||||
if (authPreparation.envPatch.CLAUDE_CONFIG_DIR) {
|
||||
readOptions.keychainConfigDir = authPreparation.configDir
|
||||
credentialsFileConfigDir: authPreparation.configDir,
|
||||
keychainConfigDir: authPreparation.configDir
|
||||
}
|
||||
return readOptions
|
||||
}
|
||||
|
||||
@@ -353,7 +353,7 @@ describe('RateLimitService', () => {
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
|
||||
authPreparation: undefined,
|
||||
allowPtyFallback: true
|
||||
allowPtyFallback: false
|
||||
})
|
||||
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1)
|
||||
@@ -464,6 +464,67 @@ describe('RateLimitService', () => {
|
||||
expect(service.getState().claudeTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
})
|
||||
|
||||
it('does not use Claude PTY fallback for system-default usage refreshes', async () => {
|
||||
const service = new RateLimitService()
|
||||
service.setClaudeAuthPreparationResolver(async () => ({
|
||||
configDir: '/tmp/.claude',
|
||||
runtime: 'host',
|
||||
wslDistro: null,
|
||||
wslLinuxConfigDir: null,
|
||||
envPatch: {},
|
||||
stripAuthEnv: false,
|
||||
provenance: 'system'
|
||||
}))
|
||||
|
||||
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
|
||||
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now()))
|
||||
|
||||
await service.refresh()
|
||||
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
|
||||
authPreparation: expect.objectContaining({ provenance: 'system' }),
|
||||
allowPtyFallback: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not use Claude PTY fallback when Claude auth preparation is unavailable', async () => {
|
||||
const service = new RateLimitService()
|
||||
|
||||
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
|
||||
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now()))
|
||||
|
||||
await service.refresh()
|
||||
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
|
||||
authPreparation: undefined,
|
||||
allowPtyFallback: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not use Claude PTY fallback for WSL system-default usage refreshes', async () => {
|
||||
const service = new RateLimitService()
|
||||
service.setClaudeFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
service.setClaudeAuthPreparationResolver(async () => ({
|
||||
configDir: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.claude',
|
||||
runtime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
wslLinuxConfigDir: '/home/jin/.claude',
|
||||
envPatch: {},
|
||||
stripAuthEnv: true,
|
||||
provenance: 'wsl:Ubuntu:system'
|
||||
}))
|
||||
|
||||
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
|
||||
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now()))
|
||||
|
||||
await service.refresh()
|
||||
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
|
||||
authPreparation: expect.objectContaining({ provenance: 'wsl:Ubuntu:system' }),
|
||||
allowPtyFallback: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not cache host Codex usage under an outgoing WSL account', async () => {
|
||||
const service = new RateLimitService()
|
||||
const wslCodexHome =
|
||||
|
||||
@@ -72,6 +72,18 @@ function normalizePollingInterval(ms: number): number {
|
||||
return Math.min(MAX_POLL_MS, Math.max(MIN_POLL_MS, ms))
|
||||
}
|
||||
|
||||
function isSystemDefaultClaudeAuth(
|
||||
authPreparation: ClaudeRuntimeAuthPreparation | undefined
|
||||
): boolean {
|
||||
// Why: fetch cycles classify missing Claude auth as system-default; keep the
|
||||
// PTY fallback gate aligned so background refresh cannot trigger auth flows.
|
||||
if (!authPreparation) {
|
||||
return true
|
||||
}
|
||||
const provenance = authPreparation?.provenance
|
||||
return provenance === 'system' || Boolean(provenance?.endsWith(':system'))
|
||||
}
|
||||
|
||||
export class RateLimitService {
|
||||
private state: InternalRateLimitState = {
|
||||
claude: null,
|
||||
@@ -792,10 +804,18 @@ export class RateLimitService {
|
||||
return process.platform !== 'win32'
|
||||
}
|
||||
|
||||
private shouldAllowClaudePtyFallback(): boolean {
|
||||
private shouldAllowClaudePtyFallback(
|
||||
authPreparation: ClaudeRuntimeAuthPreparation | undefined
|
||||
): boolean {
|
||||
// Why: automatic recovery uses Claude CLI as the next source, but Windows
|
||||
// hidden PTY support remains less reliable than host/WSL shells.
|
||||
return process.platform !== 'win32'
|
||||
if (process.platform === 'win32') {
|
||||
return false
|
||||
}
|
||||
// Why: system-default Claude is not an Orca-managed account. Background
|
||||
// quota refresh may read existing OAuth, but must not launch Claude and
|
||||
// trigger auth/browser flows for users who never configured Claude in Orca.
|
||||
return !isSystemDefaultClaudeAuth(authPreparation)
|
||||
}
|
||||
|
||||
private withFetchingStatus(
|
||||
@@ -861,7 +881,7 @@ export class RateLimitService {
|
||||
await Promise.allSettled([
|
||||
fetchClaudeRateLimits({
|
||||
authPreparation: claudeAuthPreparation,
|
||||
allowPtyFallback: this.shouldAllowClaudePtyFallback()
|
||||
allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation)
|
||||
}),
|
||||
missingWslCodexHome ??
|
||||
fetchCodexRateLimits({
|
||||
@@ -1036,7 +1056,7 @@ export class RateLimitService {
|
||||
|
||||
const claude = await fetchClaudeRateLimits({
|
||||
authPreparation: claudeAuthPreparation,
|
||||
allowPtyFallback: this.shouldAllowClaudePtyFallback()
|
||||
allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation)
|
||||
}).catch(
|
||||
(err): ProviderRateLimits => ({
|
||||
provider: 'claude',
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type ResolvedSourceControlAiGenerationParams
|
||||
} from '../../shared/source-control-ai'
|
||||
import type { SourceControlAiOperation } from '../../shared/source-control-ai-types'
|
||||
import type { GitProviderStatusOptions } from '../providers/types'
|
||||
import { getRemoteCommitUrl, getRemoteFileUrl } from '../git/repo'
|
||||
import {
|
||||
abortMerge,
|
||||
@@ -164,7 +165,7 @@ export class RuntimeGitCommands {
|
||||
|
||||
async getRuntimeGitStatus(
|
||||
worktreeSelector: string,
|
||||
options?: { includeIgnored?: boolean }
|
||||
options?: GitProviderStatusOptions
|
||||
): Promise<GitStatusResult> {
|
||||
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
|
||||
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
|
||||
|
||||
@@ -164,7 +164,7 @@ const {
|
||||
addGitHubIssueCommentMock,
|
||||
listGitHubLabelsMock,
|
||||
listGitHubAssignableUsersMock,
|
||||
detectInstalledAgentsMock,
|
||||
detectInstalledAgentsWithShellPathHydrationMock,
|
||||
detectRemoteAgentsMock,
|
||||
listGitLabMergeRequestsMock,
|
||||
listGitLabWorkItemsMock,
|
||||
@@ -255,7 +255,7 @@ const {
|
||||
addGitHubIssueCommentMock: vi.fn(),
|
||||
listGitHubLabelsMock: vi.fn(),
|
||||
listGitHubAssignableUsersMock: vi.fn(),
|
||||
detectInstalledAgentsMock: vi.fn(),
|
||||
detectInstalledAgentsWithShellPathHydrationMock: vi.fn(),
|
||||
detectRemoteAgentsMock: vi.fn(),
|
||||
listGitLabMergeRequestsMock: vi.fn(),
|
||||
listGitLabWorkItemsMock: vi.fn(),
|
||||
@@ -319,7 +319,7 @@ vi.mock('../ipc/ssh', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../ipc/preflight', () => ({
|
||||
detectInstalledAgents: detectInstalledAgentsMock,
|
||||
detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock,
|
||||
detectRemoteAgents: detectRemoteAgentsMock
|
||||
}))
|
||||
|
||||
@@ -620,8 +620,8 @@ function resetRuntimeTestMocks(): void {
|
||||
listGitHubLabelsMock.mockResolvedValue([])
|
||||
listGitHubAssignableUsersMock.mockReset()
|
||||
listGitHubAssignableUsersMock.mockResolvedValue([])
|
||||
detectInstalledAgentsMock.mockReset()
|
||||
detectInstalledAgentsMock.mockResolvedValue([])
|
||||
detectInstalledAgentsWithShellPathHydrationMock.mockReset()
|
||||
detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue([])
|
||||
detectRemoteAgentsMock.mockReset()
|
||||
detectRemoteAgentsMock.mockResolvedValue([])
|
||||
listGitLabMergeRequestsMock.mockReset()
|
||||
@@ -17898,7 +17898,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
it('uses desktop task agent selection and bracketed-pastes startup drafts for local worktrees', async () => {
|
||||
detectInstalledAgentsMock.mockResolvedValue(['claude'])
|
||||
detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue(['claude'])
|
||||
const metaById: Record<string, WorktreeMeta> = {}
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
@@ -17959,7 +17959,7 @@ describe('OrcaRuntimeService', () => {
|
||||
activate: true
|
||||
})
|
||||
|
||||
expect(detectInstalledAgentsMock).not.toHaveBeenCalled()
|
||||
expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled()
|
||||
expect(detectRemoteAgentsMock).not.toHaveBeenCalled()
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -18215,7 +18215,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
it('records the resolved fallback agent when the requested startup draft agent is disabled', async () => {
|
||||
detectInstalledAgentsMock.mockResolvedValue(['claude'])
|
||||
detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue(['claude'])
|
||||
const metaById: Record<string, WorktreeMeta> = {}
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
@@ -18276,7 +18276,7 @@ describe('OrcaRuntimeService', () => {
|
||||
activate: true
|
||||
})
|
||||
|
||||
expect(detectInstalledAgentsMock).toHaveBeenCalled()
|
||||
expect(detectInstalledAgentsWithShellPathHydrationMock).toHaveBeenCalled()
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: '/tmp/workspaces/runtime-fallback-draft',
|
||||
@@ -18405,7 +18405,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
it('lets explicit startup draft agents override the desktop default', async () => {
|
||||
detectInstalledAgentsMock.mockResolvedValue([])
|
||||
detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue([])
|
||||
const metaById: Record<string, WorktreeMeta> = {}
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
@@ -18467,7 +18467,7 @@ describe('OrcaRuntimeService', () => {
|
||||
activate: true
|
||||
})
|
||||
|
||||
expect(detectInstalledAgentsMock).not.toHaveBeenCalled()
|
||||
expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled()
|
||||
expect(detectRemoteAgentsMock).not.toHaveBeenCalled()
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -18485,7 +18485,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
it('does not auto-launch an agent for startup drafts when the default is blank', async () => {
|
||||
detectInstalledAgentsMock.mockResolvedValue(['claude', 'codex'])
|
||||
detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue(['claude', 'codex'])
|
||||
const metaById: Record<string, WorktreeMeta> = {}
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
@@ -18545,7 +18545,7 @@ describe('OrcaRuntimeService', () => {
|
||||
activate: true
|
||||
})
|
||||
|
||||
expect(detectInstalledAgentsMock).not.toHaveBeenCalled()
|
||||
expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled()
|
||||
expect(detectRemoteAgentsMock).not.toHaveBeenCalled()
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
expect(metaById[result.worktree.id]?.createdWithAgent).toBeUndefined()
|
||||
@@ -18638,7 +18638,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
expect(detectRemoteAgentsMock).toHaveBeenCalledWith({ connectionId: 'ssh-1' })
|
||||
expect(detectInstalledAgentsMock).not.toHaveBeenCalled()
|
||||
expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled()
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: '/remote/mobile-startup-draft',
|
||||
|
||||
@@ -172,7 +172,7 @@ import {
|
||||
resolveTuiAgentLaunchEnv
|
||||
} from '../../shared/tui-agent-launch-defaults'
|
||||
import { isTuiAgent, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
|
||||
import { detectInstalledAgents, detectRemoteAgents } from '../ipc/preflight'
|
||||
import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents } from '../ipc/preflight'
|
||||
import {
|
||||
markCodexProjectTrusted,
|
||||
markCopilotFolderTrusted,
|
||||
@@ -11436,9 +11436,10 @@ export class OrcaRuntimeService {
|
||||
if (!agent) {
|
||||
let detected: string[] = []
|
||||
try {
|
||||
// Why: startup-draft fallback can run from sparse runtime launch envs too.
|
||||
detected = repo.connectionId
|
||||
? await detectRemoteAgents({ connectionId: repo.connectionId })
|
||||
: await detectInstalledAgents()
|
||||
: await detectInstalledAgentsWithShellPathHydration()
|
||||
} catch {
|
||||
detected = []
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ export const WorktreeSelector = z.object({
|
||||
})
|
||||
|
||||
export const GitStatusParams = WorktreeSelector.extend({
|
||||
includeIgnored: z.boolean().optional()
|
||||
includeIgnored: z.boolean().optional(),
|
||||
bypassEffectiveUpstreamNegativeCache: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const GitCheckIgnored = WorktreeSelector.extend({
|
||||
|
||||
@@ -55,6 +55,32 @@ describe('git RPC methods', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards upstream-negative-cache bypass for status requests', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
getRuntimeGitStatus: vi.fn().mockResolvedValue({
|
||||
entries: [],
|
||||
conflictOperation: 'unknown'
|
||||
})
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('git.status', {
|
||||
worktree: 'id:wt-1',
|
||||
bypassEffectiveUpstreamNegativeCache: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1', {
|
||||
bypassEffectiveUpstreamNegativeCache: true
|
||||
})
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { entries: [] }
|
||||
})
|
||||
})
|
||||
|
||||
it('returns ignored paths for selected explorer rows', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
|
||||
@@ -87,10 +87,23 @@ export const GIT_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'git.status',
|
||||
params: GitStatusParams,
|
||||
handler: async (params, { runtime }) =>
|
||||
params.includeIgnored === undefined
|
||||
handler: async (params, { runtime }) => {
|
||||
const options =
|
||||
params.includeIgnored === undefined &&
|
||||
params.bypassEffectiveUpstreamNegativeCache === undefined
|
||||
? undefined
|
||||
: {
|
||||
...(params.includeIgnored === undefined
|
||||
? {}
|
||||
: { includeIgnored: params.includeIgnored }),
|
||||
...(params.bypassEffectiveUpstreamNegativeCache === true
|
||||
? { bypassEffectiveUpstreamNegativeCache: true }
|
||||
: {})
|
||||
}
|
||||
return options === undefined
|
||||
? runtime.getRuntimeGitStatus(params.worktree)
|
||||
: runtime.getRuntimeGitStatus(params.worktree, { includeIgnored: params.includeIgnored })
|
||||
: runtime.getRuntimeGitStatus(params.worktree, options)
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'git.checkIgnored',
|
||||
|
||||
@@ -5,19 +5,19 @@ import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { PREFLIGHT_METHODS } from './preflight'
|
||||
|
||||
const {
|
||||
detectInstalledAgentsMock,
|
||||
detectInstalledAgentsWithShellPathHydrationMock,
|
||||
detectRemoteAgentsMock,
|
||||
refreshShellPathAndDetectAgentsMock,
|
||||
runPreflightCheckMock
|
||||
} = vi.hoisted(() => ({
|
||||
detectInstalledAgentsMock: vi.fn(),
|
||||
detectInstalledAgentsWithShellPathHydrationMock: vi.fn(),
|
||||
detectRemoteAgentsMock: vi.fn(),
|
||||
refreshShellPathAndDetectAgentsMock: vi.fn(),
|
||||
runPreflightCheckMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../ipc/preflight', () => ({
|
||||
detectInstalledAgents: detectInstalledAgentsMock,
|
||||
detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock,
|
||||
detectRemoteAgents: detectRemoteAgentsMock,
|
||||
refreshShellPathAndDetectAgents: refreshShellPathAndDetectAgentsMock,
|
||||
runPreflightCheck: runPreflightCheckMock
|
||||
@@ -46,7 +46,7 @@ describe('preflight RPC methods', () => {
|
||||
})
|
||||
|
||||
it('detects agents and refreshes PATH on the server through runtime RPC', async () => {
|
||||
detectInstalledAgentsMock.mockResolvedValueOnce(['codex'])
|
||||
detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValueOnce(['codex'])
|
||||
refreshShellPathAndDetectAgentsMock.mockResolvedValueOnce({
|
||||
agents: ['codex', 'claude'],
|
||||
addedPathSegments: ['/opt/bin'],
|
||||
@@ -60,7 +60,7 @@ describe('preflight RPC methods', () => {
|
||||
const detected = await dispatcher.dispatch(makeRequest('preflight.detectAgents'))
|
||||
const refreshed = await dispatcher.dispatch(makeRequest('preflight.refreshAgents'))
|
||||
|
||||
expect(detectInstalledAgentsMock).toHaveBeenCalled()
|
||||
expect(detectInstalledAgentsWithShellPathHydrationMock).toHaveBeenCalled()
|
||||
expect(refreshShellPathAndDetectAgentsMock).toHaveBeenCalled()
|
||||
expect(detected).toMatchObject({ ok: true, result: ['codex'] })
|
||||
expect(refreshed).toMatchObject({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import {
|
||||
detectRemoteAgents,
|
||||
detectInstalledAgents,
|
||||
detectInstalledAgentsWithShellPathHydration,
|
||||
refreshShellPathAndDetectAgents,
|
||||
runPreflightCheck
|
||||
} from '../../../ipc/preflight'
|
||||
@@ -23,7 +23,7 @@ export const PREFLIGHT_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'preflight.detectAgents',
|
||||
params: null,
|
||||
handler: async () => detectInstalledAgents()
|
||||
handler: async () => detectInstalledAgentsWithShellPathHydration()
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'preflight.detectRemoteAgents',
|
||||
|
||||
@@ -17,7 +17,8 @@ const {
|
||||
registerPtyHandlersMock,
|
||||
hydrateLocalPtyRegistryAtBootMock,
|
||||
setupAutoUpdaterMock,
|
||||
browserManagerUnregisterAllMock
|
||||
browserManagerUnregisterAllMock,
|
||||
runWorktreeChangeInvalidatorsMock
|
||||
} = vi.hoisted(() => ({
|
||||
onMock: vi.fn(),
|
||||
removeAllListenersMock: vi.fn(),
|
||||
@@ -33,7 +34,8 @@ const {
|
||||
registerPtyHandlersMock: vi.fn(),
|
||||
hydrateLocalPtyRegistryAtBootMock: vi.fn(),
|
||||
setupAutoUpdaterMock: vi.fn(),
|
||||
browserManagerUnregisterAllMock: vi.fn()
|
||||
browserManagerUnregisterAllMock: vi.fn(),
|
||||
runWorktreeChangeInvalidatorsMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
@@ -64,6 +66,10 @@ vi.mock('../ipc/worktrees', () => ({
|
||||
registerWorktreeHandlers: registerWorktreeHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('../ipc/worktree-change-invalidators', () => ({
|
||||
runWorktreeChangeInvalidators: runWorktreeChangeInvalidatorsMock
|
||||
}))
|
||||
|
||||
vi.mock('../ipc/pty', () => ({
|
||||
getLocalPtyProvider: vi.fn(),
|
||||
registerPtyHandlers: registerPtyHandlersMock
|
||||
@@ -592,5 +598,9 @@ describe('attachMainWindowServices', () => {
|
||||
}
|
||||
]
|
||||
])
|
||||
expect(runWorktreeChangeInvalidatorsMock).toHaveBeenCalledWith('repo-1')
|
||||
expect(runWorktreeChangeInvalidatorsMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
sendMock.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,6 +36,7 @@ import { isNativeFileDropPayload, type NativeFileDropPayload } from '../../share
|
||||
import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-relay'
|
||||
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
|
||||
import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection'
|
||||
import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidators'
|
||||
|
||||
let appReloadHandlerTokenCounter = 0
|
||||
let activeAppReloadHandlerToken: number | null = null
|
||||
@@ -216,8 +217,12 @@ function registerRuntimeWindowLifecycle(
|
||||
}
|
||||
}
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: (repoId, renamed) =>
|
||||
send('worktrees:changed', renamed ? { repoId, renamed } : { repoId }),
|
||||
worktreesChanged: (repoId, renamed) => {
|
||||
// Why: clear detected-worktree scan caches before renderer listeners
|
||||
// handle this event, preventing stale TTL reads after mutations.
|
||||
runWorktreeChangeInvalidators(repoId)
|
||||
send('worktrees:changed', renamed ? { repoId, renamed } : { repoId })
|
||||
},
|
||||
worktreeBaseStatus: (event) => send('worktree:baseStatus', event),
|
||||
worktreeRemoteBranchConflict: (event) => send('worktree:remoteBranchConflict', event),
|
||||
reposChanged: () => send('repos:changed'),
|
||||
|
||||
@@ -115,6 +115,7 @@ import type {
|
||||
FloatingTerminalCwdRequest,
|
||||
GitHubIssueUpdate,
|
||||
GitHubPRRefreshCandidate,
|
||||
GitHubPRRefreshEnqueueResult,
|
||||
GitHubPRRefreshEvent,
|
||||
GitHubPRRefreshReason,
|
||||
GetRateLimitResult,
|
||||
@@ -1156,7 +1157,7 @@ export type PreloadApi = {
|
||||
candidate: GitHubPRRefreshCandidate
|
||||
reason: GitHubPRRefreshReason
|
||||
priority?: number
|
||||
}) => Promise<boolean>
|
||||
}) => Promise<GitHubPRRefreshEnqueueResult | false>
|
||||
reportVisiblePRRefreshCandidates: (args: {
|
||||
candidates: GitHubPRRefreshCandidate[]
|
||||
generation: number
|
||||
@@ -2107,6 +2108,7 @@ export type PreloadApi = {
|
||||
worktreePath: string
|
||||
connectionId?: string
|
||||
includeIgnored?: boolean
|
||||
bypassEffectiveUpstreamNegativeCache?: boolean
|
||||
}) => Promise<GitStatusResult>
|
||||
checkIgnored: (args: {
|
||||
worktreePath: string
|
||||
|
||||
@@ -2536,6 +2536,7 @@ const api = {
|
||||
worktreePath: string
|
||||
connectionId?: string
|
||||
includeIgnored?: boolean
|
||||
bypassEffectiveUpstreamNegativeCache?: boolean
|
||||
}): Promise<unknown> => ipcRenderer.invoke('git:status', args),
|
||||
checkIgnored: (args: {
|
||||
worktreePath: string
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as path from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GitExec } from './git-handler-ops'
|
||||
import { getStatusOp } from './git-handler-status-ops'
|
||||
import { clearNoEffectiveUpstreamStatusCache } from './git-status-upstream-negative-cache'
|
||||
|
||||
const LARGE_STATUS_ENTRY_COUNT = 150_000
|
||||
|
||||
@@ -16,14 +17,21 @@ function buildLargeStatusOutput(count: number): string {
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function buildBranchStatusOutput(head: string, branch: string): string {
|
||||
return [`# branch.oid ${head}`, `# branch.head ${branch}`].join('\n')
|
||||
}
|
||||
|
||||
describe('getStatusOp', () => {
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
clearNoEffectiveUpstreamStatusCache()
|
||||
tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-git-status-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers()
|
||||
clearNoEffectiveUpstreamStatusCache()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
@@ -70,4 +78,170 @@ describe('getStatusOp', () => {
|
||||
expect(result.didHitLimit).toBeUndefined()
|
||||
expect(result.entries).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('caches no-effective-upstream probes across status polls for the same head', async () => {
|
||||
const git = vi.fn<GitExec>(async (args) => {
|
||||
if (args.includes('status')) {
|
||||
return { stdout: buildBranchStatusOutput('abc123', 'feature'), stderr: '' }
|
||||
}
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'feature\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
|
||||
throw new Error('fatal: no upstream configured for branch feature')
|
||||
}
|
||||
throw new Error(`No upstream fixture for git ${args.join(' ')}`)
|
||||
})
|
||||
|
||||
const first = await getStatusOp(git, { worktreePath: tmpDir })
|
||||
const firstCallCount = git.mock.calls.length
|
||||
const second = await getStatusOp(git, { worktreePath: tmpDir })
|
||||
|
||||
expect(first.upstreamStatus).toEqual({ hasUpstream: false, ahead: 0, behind: 0 })
|
||||
expect(second.upstreamStatus).toEqual(first.upstreamStatus)
|
||||
expect(git.mock.calls).toHaveLength(firstCallCount + 1)
|
||||
expect(
|
||||
git.mock.calls.filter(([args]) => args[0] === 'rev-parse' && args.includes('HEAD@{u}'))
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
git.mock.calls.filter(
|
||||
([args]) => args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')
|
||||
)
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps no-effective-upstream probes cached beyond thirty seconds', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
const git = vi.fn<GitExec>(async (args) => {
|
||||
if (args.includes('status')) {
|
||||
return { stdout: buildBranchStatusOutput('abc123', 'feature'), stderr: '' }
|
||||
}
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'feature\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
|
||||
throw new Error('fatal: no upstream configured for branch feature')
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) {
|
||||
throw new Error('missing remote branch')
|
||||
}
|
||||
throw new Error(`No upstream fixture for git ${args.join(' ')}`)
|
||||
})
|
||||
|
||||
await getStatusOp(git, { worktreePath: tmpDir })
|
||||
vi.setSystemTime(31_000)
|
||||
await getStatusOp(git, { worktreePath: tmpDir })
|
||||
|
||||
expect(
|
||||
git.mock.calls.filter(([args]) => args[0] === 'rev-parse' && args.includes('HEAD@{u}'))
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('coalesces concurrent no-effective-upstream probes', async () => {
|
||||
const git = vi.fn<GitExec>(async (args) => {
|
||||
if (args.includes('status')) {
|
||||
return { stdout: buildBranchStatusOutput('abc123', 'feature'), stderr: '' }
|
||||
}
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'feature\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
|
||||
await Promise.resolve()
|
||||
throw new Error('fatal: no upstream configured for branch feature')
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) {
|
||||
await Promise.resolve()
|
||||
throw new Error('missing remote branch')
|
||||
}
|
||||
throw new Error(`No upstream fixture for git ${args.join(' ')}`)
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
getStatusOp(git, { worktreePath: tmpDir }),
|
||||
getStatusOp(git, { worktreePath: tmpDir }),
|
||||
getStatusOp(git, { worktreePath: tmpDir })
|
||||
])
|
||||
|
||||
expect(
|
||||
git.mock.calls.filter(([args]) => args[0] === 'rev-parse' && args.includes('HEAD@{u}'))
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
git.mock.calls.filter(
|
||||
([args]) => args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')
|
||||
)
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('invalidates cached no-effective-upstream probes when the branch changes', async () => {
|
||||
let branch = 'feature'
|
||||
const git = vi.fn<GitExec>(async (args) => {
|
||||
if (args.includes('status')) {
|
||||
return { stdout: buildBranchStatusOutput('abc123', branch), stderr: '' }
|
||||
}
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: `${branch}\n`, stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
|
||||
throw new Error(`fatal: no upstream configured for branch ${branch}`)
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/origin/'))) {
|
||||
throw new Error('missing remote branch')
|
||||
}
|
||||
throw new Error(`No upstream fixture for git ${args.join(' ')}`)
|
||||
})
|
||||
|
||||
await getStatusOp(git, { worktreePath: tmpDir })
|
||||
branch = 'other-feature'
|
||||
await getStatusOp(git, { worktreePath: tmpDir })
|
||||
|
||||
expect(
|
||||
git.mock.calls
|
||||
.filter(
|
||||
([args]) =>
|
||||
args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/origin/'))
|
||||
)
|
||||
.map(([args]) => args.at(-1))
|
||||
).toEqual(['refs/remotes/origin/feature', 'refs/remotes/origin/other-feature'])
|
||||
})
|
||||
|
||||
it('does not cache a configured push target signal', async () => {
|
||||
const git = vi.fn<GitExec>(async (args) => {
|
||||
if (args.includes('status')) {
|
||||
return { stdout: buildBranchStatusOutput('abc123', 'feature/fix'), stderr: '' }
|
||||
}
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'feature/fix\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
|
||||
throw new Error('fatal: no upstream configured for branch feature/fix')
|
||||
}
|
||||
if (args[0] === 'config' && args.includes('branch.feature/fix.pushRemote')) {
|
||||
return { stdout: 'fork\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'config' && args.includes('remote.pushDefault')) {
|
||||
throw new Error('missing push default')
|
||||
}
|
||||
if (args[0] === 'config' && args.includes('branch.feature/fix.remote')) {
|
||||
return { stdout: 'fork\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'config' && args.includes('branch.feature/fix.merge')) {
|
||||
return { stdout: 'refs/heads/feature/fix\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'config' && args.includes('branch.feature/fix.base')) {
|
||||
throw new Error('missing branch base')
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/'))) {
|
||||
throw new Error('missing remote branch')
|
||||
}
|
||||
throw new Error(`No upstream fixture for git ${args.join(' ')}`)
|
||||
})
|
||||
|
||||
await getStatusOp(git, { worktreePath: tmpDir })
|
||||
await getStatusOp(git, { worktreePath: tmpDir })
|
||||
|
||||
expect(
|
||||
git.mock.calls.filter(([args]) => args[0] === 'rev-parse' && args.includes('HEAD@{u}'))
|
||||
).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,10 +11,8 @@ import { parseUnmergedEntry } from './git-handler-utils'
|
||||
import { parseStatusOutput } from './git-status-output-parser'
|
||||
import type { GitExec } from './git-handler-ops'
|
||||
import type { GitUpstreamStatus } from '../shared/types'
|
||||
import {
|
||||
getEffectiveGitUpstreamStatus,
|
||||
splitRemoteBranchName
|
||||
} from '../shared/git-effective-upstream'
|
||||
import { splitRemoteBranchName } from '../shared/git-effective-upstream'
|
||||
import { readOrProbeNoEffectiveUpstreamStatus } from './git-status-upstream-negative-cache'
|
||||
import {
|
||||
applyLineStats,
|
||||
collectUntrackedAdditions,
|
||||
@@ -133,11 +131,20 @@ export async function getStatusOp(
|
||||
|
||||
if (!didHitLimit) {
|
||||
if (shouldProbeEffectiveUpstreamStatus(branch, upstreamStatus?.upstreamName)) {
|
||||
try {
|
||||
upstreamStatus = await getEffectiveGitUpstreamStatus((args) => git(args, worktreePath))
|
||||
} catch {
|
||||
// Why: status polling should keep returning working-tree entries even
|
||||
// if the richer upstream probe hits a transient SSH/git ref error.
|
||||
const branchName = getShortBranchName(branch)
|
||||
if (branchName) {
|
||||
try {
|
||||
upstreamStatus = await readOrProbeNoEffectiveUpstreamStatus(
|
||||
{ worktreePath, branchName, upstreamName: upstreamStatus?.upstreamName },
|
||||
(args) => git(args, worktreePath),
|
||||
{
|
||||
bypassCache: params.bypassEffectiveUpstreamNegativeCache === true
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
// Why: status polling should keep returning working-tree entries even
|
||||
// if the richer upstream probe hits a transient SSH/git ref error.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
clearNoEffectiveUpstreamStatusCache,
|
||||
clearNoEffectiveUpstreamStatusCacheEntry,
|
||||
getNoEffectiveUpstreamStatusCacheCountForTests,
|
||||
getNoEffectiveUpstreamStatusGenerationCountForTests,
|
||||
readOrProbeNoEffectiveUpstreamStatus
|
||||
} from './git-status-upstream-negative-cache'
|
||||
|
||||
describe('relay upstream negative cache', () => {
|
||||
beforeEach(() => {
|
||||
clearNoEffectiveUpstreamStatusCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearNoEffectiveUpstreamStatusCache()
|
||||
})
|
||||
|
||||
it('bypasses cached no-effective-upstream status when requested', async () => {
|
||||
let originBranchExists = false
|
||||
const runGit = vi.fn(async (args: string[]): Promise<{ stdout: string }> => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'feature\n' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
|
||||
throw new Error('fatal: no upstream configured for branch feature')
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) {
|
||||
if (originBranchExists) {
|
||||
return { stdout: 'abc123\n' }
|
||||
}
|
||||
throw new Error('missing remote branch')
|
||||
}
|
||||
if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) {
|
||||
return { stdout: '0\t1\n' }
|
||||
}
|
||||
throw new Error(`No upstream fixture for git ${args.join(' ')}`)
|
||||
})
|
||||
const identity = { worktreePath: '/repo', branchName: 'feature' }
|
||||
|
||||
const first = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit)
|
||||
originBranchExists = true
|
||||
const automatic = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit)
|
||||
const strict = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit, {
|
||||
bypassCache: true
|
||||
})
|
||||
|
||||
expect(first).toEqual({ hasUpstream: false, ahead: 0, behind: 0 })
|
||||
expect(automatic).toEqual(first)
|
||||
expect(strict).toEqual({
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/feature',
|
||||
ahead: 0,
|
||||
behind: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an older automatic negative probe from overwriting a strict positive result', async () => {
|
||||
let originBranchExists = false
|
||||
let deferredOriginReject: ((error: Error) => void) | null = null
|
||||
const runGit = vi.fn(async (args: string[]) => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'feature\n' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
|
||||
throw new Error('fatal: no upstream configured for branch feature')
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) {
|
||||
if (originBranchExists) {
|
||||
return { stdout: 'abc123\n' }
|
||||
}
|
||||
return await new Promise<{ stdout: string }>((_, reject) => {
|
||||
deferredOriginReject = reject
|
||||
})
|
||||
}
|
||||
if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) {
|
||||
return { stdout: '0\t1\n' }
|
||||
}
|
||||
throw new Error(`No upstream fixture for git ${args.join(' ')}`)
|
||||
})
|
||||
const identity = { worktreePath: '/repo', branchName: 'feature' }
|
||||
|
||||
const automatic = readOrProbeNoEffectiveUpstreamStatus(identity, runGit)
|
||||
await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy())
|
||||
|
||||
originBranchExists = true
|
||||
const strict = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit, {
|
||||
bypassCache: true
|
||||
})
|
||||
if (!deferredOriginReject) {
|
||||
throw new Error('expected deferred origin reject')
|
||||
}
|
||||
;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch'))
|
||||
const staleAutomatic = await automatic
|
||||
const nextAutomatic = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit)
|
||||
|
||||
expect(strict).toEqual({
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/feature',
|
||||
ahead: 0,
|
||||
behind: 1
|
||||
})
|
||||
expect(staleAutomatic).toEqual({ hasUpstream: false, ahead: 0, behind: 0 })
|
||||
expect(nextAutomatic).toEqual(strict)
|
||||
})
|
||||
|
||||
it('does not trim generation for an unresolved automatic probe', async () => {
|
||||
let originBranchExists = false
|
||||
let deferredOriginReject: ((error: Error) => void) | null = null
|
||||
const runGit = vi.fn(async (args: string[]): Promise<{ stdout: string }> => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'feature\n' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
|
||||
throw new Error('fatal: no upstream configured for branch feature')
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) {
|
||||
if (originBranchExists) {
|
||||
return { stdout: 'abc123\n' }
|
||||
}
|
||||
return await new Promise<{ stdout: string }>((_, reject) => {
|
||||
deferredOriginReject = reject
|
||||
})
|
||||
}
|
||||
if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) {
|
||||
return { stdout: '0\t1\n' }
|
||||
}
|
||||
throw new Error(`No upstream fixture for git ${args.join(' ')}`)
|
||||
})
|
||||
const identity = { worktreePath: '/repo', branchName: 'feature' }
|
||||
|
||||
const automatic = readOrProbeNoEffectiveUpstreamStatus(identity, runGit)
|
||||
await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy())
|
||||
|
||||
originBranchExists = true
|
||||
const strict = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit, {
|
||||
bypassCache: true
|
||||
})
|
||||
for (let index = 0; index < 512; index += 1) {
|
||||
const branchName = `other-${index}`
|
||||
await readOrProbeNoEffectiveUpstreamStatus(
|
||||
{ worktreePath: '/repo', branchName },
|
||||
async (args) => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: `${branchName}\n` }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
|
||||
throw new Error(`fatal: no upstream configured for branch ${branchName}`)
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) {
|
||||
return { stdout: 'abc123\n' }
|
||||
}
|
||||
if (args[0] === 'rev-list' && args.includes(`HEAD...origin/${branchName}`)) {
|
||||
return { stdout: '0\t1\n' }
|
||||
}
|
||||
throw new Error(`No upstream fixture for git ${args.join(' ')}`)
|
||||
},
|
||||
{ bypassCache: true }
|
||||
)
|
||||
}
|
||||
if (!deferredOriginReject) {
|
||||
throw new Error('expected deferred origin reject')
|
||||
}
|
||||
;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch'))
|
||||
await automatic
|
||||
const nextAutomatic = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit)
|
||||
|
||||
expect(strict).toEqual({
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/feature',
|
||||
ahead: 0,
|
||||
behind: 1
|
||||
})
|
||||
expect(nextAutomatic).toEqual(strict)
|
||||
expect(getNoEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512)
|
||||
})
|
||||
|
||||
it('does not trim generation for a cleared automatic probe before it settles', async () => {
|
||||
let originBranchExists = false
|
||||
let deferredOriginReject: ((error: Error) => void) | null = null
|
||||
const runGit = vi.fn(async (args: string[]): Promise<{ stdout: string }> => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'feature\n' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
|
||||
throw new Error('fatal: no upstream configured for branch feature')
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) {
|
||||
if (originBranchExists) {
|
||||
return { stdout: 'abc123\n' }
|
||||
}
|
||||
return await new Promise<{ stdout: string }>((_, reject) => {
|
||||
deferredOriginReject = reject
|
||||
})
|
||||
}
|
||||
if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) {
|
||||
return { stdout: '0\t1\n' }
|
||||
}
|
||||
throw new Error(`No upstream fixture for git ${args.join(' ')}`)
|
||||
})
|
||||
const identity = { worktreePath: '/repo', branchName: 'feature' }
|
||||
|
||||
const automatic = readOrProbeNoEffectiveUpstreamStatus(identity, runGit)
|
||||
await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy())
|
||||
|
||||
originBranchExists = true
|
||||
clearNoEffectiveUpstreamStatusCacheEntry(identity)
|
||||
for (let index = 0; index < 512; index += 1) {
|
||||
const branchName = `other-${index}`
|
||||
await readOrProbeNoEffectiveUpstreamStatus(
|
||||
{ worktreePath: '/repo', branchName },
|
||||
async (args) => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: `${branchName}\n` }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
|
||||
throw new Error(`fatal: no upstream configured for branch ${branchName}`)
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) {
|
||||
return { stdout: 'abc123\n' }
|
||||
}
|
||||
if (args[0] === 'rev-list' && args.includes(`HEAD...origin/${branchName}`)) {
|
||||
return { stdout: '0\t1\n' }
|
||||
}
|
||||
throw new Error(`No upstream fixture for git ${args.join(' ')}`)
|
||||
},
|
||||
{ bypassCache: true }
|
||||
)
|
||||
}
|
||||
if (!deferredOriginReject) {
|
||||
throw new Error('expected deferred origin reject')
|
||||
}
|
||||
;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch'))
|
||||
await automatic
|
||||
const nextAutomatic = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit)
|
||||
|
||||
expect(nextAutomatic).toEqual({
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/feature',
|
||||
ahead: 0,
|
||||
behind: 1
|
||||
})
|
||||
expect(getNoEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512)
|
||||
})
|
||||
|
||||
it('bounds no-effective-upstream entries', async () => {
|
||||
for (let index = 0; index < 513; index += 1) {
|
||||
const branchName = `feature-${index}`
|
||||
await readOrProbeNoEffectiveUpstreamStatus(
|
||||
{ worktreePath: '/repo', branchName },
|
||||
async (args) => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: `${branchName}\n` }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
|
||||
throw new Error(`fatal: no upstream configured for branch ${branchName}`)
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) {
|
||||
throw new Error('missing remote branch')
|
||||
}
|
||||
throw new Error(`No upstream fixture for git ${args.join(' ')}`)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
expect(getNoEffectiveUpstreamStatusCacheCountForTests()).toBe(512)
|
||||
expect(getNoEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512)
|
||||
})
|
||||
|
||||
it('bounds write-generation entries from positive strict probes', async () => {
|
||||
for (let index = 0; index < 513; index += 1) {
|
||||
const branchName = `feature-${index}`
|
||||
await readOrProbeNoEffectiveUpstreamStatus(
|
||||
{ worktreePath: '/repo', branchName },
|
||||
async (args) => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: `${branchName}\n` }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
|
||||
throw new Error(`fatal: no upstream configured for branch ${branchName}`)
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) {
|
||||
return { stdout: 'abc123\n' }
|
||||
}
|
||||
if (args[0] === 'rev-list' && args.includes(`HEAD...origin/${branchName}`)) {
|
||||
return { stdout: '0\t1\n' }
|
||||
}
|
||||
throw new Error(`No upstream fixture for git ${args.join(' ')}`)
|
||||
},
|
||||
{ bypassCache: true }
|
||||
)
|
||||
}
|
||||
|
||||
expect(getNoEffectiveUpstreamStatusCacheCountForTests()).toBe(0)
|
||||
expect(getNoEffectiveUpstreamStatusGenerationCountForTests()).toBe(512)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
import { getEffectiveGitUpstreamStatus } from '../shared/git-effective-upstream'
|
||||
import type { GitCommandRunner } from '../shared/git-effective-upstream'
|
||||
import type { GitUpstreamStatus } from '../shared/types'
|
||||
|
||||
const NO_EFFECTIVE_UPSTREAM_CACHE_TTL_MS = 5 * 60_000
|
||||
const MAX_NO_EFFECTIVE_UPSTREAM_CACHE_ENTRIES = 512
|
||||
|
||||
type NoEffectiveUpstreamCacheIdentity = {
|
||||
worktreePath: string
|
||||
branchName: string
|
||||
upstreamName?: string
|
||||
}
|
||||
|
||||
type NoEffectiveUpstreamCacheEntry = {
|
||||
status: GitUpstreamStatus
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
const noEffectiveUpstreamByIdentity = new Map<string, NoEffectiveUpstreamCacheEntry>()
|
||||
const noEffectiveUpstreamInFlight = new Map<string, Promise<GitUpstreamStatus>>()
|
||||
const retiredNoEffectiveUpstreamInFlight = new Map<string, Promise<GitUpstreamStatus>>()
|
||||
const noEffectiveUpstreamWriteGeneration = new Map<string, number>()
|
||||
|
||||
function noEffectiveUpstreamCacheKey(identity: NoEffectiveUpstreamCacheIdentity): string {
|
||||
return [identity.worktreePath, identity.branchName, identity.upstreamName ?? ''].join('\0')
|
||||
}
|
||||
|
||||
function readCachedNoEffectiveUpstreamStatus(
|
||||
cacheKey: string,
|
||||
nowMs = Date.now()
|
||||
): GitUpstreamStatus | null {
|
||||
const entry = noEffectiveUpstreamByIdentity.get(cacheKey)
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
if (entry.expiresAt <= nowMs) {
|
||||
noEffectiveUpstreamByIdentity.delete(cacheKey)
|
||||
return null
|
||||
}
|
||||
return entry.status
|
||||
}
|
||||
|
||||
function hasPendingNoEffectiveUpstreamProbe(cacheKey: string): boolean {
|
||||
return (
|
||||
noEffectiveUpstreamInFlight.has(cacheKey) || retiredNoEffectiveUpstreamInFlight.has(cacheKey)
|
||||
)
|
||||
}
|
||||
|
||||
function trimNoEffectiveUpstreamWriteGeneration(): void {
|
||||
for (const cacheKey of noEffectiveUpstreamWriteGeneration.keys()) {
|
||||
if (noEffectiveUpstreamWriteGeneration.size <= MAX_NO_EFFECTIVE_UPSTREAM_CACHE_ENTRIES) {
|
||||
break
|
||||
}
|
||||
if (hasPendingNoEffectiveUpstreamProbe(cacheKey)) {
|
||||
continue
|
||||
}
|
||||
noEffectiveUpstreamWriteGeneration.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
function cacheNoEffectiveUpstreamStatus(
|
||||
cacheKey: string,
|
||||
status: GitUpstreamStatus,
|
||||
probedSameNameOriginRef: boolean,
|
||||
writeGeneration: number,
|
||||
nowMs = Date.now()
|
||||
): void {
|
||||
// Why: hasConfiguredPushTarget controls publish behavior; keep that signal
|
||||
// fresh rather than serving a stale positive from status polling.
|
||||
if (status.hasUpstream || status.hasConfiguredPushTarget) {
|
||||
noEffectiveUpstreamByIdentity.delete(cacheKey)
|
||||
noEffectiveUpstreamWriteGeneration.set(cacheKey, writeGeneration + 1)
|
||||
trimNoEffectiveUpstreamWriteGeneration()
|
||||
return
|
||||
}
|
||||
if ((noEffectiveUpstreamWriteGeneration.get(cacheKey) ?? 0) !== writeGeneration) {
|
||||
return
|
||||
}
|
||||
// Why: only cache negatives after probing origin/<branch>; other resolution
|
||||
// paths can fail without proving the same-name publish branch is absent.
|
||||
if (!probedSameNameOriginRef) {
|
||||
return
|
||||
}
|
||||
noEffectiveUpstreamByIdentity.set(cacheKey, {
|
||||
status,
|
||||
expiresAt: nowMs + NO_EFFECTIVE_UPSTREAM_CACHE_TTL_MS
|
||||
})
|
||||
while (noEffectiveUpstreamByIdentity.size > MAX_NO_EFFECTIVE_UPSTREAM_CACHE_ENTRIES) {
|
||||
const oldest = noEffectiveUpstreamByIdentity.keys().next()
|
||||
if (oldest.done) {
|
||||
break
|
||||
}
|
||||
noEffectiveUpstreamByIdentity.delete(oldest.value)
|
||||
noEffectiveUpstreamWriteGeneration.delete(oldest.value)
|
||||
}
|
||||
trimNoEffectiveUpstreamWriteGeneration()
|
||||
}
|
||||
|
||||
export async function readOrProbeNoEffectiveUpstreamStatus(
|
||||
identity: NoEffectiveUpstreamCacheIdentity,
|
||||
runGit: GitCommandRunner,
|
||||
options: { bypassCache?: boolean } = {}
|
||||
): Promise<GitUpstreamStatus> {
|
||||
const cacheKey = noEffectiveUpstreamCacheKey(identity)
|
||||
if (options.bypassCache !== true) {
|
||||
const cachedStatus = readCachedNoEffectiveUpstreamStatus(cacheKey)
|
||||
if (cachedStatus) {
|
||||
return cachedStatus
|
||||
}
|
||||
|
||||
const inFlight = noEffectiveUpstreamInFlight.get(cacheKey)
|
||||
if (inFlight) {
|
||||
return inFlight
|
||||
}
|
||||
}
|
||||
|
||||
let probedSameNameOriginRef = false
|
||||
const writeGeneration = noEffectiveUpstreamWriteGeneration.get(cacheKey) ?? 0
|
||||
const probe = getEffectiveGitUpstreamStatus((args) => {
|
||||
if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${identity.branchName}`)) {
|
||||
probedSameNameOriginRef = true
|
||||
}
|
||||
return runGit(args)
|
||||
}).then((status) => {
|
||||
cacheNoEffectiveUpstreamStatus(cacheKey, status, probedSameNameOriginRef, writeGeneration)
|
||||
return status
|
||||
})
|
||||
if (options.bypassCache !== true) {
|
||||
noEffectiveUpstreamInFlight.set(cacheKey, probe)
|
||||
}
|
||||
try {
|
||||
return await probe
|
||||
} finally {
|
||||
if (noEffectiveUpstreamInFlight.get(cacheKey) === probe) {
|
||||
noEffectiveUpstreamInFlight.delete(cacheKey)
|
||||
trimNoEffectiveUpstreamWriteGeneration()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function clearNoEffectiveUpstreamStatusCache(): void {
|
||||
noEffectiveUpstreamByIdentity.clear()
|
||||
noEffectiveUpstreamInFlight.clear()
|
||||
retiredNoEffectiveUpstreamInFlight.clear()
|
||||
noEffectiveUpstreamWriteGeneration.clear()
|
||||
}
|
||||
|
||||
export function clearNoEffectiveUpstreamStatusCacheEntry(
|
||||
identity: NoEffectiveUpstreamCacheIdentity
|
||||
): void {
|
||||
const cacheKey = noEffectiveUpstreamCacheKey(identity)
|
||||
retireNoEffectiveUpstreamProbe(cacheKey)
|
||||
noEffectiveUpstreamByIdentity.delete(cacheKey)
|
||||
noEffectiveUpstreamInFlight.delete(cacheKey)
|
||||
noEffectiveUpstreamWriteGeneration.set(
|
||||
cacheKey,
|
||||
(noEffectiveUpstreamWriteGeneration.get(cacheKey) ?? 0) + 1
|
||||
)
|
||||
}
|
||||
|
||||
function retireNoEffectiveUpstreamProbe(cacheKey: string): void {
|
||||
const retiredProbe = noEffectiveUpstreamInFlight.get(cacheKey)
|
||||
if (!retiredProbe) {
|
||||
return
|
||||
}
|
||||
retiredNoEffectiveUpstreamInFlight.set(cacheKey, retiredProbe)
|
||||
void retiredProbe
|
||||
.finally(() => {
|
||||
if (retiredNoEffectiveUpstreamInFlight.get(cacheKey) === retiredProbe) {
|
||||
retiredNoEffectiveUpstreamInFlight.delete(cacheKey)
|
||||
trimNoEffectiveUpstreamWriteGeneration()
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
export function getNoEffectiveUpstreamStatusCacheCountForTests(): number {
|
||||
return noEffectiveUpstreamByIdentity.size
|
||||
}
|
||||
|
||||
export function getNoEffectiveUpstreamStatusGenerationCountForTests(): number {
|
||||
return noEffectiveUpstreamWriteGeneration.size
|
||||
}
|
||||
@@ -1,5 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildCommandLookupSpec, hasAbsoluteCommandPath } from './preflight-handler'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { execFileAsyncMock } = vi.hoisted(() => ({
|
||||
execFileAsyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('child_process', () => {
|
||||
const execFileWithPromisify = Object.assign(vi.fn(), {
|
||||
[Symbol.for('nodejs.util.promisify.custom')]: execFileAsyncMock
|
||||
})
|
||||
return { execFile: execFileWithPromisify }
|
||||
})
|
||||
|
||||
import {
|
||||
buildCommandLookupSpec,
|
||||
buildCommandLookupSpecs,
|
||||
hasAbsoluteCommandPath,
|
||||
isCommandOnPathForRelay
|
||||
} from './preflight-handler'
|
||||
|
||||
function lookupArgs(command: string, mode: '-lc' | '-ilc' = '-lc'): string[] {
|
||||
return [
|
||||
mode,
|
||||
[
|
||||
`if resolved=$(command -v ${command} 2>/dev/null); then`,
|
||||
'printf \'__ORCA_AGENT_PATH__%s\\n\' "$resolved"',
|
||||
'fi'
|
||||
].join('\n')
|
||||
]
|
||||
}
|
||||
|
||||
function fishLookupArgs(command: string): string[] {
|
||||
return [
|
||||
'-ilc',
|
||||
[
|
||||
`set -l resolved (command -v ${command} 2>/dev/null)`,
|
||||
'if test -n "$resolved"',
|
||||
'printf \'__ORCA_AGENT_PATH__%s\\n\' "$resolved"',
|
||||
'end'
|
||||
].join('\n')
|
||||
]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execFileAsyncMock.mockReset()
|
||||
})
|
||||
|
||||
describe('buildCommandLookupSpec', () => {
|
||||
it('uses where.exe on native Windows SSH hosts', () => {
|
||||
@@ -10,15 +54,160 @@ describe('buildCommandLookupSpec', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('passes the command as an argument to the POSIX login-shell probe', () => {
|
||||
expect(buildCommandLookupSpec('codex', 'linux')).toEqual({
|
||||
it('falls back to sh for POSIX probes without a configured shell', () => {
|
||||
expect(buildCommandLookupSpec('codex', 'linux', {}, null)).toEqual({
|
||||
file: '/bin/sh',
|
||||
args: ['-lc', 'command -v "$1"', 'sh', 'codex']
|
||||
args: lookupArgs("'codex'")
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the configured remote shell for POSIX probes', () => {
|
||||
expect(buildCommandLookupSpec('codex', 'linux', { SHELL: '/bin/zsh' }, '/bin/zsh')).toEqual({
|
||||
file: '/bin/zsh',
|
||||
args: lookupArgs("'codex'", '-ilc')
|
||||
})
|
||||
})
|
||||
|
||||
it('quotes command names in shell probes', () => {
|
||||
expect(
|
||||
buildCommandLookupSpec("agent'cli", 'linux', { SHELL: '/bin/bash' }, '/bin/bash')
|
||||
).toEqual({
|
||||
file: '/bin/bash',
|
||||
args: lookupArgs("'agent'\\''cli'", '-ilc')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildCommandLookupSpecs', () => {
|
||||
it('falls back to inherited PATH after a trusted configured POSIX shell', () => {
|
||||
expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/bin/zsh' }, '/bin/zsh')).toEqual([
|
||||
{ file: '/bin/zsh', args: lookupArgs("'codex'", '-ilc') },
|
||||
{ file: '/bin/sh', args: lookupArgs("'codex'") }
|
||||
])
|
||||
})
|
||||
|
||||
it('allows a custom shell path only when the account login shell matches', () => {
|
||||
expect(
|
||||
buildCommandLookupSpecs(
|
||||
'codex',
|
||||
'darwin',
|
||||
{ SHELL: '/opt/homebrew/bin/zsh' },
|
||||
'/opt/homebrew/bin/zsh'
|
||||
)
|
||||
).toEqual([
|
||||
{ file: '/opt/homebrew/bin/zsh', args: lookupArgs("'codex'", '-ilc') },
|
||||
{ file: '/bin/sh', args: lookupArgs("'codex'") }
|
||||
])
|
||||
})
|
||||
|
||||
it('allows conservative system shell paths when account lookup is unavailable', () => {
|
||||
expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/usr/bin/bash' }, null)[0]).toEqual({
|
||||
file: '/usr/bin/bash',
|
||||
args: lookupArgs("'codex'", '-ilc')
|
||||
})
|
||||
})
|
||||
|
||||
it('uses fish syntax for trusted fish shells', () => {
|
||||
expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/usr/bin/fish' }, null)[0]).toEqual({
|
||||
file: '/usr/bin/fish',
|
||||
args: fishLookupArgs("'codex'")
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores untrusted temp shell paths even when the basename is supported', () => {
|
||||
expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/tmp/zsh' }, '/bin/bash')).toEqual([
|
||||
{ file: '/bin/sh', args: lookupArgs("'codex'") }
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores untrusted home-bin shell paths even when the basename is supported', () => {
|
||||
expect(
|
||||
buildCommandLookupSpecs('codex', 'linux', { SHELL: '/home/test/bin/bash' }, '/bin/bash')
|
||||
).toEqual([{ file: '/bin/sh', args: lookupArgs("'codex'") }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isCommandOnPathForRelay', () => {
|
||||
it('falls back to inherited PATH when shell startup returns no absolute command path', async () => {
|
||||
execFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'welcome\ncodex is a function\n' })
|
||||
.mockResolvedValueOnce({ stdout: '__ORCA_AGENT_PATH__/relay/path/codex\n' })
|
||||
|
||||
await expect(
|
||||
isCommandOnPathForRelay('codex', {
|
||||
platform: 'linux',
|
||||
env: { SHELL: '/bin/zsh', PATH: '/usr/bin' },
|
||||
accountLoginShell: '/bin/zsh'
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
expect(execFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/bin/zsh',
|
||||
lookupArgs("'codex'", '-ilc'),
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
env: expect.objectContaining({ SHELL: '/bin/zsh' }),
|
||||
timeout: 5000
|
||||
}
|
||||
)
|
||||
expect(execFileAsyncMock).toHaveBeenNthCalledWith(2, '/bin/sh', lookupArgs("'codex'"), {
|
||||
encoding: 'utf-8',
|
||||
env: expect.objectContaining({ SHELL: '/bin/zsh' }),
|
||||
timeout: 5000
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to inherited PATH when shell startup fails', async () => {
|
||||
execFileAsyncMock
|
||||
.mockRejectedValueOnce(new Error('startup failed'))
|
||||
.mockResolvedValueOnce({ stdout: '__ORCA_AGENT_PATH__/relay/path/codex\n' })
|
||||
|
||||
await expect(
|
||||
isCommandOnPathForRelay('codex', {
|
||||
platform: 'linux',
|
||||
env: { SHELL: '/bin/bash', PATH: '/usr/bin' },
|
||||
accountLoginShell: '/bin/bash'
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
expect(execFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not execute an untrusted configured shell before inherited PATH lookup', async () => {
|
||||
execFileAsyncMock.mockResolvedValueOnce({ stdout: '__ORCA_AGENT_PATH__/relay/path/codex\n' })
|
||||
|
||||
await expect(
|
||||
isCommandOnPathForRelay('codex', {
|
||||
platform: 'linux',
|
||||
env: { SHELL: '/tmp/zsh', PATH: '/usr/bin' },
|
||||
accountLoginShell: '/bin/bash'
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
expect(execFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
expect(execFileAsyncMock).toHaveBeenCalledWith('/bin/sh', lookupArgs("'codex'"), {
|
||||
encoding: 'utf-8',
|
||||
env: expect.objectContaining({ SHELL: '/tmp/zsh' }),
|
||||
timeout: 5000
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasAbsoluteCommandPath', () => {
|
||||
it('ignores banners and shell function output', () => {
|
||||
expect(hasAbsoluteCommandPath('/tmp/not-the-agent\ncodex is a shell function\n', 'linux')).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores unmarked POSIX absolute paths from shell startup output', () => {
|
||||
expect(hasAbsoluteCommandPath('/tmp/not-the-agent\n', 'linux')).toBe(false)
|
||||
})
|
||||
|
||||
it('recognizes a sentinel-marked command path amid shell startup and exit output', () => {
|
||||
expect(
|
||||
hasAbsoluteCommandPath('welcome\n__ORCA_AGENT_PATH__/opt/bin/codex\nlogout-banner\n', 'linux')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes Windows absolute command paths', () => {
|
||||
expect(
|
||||
hasAbsoluteCommandPath('C:\\Users\\alice\\AppData\\Roaming\\npm\\codex.cmd\r\n', 'win32')
|
||||
|
||||
+155
-21
@@ -1,4 +1,5 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { userInfo } from 'os'
|
||||
import { promisify } from 'util'
|
||||
import path, { win32 } from 'path'
|
||||
import type { RelayDispatcher } from './dispatcher'
|
||||
@@ -12,6 +13,16 @@ type CommandLookupSpec = {
|
||||
windowsHide?: true
|
||||
}
|
||||
|
||||
type RelayCommandLookupOptions = {
|
||||
platform?: NodeJS.Platform
|
||||
env?: NodeJS.ProcessEnv
|
||||
accountLoginShell?: string | null
|
||||
}
|
||||
|
||||
const SUPPORTED_POSIX_SHELLS = new Set(['sh', 'dash', 'bash', 'zsh', 'fish'])
|
||||
const CONSERVATIVE_SYSTEM_SHELL_DIRS = new Set(['/bin', '/usr/bin'])
|
||||
const AGENT_PATH_PREFIX = '__ORCA_AGENT_PATH__'
|
||||
|
||||
export class PreflightHandler {
|
||||
private dispatcher: RelayDispatcher
|
||||
|
||||
@@ -43,35 +54,77 @@ export class PreflightHandler {
|
||||
return { agents: [...new Set(results.filter((r) => r.installed).map((r) => r.id))] }
|
||||
}
|
||||
|
||||
// Why: SSH exec channels give the relay a minimal environment without
|
||||
// .zprofile/.bash_profile sourced. Running `which` directly would miss
|
||||
// agents installed via Homebrew, nvm, cargo, pipx, etc. Spawning a login
|
||||
// shell (`-lc`) ensures PATH matches what the user's PTY sessions see.
|
||||
// Windows has no /bin/sh on native OpenSSH hosts, so use where.exe there.
|
||||
// Why: SSH exec channels give the relay a minimal environment without shell
|
||||
// startup files sourced. Ask the user's configured shell so agent dirs added
|
||||
// by zsh/bash/fish startup hooks match the remote terminal experience.
|
||||
// Windows has no POSIX shell on native OpenSSH hosts, so use where.exe there.
|
||||
private async isCommandOnPath(command: string): Promise<boolean> {
|
||||
try {
|
||||
const spec = buildCommandLookupSpec(command, process.platform)
|
||||
const { stdout } = await execFileAsync(spec.file, spec.args, {
|
||||
encoding: 'utf-8',
|
||||
env: buildRelayCommandEnv(),
|
||||
timeout: 5000,
|
||||
...(spec.windowsHide ? { windowsHide: true } : {})
|
||||
})
|
||||
return hasAbsoluteCommandPath(stdout, process.platform)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return isCommandOnPathForRelay(command)
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCommandLookupSpec(
|
||||
command: string,
|
||||
platform: NodeJS.Platform
|
||||
platform: NodeJS.Platform,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountLoginShell?: string | null
|
||||
): CommandLookupSpec {
|
||||
const [spec] = buildCommandLookupSpecs(command, platform, env, accountLoginShell)
|
||||
return spec ?? buildPosixCommandLookupSpec(command, '/bin/sh')
|
||||
}
|
||||
|
||||
export function buildCommandLookupSpecs(
|
||||
command: string,
|
||||
platform: NodeJS.Platform,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountLoginShell?: string | null
|
||||
): CommandLookupSpec[] {
|
||||
if (platform === 'win32') {
|
||||
return { file: 'where.exe', args: [command], windowsHide: true }
|
||||
return [{ file: 'where.exe', args: [command], windowsHide: true }]
|
||||
}
|
||||
return { file: '/bin/sh', args: ['-lc', 'command -v "$1"', 'sh', command] }
|
||||
const trustedShell = pickTrustedPosixShell(
|
||||
env,
|
||||
resolveAccountLoginShell(platform, accountLoginShell)
|
||||
)
|
||||
const specs: CommandLookupSpec[] = []
|
||||
|
||||
if (trustedShell) {
|
||||
specs.push(buildPosixCommandLookupSpec(command, trustedShell))
|
||||
}
|
||||
|
||||
const inheritedPathSpec = buildPosixCommandLookupSpec(command, '/bin/sh')
|
||||
if (!trustedShell || trustedShell !== inheritedPathSpec.file) {
|
||||
specs.push(inheritedPathSpec)
|
||||
}
|
||||
|
||||
return specs
|
||||
}
|
||||
|
||||
export async function isCommandOnPathForRelay(
|
||||
command: string,
|
||||
options: RelayCommandLookupOptions = {}
|
||||
): Promise<boolean> {
|
||||
const platform = options.platform ?? process.platform
|
||||
const env = options.env ?? process.env
|
||||
const specs = buildCommandLookupSpecs(command, platform, env, options.accountLoginShell)
|
||||
|
||||
for (const spec of specs) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(spec.file, spec.args, {
|
||||
encoding: 'utf-8',
|
||||
env: buildRelayCommandEnv(env, platform),
|
||||
timeout: 5000,
|
||||
...(spec.windowsHide ? { windowsHide: true } : {})
|
||||
})
|
||||
if (hasAbsoluteCommandPath(stdout, platform)) {
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// Try the inherited-PATH fallback before reporting the agent missing.
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function hasAbsoluteCommandPath(output: string, platform: NodeJS.Platform): boolean {
|
||||
@@ -79,5 +132,86 @@ export function hasAbsoluteCommandPath(output: string, platform: NodeJS.Platform
|
||||
return output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.some((line) => pathOps.isAbsolute(line))
|
||||
.some((line) => {
|
||||
const resolvedPath =
|
||||
platform === 'win32'
|
||||
? line
|
||||
: line.startsWith(AGENT_PATH_PREFIX)
|
||||
? line.slice(AGENT_PATH_PREFIX.length)
|
||||
: ''
|
||||
return pathOps.isAbsolute(resolvedPath)
|
||||
})
|
||||
}
|
||||
|
||||
function buildPosixCommandLookupSpec(command: string, shell: string): CommandLookupSpec {
|
||||
const shellName = path.posix.basename(shell).toLowerCase()
|
||||
if (shellName === 'fish') {
|
||||
return { file: shell, args: ['-ilc', buildFishCommandLookupScript(command)] }
|
||||
}
|
||||
return { file: shell, args: [getShellCommandMode(shell), buildShCommandLookupScript(command)] }
|
||||
}
|
||||
|
||||
function buildShCommandLookupScript(command: string): string {
|
||||
const quotedCommand = shellQuote(command)
|
||||
return [
|
||||
`if resolved=$(command -v ${quotedCommand} 2>/dev/null); then`,
|
||||
`printf '${AGENT_PATH_PREFIX}%s\\n' "$resolved"`,
|
||||
'fi'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function buildFishCommandLookupScript(command: string): string {
|
||||
const quotedCommand = shellQuote(command)
|
||||
return [
|
||||
`set -l resolved (command -v ${quotedCommand} 2>/dev/null)`,
|
||||
'if test -n "$resolved"',
|
||||
`printf '${AGENT_PATH_PREFIX}%s\\n' "$resolved"`,
|
||||
'end'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function resolveAccountLoginShell(
|
||||
platform: NodeJS.Platform,
|
||||
accountLoginShell?: string | null
|
||||
): string | null {
|
||||
if (accountLoginShell !== undefined) {
|
||||
return accountLoginShell
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return userInfo().shell ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function pickTrustedPosixShell(
|
||||
env: NodeJS.ProcessEnv,
|
||||
accountLoginShell: string | null
|
||||
): string | null {
|
||||
const shell = env.SHELL
|
||||
if (!shell || !path.posix.isAbsolute(shell)) {
|
||||
return null
|
||||
}
|
||||
const shellName = path.posix.basename(shell).toLowerCase()
|
||||
if (!SUPPORTED_POSIX_SHELLS.has(shellName)) {
|
||||
return null
|
||||
}
|
||||
if (accountLoginShell) {
|
||||
return shell === accountLoginShell ? shell : null
|
||||
}
|
||||
return CONSERVATIVE_SYSTEM_SHELL_DIRS.has(path.posix.dirname(shell)) ? shell : null
|
||||
}
|
||||
|
||||
function getShellCommandMode(shell: string): '-lc' | '-ilc' {
|
||||
const shellName = path.posix.basename(shell).toLowerCase()
|
||||
// Why: bash/zsh/fish users commonly add package-manager bins from interactive
|
||||
// startup files. POSIX sh/dash may not support interactive login flags.
|
||||
return shellName === 'sh' || shellName === 'dash' ? '-lc' : '-ilc'
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`
|
||||
}
|
||||
|
||||
@@ -61,7 +61,10 @@ import { onOnboardingReopened } from './components/onboarding/show-onboarding-ev
|
||||
import { shouldShowOnboarding } from './components/onboarding/should-show-onboarding'
|
||||
import { MarkdownTemplatePicker } from './components/editor/MarkdownTemplatePicker'
|
||||
import { FloatingTerminalToggleButton } from './components/floating-terminal/FloatingTerminalToggleButton'
|
||||
import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal'
|
||||
import {
|
||||
TOGGLE_FLOATING_TERMINAL_EVENT,
|
||||
requestFloatingTerminalOpenMaximized
|
||||
} from '@/lib/floating-terminal'
|
||||
import {
|
||||
isFloatingWorkspacePanelFocused,
|
||||
isFloatingWorkspacePanelShortcut,
|
||||
@@ -1395,6 +1398,7 @@ function App(): React.JSX.Element {
|
||||
activeView,
|
||||
activeWorktreeId,
|
||||
actions,
|
||||
floatingTerminalEnabled,
|
||||
floatingTerminalOpen,
|
||||
floatingVisibleTabCount,
|
||||
keybindings,
|
||||
@@ -1409,6 +1413,7 @@ function App(): React.JSX.Element {
|
||||
activeView,
|
||||
activeWorktreeId,
|
||||
actions,
|
||||
floatingTerminalEnabled,
|
||||
floatingTerminalOpen,
|
||||
floatingVisibleTabCount,
|
||||
keybindings,
|
||||
@@ -1426,6 +1431,7 @@ function App(): React.JSX.Element {
|
||||
activeView,
|
||||
activeWorktreeId,
|
||||
actions,
|
||||
floatingTerminalEnabled,
|
||||
floatingTerminalOpen,
|
||||
floatingVisibleTabCount,
|
||||
keybindings,
|
||||
@@ -1521,6 +1527,22 @@ function App(): React.JSX.Element {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: when the floating workspace is closed, its own keydown handler is
|
||||
// unmounted and cannot claim Cmd+Opt+Shift+A. Honor the maximize chord
|
||||
// here by opening the panel with a one-shot intent so it mounts straight
|
||||
// into the maximized state. While the panel is open, this is a no-op: the
|
||||
// panel's handler owns the maximize/restore toggle.
|
||||
if (
|
||||
!floatingTerminalOpen &&
|
||||
matchShortcut('floatingWorkspace.maximize') &&
|
||||
floatingTerminalEnabled
|
||||
) {
|
||||
input.preventDefault()
|
||||
requestFloatingTerminalOpenMaximized()
|
||||
setFloatingTerminalOpenWithFocus(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Why: keep this guard. TipTap's Cmd+B bold binding depends on the
|
||||
// window-level handler *not* toggling the sidebar when focus lives in an
|
||||
// editable surface. The main-process before-input-event already carves out
|
||||
@@ -1582,6 +1604,21 @@ function App(): React.JSX.Element {
|
||||
return
|
||||
}
|
||||
|
||||
// Toggle the "show sleeping workspaces" sidebar filter without opening the
|
||||
// filters menu (issue #5209). When revealing them, open the left sidebar
|
||||
// so the now-visible sleeping worktrees are actually reachable.
|
||||
if (matchShortcut('sidebar.sleepingWorkspaces.toggle')) {
|
||||
input.preventDefault()
|
||||
notifyTerminalCapture('sidebar.sleepingWorkspaces.toggle')
|
||||
const store = useAppStore.getState()
|
||||
const nextShowSleeping = !store.showSleepingWorkspaces
|
||||
store.setShowSleepingWorkspaces(nextShowSleeping)
|
||||
if (nextShowSleeping) {
|
||||
store.setSidebarOpen(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Why: rename the active terminal tab. Cmd+R is free in the app/terminal
|
||||
// focus zone because the browser pane owns its own Cmd+R reload and that
|
||||
// focus never reaches this renderer-window handler. Only terminal tabs
|
||||
|
||||
@@ -557,6 +557,15 @@
|
||||
height: 116px;
|
||||
}
|
||||
|
||||
.mobile-page-root .mp-qr-large {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.mobile-page-root .mp-qr-large img {
|
||||
width: 164px;
|
||||
height: 164px;
|
||||
}
|
||||
|
||||
.mobile-page-root .mp-qr-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -704,8 +713,9 @@
|
||||
}
|
||||
|
||||
.mobile-page-root .mp-network-select {
|
||||
min-width: 220px;
|
||||
max-width: 280px;
|
||||
flex: 1 1 180px;
|
||||
min-width: 0;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.mobile-page-root .mp-network-refresh {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -95,6 +95,10 @@ type NewWorkspaceComposerCardProps = {
|
||||
canReuseSelectedBranch: boolean
|
||||
reuseSelectedBranch: boolean
|
||||
onReuseSelectedBranchChange: (next: boolean) => void
|
||||
/** Shows the footer "Create more" switch — worktree targets only. */
|
||||
showCreateMultiple?: boolean
|
||||
createMultiple?: boolean
|
||||
onCreateMultipleChange?: (next: boolean) => void
|
||||
smartNameGitHubSourceContext?: TaskSourceContext | null
|
||||
/** Advisory shown under the name field when a fork PR can't accept maintainer pushes. */
|
||||
forkPushWarning: string | null
|
||||
@@ -331,6 +335,9 @@ export default function NewWorkspaceComposerCard({
|
||||
canReuseSelectedBranch,
|
||||
reuseSelectedBranch,
|
||||
onReuseSelectedBranchChange,
|
||||
showCreateMultiple = false,
|
||||
createMultiple = false,
|
||||
onCreateMultipleChange,
|
||||
smartNameGitHubSourceContext,
|
||||
forkPushWarning,
|
||||
detectedAgentIds,
|
||||
@@ -810,7 +817,10 @@ export default function NewWorkspaceComposerCard({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{/* Why: Advanced is a disclosure header, so keep it visually grouped
|
||||
with the content or footer below it while preserving normal spacing
|
||||
from the Agent field above. */}
|
||||
<div className="!mb-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -828,6 +838,7 @@ export default function NewWorkspaceComposerCard({
|
||||
<div
|
||||
className={cn(
|
||||
'grid overflow-hidden transition-[grid-template-rows] duration-200 ease-out',
|
||||
!advancedOpen && '!mt-2',
|
||||
advancedOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
|
||||
)}
|
||||
aria-hidden={!advancedOpen}
|
||||
@@ -1043,7 +1054,39 @@ export default function NewWorkspaceComposerCard({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3',
|
||||
showCreateMultiple ? 'justify-between' : 'justify-end'
|
||||
)}
|
||||
>
|
||||
{showCreateMultiple ? (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={createMultiple}
|
||||
onClick={() => onCreateMultipleChange?.(!createMultiple)}
|
||||
className="group flex w-fit cursor-pointer items-center gap-2 rounded-md text-xs text-foreground outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent transition-colors',
|
||||
createMultiple ? 'bg-foreground' : 'bg-muted-foreground/30'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform',
|
||||
createMultiple ? 'translate-x-4' : 'translate-x-0.5'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
{translate('auto.components.NewWorkspaceComposerCard.createMultiple', 'Create more')}
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
<Button
|
||||
onClick={() => void onCreate()}
|
||||
disabled={createDisabled}
|
||||
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
RefreshCw,
|
||||
Send,
|
||||
UndoDot,
|
||||
Users,
|
||||
Wrench,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
@@ -58,7 +57,7 @@ import {
|
||||
} from '@/components/ui/accordion'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -155,6 +154,7 @@ import {
|
||||
filterGitHubPRReviewerCandidates,
|
||||
getGitHubPRReviewerQueryState
|
||||
} from '@/components/github/github-pr-reviewer-candidate-filter'
|
||||
import { githubAvatarUrl } from '@/components/github/github-issue-comment-helpers'
|
||||
import { filterGitHubMentionOptions } from '@/components/github/github-mention-option-filter'
|
||||
import {
|
||||
getCommentBodySubmitState,
|
||||
@@ -448,25 +448,15 @@ function ReviewerAvatar({
|
||||
login: string
|
||||
avatarUrl: string
|
||||
}): React.JSX.Element {
|
||||
if (avatarUrl) {
|
||||
return (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
title={login}
|
||||
className="size-6 shrink-0 rounded-full border border-border/50 bg-muted object-cover"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span
|
||||
<img
|
||||
src={avatarUrl || githubAvatarUrl(login)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
title={login}
|
||||
className="inline-flex size-6 shrink-0 items-center justify-center rounded-full border border-border/50 bg-muted text-[10px] font-medium text-muted-foreground"
|
||||
>
|
||||
{login.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
className="size-6 shrink-0 rounded-full border border-border/50 bg-muted object-cover"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -509,6 +499,222 @@ function buildRequestedReviewUsers(
|
||||
return Array.from(byLogin.values())
|
||||
}
|
||||
|
||||
function PRAssigneesPanel({
|
||||
item,
|
||||
repoPath,
|
||||
projectOrigin,
|
||||
onMutated
|
||||
}: {
|
||||
item: GitHubWorkItem
|
||||
repoPath: string | null
|
||||
projectOrigin: PullRequestPageProjectOrigin | undefined
|
||||
onMutated: () => void
|
||||
}): React.JSX.Element {
|
||||
const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false)
|
||||
const [localAssignees, setLocalAssignees] = useState<GitHubAssignableUser[]>(
|
||||
() => item.assignees ?? []
|
||||
)
|
||||
const [assigneesSource, setAssigneesSource] = useState(() => ({
|
||||
itemId: item.id,
|
||||
repoId: item.repoId,
|
||||
assignees: item.assignees
|
||||
}))
|
||||
const patchWorkItem = useAppStore((s) => s.patchWorkItem)
|
||||
const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent)
|
||||
const repoOwnerSettings = useAppStore(
|
||||
useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null))
|
||||
)
|
||||
const { isPending, run } = useImmediateMutation()
|
||||
|
||||
// Why: PR assignees can change through background refetches; sync them
|
||||
// before paint so the right rail never shows a stale reviewer/assignee split.
|
||||
if (
|
||||
assigneesSource.itemId !== item.id ||
|
||||
assigneesSource.repoId !== item.repoId ||
|
||||
assigneesSource.assignees !== item.assignees
|
||||
) {
|
||||
setAssigneesSource({ itemId: item.id, repoId: item.repoId, assignees: item.assignees })
|
||||
setLocalAssignees(item.assignees ?? [])
|
||||
}
|
||||
|
||||
const patchProjectRowIfNeeded = useCallback(
|
||||
(assignees: string[]) => {
|
||||
if (!projectOrigin) {
|
||||
return
|
||||
}
|
||||
patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, { assignees })
|
||||
},
|
||||
[patchProjectRowContent, projectOrigin]
|
||||
)
|
||||
const assigneeLogins = useMemo(() => localAssignees.map((user) => user.login), [localAssignees])
|
||||
const assigneeSlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url])
|
||||
const slugOwner = projectOrigin?.owner ?? assigneeSlug?.owner ?? null
|
||||
const slugRepo = projectOrigin?.repo ?? assigneeSlug?.repo ?? null
|
||||
const repoAssigneesBySlug = useRepoAssigneesBySlug(
|
||||
slugOwner,
|
||||
slugRepo,
|
||||
assigneeLogins,
|
||||
repoOwnerSettings
|
||||
)
|
||||
const repoAssigneesByPath = useRepoAssignees(repoPath, item.repoId, repoOwnerSettings)
|
||||
const repoAssignees = slugOwner && slugRepo ? repoAssigneesBySlug : repoAssigneesByPath
|
||||
const canEditAssignees = Boolean(projectOrigin || repoPath)
|
||||
const assigneesByLogin = useMemo(
|
||||
() => new Map(repoAssignees.data.map((user) => [user.login.toLowerCase(), user])),
|
||||
[repoAssignees.data]
|
||||
)
|
||||
|
||||
const handleAssigneeToggle = useCallback(
|
||||
(login: string) => {
|
||||
const lowerLogin = login.toLowerCase()
|
||||
const isAssigned = localAssignees.some((user) => user.login.toLowerCase() === lowerLogin)
|
||||
const prevAssignees = localAssignees
|
||||
const candidate = assigneesByLogin.get(lowerLogin) ?? { login, name: null, avatarUrl: '' }
|
||||
const nextAssignees = isAssigned
|
||||
? prevAssignees.filter((user) => user.login.toLowerCase() !== lowerLogin)
|
||||
: [...prevAssignees, candidate]
|
||||
const nextLogins = nextAssignees.map((user) => user.login)
|
||||
const prevLogins = prevAssignees.map((user) => user.login)
|
||||
|
||||
run('assignees', {
|
||||
mutate: () =>
|
||||
runIssueUpdate({
|
||||
repoId: item.repoId,
|
||||
repoPath,
|
||||
projectOrigin,
|
||||
number: item.number,
|
||||
updates: isAssigned ? { removeAssignees: [login] } : { addAssignees: [login] }
|
||||
}),
|
||||
onOptimistic: () => {
|
||||
setLocalAssignees(nextAssignees)
|
||||
patchWorkItem(item.id, { assignees: nextAssignees }, item.repoId)
|
||||
patchProjectRowIfNeeded(nextLogins)
|
||||
},
|
||||
onRevert: () => {
|
||||
setLocalAssignees(prevAssignees)
|
||||
patchWorkItem(item.id, { assignees: prevAssignees }, item.repoId)
|
||||
patchProjectRowIfNeeded(prevLogins)
|
||||
},
|
||||
onSuccess: () => {
|
||||
useAppStore.getState().recordFeatureInteraction('github-tasks')
|
||||
onMutated()
|
||||
},
|
||||
onError: (err) => toast.error(err)
|
||||
})
|
||||
},
|
||||
[
|
||||
assigneesByLogin,
|
||||
item.id,
|
||||
item.number,
|
||||
item.repoId,
|
||||
localAssignees,
|
||||
onMutated,
|
||||
patchProjectRowIfNeeded,
|
||||
patchWorkItem,
|
||||
projectOrigin,
|
||||
repoPath,
|
||||
run
|
||||
]
|
||||
)
|
||||
|
||||
const checkIcon = (
|
||||
<svg className="size-2.5" viewBox="0 0 12 12" fill="none">
|
||||
<path
|
||||
d="M2 6l3 3 5-5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-2 flex items-center justify-between text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
<span>{translate('auto.components.PullRequestPage.8ff5ae8866', 'Assignees')}</span>
|
||||
<Popover open={assigneePopoverOpen} onOpenChange={setAssigneePopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canEditAssignees || isPending('assignees') || repoAssignees.loading}
|
||||
aria-label={translate('auto.components.PullRequestPage.82c87eceb9', 'Edit assignees')}
|
||||
className="rounded p-0.5 text-muted-foreground transition hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
{isPending('assignees') ? (
|
||||
<LoaderCircle className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Pencil className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="popover-scroll-content scrollbar-sleek w-60 p-1" align="end">
|
||||
{repoAssignees.error ? (
|
||||
<div className="px-2 py-3 text-center text-[12px] text-destructive">
|
||||
{repoAssignees.error}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{repoAssignees.data.map((user) => {
|
||||
const selected = localAssignees.some(
|
||||
(assignee) => assignee.login.toLowerCase() === user.login.toLowerCase()
|
||||
)
|
||||
return (
|
||||
<button
|
||||
key={user.login}
|
||||
type="button"
|
||||
onClick={() => handleAssigneeToggle(user.login)}
|
||||
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-3.5 items-center justify-center rounded-sm border',
|
||||
selected
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-input'
|
||||
)}
|
||||
>
|
||||
{selected && checkIcon}
|
||||
</span>
|
||||
{user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt="" className="size-5 rounded-full" />
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 text-left">
|
||||
<span className="block truncate">{user.login}</span>
|
||||
{user.name ? (
|
||||
<span className="block truncate text-[11px] text-muted-foreground">
|
||||
{user.name}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
{localAssignees.length === 0 ? (
|
||||
<div className="text-[12px] text-muted-foreground">
|
||||
{translate('auto.components.PullRequestPage.1ff5d979df', 'No one assigned')}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{localAssignees.map((assignee) => (
|
||||
<li key={assignee.login} className="flex min-w-0 items-center gap-2">
|
||||
<ReviewerAvatar login={assignee.login} avatarUrl={assignee.avatarUrl} />
|
||||
<span className="min-w-0 truncate text-[13px] font-medium text-foreground">
|
||||
{assignee.login}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function PRReviewersPanel({
|
||||
item,
|
||||
loading,
|
||||
@@ -522,8 +728,6 @@ function PRReviewersPanel({
|
||||
}): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [reviewerInput, setReviewerInput] = useState('')
|
||||
const [reviewerPickerSide, setReviewerPickerSide] = useState<'top' | 'bottom'>('bottom')
|
||||
const [reviewerPickerMaxHeight, setReviewerPickerMaxHeight] = useState<number | null>(null)
|
||||
const [activeReviewerCursor, setActiveReviewerCursor] = useState({
|
||||
resetKey: '',
|
||||
index: 0
|
||||
@@ -713,26 +917,6 @@ function PRReviewersPanel({
|
||||
const canRequestReview =
|
||||
!!repoPath || getActiveRuntimeTarget(repoOwnerSettings).kind === 'environment'
|
||||
|
||||
const measureReviewerPickerPlacement = useCallback(() => {
|
||||
const rect = reviewerInputRef.current?.getBoundingClientRect()
|
||||
if (!rect) {
|
||||
setReviewerPickerSide('bottom')
|
||||
setReviewerPickerMaxHeight(null)
|
||||
return
|
||||
}
|
||||
|
||||
const gap = 8
|
||||
const minUsefulHeight = 180
|
||||
const availableBelow = window.innerHeight - rect.bottom - gap
|
||||
const availableAbove = rect.top - gap
|
||||
const nextSide =
|
||||
availableBelow < minUsefulHeight && availableAbove > availableBelow ? 'top' : 'bottom'
|
||||
const available = nextSide === 'top' ? availableAbove : availableBelow
|
||||
|
||||
setReviewerPickerSide(nextSide)
|
||||
setReviewerPickerMaxHeight(Math.max(120, Math.min(330, available)))
|
||||
}, [])
|
||||
|
||||
const handleRequestReview = async (requestedLogins?: string[]): Promise<void> => {
|
||||
if (submitting) {
|
||||
return
|
||||
@@ -898,9 +1082,6 @@ function PRReviewersPanel({
|
||||
}
|
||||
|
||||
const handleReviewerPickerOpenChange = (nextOpen: boolean): void => {
|
||||
if (nextOpen) {
|
||||
measureReviewerPickerPlacement()
|
||||
}
|
||||
setOpen(nextOpen)
|
||||
if (nextOpen) {
|
||||
scheduleReviewerInputFocus()
|
||||
@@ -978,162 +1159,80 @@ function PRReviewersPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="rounded-lg border border-border/50 bg-card shadow-xs">
|
||||
<div className="flex h-10 items-center gap-2 border-b border-border/50 px-3">
|
||||
<Users className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-[13px] font-medium text-foreground">
|
||||
{translate('auto.components.PullRequestPage.00d3be6bcd', 'Reviewers')}
|
||||
</span>
|
||||
{reviewers.length > 0 ? (
|
||||
<span className="ml-auto rounded-full border border-border/50 bg-muted/30 px-1.5 py-0.5 text-[11px] tabular-nums text-muted-foreground">
|
||||
{reviewers.length}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="px-3 py-2.5">
|
||||
{loading && !hasReviewerMetadata ? (
|
||||
<div className="flex items-center gap-2 py-1 text-[12px] text-muted-foreground">
|
||||
<LoaderCircle className="size-3.5 animate-spin" />
|
||||
{translate('auto.components.PullRequestPage.acbd110867', 'Loading reviewers')}
|
||||
</div>
|
||||
) : reviewers.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{reviewers.map((reviewer) => {
|
||||
const canRemoveReviewer = selectedReviewerLogins.has(reviewer.login.toLowerCase())
|
||||
return (
|
||||
<div key={reviewer.login} className="flex min-w-0 items-center gap-2">
|
||||
<ReviewerAvatar login={reviewer.login} avatarUrl={reviewer.avatarUrl} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-medium text-foreground">
|
||||
{reviewer.login}
|
||||
</div>
|
||||
{reviewer.name ? (
|
||||
<div className="truncate text-[11px] text-muted-foreground">
|
||||
{reviewer.name}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||
{reviewer.stateLabel}
|
||||
</span>
|
||||
{canRemoveReviewer ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="size-6 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
disabled={submitting || !canRequestReview}
|
||||
aria-label={translate(
|
||||
'auto.components.PullRequestPage.ae9a38fd4a',
|
||||
'Remove reviewer {{value0}}',
|
||||
{ value0: reviewer.login }
|
||||
)}
|
||||
onClick={() => {
|
||||
void handleRemoveReviewers([reviewer.login])
|
||||
}}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{translate('auto.components.PullRequestPage.7f964a365a', 'Remove reviewer')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-1 text-[12px] text-muted-foreground">
|
||||
{translate('auto.components.PullRequestPage.d10b6d5209', 'No reviewers requested.')}
|
||||
</div>
|
||||
)}
|
||||
<section>
|
||||
<div className="mb-2 flex items-center justify-between text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
<span>{translate('auto.components.PullRequestPage.00d3be6bcd', 'Reviewers')}</span>
|
||||
<Popover open={open} onOpenChange={handleReviewerPickerOpenChange}>
|
||||
<PopoverAnchor asChild>
|
||||
<Input
|
||||
ref={reviewerInputRef}
|
||||
value={reviewerInput}
|
||||
onChange={(event) => {
|
||||
setReviewerInput(event.target.value)
|
||||
if (!open) {
|
||||
handleReviewerPickerOpenChange(true)
|
||||
}
|
||||
}}
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting || !canRequestReview}
|
||||
placeholder={translate(
|
||||
'auto.components.PullRequestPage.3bde131f49',
|
||||
'Type or choose a user'
|
||||
)}
|
||||
aria-label={translate('auto.components.PullRequestPage.a04c137bb7', 'Reviewer')}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
className="mt-3 h-8 min-w-0 cursor-text rounded-md border-border/50 bg-background text-xs"
|
||||
onFocus={() => {
|
||||
if (canRequestReview) {
|
||||
handleReviewerPickerOpenChange(true)
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
if (canRequestReview) {
|
||||
handleReviewerPickerOpenChange(true)
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowDown' && actionableReviewerRows.length > 0) {
|
||||
event.preventDefault()
|
||||
setOpen(true)
|
||||
setActiveReviewerIndex((current) => (current + 1) % actionableReviewerRows.length)
|
||||
return
|
||||
}
|
||||
if (event.key === 'ArrowUp' && actionableReviewerRows.length > 0) {
|
||||
event.preventDefault()
|
||||
setOpen(true)
|
||||
setActiveReviewerIndex(
|
||||
(current) =>
|
||||
(current - 1 + actionableReviewerRows.length) % actionableReviewerRows.length
|
||||
)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
const activeReviewer = actionableReviewerRows[activeReviewerIndex]
|
||||
if (activeReviewer) {
|
||||
void requestReviewer(activeReviewer)
|
||||
return
|
||||
}
|
||||
void handleRequestReview()
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
handleReviewerPickerOpenChange(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
className="rounded p-0.5 text-muted-foreground transition hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
{submitting ? (
|
||||
<LoaderCircle className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Pencil className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="flex w-[330px] flex-col overflow-hidden rounded-md border-border/70 p-0"
|
||||
align="start"
|
||||
side={reviewerPickerSide}
|
||||
className="flex max-h-[420px] w-[330px] flex-col overflow-hidden rounded-md border-border/70 p-0"
|
||||
align="end"
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
avoidCollisions={false}
|
||||
style={{
|
||||
maxHeight: reviewerPickerMaxHeight ? `${reviewerPickerMaxHeight}px` : undefined
|
||||
}}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<div className="border-b border-border/70 px-3 py-2">
|
||||
<div className="text-[13px] font-semibold text-foreground">
|
||||
{translate(
|
||||
'auto.components.PullRequestPage.805cb72cd4',
|
||||
'Request up to 15 reviewers'
|
||||
<div className="border-b border-border/70 p-2">
|
||||
<Input
|
||||
ref={reviewerInputRef}
|
||||
value={reviewerInput}
|
||||
onChange={(event) => setReviewerInput(event.target.value)}
|
||||
disabled={submitting || !canRequestReview}
|
||||
placeholder={translate(
|
||||
'auto.components.PullRequestPage.3bde131f49',
|
||||
'Type or choose a user'
|
||||
)}
|
||||
</div>
|
||||
aria-label={translate('auto.components.PullRequestPage.a04c137bb7', 'Reviewer')}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
className="h-8 min-w-0 cursor-text rounded-md border-border/50 bg-background text-xs"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowDown' && actionableReviewerRows.length > 0) {
|
||||
event.preventDefault()
|
||||
setActiveReviewerIndex(
|
||||
(current) => (current + 1) % actionableReviewerRows.length
|
||||
)
|
||||
return
|
||||
}
|
||||
if (event.key === 'ArrowUp' && actionableReviewerRows.length > 0) {
|
||||
event.preventDefault()
|
||||
setActiveReviewerIndex(
|
||||
(current) =>
|
||||
(current - 1 + actionableReviewerRows.length) %
|
||||
actionableReviewerRows.length
|
||||
)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
const activeReviewer = actionableReviewerRows[activeReviewerIndex]
|
||||
if (activeReviewer) {
|
||||
void requestReviewer(activeReviewer)
|
||||
return
|
||||
}
|
||||
void handleRequestReview()
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
handleReviewerPickerOpenChange(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek">
|
||||
{reviewerMetadata.loading ? (
|
||||
@@ -1192,7 +1291,67 @@ function PRReviewersPanel({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</aside>
|
||||
{loading && !hasReviewerMetadata ? (
|
||||
<div className="flex items-center gap-2 py-1 text-[12px] text-muted-foreground">
|
||||
<LoaderCircle className="size-3.5 animate-spin" />
|
||||
{translate('auto.components.PullRequestPage.acbd110867', 'Loading reviewers')}
|
||||
</div>
|
||||
) : reviewers.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{reviewers.map((reviewer) => {
|
||||
const canRemoveReviewer = selectedReviewerLogins.has(reviewer.login.toLowerCase())
|
||||
return (
|
||||
<div key={reviewer.login} className="flex min-w-0 items-center gap-2">
|
||||
<ReviewerAvatar login={reviewer.login} avatarUrl={reviewer.avatarUrl} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-medium text-foreground">
|
||||
{reviewer.login}
|
||||
</div>
|
||||
{reviewer.name ? (
|
||||
<div className="truncate text-[11px] text-muted-foreground">
|
||||
{reviewer.name}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||
{reviewer.stateLabel}
|
||||
</span>
|
||||
{canRemoveReviewer ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="size-6 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
disabled={submitting || !canRequestReview}
|
||||
aria-label={translate(
|
||||
'auto.components.PullRequestPage.ae9a38fd4a',
|
||||
'Remove reviewer {{value0}}',
|
||||
{ value0: reviewer.login }
|
||||
)}
|
||||
onClick={() => {
|
||||
void handleRemoveReviewers([reviewer.login])
|
||||
}}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{translate('auto.components.PullRequestPage.7f964a365a', 'Remove reviewer')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-1 text-[12px] text-muted-foreground">
|
||||
{translate('auto.components.PullRequestPage.d10b6d5209', 'No reviewers requested.')}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2968,7 +3127,7 @@ function ConversationTab({
|
||||
|
||||
const rightPanel =
|
||||
item.type === 'pr' ? (
|
||||
<div className="flex h-fit flex-col gap-3 xl:sticky xl:top-4">
|
||||
<div className="flex h-fit flex-col gap-5 xl:sticky xl:top-4">
|
||||
<PRActionsPanel
|
||||
item={item}
|
||||
repoPath={repoPath}
|
||||
@@ -2978,6 +3137,12 @@ function ConversationTab({
|
||||
onStateChange={onStateChange}
|
||||
onMutated={onMutated}
|
||||
/>
|
||||
<PRAssigneesPanel
|
||||
item={item}
|
||||
repoPath={repoPath}
|
||||
projectOrigin={projectOrigin}
|
||||
onMutated={onMutated}
|
||||
/>
|
||||
<PRReviewersPanel
|
||||
item={item}
|
||||
loading={loading}
|
||||
@@ -5621,7 +5786,7 @@ function GHCommentComposer({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col items-start gap-2', className)}>
|
||||
<div className={cn('relative', className)}>
|
||||
<MentionTextarea
|
||||
textareaRef={textareaRef}
|
||||
value={body}
|
||||
@@ -5634,21 +5799,29 @@ function GHCommentComposer({
|
||||
rows={4}
|
||||
mentionOptions={mentionOptions}
|
||||
wrapperClassName="flex min-h-20 w-full items-stretch"
|
||||
className="scrollbar-sleek block h-20 max-h-[240px] min-h-20 w-full resize-none overflow-y-auto rounded-md border border-input bg-card px-3 py-2 text-[13px] leading-5 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="scrollbar-sleek block h-20 max-h-[240px] min-h-20 w-full resize-none overflow-y-auto rounded-md border border-input bg-card px-3 py-2 pb-12 pr-12 text-[13px] leading-5 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmitComment || submitting}
|
||||
className="gap-2"
|
||||
aria-label={translate('auto.components.PullRequestPage.161d91ef02', 'Send comment')}
|
||||
>
|
||||
{submitting ? (
|
||||
<LoaderCircle className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-3.5" />
|
||||
)}
|
||||
{translate('auto.components.PullRequestPage.3450247584', 'Comment')}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmitComment || submitting}
|
||||
className="absolute bottom-3 right-3 shadow-sm"
|
||||
aria-label={translate('auto.components.PullRequestPage.161d91ef02', 'Send comment')}
|
||||
>
|
||||
{submitting ? (
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{translate('auto.components.PullRequestPage.161d91ef02', 'Send comment')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1888,6 +1888,10 @@ function Terminal(): React.JSX.Element | null {
|
||||
// Keeping `isVisible` true for the portaled tab lets
|
||||
// xterm fit and stream foreground output in-place.
|
||||
isVisible={isActiveTerminalTab || isActivityPortalTab}
|
||||
// Why: inactive tabs in the visible legacy surface
|
||||
// are tab-hidden, not worktree-hidden, so they need
|
||||
// the same light resume path as split-group overlays.
|
||||
isWorktreeActive={isVisible || isActivityPortalTab}
|
||||
// Why: when portaled to Activity for a specific agent
|
||||
// pane, isolate that leaf so split siblings stay
|
||||
// hidden. Workspace renders pass null → no override.
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { FileText, Globe, Plus, Server, ServerOff, Smartphone, SquareTerminal } from 'lucide-react'
|
||||
import {
|
||||
FileText,
|
||||
FolderTree,
|
||||
Globe,
|
||||
Plus,
|
||||
Server,
|
||||
ServerOff,
|
||||
Smartphone,
|
||||
SquareTerminal
|
||||
} from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getRepoMapFromState, useAllWorktrees } from '@/store/selectors'
|
||||
import {
|
||||
@@ -77,6 +86,12 @@ import {
|
||||
type CmdJActionResult,
|
||||
type CmdJSettingsResult
|
||||
} from '@/components/cmd-j/palette-results'
|
||||
import { buildImportedWorktreesCardCandidates } from '@/components/sidebar/imported-worktrees-card-candidates'
|
||||
import {
|
||||
hasCmdJProjectSearchCandidates,
|
||||
searchCmdJProjectResults,
|
||||
type CmdJProjectSearchResult
|
||||
} from '@/components/cmd-j/palette-project-results'
|
||||
import {
|
||||
buildCmdJQuickActionContext,
|
||||
captureCmdJActiveGroupSnapshot,
|
||||
@@ -139,6 +154,12 @@ type QuickActionPaletteItem = {
|
||||
result: CmdJActionResult
|
||||
}
|
||||
|
||||
type ProjectTargetPaletteItem = {
|
||||
id: string
|
||||
type: 'project-target'
|
||||
result: CmdJProjectSearchResult
|
||||
}
|
||||
|
||||
type SectionHeader = {
|
||||
id: string
|
||||
type: 'section-header'
|
||||
@@ -160,6 +181,7 @@ type CreateWorktreePaletteItem = {
|
||||
// Keep future quick actions curated; route one-time setup flows through Settings.
|
||||
type PaletteItem =
|
||||
| WorktreePaletteItem
|
||||
| ProjectTargetPaletteItem
|
||||
| SettingsPaletteItem
|
||||
| QuickActionPaletteItem
|
||||
| BrowserPaletteItem
|
||||
@@ -305,9 +327,15 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
|
||||
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
|
||||
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
|
||||
const revealSidebarRow = useAppStore((s) => s.revealSidebarRow)
|
||||
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
|
||||
const allWorktrees = useAllWorktrees()
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const projectGroups = useAppStore((s) => s.projectGroups)
|
||||
const projects = useAppStore((s) => s.projects)
|
||||
const projectHostSetups = useAppStore((s) => s.projectHostSetups)
|
||||
const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo)
|
||||
const pendingWorktreeCreations = useAppStore((s) => s.pendingWorktreeCreations)
|
||||
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
|
||||
// Why: getWorktreeStatus needs per-pane titles so split-pane tabs with a
|
||||
// working agent in a non-focused pane still surface as 'working' in the
|
||||
@@ -740,6 +768,68 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
[settingsSections]
|
||||
)
|
||||
const actionResults = useMemo(() => buildCmdJActionResults(getCmdJQuickActions()), [])
|
||||
// Why: Cmd+J should only offer project jumps the sidebar can actually reveal;
|
||||
// archived-only repos are intentionally left out of this navigation surface.
|
||||
const renderableProjectRepoIds = useMemo(() => {
|
||||
const ids = new Set<string>()
|
||||
for (const worktree of allWorktrees) {
|
||||
if (!worktree.isArchived) {
|
||||
ids.add(worktree.repoId)
|
||||
}
|
||||
}
|
||||
for (const repo of repos) {
|
||||
if ((worktreesByRepo[repo.id]?.length ?? 0) === 0) {
|
||||
ids.add(repo.id)
|
||||
}
|
||||
}
|
||||
for (const repoId of buildImportedWorktreesCardCandidates({
|
||||
repos,
|
||||
detectedWorktreesByRepo
|
||||
}).keys()) {
|
||||
ids.add(repoId)
|
||||
}
|
||||
for (const creation of Object.values(pendingWorktreeCreations)) {
|
||||
ids.add(creation.request.repoId)
|
||||
}
|
||||
return ids
|
||||
}, [allWorktrees, detectedWorktreesByRepo, pendingWorktreeCreations, repos, worktreesByRepo])
|
||||
const hasAnyProjectSearchCandidates = useMemo(
|
||||
() =>
|
||||
hasCmdJProjectSearchCandidates({
|
||||
projectGroups,
|
||||
repos,
|
||||
projects,
|
||||
projectHostSetups,
|
||||
renderableRepoIds: renderableProjectRepoIds
|
||||
}),
|
||||
[projectGroups, projectHostSetups, projects, renderableProjectRepoIds, repos]
|
||||
)
|
||||
const projectTargetItems = useMemo<ProjectTargetPaletteItem[]>(
|
||||
() =>
|
||||
hasQuery
|
||||
? searchCmdJProjectResults({
|
||||
query: deferredQuery,
|
||||
projectGroups,
|
||||
repos,
|
||||
projects,
|
||||
projectHostSetups,
|
||||
renderableRepoIds: renderableProjectRepoIds
|
||||
}).map((result) => ({
|
||||
id: result.id,
|
||||
type: 'project-target' as const,
|
||||
result
|
||||
}))
|
||||
: [],
|
||||
[
|
||||
deferredQuery,
|
||||
hasQuery,
|
||||
projectGroups,
|
||||
projectHostSetups,
|
||||
projects,
|
||||
renderableProjectRepoIds,
|
||||
repos
|
||||
]
|
||||
)
|
||||
|
||||
const prefetchCreateWorkspaceBaseForComposer = useCallback((initialRepoId?: string): void => {
|
||||
const state = useAppStore.getState()
|
||||
@@ -830,6 +920,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
// viewport naturally.
|
||||
const worktreeCap = !hasQuery && openTabItems.length > 0 ? EMPTY_QUERY_WORKTREE_CAP : Infinity
|
||||
const visibleWorktreeItems = hasQuery ? worktreeItems : worktreeItems.slice(0, worktreeCap)
|
||||
const visibleProjectTargetItems = hasQuery ? projectTargetItems : []
|
||||
const visibleMiddleItems = hasQuery ? middleItems : []
|
||||
const visibleOpenTabItems = hasQuery
|
||||
? openTabItems
|
||||
@@ -838,15 +929,17 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
|
||||
return {
|
||||
visibleWorktreeItems,
|
||||
visibleProjectTargetItems,
|
||||
visibleMiddleItems,
|
||||
visibleOpenTabItems,
|
||||
showWorktreeHint
|
||||
}
|
||||
}, [worktreeItems, middleItems, openTabItems, hasQuery])
|
||||
}, [worktreeItems, projectTargetItems, middleItems, openTabItems, hasQuery])
|
||||
|
||||
const selectableItems = useMemo<PaletteItem[]>(
|
||||
() => [
|
||||
...paletteSections.visibleWorktreeItems,
|
||||
...paletteSections.visibleProjectTargetItems,
|
||||
...paletteSections.visibleMiddleItems,
|
||||
...paletteSections.visibleOpenTabItems
|
||||
],
|
||||
@@ -864,11 +957,17 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
|
||||
const listEntries = useMemo<PaletteListEntry[]>(() => {
|
||||
const entries: PaletteListEntry[] = []
|
||||
const { visibleWorktreeItems, visibleMiddleItems, visibleOpenTabItems, showWorktreeHint } =
|
||||
paletteSections
|
||||
const {
|
||||
visibleWorktreeItems,
|
||||
visibleProjectTargetItems,
|
||||
visibleMiddleItems,
|
||||
visibleOpenTabItems,
|
||||
showWorktreeHint
|
||||
} = paletteSections
|
||||
const visibleWorkspaceItemCount = visibleWorktreeItems.length + (showCreateAction ? 1 : 0)
|
||||
const populatedSectionCount = [
|
||||
visibleWorkspaceItemCount,
|
||||
visibleProjectTargetItems.length,
|
||||
visibleMiddleItems.length,
|
||||
visibleOpenTabItems.length
|
||||
].filter((count) => count > 0).length
|
||||
@@ -883,6 +982,8 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
const showOpenTabsHeader = hasQuery
|
||||
? visibleOpenTabItems.length > 0 && populatedSectionCount > 1
|
||||
: visibleOpenTabItems.length > 0
|
||||
const showProjectTargetHeader =
|
||||
hasQuery && visibleProjectTargetItems.length > 0 && populatedSectionCount > 1
|
||||
const showMiddleHeader = hasQuery && visibleMiddleItems.length > 0 && populatedSectionCount > 1
|
||||
|
||||
if (visibleWorkspaceItemCount > 0) {
|
||||
@@ -899,11 +1000,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
})
|
||||
}
|
||||
appendPaletteListEntries(entries, visibleWorktreeItems)
|
||||
if (showCreateAction) {
|
||||
// Why: the typed create affordance is workspace-scoped, so keep it
|
||||
// directly under workspace matches instead of after actions/tabs.
|
||||
entries.push({ id: CREATE_WORKTREE_ITEM_ID, type: 'create-worktree' })
|
||||
}
|
||||
if (showWorktreeHint) {
|
||||
entries.push({
|
||||
id: '__hint_worktree_cap__',
|
||||
@@ -916,6 +1012,24 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
})
|
||||
}
|
||||
}
|
||||
if (visibleProjectTargetItems.length > 0) {
|
||||
if (showProjectTargetHeader) {
|
||||
entries.push({
|
||||
id: '__header_projects_groups__',
|
||||
type: 'section-header',
|
||||
label: translate(
|
||||
'auto.components.WorktreeJumpPalette.projectsGroupsHeader',
|
||||
'Projects & Groups'
|
||||
)
|
||||
})
|
||||
}
|
||||
appendPaletteListEntries(entries, visibleProjectTargetItems)
|
||||
}
|
||||
if (showCreateAction) {
|
||||
// Why: project/group jump targets are navigation results; keep them
|
||||
// directly after worktree matches before the creation fallback.
|
||||
entries.push({ id: CREATE_WORKTREE_ITEM_ID, type: 'create-worktree' })
|
||||
}
|
||||
if (visibleMiddleItems.length > 0) {
|
||||
if (showMiddleHeader) {
|
||||
entries.push({
|
||||
@@ -1260,10 +1374,41 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
[buildQuickActionContext, closeModal, recordFeatureInteraction]
|
||||
)
|
||||
|
||||
const handleSelectProjectTarget = useCallback(
|
||||
(result: CmdJProjectSearchResult) => {
|
||||
skipRestoreFocusRef.current = true
|
||||
// Why: selecting a project or repo group is a sidebar navigation action;
|
||||
// it should reveal the grouping row without activating an arbitrary workspace.
|
||||
revealSidebarRow(result.rowKey, { behavior: 'smooth', highlight: true })
|
||||
recordFeatureInteraction('cmd-j')
|
||||
closeModal()
|
||||
setSelectedItemId('')
|
||||
if (previousActiveTabTypeRef.current === 'browser' && previousBrowserPageIdRef.current) {
|
||||
requestBrowserFocus({
|
||||
pageId: previousBrowserPageIdRef.current,
|
||||
target: previousBrowserFocusTargetRef.current
|
||||
})
|
||||
return
|
||||
}
|
||||
if (previousWorktreeIdRef.current) {
|
||||
focusFallbackSurface()
|
||||
}
|
||||
},
|
||||
[
|
||||
closeModal,
|
||||
focusFallbackSurface,
|
||||
recordFeatureInteraction,
|
||||
requestBrowserFocus,
|
||||
revealSidebarRow
|
||||
]
|
||||
)
|
||||
|
||||
const handleSelectItem = useCallback(
|
||||
(item: PaletteItem) => {
|
||||
if (item.type === 'worktree') {
|
||||
handleSelectWorktree(item.worktree.id)
|
||||
} else if (item.type === 'project-target') {
|
||||
handleSelectProjectTarget(item.result)
|
||||
} else if (item.type === 'browser-page') {
|
||||
handleSelectBrowserPage(item.result)
|
||||
} else if (item.type === 'simulator-tab') {
|
||||
@@ -1278,6 +1423,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
},
|
||||
[
|
||||
handleSelectBrowserPage,
|
||||
handleSelectProjectTarget,
|
||||
handleSelectQuickAction,
|
||||
handleSelectSettings,
|
||||
handleSelectSimulatorTab,
|
||||
@@ -1495,7 +1641,13 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
|
||||
const resultCount = selectableItems.length
|
||||
const emptyState = (() => {
|
||||
if ((hasAnySearchableWorktrees || hasAnyMiddleResults || hasAnyOpenTabs) && hasQuery) {
|
||||
if (
|
||||
(hasAnySearchableWorktrees ||
|
||||
hasAnyProjectSearchCandidates ||
|
||||
hasAnyMiddleResults ||
|
||||
hasAnyOpenTabs) &&
|
||||
hasQuery
|
||||
) {
|
||||
return {
|
||||
title: translate(
|
||||
'auto.components.WorktreeJumpPalette.dbd9d87eec',
|
||||
@@ -1503,7 +1655,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
),
|
||||
subtitle: translate(
|
||||
'auto.components.WorktreeJumpPalette.c4afa68159',
|
||||
'Try a worktree, setting, action, tab title, agent prompt, URL, PR, or port.'
|
||||
'Try a worktree, project, setting, action, tab title, agent prompt, URL, PR, or port.'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1698,7 +1850,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground">
|
||||
<span className="truncate text-[14px] font-semibold text-foreground">
|
||||
{entry.match.displayNameRange ? (
|
||||
<HighlightedText
|
||||
text={worktree.displayName}
|
||||
@@ -1776,6 +1928,53 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
)
|
||||
}
|
||||
|
||||
if (entry.type === 'project-target') {
|
||||
const result = entry.result
|
||||
const isProject = result.kind === 'project'
|
||||
const hostBadge = isProject ? getPaletteHostBadge(result.repo, hostOptions) : null
|
||||
const badgeLabel = isProject
|
||||
? translate('auto.components.WorktreeJumpPalette.projectBadge', 'Project')
|
||||
: translate('auto.components.WorktreeJumpPalette.repoGroupBadge', 'Repo group')
|
||||
return (
|
||||
<CommandItem
|
||||
key={entry.id}
|
||||
value={entry.id}
|
||||
onSelect={() => handleSelectItem(entry)}
|
||||
className={cn(
|
||||
'group mx-0.5 flex cursor-pointer items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow]',
|
||||
'data-[selected=true]:border-border data-[selected=true]:bg-accent data-[selected=true]:text-foreground'
|
||||
)}
|
||||
>
|
||||
<div className="flex w-4 shrink-0 items-center justify-center self-start pt-0.5 text-muted-foreground/85">
|
||||
<FolderTree className="size-3.5" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-[14px] font-semibold text-foreground">
|
||||
{result.title}
|
||||
</span>
|
||||
<span className="shrink-0 rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
|
||||
{badgeLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{isProject ? (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<PaletteHostBadgeChip badge={hostBadge} />
|
||||
<span className="inline-flex max-w-[180px] items-center gap-1.5 rounded-md border border-border bg-muted px-2 py-1 text-[11px] font-semibold leading-none text-foreground">
|
||||
<RepoBadgeMark color={result.repo.badgeColor} />
|
||||
<span className="truncate">{result.repo.displayName}</span>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
)
|
||||
}
|
||||
|
||||
if (entry.type === 'settings' || entry.type === 'quick-action') {
|
||||
const result = entry.result
|
||||
const Icon = result.icon
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
ACTIVITY_SEARCH_QUERY_MAX_BYTES,
|
||||
activityThreadResponseRenderPreview,
|
||||
activityThreadMatchesSearchQuery,
|
||||
handleActivityFilterFocusShortcut,
|
||||
isActivityFilterFocusShortcut,
|
||||
shouldIgnoreActivityFilterFocusShortcutTarget,
|
||||
buildActivityThreadGroups,
|
||||
buildActivityEvents,
|
||||
buildAgentPaneThreads,
|
||||
@@ -724,3 +727,188 @@ describe('activity thread grouping', () => {
|
||||
expect(buildActivityThreadGroups([], 'status')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('activity filter focus shortcut', () => {
|
||||
it('matches Cmd+F on Mac and Ctrl+F elsewhere without extra modifiers', () => {
|
||||
expect(
|
||||
isActivityFilterFocusShortcut(
|
||||
{ key: 'f', metaKey: true, ctrlKey: false, shiftKey: false, altKey: false },
|
||||
true
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
isActivityFilterFocusShortcut(
|
||||
{ key: 'F', metaKey: false, ctrlKey: true, shiftKey: false, altKey: false },
|
||||
false
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
isActivityFilterFocusShortcut(
|
||||
{ key: 'f', metaKey: false, ctrlKey: true, shiftKey: false, altKey: false },
|
||||
true
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
isActivityFilterFocusShortcut(
|
||||
{ key: 'f', metaKey: true, ctrlKey: false, shiftKey: true, altKey: false },
|
||||
true
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
isActivityFilterFocusShortcut(
|
||||
{ key: 'f', metaKey: true, ctrlKey: true, shiftKey: false, altKey: false },
|
||||
true
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
isActivityFilterFocusShortcut(
|
||||
{ key: 'f', metaKey: true, ctrlKey: true, shiftKey: false, altKey: false },
|
||||
false
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('prevents default, focuses, and selects only for handled filter shortcuts', () => {
|
||||
let prevented = 0
|
||||
let stopped = 0
|
||||
let stoppedImmediate = 0
|
||||
let focused = 0
|
||||
let selected = 0
|
||||
const input = {
|
||||
focus: () => {
|
||||
focused += 1
|
||||
},
|
||||
select: () => {
|
||||
selected += 1
|
||||
}
|
||||
} as Pick<HTMLInputElement, 'focus' | 'select'>
|
||||
const activeElement = {
|
||||
classList: { contains: () => false }
|
||||
} as unknown as Element
|
||||
const terminalElement = {
|
||||
classList: { contains: (className: string) => className === 'xterm-helper-textarea' }
|
||||
} as unknown as Element
|
||||
const terminalPortalTarget = {
|
||||
contains: (target: Element) => target === terminalElement
|
||||
} as unknown as HTMLElement
|
||||
|
||||
expect(
|
||||
handleActivityFilterFocusShortcut({
|
||||
activeElement,
|
||||
event: {
|
||||
key: 'f',
|
||||
metaKey: true,
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
preventDefault: () => {
|
||||
prevented += 1
|
||||
},
|
||||
stopPropagation: () => {
|
||||
stopped += 1
|
||||
},
|
||||
stopImmediatePropagation: () => {
|
||||
stoppedImmediate += 1
|
||||
}
|
||||
},
|
||||
input,
|
||||
isMac: true,
|
||||
terminalPortalTargets: []
|
||||
})
|
||||
).toBe(true)
|
||||
expect(prevented).toBe(1)
|
||||
expect(stopped).toBe(1)
|
||||
expect(stoppedImmediate).toBe(1)
|
||||
expect(focused).toBe(1)
|
||||
expect(selected).toBe(1)
|
||||
|
||||
expect(
|
||||
handleActivityFilterFocusShortcut({
|
||||
activeElement: terminalElement,
|
||||
event: {
|
||||
key: 'f',
|
||||
metaKey: true,
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
preventDefault: () => {
|
||||
prevented += 1
|
||||
},
|
||||
stopPropagation: () => {
|
||||
stopped += 1
|
||||
},
|
||||
stopImmediatePropagation: () => {
|
||||
stoppedImmediate += 1
|
||||
}
|
||||
},
|
||||
input,
|
||||
isMac: true,
|
||||
terminalPortalTargets: [terminalPortalTarget]
|
||||
})
|
||||
).toBe(false)
|
||||
expect(prevented).toBe(1)
|
||||
expect(stopped).toBe(1)
|
||||
expect(stoppedImmediate).toBe(1)
|
||||
expect(focused).toBe(1)
|
||||
expect(selected).toBe(1)
|
||||
})
|
||||
|
||||
it('does not prevent default when the filter input is unavailable', () => {
|
||||
let prevented = 0
|
||||
const activeElement = {
|
||||
classList: { contains: () => false }
|
||||
} as unknown as Element
|
||||
|
||||
expect(
|
||||
handleActivityFilterFocusShortcut({
|
||||
activeElement,
|
||||
event: {
|
||||
key: 'f',
|
||||
metaKey: true,
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
preventDefault: () => {
|
||||
prevented += 1
|
||||
},
|
||||
stopPropagation: () => {
|
||||
throw new Error('unavailable input must not stop propagation')
|
||||
},
|
||||
stopImmediatePropagation: () => {
|
||||
throw new Error('unavailable input must not stop immediate propagation')
|
||||
}
|
||||
},
|
||||
input: null,
|
||||
isMac: true,
|
||||
terminalPortalTargets: []
|
||||
})
|
||||
).toBe(false)
|
||||
expect(prevented).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores shortcut handling while terminal-owned elements have focus', () => {
|
||||
const terminalTextarea = {
|
||||
classList: { contains: (className: string) => className === 'xterm-helper-textarea' }
|
||||
} as unknown as Element
|
||||
const portalChild = {
|
||||
classList: { contains: () => false }
|
||||
} as unknown as Element
|
||||
const outside = {
|
||||
classList: { contains: () => false }
|
||||
} as unknown as Element
|
||||
const portalTarget = {
|
||||
contains: (target: Element) => target === portalChild || target === terminalTextarea
|
||||
} as unknown as HTMLElement
|
||||
const hiddenWorkbenchTerminal = {
|
||||
classList: { contains: (className: string) => className === 'xterm-helper-textarea' }
|
||||
} as unknown as Element
|
||||
expect(shouldIgnoreActivityFilterFocusShortcutTarget(terminalTextarea, [portalTarget])).toBe(
|
||||
true
|
||||
)
|
||||
expect(shouldIgnoreActivityFilterFocusShortcutTarget(portalChild, [portalTarget])).toBe(true)
|
||||
expect(shouldIgnoreActivityFilterFocusShortcutTarget(outside, [portalTarget])).toBe(false)
|
||||
expect(
|
||||
shouldIgnoreActivityFilterFocusShortcutTarget(hiddenWorkbenchTerminal, [portalTarget])
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable max-lines -- Why: this prototype keeps the real-data adapter
|
||||
and current visual skeleton together until the next refinement pass decides
|
||||
which pieces become production modules. */
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
Bell,
|
||||
@@ -1028,6 +1028,70 @@ export function activityThreadMatchesSearchQuery({
|
||||
return threadSearchText(thread).includes(trimmedQuery.toLowerCase())
|
||||
}
|
||||
|
||||
export function isActivityFilterFocusShortcut(
|
||||
event: Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>,
|
||||
isMac = navigator.userAgent.includes('Mac')
|
||||
): boolean {
|
||||
if (event.key.toLowerCase() !== 'f' || event.shiftKey || event.altKey) {
|
||||
return false
|
||||
}
|
||||
return isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
|
||||
}
|
||||
|
||||
export function shouldIgnoreActivityFilterFocusShortcutTarget(
|
||||
target: Element | null,
|
||||
terminalPortalTargets: (HTMLElement | null)[]
|
||||
): boolean {
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
// Why: the workspace terminal stays mounted while Activity is open; only the
|
||||
// Activity-portaled terminal should keep Cmd/Ctrl+F for terminal search.
|
||||
return terminalPortalTargets.some((portalTarget) => portalTarget?.contains(target) ?? false)
|
||||
}
|
||||
|
||||
export function handleActivityFilterFocusShortcut({
|
||||
activeElement,
|
||||
event,
|
||||
input,
|
||||
isMac,
|
||||
terminalPortalTargets
|
||||
}: {
|
||||
activeElement: Element | null
|
||||
event: Pick<
|
||||
KeyboardEvent,
|
||||
| 'altKey'
|
||||
| 'ctrlKey'
|
||||
| 'key'
|
||||
| 'metaKey'
|
||||
| 'preventDefault'
|
||||
| 'shiftKey'
|
||||
| 'stopImmediatePropagation'
|
||||
| 'stopPropagation'
|
||||
>
|
||||
input: Pick<HTMLInputElement, 'focus' | 'select'> | null
|
||||
isMac?: boolean
|
||||
terminalPortalTargets: (HTMLElement | null)[]
|
||||
}): boolean {
|
||||
if (shouldIgnoreActivityFilterFocusShortcutTarget(activeElement, terminalPortalTargets)) {
|
||||
return false
|
||||
}
|
||||
if (!isActivityFilterFocusShortcut(event, isMac)) {
|
||||
return false
|
||||
}
|
||||
if (!input) {
|
||||
return false
|
||||
}
|
||||
event.preventDefault()
|
||||
// Why: hidden workspace xterms can retain focus behind Activity; capture-phase
|
||||
// handling must stop the chord before xterm forwards it to a local/SSH PTY.
|
||||
event.stopPropagation()
|
||||
event.stopImmediatePropagation()
|
||||
input.focus()
|
||||
input.select()
|
||||
return true
|
||||
}
|
||||
|
||||
function ThreadAgentStateIndicator({ thread }: { thread: AgentPaneThread }): React.JSX.Element {
|
||||
const state = threadAgentState(thread)
|
||||
const label = threadAgentStateLabel(thread)
|
||||
@@ -1283,6 +1347,7 @@ export default function ActivityPrototypePage(): React.JSX.Element {
|
||||
const [readFilter, setReadFilter] = useState<ThreadReadFilter>('all')
|
||||
const [groupBy, setGroupBy] = useState<ActivityGroupBy>('status')
|
||||
const [query, setQuery] = useState('')
|
||||
const activityFilterInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const [compactMode, setCompactMode] = useState(false)
|
||||
const [selectedPaneKey, setSelectedPaneKey] = useState<string | null>(null)
|
||||
const [displayedPaneKey, setDisplayedPaneKey] = useState<string | null>(null)
|
||||
@@ -1555,6 +1620,20 @@ export default function ActivityPrototypePage(): React.JSX.Element {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const focusActivityFilter = (event: KeyboardEvent): void => {
|
||||
handleActivityFilterFocusShortcut({
|
||||
activeElement: document.activeElement,
|
||||
event,
|
||||
input: activityFilterInputRef.current,
|
||||
terminalPortalTargets: [activePortalTargetEl, inactivePortalTargetEl]
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', focusActivityFilter, { capture: true })
|
||||
return () => window.removeEventListener('keydown', focusActivityFilter, { capture: true })
|
||||
}, [activePortalTargetEl, inactivePortalTargetEl])
|
||||
|
||||
const markThreadRead = (thread: AgentPaneThread): void => {
|
||||
storeData.acknowledgeAgents([thread.paneKey])
|
||||
}
|
||||
@@ -1660,6 +1739,7 @@ export default function ActivityPrototypePage(): React.JSX.Element {
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
ref={activityFilterInputRef}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={translate(
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import { isCmdJPaletteQueryTooLarge } from './palette-results'
|
||||
import type { Project, ProjectGroup, ProjectHostSetup, Repo } from '../../../../shared/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
getProjectGroupHeaderKey,
|
||||
getProjectHeaderRevealTarget,
|
||||
type ProjectGroupingModel
|
||||
} from '../sidebar/worktree-list-groups'
|
||||
|
||||
export type CmdJProjectGroupResult = {
|
||||
id: string
|
||||
kind: 'project-group'
|
||||
title: string
|
||||
description: string
|
||||
rowKey: string
|
||||
order: number
|
||||
keywords: string[]
|
||||
}
|
||||
|
||||
export type CmdJProjectResult = {
|
||||
id: string
|
||||
kind: 'project'
|
||||
title: string
|
||||
description: string
|
||||
rowKey: string
|
||||
repo: Repo
|
||||
order: number
|
||||
keywords: string[]
|
||||
}
|
||||
|
||||
export type CmdJProjectSearchResult = CmdJProjectGroupResult | CmdJProjectResult
|
||||
|
||||
type RankedProjectResult = {
|
||||
result: CmdJProjectSearchResult
|
||||
rule: number
|
||||
score: number
|
||||
}
|
||||
|
||||
const PROJECT_GROUP_ALIASES = ['group', 'repo group']
|
||||
const PROJECT_ALIASES = ['project', 'repo']
|
||||
|
||||
function normalizeQuery(value: string): string {
|
||||
let normalized = ''
|
||||
let pendingWhitespace = false
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index)
|
||||
if (isCmdJPaletteWhitespace(code)) {
|
||||
pendingWhitespace = normalized.length > 0
|
||||
continue
|
||||
}
|
||||
if (pendingWhitespace) {
|
||||
normalized += ' '
|
||||
pendingWhitespace = false
|
||||
}
|
||||
normalized += value.charAt(index).toLowerCase()
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function isCmdJPaletteWhitespace(code: number): boolean {
|
||||
return (
|
||||
code === 32 ||
|
||||
(code >= 9 && code <= 13) ||
|
||||
code === 160 ||
|
||||
code === 5760 ||
|
||||
(code >= 8192 && code <= 8202) ||
|
||||
code === 8232 ||
|
||||
code === 8233 ||
|
||||
code === 8239 ||
|
||||
code === 8287 ||
|
||||
code === 12288 ||
|
||||
code === 65279
|
||||
)
|
||||
}
|
||||
|
||||
function uniqueNormalized(values: readonly string[]): string[] {
|
||||
return [...new Set(values.map(normalizeQuery).filter(Boolean))]
|
||||
}
|
||||
|
||||
function tokenize(value: string): string[] {
|
||||
return normalizeQuery(value)
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function tokenScore(query: string, values: readonly string[]): number {
|
||||
const candidateTokens = values.flatMap(tokenize)
|
||||
if (candidateTokens.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let score = 0
|
||||
for (const queryToken of tokenize(query)) {
|
||||
let best = 0
|
||||
for (const candidateToken of candidateTokens) {
|
||||
if (candidateToken === queryToken) {
|
||||
best = Math.max(best, 3)
|
||||
} else if (candidateToken.startsWith(queryToken)) {
|
||||
best = Math.max(best, 2)
|
||||
} else if (candidateToken.includes(queryToken)) {
|
||||
best = Math.max(best, 1)
|
||||
}
|
||||
}
|
||||
score += best
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
function buildCmdJProjectSearchCandidates({
|
||||
projectGroups,
|
||||
repos,
|
||||
projects,
|
||||
projectHostSetups,
|
||||
renderableRepoIds
|
||||
}: {
|
||||
projectGroups: readonly ProjectGroup[]
|
||||
repos: readonly Repo[]
|
||||
projects: readonly Project[]
|
||||
projectHostSetups: readonly ProjectHostSetup[]
|
||||
renderableRepoIds?: ReadonlySet<string>
|
||||
}): CmdJProjectSearchResult[] {
|
||||
const projectGrouping: ProjectGroupingModel = { projects, projectHostSetups }
|
||||
const repoMap = new Map(repos.map((repo) => [repo.id, repo]))
|
||||
const candidates: CmdJProjectSearchResult[] = []
|
||||
|
||||
projectGroups.forEach((group, order) => {
|
||||
candidates.push({
|
||||
id: `project-group:${group.id}`,
|
||||
kind: 'project-group',
|
||||
title: group.name,
|
||||
description: translate(
|
||||
'auto.components.cmd.j.palette.project.results.repoGroup',
|
||||
'Repo group'
|
||||
),
|
||||
rowKey: getProjectGroupHeaderKey(group.id),
|
||||
order,
|
||||
keywords: uniqueNormalized([group.name, ...PROJECT_GROUP_ALIASES])
|
||||
})
|
||||
})
|
||||
|
||||
const seenRowKeys = new Set<string>()
|
||||
repos.forEach((repo, repoIndex) => {
|
||||
if (renderableRepoIds && !renderableRepoIds.has(repo.id)) {
|
||||
return
|
||||
}
|
||||
const target = getProjectHeaderRevealTarget(repo.id, repoMap, projectGrouping)
|
||||
if (!target.repo || seenRowKeys.has(target.key)) {
|
||||
return
|
||||
}
|
||||
seenRowKeys.add(target.key)
|
||||
candidates.push({
|
||||
id: `project:${target.key}`,
|
||||
kind: 'project',
|
||||
title: target.label,
|
||||
description: translate('auto.components.cmd.j.palette.project.results.project', 'Project'),
|
||||
rowKey: target.key,
|
||||
repo: target.repo,
|
||||
order: projectGroups.length + repoIndex,
|
||||
keywords: uniqueNormalized([target.label, repo.displayName, ...PROJECT_ALIASES])
|
||||
})
|
||||
})
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
export function hasCmdJProjectSearchCandidates({
|
||||
projectGroups,
|
||||
repos,
|
||||
projects,
|
||||
projectHostSetups,
|
||||
renderableRepoIds
|
||||
}: {
|
||||
projectGroups: readonly ProjectGroup[]
|
||||
repos: readonly Repo[]
|
||||
projects: readonly Project[]
|
||||
projectHostSetups: readonly ProjectHostSetup[]
|
||||
renderableRepoIds?: ReadonlySet<string>
|
||||
}): boolean {
|
||||
return (
|
||||
buildCmdJProjectSearchCandidates({
|
||||
projectGroups,
|
||||
repos,
|
||||
projects,
|
||||
projectHostSetups,
|
||||
renderableRepoIds
|
||||
}).length > 0
|
||||
)
|
||||
}
|
||||
|
||||
function projectRankingForCandidate(
|
||||
query: string,
|
||||
candidate: CmdJProjectSearchResult
|
||||
): RankedProjectResult | null {
|
||||
const title = normalizeQuery(candidate.title)
|
||||
if (query === title) {
|
||||
return { result: candidate, rule: 1, score: 0 }
|
||||
}
|
||||
if (title.startsWith(query)) {
|
||||
return { result: candidate, rule: 2, score: 0 }
|
||||
}
|
||||
const aliasKeywords = candidate.kind === 'project-group' ? PROJECT_GROUP_ALIASES : PROJECT_ALIASES
|
||||
if (aliasKeywords.map(normalizeQuery).includes(query)) {
|
||||
return { result: candidate, rule: 3, score: 0 }
|
||||
}
|
||||
if (candidate.keywords.some((keyword) => keyword.startsWith(query))) {
|
||||
return { result: candidate, rule: 4, score: 0 }
|
||||
}
|
||||
const score = tokenScore(query, [candidate.title, ...candidate.keywords])
|
||||
return score > 0 ? { result: candidate, rule: 5, score } : null
|
||||
}
|
||||
|
||||
function compareProjectRanked(a: RankedProjectResult, b: RankedProjectResult): number {
|
||||
if (a.rule !== b.rule) {
|
||||
return a.rule - b.rule
|
||||
}
|
||||
if (a.score !== b.score) {
|
||||
return b.score - a.score
|
||||
}
|
||||
if (a.result.order !== b.result.order) {
|
||||
return a.result.order - b.result.order
|
||||
}
|
||||
return a.result.id.localeCompare(b.result.id)
|
||||
}
|
||||
|
||||
export function searchCmdJProjectResults({
|
||||
query,
|
||||
projectGroups,
|
||||
repos,
|
||||
projects,
|
||||
projectHostSetups,
|
||||
renderableRepoIds
|
||||
}: {
|
||||
query: string
|
||||
projectGroups: readonly ProjectGroup[]
|
||||
repos: readonly Repo[]
|
||||
projects: readonly Project[]
|
||||
projectHostSetups: readonly ProjectHostSetup[]
|
||||
renderableRepoIds?: ReadonlySet<string>
|
||||
}): CmdJProjectSearchResult[] {
|
||||
// Why: oversized pasted input should not force the palette to scan project,
|
||||
// repo, or group names that may include private workspace details.
|
||||
if (isCmdJPaletteQueryTooLarge(query)) {
|
||||
return []
|
||||
}
|
||||
const normalizedQuery = normalizeQuery(query)
|
||||
// Why: project/group rows sit after worktree matches, so one-character
|
||||
// searches would add broad noisy navigation targets before intent is clear.
|
||||
if (normalizedQuery.length < 2) {
|
||||
return []
|
||||
}
|
||||
return buildCmdJProjectSearchCandidates({
|
||||
projectGroups,
|
||||
repos,
|
||||
projects,
|
||||
projectHostSetups,
|
||||
renderableRepoIds
|
||||
})
|
||||
.map((candidate) => projectRankingForCandidate(normalizedQuery, candidate))
|
||||
.filter((entry): entry is RankedProjectResult => entry !== null)
|
||||
.sort(compareProjectRanked)
|
||||
.map((entry) => entry.result)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Globe, Settings } from 'lucide-react'
|
||||
import type { CmdJQuickAction } from './quick-actions'
|
||||
@@ -10,7 +11,9 @@ import {
|
||||
type CmdJActionResult,
|
||||
type CmdJSettingsResult
|
||||
} from './palette-results'
|
||||
import { hasCmdJProjectSearchCandidates, searchCmdJProjectResults } from './palette-project-results'
|
||||
import type { SettingsNavSection } from '@/lib/settings-navigation-types'
|
||||
import type { Project, ProjectGroup, ProjectHostSetup, Repo } from '../../../../shared/types'
|
||||
|
||||
const noopRun: CmdJQuickAction['run'] = async () => ({ status: 'ok' })
|
||||
const available: CmdJQuickAction['isAvailable'] = () => ({ available: true })
|
||||
@@ -259,3 +262,191 @@ describe('Cmd+J palette middle-band ranking', () => {
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
function repo(id: string, displayName: string, projectGroupId?: string | null): Repo {
|
||||
return {
|
||||
id,
|
||||
path: path.join('/repos', displayName),
|
||||
displayName,
|
||||
badgeColor: '#999999',
|
||||
addedAt: 1,
|
||||
projectGroupId
|
||||
} as Repo
|
||||
}
|
||||
|
||||
function project(id: string, displayName: string): Project {
|
||||
return {
|
||||
id,
|
||||
displayName,
|
||||
badgeColor: '#999999',
|
||||
sourceRepoIds: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function setup(id: string, projectId: string, hostId: string, repoId: string): ProjectHostSetup {
|
||||
return {
|
||||
id,
|
||||
projectId,
|
||||
hostId: hostId as ProjectHostSetup['hostId'],
|
||||
repoId,
|
||||
path: path.join('/repos', repoId),
|
||||
displayName: repoId,
|
||||
setupState: 'ready',
|
||||
setupMethod: 'cloned',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function projectGroup(id: string, name: string, parentGroupId: string | null = null): ProjectGroup {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
parentPath: null,
|
||||
parentGroupId,
|
||||
createdFrom: 'manual',
|
||||
tabOrder: 1,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
describe('Cmd+J project and repo-group search', () => {
|
||||
it('finds a Project Group by name', () => {
|
||||
const [result] = searchCmdJProjectResults({
|
||||
query: 'infra',
|
||||
projectGroups: [projectGroup('group-1', 'Infrastructure')],
|
||||
repos: [],
|
||||
projects: [],
|
||||
projectHostSetups: []
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
kind: 'project-group',
|
||||
title: 'Infrastructure',
|
||||
description: 'Repo group',
|
||||
rowKey: 'project-group:group-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('finds a Project by project name and a repo-backed fallback by repo name', () => {
|
||||
const projectResults = searchCmdJProjectResults({
|
||||
query: 'api',
|
||||
projectGroups: [],
|
||||
repos: [repo('repo-1', 'legacy-api'), repo('repo-2', 'source-folder')],
|
||||
projects: [project('project-1', 'API Service')],
|
||||
projectHostSetups: [setup('setup-1', 'project-1', 'local', 'repo-1')]
|
||||
})
|
||||
const fallbackResults = searchCmdJProjectResults({
|
||||
query: 'source',
|
||||
projectGroups: [],
|
||||
repos: [repo('repo-1', 'legacy-api'), repo('repo-2', 'source-folder')],
|
||||
projects: [project('project-1', 'API Service')],
|
||||
projectHostSetups: [setup('setup-1', 'project-1', 'local', 'repo-1')]
|
||||
})
|
||||
|
||||
expect(projectResults.map((result) => [result.title, result.rowKey])).toEqual([
|
||||
['API Service', 'project:project-1']
|
||||
])
|
||||
expect(fallbackResults.map((result) => [result.title, result.rowKey])).toEqual([
|
||||
['source-folder', 'repo:repo-2']
|
||||
])
|
||||
})
|
||||
|
||||
it('uses setup-specific project header keys for multi-setup projects on one host', () => {
|
||||
const results = searchCmdJProjectResults({
|
||||
query: 'platform',
|
||||
projectGroups: [],
|
||||
repos: [repo('repo-1', 'platform-a'), repo('repo-2', 'platform-b')],
|
||||
projects: [project('project-1', 'Platform')],
|
||||
projectHostSetups: [
|
||||
setup('setup-1', 'project-1', 'local', 'repo-1'),
|
||||
setup('setup-2', 'project-1', 'local', 'repo-2')
|
||||
]
|
||||
})
|
||||
|
||||
expect(results.map((result) => result.rowKey)).toEqual([
|
||||
'project:project-1::setup:repo-1',
|
||||
'project:project-1::setup:repo-2'
|
||||
])
|
||||
})
|
||||
|
||||
it('suppresses raw Project records without renderable repo header targets', () => {
|
||||
const results = searchCmdJProjectResults({
|
||||
query: 'orphan',
|
||||
projectGroups: [],
|
||||
repos: [],
|
||||
projects: [project('project-1', 'Orphan Project')],
|
||||
projectHostSetups: []
|
||||
})
|
||||
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
it('suppresses repo-backed projects when the sidebar cannot render their header row', () => {
|
||||
const results = searchCmdJProjectResults({
|
||||
query: 'archived',
|
||||
projectGroups: [],
|
||||
repos: [repo('repo-1', 'archived-service')],
|
||||
projects: [project('project-1', 'Archived Service')],
|
||||
projectHostSetups: [setup('setup-1', 'project-1', 'local', 'repo-1')],
|
||||
renderableRepoIds: new Set()
|
||||
})
|
||||
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
it('reports searchable project candidates even when a query has no match', () => {
|
||||
expect(
|
||||
hasCmdJProjectSearchCandidates({
|
||||
projectGroups: [projectGroup('group-1', 'Infrastructure')],
|
||||
repos: [],
|
||||
projects: [],
|
||||
projectHostSetups: []
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
searchCmdJProjectResults({
|
||||
query: 'zzzz',
|
||||
projectGroups: [projectGroup('group-1', 'Infrastructure')],
|
||||
repos: [],
|
||||
projects: [],
|
||||
projectHostSetups: []
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects oversized project queries before reading names', () => {
|
||||
const oversizedQuery = 'secret-palette-query'.repeat(CMD_J_PALETTE_QUERY_MAX_BYTES)
|
||||
const throwingGroup = {
|
||||
get id() {
|
||||
throw new Error('oversized palette queries must not scan project groups')
|
||||
},
|
||||
get name() {
|
||||
throw new Error('oversized palette queries must not scan project groups')
|
||||
}
|
||||
} as unknown as ProjectGroup
|
||||
const throwingRepo = {
|
||||
get id() {
|
||||
throw new Error('oversized palette queries must not scan repos')
|
||||
},
|
||||
get displayName() {
|
||||
throw new Error('oversized palette queries must not scan repos')
|
||||
}
|
||||
} as unknown as Repo
|
||||
|
||||
expect(
|
||||
searchCmdJProjectResults({
|
||||
query: oversizedQuery,
|
||||
projectGroups: [throwingGroup],
|
||||
repos: [throwingRepo],
|
||||
projects: [],
|
||||
projectHostSetups: []
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,6 +34,10 @@ type Props = {
|
||||
onSubmit: (body: string) => Promise<void>
|
||||
}
|
||||
|
||||
function hasDraftText(body: string): boolean {
|
||||
return /\S/u.test(body)
|
||||
}
|
||||
|
||||
export function DiffCommentPopover({
|
||||
lineNumber,
|
||||
startLine,
|
||||
@@ -48,6 +52,12 @@ export function DiffCommentPopover({
|
||||
onSubmit
|
||||
}: Props): React.JSX.Element {
|
||||
const [body, setBody] = useState('')
|
||||
// Why: mirror the draft into a ref so the document mousedown listener (empty
|
||||
// dep array, see below) can read the freshest body without re-registering on
|
||||
// every keystroke. Used to keep the popover open on outside-click when the
|
||||
// user has typed something, so an accidental click never discards a draft.
|
||||
const bodyRef = useRef(body)
|
||||
bodyRef.current = body
|
||||
// Why: `submitting` prevents duplicate note rows when the user
|
||||
// double-clicks the Add note button or hits Enter twice before the
|
||||
// IPC round-trip resolves. Iteration 1 made submission async and keeps the
|
||||
@@ -144,6 +154,11 @@ export function DiffCommentPopover({
|
||||
if (popoverRef.current.contains(ev.target as Node)) {
|
||||
return
|
||||
}
|
||||
// Why: outside-click is a soft dismiss, so preserve any non-whitespace
|
||||
// draft even when submit's bounded scanner would reject it as too large.
|
||||
if (hasDraftText(bodyRef.current)) {
|
||||
return
|
||||
}
|
||||
// Why: read the latest onCancel from the ref rather than closing over it
|
||||
// so the listener does not need to be re-registered on every parent
|
||||
// render (see onCancelRef comment above).
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { COMMENT_BODY_NONBLANK_SCAN_MAX_BYTES } from '@/lib/comment-body-submit-state'
|
||||
import { DiffCommentPopover } from './DiffCommentPopover'
|
||||
|
||||
const roots: Root[] = []
|
||||
|
||||
async function renderPopover(args: {
|
||||
onCancel: () => void
|
||||
onSubmit?: (body: string) => Promise<void>
|
||||
}): Promise<HTMLDivElement> {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
roots.push(root)
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<DiffCommentPopover
|
||||
lineNumber={3}
|
||||
top={10}
|
||||
onCancel={args.onCancel}
|
||||
onSubmit={args.onSubmit ?? (async () => {})}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
// Why: the textarea is a controlled React input, so writing `.value` directly is
|
||||
// invisible to React. Drive the change through the native value setter + an
|
||||
// 'input' event the way React's synthetic onChange listens for it.
|
||||
function typeInDraft(container: HTMLElement, value: string): void {
|
||||
const textarea = container.querySelector('textarea')
|
||||
if (!textarea) {
|
||||
throw new Error('textarea not found')
|
||||
}
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set
|
||||
setter?.call(textarea, value)
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
|
||||
async function clickOutside(): Promise<void> {
|
||||
await act(async () => {
|
||||
document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
})
|
||||
}
|
||||
|
||||
describe('DiffCommentPopover outside-click draft preservation', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
roots.splice(0).forEach((root) => {
|
||||
act(() => root.unmount())
|
||||
})
|
||||
document.body.replaceChildren()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('keeps the popover open on outside-click when the draft has content', async () => {
|
||||
const onCancel = vi.fn()
|
||||
const container = await renderPopover({ onCancel })
|
||||
|
||||
await act(async () => {
|
||||
typeInDraft(container, 'unsaved note')
|
||||
})
|
||||
await clickOutside()
|
||||
|
||||
expect(onCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dismisses on outside-click when the draft is empty', async () => {
|
||||
const onCancel = vi.fn()
|
||||
await renderPopover({ onCancel })
|
||||
|
||||
await clickOutside()
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dismisses on outside-click when the draft is only whitespace', async () => {
|
||||
const onCancel = vi.fn()
|
||||
const container = await renderPopover({ onCancel })
|
||||
|
||||
await act(async () => {
|
||||
typeInDraft(container, ' ')
|
||||
})
|
||||
await clickOutside()
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the popover open when non-whitespace text follows large leading whitespace', async () => {
|
||||
const onCancel = vi.fn()
|
||||
const container = await renderPopover({ onCancel })
|
||||
|
||||
await act(async () => {
|
||||
typeInDraft(container, `${' '.repeat(COMMENT_BODY_NONBLANK_SCAN_MAX_BYTES + 1)}note`)
|
||||
})
|
||||
await clickOutside()
|
||||
|
||||
expect(onCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still dismisses after the user clears a draft back to empty', async () => {
|
||||
const onCancel = vi.fn()
|
||||
const container = await renderPopover({ onCancel })
|
||||
|
||||
await act(async () => {
|
||||
typeInDraft(container, 'typed then deleted')
|
||||
})
|
||||
await act(async () => {
|
||||
typeInDraft(container, '')
|
||||
})
|
||||
await clickOutside()
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -41,10 +41,25 @@ describe('CombinedDiffFileTree navigation mapping', () => {
|
||||
it('maps branch and commit entries to combined section prefixes', () => {
|
||||
const entry: GitBranchChangeEntry = { path: 'src/view.ts', status: 'renamed' }
|
||||
|
||||
expect(getCombinedDiffFileTreeSectionKey('all', entry)).toBe('combined-branch:src/view.ts')
|
||||
expect(getCombinedDiffFileTreeSectionKey('branch', entry)).toBe('combined-branch:src/view.ts')
|
||||
expect(getCombinedDiffFileTreeSectionKey('commit', entry)).toBe('combined-commit:src/view.ts')
|
||||
})
|
||||
|
||||
it('keeps all-changes local and branch entries separate', () => {
|
||||
const localEntry: GitStatusEntry = {
|
||||
path: 'src/view.ts',
|
||||
status: 'modified',
|
||||
area: 'unstaged'
|
||||
}
|
||||
const branchEntry: GitBranchChangeEntry = { path: 'src/view.ts', status: 'modified' }
|
||||
|
||||
expect(getCombinedDiffFileTreeSectionKey('all', localEntry)).toBe('unstaged:src/view.ts')
|
||||
expect(getCombinedDiffFileTreeSectionKey('all', branchEntry)).toBe(
|
||||
'combined-branch:src/view.ts'
|
||||
)
|
||||
})
|
||||
|
||||
it('expands a collapsed target section and scrolls to its index', () => {
|
||||
const entry: GitBranchChangeEntry = { path: 'src/view.ts', status: 'modified' }
|
||||
const toggleSection = vi.fn()
|
||||
|
||||
@@ -61,14 +61,14 @@ function buildUncommittedRows(
|
||||
}
|
||||
|
||||
function buildBranchRows(
|
||||
mode: Extract<CombinedDiffFileTreeMode, 'branch' | 'commit'>,
|
||||
mode: Extract<CombinedDiffFileTreeMode, 'all' | 'branch' | 'commit'>,
|
||||
entries: readonly CombinedDiffFileTreeEntry[],
|
||||
collapsedDirectoryKeys: ReadonlySet<string>
|
||||
): CombinedDiffTreeNode[] {
|
||||
const branchEntries = entries.filter(
|
||||
(entry): entry is GitBranchChangeEntry => !isGitStatusEntry(entry)
|
||||
)
|
||||
const area: CombinedDiffBranchTreeArea = mode === 'branch' ? 'combined-branch' : 'combined-commit'
|
||||
const area: CombinedDiffBranchTreeArea = mode === 'commit' ? 'combined-commit' : 'combined-branch'
|
||||
const roots = compactSourceControlTree(buildSourceControlTree(area, branchEntries))
|
||||
return flattenSourceControlTree(roots, collapsedDirectoryKeys) as CombinedDiffTreeNode[]
|
||||
}
|
||||
@@ -149,12 +149,14 @@ export function CombinedDiffFileTree({
|
||||
|
||||
const uncommittedGroups = React.useMemo(
|
||||
() =>
|
||||
mode === 'uncommitted' ? buildUncommittedRows(filteredEntries, collapsedDirectoryKeys) : [],
|
||||
mode === 'all' || mode === 'uncommitted'
|
||||
? buildUncommittedRows(filteredEntries, collapsedDirectoryKeys)
|
||||
: [],
|
||||
[collapsedDirectoryKeys, filteredEntries, mode]
|
||||
)
|
||||
const branchRows = React.useMemo(
|
||||
() =>
|
||||
mode === 'branch' || mode === 'commit'
|
||||
mode === 'all' || mode === 'branch' || mode === 'commit'
|
||||
? buildBranchRows(mode, filteredEntries, collapsedDirectoryKeys)
|
||||
: [],
|
||||
[collapsedDirectoryKeys, filteredEntries, mode]
|
||||
@@ -280,27 +282,52 @@ export function CombinedDiffFileTree({
|
||||
'No files match the current filters.'
|
||||
)}
|
||||
</div>
|
||||
) : mode === 'uncommitted' ? (
|
||||
uncommittedGroups.map((group) => (
|
||||
<div key={group.area} className="py-1">
|
||||
<div className="px-3 pb-1 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
{group.label}
|
||||
) : mode === 'all' || mode === 'uncommitted' ? (
|
||||
<>
|
||||
{uncommittedGroups.map((group) => (
|
||||
<div key={group.area} className="py-1">
|
||||
<div className="px-3 pb-1 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
{group.label}
|
||||
</div>
|
||||
{group.rows.map((node) => (
|
||||
<CombinedDiffFileTreeRow
|
||||
key={node.key}
|
||||
node={node}
|
||||
mode={mode}
|
||||
worktreePath={worktreePath}
|
||||
activeSectionKey={activeSectionKey}
|
||||
sectionIndexByKey={sectionIndexByKey}
|
||||
isCollapsed={collapsedDirectoryKeys.has(node.key)}
|
||||
onToggleDirectory={toggleDirectory}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{group.rows.map((node) => (
|
||||
<CombinedDiffFileTreeRow
|
||||
key={node.key}
|
||||
node={node}
|
||||
mode={mode}
|
||||
worktreePath={worktreePath}
|
||||
activeSectionKey={activeSectionKey}
|
||||
sectionIndexByKey={sectionIndexByKey}
|
||||
isCollapsed={collapsedDirectoryKeys.has(node.key)}
|
||||
onToggleDirectory={toggleDirectory}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
))}
|
||||
{mode === 'all' && branchRows.length > 0 ? (
|
||||
<div className="py-1">
|
||||
<div className="px-3 pb-1 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.editor.CombinedDiffFileTree.39b6b9e4e4',
|
||||
'Committed on Branch'
|
||||
)}
|
||||
</div>
|
||||
{branchRows.map((node) => (
|
||||
<CombinedDiffFileTreeRow
|
||||
key={node.key}
|
||||
node={node}
|
||||
mode={mode}
|
||||
worktreePath={worktreePath}
|
||||
activeSectionKey={activeSectionKey}
|
||||
sectionIndexByKey={sectionIndexByKey}
|
||||
isCollapsed={collapsedDirectoryKeys.has(node.key)}
|
||||
onToggleDirectory={toggleDirectory}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
branchRows.map((node) => (
|
||||
<CombinedDiffFileTreeRow
|
||||
|
||||
@@ -8,6 +8,11 @@ import React, { useState, useEffect, useCallback, useRef, useLayoutEffect } from
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import type { editor as monacoEditor } from 'monaco-editor'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
useVirtualizedScrollAnchor,
|
||||
type VirtualizedScrollAnchor
|
||||
} from '@/hooks/useVirtualizedScrollAnchor'
|
||||
import { getVirtualizedScrollAnchorForOffset } from '@/hooks/virtualized-scroll-anchor-recording'
|
||||
import { joinPath } from '@/lib/path'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { setWithLRU } from '@/lib/scroll-cache'
|
||||
@@ -50,7 +55,10 @@ import {
|
||||
createCombinedDiffSectionIndexMap,
|
||||
handleCombinedDiffFileTreeNavigation
|
||||
} from './CombinedDiffFileTree'
|
||||
import { getCombinedDiffFileTreeSectionKey } from './combined-diff-file-tree-model'
|
||||
import {
|
||||
getCombinedDiffFileTreeSectionKey,
|
||||
type CombinedDiffFileTreeMode
|
||||
} from './combined-diff-file-tree-model'
|
||||
import {
|
||||
ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT,
|
||||
type EditorPathMutationTarget
|
||||
@@ -92,6 +100,7 @@ type CombinedDiffScrollThumb = {
|
||||
|
||||
const combinedDiffViewStateCache = new Map<string, CachedCombinedDiffViewState>()
|
||||
const combinedDiffScrollTopCache = new Map<string, number>()
|
||||
const combinedDiffScrollAnchorCache = new Map<string, VirtualizedScrollAnchor>()
|
||||
|
||||
function buildCombinedGitStatusSignature(
|
||||
sections: readonly { path: string }[],
|
||||
@@ -183,6 +192,26 @@ 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
|
||||
@@ -259,7 +288,14 @@ export default function CombinedDiffViewer({
|
||||
top: 0,
|
||||
height: COMBINED_DIFF_SCROLLBAR_THUMB_MIN_HEIGHT
|
||||
})
|
||||
const pendingRestoreScrollTopRef = useRef<number | null>(null)
|
||||
const scrollOffsetRef = useRef(combinedDiffScrollTopCache.get(viewStateKey) ?? 0)
|
||||
const scrollAnchorRef = useRef<VirtualizedScrollAnchor>(
|
||||
combinedDiffScrollAnchorCache.get(viewStateKey) ?? null
|
||||
)
|
||||
const latestDomScrollAnchorRef = useRef<VirtualizedScrollAnchor>(
|
||||
combinedDiffScrollAnchorCache.get(viewStateKey) ?? null
|
||||
)
|
||||
const directScrollInputUntilRef = useRef(0)
|
||||
const activeScrollbarDragCleanupRef = useRef<CombinedDiffScrollbarDragCleanup | null>(null)
|
||||
const loadedIndicesRef = useRef<Set<number>>(new Set())
|
||||
const loadingIndicesRef = useRef<Set<number>>(new Set())
|
||||
@@ -295,6 +331,15 @@ export default function CombinedDiffViewer({
|
||||
setScrollThumb({ visible: true, top, height })
|
||||
}, [])
|
||||
|
||||
const markDirectScrollInput = useCallback((): void => {
|
||||
directScrollInputUntilRef.current = window.performance.now() + 250
|
||||
}, [])
|
||||
|
||||
const hasDirectScrollInput = useCallback(
|
||||
() => window.performance.now() < directScrollInputUntilRef.current,
|
||||
[]
|
||||
)
|
||||
|
||||
const clearNotesCopiedResetTimer = useCallback((): void => {
|
||||
if (notesCopiedResetTimerRef.current !== null) {
|
||||
window.clearTimeout(notesCopiedResetTimerRef.current)
|
||||
@@ -361,6 +406,7 @@ export default function CombinedDiffViewer({
|
||||
|
||||
const isBranchMode = file.diffSource === 'combined-branch'
|
||||
const isCommitMode = file.diffSource === 'combined-commit'
|
||||
const isAllMode = file.diffSource === 'combined-all'
|
||||
const branchCompare =
|
||||
file.branchCompare?.baseOid && file.branchCompare.headOid && file.branchCompare.mergeBase
|
||||
? file.branchCompare
|
||||
@@ -385,12 +431,32 @@ export default function CombinedDiffViewer({
|
||||
const branchEntries = React.useMemo<GitBranchChangeEntry[]>(() => {
|
||||
return getCombinedBranchEntries(file.branchEntriesSnapshot, liveBranchEntries)
|
||||
}, [file.branchEntriesSnapshot, liveBranchEntries])
|
||||
const renderableBranchEntries = React.useMemo(
|
||||
() => (branchCompare ? branchEntries : []),
|
||||
[branchCompare, branchEntries]
|
||||
)
|
||||
const commitEntries = React.useMemo<GitBranchChangeEntry[]>(
|
||||
() => file.commitEntriesSnapshot ?? [],
|
||||
[file.commitEntriesSnapshot]
|
||||
)
|
||||
const entries = isBranchMode ? branchEntries : isCommitMode ? commitEntries : uncommittedEntries
|
||||
const treeMode = isBranchMode ? 'branch' : isCommitMode ? 'commit' : 'uncommitted'
|
||||
const allEntries = React.useMemo(
|
||||
() => [...uncommittedEntries, ...renderableBranchEntries],
|
||||
[renderableBranchEntries, uncommittedEntries]
|
||||
)
|
||||
const entries = isAllMode
|
||||
? allEntries
|
||||
: isBranchMode
|
||||
? renderableBranchEntries
|
||||
: isCommitMode
|
||||
? commitEntries
|
||||
: uncommittedEntries
|
||||
const treeMode = isAllMode
|
||||
? 'all'
|
||||
: isBranchMode
|
||||
? 'branch'
|
||||
: isCommitMode
|
||||
? 'commit'
|
||||
: 'uncommitted'
|
||||
const hasUncommittedEntriesSnapshot = file.uncommittedEntriesSnapshot !== undefined
|
||||
const shouldAutoReloadFromGitStatus = shouldAutoReloadCombinedDiffFromGitStatus({
|
||||
mode: treeMode,
|
||||
@@ -443,15 +509,23 @@ export default function CombinedDiffViewer({
|
||||
// Why: switching tabs or worktrees unmounts this viewer through the shared
|
||||
// editor surface above it. Cache the rendered combined-diff state by the
|
||||
// visible pane key so remounting can restore loaded sections and scroll
|
||||
// position instead of flashing back to "Loading..." and forcing the user to
|
||||
// find their place again.
|
||||
useEffect(() => {
|
||||
// position before the remounted surface paints at the top.
|
||||
useLayoutEffect(() => {
|
||||
const cached = combinedDiffViewStateCache.get(viewStateKey)
|
||||
const canRestoreSnapshotSectionsByKey =
|
||||
hasUncommittedEntriesSnapshot &&
|
||||
cached !== undefined &&
|
||||
cachedCombinedDiffSectionsMatchEntries({
|
||||
entries,
|
||||
sections: cached.sections,
|
||||
treeMode
|
||||
})
|
||||
const canRestoreCachedSections =
|
||||
cached &&
|
||||
cached.entrySignature === entrySignature &&
|
||||
(cached.gitStatusSignature ?? '') ===
|
||||
buildCombinedGitStatusSignature(cached.sections, gitStatusEntries) &&
|
||||
(cached.entrySignature === entrySignature || canRestoreSnapshotSectionsByKey) &&
|
||||
(!shouldAutoReloadFromGitStatus ||
|
||||
(cached.gitStatusSignature ?? '') ===
|
||||
buildCombinedGitStatusSignature(cached.sections, gitStatusEntries)) &&
|
||||
(cached.sections.length > 0 || entries.length === 0)
|
||||
if (canRestoreCachedSections && cached) {
|
||||
const collapsedPreference = combinedDiffCollapsedPreference
|
||||
@@ -469,15 +543,18 @@ export default function CombinedDiffViewer({
|
||||
cached.loadedIndices.filter((index) => !restoredSections[index]?.loading)
|
||||
)
|
||||
loadingIndicesRef.current.clear()
|
||||
pendingRestoreScrollTopRef.current =
|
||||
combinedDiffScrollTopCache.get(viewStateKey) ?? cached.scrollTop
|
||||
scrollOffsetRef.current = combinedDiffScrollTopCache.get(viewStateKey) ?? cached.scrollTop
|
||||
scrollAnchorRef.current = combinedDiffScrollAnchorCache.get(viewStateKey) ?? null
|
||||
latestDomScrollAnchorRef.current = scrollAnchorRef.current
|
||||
return
|
||||
}
|
||||
|
||||
pendingRestoreScrollTopRef.current = combinedDiffScrollTopCache.get(viewStateKey) ?? null
|
||||
scrollOffsetRef.current = combinedDiffScrollTopCache.get(viewStateKey) ?? 0
|
||||
scrollAnchorRef.current = combinedDiffScrollAnchorCache.get(viewStateKey) ?? null
|
||||
latestDomScrollAnchorRef.current = scrollAnchorRef.current
|
||||
setSections(
|
||||
entries.map((entry) => ({
|
||||
key: `${'area' in entry ? entry.area : (file.diffSource ?? 'compare')}:${entry.path}`,
|
||||
key: getCombinedDiffFileTreeSectionKey(treeMode, entry),
|
||||
path: entry.path,
|
||||
status: entry.status,
|
||||
area: 'area' in entry ? entry.area : undefined,
|
||||
@@ -500,7 +577,15 @@ export default function CombinedDiffViewer({
|
||||
loadSchedulerRef.current.reset()
|
||||
generationRef.current += 1
|
||||
setGeneration((prev) => prev + 1)
|
||||
}, [entries, entrySignature, file.diffSource, gitStatusEntries, viewStateKey])
|
||||
}, [
|
||||
entries,
|
||||
entrySignature,
|
||||
gitStatusEntries,
|
||||
hasUncommittedEntriesSnapshot,
|
||||
shouldAutoReloadFromGitStatus,
|
||||
treeMode,
|
||||
viewStateKey
|
||||
])
|
||||
|
||||
const loadSectionNow = useCallback(
|
||||
async (index: number) => {
|
||||
@@ -510,11 +595,13 @@ export default function CombinedDiffViewer({
|
||||
loadingIndicesRef.current.add(index)
|
||||
|
||||
const gen = generationRef.current
|
||||
const entries = isBranchMode
|
||||
? branchEntries
|
||||
: isCommitMode
|
||||
? commitEntries
|
||||
: uncommittedEntries
|
||||
const entries = isAllMode
|
||||
? allEntries
|
||||
: isBranchMode
|
||||
? renderableBranchEntries
|
||||
: isCommitMode
|
||||
? commitEntries
|
||||
: uncommittedEntries
|
||||
const entry = entries[index]
|
||||
if (!entry) {
|
||||
loadingIndicesRef.current.delete(index)
|
||||
@@ -527,7 +614,7 @@ export default function CombinedDiffViewer({
|
||||
const connectionId = getConnectionId(file.worktreeId) ?? undefined
|
||||
const state = useAppStore.getState()
|
||||
const fileSettings = settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId)
|
||||
if (isBranchMode && branchCompare) {
|
||||
if ((isBranchMode || (isAllMode && !('area' in entry))) && branchCompare) {
|
||||
result = await withDiffSectionLoadTimeout(
|
||||
getRuntimeGitBranchDiff(
|
||||
{
|
||||
@@ -629,14 +716,16 @@ export default function CombinedDiffViewer({
|
||||
branchCompare?.baseOid,
|
||||
branchCompare?.headOid,
|
||||
branchCompare?.mergeBase,
|
||||
branchEntries,
|
||||
allEntries,
|
||||
commitCompare?.commitOid,
|
||||
commitCompare?.parentOid,
|
||||
commitEntries,
|
||||
file.filePath,
|
||||
file.runtimeEnvironmentId,
|
||||
isAllMode,
|
||||
isBranchMode,
|
||||
isCommitMode,
|
||||
renderableBranchEntries,
|
||||
uncommittedEntries
|
||||
]
|
||||
)
|
||||
@@ -746,6 +835,7 @@ export default function CombinedDiffViewer({
|
||||
})
|
||||
},
|
||||
overscan: COMBINED_DIFF_OVERSCAN,
|
||||
initialOffset: () => scrollOffsetRef.current,
|
||||
getItemKey: (index) => {
|
||||
const section = sections[index]
|
||||
if (!section) {
|
||||
@@ -754,6 +844,103 @@ export default function CombinedDiffViewer({
|
||||
return `${section.key}:${section.collapsed ? 'collapsed' : 'expanded'}:${generation}`
|
||||
}
|
||||
})
|
||||
const combinedDiffTotalSize = virtualizer.getTotalSize()
|
||||
const getCombinedDiffSectionKey = useCallback((section: DiffSection): string => section.key, [])
|
||||
const getCombinedDiffSectionElementKey = useCallback(
|
||||
(element: Element): string | null =>
|
||||
element instanceof HTMLElement ? (element.dataset.combinedDiffSectionKey ?? null) : null,
|
||||
[]
|
||||
)
|
||||
const recordCombinedDiffVirtualScrollAnchor = useCallback(
|
||||
(scrollTop: number): void => {
|
||||
scrollAnchorRef.current = getVirtualizedScrollAnchorForOffset({
|
||||
getRowKey: getCombinedDiffSectionKey,
|
||||
rows: sectionsRef.current,
|
||||
scrollTop,
|
||||
virtualItems: virtualizer.getVirtualItems()
|
||||
})
|
||||
latestDomScrollAnchorRef.current = null
|
||||
},
|
||||
[getCombinedDiffSectionKey, virtualizer]
|
||||
)
|
||||
const recordCombinedDiffDomScrollAnchor = useCallback((): boolean => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container) {
|
||||
return false
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const visibleRows = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('[data-combined-diff-section-row]')
|
||||
)
|
||||
.map((row) => {
|
||||
const key = row.dataset.combinedDiffSectionKey
|
||||
if (!key || !row.isConnected) {
|
||||
return null
|
||||
}
|
||||
const rect = row.getBoundingClientRect()
|
||||
if (
|
||||
rect.height <= 0 ||
|
||||
rect.bottom <= containerRect.top ||
|
||||
rect.top >= containerRect.bottom
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return { key, rect }
|
||||
})
|
||||
.filter((row): row is { key: string; rect: DOMRect } => row !== null)
|
||||
.sort((a, b) => a.rect.top - b.rect.top)
|
||||
|
||||
const firstVisible = visibleRows[0]
|
||||
if (!firstVisible) {
|
||||
return false
|
||||
}
|
||||
|
||||
const anchor: NonNullable<VirtualizedScrollAnchor> = {
|
||||
fallbackKeys: visibleRows.slice(1).map((row) => row.key),
|
||||
key: firstVisible.key,
|
||||
offset: Math.min(
|
||||
firstVisible.rect.height,
|
||||
Math.max(0, containerRect.top - firstVisible.rect.top)
|
||||
)
|
||||
}
|
||||
scrollAnchorRef.current = anchor
|
||||
latestDomScrollAnchorRef.current = anchor
|
||||
return true
|
||||
}, [])
|
||||
const writeCombinedDiffScrollAnchor = useCallback((): void => {
|
||||
const anchor = scrollAnchorRef.current
|
||||
if (anchor) {
|
||||
setWithLRU(combinedDiffScrollAnchorCache, viewStateKey, anchor)
|
||||
} else {
|
||||
combinedDiffScrollAnchorCache.delete(viewStateKey)
|
||||
}
|
||||
}, [viewStateKey])
|
||||
const persistCombinedDiffScrollAnchor = useCallback(
|
||||
(refreshDomAnchor = true): void => {
|
||||
if (refreshDomAnchor) {
|
||||
recordCombinedDiffDomScrollAnchor()
|
||||
}
|
||||
writeCombinedDiffScrollAnchor()
|
||||
},
|
||||
[recordCombinedDiffDomScrollAnchor, writeCombinedDiffScrollAnchor]
|
||||
)
|
||||
|
||||
useVirtualizedScrollAnchor({
|
||||
anchorRef: scrollAnchorRef,
|
||||
getItemElementKey: getCombinedDiffSectionElementKey,
|
||||
getRowKey: getCombinedDiffSectionKey,
|
||||
hasDirectScrollInput,
|
||||
itemElementSelector: '[data-combined-diff-section-row]',
|
||||
recordAnchorOnCleanup: false,
|
||||
recordAnchorOnScroll: false,
|
||||
rows: sections,
|
||||
scrollElementRef: scrollContainerRef,
|
||||
shouldSkipRestore: hasDirectScrollInput,
|
||||
scrollOffsetRef,
|
||||
totalSize: combinedDiffTotalSize,
|
||||
virtualizer
|
||||
})
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Why: inline vs side-by-side can change Monaco content heights across
|
||||
@@ -799,13 +986,18 @@ export default function CombinedDiffViewer({
|
||||
)
|
||||
const handleTreeNavigate = useCallback(
|
||||
(entry: GitStatusEntry | GitBranchChangeEntry) => {
|
||||
markDirectScrollInput()
|
||||
const navigatedIndex = handleCombinedDiffFileTreeNavigation({
|
||||
mode: treeMode,
|
||||
entry,
|
||||
sections: sectionsRef.current,
|
||||
sectionIndexByKey,
|
||||
toggleSection,
|
||||
scrollToIndex: (index) => virtualizer.scrollToIndex(index, { align: 'start' })
|
||||
scrollToIndex: (index) => {
|
||||
scrollAnchorRef.current = null
|
||||
latestDomScrollAnchorRef.current = null
|
||||
virtualizer.scrollToIndex(index, { align: 'start' })
|
||||
}
|
||||
})
|
||||
if (navigatedIndex !== null) {
|
||||
// Why: tree navigation is also the user's explicit "show me this diff"
|
||||
@@ -820,6 +1012,7 @@ export default function CombinedDiffViewer({
|
||||
},
|
||||
[
|
||||
entrySignature,
|
||||
markDirectScrollInput,
|
||||
requestCombinedDiffSectionReload,
|
||||
sectionIndexByKey,
|
||||
toggleSection,
|
||||
@@ -855,7 +1048,7 @@ export default function CombinedDiffViewer({
|
||||
}, [combinedGitStatusSignature, requestCombinedDiffSectionReload, shouldAutoReloadFromGitStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (treeMode !== 'uncommitted') {
|
||||
if (treeMode !== 'all' && treeMode !== 'uncommitted') {
|
||||
return
|
||||
}
|
||||
const handler = (event: Event): void => {
|
||||
@@ -927,7 +1120,9 @@ export default function CombinedDiffViewer({
|
||||
removed: section.removed
|
||||
}
|
||||
|
||||
if (isBranchMode && branchCompare) {
|
||||
const isBranchEntry = section.area === undefined
|
||||
|
||||
if ((isBranchMode || (isAllMode && isBranchEntry)) && branchCompare) {
|
||||
openBranchDiff(file.worktreeId, file.filePath, entry, branchCompare, language)
|
||||
return
|
||||
}
|
||||
@@ -952,6 +1147,7 @@ export default function CombinedDiffViewer({
|
||||
file.filePath,
|
||||
file.runtimeEnvironmentId,
|
||||
file.worktreeId,
|
||||
isAllMode,
|
||||
isBranchMode,
|
||||
isCommitMode,
|
||||
openBranchDiff,
|
||||
@@ -1067,20 +1263,82 @@ export default function CombinedDiffViewer({
|
||||
|
||||
const cached = combinedDiffViewStateCache.get(viewStateKey)
|
||||
if (cached && cached.entrySignature === entrySignature) {
|
||||
pendingRestoreScrollTopRef.current =
|
||||
combinedDiffScrollTopCache.get(viewStateKey) ?? cached.scrollTop
|
||||
scrollOffsetRef.current = combinedDiffScrollTopCache.get(viewStateKey) ?? cached.scrollTop
|
||||
}
|
||||
|
||||
const updateCachedScrollPosition = (): void => {
|
||||
let anchorIdleTimerId: number | null = null
|
||||
let anchorFrameId: number | null = null
|
||||
const cancelScheduledAnchorPersist = (): void => {
|
||||
if (anchorIdleTimerId !== null) {
|
||||
window.clearTimeout(anchorIdleTimerId)
|
||||
anchorIdleTimerId = null
|
||||
}
|
||||
if (anchorFrameId !== null) {
|
||||
window.cancelAnimationFrame(anchorFrameId)
|
||||
anchorFrameId = null
|
||||
}
|
||||
}
|
||||
const scheduleSettledAnchorPersist = (): void => {
|
||||
cancelScheduledAnchorPersist()
|
||||
anchorIdleTimerId = window.setTimeout(() => {
|
||||
anchorIdleTimerId = null
|
||||
if (hasDirectScrollInput()) {
|
||||
// Why: the first idle timer can fire while wheel input is still
|
||||
// active and TanStack may be showing a transitional virtual window.
|
||||
scheduleSettledAnchorPersist()
|
||||
return
|
||||
}
|
||||
anchorFrameId = window.requestAnimationFrame(() => {
|
||||
anchorFrameId = null
|
||||
persistCombinedDiffScrollAnchor()
|
||||
})
|
||||
}, 150)
|
||||
}
|
||||
|
||||
const updateCachedScrollPosition = ({
|
||||
recordDomAnchor,
|
||||
scheduleSettled,
|
||||
scrollTop,
|
||||
writeAnchor
|
||||
}: {
|
||||
recordDomAnchor: boolean
|
||||
scheduleSettled: boolean
|
||||
scrollTop: number
|
||||
writeAnchor: boolean
|
||||
}): void => {
|
||||
const existing = combinedDiffViewStateCache.get(viewStateKey)
|
||||
setWithLRU(combinedDiffScrollTopCache, viewStateKey, container.scrollTop)
|
||||
scrollOffsetRef.current = scrollTop
|
||||
setWithLRU(combinedDiffScrollTopCache, viewStateKey, scrollTop)
|
||||
if (writeAnchor) {
|
||||
if (recordDomAnchor) {
|
||||
persistCombinedDiffScrollAnchor()
|
||||
} else {
|
||||
writeCombinedDiffScrollAnchor()
|
||||
}
|
||||
}
|
||||
if (scheduleSettled) {
|
||||
scheduleSettledAnchorPersist()
|
||||
}
|
||||
updateCombinedDiffScrollbar()
|
||||
if (!existing || existing.entrySignature !== entrySignature) {
|
||||
return
|
||||
}
|
||||
setWithLRU(combinedDiffViewStateCache, viewStateKey, {
|
||||
...existing,
|
||||
scrollTop: container.scrollTop
|
||||
scrollTop
|
||||
})
|
||||
}
|
||||
const handleScroll = (): void => {
|
||||
if (!hasDirectScrollInput()) {
|
||||
updateCombinedDiffScrollbar()
|
||||
return
|
||||
}
|
||||
recordCombinedDiffVirtualScrollAnchor(container.scrollTop)
|
||||
updateCachedScrollPosition({
|
||||
recordDomAnchor: false,
|
||||
scheduleSettled: true,
|
||||
scrollTop: container.scrollTop,
|
||||
writeAnchor: true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1091,68 +1349,77 @@ export default function CombinedDiffViewer({
|
||||
updateCombinedDiffScrollbar()
|
||||
const resizeObserver = new ResizeObserver(updateCombinedDiffScrollbar)
|
||||
resizeObserver.observe(container)
|
||||
container.addEventListener('scroll', updateCachedScrollPosition)
|
||||
container.addEventListener('scroll', handleScroll)
|
||||
return () => {
|
||||
updateCachedScrollPosition()
|
||||
cancelScheduledAnchorPersist()
|
||||
if (latestDomScrollAnchorRef.current) {
|
||||
scrollAnchorRef.current = latestDomScrollAnchorRef.current
|
||||
}
|
||||
updateCachedScrollPosition({
|
||||
recordDomAnchor: false,
|
||||
scheduleSettled: false,
|
||||
scrollTop: scrollOffsetRef.current,
|
||||
writeAnchor: true
|
||||
})
|
||||
resizeObserver.disconnect()
|
||||
container.removeEventListener('scroll', updateCachedScrollPosition)
|
||||
container.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
}, [entrySignature, sections.length, updateCombinedDiffScrollbar, viewStateKey])
|
||||
}, [
|
||||
entrySignature,
|
||||
hasDirectScrollInput,
|
||||
persistCombinedDiffScrollAnchor,
|
||||
recordCombinedDiffVirtualScrollAnchor,
|
||||
sections.length,
|
||||
updateCombinedDiffScrollbar,
|
||||
writeCombinedDiffScrollAnchor,
|
||||
viewStateKey
|
||||
])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
updateCombinedDiffScrollbar()
|
||||
}, [sectionHeights, sections, updateCombinedDiffScrollbar])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = scrollContainerRef.current
|
||||
const targetScrollTop = pendingRestoreScrollTopRef.current
|
||||
if (!container || targetScrollTop === null) {
|
||||
if (!container || container.scrollTop <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let frameId = 0
|
||||
let attempts = 0
|
||||
|
||||
const restoreScrollPosition = (): void => {
|
||||
const liveContainer = scrollContainerRef.current
|
||||
const liveTarget = pendingRestoreScrollTopRef.current
|
||||
if (!liveContainer || liveTarget === null) {
|
||||
let frameId: number | null = null
|
||||
const timerId = window.setTimeout(() => {
|
||||
if (!container.isConnected || hasDirectScrollInput()) {
|
||||
return
|
||||
}
|
||||
frameId = window.requestAnimationFrame(() => {
|
||||
frameId = null
|
||||
persistCombinedDiffScrollAnchor()
|
||||
})
|
||||
}, 300)
|
||||
|
||||
const maxScrollTop = Math.max(0, liveContainer.scrollHeight - liveContainer.clientHeight)
|
||||
const nextScrollTop = Math.min(liveTarget, maxScrollTop)
|
||||
liveContainer.scrollTop = nextScrollTop
|
||||
setWithLRU(combinedDiffScrollTopCache, viewStateKey, nextScrollTop)
|
||||
|
||||
if (Math.abs(liveContainer.scrollTop - liveTarget) <= 1 || maxScrollTop >= liveTarget) {
|
||||
pendingRestoreScrollTopRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
attempts += 1
|
||||
if (attempts < 30) {
|
||||
frameId = window.requestAnimationFrame(restoreScrollPosition)
|
||||
return () => {
|
||||
window.clearTimeout(timerId)
|
||||
if (frameId !== null) {
|
||||
window.cancelAnimationFrame(frameId)
|
||||
}
|
||||
}
|
||||
|
||||
restoreScrollPosition()
|
||||
return () => window.cancelAnimationFrame(frameId)
|
||||
}, [sectionHeights, sections, viewStateKey])
|
||||
}, [
|
||||
hasDirectScrollInput,
|
||||
persistCombinedDiffScrollAnchor,
|
||||
sectionHeights,
|
||||
sections,
|
||||
updateCombinedDiffScrollbar
|
||||
])
|
||||
|
||||
const openAlternateDiff = useCallback(() => {
|
||||
if (!file.combinedAlternate) {
|
||||
return
|
||||
}
|
||||
|
||||
if (file.combinedAlternate.source === 'combined-uncommitted') {
|
||||
if (file.combinedAlternate.source === 'combined-all') {
|
||||
openAllDiffs(file.worktreeId, file.filePath)
|
||||
return
|
||||
}
|
||||
|
||||
if (branchSummary && branchSummary.status === 'ready') {
|
||||
openBranchAllDiffs(file.worktreeId, file.filePath, branchSummary, {
|
||||
source: 'combined-uncommitted'
|
||||
source: 'combined-all'
|
||||
})
|
||||
}
|
||||
}, [branchSummary, file, openAllDiffs, openBranchAllDiffs])
|
||||
@@ -1165,6 +1432,7 @@ export default function CombinedDiffViewer({
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
markDirectScrollInput()
|
||||
const track = event.currentTarget
|
||||
const thumb =
|
||||
event.target instanceof HTMLElement
|
||||
@@ -1203,6 +1471,7 @@ export default function CombinedDiffViewer({
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent): void => {
|
||||
moveEvent.preventDefault()
|
||||
markDirectScrollInput()
|
||||
container.scrollTop = getScrollTopForPointer(moveEvent.clientY, grabOffset)
|
||||
updateCombinedDiffScrollbar()
|
||||
}
|
||||
@@ -1220,7 +1489,7 @@ export default function CombinedDiffViewer({
|
||||
})
|
||||
activeScrollbarDragCleanupRef.current = cleanupPointerDrag
|
||||
},
|
||||
[cleanupActiveScrollbarDrag, updateCombinedDiffScrollbar]
|
||||
[cleanupActiveScrollbarDrag, markDirectScrollInput, updateCombinedDiffScrollbar]
|
||||
)
|
||||
|
||||
const handleCopyNotes = useCallback(async (): Promise<void> => {
|
||||
@@ -1447,7 +1716,7 @@ export default function CombinedDiffViewer({
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{sections.length}{' '}
|
||||
{translate('auto.components.editor.CombinedDiffViewer.7e7ca60816', 'changed files')}
|
||||
{isBranchMode && branchCompare
|
||||
{(isAllMode || isBranchMode) && branchCompare
|
||||
? translate(
|
||||
'auto.components.editor.CombinedDiffViewer.6094135eec',
|
||||
' vs {{value0}}',
|
||||
@@ -1524,7 +1793,7 @@ export default function CombinedDiffViewer({
|
||||
)
|
||||
: translate(
|
||||
'auto.components.editor.CombinedDiffViewer.982d14bfa5',
|
||||
'Open Uncommitted Diff'
|
||||
'Open All Changes'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
@@ -1564,12 +1833,11 @@ export default function CombinedDiffViewer({
|
||||
<div
|
||||
ref={setScrollContainerRef}
|
||||
className="combined-diff-scroll-container h-full overflow-auto pr-5 scrollbar-editor"
|
||||
onWheel={markDirectScrollInput}
|
||||
onTouchMove={markDirectScrollInput}
|
||||
>
|
||||
{skippedConflictNotice}
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
<div className="relative w-full" style={{ height: `${combinedDiffTotalSize}px` }}>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const section = sections[virtualItem.index]
|
||||
if (!section) {
|
||||
@@ -1580,6 +1848,8 @@ export default function CombinedDiffViewer({
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
data-index={virtualItem.index}
|
||||
data-combined-diff-section-row
|
||||
data-combined-diff-section-key={section.key}
|
||||
ref={virtualizer.measureElement}
|
||||
className="absolute left-0 top-0 w-full"
|
||||
// Why: `top` preserves sticky file headers inside each row;
|
||||
@@ -1601,7 +1871,7 @@ export default function CombinedDiffViewer({
|
||||
toggleSection={toggleSection}
|
||||
openSection={openSection}
|
||||
openSectionTitle={
|
||||
isBranchMode || isCommitMode ? 'Open diff' : 'Open in editor'
|
||||
isAllMode || isBranchMode || isCommitMode ? 'Open diff' : 'Open in editor'
|
||||
}
|
||||
setSectionHeights={setSectionHeights}
|
||||
setSections={setSections}
|
||||
|
||||
@@ -199,7 +199,8 @@ export function EditorContent({
|
||||
|
||||
const isCombinedDiff =
|
||||
activeFile.mode === 'diff' &&
|
||||
(activeFile.diffSource === 'combined-uncommitted' ||
|
||||
(activeFile.diffSource === 'combined-all' ||
|
||||
activeFile.diffSource === 'combined-uncommitted' ||
|
||||
activeFile.diffSource === 'combined-branch' ||
|
||||
activeFile.diffSource === 'combined-commit')
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user