From 0880021665be55ccd66c2ea6c9346e25f4e3666c Mon Sep 17 00:00:00 2001 From: gatsby74 <166927047+gatsby74@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:26:01 +0200 Subject: [PATCH 01/18] feat(shortcuts): add assignable shortcut to toggle sleeping workspaces Adds an unbound sidebar.sleepingWorkspaces.toggle keybinding action, wires it to the global shortcut dispatcher, and shows assigned shortcut labels in the workspace-board filter menu. Also resolves the PR against current main and removes an unnecessary exhaustive-switch fallback that blocked current lint. --- src/renderer/src/App.tsx | 15 ++++++++ .../src/components/sidebar/SidebarFilter.tsx | 33 +++++++++++------ .../task-page-github-work-item-status.ts | 3 -- src/shared/keybindings.test.ts | 36 +++++++++++++++++++ src/shared/keybindings.ts | 21 +++++++++++ 5 files changed, 94 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 3bcbf8887c2..1071ff9ae8d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1604,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 diff --git a/src/renderer/src/components/sidebar/SidebarFilter.tsx b/src/renderer/src/components/sidebar/SidebarFilter.tsx index 7d21c357afd..9927e4e9db2 100644 --- a/src/renderer/src/components/sidebar/SidebarFilter.tsx +++ b/src/renderer/src/components/sidebar/SidebarFilter.tsx @@ -13,10 +13,12 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuSeparator, + DropdownMenuShortcut, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import { useShortcutLabel } from '@/hooks/useShortcutLabel' import { searchRepos } from '@/lib/repo-search' import { cn } from '@/lib/utils' import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' @@ -37,6 +39,9 @@ const SidebarFilter = React.memo(function SidebarFilter({ }: SidebarFilterProps) { const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const setShowSleepingWorkspaces = useAppStore((s) => s.setShowSleepingWorkspaces) + // Surface the user-assigned shortcut here so the filter menu doubles as its + // discovery point ('Unassigned' until they bind one in Settings → Shortcuts). + const sleepingShortcut = useShortcutLabel('sidebar.sleepingWorkspaces.toggle') const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) @@ -183,6 +188,7 @@ const SidebarFilter = React.memo(function SidebarFilter({ label={translate('auto.components.sidebar.SidebarFilter.638a2d221d', 'Hide sleeping')} checked={!showSleepingWorkspaces} onChange={(hideSleeping) => setShowSleepingWorkspaces(!hideSleeping)} + shortcutLabel={sleepingShortcut === 'Unassigned' ? undefined : sleepingShortcut} /> } @@ -333,12 +339,14 @@ function FilterToggleRow({ icon, label, checked, - onChange + onChange, + shortcutLabel }: { icon: React.ReactNode label: string checked: boolean onChange: (next: boolean) => void + shortcutLabel?: string }) { return ( ) diff --git a/src/renderer/src/components/task-page-github-work-item-status.ts b/src/renderer/src/components/task-page-github-work-item-status.ts index a8028e6a43b..ab9d0361032 100644 --- a/src/renderer/src/components/task-page-github-work-item-status.ts +++ b/src/renderer/src/components/task-page-github-work-item-status.ts @@ -65,8 +65,5 @@ export function getTaskPageGitHubPRIconTone(item: GitHubWorkItemStatusItem): str return 'text-purple-600 dark:text-purple-300' case 'closed': return 'text-rose-600 dark:text-rose-300' - default: - // Fallback for any unexpected state - return 'text-muted-foreground' } } diff --git a/src/shared/keybindings.test.ts b/src/shared/keybindings.test.ts index 83db7e37efd..e69917db03e 100644 --- a/src/shared/keybindings.test.ts +++ b/src/shared/keybindings.test.ts @@ -456,6 +456,42 @@ describe('keybindings', () => { ) }) + it('keeps the sleeping-workspaces toggle unassigned until users customize it', () => { + const binding = { + key: 's', + code: 'KeyS', + control: true, + meta: false, + alt: true, + shift: false + } + + // Ships unbound on every platform (issue #5209): assign-it-yourself. + expect(getEffectiveKeybindingsForAction('sidebar.sleepingWorkspaces.toggle', 'darwin')).toEqual( + [] + ) + expect(getEffectiveKeybindingsForAction('sidebar.sleepingWorkspaces.toggle', 'linux')).toEqual( + [] + ) + expect(getEffectiveKeybindingsForAction('sidebar.sleepingWorkspaces.toggle', 'win32')).toEqual( + [] + ) + expect(keybindingMatchesAction('sidebar.sleepingWorkspaces.toggle', binding, 'linux')).toBe( + false + ) + expect( + keybindingMatchesAction('sidebar.sleepingWorkspaces.toggle', binding, 'linux', { + 'sidebar.sleepingWorkspaces.toggle': ['Mod+Alt+S'] + }) + ).toBe(true) + + const definition = getKeybindingDefinition('sidebar.sleepingWorkspaces.toggle') + expect(definition?.title).toBe('Toggle Sleeping Workspaces') + expect(definition?.searchKeywords).toEqual( + expect.arrayContaining(['sleeping', 'workspaces', 'filter']) + ) + }) + it('defines floating workspace panel action metadata', () => { const actionIds = [ 'floatingWorkspace.maximize' as KeybindingActionId, diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts index 57a8b775267..6d4c3e64fb7 100644 --- a/src/shared/keybindings.ts +++ b/src/shared/keybindings.ts @@ -48,6 +48,7 @@ export type KeybindingActionId = | 'sidebar.sourceControl.toggle' | 'sidebar.checks.toggle' | 'sidebar.ports.toggle' + | 'sidebar.sleepingWorkspaces.toggle' | 'sidebar.focusWorktreeList' | 'floatingTerminal.toggle' | 'floatingWorkspace.maximize' @@ -393,6 +394,26 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ win32: [] } }, + { + id: 'sidebar.sleepingWorkspaces.toggle', + title: 'Toggle Sleeping Workspaces', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'sidebar', + 'sleeping', + 'asleep', + 'workspaces', + 'worktree', + 'filter', + 'show', + 'hide' + ], + // Why: ship unbound — issue #5209 asks to "assign a shortcut", so we avoid + // claiming a cross-platform chord and let users bind it in Settings. + defaultBindings: platformBindings([]) + }, { id: 'sidebar.focusWorktreeList', title: 'Focus worktree list', From cf39bd7abe0c19a98903caf1684bdc8fd3c4af04 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:55:59 -0700 Subject: [PATCH 02/18] Find projects and repo groups from Cmd+J (#6193) Co-authored-by: Orca --- .../src/components/WorktreeJumpPalette.tsx | 223 ++++++++++- .../cmd-j/palette-project-results.ts | 262 +++++++++++++ .../components/cmd-j/palette-results.test.ts | 191 ++++++++++ .../src/components/sidebar/WorktreeList.tsx | 352 +++++++++++++++++- .../sidebar/visible-worktrees.test.ts | 11 + .../components/sidebar/visible-worktrees.ts | 8 +- .../sidebar/worktree-list-groups.ts | 35 +- .../task-page-github-work-item-status.ts | 1 - src/renderer/src/i18n/locales/en.json | 16 +- src/renderer/src/i18n/locales/es.json | 16 +- src/renderer/src/i18n/locales/ja.json | 16 +- src/renderer/src/i18n/locales/ko.json | 16 +- src/renderer/src/i18n/locales/zh.json | 16 +- .../lib/scroll-to-current-workspace-status.ts | 17 +- src/renderer/src/store/slices/ui.ts | 25 ++ 15 files changed, 1156 insertions(+), 49 deletions(-) create mode 100644 src/renderer/src/components/cmd-j/palette-project-results.ts diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 55adcae851f..f43cafa131d 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -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() + 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( + () => + 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( () => [ ...paletteSections.visibleWorktreeItems, + ...paletteSections.visibleProjectTargetItems, ...paletteSections.visibleMiddleItems, ...paletteSections.visibleOpenTabItems ], @@ -864,11 +957,17 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const listEntries = useMemo(() => { 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 { )} )} - + {entry.match.displayNameRange ? ( 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' + )} + > +
+
+
+
+
+
+ + {result.title} + + + {badgeLabel} + +
+
+ {isProject ? ( +
+ + + + {result.repo.displayName} + +
+ ) : null} +
+
+ + ) + } + if (entry.type === 'settings' || entry.type === 'quick-action') { const result = entry.result const Icon = result.icon diff --git a/src/renderer/src/components/cmd-j/palette-project-results.ts b/src/renderer/src/components/cmd-j/palette-project-results.ts new file mode 100644 index 00000000000..e4b26441bde --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-project-results.ts @@ -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 +}): 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() + 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 +}): 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 +}): 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) +} diff --git a/src/renderer/src/components/cmd-j/palette-results.test.ts b/src/renderer/src/components/cmd-j/palette-results.test.ts index d6e0257640e..1d034b1a507 100644 --- a/src/renderer/src/components/cmd-j/palette-results.test.ts +++ b/src/renderer/src/components/cmd-j/palette-results.test.ts @@ -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([]) + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index a46ed4c2d47..1236d6dad5c 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -78,6 +78,7 @@ import { ALL_GROUP_KEY, PINNED_GROUP_KEY, buildRows, + getProjectGroupHeaderKey, getGroupKeysForWorktree, getLineageGroupKey } from './worktree-list-groups' @@ -198,7 +199,7 @@ import { parseExecutionHostId } from '../../../../shared/execution-host' import { getRepoHeaderCreateState } from './repo-header-create-state' -import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui' +import type { PendingSidebarRowReveal, PendingSidebarWorktreeReveal } from '@/store/slices/ui' import { getRepositoryIconSectionId } from '@/components/settings/repository-settings-targets' import { keybindingMatchesAction } from '../../../../shared/keybindings' import { ProjectGroupNameDialog } from './ProjectGroupNameDialog' @@ -439,6 +440,130 @@ function revealMountedWorktreeElement( return revealElementInScrollContainer(container, element, behavior) ? element : null } +function revealMountedSidebarRowElement( + container: HTMLElement, + rowKey: string, + behavior: ScrollBehavior +): HTMLElement | null { + const element = document.getElementById(getWorktreeOptionId(rowKey)) + if (!element || !container.contains(element)) { + return null + } + return revealElementInScrollContainer(container, element, behavior) ? element : null +} + +function getRenderRowSidebarKey(row: RenderRow): string | null { + if (row.type === 'header') { + return row.key + } + if (row.type === 'item') { + return row.rowKey + } + if (row.type === 'folder-workspace') { + return folderWorkspaceKey(row.folderWorkspace.id) + } + if (row.type === 'pending-creation') { + return `pending:${row.creationId}` + } + if (row.type === 'imported-worktrees-card') { + return row.key + } + return null +} + +function rowKeyMatchesRenderRow(row: RenderRow, rowKey: string): boolean { + if (row.type === 'lineage-group') { + return row.rows.some((item) => item.rowKey === rowKey) + } + return getRenderRowSidebarKey(row) === rowKey +} + +function getProjectIdFromHeaderRowKey(rowKey: string): string | null { + if (!rowKey.startsWith('project:')) { + return null + } + const withoutPrefix = rowKey.slice('project:'.length) + const setupSeparator = withoutPrefix.indexOf('::setup:') + return setupSeparator === -1 ? withoutPrefix : withoutPrefix.slice(0, setupSeparator) +} + +function getRepoIdsFromHeaderRowKey( + rowKey: string, + repoMap: Map, + projectGrouping?: ProjectGroupingModel +): string[] { + if (rowKey.startsWith('repo:')) { + return [rowKey.slice('repo:'.length)] + } + const setupMarker = '::setup:' + const setupIndex = rowKey.indexOf(setupMarker) + if (rowKey.startsWith('project:') && setupIndex !== -1) { + return [rowKey.slice(setupIndex + setupMarker.length)] + } + const projectId = getProjectIdFromHeaderRowKey(rowKey) + if (!projectId) { + return [] + } + const repoIds = new Set() + for (const setup of projectGrouping?.projectHostSetups ?? []) { + if (setup.projectId === projectId && repoMap.has(setup.repoId)) { + repoIds.add(setup.repoId) + } + } + const project = projectGrouping?.projects.find((candidate) => candidate.id === projectId) + for (const repoId of project?.sourceRepoIds ?? []) { + if (repoMap.has(repoId)) { + repoIds.add(repoId) + } + } + return [...repoIds] +} + +function getProjectGroupAncestorKeys( + projectGroupId: string | null | undefined, + projectGroups: readonly ProjectGroup[] +): string[] { + const groupsById = new Map(projectGroups.map((group) => [group.id, group])) + const keys: string[] = [] + const seen = new Set() + let currentGroupId = projectGroupId ?? null + while (currentGroupId && !seen.has(currentGroupId)) { + const group = groupsById.get(currentGroupId) + if (!group) { + break + } + seen.add(currentGroupId) + keys.unshift(getProjectGroupHeaderKey(group.id)) + currentGroupId = group.parentGroupId + } + return keys +} + +function getSidebarRowRevealAncestorKeys(args: { + rowKey: string + repoMap: Map + projectGroups: readonly ProjectGroup[] + projectGrouping?: ProjectGroupingModel +}): string[] { + if (args.rowKey.startsWith('project-group:')) { + const groupId = args.rowKey.slice('project-group:'.length) + const group = args.projectGroups.find((candidate) => candidate.id === groupId) + return getProjectGroupAncestorKeys(group?.parentGroupId, args.projectGroups) + } + const keys = new Set() + for (const repoId of getRepoIdsFromHeaderRowKey( + args.rowKey, + args.repoMap, + args.projectGrouping + )) { + const repo = args.repoMap.get(repoId) + for (const key of getProjectGroupAncestorKeys(repo?.projectGroupId, args.projectGroups)) { + keys.add(key) + } + } + return [...keys] +} + function getWorktreeVisibilityMenuLabel(repo: Repo): string { const visibility = effectiveExternalWorktreeVisibility( repo, @@ -471,7 +596,9 @@ type VirtualizedWorktreeViewportProps = { handleCreateFolderWorkspace: (projectGroup: ProjectGroup) => void activeModal: string pendingRevealWorktree: PendingSidebarWorktreeReveal | null + pendingRevealSidebarRow: PendingSidebarRowReveal | null clearPendingRevealWorktreeId: () => void + clearPendingRevealSidebarRow: () => void agentSendTargetWorktreeId: string | null worktrees: Worktree[] folderWorkspaces: readonly FolderWorkspace[] @@ -1097,7 +1224,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp handleCreateFolderWorkspace, activeModal, pendingRevealWorktree, + pendingRevealSidebarRow, clearPendingRevealWorktreeId, + clearPendingRevealSidebarRow, agentSendTargetWorktreeId, worktrees, folderWorkspaces, @@ -1143,9 +1272,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ) const [pendingRevealRetryTick, setPendingRevealRetryTick] = useState(0) const [documentVisibilityRevision, setDocumentVisibilityRevision] = useState(0) - const [highlightedRevealWorktreeId, setHighlightedRevealWorktreeId] = useState( - null - ) + const [highlightedRevealRowKey, setHighlightedRevealRowKey] = useState(null) const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId) const assignWorktreeParent = useAppStore((s) => s.assignWorktreeParent) const worktreeDragSessionRef = useRef(null) @@ -1156,6 +1283,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const worktreeNativeAutoscrollLastFrameTimeRef = useRef(null) const worktreeNativeLatestPointRef = useRef(null) const pendingRevealRetryRef = useRef<{ worktreeId: string; count: number } | null>(null) + const pendingRowRevealRetryRef = useRef<{ rowKey: string; count: number } | null>(null) const pendingRevealFrameIdsRef = useRef>(new Set()) const revealHighlightFrameIdRef = useRef(null) const revealHighlightTimeoutRef = useRef(null) @@ -1184,19 +1312,19 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp revealHighlightTimeoutRef.current = null } }, []) - const flashRevealedWorktree = useCallback( - (worktreeId: string) => { + const flashRevealedRow = useCallback( + (rowKey: string) => { clearRevealHighlightTimeout() clearRevealHighlightFrame() // Why: remove before add restarts the CSS glow when the user repeatedly // asks to reveal the same active workspace. - setHighlightedRevealWorktreeId(null) + setHighlightedRevealRowKey(null) revealHighlightFrameIdRef.current = window.requestAnimationFrame(() => { revealHighlightFrameIdRef.current = null - setHighlightedRevealWorktreeId(worktreeId) + setHighlightedRevealRowKey(rowKey) revealHighlightTimeoutRef.current = window.setTimeout(() => { revealHighlightTimeoutRef.current = null - setHighlightedRevealWorktreeId(null) + setHighlightedRevealRowKey(null) }, 1500) }) }, @@ -1867,7 +1995,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp : null if (revealedOption) { if (pendingRevealWorktree.highlight) { - flashRevealedWorktree(pendingRevealWorktree.worktreeId) + const revealedRowKey = + revealedOption.dataset.worktreeRowKey ?? getRenderRowSidebarKey(targetRow) + if (revealedRowKey) { + flashRevealedRow(revealedRowKey) + } } if (pendingRevealWorktree.beginRename) { setRenamingWorktreeId({ @@ -1930,12 +2062,135 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp settings, projectGroups, pendingRevealRetryTick, - flashRevealedWorktree, + flashRevealedRow, setRenamingWorktreeId, schedulePendingRevealFrame, cancelPendingRevealFrames ]) + React.useEffect(() => { + if (!pendingRevealSidebarRow) { + return + } + + const isProjectHeaderTarget = + pendingRevealSidebarRow.rowKey.startsWith('project-group:') || + pendingRevealSidebarRow.rowKey.startsWith('project:') || + pendingRevealSidebarRow.rowKey.startsWith('repo:') + if (isProjectHeaderTarget && groupBy !== 'repo') { + return + } + + let toggledAncestor = false + for (const groupKey of getSidebarRowRevealAncestorKeys({ + rowKey: pendingRevealSidebarRow.rowKey, + repoMap, + projectGroups, + projectGrouping + })) { + if (collapsedGroups.has(groupKey)) { + toggleGroup(groupKey) + toggledAncestor = true + } + } + if (toggledAncestor) { + return + } + + let cancelled = false + const retryPendingReveal = () => { + const previousRetry = pendingRowRevealRetryRef.current + const nextRetryCount = + previousRetry?.rowKey === pendingRevealSidebarRow.rowKey ? previousRetry.count + 1 : 1 + pendingRowRevealRetryRef.current = { + rowKey: pendingRevealSidebarRow.rowKey, + count: nextRetryCount + } + if (nextRetryCount <= 8) { + schedulePendingRevealFrame(() => { + if (!cancelled) { + setPendingRevealRetryTick((tick) => tick + 1) + } + }) + return true + } + return false + } + schedulePendingRevealFrame(() => { + if (cancelled) { + return + } + const targetIndex = renderRows.findIndex((row) => + rowKeyMatchesRenderRow(row, pendingRevealSidebarRow.rowKey) + ) + if (targetIndex === -1) { + if (retryPendingReveal()) { + return + } + pendingRowRevealRetryRef.current = null + clearPendingRevealSidebarRow() + toast.error( + translate( + 'auto.components.sidebar.WorktreeList.sidebarRowMissing', + 'Target no longer exists' + ) + ) + return + } + + const retryExactRevealOnNextFrame = () => { + if (retryPendingReveal()) { + return + } + pendingRowRevealRetryRef.current = null + clearPendingRevealSidebarRow() + } + + const container = scrollRef.current + const revealedElement = container + ? revealMountedSidebarRowElement( + container, + pendingRevealSidebarRow.rowKey, + pendingRevealSidebarRow.behavior + ) + : null + if (revealedElement) { + if (pendingRevealSidebarRow.highlight) { + flashRevealedRow(pendingRevealSidebarRow.rowKey) + } + pendingRowRevealRetryRef.current = null + clearPendingRevealSidebarRow() + return + } + + virtualizer.scrollToIndex(targetIndex, { + align: 'auto', + behavior: 'auto' + }) + retryExactRevealOnNextFrame() + }) + + return () => { + cancelled = true + cancelPendingRevealFrames() + } + }, [ + pendingRevealSidebarRow, + repoMap, + projectGroups, + projectGrouping, + collapsedGroups, + groupBy, + toggleGroup, + renderRows, + virtualizer, + pendingRevealRetryTick, + flashRevealedRow, + clearPendingRevealSidebarRow, + schedulePendingRevealFrame, + cancelPendingRevealFrames + ]) + const prCacheLen = useAppStore((s) => countRecordKeysByReference(s.prCache)) const issueCacheLen = useAppStore((s) => countRecordKeysByReference(s.issueCache)) const renderRowKeySignature = useMemo( @@ -3742,6 +3997,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } >
s.activeWorktreeId) const currentSidebarWorktreeId = activeWorktreeId const groupBy = useAppStore((s) => s.groupBy) + const setGroupBy = useAppStore((s) => s.setGroupBy) const workspaceHostScope = useAppStore((s) => s.workspaceHostScope) const visibleWorkspaceHostIds = useAppStore((s) => s.visibleWorkspaceHostIds) const workspaceHostOrder = useAppStore((s) => s.workspaceHostOrder) @@ -4679,9 +4938,12 @@ const WorktreeList = React.memo(function WorktreeList({ const activeView = useAppStore((s) => s.activeView) const activeModal = useAppStore((s) => s.activeModal) const pendingRevealWorktree = useAppStore((s) => s.pendingRevealWorktree) + const pendingRevealSidebarRow = useAppStore((s) => s.pendingRevealSidebarRow) const revealWorktreeInSidebar = useAppStore((s) => s.revealWorktreeInSidebar) + const revealSidebarRow = useAppStore((s) => s.revealSidebarRow) const setWorktreesPinnedAndReveal = useAppStore((s) => s.setWorktreesPinnedAndReveal) const clearPendingRevealWorktreeId = useAppStore((s) => s.clearPendingRevealWorktreeId) + const clearPendingRevealSidebarRow = useAppStore((s) => s.clearPendingRevealSidebarRow) const agentSendPopoverTargetMode = useAppStore((s) => s.agentSendPopoverTargetMode) // Why: agent-send eligibility only matters while the picker is open. When it // is closed, avoid subscribing WorktreeList to wake-time terminal layout churn. @@ -5341,6 +5603,23 @@ const WorktreeList = React.memo(function WorktreeList({ workspaceHostScope ] ) + const renderedSidebarRowKeys = useMemo(() => { + const keys = new Set() + for (const row of sectionRows) { + if (row.type === 'header') { + keys.add(row.key) + } else if (row.type === 'item') { + keys.add(row.rowKey) + } else if (row.type === 'folder-workspace') { + keys.add(folderWorkspaceKey(row.folderWorkspace.id)) + } else if (row.type === 'pending-creation') { + keys.add(`pending:${row.creationId}`) + } else if (row.type === 'imported-worktrees-card') { + keys.add(row.key) + } + } + return keys + }, [sectionRows]) // Why: status headers change during wake (inactive -> active). Key only on // the grouping mode so row identity survives those ordinary status moves. const visibleHostResetKey = visibleWorkspaceHostIds?.join(',') ?? 'all' @@ -5878,14 +6157,16 @@ const WorktreeList = React.memo(function WorktreeList({ filterRepoIds, hideDefaultBranchWorkspace, hideAutomationGeneratedWorkspaces, - visibleWorkspaceHostIds + visibleWorkspaceHostIds, + workspaceHostScope }), [ showSleepingWorkspaces, filterRepoIds, hideDefaultBranchWorkspace, hideAutomationGeneratedWorkspaces, - visibleWorkspaceHostIds + visibleWorkspaceHostIds, + workspaceHostScope ] ) const hasFilters = sidebarHasActiveFilters(filterState) @@ -5923,12 +6204,48 @@ const WorktreeList = React.memo(function WorktreeList({ filterState ]) + useEffect(() => { + if (!pendingRevealSidebarRow) { + return + } + const rowKey = pendingRevealSidebarRow.rowKey + const isProjectHeaderTarget = + rowKey.startsWith('project-group:') || + rowKey.startsWith('project:') || + rowKey.startsWith('repo:') + if (isProjectHeaderTarget && groupBy !== 'repo') { + setGroupBy('repo') + return + } + if (!renderedSidebarRowKeys.has(rowKey) && hasFilters) { + clearFilters() + } + }, [ + clearFilters, + groupBy, + hasFilters, + pendingRevealSidebarRow, + renderedSidebarRowKeys, + setGroupBy + ]) + const handleRevealCurrentWorkspaceRequest = useCallback( (event: Event) => { const detail = event instanceof CustomEvent ? (event.detail as ScrollToCurrentWorkspaceRevealRequestDetail | undefined) : undefined + if (detail?.target?.type === 'sidebar-row') { + const sidebarDetail = detail as Extract< + ScrollToCurrentWorkspaceRevealRequestDetail, + { target: { type: 'sidebar-row' } } + > + revealSidebarRow(detail.target.rowKey, { + behavior: 'smooth', + highlight: sidebarDetail.highlight !== false + }) + return + } if (!activeWorktreeId) { return } @@ -5948,13 +6265,14 @@ const WorktreeList = React.memo(function WorktreeList({ revealWorktreeInSidebar(activeWorktreeId, { behavior: 'smooth', highlight: true, - beginRename: detail?.beginRename === true + beginRename: (detail as { beginRename?: boolean } | undefined)?.beginRename === true }) }, [ activeWorktreeId, clearFilters, folderWorkspaces, + revealSidebarRow, renderedWorktreeIds, revealWorktreeInSidebar, worktreeMap @@ -6085,7 +6403,9 @@ const WorktreeList = React.memo(function WorktreeList({ handleCreateFolderWorkspace={handleCreateFolderWorkspace} activeModal={activeModal} pendingRevealWorktree={pendingRevealWorktree} + pendingRevealSidebarRow={pendingRevealSidebarRow} clearPendingRevealWorktreeId={clearPendingRevealWorktreeId} + clearPendingRevealSidebarRow={clearPendingRevealSidebarRow} agentSendTargetWorktreeId={agentSendTargetWorktreeId} worktrees={worktrees} folderWorkspaces={folderWorkspaces} diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index 81a78bec138..25165df25b8 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -98,6 +98,7 @@ function filterState(overrides: Partial = {}): FilterState { filterRepoIds: [], hideDefaultBranchWorkspace: false, hideAutomationGeneratedWorkspaces: false, + workspaceHostScope: 'all', ...overrides } } @@ -617,6 +618,16 @@ describe('computeClearFilterActions', () => { expect(actions.resetFilterRepoIds).toBe(true) }) + it('flags legacy single-host scope for reset even without visible host ids', () => { + expect(computeClearFilterActions(filterState({ workspaceHostScope: 'ssh:host-1' }))).toEqual({ + resetShowSleepingWorkspaces: false, + resetFilterRepoIds: false, + resetHideDefaultBranchWorkspace: false, + resetHideAutomationGeneratedWorkspaces: false, + resetVisibleWorkspaceHostIds: true + }) + }) + it('flags every active filter simultaneously', () => { expect( computeClearFilterActions( diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 785fc2a39fa..434df0ee0de 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -36,6 +36,7 @@ export type SidebarFilterState = { hideDefaultBranchWorkspace: boolean hideAutomationGeneratedWorkspaces: boolean visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null + workspaceHostScope?: ExecutionHostScope } /** @@ -53,7 +54,8 @@ export function sidebarHasActiveFilters(state: SidebarFilterState): boolean { state.filterRepoIds.length > 0 || state.hideDefaultBranchWorkspace || state.hideAutomationGeneratedWorkspaces || - state.visibleWorkspaceHostIds != null + state.visibleWorkspaceHostIds != null || + (state.workspaceHostScope != null && state.workspaceHostScope !== ALL_EXECUTION_HOSTS_SCOPE) ) } @@ -83,7 +85,9 @@ export function computeClearFilterActions(state: SidebarFilterState): ClearFilte resetFilterRepoIds: state.filterRepoIds.length > 0, resetHideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace, resetHideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces, - resetVisibleWorkspaceHostIds: state.visibleWorkspaceHostIds != null + resetVisibleWorkspaceHostIds: + state.visibleWorkspaceHostIds != null || + (state.workspaceHostScope != null && state.workspaceHostScope !== ALL_EXECUTION_HOSTS_SCOPE) } } diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 2b5a89f18e2..91f9aa5c8b3 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -145,14 +145,24 @@ type ProjectGroupingIndex = { multiSetupProjectHostKeys: Set } +const projectGroupingIndexCache = new WeakMap() + function projectHostKey(projectId: string, hostId: string): string { return `${projectId}::${hostId}` } function buildProjectGroupingIndex(model?: ProjectGroupingModel): ProjectGroupingIndex | null { - const projects = model?.projects ?? [] - const projectHostSetups = model?.projectHostSetups ?? [] + if (!model) { + return null + } + const cached = projectGroupingIndexCache.get(model) + if (cached !== undefined) { + return cached + } + const projects = model.projects ?? [] + const projectHostSetups = model.projectHostSetups ?? [] if (projects.length === 0 || projectHostSetups.length === 0) { + projectGroupingIndexCache.set(model, null) return null } const setupCountByProjectHost = new Map() @@ -166,18 +176,27 @@ function buildProjectGroupingIndex(model?: ProjectGroupingModel): ProjectGroupin multiSetupProjectHostKeys.add(key) } } - return { + const index = { projectById: new Map(projects.map((project) => [project.id, project])), setupByRepoId: new Map(projectHostSetups.map((setup) => [setup.repoId, setup])), multiSetupProjectHostKeys } + projectGroupingIndexCache.set(model, index) + return index +} + +export type ProjectHeaderRevealTarget = { + key: string + label: string + repo?: Repo + projectId?: string } function getProjectGroupingForRepo( repoId: string, repoMap: Map, projectIndex: ProjectGroupingIndex | null -): { key: string; label: string; repo?: Repo; projectId?: string } { +): ProjectHeaderRevealTarget { const repo = repoMap.get(repoId) const setup = projectIndex?.setupByRepoId.get(repoId) const project = setup ? projectIndex?.projectById.get(setup.projectId) : undefined @@ -207,6 +226,14 @@ function getProjectGroupingForRepo( } } +export function getProjectHeaderRevealTarget( + repoId: string, + repoMap: Map, + projectGrouping?: ProjectGroupingModel +): ProjectHeaderRevealTarget { + return getProjectGroupingForRepo(repoId, repoMap, buildProjectGroupingIndex(projectGrouping)) +} + function addRepoIdToGroup(group: WorktreeGroupEntry, repoId: string): void { group.repoIds.add(repoId) } diff --git a/src/renderer/src/components/task-page-github-work-item-status.ts b/src/renderer/src/components/task-page-github-work-item-status.ts index ab9d0361032..b200abb2328 100644 --- a/src/renderer/src/components/task-page-github-work-item-status.ts +++ b/src/renderer/src/components/task-page-github-work-item-status.ts @@ -55,7 +55,6 @@ export function getTaskPageGitHubPRIconTone(item: GitHubWorkItemStatusItem): str return 'text-muted-foreground' } - // Exhaustive switch ensures TypeScript will error if new states are added switch (item.state) { case 'draft': return 'text-muted-foreground' diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 86a665eafb7..885882707ec 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1703,7 +1703,10 @@ "settingsBadge": "Settings", "actionBadge": "Action", "paletteHostBadge": "Host: {{value0}}", - "workspaceTabMissing": "Tab no longer exists" + "workspaceTabMissing": "Tab no longer exists", + "projectsGroupsHeader": "Projects & Groups", + "projectBadge": "Project", + "repoGroupBadge": "Repo group" }, "github": { "pr": { @@ -3965,7 +3968,8 @@ "f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.", "groupDeleteFailed": "Failed to delete group", "groupDeleteFailedDesc": "Something went wrong while deleting the group. No projects were removed.", - "failedNestWorkspace": "Failed to nest workspace" + "failedNestWorkspace": "Failed to nest workspace", + "sidebarRowMissing": "Target no longer exists" }, "WorktreeMetaDialog": { "3db0a2a593": "Cancel", @@ -11060,6 +11064,14 @@ "newQuickCommand": "new quick command" } } + }, + "palette": { + "project": { + "results": { + "repoGroup": "Repo group", + "project": "Project" + } + } } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index f4592d35d23..5cfaa362c6b 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1703,7 +1703,10 @@ "settingsBadge": "Ajustes", "actionBadge": "Acción", "paletteHostBadge": "Host: {{value0}}", - "workspaceTabMissing": "La pestaña ya no existe" + "workspaceTabMissing": "La pestaña ya no existe", + "projectsGroupsHeader": "Projects & Groups", + "projectBadge": "Project", + "repoGroupBadge": "Repo group" }, "github": { "pr": { @@ -3951,7 +3954,8 @@ "7a8b9c0d1e": "Update required", "hostAuthNeeded": "Authentication needed", "hostDisconnected": "Disconnected", - "failedNestWorkspace": "No se pudo anidar el espacio de trabajo" + "failedNestWorkspace": "No se pudo anidar el espacio de trabajo", + "sidebarRowMissing": "Target no longer exists" }, "WorktreeMetaDialog": { "3db0a2a593": "Cancelar", @@ -11060,6 +11064,14 @@ "newQuickCommand": "nuevo comando rápido" } } + }, + "palette": { + "project": { + "results": { + "repoGroup": "Repo group", + "project": "Project" + } + } } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 205a953ec87..14cbf557eaf 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1703,7 +1703,10 @@ "settingsBadge": "設定", "actionBadge": "操作", "paletteHostBadge": "Host: {{value0}}", - "workspaceTabMissing": "タブはもう存在しません" + "workspaceTabMissing": "タブはもう存在しません", + "projectsGroupsHeader": "Projects & Groups", + "projectBadge": "Project", + "repoGroupBadge": "Repo group" }, "github": { "pr": { @@ -3932,7 +3935,8 @@ "7a8b9c0d1e": "Update required", "hostAuthNeeded": "Authentication needed", "hostDisconnected": "Disconnected", - "failedNestWorkspace": "ワークスペースをネストできませんでした" + "failedNestWorkspace": "ワークスペースをネストできませんでした", + "sidebarRowMissing": "Target no longer exists" }, "WorktreeMetaDialog": { "3db0a2a593": "キャンセル", @@ -11060,6 +11064,14 @@ "newQuickCommand": "新規クイックコマンド" } } + }, + "palette": { + "project": { + "results": { + "repoGroup": "Repo group", + "project": "Project" + } + } } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 60f7ac0ebbb..4478b7fa714 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1703,7 +1703,10 @@ "settingsBadge": "설정", "actionBadge": "작업", "paletteHostBadge": "호스트: {{value0}}", - "workspaceTabMissing": "탭이 더 이상 존재하지 않습니다" + "workspaceTabMissing": "탭이 더 이상 존재하지 않습니다", + "projectsGroupsHeader": "Projects & Groups", + "projectBadge": "Project", + "repoGroupBadge": "Repo group" }, "github": { "pr": { @@ -3932,7 +3935,8 @@ "7a8b9c0d1e": "업데이트 필요", "hostAuthNeeded": "인증 필요", "hostDisconnected": "Disconnected", - "failedNestWorkspace": "워크스페이스를 중첩하지 못했습니다" + "failedNestWorkspace": "워크스페이스를 중첩하지 못했습니다", + "sidebarRowMissing": "Target no longer exists" }, "WorktreeMetaDialog": { "3db0a2a593": "취소", @@ -11060,6 +11064,14 @@ "newQuickCommand": "새로운 빠른 명령" } } + }, + "palette": { + "project": { + "results": { + "repoGroup": "Repo group", + "project": "Project" + } + } } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 0c5d2d3c9fd..0e6c0214a88 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1703,7 +1703,10 @@ "settingsBadge": "设置", "actionBadge": "操作", "paletteHostBadge": "主机:{{value0}}", - "workspaceTabMissing": "标签页不再存在" + "workspaceTabMissing": "标签页不再存在", + "projectsGroupsHeader": "Projects & Groups", + "projectBadge": "Project", + "repoGroupBadge": "Repo group" }, "github": { "pr": { @@ -3932,7 +3935,8 @@ "7a8b9c0d1e": "Update required", "hostAuthNeeded": "Authentication needed", "hostDisconnected": "Disconnected", - "failedNestWorkspace": "无法嵌套工作区" + "failedNestWorkspace": "无法嵌套工作区", + "sidebarRowMissing": "Target no longer exists" }, "WorktreeMetaDialog": { "3db0a2a593": "取消", @@ -11060,6 +11064,14 @@ "newQuickCommand": "新的快速命令" } } + }, + "palette": { + "project": { + "results": { + "repoGroup": "Repo group", + "project": "Project" + } + } } } }, diff --git a/src/renderer/src/lib/scroll-to-current-workspace-status.ts b/src/renderer/src/lib/scroll-to-current-workspace-status.ts index fbca39dc2a1..6f077da3b06 100644 --- a/src/renderer/src/lib/scroll-to-current-workspace-status.ts +++ b/src/renderer/src/lib/scroll-to-current-workspace-status.ts @@ -1,9 +1,15 @@ export const SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT = 'orca-scroll-to-current-workspace-reveal-request' -export type ScrollToCurrentWorkspaceRevealRequestDetail = { - beginRename?: boolean -} +export type ScrollToCurrentWorkspaceRevealRequestDetail = + | { + target?: { type: 'active-workspace' } + beginRename?: boolean + } + | { + target: { type: 'sidebar-row'; rowKey: string } + highlight?: boolean + } function dispatchScrollToCurrentWorkspaceReveal( detail?: ScrollToCurrentWorkspaceRevealRequestDetail @@ -21,5 +27,8 @@ export function requestScrollToCurrentWorkspaceReveal(): void { } export function requestScrollToCurrentWorkspaceRevealAndRename(): void { - dispatchScrollToCurrentWorkspaceReveal({ beginRename: true }) + dispatchScrollToCurrentWorkspaceReveal({ + target: { type: 'active-workspace' }, + beginRename: true + }) } diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index ceb59dd752a..5e0c78c2945 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -117,6 +117,12 @@ export type PendingSidebarWorktreeReveal = { beginRename?: boolean } +export type PendingSidebarRowReveal = { + rowKey: string + behavior: 'auto' | 'smooth' + highlight?: boolean +} + export type AgentSendPopoverTargetMode = { id: string instanceId: string @@ -847,6 +853,7 @@ export type UISlice = { petSize: number setPetSize: (size: number) => void pendingRevealWorktree: PendingSidebarWorktreeReveal | null + pendingRevealSidebarRow: PendingSidebarRowReveal | null revealWorktreeInSidebar: ( worktreeId: string, options?: { @@ -855,7 +862,15 @@ export type UISlice = { beginRename?: boolean } ) => void + revealSidebarRow: ( + rowKey: string, + options?: { + behavior?: PendingSidebarRowReveal['behavior'] + highlight?: boolean + } + ) => void clearPendingRevealWorktreeId: () => void + clearPendingRevealSidebarRow: () => void // Why: lets the SourceControl sidebar request that the diff editor scroll // to a specific note. Cleared by the diff decorator after it reveals the // line, so the same id can be requested again later without the surface @@ -2145,6 +2160,7 @@ export const createUISlice: StateCreator = (set, get) }), pendingRevealWorktree: null, + pendingRevealSidebarRow: null, revealWorktreeInSidebar: (worktreeId, options) => set({ pendingRevealWorktree: { @@ -2154,7 +2170,16 @@ export const createUISlice: StateCreator = (set, get) ...(options?.beginRename ? { beginRename: true } : {}) } }), + revealSidebarRow: (rowKey, options) => + set({ + pendingRevealSidebarRow: { + rowKey, + behavior: options?.behavior ?? 'smooth', + ...(options?.highlight === false ? {} : { highlight: true }) + } + }), clearPendingRevealWorktreeId: () => set({ pendingRevealWorktree: null }), + clearPendingRevealSidebarRow: () => set({ pendingRevealSidebarRow: null }), scrollToDiffCommentId: null, setScrollToDiffCommentId: (id) => set({ scrollToDiffCommentId: id }), persistedUIReady: false, From a2fd8c11c272dac0a36a672d6ad4b3da82a609cf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:06:38 -0700 Subject: [PATCH 03/18] Reduce source-control background load during refreshes (#6189) Co-authored-by: Orca --- .../status-upstream-negative-cache.test.ts | 329 ++++++++++++++++++ .../git/status-upstream-probe-churn.test.ts | 39 ++- src/main/git/status.ts | 122 ++++++- src/main/github/pr-refresh-coordinator.ts | 39 +++ .../pr-refresh-validation-backoff.test.ts | 73 ++++ .../github/pr-refresh-validation-backoff.ts | 107 ++++++ src/main/ipc/filesystem.test.ts | 30 ++ src/main/ipc/filesystem.ts | 14 +- src/main/ipc/github.test.ts | 152 ++++++++ src/main/ipc/github.ts | 144 +++++--- src/main/providers/ssh-git-provider.test.ts | 16 + src/main/providers/ssh-git-provider.ts | 10 +- src/main/providers/types.ts | 7 +- src/main/runtime/orca-runtime-git.ts | 3 +- src/main/runtime/rpc/methods/git-params.ts | 3 +- src/main/runtime/rpc/methods/git.test.ts | 26 ++ src/main/runtime/rpc/methods/git.ts | 19 +- src/preload/api-types.ts | 4 +- src/preload/index.ts | 1 + src/relay/git-handler-status-ops.test.ts | 29 ++ src/relay/git-handler-status-ops.ts | 5 +- ...git-status-upstream-negative-cache.test.ts | 297 ++++++++++++++++ .../git-status-upstream-negative-cache.ts | 104 +++++- .../right-sidebar/git-status-refresh.test.ts | 156 ++++++++- .../right-sidebar/git-status-refresh.ts | 214 +++++++++--- .../right-sidebar/useGitStatusPolling.test.ts | 6 +- .../src/runtime/runtime-git-client.test.ts | 56 +++ .../src/runtime/runtime-git-client.ts | 14 +- src/renderer/src/store/slices/editor.ts | 9 +- src/renderer/src/store/slices/github.test.ts | 69 ++++ src/renderer/src/store/slices/github.ts | 2 +- src/shared/types.ts | 5 + 32 files changed, 1961 insertions(+), 143 deletions(-) create mode 100644 src/main/git/status-upstream-negative-cache.test.ts create mode 100644 src/main/github/pr-refresh-validation-backoff.test.ts create mode 100644 src/main/github/pr-refresh-validation-backoff.ts create mode 100644 src/relay/git-status-upstream-negative-cache.test.ts diff --git a/src/main/git/status-upstream-negative-cache.test.ts b/src/main/git/status-upstream-negative-cache.test.ts new file mode 100644 index 00000000000..0dcc4f4bcc6 --- /dev/null +++ b/src/main/git/status-upstream-negative-cache.test.ts @@ -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) + }) +}) diff --git a/src/main/git/status-upstream-probe-churn.test.ts b/src/main/git/status-upstream-probe-churn.test.ts index b59f88dee8a..ddf216b3f9d 100644 --- a/src/main/git/status-upstream-probe-churn.test.ts +++ b/src/main/git/status-upstream-probe-churn.test.ts @@ -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')) { diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 4097adaf1e6..8876fb18f53 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -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() const effectiveUpstreamStatusInFlight = new Map>() +const retiredEffectiveUpstreamStatusInFlight = new Map>() +const effectiveUpstreamStatusWriteGeneration = new Map() 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 { - 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() } } } diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index 8af4ee10b4b..7dbb30f30d6 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -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() let lastBackgroundStartAt = 0 const visibleByWindow = new Map }>() 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 { 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 { 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, diff --git a/src/main/github/pr-refresh-validation-backoff.test.ts b/src/main/github/pr-refresh-validation-backoff.test.ts new file mode 100644 index 00000000000..fd4211783ec --- /dev/null +++ b/src/main/github/pr-refresh-validation-backoff.test.ts @@ -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') + }) +}) diff --git a/src/main/github/pr-refresh-validation-backoff.ts b/src/main/github/pr-refresh-validation-backoff.ts new file mode 100644 index 00000000000..ab03a064d84 --- /dev/null +++ b/src/main/github/pr-refresh-validation-backoff.ts @@ -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() +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 +} diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index f8670c4ebd3..dd7ec9c2697 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -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']) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 6f803ca737c..021ee8f4f1d 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -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 => { - 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) { diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index 687c0f8d0a7..d889120497c 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -158,6 +158,7 @@ vi.mock('../telemetry/cohort-classifier', () => ({ })) import { registerGitHubHandlers } from './github' +import { clearPRRefreshValidationBackoffForTests } from '../github/pr-refresh-validation-backoff' type HandlerMap = Record 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) diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index f4c4b1a6401..87b873e73af 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -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 + } { + 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 } diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 2ebcf99053c..a6fd5911fa2 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -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']) diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index b9495dd7e7c..9c93c46e6d5 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -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 { 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 } diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 1a11df9e1af..c8a090e59fa 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -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 + getStatus(worktreePath: string, options?: GitProviderStatusOptions): Promise checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise getHistory(worktreePath: string, options?: GitHistoryOptions): Promise commit(worktreePath: string, message: string): Promise<{ success: boolean; error?: string }> diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index c2cd775ac0e..e39ef000eea 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -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 { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts index ca80c969709..c94c3349c6c 100644 --- a/src/main/runtime/rpc/methods/git-params.ts +++ b/src/main/runtime/rpc/methods/git-params.ts @@ -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({ diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts index b5937d8fc61..c6307c0aa63 100644 --- a/src/main/runtime/rpc/methods/git.test.ts +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -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', diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index 06cc3205452..964f7d09ce3 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -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', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index e57c86aa041..55c32a9a5d9 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 + }) => Promise reportVisiblePRRefreshCandidates: (args: { candidates: GitHubPRRefreshCandidate[] generation: number @@ -2107,6 +2108,7 @@ export type PreloadApi = { worktreePath: string connectionId?: string includeIgnored?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean }) => Promise checkIgnored: (args: { worktreePath: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 7852bd91f31..7936edb853b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2536,6 +2536,7 @@ const api = { worktreePath: string connectionId?: string includeIgnored?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean }): Promise => ipcRenderer.invoke('git:status', args), checkIgnored: (args: { worktreePath: string diff --git a/src/relay/git-handler-status-ops.test.ts b/src/relay/git-handler-status-ops.test.ts index d55e86d7584..5c409f4c943 100644 --- a/src/relay/git-handler-status-ops.test.ts +++ b/src/relay/git-handler-status-ops.test.ts @@ -30,6 +30,7 @@ describe('getStatusOp', () => { }) afterEach(async () => { + vi.useRealTimers() clearNoEffectiveUpstreamStatusCache() await fs.rm(tmpDir, { recursive: true, force: true }) }) @@ -109,6 +110,34 @@ describe('getStatusOp', () => { ).toHaveLength(1) }) + it('keeps no-effective-upstream probes cached beyond thirty seconds', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + const git = vi.fn(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(async (args) => { if (args.includes('status')) { diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index 8619e6fd1e9..03fe7c35b69 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -136,7 +136,10 @@ export async function getStatusOp( try { upstreamStatus = await readOrProbeNoEffectiveUpstreamStatus( { worktreePath, branchName, upstreamName: upstreamStatus?.upstreamName }, - (args) => git(args, worktreePath) + (args) => git(args, worktreePath), + { + bypassCache: params.bypassEffectiveUpstreamNegativeCache === true + } ) } catch { // Why: status polling should keep returning working-tree entries even diff --git a/src/relay/git-status-upstream-negative-cache.test.ts b/src/relay/git-status-upstream-negative-cache.test.ts new file mode 100644 index 00000000000..e232f791265 --- /dev/null +++ b/src/relay/git-status-upstream-negative-cache.test.ts @@ -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) + }) +}) diff --git a/src/relay/git-status-upstream-negative-cache.ts b/src/relay/git-status-upstream-negative-cache.ts index ec1764891c8..a9dbec3175c 100644 --- a/src/relay/git-status-upstream-negative-cache.ts +++ b/src/relay/git-status-upstream-negative-cache.ts @@ -2,7 +2,8 @@ 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 = 30_000 +const NO_EFFECTIVE_UPSTREAM_CACHE_TTL_MS = 5 * 60_000 +const MAX_NO_EFFECTIVE_UPSTREAM_CACHE_ENTRIES = 512 type NoEffectiveUpstreamCacheIdentity = { worktreePath: string @@ -17,6 +18,8 @@ type NoEffectiveUpstreamCacheEntry = { const noEffectiveUpstreamByIdentity = new Map() const noEffectiveUpstreamInFlight = new Map>() +const retiredNoEffectiveUpstreamInFlight = new Map>() +const noEffectiveUpstreamWriteGeneration = new Map() function noEffectiveUpstreamCacheKey(identity: NoEffectiveUpstreamCacheIdentity): string { return [identity.worktreePath, identity.branchName, identity.upstreamName ?? ''].join('\0') @@ -37,16 +40,40 @@ function readCachedNoEffectiveUpstreamStatus( 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/; other resolution @@ -58,39 +85,55 @@ function cacheNoEffectiveUpstreamStatus( 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 + runGit: GitCommandRunner, + options: { bypassCache?: boolean } = {} ): Promise { const cacheKey = noEffectiveUpstreamCacheKey(identity) - const cachedStatus = readCachedNoEffectiveUpstreamStatus(cacheKey) - if (cachedStatus) { - return cachedStatus - } + if (options.bypassCache !== true) { + const cachedStatus = readCachedNoEffectiveUpstreamStatus(cacheKey) + if (cachedStatus) { + return cachedStatus + } - const inFlight = noEffectiveUpstreamInFlight.get(cacheKey) - if (inFlight) { - return inFlight + 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) + cacheNoEffectiveUpstreamStatus(cacheKey, status, probedSameNameOriginRef, writeGeneration) return status }) - noEffectiveUpstreamInFlight.set(cacheKey, probe) + if (options.bypassCache !== true) { + noEffectiveUpstreamInFlight.set(cacheKey, probe) + } try { return await probe } finally { if (noEffectiveUpstreamInFlight.get(cacheKey) === probe) { noEffectiveUpstreamInFlight.delete(cacheKey) + trimNoEffectiveUpstreamWriteGeneration() } } } @@ -98,4 +141,43 @@ export async function readOrProbeNoEffectiveUpstreamStatus( 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 } diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts index 1613f3c8b54..5fe28efa4be 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts @@ -1,19 +1,39 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { refreshGitStatusForWorktree, type GitStatusRefreshDeps } from './git-status-refresh' -import type { GitStatusResult } from '../../../../shared/types' +import { + clearGitStatusRefreshOrderingForTests, + refreshGitStatusForWorktree, + refreshGitStatusForWorktreeStrict, + type GitStatusRefreshDeps +} from './git-status-refresh' +import type { GitStatusResult, GitUpstreamStatus } from '../../../../shared/types' function makeDeps(): GitStatusRefreshDeps { return { setGitStatus: vi.fn(), updateWorktreeGitIdentity: vi.fn(), setUpstreamStatus: vi.fn(), - fetchUpstreamStatus: vi.fn().mockResolvedValue(undefined) + fetchUpstreamStatus: vi.fn().mockResolvedValue(null) } } +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + return { promise, resolve, reject } +} + describe('refreshGitStatusForWorktree', () => { beforeEach(() => { vi.unstubAllGlobals() + clearGitStatusRefreshOrderingForTests() }) it('stores status, branch identity, and upstream data from git status', async () => { @@ -77,7 +97,8 @@ describe('refreshGitStatusForWorktree', () => { expect(deps.setUpstreamStatus).not.toHaveBeenCalled() expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-1', '/repo', undefined, undefined, { - runtimeTargetSettings: undefined + runtimeTargetSettings: undefined, + applyUpstreamStatus: false }) }) @@ -106,7 +127,8 @@ describe('refreshGitStatusForWorktree', () => { }) expect(deps.setUpstreamStatus).not.toHaveBeenCalled() expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-2', '/repo', 'ssh-2', undefined, { - runtimeTargetSettings: undefined + runtimeTargetSettings: undefined, + applyUpstreamStatus: false }) }) @@ -133,6 +155,130 @@ describe('refreshGitStatusForWorktree', () => { expect(deps.setGitStatus).toHaveBeenCalledWith('wt-3', status) }) + it('bypasses automatic no-upstream backoff only for strict refreshes', async () => { + const status: GitStatusResult = { + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } + } + const gitStatus = vi.fn().mockResolvedValue(status) + vi.stubGlobal('window', { api: { git: { status: gitStatus } } }) + const deps = makeDeps() + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-normal', + worktreePath: '/repo', + deps + }) + await refreshGitStatusForWorktreeStrict({ + worktreeId: 'wt-strict', + worktreePath: '/repo', + deps + }) + + expect(gitStatus).toHaveBeenNthCalledWith(1, { + worktreePath: '/repo', + connectionId: undefined + }) + expect(gitStatus).toHaveBeenNthCalledWith(2, { + worktreePath: '/repo', + connectionId: undefined, + bypassEffectiveUpstreamNegativeCache: true + }) + }) + + it('does not let an older automatic upstream result overwrite a strict result', async () => { + const automaticStatus = deferred() + const strictStatus: GitStatusResult = { + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + } + } + const staleAutomaticStatus: GitStatusResult = { + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } + } + const gitStatus = vi + .fn() + .mockReturnValueOnce(automaticStatus.promise) + .mockResolvedValueOnce(strictStatus) + vi.stubGlobal('window', { api: { git: { status: gitStatus } } }) + const deps = makeDeps() + + const automatic = refreshGitStatusForWorktree({ + worktreeId: 'wt-race', + worktreePath: '/repo', + deps + }) + await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(1)) + + await refreshGitStatusForWorktreeStrict({ + worktreeId: 'wt-race', + worktreePath: '/repo', + deps + }) + automaticStatus.resolve(staleAutomaticStatus) + await automatic + + expect(deps.setGitStatus).toHaveBeenCalledTimes(1) + expect(deps.setGitStatus).toHaveBeenCalledWith('wt-race', strictStatus) + expect(deps.updateWorktreeGitIdentity).toHaveBeenCalledTimes(1) + expect(deps.setUpstreamStatus).toHaveBeenCalledTimes(1) + expect(deps.setUpstreamStatus).toHaveBeenCalledWith('wt-race', strictStatus.upstreamStatus) + }) + + it('does not let an older automatic explicit upstream fetch overwrite a strict result', async () => { + const automaticFetch = deferred() + const strictStatus: GitStatusResult = { + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + } + } + const staleAutomaticUpstream: GitUpstreamStatus = { hasUpstream: false, ahead: 0, behind: 0 } + const gitStatus = vi + .fn() + .mockResolvedValueOnce({ + entries: [], + conflictOperation: 'unknown' + } satisfies GitStatusResult) + .mockResolvedValueOnce(strictStatus) + vi.stubGlobal('window', { api: { git: { status: gitStatus } } }) + const deps = makeDeps() + vi.mocked(deps.fetchUpstreamStatus).mockReturnValueOnce(automaticFetch.promise) + + const automatic = refreshGitStatusForWorktree({ + worktreeId: 'wt-fetch-race', + worktreePath: '/repo', + deps + }) + await vi.waitFor(() => expect(deps.fetchUpstreamStatus).toHaveBeenCalledTimes(1)) + + await refreshGitStatusForWorktreeStrict({ + worktreeId: 'wt-fetch-race', + worktreePath: '/repo', + deps + }) + automaticFetch.resolve(staleAutomaticUpstream) + await automatic + + expect(deps.setUpstreamStatus).toHaveBeenCalledTimes(1) + expect(deps.setUpstreamStatus).toHaveBeenCalledWith( + 'wt-fetch-race', + strictStatus.upstreamStatus + ) + }) + it('clears stale branch identity when git status reports detached HEAD', async () => { const status: GitStatusResult = { entries: [], diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.ts index 89d4ec4f662..4c5b8fb7ace 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.ts @@ -18,8 +18,94 @@ export type GitStatusRefreshDeps = { worktreePath: string, connectionId?: string, pushTarget?: GitPushTarget, - options?: { runtimeTargetSettings?: Pick | null } - ) => Promise + options?: { + runtimeTargetSettings?: Pick | null + applyUpstreamStatus?: boolean + } + ) => Promise +} + +const MAX_REFRESH_ORDERING_WORKTREES = 1024 +const strictUpstreamRefreshGenerationByWorktree = new Map() +const automaticUpstreamRefreshInFlightByWorktree = new Map() + +function trimRefreshOrderingState(): void { + for (const worktreeId of strictUpstreamRefreshGenerationByWorktree.keys()) { + if (strictUpstreamRefreshGenerationByWorktree.size <= MAX_REFRESH_ORDERING_WORKTREES) { + break + } + if (automaticUpstreamRefreshInFlightByWorktree.has(worktreeId)) { + continue + } + strictUpstreamRefreshGenerationByWorktree.delete(worktreeId) + } +} + +function beginAutomaticUpstreamRefresh(worktreeId: string): number { + automaticUpstreamRefreshInFlightByWorktree.set( + worktreeId, + (automaticUpstreamRefreshInFlightByWorktree.get(worktreeId) ?? 0) + 1 + ) + return strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0 +} + +function finishAutomaticUpstreamRefresh(worktreeId: string): void { + const count = automaticUpstreamRefreshInFlightByWorktree.get(worktreeId) ?? 0 + if (count <= 1) { + automaticUpstreamRefreshInFlightByWorktree.delete(worktreeId) + } else { + automaticUpstreamRefreshInFlightByWorktree.set(worktreeId, count - 1) + } + trimRefreshOrderingState() +} + +function shouldApplyAutomaticUpstreamRefresh(worktreeId: string, startGeneration: number): boolean { + return (strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0) === startGeneration +} + +async function fetchAndApplyAutomaticUpstreamStatus({ + settings, + worktreeId, + worktreePath, + connectionId, + pushTarget, + deps, + startGeneration +}: { + settings?: Pick | null + worktreeId: string + worktreePath: string + connectionId?: string + pushTarget?: GitPushTarget + deps: GitStatusRefreshDeps + startGeneration: number +}): Promise { + const upstreamStatus = await deps.fetchUpstreamStatus( + worktreeId, + worktreePath, + connectionId, + pushTarget, + { + runtimeTargetSettings: settings, + applyUpstreamStatus: false + } + ) + if (upstreamStatus && shouldApplyAutomaticUpstreamRefresh(worktreeId, startGeneration)) { + deps.setUpstreamStatus(worktreeId, upstreamStatus) + } +} + +function beginStrictUpstreamRefresh(worktreeId: string): void { + strictUpstreamRefreshGenerationByWorktree.set( + worktreeId, + (strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0) + 1 + ) + trimRefreshOrderingState() +} + +export function clearGitStatusRefreshOrderingForTests(): void { + strictUpstreamRefreshGenerationByWorktree.clear() + automaticUpstreamRefreshInFlightByWorktree.clear() } export async function refreshGitStatusForWorktree({ @@ -37,51 +123,77 @@ export async function refreshGitStatusForWorktree({ pushTarget?: GitPushTarget deps: GitStatusRefreshDeps }): Promise { - const status = (await getRuntimeGitStatus({ - settings, - worktreeId, - worktreePath, - connectionId - })) as GitStatusResult + const upstreamStartGeneration = beginAutomaticUpstreamRefresh(worktreeId) + try { + const status = (await getRuntimeGitStatus({ + settings, + worktreeId, + worktreePath, + connectionId + })) as GitStatusResult - deps.setGitStatus(worktreeId, status) - // Why: branch switches can happen inside a terminal. `git status --branch` - // gives us the new identity without a separate worktree-list poll. - deps.updateWorktreeGitIdentity(worktreeId, { - head: status.head, - // Why: detached HEAD reports a head oid and no branch. Pass null as an - // explicit clear signal so stale branch names don't linger in the UI. - branch: status.branch ?? (status.head ? null : undefined) - }) - if (pushTarget) { - // Why: porcelain status reports Git's configured upstream. Source Control - // actions for PR-created worktrees must instead reconcile with Orca's - // explicit publish target. - await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { - runtimeTargetSettings: settings + if (!shouldApplyAutomaticUpstreamRefresh(worktreeId, upstreamStartGeneration)) { + return + } + + deps.setGitStatus(worktreeId, status) + // Why: branch switches can happen inside a terminal. `git status --branch` + // gives us the new identity without a separate worktree-list poll. + deps.updateWorktreeGitIdentity(worktreeId, { + head: status.head, + // Why: detached HEAD reports a head oid and no branch. Pass null as an + // explicit clear signal so stale branch names don't linger in the UI. + branch: status.branch ?? (status.head ? null : undefined) }) - return - } - if (status.upstreamStatus) { - if ( - status.upstreamStatus.ahead > 0 && - status.upstreamStatus.behind > 0 && - status.upstreamStatus.behindCommitsArePatchEquivalent === undefined - ) { - // Why: porcelain status has counts but cannot tell stale post-rebase - // upstream commits from real remote work. Writing it first makes the - // primary action flicker between Sync and Force Push on every poll. - await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId, undefined, { - runtimeTargetSettings: settings + if (pushTarget) { + // Why: porcelain status reports Git's configured upstream. Source Control + // actions for PR-created worktrees must instead reconcile with Orca's + // explicit publish target. + await fetchAndApplyAutomaticUpstreamStatus({ + settings, + worktreeId, + worktreePath, + connectionId, + pushTarget, + deps, + startGeneration: upstreamStartGeneration }) return } - deps.setUpstreamStatus(worktreeId, status.upstreamStatus) - return + if (status.upstreamStatus) { + if ( + status.upstreamStatus.ahead > 0 && + status.upstreamStatus.behind > 0 && + status.upstreamStatus.behindCommitsArePatchEquivalent === undefined + ) { + // Why: porcelain status has counts but cannot tell stale post-rebase + // upstream commits from real remote work. Writing it first makes the + // primary action flicker between Sync and Force Push on every poll. + await fetchAndApplyAutomaticUpstreamStatus({ + settings, + worktreeId, + worktreePath, + connectionId, + deps, + startGeneration: upstreamStartGeneration + }) + return + } + deps.setUpstreamStatus(worktreeId, status.upstreamStatus) + return + } + await fetchAndApplyAutomaticUpstreamStatus({ + settings, + worktreeId, + worktreePath, + connectionId, + pushTarget, + deps, + startGeneration: upstreamStartGeneration + }) + } finally { + finishAutomaticUpstreamRefresh(worktreeId) } - await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId, undefined, { - runtimeTargetSettings: settings - }) } export async function refreshGitStatusForWorktreeStrict({ @@ -101,12 +213,20 @@ export async function refreshGitStatusForWorktreeStrict({ fetchUpstreamStatus?: GitStatusRefreshDeps['fetchUpstreamStatus'] } }): Promise<{ status: GitStatusResult; upstreamStatus: GitUpstreamStatus }> { - const status = (await getRuntimeGitStatus({ - settings, - worktreeId, - worktreePath, - connectionId - })) as GitStatusResult + beginStrictUpstreamRefresh(worktreeId) + const status = (await getRuntimeGitStatus( + { + settings, + worktreeId, + worktreePath, + connectionId + }, + { + // Why: strict refreshes are user-triggered reconciliation and must not reuse + // automatic polling's no-upstream backoff window. + bypassEffectiveUpstreamNegativeCache: true + } + )) as GitStatusResult deps.setGitStatus(worktreeId, status) // Why: branch switches can happen inside a terminal. `git status --branch` diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts index 9f045022168..c534a7859f7 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts @@ -169,7 +169,8 @@ describe('useGitStatusPolling', () => { undefined, undefined, { - runtimeTargetSettings: { activeRuntimeEnvironmentId: null } + runtimeTargetSettings: { activeRuntimeEnvironmentId: null }, + applyUpstreamStatus: false } ) }) @@ -192,7 +193,8 @@ describe('useGitStatusPolling', () => { undefined, pushTarget, { - runtimeTargetSettings: { activeRuntimeEnvironmentId: null } + runtimeTargetSettings: { activeRuntimeEnvironmentId: null }, + applyUpstreamStatus: false } ) }) diff --git a/src/renderer/src/runtime/runtime-git-client.test.ts b/src/renderer/src/runtime/runtime-git-client.test.ts index 8dabf867757..d4f1f57abc4 100644 --- a/src/renderer/src/runtime/runtime-git-client.test.ts +++ b/src/renderer/src/runtime/runtime-git-client.test.ts @@ -132,6 +132,37 @@ describe('runtime git client', () => { }) }) + it('forwards upstream-negative-cache bypass to local git status only when enabled', async () => { + gitStatus.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + + await getRuntimeGitStatus( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { bypassEffectiveUpstreamNegativeCache: true } + ) + await getRuntimeGitStatus( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { bypassEffectiveUpstreamNegativeCache: false } + ) + + expect(gitStatus).toHaveBeenNthCalledWith(1, { + worktreePath: '/repo', + connectionId: undefined, + bypassEffectiveUpstreamNegativeCache: true + }) + expect(gitStatus).toHaveBeenNthCalledWith(2, { + worktreePath: '/repo', + connectionId: undefined + }) + }) + it('checks ignored paths through local git IPC', async () => { gitCheckIgnored.mockResolvedValue(['dist/bundle.js']) @@ -262,6 +293,31 @@ describe('runtime git client', () => { }) }) + it('forwards upstream-negative-cache bypass through the active runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { entries: [], conflictOperation: 'unknown' }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await getRuntimeGitStatus( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { bypassEffectiveUpstreamNegativeCache: true } + ) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'git.status', + params: { worktree: 'id:wt-1', bypassEffectiveUpstreamNegativeCache: true }, + timeoutMs: 15_000 + }) + }) + it('checks ignored paths through the active runtime environment', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts index f7c9c02f06d..b0795718ac2 100644 --- a/src/renderer/src/runtime/runtime-git-client.ts +++ b/src/renderer/src/runtime/runtime-git-client.ts @@ -121,21 +121,29 @@ export function getRuntimeGitScope( export async function getRuntimeGitStatus( context: RuntimeGitContext, - options?: { includeIgnored?: boolean } + options?: { includeIgnored?: boolean; bypassEffectiveUpstreamNegativeCache?: boolean } ): Promise { const target = getActiveRuntimeTarget(context.settings) const includeIgnoredArgs = options?.includeIgnored ? { includeIgnored: true } : {} + const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache + ? { bypassEffectiveUpstreamNegativeCache: true } + : {} if (target.kind === 'local' || !context.worktreeId) { return window.api.git.status({ worktreePath: context.worktreePath, connectionId: context.connectionId, - ...includeIgnoredArgs + ...includeIgnoredArgs, + ...upstreamCacheBypassArgs }) } return callRuntimeRpc( target, 'git.status', - { worktree: toRuntimeWorktreeSelector(context.worktreeId), ...includeIgnoredArgs }, + { + worktree: toRuntimeWorktreeSelector(context.worktreeId), + ...includeIgnoredArgs, + ...upstreamCacheBypassArgs + }, { timeoutMs: 15_000 } ) } diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 3780be8c6e3..213f575e25e 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -295,6 +295,7 @@ type EditorOpenTargetOptions = { type GitRuntimeOperationOptions = { runtimeTargetSettings?: Pick | null + applyUpstreamStatus?: boolean } export type PendingEditorReveal = { @@ -596,7 +597,7 @@ export type EditorSlice = { connectionId?: string, pushTarget?: GitPushTarget, options?: GitRuntimeOperationOptions - ) => Promise + ) => Promise pushBranch: ( worktreeId: string, worktreePath: string, @@ -3540,7 +3541,10 @@ export const createEditorSlice: StateCreator = (s }, pushTarget ) - get().setUpstreamStatus(worktreeId, status) + if (options?.applyUpstreamStatus !== false) { + get().setUpstreamStatus(worktreeId, status) + } + return status } catch (error) { // Why: on error we leave the prior status in place rather than writing a // synthetic {hasUpstream:false} — that would flash 'Publish Branch' on a @@ -3549,6 +3553,7 @@ export const createEditorSlice: StateCreator = (s // genuinely newly unpublished, the polling effect will eventually correct // the status on success. console.error('fetchUpstreamStatus failed', error) + return null } }, pushBranch: async ( diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index 372ddfd9bb4..823ed97e0ec 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -2967,6 +2967,75 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => { }) }) + it('does not direct-fetch when enqueue returns an automatic validation skip', async () => { + const store = createTestStore() + const repoPath = '/repo' + const branch = 'feature/test' + const worktreeId = 'wt-1' + mockApi.gh.enqueuePRRefresh.mockResolvedValueOnce({ + kind: 'skipped', + skippedReason: 'validation-denied' + }) + + store.setState({ + repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }], + worktreesByRepo: { + 'repo-1': [ + { + id: worktreeId, + repoId: 'repo-1', + path: '/repo/worktrees/test', + branch, + displayName: 'test', + isMainWorktree: false, + isBare: false, + isArchived: false + } + ] + } + } as unknown as Partial) + + store.getState().enqueueGitHubPRRefresh(worktreeId, 'active', 80) + await Promise.resolve() + + expect(mockApi.gh.enqueuePRRefresh).toHaveBeenCalledTimes(1) + expect(mockApi.gh.prForBranch).not.toHaveBeenCalled() + }) + + it('direct-fetches when enqueue returns an explicit fallback result', async () => { + const store = createTestStore() + const repoPath = '/repo' + const branch = 'feature/test' + const worktreeId = 'wt-1' + mockApi.gh.enqueuePRRefresh.mockResolvedValueOnce({ kind: 'fallback' }) + mockApi.gh.refreshPRNow.mockResolvedValueOnce({ kind: 'no-pr', fetchedAt: 1 }) + + store.setState({ + repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }], + worktreesByRepo: { + 'repo-1': [ + { + id: worktreeId, + repoId: 'repo-1', + path: '/repo/worktrees/test', + branch, + displayName: 'test', + isMainWorktree: false, + isBare: false, + isArchived: false + } + ] + } + } as unknown as Partial) + + store.getState().enqueueGitHubPRRefresh(worktreeId, 'active', 80) + await Promise.resolve() + await Promise.resolve() + + expect(mockApi.gh.enqueuePRRefresh).toHaveBeenCalledTimes(1) + expect(mockApi.gh.refreshPRNow).toHaveBeenCalledTimes(1) + }) + it('enqueues active PR refresh with a GitHub hosted-review fallback number', () => { const store = createTestStore() const repoPath = '/repo' diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index cfd032f969d..495eaef72b7 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -3441,7 +3441,7 @@ export const createGitHubSlice: StateCreator = (s if (enqueue) { void enqueue({ candidate, reason, priority }) .then((queued) => { - if (queued === false) { + if (queued === false || queued.kind === 'fallback') { return get().fetchPRForBranch(candidate.repoPath, candidate.branch, { force: bypassesGitHubPRRefreshFreshness(reason), repoId: candidate.repoId, diff --git a/src/shared/types.ts b/src/shared/types.ts index 20db64abc6c..c56b9af4acc 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1125,6 +1125,11 @@ export type PRRefreshOutcome = export type GitHubPRRefreshReason = 'visible' | 'active' | 'post-push' | 'manual' | 'swr' +export type GitHubPRRefreshEnqueueResult = + | { kind: 'queued' } + | { kind: 'skipped'; skippedReason: 'validation-denied' | 'validation-backoff' } + | { kind: 'fallback' } + export type GitHubPRRefreshAlias = { cacheKey: string repoId?: string From ce403c09d5b21a40c696eb695af41098b9afaca2 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:18:46 -0700 Subject: [PATCH 04/18] Add GitHub task close reason parity (#6141) Co-authored-by: Orca --- docs/github-tasks-close-reason-parity.md | 137 +++ src/main/github/issues.test.ts | 48 + .../github/project-view/mutations.test.ts | 121 +++ src/main/github/project-view/mutations.ts | 69 +- .../src/components/GitHubItemDialog.tsx | 992 ++++++++++++------ .../src/components/PullRequestPage.tsx | 587 +++++++---- src/renderer/src/components/TaskPage.tsx | 505 +++++++-- .../task-page-github-status-actions.test.ts | 88 ++ .../task-page-github-status-actions.ts | 102 ++ src/renderer/src/i18n/locales/en.json | 25 +- src/renderer/src/i18n/locales/es.json | 25 +- src/renderer/src/i18n/locales/ja.json | 25 +- src/renderer/src/i18n/locales/ko.json | 25 +- src/renderer/src/i18n/locales/zh.json | 25 +- 14 files changed, 2141 insertions(+), 633 deletions(-) create mode 100644 docs/github-tasks-close-reason-parity.md create mode 100644 src/main/github/project-view/mutations.test.ts create mode 100644 src/renderer/src/components/task-page-github-status-actions.test.ts create mode 100644 src/renderer/src/components/task-page-github-status-actions.ts diff --git a/docs/github-tasks-close-reason-parity.md b/docs/github-tasks-close-reason-parity.md new file mode 100644 index 00000000000..dea16edfcec --- /dev/null +++ b/docs/github-tasks-close-reason-parity.md @@ -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 ` 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 `, 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. diff --git a/src/main/github/issues.test.ts b/src/main/github/issues.test.ts index 1c8e5c5596f..f68726b395a 100644 --- a/src/main/github/issues.test.ts +++ b/src/main/github/issues.test.ts @@ -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' } + ) + }) }) diff --git a/src/main/github/project-view/mutations.test.ts b/src/main/github/project-view/mutations.test.ts new file mode 100644 index 00000000000..9d8cabca7f8 --- /dev/null +++ b/src/main/github/project-view/mutations.test.ts @@ -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' } + ) + }) +}) diff --git a/src/main/github/project-view/mutations.ts b/src/main/github/project-view/mutations.ts index cd72c9e8ce1..e2da3dae068 100644 --- a/src/main/github/project-view/mutations.ts +++ b/src/main/github/project-view/mutations.ts @@ -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(patchArgs) if (!r.ok) { return { ok: false, error: r.error } diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index c35ddee07af..a417419855e 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -17,10 +17,12 @@ import { ArrowDown, ArrowRight, ArrowUp, + Ban, Braces, Check, ChevronDown, ChevronLeft, + ChevronRight, CircleDashed, CircleDot, Copy, @@ -38,10 +40,10 @@ import { Pencil, Plus, RefreshCw, + Search, Send, Settings, UndoDot, - Users, Wrench, X } from 'lucide-react' @@ -59,7 +61,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, @@ -159,6 +161,7 @@ import { filterGitHubPRReviewerCandidates, getGitHubPRReviewerQueryState } from '@/components/github/github-pr-reviewer-candidate-filter' +import { githubAvatarUrl } from '@/components/github/github-issue-comment-helpers' import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' import { GITHUB_PR_MERGE_METHOD_LABELS, @@ -193,6 +196,13 @@ import { import { PER_REPO_FETCH_LIMIT } from '../../../shared/work-items' import { translate } from '@/i18n/i18n' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { + buildTaskPageGitHubCloseUpdate, + getTaskPageGitHubDuplicateCandidates, + getTaskPageGitHubDuplicateTargetErrorMessage, + validateTaskPageGitHubDuplicateTarget, + type TaskPageGitHubCloseAction +} from '@/components/task-page-github-status-actions' // Why: the GH item dialog can be opened from any work-item list surface and // doesn't have the full owner/repo context the list's cache entry carries. @@ -354,7 +364,9 @@ function getStateTone(item: GitHubWorkItem): string { return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' } if (item.state === 'closed') { - return 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300' + // Why: closed issues can mean completed/resolved; keep them neutral instead + // of using destructive red, which is reserved for PR closed-without-merge. + return 'border-ring/50 bg-primary/10 text-foreground' } return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' } @@ -386,25 +398,15 @@ function ReviewerAvatar({ login: string avatarUrl: string }): React.JSX.Element { - if (avatarUrl) { - return ( - - ) - } return ( - - {login.slice(0, 1).toUpperCase()} - + className="size-6 shrink-0 rounded-full border border-border/50 bg-muted object-cover" + /> ) } @@ -447,6 +449,239 @@ function buildRequestedReviewUsers( return Array.from(byLogin.values()) } +function PRAssigneesPanel({ + item, + repoPath, + projectOrigin, + sourceContext, + onMutated +}: { + item: GitHubWorkItem + repoPath: string | null + projectOrigin: GitHubItemDialogProjectOrigin | undefined + sourceContext?: TaskSourceContext | null + onMutated: () => void +}): React.JSX.Element { + const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false) + const [localAssignees, setLocalAssignees] = useState( + () => 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 sourceSettings = useMemo( + () => + sourceContext?.provider === 'github' + ? ({ + ...repoOwnerSettings, + ...getTaskSourceRuntimeSettings(sourceContext) + } as typeof repoOwnerSettings) + : repoOwnerSettings, + [repoOwnerSettings, sourceContext] + ) + 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, + sourceSettings + ) + const repoAssigneesByPath = useRepoAssignees(repoPath, item.repoId, sourceSettings) + 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, + sourceContext, + projectOrigin, + number: item.number, + updates: isAssigned ? { removeAssignees: [login] } : { addAssignees: [login] } + }), + onOptimistic: () => { + setLocalAssignees(nextAssignees) + patchWorkItem(item.id, { assignees: nextAssignees }, item.repoId, { sourceContext }) + patchProjectRowIfNeeded(nextLogins) + }, + onRevert: () => { + setLocalAssignees(prevAssignees) + patchWorkItem(item.id, { assignees: prevAssignees }, item.repoId, { sourceContext }) + 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, + sourceContext + ] + ) + + const checkIcon = ( + + + + ) + + return ( +
+
+ {translate('auto.components.GitHubItemDialog.83ac703dda', 'Assignees')} + + + + + + {repoAssignees.error ? ( +
+ {repoAssignees.error} +
+ ) : ( +
+ {repoAssignees.data.map((user) => { + const selected = localAssignees.some( + (assignee) => assignee.login.toLowerCase() === user.login.toLowerCase() + ) + return ( + + ) + })} +
+ )} +
+
+
+ {localAssignees.length === 0 ? ( +
+ {translate('auto.components.GitHubItemDialog.c67de9e2fe', 'No one assigned')} +
+ ) : ( +
    + {localAssignees.map((assignee) => ( +
  • + + + {assignee.login} + +
  • + ))} +
+ )} +
+ ) +} + function PRReviewersPanel({ item, loading, @@ -462,8 +697,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(null) const [activeReviewerCursor, setActiveReviewerCursor] = useState({ resetKey: '', index: 0 @@ -663,26 +896,6 @@ function PRReviewersPanel({ const canRequestReview = !!repoPath || getActiveRuntimeTarget(sourceSettings).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 => { if (submitting) { return @@ -856,9 +1069,6 @@ function PRReviewersPanel({ } const handleReviewerPickerOpenChange = (nextOpen: boolean): void => { - if (nextOpen) { - measureReviewerPickerPlacement() - } setOpen(nextOpen) if (nextOpen) { scheduleReviewerInputFocus() @@ -936,165 +1146,80 @@ function PRReviewersPanel({ } return ( - + {loading && !hasReviewerMetadata ? ( +
+ + {translate('auto.components.GitHubItemDialog.6a45771d47', 'Loading reviewers')} +
+ ) : reviewers.length > 0 ? ( +
+ {reviewers.map((reviewer) => { + const canRemoveReviewer = selectedReviewerLogins.has(reviewer.login.toLowerCase()) + return ( +
+ +
+
+ {reviewer.login} +
+ {reviewer.name ? ( +
+ {reviewer.name} +
+ ) : null} +
+ + {reviewer.stateLabel} + + {canRemoveReviewer ? ( + + + + + + {translate('auto.components.GitHubItemDialog.5c1c973855', 'Remove reviewer')} + + + ) : null} +
+ ) + })} +
+ ) : ( +
+ {translate('auto.components.GitHubItemDialog.36f9ac4a47', 'No reviewers requested.')} +
+ )} + ) } @@ -2794,7 +2979,7 @@ function ConversationTab({ const rightPanel = item.type === 'pr' ? ( -
+
+ (null) const [localAssignees, setLocalAssignees] = useState(assignees) const editedAssigneesItemKeyRef = useRef(null) const assigneesItemKey = `${item.repoId}\0${item.id}` const patchWorkItem = useAppStore((s) => s.patchWorkItem) const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) + const duplicateIssueCandidates = useAppStore( + useShallow((s) => { + if (!duplicatePickerOpen) { + return [] + } + const deduped = new Map() + for (const entry of Object.values(s.workItemsCache)) { + for (const candidate of entry.data ?? []) { + if ( + candidate.type === 'issue' && + candidate.repoId === item.repoId && + candidate.number !== item.number && + !deduped.has(candidate.number) + ) { + deduped.set(candidate.number, candidate) + } + } + } + return Array.from(deduped.values()).sort((a, b) => b.number - a.number) + }) + ) const repoOwnerSettings = useAppStore( useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)) ) @@ -4723,6 +4940,33 @@ function GHEditSection({ const repoAssignees = projectOrigin ? repoAssigneesBySlug : repoAssigneesByPath const hasAttachedWorkspace = attachedWorkspaceLabel !== null && attachedWorkspaceLabel !== undefined + const filteredDuplicateCandidates = useMemo( + () => + getTaskPageGitHubDuplicateCandidates(duplicateIssueCandidates, item.number, duplicateSearch), + [duplicateIssueCandidates, duplicateSearch, item.number] + ) + const directDuplicateTarget = useMemo(() => { + const trimmed = duplicateSearch.trim() + const validation = validateTaskPageGitHubDuplicateTarget(trimmed, item.number) + if (!trimmed || !validation.ok) { + return null + } + if ( + filteredDuplicateCandidates.some((candidate) => candidate.number === validation.duplicateOf) + ) { + return null + } + return validation.duplicateOf + }, [duplicateSearch, filteredDuplicateCandidates, item.number]) + const duplicatePickerTitle = useMemo(() => { + if (projectOrigin) { + return `${projectOrigin.owner}/${projectOrigin.repo}` + } + const parsed = parseOwnerRepoFromItemUrl(item.url) + return parsed + ? `${parsed.owner}/${parsed.repo}` + : translate('auto.components.TaskPage.repository', 'Repository') + }, [item.url, projectOrigin]) const handleOpenOrUseWorkspace = useCallback((): void => { if (onOpenOrUse) { onOpenOrUse(item) @@ -4742,7 +4986,7 @@ function GHEditSection({ }, [assigneesItemKey, assignees]) const handleStateChange = useCallback( - (newState: 'open' | 'closed') => { + (newState: 'open' | 'closed', closeAction?: TaskPageGitHubCloseAction) => { if (newState === localState) { return } @@ -4755,7 +4999,10 @@ function GHEditSection({ sourceContext, projectOrigin, number: item.number, - updates: { state: newState } + updates: + newState === 'closed' && closeAction + ? buildTaskPageGitHubCloseUpdate(closeAction) + : { state: newState } }), onOptimistic: () => { onStateChange(newState) @@ -4792,6 +5039,42 @@ function GHEditSection({ ] ) + const closeAsDuplicate = useCallback( + (targetIssueNumber: number | string) => { + const validation = validateTaskPageGitHubDuplicateTarget( + String(targetIssueNumber), + item.number + ) + if (!validation.ok) { + setDuplicateError(getTaskPageGitHubDuplicateTargetErrorMessage(validation, translate)) + return + } + setDuplicateError(null) + handleStateChange('closed', { stateReason: 'duplicate', duplicateOf: validation.duplicateOf }) + setStatusPopoverOpen(false) + setDuplicatePickerOpen(false) + }, + [handleStateChange, item.number] + ) + + const handleDuplicateSearchSubmit = useCallback(() => { + const validation = validateTaskPageGitHubDuplicateTarget(duplicateSearch, item.number) + if (!validation.ok) { + setDuplicateError(getTaskPageGitHubDuplicateTargetErrorMessage(validation, translate)) + return + } + closeAsDuplicate(validation.duplicateOf) + }, [closeAsDuplicate, duplicateSearch, item.number]) + + const handleStatusPopoverOpenChange = useCallback((nextOpen: boolean) => { + setStatusPopoverOpen(nextOpen) + if (!nextOpen) { + setDuplicatePickerOpen(false) + setDuplicateSearch('') + setDuplicateError(null) + } + }, []) + const handleLabelToggle = useCallback( (label: string) => { const isAdding = !localLabels.includes(label) @@ -4947,6 +5230,196 @@ function GHEditSection({ ] ) + const renderIssueStatusPopover = (variant: 'sidebar' | 'pill'): React.JSX.Element => { + const isSidebar = variant === 'sidebar' + return ( + + + + + + {duplicatePickerOpen ? ( +
+
+ + + {duplicatePickerTitle} + +
+
+ + { + setDuplicateSearch(event.target.value) + setDuplicateError(null) + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + handleDuplicateSearchSubmit() + } + }} + placeholder={translate('auto.components.TaskPage.searchIssues', 'Search issues')} + className="h-9 pl-8 text-[12px]" + aria-invalid={duplicateError ? true : undefined} + /> +
+ {duplicateError ? ( +

{duplicateError}

+ ) : null} +
+ {directDuplicateTarget ? ( + + ) : null} + {filteredDuplicateCandidates.map((candidate) => ( + + ))} + {!directDuplicateTarget && filteredDuplicateCandidates.length === 0 ? ( +

+ {translate( + 'auto.components.TaskPage.noMatchingIssuesLoaded', + 'No matching issues loaded.' + )} +

+ ) : null} +
+
+ ) : ( + <> + + + + + + )} +
+
+ ) + } + if (item.type === 'pr') { return null } @@ -4971,51 +5444,7 @@ function GHEditSection({
{translate('auto.components.GitHubItemDialog.00ccdf9b5a', 'Status')}
- - - - - - - - - + {renderIssueStatusPopover('sidebar')} {/* Assignees */} @@ -5264,44 +5693,7 @@ function GHEditSection({ return (
{/* State */} - - - - - - - - - + {renderIssueStatusPopover('pill')} {/* Labels */} @@ -5571,29 +5963,37 @@ function GHCommentComposer({ const canSubmitComment = hasBoundedCommentBodyText(body) return ( -
+
void handleSubmit()} /> - + + + + + + {translate('auto.components.GitHubItemDialog.0a73f59e85', 'Send comment')} + +
) } diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index 849fad04317..c42fdff9119 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -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 ( - - ) - } return ( - - {login.slice(0, 1).toUpperCase()} - + 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( + () => 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 = ( + + + + ) + + return ( +
+
+ {translate('auto.components.PullRequestPage.8ff5ae8866', 'Assignees')} + + + + + + {repoAssignees.error ? ( +
+ {repoAssignees.error} +
+ ) : ( +
+ {repoAssignees.data.map((user) => { + const selected = localAssignees.some( + (assignee) => assignee.login.toLowerCase() === user.login.toLowerCase() + ) + return ( + + ) + })} +
+ )} +
+
+
+ {localAssignees.length === 0 ? ( +
+ {translate('auto.components.PullRequestPage.1ff5d979df', 'No one assigned')} +
+ ) : ( +
    + {localAssignees.map((assignee) => ( +
  • + + + {assignee.login} + +
  • + ))} +
+ )} +
+ ) +} + 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(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 => { 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 ( - + {loading && !hasReviewerMetadata ? ( +
+ + {translate('auto.components.PullRequestPage.acbd110867', 'Loading reviewers')} +
+ ) : reviewers.length > 0 ? ( +
+ {reviewers.map((reviewer) => { + const canRemoveReviewer = selectedReviewerLogins.has(reviewer.login.toLowerCase()) + return ( +
+ +
+
+ {reviewer.login} +
+ {reviewer.name ? ( +
+ {reviewer.name} +
+ ) : null} +
+ + {reviewer.stateLabel} + + {canRemoveReviewer ? ( + + + + + + {translate('auto.components.PullRequestPage.7f964a365a', 'Remove reviewer')} + + + ) : null} +
+ ) + })} +
+ ) : ( +
+ {translate('auto.components.PullRequestPage.d10b6d5209', 'No reviewers requested.')} +
+ )} + ) } @@ -2968,7 +3127,7 @@ function ConversationTab({ const rightPanel = item.type === 'pr' ? ( -
+
+ +
- + + + + + + {translate('auto.components.PullRequestPage.161d91ef02', 'Send comment')} + +
) } diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index e9495d4c30f..fc56f6692c1 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -9,6 +9,7 @@ import { AlertCircle, ArrowDownUp, ArrowRight, + Ban, Check, CheckCircle2, ChevronDown, @@ -16,6 +17,7 @@ import { ChevronRight, CircleDot, Clock3, + Copy, EllipsisVertical, ExternalLink, Eye, @@ -106,6 +108,7 @@ import type { import { useConfirmationDialog } from '@/components/confirmation-dialog' import { getGitHubPRPrimaryReviewer, + getGitHubPRReviewerRows, getGitHubPRReviewLabel, normalizeGitHubReviewerLogins, parseGitHubReviewerInputLogins, @@ -216,6 +219,13 @@ import { getTaskPageGitHubPRIconTone, isTaskPageGitHubDraftPR } from '@/components/task-page-github-work-item-status' +import { + buildTaskPageGitHubCloseUpdate, + getTaskPageGitHubDuplicateCandidates, + getTaskPageGitHubDuplicateTargetErrorMessage, + validateTaskPageGitHubDuplicateTarget, + type TaskPageGitHubCloseAction +} from '@/components/task-page-github-status-actions' import { createTaskPageJiraLoadFailureState, type TaskPageJiraLoadError @@ -231,6 +241,7 @@ import type { GitHubOwnerRepo, GitHubAssignableUser, GitHubPRMergeMethod, + GitHubIssueUpdate, GitHubWorkItem, GitLabTodo, GitLabWorkItem, @@ -1021,6 +1032,34 @@ function GHStatusCell({ sourceContext?: TaskSourceContext | null }): React.JSX.Element { const patchWorkItem = useAppStore((s) => s.patchWorkItem) + const [statusStateDraft, setStatusStateDraft] = useState(() => + createTaskPageGitHubStatusStateDraft(item) + ) + const [open, setOpen] = useState(false) + const [duplicatePickerOpen, setDuplicatePickerOpen] = useState(false) + const [duplicateSearch, setDuplicateSearch] = useState('') + const [duplicateError, setDuplicateError] = useState(null) + const duplicateIssueCandidates = useAppStore( + useShallow((s) => { + if (!duplicatePickerOpen) { + return [] + } + const deduped = new Map() + for (const entry of Object.values(s.workItemsCache)) { + for (const candidate of entry.data ?? []) { + if ( + candidate.type === 'issue' && + candidate.repoId === item.repoId && + candidate.number !== item.number && + !deduped.has(candidate.number) + ) { + deduped.set(candidate.number, candidate) + } + } + } + return Array.from(deduped.values()).sort((a, b) => b.number - a.number) + }) + ) const repoOwnerSettings = useAppStore( useShallow((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null)) ) @@ -1034,11 +1073,29 @@ function GHStatusCell({ : repoOwnerSettings, [repoOwnerSettings, sourceContext] ) - const [statusStateDraft, setStatusStateDraft] = useState(() => - createTaskPageGitHubStatusStateDraft(item) - ) - const [open, setOpen] = useState(false) const reqRef = useRef(0) + const parsedIssueLink = useMemo(() => parseGitHubIssueOrPRLink(item.url), [item.url]) + const filteredDuplicateCandidates = useMemo( + () => + getTaskPageGitHubDuplicateCandidates(duplicateIssueCandidates, item.number, duplicateSearch), + [duplicateIssueCandidates, duplicateSearch, item.number] + ) + const directDuplicateTarget = useMemo(() => { + const trimmed = duplicateSearch.trim() + const validation = validateTaskPageGitHubDuplicateTarget(trimmed, item.number) + if (!trimmed || !validation.ok) { + return null + } + if ( + filteredDuplicateCandidates.some((candidate) => candidate.number === validation.duplicateOf) + ) { + return null + } + return validation.duplicateOf + }, [duplicateSearch, filteredDuplicateCandidates, item.number]) + const duplicatePickerTitle = parsedIssueLink?.slug + ? `${parsedIssueLink.slug.owner}/${parsedIssueLink.slug.repo}` + : (repo?.displayName ?? translate('auto.components.TaskPage.repository', 'Repository')) const resolvedStatusStateDraft = resolveTaskPageGitHubStatusStateDraft(statusStateDraft, item) if (resolvedStatusStateDraft !== statusStateDraft) { @@ -1057,38 +1114,71 @@ function GHStatusCell({ ) const handleStateChange = useCallback( - (newState: 'open' | 'closed') => { - if (newState === localState || !repo || item.type !== 'issue') { + (newState: 'open' | 'closed', closeAction?: TaskPageGitHubCloseAction) => { + if (newState === localState || item.type !== 'issue') { + return + } + const parsedOwnerRepo = parsedIssueLink?.slug + if (!repo && !parsedOwnerRepo) { return } reqRef.current += 1 const reqId = reqRef.current + const updates: GitHubIssueUpdate = + newState === 'closed' && closeAction + ? buildTaskPageGitHubCloseUpdate(closeAction) + : { state: newState } updateLocalState(newState) patchWorkItem(item.id, { state: newState }, item.repoId, { sourceContext }) const target = getActiveRuntimeTarget(sourceSettings) - const runtimeRepoId = - sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id - const updatePromise = - target.kind === 'environment' - ? callRuntimeRpc<{ ok?: boolean; error?: string }>( + // Why: issue rows can be sourced by owner/repo URL instead of the local + // repo context; slug-aware writes preserve close reasons and duplicates. + const updatePromise = parsedOwnerRepo + ? target.kind === 'environment' + ? callRuntimeRpc<{ ok?: boolean; error?: { message?: string } | string }>( target, - 'github.updateIssue', - { repo: runtimeRepoId, number: item.number, updates: { state: newState } }, + 'github.project.updateIssueBySlug', + { + owner: parsedOwnerRepo.owner, + repo: parsedOwnerRepo.repo, + number: item.number, + updates + }, { timeoutMs: 30_000 } ) - : window.api.gh.updateIssue({ - repoPath: repo.path, - repoId: repo.id, - sourceContext, + : window.api.gh.updateIssueBySlug({ + owner: parsedOwnerRepo.owner, + repo: parsedOwnerRepo.repo, number: item.number, - updates: { state: newState } + updates }) + : (() => { + if (!repo) { + throw new Error('No GitHub repository context available for this issue.') + } + const runtimeRepoId = + sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id + return target.kind === 'environment' + ? callRuntimeRpc<{ ok?: boolean; error?: string }>( + target, + 'github.updateIssue', + { repo: runtimeRepoId, number: item.number, updates }, + { timeoutMs: 30_000 } + ) + : window.api.gh.updateIssue({ + repoPath: repo.path, + repoId: repo.id, + sourceContext, + number: item.number, + updates + }) + })() updatePromise .then((result) => { if (reqId !== reqRef.current) { return } - const typed = result as { ok?: boolean; error?: string } + const typed = result as { ok?: boolean; error?: string | { message?: string } } if (typed && typed.ok === false) { updateLocalState(newState === 'closed' ? 'open' : 'closed') patchWorkItem( @@ -1098,11 +1188,16 @@ function GHStatusCell({ { sourceContext } ) toast.error( - typed.error ?? - translate('auto.components.TaskPage.1c893195ac', 'Failed to update state') + typeof typed.error === 'string' + ? typed.error + : (typed.error?.message ?? + translate('auto.components.TaskPage.1c893195ac', 'Failed to update state')) ) return } + if (repo) { + useAppStore.getState().evictGitHubRepoCaches(repo.id, repo.path) + } useAppStore.getState().recordFeatureInteraction('github-tasks') }) .catch(() => { @@ -1121,61 +1216,236 @@ function GHStatusCell({ toast.error(translate('auto.components.TaskPage.1c893195ac', 'Failed to update state')) }) }, - [item, localState, patchWorkItem, repo, sourceContext, sourceSettings, updateLocalState] + [ + item, + localState, + parsedIssueLink, + patchWorkItem, + repo, + sourceContext, + sourceSettings, + updateLocalState + ] ) - if (item.type !== 'issue' || !repo) { + const closeAsDuplicate = useCallback( + (targetIssueNumber: number | string) => { + const validation = validateTaskPageGitHubDuplicateTarget( + String(targetIssueNumber), + item.number + ) + if (!validation.ok) { + setDuplicateError(getTaskPageGitHubDuplicateTargetErrorMessage(validation, translate)) + return + } + setDuplicateError(null) + handleStateChange('closed', { stateReason: 'duplicate', duplicateOf: validation.duplicateOf }) + setOpen(false) + setDuplicatePickerOpen(false) + }, + [handleStateChange, item.number] + ) + + const handleDuplicateSearchSubmit = useCallback(() => { + const validation = validateTaskPageGitHubDuplicateTarget(duplicateSearch, item.number) + if (!validation.ok) { + setDuplicateError(getTaskPageGitHubDuplicateTargetErrorMessage(validation, translate)) + return + } + closeAsDuplicate(validation.duplicateOf) + }, [closeAsDuplicate, duplicateSearch, item.number]) + + const handlePopoverOpenChange = useCallback((nextOpen: boolean) => { + setOpen(nextOpen) + if (!nextOpen) { + setDuplicatePickerOpen(false) + setDuplicateSearch('') + setDuplicateError(null) + } + }, []) + + if (item.type !== 'issue' || (!repo && !parsedIssueLink?.slug)) { return } return ( - + - e.stopPropagation()}> - - + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + {duplicatePickerOpen ? ( +
+
+ + + {duplicatePickerTitle} + +
+
+ + { + setDuplicateSearch(event.target.value) + setDuplicateError(null) + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + handleDuplicateSearchSubmit() + } + }} + placeholder={translate('auto.components.TaskPage.searchIssues', 'Search issues')} + className="h-9 pl-8 text-[12px]" + aria-invalid={duplicateError ? true : undefined} + /> +
+ {duplicateError ? ( +

{duplicateError}

+ ) : null} +
+ {directDuplicateTarget ? ( + + ) : null} + {filteredDuplicateCandidates.map((candidate) => ( + + ))} + {!directDuplicateTarget && filteredDuplicateCandidates.length === 0 ? ( +

+ {translate( + 'auto.components.TaskPage.noMatchingIssuesLoaded', + 'No matching issues loaded.' + )} +

+ ) : null} +
+
+ ) : ( + <> + + + + + + )}
) @@ -1195,47 +1465,27 @@ function formatPRDelta(item: GitHubWorkItem): string | null { return parts.length > 0 ? parts.join(' ') : null } -function getReviewTone(item: GitHubWorkItem): string { - if (item.reviewDecision === 'APPROVED') { - return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200' - } - if (item.reviewDecision === 'CHANGES_REQUESTED') { - return 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-200' - } - if (item.reviewRequests && item.reviewRequests.length > 0) { - return 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-200' - } - return 'border-border/60 bg-background/70 text-muted-foreground' -} - function ReviewChipAvatar({ reviewer }: { reviewer: GitHubPRPrimaryReviewer | null }): React.JSX.Element { - if (reviewer?.avatarUrl) { + if (reviewer?.login) { + // Why: `gh pr list --json reviewRequests` can return only logins; GitHub's + // public avatar endpoint keeps the list visual aligned with assignee cells. + const avatarUrl = reviewer.avatarUrl || `https://github.com/${reviewer.login}.png?size=40` return ( ) } - if (reviewer?.login) { - return ( - - {reviewer.login.slice(0, 1).toUpperCase()} - - ) - } - return + return } function GitHubAssigneeAvatar({ assignee }: { assignee: GitHubAssignableUser }): React.JSX.Element { @@ -1731,16 +1981,16 @@ function getChecksLabel(item: GitHubWorkItem): string { return `${summary.passed}/${summary.total} passed` } -function getChecksTone(item: GitHubWorkItem): string { +function getChecksPillTone(item: GitHubWorkItem): string { const state = item.checksSummary?.state if (state === 'success') { - return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200' + return 'border-emerald-500/40 bg-emerald-500/10 text-emerald-200' } if (state === 'failure') { - return 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-200' + return 'border-rose-500/40 bg-rose-500/10 text-rose-200' } if (state === 'pending') { - return 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-200' + return 'border-amber-500/40 bg-amber-500/10 text-amber-200' } return 'border-border/60 bg-background/70 text-muted-foreground' } @@ -1809,6 +2059,8 @@ function PRReviewCell({ const [localReviewRequests, setLocalReviewRequests] = useState( () => item.reviewRequests ?? [] ) + const [reviewerPickerSide, setReviewerPickerSide] = useState<'top' | 'bottom'>('bottom') + const [reviewerPickerMaxHeight, setReviewerPickerMaxHeight] = useState(null) const [reviewRequestsSource, setReviewRequestsSource] = useState(() => ({ itemId: item.id, repoId: item.repoId, @@ -1831,6 +2083,7 @@ function PRReviewCell({ [repoOwnerSettings, sourceContext] ) const reviewerInputRef = useRef(null) + const reviewerTriggerRef = useRef(null) const reviewerInputFocusFrameRef = useRef(null) const cancelReviewerInputFocusFrame = useCallback((): void => { @@ -1989,6 +2242,8 @@ function PRReviewCell({ const itemWithLocalReviewRequests = { ...item, reviewRequests: localReviewRequests } const primaryReviewer = getGitHubPRPrimaryReviewer(itemWithLocalReviewRequests) + const reviewerRows = getGitHubPRReviewerRows(itemWithLocalReviewRequests) + const extraReviewerCount = Math.max(0, reviewerRows.length - 1) const hasReviewerMetadata = item.reviewDecision !== undefined || localReviewRequests.length > 0 || @@ -2123,6 +2378,16 @@ function PRReviewCell({ } const handleReviewerPickerOpenChange = (nextOpen: boolean): void => { + if (nextOpen) { + const rect = reviewerTriggerRef.current?.getBoundingClientRect() + const gap = 8 + const availableBelow = rect ? window.innerHeight - rect.bottom - gap : 0 + const availableAbove = rect ? rect.top - gap : 0 + const nextSide = availableBelow < 240 && availableAbove > availableBelow ? 'top' : 'bottom' + const available = nextSide === 'top' ? availableAbove : availableBelow + setReviewerPickerSide(nextSide) + setReviewerPickerMaxHeight(Math.max(180, Math.min(360, available || 360))) + } setOpen(nextOpen) if (nextOpen) { cancelReviewerInputFocusFrame() @@ -2191,20 +2456,44 @@ function PRReviewCell({ event.stopPropagation()} onOpenAutoFocus={(event) => { event.preventDefault() @@ -2256,7 +2545,7 @@ function PRReviewCell({ }} />
-
+
{reviewerMetadata.loading ? (
{translate('auto.components.TaskPage.0eacf48491', 'Loading…')} @@ -2372,7 +2661,7 @@ function PRChecksCell({ }} className={cn( 'inline-flex max-w-full items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium transition hover:brightness-110', - getChecksTone(item) + getChecksPillTone(item) )} > @@ -2551,7 +2840,7 @@ function PRMergeCell({ )} > {merging ? ( - + ) : ( )} @@ -5290,6 +5579,7 @@ export default function TaskPage(): React.JSX.Element { ) const activeGithubTaskKind = getGitHubTaskKind(activeTaskPreset, appliedTaskSearch) + const appliedTaskQuery = useMemo(() => parseTaskQuery(appliedTaskSearch), [appliedTaskSearch]) const selectedGitHubRepoExternalLink = useMemo(() => { if (selectedRepos.length !== 1) { return null @@ -6062,11 +6352,18 @@ export default function TaskPage(): React.JSX.Element { setTaskRefreshNonce((current) => current + 1) }, [activeGithubTaskKind, setTaskResumeState, taskSearchInput]) - const handleTaskSearchChange = useCallback((event: React.ChangeEvent): void => { - const next = event.target.value - setTaskSearchInput(next) - setActiveTaskPreset(null) - }, []) + const handleTaskSearchChange = useCallback( + (event: React.ChangeEvent): void => { + const next = event.target.value + const scoped = scopeGitHubTaskSearch(next, activeGithubTaskKind) + setTaskSearchInput(next) + setActiveTaskPreset(null) + // Why: the visible rows are keyed by appliedTaskSearch, not the draft + // input. Hide stale rows as soon as the draft would change the query. + setTasksFiltering(scoped !== appliedTaskSearch) + }, + [activeGithubTaskKind, appliedTaskSearch] + ) const handleSetDefaultTaskPreset = useCallback( (presetId: TaskViewPresetId): void => { @@ -7884,7 +8181,7 @@ export default function TaskPage(): React.JSX.Element {
{ + it('builds completed and not planned close updates', () => { + expect(buildTaskPageGitHubCloseUpdate({ stateReason: 'completed' })).toEqual({ + state: 'closed', + stateReason: 'completed' + }) + expect(buildTaskPageGitHubCloseUpdate({ stateReason: 'not_planned' })).toEqual({ + state: 'closed', + stateReason: 'not_planned' + }) + }) + + it('builds duplicate close updates with a target issue number', () => { + expect(buildTaskPageGitHubCloseUpdate({ stateReason: 'duplicate', duplicateOf: 42 })).toEqual({ + state: 'closed', + stateReason: 'duplicate', + duplicateOf: 42 + }) + }) + + it('validates duplicate targets before dispatch', () => { + expect(validateTaskPageGitHubDuplicateTarget('', 12).ok).toBe(false) + expect(validateTaskPageGitHubDuplicateTarget('12', 12).ok).toBe(false) + expect(validateTaskPageGitHubDuplicateTarget('12.5', 12).ok).toBe(false) + expect(validateTaskPageGitHubDuplicateTarget('-1', 12).ok).toBe(false) + expect(validateTaskPageGitHubDuplicateTarget('0', 12).ok).toBe(false) + expect(validateTaskPageGitHubDuplicateTarget(' 34 ', 12)).toEqual({ + ok: true, + duplicateOf: 34 + }) + }) + + it('maps duplicate validation failures to localized messages', () => { + const t = (_key: string, fallback: string): string => fallback + const validation = validateTaskPageGitHubDuplicateTarget('same', 12) + + expect(validation.ok).toBe(false) + if (!validation.ok) { + expect(getTaskPageGitHubDuplicateTargetErrorMessage(validation, t)).toBe( + 'Use a whole issue number.' + ) + } + }) + + it('filters duplicate candidates to other matching issues', () => { + const items = [ + buildWorkItem({ number: 12, title: 'Current issue', type: 'issue' }), + buildWorkItem({ number: 34, title: 'Copy SSH file support', type: 'issue' }), + buildWorkItem({ number: 56, title: 'Unrelated pull request', type: 'pr' }), + buildWorkItem({ number: 78, title: 'Windows path copy bug', type: 'issue' }) + ] + + expect(getTaskPageGitHubDuplicateCandidates(items, 12, '').map((item) => item.number)).toEqual([ + 34, 78 + ]) + expect( + getTaskPageGitHubDuplicateCandidates(items, 12, 'copy').map((item) => item.number) + ).toEqual([34, 78]) + expect( + getTaskPageGitHubDuplicateCandidates(items, 12, '34').map((item) => item.number) + ).toEqual([34]) + }) +}) + +function buildWorkItem(overrides: Partial): GitHubWorkItem { + return { + id: `item-${overrides.number ?? 1}`, + type: 'issue', + number: 1, + title: 'Issue', + state: 'open', + url: 'https://github.com/stablyai/orca/issues/1', + labels: [], + updatedAt: '2026-06-22T00:00:00.000Z', + author: null, + repoId: 'repo-1', + ...overrides + } +} diff --git a/src/renderer/src/components/task-page-github-status-actions.ts b/src/renderer/src/components/task-page-github-status-actions.ts new file mode 100644 index 00000000000..1d3ed687767 --- /dev/null +++ b/src/renderer/src/components/task-page-github-status-actions.ts @@ -0,0 +1,102 @@ +import type { + GitHubIssueCloseReason, + GitHubIssueUpdate, + GitHubWorkItem +} from '../../../shared/types' + +export type TaskPageGitHubCloseAction = + | { stateReason: Exclude } + | { stateReason: 'duplicate'; duplicateOf: number } + +export type TaskPageGitHubDuplicateValidation = + | { ok: true; duplicateOf: number } + | { + ok: false + reason: 'missing' | 'not_integer' | 'not_positive' | 'same_issue' + } + +export type TaskPageGitHubDuplicateValidationError = Extract< + TaskPageGitHubDuplicateValidation, + { ok: false } +> + +type TranslateDuplicateError = (key: string, fallback: string) => string + +export function buildTaskPageGitHubCloseUpdate( + action: TaskPageGitHubCloseAction +): GitHubIssueUpdate { + return { + state: 'closed', + stateReason: action.stateReason, + ...(action.stateReason === 'duplicate' ? { duplicateOf: action.duplicateOf } : {}) + } +} + +export function validateTaskPageGitHubDuplicateTarget( + value: string, + currentIssueNumber: number +): TaskPageGitHubDuplicateValidation { + const trimmed = value.trim() + if (!trimmed) { + return { ok: false, reason: 'missing' } + } + if (!/^\d+$/.test(trimmed)) { + return { ok: false, reason: 'not_integer' } + } + const duplicateOf = Number(trimmed) + if (!Number.isSafeInteger(duplicateOf) || duplicateOf <= 0) { + return { ok: false, reason: 'not_positive' } + } + if (duplicateOf === currentIssueNumber) { + return { ok: false, reason: 'same_issue' } + } + return { ok: true, duplicateOf } +} + +export function getTaskPageGitHubDuplicateTargetErrorMessage( + validation: TaskPageGitHubDuplicateValidationError, + translate: TranslateDuplicateError +): string { + switch (validation.reason) { + case 'missing': + return translate( + 'auto.components.TaskPage.duplicateIssueMissing', + 'Enter an issue number in this repository.' + ) + case 'not_integer': + return translate( + 'auto.components.TaskPage.duplicateIssueNotInteger', + 'Use a whole issue number.' + ) + case 'not_positive': + return translate( + 'auto.components.TaskPage.duplicateIssueNotPositive', + 'Use a positive issue number.' + ) + case 'same_issue': + return translate( + 'auto.components.TaskPage.duplicateIssueSameIssue', + 'Choose a different issue.' + ) + } +} + +export function getTaskPageGitHubDuplicateCandidates( + items: GitHubWorkItem[], + currentIssueNumber: number, + query: string +): GitHubWorkItem[] { + const normalizedQuery = query.trim().toLowerCase() + return items.filter((candidate) => { + if (candidate.type !== 'issue' || candidate.number === currentIssueNumber) { + return false + } + if (!normalizedQuery) { + return true + } + return ( + candidate.title.toLowerCase().includes(normalizedQuery) || + String(candidate.number).includes(normalizedQuery) + ) + }) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 885882707ec..176edb3940e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1272,7 +1272,10 @@ "2b4fdb880c": "Unmark viewed", "56ec6eafb7": "Open the PR details to view current reviewers.", "7f964a365a": "Remove reviewer", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "Comment is too large to submit safely.", + "8ff5ae8866": "Assignees", + "82c87eceb9": "Edit assignees", + "1ff5d979df": "No one assigned" }, "QuickOpen": { "1dbd3f59ff": "Move", @@ -1602,7 +1605,25 @@ "fe28c9821f": "view", "8d1e17a3ef": "Open {{value0}} in GitHub", "4ac8ff2275": "Open {{value0}} in Jira", - "40eaf2c27c": "Details" + "40eaf2c27c": "Details", + "closeAsCompleted": "Close as completed", + "closeAsCompletedDescription": "Done, closed, fixed, resolved", + "closeAsNotPlanned": "Close as not planned", + "closeAsNotPlannedDescription": "Won't fix, can't repro, stale", + "closeAsDuplicate": "Close as duplicate", + "closeAsDuplicateDescription": "Duplicate of another issue in this repository", + "duplicateIssueNumberPlaceholder": "Issue number", + "closeAsDuplicateSubmit": "Close duplicate", + "duplicateIssueMissing": "Enter an issue number in this repository.", + "duplicateIssueNotInteger": "Use a whole issue number.", + "duplicateIssueNotPositive": "Use a positive issue number.", + "duplicateIssueSameIssue": "Choose a different issue.", + "repository": "Repository", + "backToCloseReasons": "Back", + "searchIssues": "Search issues", + "useIssueNumber": "Use issue #{{value0}}", + "noMatchingIssuesLoaded": "No matching issues loaded.", + "editReviewersWithCurrent": "Edit reviewers: {{value0}}" }, "Terminal": { "73768427cf": "Close", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 5cfaa362c6b..50f55cca9ac 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1272,7 +1272,10 @@ "2b4fdb880c": "Desmarcar visto", "56ec6eafb7": "Abra los detalles de relaciones públicas para ver los revisores actuales.", "7f964a365a": "Eliminar revisor", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "Comment is too large to submit safely.", + "8ff5ae8866": "Assignees", + "82c87eceb9": "Edit assignees", + "1ff5d979df": "No one assigned" }, "QuickOpen": { "1dbd3f59ff": "Mover", @@ -1602,7 +1605,25 @@ "fe28c9821f": "vista", "8d1e17a3ef": "Abra {{value0}} en GitHub", "4ac8ff2275": "Abrir {{value0}} en Jira", - "40eaf2c27c": "Details" + "40eaf2c27c": "Details", + "closeAsCompleted": "Close as completed", + "closeAsCompletedDescription": "Done, closed, fixed, resolved", + "closeAsNotPlanned": "Close as not planned", + "closeAsNotPlannedDescription": "Won't fix, can't repro, stale", + "closeAsDuplicate": "Close as duplicate", + "closeAsDuplicateDescription": "Duplicate of another issue in this repository", + "duplicateIssueNumberPlaceholder": "Issue number", + "closeAsDuplicateSubmit": "Close duplicate", + "duplicateIssueMissing": "Enter an issue number in this repository.", + "duplicateIssueNotInteger": "Use a whole issue number.", + "duplicateIssueNotPositive": "Use a positive issue number.", + "duplicateIssueSameIssue": "Choose a different issue.", + "repository": "Repository", + "backToCloseReasons": "Back", + "searchIssues": "Search issues", + "useIssueNumber": "Use issue #{{value0}}", + "noMatchingIssuesLoaded": "No matching issues loaded.", + "editReviewersWithCurrent": "Edit reviewers: {{value0}}" }, "Terminal": { "73768427cf": "Cerca", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 14cbf557eaf..9a0f98e8bc7 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1272,7 +1272,10 @@ "2b4fdb880c": "閲覧済みのマークを外す", "56ec6eafb7": "PR の詳細を開いて、現在のレビュアーを表示します。", "7f964a365a": "レビュアーを削除", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "Comment is too large to submit safely.", + "8ff5ae8866": "Assignees", + "82c87eceb9": "Edit assignees", + "1ff5d979df": "No one assigned" }, "QuickOpen": { "1dbd3f59ff": "移動", @@ -1602,7 +1605,25 @@ "fe28c9821f": "view", "8d1e17a3ef": "{{value0}} を GitHub で開く", "4ac8ff2275": "{{value0}} を Jira で開く", - "40eaf2c27c": "Details" + "40eaf2c27c": "Details", + "closeAsCompleted": "Close as completed", + "closeAsCompletedDescription": "Done, closed, fixed, resolved", + "closeAsNotPlanned": "Close as not planned", + "closeAsNotPlannedDescription": "Won't fix, can't repro, stale", + "closeAsDuplicate": "Close as duplicate", + "closeAsDuplicateDescription": "Duplicate of another issue in this repository", + "duplicateIssueNumberPlaceholder": "Issue number", + "closeAsDuplicateSubmit": "Close duplicate", + "duplicateIssueMissing": "Enter an issue number in this repository.", + "duplicateIssueNotInteger": "Use a whole issue number.", + "duplicateIssueNotPositive": "Use a positive issue number.", + "duplicateIssueSameIssue": "Choose a different issue.", + "repository": "Repository", + "backToCloseReasons": "Back", + "searchIssues": "Search issues", + "useIssueNumber": "Use issue #{{value0}}", + "noMatchingIssuesLoaded": "No matching issues loaded.", + "editReviewersWithCurrent": "Edit reviewers: {{value0}}" }, "Terminal": { "73768427cf": "閉じる", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 4478b7fa714..ed0b6ce346e 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1272,7 +1272,10 @@ "2b4fdb880c": "본 표시 해제", "56ec6eafb7": "현재 리뷰어를 보려면 PR 세부정보를 엽니다.", "7f964a365a": "리뷰어 삭제", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "Comment is too large to submit safely.", + "8ff5ae8866": "Assignees", + "82c87eceb9": "Edit assignees", + "1ff5d979df": "No one assigned" }, "QuickOpen": { "1dbd3f59ff": "이동", @@ -1602,7 +1605,25 @@ "fe28c9821f": "보기", "8d1e17a3ef": "GitHub에서 {{value0}} 열기", "4ac8ff2275": "Jira에서 {{value0}} 열기", - "40eaf2c27c": "Details" + "40eaf2c27c": "Details", + "closeAsCompleted": "Close as completed", + "closeAsCompletedDescription": "Done, closed, fixed, resolved", + "closeAsNotPlanned": "Close as not planned", + "closeAsNotPlannedDescription": "Won't fix, can't repro, stale", + "closeAsDuplicate": "Close as duplicate", + "closeAsDuplicateDescription": "Duplicate of another issue in this repository", + "duplicateIssueNumberPlaceholder": "Issue number", + "closeAsDuplicateSubmit": "Close duplicate", + "duplicateIssueMissing": "Enter an issue number in this repository.", + "duplicateIssueNotInteger": "Use a whole issue number.", + "duplicateIssueNotPositive": "Use a positive issue number.", + "duplicateIssueSameIssue": "Choose a different issue.", + "repository": "Repository", + "backToCloseReasons": "Back", + "searchIssues": "Search issues", + "useIssueNumber": "Use issue #{{value0}}", + "noMatchingIssuesLoaded": "No matching issues loaded.", + "editReviewersWithCurrent": "Edit reviewers: {{value0}}" }, "Terminal": { "73768427cf": "닫기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 0e6c0214a88..b67d1db3bf5 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1272,7 +1272,10 @@ "2b4fdb880c": "取消标记已查看", "56ec6eafb7": "打开 PR 详情以查看当前评审人。", "7f964a365a": "移除评审人", - "commentTooLarge": "评论过长,无法安全提交。" + "commentTooLarge": "评论过长,无法安全提交。", + "8ff5ae8866": "Assignees", + "82c87eceb9": "Edit assignees", + "1ff5d979df": "No one assigned" }, "QuickOpen": { "1dbd3f59ff": "手机", @@ -1602,7 +1605,25 @@ "fe28c9821f": "视图", "8d1e17a3ef": "在 GitHub 中打开 {{value0}}", "4ac8ff2275": "在 Jira 中打开 {{value0}}", - "40eaf2c27c": "Details" + "40eaf2c27c": "Details", + "closeAsCompleted": "Close as completed", + "closeAsCompletedDescription": "Done, closed, fixed, resolved", + "closeAsNotPlanned": "Close as not planned", + "closeAsNotPlannedDescription": "Won't fix, can't repro, stale", + "closeAsDuplicate": "Close as duplicate", + "closeAsDuplicateDescription": "Duplicate of another issue in this repository", + "duplicateIssueNumberPlaceholder": "Issue number", + "closeAsDuplicateSubmit": "Close duplicate", + "duplicateIssueMissing": "Enter an issue number in this repository.", + "duplicateIssueNotInteger": "Use a whole issue number.", + "duplicateIssueNotPositive": "Use a positive issue number.", + "duplicateIssueSameIssue": "Choose a different issue.", + "repository": "Repository", + "backToCloseReasons": "Back", + "searchIssues": "Search issues", + "useIssueNumber": "Use issue #{{value0}}", + "noMatchingIssuesLoaded": "No matching issues loaded.", + "editReviewersWithCurrent": "Edit reviewers: {{value0}}" }, "Terminal": { "73768427cf": "关闭", From a5aee31f6ecd87e576e69734e0d5cdb699a9757d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:32:05 +0000 Subject: [PATCH 05/18] release: v1.4.95-rc.0 [rc-slot:2026-06-23-15] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6b08ed1e086..8cb01bd38bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.94", + "version": "1.4.95-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 4e63bb0e637646a64e8419ec4455b9362d2bb5a1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:36:48 -0700 Subject: [PATCH 06/18] Keep pages usable after lazy chunk reload failures (#6206) --- ...bleRenderErrorBoundary.lazy-chunk.test.tsx | 112 ++++++++++++++++++ .../RecoverableRenderErrorBoundary.tsx | 4 + src/renderer/src/lib/lazy-with-retry.test.ts | 53 ++++++++- src/renderer/src/lib/lazy-with-retry.ts | 66 +++++++++-- 4 files changed, 219 insertions(+), 16 deletions(-) create mode 100644 src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.lazy-chunk.test.tsx diff --git a/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.lazy-chunk.test.tsx b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.lazy-chunk.test.tsx new file mode 100644 index 00000000000..a4730f59e92 --- /dev/null +++ b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.lazy-chunk.test.tsx @@ -0,0 +1,112 @@ +// @vitest-environment happy-dom + +import { Suspense, act, type ReactElement, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { lazyWithRetry } from '@/lib/lazy-with-retry' +import { RecoverableRenderErrorBoundary } from './RecoverableRenderErrorBoundary' + +const reportCrashMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/react-error-boundary-reporting', () => ({ + reportReactErrorBoundaryCrash: reportCrashMock +})) + +const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +function createContainer(): { container: HTMLDivElement; root: Root } { + const container = document.createElement('div') + document.body.appendChild(container) + return { container, root: createRoot(container) } +} + +function BoundaryHarness({ children }: { children: ReactNode }): ReactElement { + return ( + + Loading...
}>{children} + + ) +} + +async function flushReactWork(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) +} + +describe('RecoverableRenderErrorBoundary lazy chunk containment', () => { + let root: Root | null = null + let container: HTMLDivElement | null = null + let consoleError: ReturnType + + beforeEach(() => { + reportCrashMock.mockReset() + window.sessionStorage.clear() + consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + window.sessionStorage.clear() + consoleError.mockRestore() + }) + + it('renders the fallback without reporting after guarded dynamic import exhaustion', async () => { + window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') + const LazyRejectingImport = lazyWithRetry( + () => + Promise.reject( + new TypeError('Failed to fetch dynamically imported module: file://redacted/chunk.js') + ), + { retries: 0 } + ) + ;({ container, root } = createContainer()) + + await act(async () => { + root?.render( + + + + ) + }) + await flushReactWork() + await flushReactWork() + + expect(container?.querySelector('[role="alert"]')).not.toBeNull() + expect(reportCrashMock).not.toHaveBeenCalled() + }) + + it('still reports ordinary render errors', async () => { + const error = new Error('ordinary render failure') + function BrokenSurface(): ReactElement { + throw error + } + ;({ container, root } = createContainer()) + + await act(async () => { + root?.render( + + + + ) + }) + + expect(container?.querySelector('[role="alert"]')).not.toBeNull() + expect(reportCrashMock).toHaveBeenCalledTimes(1) + expect(reportCrashMock).toHaveBeenCalledWith( + expect.objectContaining({ + boundaryId: 'page.automations', + surface: 'page', + error + }) + ) + }) +}) diff --git a/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx index afa7fbf3dc9..eddfbd14252 100644 --- a/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx +++ b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx @@ -2,6 +2,7 @@ import React from 'react' import { AlertTriangle, RotateCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' +import { isLazyChunkLoadError } from '@/lib/lazy-with-retry' import { reportReactErrorBoundaryCrash } from '@/lib/react-error-boundary-reporting' import type { ReactErrorBoundaryReportArgs } from '../../../../shared/crash-reporting' import { translate } from '@/i18n/i18n' @@ -48,6 +49,9 @@ export class RecoverableRenderErrorBoundary extends React.Component null const chunkParseError = (): SyntaxError => new SyntaxError("Unexpected token ']'") +const chunkFetchError = (): TypeError => + new TypeError('Failed to fetch dynamically imported module: file://redacted/chunk.js') function spyOnReload(): ReturnType { const reload = vi.fn() @@ -107,21 +109,58 @@ describe('loadLazyWithRetry', () => { expect(settled).toBe(false) }) - it('does NOT reload twice — re-throws once the guard is already set', async () => { + it('does NOT reload twice — wraps known chunk failures once the guard is already set', async () => { const reload = spyOnReload() window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') - const error = chunkParseError() + const error = chunkFetchError() const factory = vi.fn(() => Promise.reject(error)) const loaded = loadLazyWithRetry(factory, { retries: 2, baseDelayMs: 250 }) + const assertion = expect(loaded).rejects.toMatchObject({ + name: 'LazyChunkLoadError', + cause: error + }) + await vi.advanceTimersByTimeAsync(5000) + await assertion + + expect(reload).not.toHaveBeenCalled() + const caught = await loaded.catch((rejection) => rejection) + expect(isLazyChunkLoadError(caught)).toBe(true) + }) + + it('preserves the original error when the guarded failure is not a dynamic import failure', async () => { + const reload = spyOnReload() + window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') + const error = new Error('render bug from lazy module evaluation') + const factory = vi.fn(() => Promise.reject(error)) + + const loaded = loadLazyWithRetry(factory, { retries: 1, baseDelayMs: 100 }) const assertion = expect(loaded).rejects.toBe(error) await vi.advanceTimersByTimeAsync(5000) await assertion expect(reload).not.toHaveBeenCalled() + const caught = await loaded.catch((rejection) => rejection) + expect(isLazyChunkLoadError(caught)).toBe(false) }) - it('fails closed (re-throws, never reloads) when sessionStorage reads throw', async () => { + it('preserves bare parse errors so lazy module evaluation bugs still report', async () => { + const reload = spyOnReload() + window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') + const error = chunkParseError() + const factory = vi.fn(() => Promise.reject(error)) + + const loaded = loadLazyWithRetry(factory, { retries: 1, baseDelayMs: 100 }) + const assertion = expect(loaded).rejects.toBe(error) + await vi.advanceTimersByTimeAsync(5000) + await assertion + + expect(reload).not.toHaveBeenCalled() + const caught = await loaded.catch((rejection) => rejection) + expect(isLazyChunkLoadError(caught)).toBe(false) + }) + + it('fails closed with the original error when sessionStorage reads throw', async () => { const reload = spyOnReload() // Private-mode / sandboxed storage makes reads throw. The guard must treat // this as "already reloaded" so a broken chunk can NEVER cause a reload loop. @@ -135,6 +174,8 @@ describe('loadLazyWithRetry', () => { await assertion expect(reload).not.toHaveBeenCalled() + const caught = await loaded.catch((rejection) => rejection) + expect(isLazyChunkLoadError(caught)).toBe(false) }) it('records a lazy_chunk_reload breadcrumb (with reloadKey) before reloading', async () => { @@ -166,7 +207,7 @@ describe('loadLazyWithRetry', () => { expect(settled).toBe(false) }) - it('re-throws without reloading when there is no window (SSR / node)', async () => { + it('re-throws the original error without reloading when there is no window (SSR / node)', async () => { vi.stubGlobal('window', undefined) const error = chunkParseError() const factory = vi.fn(() => Promise.reject(error)) @@ -177,6 +218,8 @@ describe('loadLazyWithRetry', () => { await assertion expect(factory).toHaveBeenCalledTimes(2) + const caught = await loaded.catch((rejection) => rejection) + expect(isLazyChunkLoadError(caught)).toBe(false) }) it('keeps the reload guard set across a successful load (no second reload in one session)', async () => { diff --git a/src/renderer/src/lib/lazy-with-retry.ts b/src/renderer/src/lib/lazy-with-retry.ts index 5a09f6b2a95..7c3cc604382 100644 --- a/src/renderer/src/lib/lazy-with-retry.ts +++ b/src/renderer/src/lib/lazy-with-retry.ts @@ -18,6 +18,8 @@ type AnyComponent = ComponentType type LazyFactory = () => Promise<{ default: T }> +type ReloadGuardState = 'not-attempted' | 'attempted' | 'unavailable' + export type LazyWithRetryOptions = { retries?: number baseDelayMs?: number @@ -25,6 +27,18 @@ export type LazyWithRetryOptions = { reloadKey?: string } +export class LazyChunkLoadError extends Error { + constructor(cause: unknown) { + super('Lazy chunk load failed after reload recovery was exhausted') + this.name = 'LazyChunkLoadError' + ;(this as { cause?: unknown }).cause = cause + } +} + +export function isLazyChunkLoadError(error: unknown): error is LazyChunkLoadError { + return error instanceof LazyChunkLoadError +} + // One recovery reload per session. The guard survives the reload itself (so we // never loop) but resets when the window/app closes, so a later launch — e.g. // after an update ships fresh chunks — can earn another reload. sessionStorage @@ -35,21 +49,26 @@ const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted' const DEFAULT_RETRIES = 2 const DEFAULT_BASE_DELAY_MS = 250 -function hasAttemptedChunkReload(): boolean { +function readChunkReloadGuardState(): ReloadGuardState { + if (typeof window === 'undefined') { + return 'unavailable' + } try { - return window.sessionStorage.getItem(RELOAD_GUARD_KEY) === '1' + return window.sessionStorage.getItem(RELOAD_GUARD_KEY) === '1' ? 'attempted' : 'not-attempted' } catch { - // Why: when sessionStorage is unavailable (private mode / sandboxed), fail - // closed (treat as already-reloaded) so we never risk an infinite reload loop. - return true + // Why: when storage is blocked we cannot prove a reload happened, but still + // fail closed on reloads so a broken chunk never loops. + return 'unavailable' } } -function markChunkReloadAttempted(): void { +function markChunkReloadAttempted(): boolean { try { window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') + return true } catch { - // Best-effort; if writing throws, hasAttemptedChunkReload() also fails closed. + // A reload without a durable guard can loop, so treat write failure as unavailable. + return false } } @@ -71,6 +90,24 @@ const wait = (ms: number): Promise => new Promise((resolve) => setTimeout( // down, so the error fallback never flashes in the moment before the reload lands. const SUSPEND_UNTIL_RELOAD = new Promise(() => undefined) +function isKnownDynamicImportFailure(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + + if (error.name === 'ChunkLoadError') { + return true + } + + return [ + /failed to fetch dynamically imported module/i, + /error loading dynamically imported module/i, + /importing a module script failed/i, + /failed to load module script/i, + /loading chunk .+ failed/i + ].some((pattern) => pattern.test(error.message)) +} + export async function loadLazyWithRetry( factory: LazyFactory, options: LazyWithRetryOptions = {} @@ -91,8 +128,11 @@ export async function loadLazyWithRetry( } } - if (typeof window !== 'undefined' && !hasAttemptedChunkReload()) { - markChunkReloadAttempted() + const reloadGuardState = readChunkReloadGuardState() + if (typeof window !== 'undefined' && reloadGuardState === 'not-attempted') { + if (!markChunkReloadAttempted()) { + throw lastError + } recordReloadBreadcrumb( options.reloadKey ?? 'unknown', lastError instanceof Error ? lastError.message : String(lastError) @@ -101,8 +141,12 @@ export async function loadLazyWithRetry( return SUSPEND_UNTIL_RELOAD } - // Already reloaded once this session, or no window (SSR / node): re-throw so - // RecoverableRenderErrorBoundary catches and reports it instead of looping. + if (reloadGuardState === 'attempted' && isKnownDynamicImportFailure(lastError)) { + throw new LazyChunkLoadError(lastError) + } + + // No proven reload attempt (SSR / node / blocked storage) or unknown failure: + // re-throw the original error so normal error reporting semantics stay intact. throw lastError } From 9e88231434600fd952974aaced73cac132a2a276 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:38:49 -0700 Subject: [PATCH 07/18] Preserve remote host repo state (#6136) Co-authored-by: Orca --- .../src/lib/repo-runtime-owner.test.ts | 30 ++ src/renderer/src/lib/repo-runtime-owner.ts | 28 +- .../src/store/slices/repo-host-identity.ts | 56 +++ .../slices/repo-identity-reconcile.test.ts | 31 ++ .../store/slices/repo-identity-reconcile.ts | 5 +- .../slices/repo-reorder-host-split.test.ts | 11 + .../store/slices/repo-reorder-host-split.ts | 23 +- .../repos-host-identity-routing.test.ts | 345 ++++++++++++++++ src/renderer/src/store/slices/repos.ts | 288 +++++++++++--- .../src/store/slices/worktrees.test.ts | 83 ++++ src/renderer/src/store/slices/worktrees.ts | 372 +++++++++++++++--- 11 files changed, 1156 insertions(+), 116 deletions(-) create mode 100644 src/renderer/src/store/slices/repo-host-identity.ts create mode 100644 src/renderer/src/store/slices/repos-host-identity-routing.test.ts diff --git a/src/renderer/src/lib/repo-runtime-owner.test.ts b/src/renderer/src/lib/repo-runtime-owner.test.ts index fab3a7547f0..719c964676e 100644 --- a/src/renderer/src/lib/repo-runtime-owner.test.ts +++ b/src/renderer/src/lib/repo-runtime-owner.test.ts @@ -55,6 +55,36 @@ describe('getRuntimeEnvironmentIdForRepo', () => { ).toBe('focused-runtime') }) + it('uses the focused host row when duplicate repo ids exist', () => { + expect( + getRuntimeEnvironmentIdForRepo( + { + settings: { activeRuntimeEnvironmentId: 'owner-runtime' }, + repos: [ + { id: 'repo-1', connectionId: null, executionHostId: 'local' }, + { id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' } + ] + }, + 'repo-1' + ) + ).toBe('owner-runtime') + }) + + it('does not silently choose a duplicate repo row when the focused host does not match', () => { + expect( + getRuntimeEnvironmentIdForRepo( + { + settings: { activeRuntimeEnvironmentId: 'other-runtime' }, + repos: [ + { id: 'repo-1', connectionId: null, executionHostId: 'local' }, + { id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' } + ] + }, + 'repo-1' + ) + ).toBe('other-runtime') + }) + it('returns settings scoped to an explicit local repo owner', () => { expect( getSettingsForRepoRuntimeOwner( diff --git a/src/renderer/src/lib/repo-runtime-owner.ts b/src/renderer/src/lib/repo-runtime-owner.ts index 81905e9d1b3..36b7cf757d2 100644 --- a/src/renderer/src/lib/repo-runtime-owner.ts +++ b/src/renderer/src/lib/repo-runtime-owner.ts @@ -1,4 +1,8 @@ -import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host' +import { + getRepoExecutionHostId, + getSettingsFocusedExecutionHostId, + parseExecutionHostId +} from '../../../shared/execution-host' import type { GlobalSettings, Repo } from '../../../shared/types' export type RepoRuntimeOwnerState = { @@ -6,6 +10,26 @@ export type RepoRuntimeOwnerState = { settings?: Pick | null } +function findRepoOwner( + state: RepoRuntimeOwnerState, + repoId: string +): Pick | null { + const matchingRepos = state.repos?.filter((entry) => entry.id === repoId) ?? [] + if (matchingRepos.length === 0) { + return null + } + if (matchingRepos.length === 1) { + return matchingRepos[0] + } + const focusedHostId = getSettingsFocusedExecutionHostId(state.settings) + const focusedMatches = matchingRepos.filter( + (entry) => getRepoExecutionHostId(entry) === focusedHostId + ) + // Why: duplicate bare repo ids are only safe to route when focus selects one + // owner unambiguously; otherwise callers must avoid guessing a host. + return focusedMatches.length === 1 ? focusedMatches[0] : null +} + export function getRuntimeEnvironmentIdForRepo( state: RepoRuntimeOwnerState, repoId: string | null | undefined @@ -13,7 +37,7 @@ export function getRuntimeEnvironmentIdForRepo( if (!repoId) { return null } - const repo = state.repos?.find((entry) => entry.id === repoId) + const repo = findRepoOwner(state, repoId) const hasExplicitOwner = Boolean(repo?.executionHostId?.trim() || repo?.connectionId?.trim()) if (repo && hasExplicitOwner) { const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) diff --git a/src/renderer/src/store/slices/repo-host-identity.ts b/src/renderer/src/store/slices/repo-host-identity.ts new file mode 100644 index 00000000000..92afdafc3ad --- /dev/null +++ b/src/renderer/src/store/slices/repo-host-identity.ts @@ -0,0 +1,56 @@ +import type { GlobalSettings, Repo } from '../../../../shared/types' +import { + getRepoExecutionHostId, + getSettingsFocusedExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' + +type RepoIdentityParts = Pick + +export function getRepoHostIdentity(repo: RepoIdentityParts): string { + return getRepoHostIdentityForParts(repo.id, getRepoExecutionHostId(repo)) +} + +export function getRepoHostIdentityForParts(repoId: string, hostId: string): string { + // Why: host ids and repo ids can contain punctuation; NUL keeps the composite + // key collision-free without escaping user/provider-owned strings. + return `${hostId}\0${repoId}` +} + +export function repoMatchesHostIdentity( + repo: RepoIdentityParts, + repoId: string, + hostId: string +): boolean { + return repo.id === repoId && getRepoExecutionHostId(repo) === hostId +} + +export function findRepoForHost( + repos: readonly RepoIdentityParts[], + repoId: string, + options: { + hostId?: ExecutionHostId | string | null + settings?: Pick | null + } = {} +): RepoIdentityParts | null { + const matchingRepos = repos.filter((repo) => repo.id === repoId) + if (matchingRepos.length === 0) { + return null + } + + if (options.hostId) { + return matchingRepos.find((repo) => getRepoExecutionHostId(repo) === options.hostId) ?? null + } + + if (matchingRepos.length === 1) { + return matchingRepos[0] + } + + const focusedHostId = getSettingsFocusedExecutionHostId(options.settings) + const focusedMatches = matchingRepos.filter( + (repo) => getRepoExecutionHostId(repo) === focusedHostId + ) + // Why: when duplicate ids exist even within the focused host, mutating by bare + // id would be ambiguous. Let callers surface no owner instead of guessing. + return focusedMatches.length === 1 ? focusedMatches[0] : null +} diff --git a/src/renderer/src/store/slices/repo-identity-reconcile.test.ts b/src/renderer/src/store/slices/repo-identity-reconcile.test.ts index 71615147156..f8c334ff4a7 100644 --- a/src/renderer/src/store/slices/repo-identity-reconcile.test.ts +++ b/src/renderer/src/store/slices/repo-identity-reconcile.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import fs from 'node:fs' import type { Repo } from '../../../../shared/types' import { reconcileFetchedRepos } from './repo-identity-reconcile' @@ -47,4 +48,34 @@ describe('reconcileFetchedRepos', () => { expect(result[0]).toBe(previous[0]) expect(result.map((r) => r.id)).toEqual(['a', 'b']) }) + + it('preserves same-id repo identity only within the matching host', () => { + const previous = [ + makeRepo('same', { executionHostId: 'local', displayName: 'Local' }), + makeRepo('same', { executionHostId: 'runtime:env-1', displayName: 'Remote' }) + ] + const next = [ + makeRepo('same', { executionHostId: 'runtime:env-1', displayName: 'Remote' }), + makeRepo('same', { executionHostId: 'local', displayName: 'Local' }) + ] + + const result = reconcileFetchedRepos(previous, next) + + expect(result[0]).toBe(previous[1]) + expect(result[1]).toBe(previous[0]) + }) + + it('uses map-style host identity lookups for large duplicate refreshes', () => { + const previous = Array.from({ length: 1000 }, (_, index) => + makeRepo(`repo-${index}`, { executionHostId: index % 2 === 0 ? 'local' : 'runtime:env-1' }) + ) + const next = previous.map((repo) => ({ ...repo })) + + const result = reconcileFetchedRepos(previous, next) + + expect(result).toBe(previous) + expect(result.every((repo, index) => repo === previous[index])).toBe(true) + const source = fs.readFileSync(new URL('./repo-identity-reconcile.ts', import.meta.url), 'utf8') + expect(source).not.toMatch(/previous\.find|next\.find|findIndex/) + }) }) diff --git a/src/renderer/src/store/slices/repo-identity-reconcile.ts b/src/renderer/src/store/slices/repo-identity-reconcile.ts index 55d313e0cd6..119c4ed3e2a 100644 --- a/src/renderer/src/store/slices/repo-identity-reconcile.ts +++ b/src/renderer/src/store/slices/repo-identity-reconcile.ts @@ -1,4 +1,5 @@ import type { Repo } from '../../../../shared/types' +import { getRepoHostIdentity } from './repo-host-identity' // Why: after a drag-reorder we optimistically set `repos`, persist, and main // broadcasts `repos:changed`. The renderer's own echo handler refetches, which @@ -27,10 +28,10 @@ function areReposEqual(a: Repo, b: Repo): boolean { } export function reconcileFetchedRepos(previous: readonly Repo[], next: Repo[]): Repo[] { - const previousById = new Map(previous.map((repo) => [repo.id, repo])) + const previousById = new Map(previous.map((repo) => [getRepoHostIdentity(repo), repo])) let identical = next.length === previous.length const reconciled = next.map((repo, index) => { - const existing = previousById.get(repo.id) + const existing = previousById.get(getRepoHostIdentity(repo)) if (existing && areReposEqual(existing, repo)) { if (existing !== previous[index]) { identical = false diff --git a/src/renderer/src/store/slices/repo-reorder-host-split.test.ts b/src/renderer/src/store/slices/repo-reorder-host-split.test.ts index 8ba15f5edd9..fd4ace117f3 100644 --- a/src/renderer/src/store/slices/repo-reorder-host-split.test.ts +++ b/src/renderer/src/store/slices/repo-reorder-host-split.test.ts @@ -43,4 +43,15 @@ describe('splitRepoReorderByHost', () => { }) expect(groups).toEqual([{ hostId: 'local', orderedIds: ['a'] }]) }) + + it('routes duplicate bare ids by occurrence in current repo order', () => { + const repos = [repo('same', 'local'), repo('same', 'runtime:env-1')] + const groups = splitRepoReorderByHost(['same', 'same'], repos, { + activeRuntimeEnvironmentId: null + }) + expect(groups).toEqual([ + { hostId: 'local', orderedIds: ['same'] }, + { hostId: 'runtime:env-1', orderedIds: ['same'] } + ]) + }) }) diff --git a/src/renderer/src/store/slices/repo-reorder-host-split.ts b/src/renderer/src/store/slices/repo-reorder-host-split.ts index 2ef975b4646..9924df17c7f 100644 --- a/src/renderer/src/store/slices/repo-reorder-host-split.ts +++ b/src/renderer/src/store/slices/repo-reorder-host-split.ts @@ -9,6 +9,11 @@ export type RepoReorderHostGroup = { orderedIds: string[] } +type RepoHostCursor = { + hosts: string[] + nextIndex: number +} + /** Split a cross-host reorder permutation into per-host permutations. * * Why: each host persists only its own repos and rejects any id list that is not @@ -25,14 +30,26 @@ export function splitRepoReorderByHost( settings: Pick | null | undefined ): RepoReorderHostGroup[] { const focusedHostId = getSettingsFocusedExecutionHostId(settings) - const hostByRepoId = new Map() + const remainingHostsByRepoId = new Map() for (const repo of repos) { const hasExplicitOwner = Boolean(repo.executionHostId?.trim() || repo.connectionId?.trim()) - hostByRepoId.set(repo.id, hasExplicitOwner ? getRepoExecutionHostId(repo) : focusedHostId) + const hostId = hasExplicitOwner ? getRepoExecutionHostId(repo) : focusedHostId + const existing = remainingHostsByRepoId.get(repo.id) + if (existing) { + existing.hosts.push(hostId) + } else { + remainingHostsByRepoId.set(repo.id, { hosts: [hostId], nextIndex: 0 }) + } } const groups = new Map() for (const id of orderedIds) { - const hostId = hostByRepoId.get(id) + const remainingHosts = remainingHostsByRepoId.get(id) + // Why: a bare repo id can appear once per host in the combined order. The + // cursor consumes each occurrence against its owner host exactly once. + const hostId = remainingHosts?.hosts[remainingHosts.nextIndex] + if (remainingHosts) { + remainingHosts.nextIndex += 1 + } if (!hostId) { continue } diff --git a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts new file mode 100644 index 00000000000..22690115f32 --- /dev/null +++ b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts @@ -0,0 +1,345 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore, makeWorktree } from './store-test-helpers' +import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +const localDuplicate: Repo = { + id: 'same-repo', + path: '/local', + displayName: 'Local', + badgeColor: '#000', + addedAt: 1, + executionHostId: 'local' +} + +const remoteDuplicate: Repo = { + id: 'same-repo', + path: '/remote', + displayName: 'Remote', + badgeColor: '#111', + addedAt: 2, + executionHostId: 'runtime:env-1' +} + +const reposRemove = vi.fn() +const reposUpdate = vi.fn() +const reposReorder = vi.fn() +const ptyKill = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +function projectHostSetup(overrides: Pick): ProjectHostSetup { + return { + projectId: 'repo:same-repo', + repoId: 'same-repo', + path: '/same-repo', + displayName: 'Same Repo', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + reposRemove.mockReset() + reposUpdate.mockReset() + reposReorder.mockReset() + ptyKill.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + repos: { + remove: reposRemove, + update: reposUpdate, + reorder: reposReorder + }, + pty: { kill: ptyKill }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) +}) + +describe('repo slice host identity routing', () => { + it('updates only the focused host row when repo ids are duplicated across hosts', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-duplicate-update', + ok: true, + result: { repo: { ...remoteDuplicate, displayName: 'Remote Renamed' } }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [localDuplicate, remoteDuplicate] + }) + + await store.getState().updateRepo('same-repo', { displayName: 'Remote Renamed' }) + + expect(store.getState().repos).toEqual([ + localDuplicate, + { ...remoteDuplicate, displayName: 'Remote Renamed' } + ]) + expect(reposUpdate).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.update', + params: { repo: 'same-repo', updates: { displayName: 'Remote Renamed' } }, + timeoutMs: 15_000 + }) + }) + + it('updates a legacy local duplicate without overwriting an explicit remote sibling', async () => { + const { executionHostId: _executionHostId, ...legacyLocalDuplicate } = localDuplicate + reposUpdate.mockResolvedValue(undefined) + const store = createTestStore() + store.setState({ repos: [legacyLocalDuplicate as Repo, remoteDuplicate] }) + + await store.getState().updateRepo('same-repo', { displayName: 'Local Renamed' }) + + expect(store.getState().repos).toEqual([ + { ...legacyLocalDuplicate, displayName: 'Local Renamed' }, + remoteDuplicate + ]) + expect(reposUpdate).toHaveBeenCalledWith({ + repoId: 'same-repo', + updates: { displayName: 'Local Renamed' } + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'repo.update' }) + ) + }) + + it('keeps queued focused-host repo updates pinned when focus changes', async () => { + const firstUpdate = deferred<{ + id: string + ok: true + result: { repo: Repo } + _meta: { runtimeId: string } + }>() + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.update') { + const { updates } = (args as unknown as { params: { updates: { displayName: string } } }) + .params + const displayName = updates.displayName + if (displayName === 'Remote slow') { + return firstUpdate.promise + } + return Promise.resolve({ + id: 'rpc-queued-update', + ok: true, + result: { repo: { ...remoteDuplicate, displayName } }, + _meta: { runtimeId: 'runtime-remote' } + }) + } + return Promise.resolve({ + id: 'rpc-other', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-remote' } + }) + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [localDuplicate, remoteDuplicate] + }) + + const first = store.getState().updateRepo('same-repo', { displayName: 'Remote slow' }) + await vi.waitFor(() => { + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'repo.update', + params: { repo: 'same-repo', updates: { displayName: 'Remote slow' } } + }) + ) + }) + + const second = store.getState().updateRepo('same-repo', { displayName: 'Remote queued' }) + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-2' } as never }) + firstUpdate.resolve({ + id: 'rpc-first-update', + ok: true, + result: { repo: { ...remoteDuplicate, displayName: 'Remote slow' } }, + _meta: { runtimeId: 'runtime-remote' } + }) + + await expect(first).resolves.toBe(true) + await expect(second).resolves.toBe(true) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'repo.update', + params: { repo: 'same-repo', updates: { displayName: 'Remote queued' } } + }) + ) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-2', method: 'repo.update' }) + ) + expect(store.getState().repos).toEqual([ + localDuplicate, + { ...remoteDuplicate, displayName: 'Remote queued' } + ]) + }) + + it('removes only the focused host row and worktrees for duplicate repo ids', async () => { + const localWorktree = makeWorktree({ + id: 'same-repo::/local/wt', + repoId: 'same-repo' + }) + const remoteWorktree = makeWorktree({ + id: 'same-repo::/remote/wt', + repoId: 'same-repo', + hostId: 'runtime:env-1' + }) + const store = createTestStore() + store.setState({ + repos: [localDuplicate, remoteDuplicate], + projects: [ + { + id: 'repo:same-repo', + displayName: 'Same Repo', + badgeColor: '#000', + sourceRepoIds: ['same-repo'], + createdAt: 1, + updatedAt: 1 + } satisfies Project + ], + projectHostSetups: [ + projectHostSetup({ id: 'local-setup', hostId: 'local' }), + projectHostSetup({ id: 'remote-setup', hostId: 'runtime:env-1' }) + ], + worktreesByRepo: { 'same-repo': [localWorktree, remoteWorktree] }, + tabsByWorktree: { + [localWorktree.id]: [{ id: 'local-tab', worktreeId: localWorktree.id }] as never, + [remoteWorktree.id]: [{ id: 'remote-tab', worktreeId: remoteWorktree.id }] as never + }, + ptyIdsByTabId: { + 'local-tab': ['local-pty'], + 'remote-tab': ['remote-pty'] + }, + lastVisitedAtByWorktreeId: { + [localWorktree.id]: 10, + [remoteWorktree.id]: 20 + } + }) + + await store.getState().removeProject('same-repo') + + expect(store.getState().repos).toEqual([remoteDuplicate]) + expect(store.getState().worktreesByRepo['same-repo']).toEqual([remoteWorktree]) + expect(store.getState().tabsByWorktree[localWorktree.id]).toBeUndefined() + expect(store.getState().tabsByWorktree[remoteWorktree.id]).toEqual([ + { id: 'remote-tab', worktreeId: remoteWorktree.id } + ]) + expect(store.getState().projectHostSetups).toEqual([ + expect.objectContaining({ hostId: 'runtime:env-1', repoId: 'same-repo' }) + ]) + expect(store.getState().lastVisitedAtByWorktreeId).toEqual({ [remoteWorktree.id]: 20 }) + expect(store.getState().projects).toEqual([ + expect.objectContaining({ id: 'repo:same-repo', sourceRepoIds: ['same-repo'] }) + ]) + expect(reposRemove).toHaveBeenCalledWith({ repoId: 'same-repo' }) + expect(ptyKill).toHaveBeenCalledWith('local-pty') + expect(ptyKill).not.toHaveBeenCalledWith('remote-pty') + }) + + it('removes a runtime duplicate without purging legacy local worktrees', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-duplicate-remove', + ok: true, + result: { status: 'removed' }, + _meta: { runtimeId: 'runtime-remote' } + }) + const localWorktree = makeWorktree({ + id: 'same-repo::/local/wt', + repoId: 'same-repo' + }) + const remoteWorktree = makeWorktree({ + id: 'same-repo::/remote/wt', + repoId: 'same-repo', + hostId: 'runtime:env-1' + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [localDuplicate, remoteDuplicate], + worktreesByRepo: { 'same-repo': [localWorktree, remoteWorktree] }, + tabsByWorktree: { + [localWorktree.id]: [{ id: 'local-tab', worktreeId: localWorktree.id }] as never, + [remoteWorktree.id]: [{ id: 'remote-tab', worktreeId: remoteWorktree.id }] as never + }, + ptyIdsByTabId: { + 'local-tab': ['local-pty'], + 'remote-tab': ['remote-pty'] + }, + lastVisitedAtByWorktreeId: { + [localWorktree.id]: 10, + [remoteWorktree.id]: 20 + } + }) + + await store.getState().removeProject('same-repo') + + expect(store.getState().repos).toEqual([localDuplicate]) + expect(store.getState().worktreesByRepo['same-repo']).toEqual([localWorktree]) + expect(store.getState().tabsByWorktree[localWorktree.id]).toEqual([ + { id: 'local-tab', worktreeId: localWorktree.id } + ]) + expect(store.getState().tabsByWorktree[remoteWorktree.id]).toBeUndefined() + expect(store.getState().lastVisitedAtByWorktreeId).toEqual({ [localWorktree.id]: 10 }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.rm', + params: { repo: 'same-repo' }, + timeoutMs: 15_000 + }) + expect(reposRemove).not.toHaveBeenCalled() + expect(ptyKill).toHaveBeenCalledWith('remote-pty') + expect(ptyKill).not.toHaveBeenCalledWith('local-pty') + }) + + it('reorders duplicate repo ids once per owning host', async () => { + reposReorder.mockResolvedValue({ status: 'applied' }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-duplicate-reorder', + ok: true, + result: { status: 'applied' }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ repos: [localDuplicate, remoteDuplicate] }) + + await store.getState().reorderRepos(['same-repo', 'same-repo']) + + expect(store.getState().repos).toEqual([localDuplicate, remoteDuplicate]) + expect(reposReorder).toHaveBeenCalledWith({ orderedIds: ['same-repo'] }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.reorder', + params: { orderedIds: ['same-repo'] }, + timeoutMs: 15_000 + }) + }) +}) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 6465697299f..16258886392 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -44,9 +44,14 @@ import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' import { selectProjectGroupRemovalTargets } from './project-group-removal-targets' -import { getRepoIdFromWorktreeId } from './worktree-helpers' import { reconcileFetchedRepos } from './repo-identity-reconcile' import { splitRepoReorderByHost } from './repo-reorder-host-split' +import { + findRepoForHost, + getRepoHostIdentity, + getRepoHostIdentityForParts, + repoMatchesHostIdentity +} from './repo-host-identity' import { assertRuntimeEnvironmentCapability, callRuntimeRpc, @@ -56,7 +61,6 @@ import { syncRuntimeGitForkDefaultBranch } from '../../runtime/runtime-git-clien import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' import { buildDismissedOnboardingFolderAgentStartup } from '@/lib/onboarding-folder-agent-startup' import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' import { filterSetupScriptPromptDismissalsToValidRepos } from '@/lib/setup-script-prompt' import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills' import { translate } from '@/i18n/i18n' @@ -187,13 +191,21 @@ function getRepoUpdateChains(get: () => AppState): Map> return chains } -function getKnownRepoWorktreeIds(state: AppState, projectId: string): string[] { +function worktreeBelongsToHost(worktree: { hostId?: string }, hostId: string): boolean { + return (worktree.hostId ?? LOCAL_EXECUTION_HOST_ID) === hostId +} + +function getKnownRepoWorktreeIds(state: AppState, projectId: string, hostId?: string): string[] { const ids = new Set() for (const worktree of state.worktreesByRepo[projectId] ?? []) { - ids.add(worktree.id) + if (!hostId || worktreeBelongsToHost(worktree, hostId)) { + ids.add(worktree.id) + } } for (const worktree of state.detectedWorktreesByRepo[projectId]?.worktrees ?? []) { - ids.add(worktree.id) + if (!hostId || worktreeBelongsToHost(worktree, hostId)) { + ids.add(worktree.id) + } } return [...ids] } @@ -460,21 +472,34 @@ function getCurrentSourceRepoIds(project: Project, currentRepoIds: ReadonlySet currentRepoIds.has(repoId)) } +function getReposById(repos: readonly Repo[]): Map { + const reposById = new Map() + for (const repo of repos) { + const existing = reposById.get(repo.id) + if (existing) { + existing.push(repo) + } else { + reposById.set(repo.id, [repo]) + } + } + return reposById +} + function getSourceRepoIdsOutsideHost( project: Project, - reposById: ReadonlyMap, + reposById: ReadonlyMap, hostId: string ): string[] { return project.sourceRepoIds.filter((repoId) => { - const repo = reposById.get(repoId) - return repo && getRepoExecutionHostId(repo) !== hostId + const repos = reposById.get(repoId) ?? [] + return repos.some((repo) => getRepoExecutionHostId(repo) !== hostId) }) } function getMergedSourceRepoIdsForHostRefresh( previous: Project, current: Project, - reposById: ReadonlyMap, + reposById: ReadonlyMap, hostId: string ): string[] { return [ @@ -498,7 +523,7 @@ function projectWithCurrentSourceRepoIds( function mergePreviousProjectMetadata( previous: Project, current: Project, - reposById: ReadonlyMap, + reposById: ReadonlyMap, hostId: string ): Project { const project = mergeProjectCompatibilityProject(previous, current) @@ -535,7 +560,7 @@ function mergeProjectHostSetupCompatibility( const derivedSetups = derived.projectHostSetups.filter( (setup) => !fetchedSetupOwners.has(getProjectHostSetupOwnerKey(setup)) ) - const projectHostSetups = mergeById(derivedSetups, fetched.setups) + const projectHostSetups = mergeProjectHostSetupsByOwner(derivedSetups, fetched.setups) const setupProjectIds = new Set(projectHostSetups.map((setup) => setup.projectId)) const fetchedProjectIds = new Set(fetched.projects.map((project) => project.id)) return { @@ -547,7 +572,27 @@ function mergeProjectHostSetupCompatibility( } function getProjectHostSetupOwnerKey(setup: ProjectHostSetup): string { - return `${setup.hostId}:${setup.repoId ?? setup.id}` + return `${setup.hostId}:${setup.repoId || setup.id}` +} + +function mergeProjectHostSetupsByOwner( + base: readonly ProjectHostSetup[], + overlay: readonly ProjectHostSetup[] +): ProjectHostSetup[] { + const merged = [...base] + const indexByOwner = new Map( + merged.map((entry, index) => [getProjectHostSetupOwnerKey(entry), index]) + ) + for (const entry of overlay) { + const index = indexByOwner.get(getProjectHostSetupOwnerKey(entry)) + if (index === undefined) { + indexByOwner.set(getProjectHostSetupOwnerKey(entry), merged.length) + merged.push(entry) + } else { + merged[index] = entry + } + } + return merged } function getProjectHostIds( @@ -568,15 +613,14 @@ function getExplicitProjectHostIds( repos: readonly Repo[] ): Set { const hostIds = new Set() - const repoById = new Map(repos.map((repo) => [repo.id, repo])) + const sourceRepoIds = new Set(project.sourceRepoIds) for (const setup of setups) { if (setup.projectId === project.id) { hostIds.add(setup.hostId) } } - for (const repoId of project.sourceRepoIds) { - const repo = repoById.get(repoId) - if (repo) { + for (const repo of repos) { + if (sourceRepoIds.has(repo.id)) { hostIds.add(getRepoExecutionHostId(repo)) } } @@ -596,9 +640,9 @@ function mergeFetchedProjectCompatibilityForHost({ }): Pick { const fetchedSetupsForHost = fetched.projectHostSetups.filter((setup) => setup.hostId === hostId) const preservedSetups = previous.projectHostSetups.filter((setup) => setup.hostId !== hostId) - const projectHostSetups = mergeById(preservedSetups, fetchedSetupsForHost) + const projectHostSetups = mergeProjectHostSetupsByOwner(preservedSetups, fetchedSetupsForHost) const previousProjectById = new Map(previous.projects.map((project) => [project.id, project])) - const reposById = new Map(repos.map((repo) => [repo.id, repo])) + const reposById = getReposById(repos) const currentRepoIds = new Set(repos.map((repo) => repo.id)) const projectHasHost = (project: Project, setups: readonly ProjectHostSetup[]): boolean => getProjectHostIds(project, setups, repos).has(hostId) @@ -663,25 +707,41 @@ function mergeFetchedReposForHost( fetched: Repo[], hostId: string ): Repo[] { - const fetchedIds = new Set(fetched.map((repo) => repo.id)) + const fetchedIdentities = new Set(fetched.map(getRepoHostIdentity)) const preserved = previous.filter((repo) => { const existingHostId = getRepoExecutionHostId(repo) - return existingHostId !== hostId || fetchedIds.has(repo.id) + return existingHostId !== hostId || fetchedIdentities.has(getRepoHostIdentity(repo)) }) - const preservedById = new Map(preserved.map((repo) => [repo.id, repo])) const merged = [...preserved] + const indexByIdentity = new Map(merged.map((repo, index) => [getRepoHostIdentity(repo), index])) for (const repo of fetched) { - const existingIndex = merged.findIndex((entry) => entry.id === repo.id) - if (existingIndex === -1) { + const identity = getRepoHostIdentity(repo) + const existingIndex = indexByIdentity.get(identity) + if (existingIndex === undefined) { + indexByIdentity.set(identity, merged.length) merged.push(repo) continue } merged[existingIndex] = repo } - return reconcileFetchedRepos( + return reconcileFetchedRepos(previous, merged) +} + +function mergeProjectCompatibilityForHostRepoChange({ + previous, + nextRepos, + hostId +}: { + previous: Pick + nextRepos: readonly Repo[] + hostId: string +}): Pick { + return mergeFetchedProjectCompatibilityForHost({ previous, - merged.filter((repo) => preservedById.has(repo.id) || fetchedIds.has(repo.id)) - ) + fetched: projectCompatibilityFromRepos(nextRepos), + repos: nextRepos, + hostId + }) } function getProjectGroupHostId(group: Pick) { @@ -821,7 +881,26 @@ async function listRuntimeEnvironmentsForAllHostLoad(): Promise<{ id: string }[] } function settingsForRepoOwner(state: Pick, repoId: string) { - return getSettingsForRepoRuntimeOwner(state, repoId) as AppState['settings'] + const repo = findRepoForHost(state.repos, repoId, { settings: state.settings }) + if (!repo) { + return state.settings + } + if (!repo.executionHostId && !repo.connectionId) { + return state.settings + } + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + if (parsed?.kind === 'runtime') { + return state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: parsed.environmentId } + : ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings']) + } + if ( + (parsed?.kind === 'local' || parsed?.kind === 'ssh') && + state.settings?.activeRuntimeEnvironmentId + ) { + return { ...state.settings, activeRuntimeEnvironmentId: null } + } + return state.settings } function getFolderWorkspacePathStatusScopeKey(request: FolderWorkspacePathStatusRequest): string { @@ -1712,6 +1791,9 @@ export const createRepoSlice: StateCreator = (set, moveProjectToGroup: async (projectId, groupId, order) => { try { + if (!findRepoForHost(get().repos, projectId, { settings: get().settings })) { + return false + } const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) const moved = target.kind === 'local' @@ -1732,10 +1814,21 @@ export const createRepoSlice: StateCreator = (set, return false } const ownedMoved = repoWithFetchedOwner(moved, target) - set((s) => ({ - repos: s.repos.map((repo) => (repo.id === projectId ? ownedMoved : repo)), - folderWorkspacePathStatuses: {} - })) + const movedHostId = getRepoExecutionHostId(ownedMoved) + set((s) => { + const nextRepos = s.repos.map((repo) => + repoMatchesHostIdentity(repo, projectId, movedHostId) ? ownedMoved : repo + ) + return { + repos: nextRepos, + ...mergeProjectCompatibilityForHostRepoChange({ + previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, + nextRepos, + hostId: movedHostId + }), + folderWorkspacePathStatuses: {} + } + }) return true } catch (err) { console.error('Failed to move repo to group:', err) @@ -1778,18 +1871,24 @@ export const createRepoSlice: StateCreator = (set, return null } repo = repoWithFetchedOwner(repo, target) - const alreadyAdded = get().repos.some((r) => r.id === repo.id) + const repoIdentity = getRepoHostIdentity(repo) + const alreadyAdded = get().repos.some((r) => getRepoHostIdentity(r) === repoIdentity) if (alreadyAdded) { get().clearOrcaHookTrustForRepo(repo.id) } set((s) => { - if (s.repos.some((r) => r.id === repo.id)) { + if (s.repos.some((r) => getRepoHostIdentity(r) === repoIdentity)) { return s } const nextRepos = [...s.repos, repo] + const hostId = getRepoExecutionHostId(repo) return { repos: nextRepos, - ...projectCompatibilityFromRepos(nextRepos), + ...mergeProjectCompatibilityForHostRepoChange({ + previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, + nextRepos, + hostId + }), folderWorkspacePathStatuses: {} } }) @@ -1836,10 +1935,15 @@ export const createRepoSlice: StateCreator = (set, ) ).result const repo = repoWithFetchedOwner(result.repo, target) + const repoHostId = getRepoExecutionHostId(repo) const setup = setupWithFetchedOwner(result.setup, target) set((s) => { - const nextRepos = s.repos.some((entry) => entry.id === repo.id) - ? s.repos.map((entry) => (entry.id === repo.id ? repo : entry)) + const nextRepos = s.repos.some((entry) => + repoMatchesHostIdentity(entry, repo.id, repoHostId) + ) + ? s.repos.map((entry) => + repoMatchesHostIdentity(entry, repo.id, repoHostId) ? repo : entry + ) : [...s.repos, repo] const nextProjects = s.projects.some((entry) => entry.id === result.project.id) ? s.projects.map((entry) => (entry.id === result.project.id ? result.project : entry)) @@ -1924,10 +2028,13 @@ export const createRepoSlice: StateCreator = (set, ).result const setup = setupWithFetchedOwner(result.setup, target) const repo = result.repo ? repoWithFetchedOwner(result.repo, target) : undefined + const repoHostId = repo ? getRepoExecutionHostId(repo) : null set((s) => ({ repos: repo - ? s.repos.some((entry) => entry.id === repo.id) - ? s.repos.map((entry) => (entry.id === repo.id ? repo : entry)) + ? s.repos.some((entry) => repoMatchesHostIdentity(entry, repo.id, repoHostId!)) + ? s.repos.map((entry) => + repoMatchesHostIdentity(entry, repo.id, repoHostId!) ? repo : entry + ) : [...s.repos, repo] : s.repos, projects: s.projects.some((entry) => entry.id === result.project.id) @@ -1968,11 +2075,15 @@ export const createRepoSlice: StateCreator = (set, ) ).result const repo = result.repo ? repoWithFetchedOwner(result.repo, target) : undefined + const repoHostId = repo ? getRepoExecutionHostId(repo) : null set((s) => { const projectHostSetups = s.projectHostSetups.filter( (setup) => setup.id !== result.setup.id ) - const repos = repo ? s.repos.filter((entry) => entry.id !== repo.id) : s.repos + const repos = + repo && repoHostId + ? s.repos.filter((entry) => !repoMatchesHostIdentity(entry, repo.id, repoHostId)) + : s.repos const projects = repo && !projectHostSetups.some((setup) => setup.projectId === result.project.id) ? s.projects.filter((project) => project.id !== result.project.id) @@ -2106,19 +2217,26 @@ export const createRepoSlice: StateCreator = (set, removeProject: async (projectId) => { try { + const ownerRepo = findRepoForHost(get().repos, projectId, { settings: get().settings }) + if (!ownerRepo) { + return + } + const ownerHostId = getRepoExecutionHostId(ownerRepo) const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) await (target.kind === 'local' ? window.api.repos.remove({ repoId: projectId }) : callRuntimeRpc(target, 'repo.rm', { repo: projectId }, { timeoutMs: 15_000 })) get().clearOrcaHookTrustForRepo(projectId) - const repoPath = get().repos.find((repo) => repo.id === projectId)?.path + const repoPath = get().repos.find((repo) => + repoMatchesHostIdentity(repo, projectId, ownerHostId) + )?.path get().evictGitHubRepoCaches(projectId, repoPath) const { clearRepoSlugCacheEntry } = await import('../../lib/repo-slug-index') clearRepoSlugCacheEntry(projectId) // Kill PTYs for all worktrees belonging to this repo - const worktreeIds = getKnownRepoWorktreeIds(get(), projectId) + const worktreeIds = getKnownRepoWorktreeIds(get(), projectId, ownerHostId) const killedTabIds = new Set() const killedPtyIds = new Set() if (target.kind === 'environment') { @@ -2155,9 +2273,26 @@ export const createRepoSlice: StateCreator = (set, set((s) => { const nextWorktrees = { ...s.worktreesByRepo } - delete nextWorktrees[projectId] + const remainingWorktrees = (nextWorktrees[projectId] ?? []).filter( + (worktree) => !worktreeBelongsToHost(worktree, ownerHostId) + ) + if (remainingWorktrees.length > 0) { + nextWorktrees[projectId] = remainingWorktrees + } else { + delete nextWorktrees[projectId] + } const nextDetectedWorktrees = { ...s.detectedWorktreesByRepo } - delete nextDetectedWorktrees[projectId] + const detected = nextDetectedWorktrees[projectId] + if (detected) { + const remainingDetected = detected.worktrees.filter( + (worktree) => !worktreeBelongsToHost(worktree, ownerHostId) + ) + if (remainingDetected.length > 0) { + nextDetectedWorktrees[projectId] = { ...detected, worktrees: remainingDetected } + } else { + delete nextDetectedWorktrees[projectId] + } + } const nextTabs = { ...s.tabsByWorktree } const nextLayouts = { ...s.terminalLayoutsByTabId } const nextPtyIdsByTabId = { ...s.ptyIdsByTabId } @@ -2195,17 +2330,21 @@ export const createRepoSlice: StateCreator = (set, // forever after the repo is removed. let nextLastVisitedAtByWorktreeId = s.lastVisitedAtByWorktreeId for (const id of Object.keys(s.lastVisitedAtByWorktreeId)) { - if (getRepoIdFromWorktreeId(id) === projectId) { + if (worktreeIdSet.has(id)) { if (nextLastVisitedAtByWorktreeId === s.lastVisitedAtByWorktreeId) { nextLastVisitedAtByWorktreeId = { ...s.lastVisitedAtByWorktreeId } } delete nextLastVisitedAtByWorktreeId[id] } } - const nextRepos = s.repos.filter((r) => r.id !== projectId) + const nextRepos = s.repos.filter((r) => !repoMatchesHostIdentity(r, projectId, ownerHostId)) return { repos: nextRepos, - ...projectCompatibilityFromRepos(nextRepos), + ...mergeProjectCompatibilityForHostRepoChange({ + previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, + nextRepos, + hostId: ownerHostId + }), activeRepoId: s.activeRepoId === projectId ? null : s.activeRepoId, filterRepoIds: s.filterRepoIds.filter((id) => id !== projectId), worktreesByRepo: nextWorktrees, @@ -2282,10 +2421,25 @@ export const createRepoSlice: StateCreator = (set, updateRepo: async (projectId, updates) => { const updateRepoChains = getRepoUpdateChains(get) + const ownerRepo = findRepoForHost(get().repos, projectId, { settings: get().settings }) + if (!ownerRepo) { + return false + } + const ownerHasExplicitHost = Boolean( + ownerRepo.executionHostId?.trim() || ownerRepo.connectionId?.trim() + ) + const explicitOwnerHostId = getRepoExecutionHostId(ownerRepo) + const ownerTarget = ownerHasExplicitHost + ? getProjectSetupRuntimeTarget(explicitOwnerHostId) + : getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) + const ownerHostId = ownerHasExplicitHost + ? explicitOwnerHostId + : getRuntimeTargetHostId(ownerTarget) + const updateChainKey = getRepoHostIdentityForParts(projectId, ownerHostId) const applyRepoUpdate = async () => { try { const sanitizedUpdates = sanitizeRepoUpdate(updates) - const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) + const target = ownerTarget const updatedRepo = target.kind === 'local' ? await window.api.repos.update({ repoId: projectId, updates: sanitizedUpdates }) @@ -2299,7 +2453,10 @@ export const createRepoSlice: StateCreator = (set, ).repo set((s) => { const nextRepos = s.repos.map((r) => { - if (r.id !== projectId) { + const matchesOwner = ownerHasExplicitHost + ? repoMatchesHostIdentity(r, projectId, ownerHostId) + : repoMatchesHostIdentity(r, projectId, ownerHostId) || r === ownerRepo + if (!matchesOwner) { return r } if (updatedRepo) { @@ -2320,7 +2477,11 @@ export const createRepoSlice: StateCreator = (set, }) return { repos: nextRepos, - ...projectCompatibilityFromRepos(nextRepos), + ...mergeProjectCompatibilityForHostRepoChange({ + previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, + nextRepos, + hostId: ownerHostId + }), folderWorkspacePathStatuses: {} } }) @@ -2330,16 +2491,16 @@ export const createRepoSlice: StateCreator = (set, return false } } - const previous = updateRepoChains.get(projectId) + const previous = updateRepoChains.get(updateChainKey) // Why: repo settings are persisted as full nested values. Preserve call // order per repo so a slower IPC/RPC response cannot overwrite newer state. const next = previous ? previous.catch(() => undefined).then(applyRepoUpdate) : applyRepoUpdate() - updateRepoChains.set(projectId, next) + updateRepoChains.set(updateChainKey, next) const cleanup = () => { - if (updateRepoChains.get(projectId) === next) { - updateRepoChains.delete(projectId) + if (updateRepoChains.get(updateChainKey) === next) { + updateRepoChains.delete(updateChainKey) } } void next.then(cleanup, cleanup) @@ -2352,10 +2513,22 @@ export const createRepoSlice: StateCreator = (set, // Optimistically apply the new order so the sidebar updates instantly; // resync only if main rejects (stale permutation due to a racing add/remove). const previous = get().repos - const byId = new Map(previous.map((r) => [r.id, r])) + const remainingById = new Map() + for (const repo of previous) { + const existing = remainingById.get(repo.id) + if (existing) { + existing.repos.push(repo) + } else { + remainingById.set(repo.id, { repos: [repo], nextIndex: 0 }) + } + } const next: Repo[] = [] for (const id of orderedIds) { - const repo = byId.get(id) + const remaining = remainingById.get(id) + const repo = remaining?.repos[remaining.nextIndex] + if (remaining) { + remaining.nextIndex += 1 + } if (repo) { next.push(repo) } @@ -2366,7 +2539,6 @@ export const createRepoSlice: StateCreator = (set, } set({ repos: next, - ...projectCompatibilityFromRepos(next), folderWorkspacePathStatuses: {} }) try { @@ -2392,11 +2564,11 @@ export const createRepoSlice: StateCreator = (set, }) ) if (results.some((result) => result.status === 'rejected')) { - await get().fetchRepos() + await get().fetchReposForAllHosts() } } catch (err) { console.error('Failed to reorder repos:', err) - await get().fetchRepos() + await get().fetchReposForAllHosts() } } }) diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index c76cc38406a..00e8c7903b2 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -4285,6 +4285,89 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { expect(store.getState().hasHydratedWorktreePurge).toBe(true) }) + it('preserves sibling host worktrees during hydrated refresh when repo ids are duplicated', async () => { + const store = createTestStore() + const localRepo = { + id: 'same-repo', + path: '/repos/local', + displayName: 'same local', + badgeColor: '#000', + addedAt: 0, + executionHostId: 'local' + } + const runtimeRepo = { + id: 'same-repo', + path: '/repos/remote', + displayName: 'same remote', + badgeColor: '#111', + addedAt: 1, + executionHostId: 'runtime:env-1' + } + const localWorktree = makeWorktree({ + id: 'same-repo::/local/wt', + repoId: 'same-repo', + path: '/local/wt' + }) + const staleRemoteWorktree = makeWorktree({ + id: 'same-repo::/remote/stale', + repoId: 'same-repo', + path: '/remote/stale', + hostId: 'runtime:env-1' + }) + const refreshedRemoteWorktree = makeWorktree({ + id: 'same-repo::/remote/fresh', + repoId: 'same-repo', + path: '/remote/fresh', + hostId: 'local' + }) + + mockApi.worktrees.listDetected.mockResolvedValueOnce( + makeDetectedResult('same-repo', [localWorktree]) + ) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-duplicate-worktrees', + ok: true, + result: makeDetectedResult('same-repo', [refreshedRemoteWorktree]), + _meta: { runtimeId: 'runtime-remote' } + }) + + store.setState({ + hasHydratedWorktreePurge: true, + repos: [localRepo, runtimeRepo], + worktreesByRepo: { + 'same-repo': [localWorktree, staleRemoteWorktree] + }, + detectedWorktreesByRepo: { + 'same-repo': makeDetectedResult('same-repo', [localWorktree, staleRemoteWorktree]) + } + } as unknown as Partial) + + await store.getState().fetchAllWorktrees() + + const refreshed = store.getState().worktreesByRepo['same-repo'] ?? [] + expect(refreshed).toHaveLength(2) + expect(refreshed).toEqual( + expect.arrayContaining([ + localWorktree, + { ...refreshedRemoteWorktree, hostId: 'runtime:env-1' } + ]) + ) + expect(refreshed.map((worktree) => worktree.id)).not.toContain(staleRemoteWorktree.id) + expect(store.getState().detectedWorktreesByRepo['same-repo']?.worktrees).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: localWorktree.id }), + expect.objectContaining({ id: refreshedRemoteWorktree.id, hostId: 'runtime:env-1' }) + ]) + ) + expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith({ repoId: 'same-repo' }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'worktree.detectedList', + params: { repo: 'same-repo' }, + timeoutMs: 15_000 + }) + }) + it('bounds concurrent repo scans during hydration-time refresh', async () => { const store = createTestStore() const repos = Array.from({ length: 7 }, (_, index) => ({ diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index b44bece7790..2c9b3ef5e67 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -24,6 +24,7 @@ import { getRepoIdFromWorktreeId, type WorktreeSlice } from './worktree-helpers' +import { findRepoForHost } from './repo-host-identity' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { @@ -72,11 +73,11 @@ const detachedHeadAutoDerivedDisplayNames = new Map() const folderWorkspaceWorktreeCache = new WeakMap() const hostedReviewPushTargetLookupsInFlight = new Set() -async function mapReposForWorktreeRefresh( - repos: readonly { id: string }[], - mapper: (repo: { id: string }) => Promise -): Promise { - const results = Array(repos.length) +async function mapReposForWorktreeRefresh( + repos: readonly TRepo[], + mapper: (repo: TRepo) => Promise +): Promise { + const results = Array(repos.length) let nextIndex = 0 const workerCount = Math.min(WORKTREE_REFRESH_CONCURRENCY, repos.length) @@ -326,8 +327,12 @@ function withRepoHostId( return hostId === LOCAL_EXECUTION_HOST_ID ? worktree : { ...worktree, hostId } } -function repoHostId(state: Pick, repoId: string): ExecutionHostId { - const repo = state.repos.find((entry) => entry.id === repoId) +function repoHostId( + state: Pick, + repoId: string, + hostId?: ExecutionHostId | null +): ExecutionHostId { + const repo = findRepoForHost(state.repos, repoId, { hostId, settings: state.settings }) return repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID } @@ -345,19 +350,133 @@ function getHydratedSessionWorktreeIdsForRepo(state: AppState, repoId: string): return Object.keys(state.tabsByWorktree).filter((id) => getRepoIdFromWorktreeId(id) === repoId) } -function getKnownWorktreeIdsForPurge(state: AppState, repoId: string): string[] { +type WorktreeHostMatchOptions = { + unhostedWorktreesMatchHost?: boolean +} + +type RepoHostSummary = { + count: number + onlyHostId?: ExecutionHostId +} + +const repoHostSummariesByRepos = new WeakMap>() + +function getRepoHostSummaries(repos: AppState['repos']): Map { + const cached = repoHostSummariesByRepos.get(repos) + if (cached) { + return cached + } + + const summaries = new Map() + for (const repo of repos) { + const current = summaries.get(repo.id) + if (current) { + summaries.set(repo.id, { count: current.count + 1 }) + } else { + summaries.set(repo.id, { count: 1, onlyHostId: getRepoExecutionHostId(repo) }) + } + } + repoHostSummariesByRepos.set(repos, summaries) + return summaries +} + +function unhostedWorktreesMatchRefreshHost( + state: Pick, + repoId: string, + hostId: ExecutionHostId +): boolean { + if (hostId === LOCAL_EXECUTION_HOST_ID) { + return true + } + + const summary = getRepoHostSummaries(state.repos).get(repoId) + return summary?.count === 1 && summary.onlyHostId === hostId +} + +function worktreeHostMatchOptions( + state: Pick, + repoId: string, + hostId: ExecutionHostId +): WorktreeHostMatchOptions { + return { + // Why: pre-host persisted runtime/SSH worktrees were stored without hostId. + // Treat them as the sole repo owner's rows, but keep ambiguous duplicates local. + unhostedWorktreesMatchHost: unhostedWorktreesMatchRefreshHost(state, repoId, hostId) + } +} + +function worktreeMatchesHost( + worktree: { hostId?: ExecutionHostId }, + hostId: ExecutionHostId, + options: WorktreeHostMatchOptions = {} +): boolean { + if (worktree.hostId) { + return worktree.hostId === hostId + } + return options.unhostedWorktreesMatchHost ?? hostId === LOCAL_EXECUTION_HOST_ID +} + +function mergeWorktreesForHost( + current: readonly T[] | undefined, + refreshed: readonly T[], + hostId: ExecutionHostId, + options?: WorktreeHostMatchOptions +): T[] { + // Why: host-scoped refreshes should replace that host in place so alternating + // local/runtime refreshes do not churn sibling row order or sortEpoch. + const existing = current ?? [] + const next: T[] = [] + let inserted = false + + for (const worktree of existing) { + if (worktreeMatchesHost(worktree, hostId, options)) { + if (!inserted) { + next.push(...refreshed) + inserted = true + } + continue + } + next.push(worktree) + } + + return inserted ? next : [...next, ...refreshed] +} + +function mergeDetectedWorktreesForHost( + current: DetectedWorktreeListResult | undefined, + refreshed: DetectedWorktreeListResult, + hostId: ExecutionHostId, + options?: WorktreeHostMatchOptions +): DetectedWorktreeListResult { + const refreshedForHost = refreshed.worktrees.map((worktree) => withRepoHostId(worktree, hostId)) + return { + ...refreshed, + worktrees: mergeWorktreesForHost(current?.worktrees, refreshedForHost, hostId, options) + } +} + +function getKnownWorktreeIdsForPurge( + state: AppState, + repoId: string, + hostId: ExecutionHostId +): string[] { const detected = state.detectedWorktreesByRepo[repoId] const knownIds = new Set() + const matchOptions = worktreeHostMatchOptions(state, repoId, hostId) if (detected?.authoritative === true) { for (const worktree of detected.worktrees) { - knownIds.add(worktree.id) + if (worktreeMatchesHost(worktree, hostId, matchOptions)) { + knownIds.add(worktree.id) + } } } else { for (const worktree of state.worktreesByRepo[repoId] ?? []) { - knownIds.add(worktree.id) + if (worktreeMatchesHost(worktree, hostId, matchOptions)) { + knownIds.add(worktree.id) + } } } - if (!state.hasHydratedWorktreePurge) { + if (!state.hasHydratedWorktreePurge && matchOptions.unhostedWorktreesMatchHost === true) { // Why (#1158): hydration can preserve tab keys before worktree metadata exists; // the first authoritative scan still needs to reap deleted session-only keys. for (const id of getHydratedSessionWorktreeIdsForRepo(state, repoId)) { @@ -370,13 +489,14 @@ function getKnownWorktreeIdsForPurge(state: AppState, repoId: string): string[] function getRemovedWorktreeIdsAfterAuthoritativeScan( state: AppState, repoId: string, - detected: DetectedWorktreeListResult + detected: DetectedWorktreeListResult, + hostId: ExecutionHostId ): string[] { if (!detected.authoritative) { return [] } const detectedIds = new Set(detected.worktrees.map((worktree) => worktree.id)) - return getKnownWorktreeIdsForPurge(state, repoId).filter((id) => !detectedIds.has(id)) + return getKnownWorktreeIdsForPurge(state, repoId, hostId).filter((id) => !detectedIds.has(id)) } function toLegacyDetectedWorktreeResult( @@ -595,30 +715,41 @@ function replaceWorktreeInRepoLists( } } -function settingsForRepoOwner(state: Pick, repoId: string) { - const repo = state.repos.find((entry) => entry.id === repoId) +function settingsForRepoOwner( + state: Pick, + repoId: string, + hostId?: ExecutionHostId | null +) { + const repo = findRepoForHost(state.repos, repoId, { hostId, settings: state.settings }) if (!repo) { return state.settings } + return settingsForKnownRepoOwner(state.settings, repo) +} + +function settingsForKnownRepoOwner( + settings: AppState['settings'], + repo: { connectionId?: string | null; executionHostId?: ExecutionHostId | null } +) { if (!repo.executionHostId && !repo.connectionId) { - return state.settings + return settings } const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) if (parsed?.kind === 'runtime') { - return state.settings - ? { ...state.settings, activeRuntimeEnvironmentId: parsed.environmentId } + return settings + ? { ...settings, activeRuntimeEnvironmentId: parsed.environmentId } : ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings']) } - if (parsed?.kind === 'local' && state.settings?.activeRuntimeEnvironmentId) { - return { ...state.settings, activeRuntimeEnvironmentId: null } + if (parsed?.kind === 'local' && settings?.activeRuntimeEnvironmentId) { + return { ...settings, activeRuntimeEnvironmentId: null } } if (parsed?.kind !== 'ssh') { - return state.settings + return settings } // Why: SSH repos are owned by the desktop client/SSH provider, not the // currently focused runtime server. - return state.settings - ? { ...state.settings, activeRuntimeEnvironmentId: null } + return settings + ? { ...settings, activeRuntimeEnvironmentId: null } : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) } @@ -641,14 +772,21 @@ function settingsForExecutionHostOwner( } function settingsForWorktreeOwner( - state: Pick, + state: Pick, worktreeId: string ) { const worktree = findWorktreeById(state.worktreesByRepo, worktreeId) if (worktree?.hostId) { return settingsForExecutionHostOwner(state.settings, worktree.hostId) } - return settingsForRepoOwner(state, getRepoIdFromWorktreeId(worktreeId)) + const repoId = getRepoIdFromWorktreeId(worktreeId) + const detected = state.detectedWorktreesByRepo[repoId]?.worktrees.find( + (entry) => entry.id === worktreeId + ) + if (detected?.hostId) { + return settingsForExecutionHostOwner(state.settings, detected.hostId) + } + return settingsForRepoOwner(state, repoId) } async function listDetectedWorktreesForRepo( @@ -860,7 +998,7 @@ async function refreshRemoteWorktreeLineageBestEffort( } function getWorktreeHostId( - state: Pick, + state: Pick, worktreeId: string ): ExecutionHostId | null { const worktree = findWorktreeById(state.worktreesByRepo, worktreeId) @@ -868,12 +1006,21 @@ function getWorktreeHostId( return worktree.hostId } const repoId = getRepoIdFromWorktreeId(worktreeId) - const repo = state.repos.find((entry) => entry.id === repoId) + const detected = state.detectedWorktreesByRepo[repoId]?.worktrees.find( + (entry) => entry.id === worktreeId + ) + if (detected?.hostId) { + return detected.hostId + } + const repo = findRepoForHost(state.repos, repoId, { settings: state.settings }) return repo ? getRepoExecutionHostId(repo) : null } function mergeLineageForHost( - state: Pick, + state: Pick< + AppState, + 'repos' | 'settings' | 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'worktreeLineageById' + >, hostId: ExecutionHostId, lineage: Record ): Record { @@ -887,7 +1034,14 @@ function mergeLineageForHost( } function mergeWorkspaceLineageForHost( - state: Pick, + state: Pick< + AppState, + | 'repos' + | 'settings' + | 'worktreesByRepo' + | 'detectedWorktreesByRepo' + | 'workspaceLineageByChildKey' + >, hostId: ExecutionHostId, lineage: Record ): Record { @@ -1552,24 +1706,58 @@ export const createWorktreeSlice: StateCreator fetchWorktrees: async (repoId, options) => { try { - const settings = settingsForRepoOwner(get(), repoId) + const ownerState = get() + const hostId = repoHostId(ownerState, repoId) + const settings = settingsForRepoOwner(ownerState, repoId, hostId) const detected = await listDetectedWorktreesForRepo(settings, repoId) if (options?.requireAuthoritative && !detected.authoritative) { return false } - const worktrees = toVisibleWorktrees(detected, repoHostId(get(), repoId)) + const worktrees = toVisibleWorktrees(detected, hostId) const current = get().worktreesByRepo[repoId] - if (areWorktreesEqual(current, worktrees)) { + const currentMatchOptions = worktreeHostMatchOptions(get(), repoId, hostId) + const currentForHost = (current ?? []).filter((worktree) => + worktreeMatchesHost(worktree, hostId, currentMatchOptions) + ) + if (areWorktreesEqual(currentForHost, worktrees)) { set((s) => { - const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan(s, repoId, detected) + const matchOptions = worktreeHostMatchOptions(s, repoId, hostId) + const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan( + s, + repoId, + detected, + hostId + ) + const mergedDetected = mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[repoId], + detected, + hostId, + matchOptions + ) + const mergedWorktrees = mergeWorktreesForHost( + s.worktreesByRepo[repoId], + worktrees, + hostId, + matchOptions + ) + const worktreesChanged = !areWorktreesEqual(s.worktreesByRepo[repoId], mergedWorktrees) if ( - areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[repoId], detected) && + !worktreesChanged && + areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[repoId], mergedDetected) && removedIds.length === 0 ) { return s } return { - detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [repoId]: detected }, + worktreesByRepo: { + ...s.worktreesByRepo, + [repoId]: mergedWorktrees + }, + detectedWorktreesByRepo: { + ...s.detectedWorktreesByRepo, + [repoId]: mergedDetected + }, + ...(worktreesChanged ? { sortEpoch: s.sortEpoch + 1 } : {}), ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) } }) @@ -1584,9 +1772,17 @@ export const createWorktreeSlice: StateCreator // badge then shows raw worktree IDs instead of display names, and click- // to-navigate silently fails because findWorktreeById returns undefined. // Keep the stale-but-correct data until the next successful refresh. - if (!detected.authoritative && worktrees.length === 0 && current && current.length > 0) { + if (!detected.authoritative && worktrees.length === 0 && currentForHost.length > 0) { set((s) => ({ - detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [repoId]: detected } + detectedWorktreesByRepo: { + ...s.detectedWorktreesByRepo, + [repoId]: mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[repoId], + detected, + hostId, + worktreeHostMatchOptions(s, repoId, hostId) + ) + } })) return false } @@ -1595,14 +1791,27 @@ export const createWorktreeSlice: StateCreator // Why: hidden worktrees are not in worktreesByRepo. Purge decisions // must diff against the previous authoritative detected list so hiding // does not delete state, and deleting a hidden worktree still does. - const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan(s, repoId, detected) + const matchOptions = worktreeHostMatchOptions(s, repoId, hostId) + const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan(s, repoId, detected, hostId) + const mergedWorktrees = mergeWorktreesForHost( + s.worktreesByRepo[repoId], + worktrees, + hostId, + matchOptions + ) + const mergedDetected = mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[repoId], + detected, + hostId, + matchOptions + ) return { // Why: active worktrees can change branches entirely from a terminal. // We refresh that live git identity into renderer state, but only bump // sortEpoch when git actually reports a different worktree payload. - worktreesByRepo: { ...s.worktreesByRepo, [repoId]: worktrees }, - detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [repoId]: detected }, + worktreesByRepo: { ...s.worktreesByRepo, [repoId]: mergedWorktrees }, + detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [repoId]: mergedDetected }, sortEpoch: s.sortEpoch + 1, ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) } @@ -1622,7 +1831,41 @@ export const createWorktreeSlice: StateCreator // calls just need to refresh each repo's cached list. No need to // double-probe the IPC for the per-repo success signal. if (get().hasHydratedWorktreePurge) { - await mapReposForWorktreeRefresh(repos, (r) => get().fetchWorktrees(r.id)) + await mapReposForWorktreeRefresh(repos, async (r) => { + const hostId = getRepoExecutionHostId(r) + const settings = settingsForKnownRepoOwner(get().settings, r) + const detected = await listDetectedWorktreesForRepo(settings, r.id) + const worktrees = toVisibleWorktrees(detected, hostId) + set((s) => { + const matchOptions = worktreeHostMatchOptions(s, r.id, hostId) + const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan(s, r.id, detected, hostId) + const mergedWorktrees = mergeWorktreesForHost( + s.worktreesByRepo[r.id], + worktrees, + hostId, + matchOptions + ) + const mergedDetected = mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[r.id], + detected, + hostId, + matchOptions + ) + if ( + areWorktreesEqual(s.worktreesByRepo[r.id], mergedWorktrees) && + areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[r.id], mergedDetected) && + removedIds.length === 0 + ) { + return s + } + return { + worktreesByRepo: { ...s.worktreesByRepo, [r.id]: mergedWorktrees }, + detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [r.id]: mergedDetected }, + sortEpoch: s.sortEpoch + 1, + ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) + } + }) + }) return } @@ -1646,24 +1889,51 @@ export const createWorktreeSlice: StateCreator | { repoId: string; ok: false } > => { try { + const hostId = getRepoExecutionHostId(r) const detected = await listDetectedWorktreesForRepo( - settingsForRepoOwner(get(), r.id), + settingsForKnownRepoOwner(get().settings, r), r.id ) - const list = toVisibleWorktrees(detected, repoHostId(get(), r.id)) + const list = toVisibleWorktrees(detected, hostId) const current = get().worktreesByRepo[r.id] + const currentMatchOptions = worktreeHostMatchOptions(get(), r.id, hostId) + const currentForHost = (current ?? []).filter((worktree) => + worktreeMatchesHost(worktree, hostId, currentMatchOptions) + ) if ( - !areWorktreesEqual(current, list) && - !(list.length === 0 && current && current.length > 0 && !detected.authoritative) + !areWorktreesEqual(currentForHost, list) && + !(list.length === 0 && currentForHost.length > 0 && !detected.authoritative) ) { - set((s) => ({ - worktreesByRepo: { ...s.worktreesByRepo, [r.id]: list }, - detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [r.id]: detected }, - sortEpoch: s.sortEpoch + 1 - })) + set((s) => { + const matchOptions = worktreeHostMatchOptions(s, r.id, hostId) + return { + worktreesByRepo: { + ...s.worktreesByRepo, + [r.id]: mergeWorktreesForHost(s.worktreesByRepo[r.id], list, hostId, matchOptions) + }, + detectedWorktreesByRepo: { + ...s.detectedWorktreesByRepo, + [r.id]: mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[r.id], + detected, + hostId, + matchOptions + ) + }, + sortEpoch: s.sortEpoch + 1 + } + }) } else { set((s) => ({ - detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [r.id]: detected } + detectedWorktreesByRepo: { + ...s.detectedWorktreesByRepo, + [r.id]: mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[r.id], + detected, + hostId, + worktreeHostMatchOptions(s, r.id, hostId) + ) + } })) } return { repoId: r.id, ok: detected.authoritative, detected } From d14498a11188e29ae17febaea0053e26711c76d6 Mon Sep 17 00:00:00 2001 From: AJ <60591772+devajmeireles@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:49:38 -0300 Subject: [PATCH 08/18] feat(worktree): add a "create multiple" toggle to the worktree composer (#5813) Co-authored-by: Orca Co-authored-by: brennanb2025 --- .../components/NewWorkspaceComposerCard.tsx | 47 +++++++++++++- src/renderer/src/hooks/useComposerState.ts | 63 ++++++++++++++++++- src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- .../src/lib/pending-worktree-creation.ts | 3 + .../src/lib/worktree-creation-flow.ts | 2 +- 8 files changed, 116 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 0079a9aa868..6c1f8c1d9bf 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -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({ />
-
+ {/* 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. */} +
+ ) : null} ') }) - it('shows the same monorepo import controls after a repo already exists', () => { + it('shows the same grouping import controls after a repo already exists', () => { const html = renderNestedStep(1) - expect(html).toContain('Is this a monorepo?') - expect(html).toContain('aria-label="Monorepo name"') - expect(html).toContain('Yes, import as monorepo') + expect(html).toContain('Group these repositories?') + expect(html).toContain('aria-label="Group name"') + expect(html).toContain('Yes, import as group') expect(html).toContain('No, import separately') expect(html).not.toContain('>Import') }) diff --git a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx index 13be2021c7d..ba9022ca742 100644 --- a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx @@ -72,19 +72,19 @@ describe('AddRepoNestedImportStep', () => { container = null }) - it('asks whether the selected folder is a monorepo', () => { + it('asks whether the selected folder should be grouped', () => { const html = renderStepMarkup() expect(html).toContain('Import repositories from folder') expect(html).toContain('Found 3 repositories in') expect(html).toContain('/workspace/platform') - expect(html).toContain('aria-label="Monorepo name"') + expect(html).toContain('aria-label="Group name"') expect(html).not.toContain('What is a') - expect(html).toContain('Is this a monorepo?') + expect(html).toContain('Group these repositories?') expect(html).toContain('Choose this if these projects belong together') expect(html).toContain('Orca will group them and let you work from the parent folder') expect(html).toContain('No, import separately') - expect(html).toContain('Yes, import as monorepo') + expect(html).toContain('Yes, import as group') expect(html).toContain('payments/api') expect(html).toContain('billing/api') expect(html).not.toContain('disabled=""') @@ -95,14 +95,14 @@ describe('AddRepoNestedImportStep', () => { it('disables both import actions while scanning', () => { const html = renderStepMarkup({ scanInProgress: true }) - expect(html).toContain('Is this a monorepo?') + expect(html).toContain('Group these repositories?') expect(html).toContain('No, import separately') - expect(html).toContain('Yes, import as monorepo') + expect(html).toContain('Yes, import as group') expect(html).toMatch(/]*disabled=""[^>]*>No, import separately<\/button>/) - expect(html).toMatch(/]*disabled=""[^>]*>Yes, import as monorepo<\/button>/) + expect(html).toMatch(/]*disabled=""[^>]*>Yes, import as group<\/button>/) }) - it('maps the monorepo choice to grouped import and the non-monorepo choice to separate import', () => { + it('maps the group choice to grouped import and the separate choice to separate import', () => { const onImport = vi.fn() const host = document.createElement('div') container = host @@ -130,7 +130,7 @@ describe('AddRepoNestedImportStep', () => { }) act(() => { - findButton(host, 'Yes, import as monorepo').click() + findButton(host, 'Yes, import as group').click() findButton(host, 'No, import separately').click() }) @@ -174,13 +174,11 @@ describe('AddRepoNestedImportStep', () => { }) act(() => { - findButton(host, 'Yes, import as monorepo').click() + findButton(host, 'Yes, import as group').click() }) expect(onImport).toHaveBeenCalledWith('group') - expect( - findButton(host, 'Yes, import as monorepo').querySelector('.animate-spin') - ).not.toBeNull() + expect(findButton(host, 'Yes, import as group').querySelector('.animate-spin')).not.toBeNull() expect(findButton(host, 'No, import separately').querySelector('.animate-spin')).toBeNull() }) }) diff --git a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx index 4de4a9b469f..ac7db3b2958 100644 --- a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx @@ -105,13 +105,13 @@ export function AddRepoNestedImportStep({

{translate( 'auto.components.sidebar.AddRepoNestedImportStep.fb33359f69', - 'Is this a monorepo?' + 'Group these repositories?' )}

{translate( 'auto.components.sidebar.AddRepoNestedImportStep.d75170194e', - "Import them as a group if they're a monorepo or otherwise belong together. Orca will group them and let you work from the parent folder." + 'Choose this if these projects belong together — a monorepo, or just a set of related repos. Orca will group them and let you work from the parent folder.' )}

@@ -156,7 +156,7 @@ export function AddRepoNestedImportStep({ {showGroupSpinner ? : null} {translate( 'auto.components.sidebar.AddRepoNestedImportStep.a0bc4d1f8e', - 'Import as group' + 'Yes, import as group' )}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e6ed96882bf..c05c5058070 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3384,22 +3384,17 @@ "496f68cf8c": "Scanning repositories. Click to stop.", "a32bef9516": "Stop scanning", "2f8298f3c3": "Stop scan", - "c157f31a95": "Import as group", - "40199ef7b3": "Group name", - "b20bb7c24f": "Keeps these repos together in one group. Best for related repos like microservices.", - "787412361a": "What is a group name?", "5f857ba8e6": "in", "4df0d08cc5": "Found", "8db50afe1a": "Import repositories from folder", "5b2e6fe3c8": "Import separately", "cf9d382ca1": "Import", "220dd32d83": "Scanning...", - "fb33359f69": "Is this a monorepo?", - "d75170194e": "Choose this if these projects belong together. Orca will group them and let you work from the parent folder.", - "39d51212cc": "Monorepo name", - "e907ec8935": "What is a monorepo name?", + "fb33359f69": "Group these repositories?", + "d75170194e": "Choose this if these projects belong together — a monorepo, or just a set of related repos. Orca will group them and let you work from the parent folder.", + "39d51212cc": "Group name", "aa0247680d": "No, import separately", - "a0bc4d1f8e": "Yes, import as monorepo", + "a0bc4d1f8e": "Yes, import as group", "8401a7a0d0": "1 repository", "d4f1df62ef": "{{value0}} repositories", "b4263a2ac4": "Found {{value0}} in {{value1}}.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 4ec8e276dd3..1aad6ed2381 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3378,26 +3378,21 @@ "496f68cf8c": "Escaneo de repositorios. Haga clic para detener.", "a32bef9516": "Dejar de escanear", "2f8298f3c3": "Detener escaneo", - "c157f31a95": "Importar como grupo", - "40199ef7b3": "Nombre del grupo", - "787412361a": "¿Qué es el nombre de un grupo?", "5f857ba8e6": "en", "4df0d08cc5": "Encontró", "8db50afe1a": "Importar repositorios desde la carpeta", "5b2e6fe3c8": "Importar por separado", "cf9d382ca1": "Importar", "220dd32d83": "Exploración...", - "fb33359f69": "¿Es esto un monorepo?", - "d75170194e": "Impórtalos como grupo si son un monorepo o si pertenecen juntos. Orca los agrupará y te permitirá trabajar desde la carpeta principal.", + "fb33359f69": "¿Agrupar estos repositorios?", + "d75170194e": "Elige esto si estos proyectos van juntos: un monorepo o simplemente un conjunto de repositorios relacionados. Orca los agrupará y te permitirá trabajar desde la carpeta principal.", "39d51212cc": "Nombre del grupo", "aa0247680d": "No, importar por separado", - "a0bc4d1f8e": "Importar como grupo", + "a0bc4d1f8e": "Sí, importar como grupo", "8401a7a0d0": "1 repositorio", "d4f1df62ef": "{{value0}} repositorios", "b4263a2ac4": "Se encontraron {{value0}} en {{value1}}.", - "24eda6c8b2": "Explorando... {{value0}}", - "b20bb7c24f": "Keeps these repos together in one group. Best for related repos like microservices.", - "e907ec8935": "What is a monorepo name?" + "24eda6c8b2": "Explorando... {{value0}}" }, "AddRepoRemoteStep": { "5b205b5281": "Detener escaneo", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 8fa3a8a09b4..3e2ac5ce512 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3359,26 +3359,21 @@ "496f68cf8c": "repos をスキャンしています。クリックして停止します。", "a32bef9516": "スキャンを停止する", "2f8298f3c3": "スキャンの停止", - "c157f31a95": "グループとしてインポート", - "40199ef7b3": "グループ名", - "787412361a": "グループ名とは何ですか?", "5f857ba8e6": "で", "4df0d08cc5": "見つかった", "8db50afe1a": "フォルダーから repos をインポートする", "5b2e6fe3c8": "個別にインポート", "cf9d382ca1": "インポート", "220dd32d83": "スキャン中...", - "fb33359f69": "これはモノレポですか?", - "d75170194e": "モノレポ、または一緒に扱うべきプロジェクトの場合は、グループとしてインポートします。Orca がグループ化し、親フォルダーから作業できるようにします。", + "fb33359f69": "これらのリポジトリをグループ化しますか?", + "d75170194e": "これらのプロジェクトをまとめたい場合(モノレポでも、関連するリポジトリの集まりでも)に選択します。Orca がグループ化し、親フォルダーから作業できるようにします。", "39d51212cc": "グループ名", "aa0247680d": "いいえ、個別にインポート", - "a0bc4d1f8e": "グループとしてインポート", + "a0bc4d1f8e": "はい、グループとしてインポート", "8401a7a0d0": "1 個の repos", "d4f1df62ef": "{{value0}} 個の repos", "b4263a2ac4": "{{value1}} で {{value0}} が見つかりました。", - "24eda6c8b2": "スキャン中... {{value0}}", - "b20bb7c24f": "Keeps these repos together in one group. Best for related repos like microservices.", - "e907ec8935": "What is a monorepo name?" + "24eda6c8b2": "スキャン中... {{value0}}" }, "AddRepoRemoteStep": { "5b205b5281": "スキャンの停止", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 0a0594501b5..d452e81de2c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3359,26 +3359,21 @@ "496f68cf8c": "repos를 스캔하는 중입니다. 중지하려면 클릭하세요.", "a32bef9516": "스캔 중지", "2f8298f3c3": "스캔 중지", - "c157f31a95": "그룹으로 가져오기", - "40199ef7b3": "그룹 이름", - "787412361a": "그룹 이름은 무엇입니까?", "5f857ba8e6": "~에", "4df0d08cc5": "발견됨", "8db50afe1a": "폴더에서 repos 가져오기", "5b2e6fe3c8": "별도로 가져오기", "cf9d382ca1": "가져오기", "220dd32d83": "스캔 중...", - "fb33359f69": "이 폴더가 모노 repo 인가요?", - "d75170194e": "모노레포이거나 함께 속한 프로젝트라면 그룹으로 가져오세요. Orca가 그룹으로 묶고 상위 폴더에서 작업할 수 있게 합니다.", + "fb33359f69": "이 저장소들을 그룹으로 묶을까요?", + "d75170194e": "이 프로젝트들이 함께 속한 경우(모노레포이거나 단순히 관련된 저장소 모음) 이 옵션을 선택하세요. Orca가 그룹으로 묶고 상위 폴더에서 작업할 수 있게 합니다.", "39d51212cc": "그룹 이름", "aa0247680d": "아니요, 별도로 가져오기", - "a0bc4d1f8e": "그룹으로 가져오기", + "a0bc4d1f8e": "예, 그룹으로 가져오기", "8401a7a0d0": "repos 1개", "d4f1df62ef": "repos {{value0}}개", "b4263a2ac4": "{{value1}}에서 {{value0}}을(를) 찾았습니다.", - "24eda6c8b2": "스캔 중... {{value0}}", - "b20bb7c24f": "이 리포지토리들을 하나의 그룹으로 묶어 둡니다. 마이크로서비스처럼 서로 관련된 리포지토리에 적합합니다.", - "e907ec8935": "모노 repo 이름이란?" + "24eda6c8b2": "스캔 중... {{value0}}" }, "AddRepoRemoteStep": { "5b205b5281": "스캔 중지", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b67d1db3bf5..0c7753af883 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3358,26 +3358,21 @@ "496f68cf8c": "扫描存储库。单击停止。", "a32bef9516": "停止扫描", "2f8298f3c3": "停止扫描", - "c157f31a95": "作为组导入", - "40199ef7b3": "团体名称", - "787412361a": "什么是群组名称?", "5f857ba8e6": "在", "4df0d08cc5": "成立", "8db50afe1a": "从文件夹导入存储库", "5b2e6fe3c8": "单独导入", "cf9d382ca1": "导入", "220dd32d83": "扫描...", - "fb33359f69": "这是 Monorepo 吗?", - "d75170194e": "如果它们是 Monorepo,或本来就应该放在一起,请将它们作为组导入。Orca 会将它们分组,并让你从父文件夹开始工作。", + "fb33359f69": "将这些仓库分组吗?", + "d75170194e": "如果这些项目应放在一起(可以是 Monorepo,也可以只是一组相关仓库),请选择此项。Orca 会将它们分组,并让你从父文件夹开始工作。", "39d51212cc": "组名称", "aa0247680d": "否,单独导入", - "a0bc4d1f8e": "作为组导入", + "a0bc4d1f8e": "是,作为组导入", "8401a7a0d0": "1 个仓库", "d4f1df62ef": "{{value0}} 个仓库", "b4263a2ac4": "在 {{value1}} 中找到 {{value0}}。", - "24eda6c8b2": "正在扫描... {{value0}}", - "b20bb7c24f": "Keeps these repos together in one group. Best for related repos like microservices.", - "e907ec8935": "What is a monorepo name?" + "24eda6c8b2": "正在扫描... {{value0}}" }, "AddRepoRemoteStep": { "5b205b5281": "停止扫描", diff --git a/tests/e2e/folder-setup-shallow-priority.spec.ts b/tests/e2e/folder-setup-shallow-priority.spec.ts index 96fe04580a1..b970a29ba8f 100644 --- a/tests/e2e/folder-setup-shallow-priority.spec.ts +++ b/tests/e2e/folder-setup-shallow-priority.spec.ts @@ -8,7 +8,7 @@ import { test, expect } from './helpers/orca-app' import { waitForSessionReady } from './helpers/store' const tempRoots: string[] = [] -const IMPORT_AS_MONOREPO_BUTTON_NAME = 'Yes, import as monorepo' +const IMPORT_AS_GROUP_BUTTON_NAME = 'Yes, import as group' function initializeGitRepo(repoPath: string): void { mkdirSync(repoPath, { recursive: true }) @@ -133,11 +133,11 @@ async function installCancellableNestedScanMock( }, scan) } -function getImportAsMonorepoButton(importDialog: Locator): Locator { +function getImportAsGroupButton(importDialog: Locator): Locator { // Why: this test should fail on import-dialog copy drift instead of falling // back to a retired accessible label. return importDialog.getByRole('button', { - name: IMPORT_AS_MONOREPO_BUTTON_NAME, + name: IMPORT_AS_GROUP_BUTTON_NAME, exact: true }) } @@ -177,7 +177,7 @@ test('prioritizes shallow sibling repositories in a bounded nested scan', async .filter({ hasText: 'z-web-client' }) .locator('input[type="checkbox"]') .check() - await getImportAsMonorepoButton(importDialog).click() + await getImportAsGroupButton(importDialog).click() await expect .poll( @@ -257,13 +257,13 @@ test('can stop a nested repo scan and import repositories found so far', async ( name: /Import repositories from folder/i }) await expect(importDialog.getByText(/Scanning\.\.\.\s*Found 1 repository in/)).toBeVisible() - await expect(getImportAsMonorepoButton(importDialog)).toBeDisabled() + await expect(getImportAsGroupButton(importDialog)).toBeDisabled() await importDialog.getByRole('button', { name: /Stop scan/i }).click() await expect(importDialog.getByText('Scan stopped early.')).toBeVisible() await expect(importDialog.getByText(/Found 1 repository in/)).toBeVisible() - await expect(getImportAsMonorepoButton(importDialog)).toBeEnabled() + await expect(getImportAsGroupButton(importDialog)).toBeEnabled() - await getImportAsMonorepoButton(importDialog).click() + await getImportAsGroupButton(importDialog).click() await expect .poll( diff --git a/tests/e2e/folder-setup.spec.ts b/tests/e2e/folder-setup.spec.ts index 107ce1088f2..cbabf8e0bbe 100644 --- a/tests/e2e/folder-setup.spec.ts +++ b/tests/e2e/folder-setup.spec.ts @@ -7,7 +7,7 @@ import { test, expect } from './helpers/orca-app' import { waitForSessionReady } from './helpers/store' import type { ElectronApplication, Locator } from '@stablyai/playwright-test' -const IMPORT_AS_MONOREPO_BUTTON_NAME = 'Yes, import as monorepo' +const IMPORT_AS_GROUP_BUTTON_NAME = 'Yes, import as group' const tempRoots: string[] = [] @@ -95,11 +95,11 @@ async function chooseFolderInNativeDialog( }, folderPath) } -function getImportAsMonorepoButton(importDialog: Locator): Locator { +function getImportAsGroupButton(importDialog: Locator): Locator { // Why: the current accessible action is intentional; accepting retired // labels would hide deterministic dialog copy drift. return importDialog.getByRole('button', { - name: IMPORT_AS_MONOREPO_BUTTON_NAME, + name: IMPORT_AS_GROUP_BUTTON_NAME, exact: true }) } @@ -129,8 +129,8 @@ test.describe('Folder setup', () => { ).toBeVisible() await expect(importDialog.getByText('api-service', { exact: true }).first()).toBeVisible() await expect(importDialog.getByText('web-client', { exact: true }).first()).toBeVisible() - await expect(getImportAsMonorepoButton(importDialog)).toBeEnabled() - await getImportAsMonorepoButton(importDialog).click() + await expect(getImportAsGroupButton(importDialog)).toBeEnabled() + await getImportAsGroupButton(importDialog).click() await expect .poll( @@ -217,7 +217,7 @@ test.describe('Folder setup', () => { .locator('input[type="checkbox"]') .check() } - await getImportAsMonorepoButton(importDialog).click() + await getImportAsGroupButton(importDialog).click() await expect .poll( From 7c9943959e87abd23fc2dc24e194c4ded0bbbc7c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:05:06 -0700 Subject: [PATCH 17/18] Prevent accidental tab drags when clicking (#6210) --- .../tab-group/useTabDragSplit.test.ts | 17 +++++++++++++++++ .../src/components/tab-group/useTabDragSplit.ts | 10 +++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.test.ts b/src/renderer/src/components/tab-group/useTabDragSplit.test.ts index 6a1efe52481..453096115b0 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.test.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.test.ts @@ -10,6 +10,8 @@ import type { TabDragItemData } from './useTabDragSplit' import { canDropTabForPaneColumnSplit, canDropTabIntoPaneBody, + getTabDragActivationDistance, + TAB_DRAG_ACTIVATION_DISTANCE_PX, useTabDragSplit } from './useTabDragSplit' @@ -177,6 +179,21 @@ afterEach(() => { vi.clearAllMocks() }) +describe('tab drag activation distance', () => { + it('uses the named threshold for enabled tab drags', () => { + expect(TAB_DRAG_ACTIVATION_DISTANCE_PX).toBe(12) + expect(getTabDragActivationDistance(true)).toBe(TAB_DRAG_ACTIVATION_DISTANCE_PX) + }) + + it('keeps enabled tab drags above the old overly-sensitive distance', () => { + expect(TAB_DRAG_ACTIVATION_DISTANCE_PX).toBeGreaterThan(5) + }) + + it('uses an impossible activation distance when tab dragging is disabled', () => { + expect(getTabDragActivationDistance(false)).toBe(Number.MAX_SAFE_INTEGER) + }) +}) + describe('canDropTabIntoPaneBody', () => { it('rejects pane-body drops that would split a single tab onto itself', () => { expect( diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.ts b/src/renderer/src/components/tab-group/useTabDragSplit.ts index 83d6b598163..8515942da7f 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.ts @@ -45,6 +45,10 @@ export type { HoveredTabInsertion } export type TabDropZone = 'center' | TabSplitDirection +// Why: tab activation waits for pointerup, so dnd-kit needs enough movement +// tolerance to avoid treating ordinary click jitter as an intentional drag. +export const TAB_DRAG_ACTIVATION_DISTANCE_PX = 12 + export type TabDragItemData = { kind: 'tab' worktreeId: string @@ -145,6 +149,10 @@ export function getTabPaneBodyDroppableId(groupId: string): UniqueIdentifier { return `tab-group-pane-body:${groupId}` } +export function getTabDragActivationDistance(enabled: boolean): number { + return enabled ? TAB_DRAG_ACTIVATION_DISTANCE_PX : Number.MAX_SAFE_INTEGER +} + export function useTabDragSplit({ worktreeId, enabled = true @@ -187,7 +195,7 @@ export function useTabDragSplit({ // the sensors array into a useEffect dependency list — changing its // length between renders violates React's rules of hooks. const pointerSensor = useSensor(PointerSensor, { - activationConstraint: { distance: enabled ? 5 : Number.MAX_SAFE_INTEGER } + activationConstraint: { distance: getTabDragActivationDistance(enabled) } }) const sensors = useSensors(pointerSensor) From 1b6e65c4efe867744432a4e19271890fe8cd83c0 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:24:13 -0700 Subject: [PATCH 18/18] Fix terminal paste cancellation on transient blur (#6232) Fix terminal paste cancellation on transient blur --- .../terminal-paste-target-state.test.ts | 60 +++++++++++ .../terminal-paste-target-state.ts | 32 +++++- tests/e2e/terminal-paste-ownership.spec.ts | 101 +++++++++++++++++- 3 files changed, 187 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-target-state.test.ts b/src/renderer/src/components/terminal-pane/terminal-paste-target-state.test.ts index 64a78e1ed7e..4d35ef7cfbe 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-target-state.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-target-state.test.ts @@ -17,6 +17,21 @@ function makePaneContainer(acceptedElement: Element): Element { } as unknown as Element } +function makePaneContainerFor(acceptedElements: readonly Element[]): Element { + return { + contains: vi.fn((element: Element | null) => acceptedElements.includes(element as Element)) + } as unknown as Element +} + +function makeElement(tagName: string, classNames: string[] = []): Element { + return { + tagName, + classList: { + contains: (className: string) => classNames.includes(className) + } + } as unknown as Element +} + describe('terminal paste target state', () => { it('accepts the same mounted pane, transport, and PTY identity', () => { const transport = makeTransport() @@ -128,6 +143,51 @@ describe('terminal paste target state', () => { ).toBe(false) }) + it('keeps keyboard-owned paste current across transient document blur', () => { + const terminalInput = {} as Element + const body = makeElement('body') + const paneContainer = makePaneContainer(terminalInput) + + expect( + isTerminalPanePasteFocusCurrent({ + requireSameFocusedElement: true, + activeElementAtDispatch: terminalInput, + paneContainer, + activeElement: body + }) + ).toBe(true) + }) + + it('keeps keyboard-owned paste current when xterm replaces its helper textarea', () => { + const originalTerminalInput = makeElement('textarea', ['xterm-helper-textarea']) + const replacementTerminalInput = makeElement('textarea', ['xterm-helper-textarea']) + const paneContainer = makePaneContainerFor([originalTerminalInput, replacementTerminalInput]) + + expect( + isTerminalPanePasteFocusCurrent({ + requireSameFocusedElement: true, + activeElementAtDispatch: originalTerminalInput, + paneContainer, + activeElement: replacementTerminalInput + }) + ).toBe(true) + }) + + it('rejects keyboard-owned paste when focus moves to another control in the pane', () => { + const terminalInput = makeElement('textarea', ['xterm-helper-textarea']) + const searchInput = makeElement('input') + const paneContainer = makePaneContainerFor([terminalInput, searchInput]) + + expect( + isTerminalPanePasteFocusCurrent({ + requireSameFocusedElement: true, + activeElementAtDispatch: terminalInput, + paneContainer, + activeElement: searchInput + }) + ).toBe(false) + }) + it('does not require focus continuity for programmatic terminal paste', () => { const terminalInput = {} as Element const otherInput = {} as Element diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-target-state.ts b/src/renderer/src/components/terminal-pane/terminal-paste-target-state.ts index 42bf8cc07bc..1f42adfaae9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-target-state.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-target-state.ts @@ -54,9 +54,31 @@ export function isTerminalPanePasteFocusCurrent({ if (!requireSameFocusedElement || activeElementAtDispatch === null) { return true } - // Why: clipboard reads are async, so focus may leave the terminal before - // execution. In that case the stale terminal must not receive the payload. - return ( - activeElement === activeElementAtDispatch && paneContainer.contains(activeElementAtDispatch) - ) + if (!paneContainer.contains(activeElementAtDispatch)) { + return false + } + if (activeElement === activeElementAtDispatch) { + return true + } + // Why: macOS dictation and clipboard permission handoffs can transiently + // blur xterm to body, and xterm may replace its helper textarea mid-paste. + if (isInertDocumentFocus(activeElement)) { + return true + } + if (activeElement === paneContainer) { + return true + } + return paneContainer.contains(activeElement) && isXtermHelperTextarea(activeElement) +} + +function isInertDocumentFocus(element: Element | null): boolean { + if (!element) { + return true + } + const tagName = element.tagName?.toUpperCase() + return tagName === 'BODY' || tagName === 'HTML' +} + +function isXtermHelperTextarea(element: Element | null): boolean { + return element?.classList?.contains('xterm-helper-textarea') === true } diff --git a/tests/e2e/terminal-paste-ownership.spec.ts b/tests/e2e/terminal-paste-ownership.spec.ts index 998aa844147..3e95b02f123 100644 --- a/tests/e2e/terminal-paste-ownership.spec.ts +++ b/tests/e2e/terminal-paste-ownership.spec.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto' import { rmSync, writeFileSync } from 'node:fs' import path from 'node:path' -import type { Page } from '@stablyai/playwright-test' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { focusActiveTerminalInput, @@ -112,6 +112,62 @@ async function rightClickActiveTerminalSurface(page: Page): Promise { await page.mouse.click(point.x, point.y, { button: 'right' }) } +async function installClipboardReadTerminalBlurRepro(app: ElectronApplication): Promise { + await app.evaluate(({ BrowserWindow, ipcMain }) => { + type ClipboardReadHandler = (event: unknown, ...args: unknown[]) => Promise | unknown + const global = globalThis as unknown as { + __orcaOriginalClipboardReadTextHandler?: ClipboardReadHandler + } + const invokeHandlers = ( + ipcMain as unknown as { + _invokeHandlers?: Map + } + )._invokeHandlers + const handler = invokeHandlers?.get('clipboard:readText') + if (!invokeHandlers || !handler || global.__orcaOriginalClipboardReadTextHandler) { + return + } + global.__orcaOriginalClipboardReadTextHandler = handler + invokeHandlers.set('clipboard:readText', async (event, ...args) => { + const windows = BrowserWindow.getAllWindows().filter((window) => !window.isDestroyed()) + await Promise.all( + windows.map((window) => + window.webContents + .executeJavaScript( + ` + (document.activeElement && document.activeElement.blur && document.activeElement.blur()); + document.body.tabIndex = -1; + document.body.focus(); + ` + ) + .catch(() => undefined) + ) + ) + // Why: reproduce the focus churn window before the async clipboard read resolves. + await new Promise((resolve) => setTimeout(resolve, 0)) + return global.__orcaOriginalClipboardReadTextHandler!(event, ...args) + }) + }) +} + +async function restoreClipboardReadTerminalBlurRepro(app: ElectronApplication): Promise { + await app.evaluate(({ ipcMain }) => { + type ClipboardReadHandler = (event: unknown, ...args: unknown[]) => Promise | unknown + const global = globalThis as unknown as { + __orcaOriginalClipboardReadTextHandler?: ClipboardReadHandler + } + const invokeHandlers = ( + ipcMain as unknown as { + _invokeHandlers?: Map + } + )._invokeHandlers + if (invokeHandlers && global.__orcaOriginalClipboardReadTextHandler) { + invokeHandlers.set('clipboard:readText', global.__orcaOriginalClipboardReadTextHandler) + delete global.__orcaOriginalClipboardReadTextHandler + } + }) +} + async function openTerminalContextMenu(page: Page): Promise { const isWindows = await page.evaluate(() => navigator.userAgent.includes('Windows')) const isMac = await page.evaluate(() => navigator.userAgent.includes('Mac')) @@ -171,6 +227,49 @@ test.describe('terminal paste ownership', () => { } }) + test('keyboard paste survives transient terminal blur during clipboard read', async ({ + electronApp, + orcaPage, + testRepoPath + }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await installTerminalPtyWriteSpy(electronApp) + + const ptyId = await waitForActivePanePtyId(orcaPage) + const runId = randomUUID() + const scriptPath = path.join(testRepoPath, `.orca-paste-blur-${runId}.mjs`) + writeFileSync(scriptPath, pasteEchoScript(runId)) + let scriptStarted = false + + try { + await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`) + scriptStarted = true + await waitForTerminalOutput(orcaPage, `PASTE_READY_${runId}`, 10_000) + + const payload = `ORCA_E2E_TRANSIENT_BLUR_PASTE_${runId}` + const encodedPayload = Buffer.from(payload, 'utf8').toString('base64') + await clearTerminalPtyWriteLog(electronApp) + await orcaPage.evaluate((text) => window.api.ui.writeClipboardText(text), payload) + await focusActiveTerminalInput(orcaPage) + await installClipboardReadTerminalBlurRepro(electronApp) + + await orcaPage.keyboard.press(keyboardPasteChords()[0]) + await waitForTerminalOutput(orcaPage, encodedPayload, 10_000, 12_000) + + const writes = (await readTerminalPtyWrites(electronApp)).join('') + expect(countOccurrences(writes, payload), 'transient blur PTY write count').toBe(1) + } finally { + await restoreClipboardReadTerminalBlurRepro(electronApp).catch(() => undefined) + if (scriptStarted) { + await sendToTerminal(orcaPage, ptyId, '\x03').catch(() => undefined) + } + rmSync(scriptPath, { force: true }) + } + }) + test('terminal context-menu Paste sends clipboard text exactly once', async ({ electronApp, orcaPage,