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] 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 55adcae85..f43cafa13 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 000000000..e4b26441b --- /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 d6e025764..1d034b1a5 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 a46ed4c2d..1236d6dad 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 81a78bec1..25165df25 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 785fc2a39..434df0ee0 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 2b5a89f18..91f9aa5c8 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 ab9d03610..b200abb23 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 86a665eaf..885882707 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 f4592d35d..5cfaa362c 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 205a953ec..14cbf557e 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 60f7ac0eb..4478b7fa7 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 0c5d2d3c9..0e6c0214a 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 fbca39dc2..6f077da3b 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 ceb59dd75..5e0c78c29 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,