Find projects and repo groups from Cmd+J (#6193)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-23 14:55:59 -07:00 committed by GitHub
parent 0880021665
commit cf39bd7abe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1156 additions and 49 deletions

View File

@ -2,7 +2,16 @@
import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { FileText, Globe, Plus, Server, ServerOff, Smartphone, SquareTerminal } from 'lucide-react'
import {
FileText,
FolderTree,
Globe,
Plus,
Server,
ServerOff,
Smartphone,
SquareTerminal
} from 'lucide-react'
import { useAppStore } from '@/store'
import { getRepoMapFromState, useAllWorktrees } from '@/store/selectors'
import {
@ -77,6 +86,12 @@ import {
type CmdJActionResult,
type CmdJSettingsResult
} from '@/components/cmd-j/palette-results'
import { buildImportedWorktreesCardCandidates } from '@/components/sidebar/imported-worktrees-card-candidates'
import {
hasCmdJProjectSearchCandidates,
searchCmdJProjectResults,
type CmdJProjectSearchResult
} from '@/components/cmd-j/palette-project-results'
import {
buildCmdJQuickActionContext,
captureCmdJActiveGroupSnapshot,
@ -139,6 +154,12 @@ type QuickActionPaletteItem = {
result: CmdJActionResult
}
type ProjectTargetPaletteItem = {
id: string
type: 'project-target'
result: CmdJProjectSearchResult
}
type SectionHeader = {
id: string
type: 'section-header'
@ -160,6 +181,7 @@ type CreateWorktreePaletteItem = {
// Keep future quick actions curated; route one-time setup flows through Settings.
type PaletteItem =
| WorktreePaletteItem
| ProjectTargetPaletteItem
| SettingsPaletteItem
| QuickActionPaletteItem
| BrowserPaletteItem
@ -305,9 +327,15 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
const revealSidebarRow = useAppStore((s) => s.revealSidebarRow)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const allWorktrees = useAllWorktrees()
const repos = useAppStore((s) => s.repos)
const projectGroups = useAppStore((s) => s.projectGroups)
const projects = useAppStore((s) => s.projects)
const projectHostSetups = useAppStore((s) => s.projectHostSetups)
const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo)
const pendingWorktreeCreations = useAppStore((s) => s.pendingWorktreeCreations)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
// Why: getWorktreeStatus needs per-pane titles so split-pane tabs with a
// working agent in a non-focused pane still surface as 'working' in the
@ -740,6 +768,68 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
[settingsSections]
)
const actionResults = useMemo(() => buildCmdJActionResults(getCmdJQuickActions()), [])
// Why: Cmd+J should only offer project jumps the sidebar can actually reveal;
// archived-only repos are intentionally left out of this navigation surface.
const renderableProjectRepoIds = useMemo(() => {
const ids = new Set<string>()
for (const worktree of allWorktrees) {
if (!worktree.isArchived) {
ids.add(worktree.repoId)
}
}
for (const repo of repos) {
if ((worktreesByRepo[repo.id]?.length ?? 0) === 0) {
ids.add(repo.id)
}
}
for (const repoId of buildImportedWorktreesCardCandidates({
repos,
detectedWorktreesByRepo
}).keys()) {
ids.add(repoId)
}
for (const creation of Object.values(pendingWorktreeCreations)) {
ids.add(creation.request.repoId)
}
return ids
}, [allWorktrees, detectedWorktreesByRepo, pendingWorktreeCreations, repos, worktreesByRepo])
const hasAnyProjectSearchCandidates = useMemo(
() =>
hasCmdJProjectSearchCandidates({
projectGroups,
repos,
projects,
projectHostSetups,
renderableRepoIds: renderableProjectRepoIds
}),
[projectGroups, projectHostSetups, projects, renderableProjectRepoIds, repos]
)
const projectTargetItems = useMemo<ProjectTargetPaletteItem[]>(
() =>
hasQuery
? searchCmdJProjectResults({
query: deferredQuery,
projectGroups,
repos,
projects,
projectHostSetups,
renderableRepoIds: renderableProjectRepoIds
}).map((result) => ({
id: result.id,
type: 'project-target' as const,
result
}))
: [],
[
deferredQuery,
hasQuery,
projectGroups,
projectHostSetups,
projects,
renderableProjectRepoIds,
repos
]
)
const prefetchCreateWorkspaceBaseForComposer = useCallback((initialRepoId?: string): void => {
const state = useAppStore.getState()
@ -830,6 +920,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
// viewport naturally.
const worktreeCap = !hasQuery && openTabItems.length > 0 ? EMPTY_QUERY_WORKTREE_CAP : Infinity
const visibleWorktreeItems = hasQuery ? worktreeItems : worktreeItems.slice(0, worktreeCap)
const visibleProjectTargetItems = hasQuery ? projectTargetItems : []
const visibleMiddleItems = hasQuery ? middleItems : []
const visibleOpenTabItems = hasQuery
? openTabItems
@ -838,15 +929,17 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
return {
visibleWorktreeItems,
visibleProjectTargetItems,
visibleMiddleItems,
visibleOpenTabItems,
showWorktreeHint
}
}, [worktreeItems, middleItems, openTabItems, hasQuery])
}, [worktreeItems, projectTargetItems, middleItems, openTabItems, hasQuery])
const selectableItems = useMemo<PaletteItem[]>(
() => [
...paletteSections.visibleWorktreeItems,
...paletteSections.visibleProjectTargetItems,
...paletteSections.visibleMiddleItems,
...paletteSections.visibleOpenTabItems
],
@ -864,11 +957,17 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const listEntries = useMemo<PaletteListEntry[]>(() => {
const entries: PaletteListEntry[] = []
const { visibleWorktreeItems, visibleMiddleItems, visibleOpenTabItems, showWorktreeHint } =
paletteSections
const {
visibleWorktreeItems,
visibleProjectTargetItems,
visibleMiddleItems,
visibleOpenTabItems,
showWorktreeHint
} = paletteSections
const visibleWorkspaceItemCount = visibleWorktreeItems.length + (showCreateAction ? 1 : 0)
const populatedSectionCount = [
visibleWorkspaceItemCount,
visibleProjectTargetItems.length,
visibleMiddleItems.length,
visibleOpenTabItems.length
].filter((count) => count > 0).length
@ -883,6 +982,8 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const showOpenTabsHeader = hasQuery
? visibleOpenTabItems.length > 0 && populatedSectionCount > 1
: visibleOpenTabItems.length > 0
const showProjectTargetHeader =
hasQuery && visibleProjectTargetItems.length > 0 && populatedSectionCount > 1
const showMiddleHeader = hasQuery && visibleMiddleItems.length > 0 && populatedSectionCount > 1
if (visibleWorkspaceItemCount > 0) {
@ -899,11 +1000,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
})
}
appendPaletteListEntries(entries, visibleWorktreeItems)
if (showCreateAction) {
// Why: the typed create affordance is workspace-scoped, so keep it
// directly under workspace matches instead of after actions/tabs.
entries.push({ id: CREATE_WORKTREE_ITEM_ID, type: 'create-worktree' })
}
if (showWorktreeHint) {
entries.push({
id: '__hint_worktree_cap__',
@ -916,6 +1012,24 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
})
}
}
if (visibleProjectTargetItems.length > 0) {
if (showProjectTargetHeader) {
entries.push({
id: '__header_projects_groups__',
type: 'section-header',
label: translate(
'auto.components.WorktreeJumpPalette.projectsGroupsHeader',
'Projects & Groups'
)
})
}
appendPaletteListEntries(entries, visibleProjectTargetItems)
}
if (showCreateAction) {
// Why: project/group jump targets are navigation results; keep them
// directly after worktree matches before the creation fallback.
entries.push({ id: CREATE_WORKTREE_ITEM_ID, type: 'create-worktree' })
}
if (visibleMiddleItems.length > 0) {
if (showMiddleHeader) {
entries.push({
@ -1260,10 +1374,41 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
[buildQuickActionContext, closeModal, recordFeatureInteraction]
)
const handleSelectProjectTarget = useCallback(
(result: CmdJProjectSearchResult) => {
skipRestoreFocusRef.current = true
// Why: selecting a project or repo group is a sidebar navigation action;
// it should reveal the grouping row without activating an arbitrary workspace.
revealSidebarRow(result.rowKey, { behavior: 'smooth', highlight: true })
recordFeatureInteraction('cmd-j')
closeModal()
setSelectedItemId('')
if (previousActiveTabTypeRef.current === 'browser' && previousBrowserPageIdRef.current) {
requestBrowserFocus({
pageId: previousBrowserPageIdRef.current,
target: previousBrowserFocusTargetRef.current
})
return
}
if (previousWorktreeIdRef.current) {
focusFallbackSurface()
}
},
[
closeModal,
focusFallbackSurface,
recordFeatureInteraction,
requestBrowserFocus,
revealSidebarRow
]
)
const handleSelectItem = useCallback(
(item: PaletteItem) => {
if (item.type === 'worktree') {
handleSelectWorktree(item.worktree.id)
} else if (item.type === 'project-target') {
handleSelectProjectTarget(item.result)
} else if (item.type === 'browser-page') {
handleSelectBrowserPage(item.result)
} else if (item.type === 'simulator-tab') {
@ -1278,6 +1423,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
},
[
handleSelectBrowserPage,
handleSelectProjectTarget,
handleSelectQuickAction,
handleSelectSettings,
handleSelectSimulatorTab,
@ -1495,7 +1641,13 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const resultCount = selectableItems.length
const emptyState = (() => {
if ((hasAnySearchableWorktrees || hasAnyMiddleResults || hasAnyOpenTabs) && hasQuery) {
if (
(hasAnySearchableWorktrees ||
hasAnyProjectSearchCandidates ||
hasAnyMiddleResults ||
hasAnyOpenTabs) &&
hasQuery
) {
return {
title: translate(
'auto.components.WorktreeJumpPalette.dbd9d87eec',
@ -1503,7 +1655,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
),
subtitle: translate(
'auto.components.WorktreeJumpPalette.c4afa68159',
'Try a worktree, setting, action, tab title, agent prompt, URL, PR, or port.'
'Try a worktree, project, setting, action, tab title, agent prompt, URL, PR, or port.'
)
}
}
@ -1698,7 +1850,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
)}
</span>
)}
<span className="truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground">
<span className="truncate text-[14px] font-semibold text-foreground">
{entry.match.displayNameRange ? (
<HighlightedText
text={worktree.displayName}
@ -1776,6 +1928,53 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
)
}
if (entry.type === 'project-target') {
const result = entry.result
const isProject = result.kind === 'project'
const hostBadge = isProject ? getPaletteHostBadge(result.repo, hostOptions) : null
const badgeLabel = isProject
? translate('auto.components.WorktreeJumpPalette.projectBadge', 'Project')
: translate('auto.components.WorktreeJumpPalette.repoGroupBadge', 'Repo group')
return (
<CommandItem
key={entry.id}
value={entry.id}
onSelect={() => handleSelectItem(entry)}
className={cn(
'group mx-0.5 flex cursor-pointer items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow]',
'data-[selected=true]:border-border data-[selected=true]:bg-accent data-[selected=true]:text-foreground'
)}
>
<div className="flex w-4 shrink-0 items-center justify-center self-start pt-0.5 text-muted-foreground/85">
<FolderTree className="size-3.5" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2.5">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-[14px] font-semibold text-foreground">
{result.title}
</span>
<span className="shrink-0 rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
{badgeLabel}
</span>
</div>
</div>
{isProject ? (
<div className="flex shrink-0 items-center gap-1.5">
<PaletteHostBadgeChip badge={hostBadge} />
<span className="inline-flex max-w-[180px] items-center gap-1.5 rounded-md border border-border bg-muted px-2 py-1 text-[11px] font-semibold leading-none text-foreground">
<RepoBadgeMark color={result.repo.badgeColor} />
<span className="truncate">{result.repo.displayName}</span>
</span>
</div>
) : null}
</div>
</div>
</CommandItem>
)
}
if (entry.type === 'settings' || entry.type === 'quick-action') {
const result = entry.result
const Icon = result.icon

View File

@ -0,0 +1,262 @@
import { isCmdJPaletteQueryTooLarge } from './palette-results'
import type { Project, ProjectGroup, ProjectHostSetup, Repo } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
import {
getProjectGroupHeaderKey,
getProjectHeaderRevealTarget,
type ProjectGroupingModel
} from '../sidebar/worktree-list-groups'
export type CmdJProjectGroupResult = {
id: string
kind: 'project-group'
title: string
description: string
rowKey: string
order: number
keywords: string[]
}
export type CmdJProjectResult = {
id: string
kind: 'project'
title: string
description: string
rowKey: string
repo: Repo
order: number
keywords: string[]
}
export type CmdJProjectSearchResult = CmdJProjectGroupResult | CmdJProjectResult
type RankedProjectResult = {
result: CmdJProjectSearchResult
rule: number
score: number
}
const PROJECT_GROUP_ALIASES = ['group', 'repo group']
const PROJECT_ALIASES = ['project', 'repo']
function normalizeQuery(value: string): string {
let normalized = ''
let pendingWhitespace = false
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (isCmdJPaletteWhitespace(code)) {
pendingWhitespace = normalized.length > 0
continue
}
if (pendingWhitespace) {
normalized += ' '
pendingWhitespace = false
}
normalized += value.charAt(index).toLowerCase()
}
return normalized
}
function isCmdJPaletteWhitespace(code: number): boolean {
return (
code === 32 ||
(code >= 9 && code <= 13) ||
code === 160 ||
code === 5760 ||
(code >= 8192 && code <= 8202) ||
code === 8232 ||
code === 8233 ||
code === 8239 ||
code === 8287 ||
code === 12288 ||
code === 65279
)
}
function uniqueNormalized(values: readonly string[]): string[] {
return [...new Set(values.map(normalizeQuery).filter(Boolean))]
}
function tokenize(value: string): string[] {
return normalizeQuery(value)
.split(/[^a-z0-9]+/)
.filter(Boolean)
}
function tokenScore(query: string, values: readonly string[]): number {
const candidateTokens = values.flatMap(tokenize)
if (candidateTokens.length === 0) {
return 0
}
let score = 0
for (const queryToken of tokenize(query)) {
let best = 0
for (const candidateToken of candidateTokens) {
if (candidateToken === queryToken) {
best = Math.max(best, 3)
} else if (candidateToken.startsWith(queryToken)) {
best = Math.max(best, 2)
} else if (candidateToken.includes(queryToken)) {
best = Math.max(best, 1)
}
}
score += best
}
return score
}
function buildCmdJProjectSearchCandidates({
projectGroups,
repos,
projects,
projectHostSetups,
renderableRepoIds
}: {
projectGroups: readonly ProjectGroup[]
repos: readonly Repo[]
projects: readonly Project[]
projectHostSetups: readonly ProjectHostSetup[]
renderableRepoIds?: ReadonlySet<string>
}): CmdJProjectSearchResult[] {
const projectGrouping: ProjectGroupingModel = { projects, projectHostSetups }
const repoMap = new Map(repos.map((repo) => [repo.id, repo]))
const candidates: CmdJProjectSearchResult[] = []
projectGroups.forEach((group, order) => {
candidates.push({
id: `project-group:${group.id}`,
kind: 'project-group',
title: group.name,
description: translate(
'auto.components.cmd.j.palette.project.results.repoGroup',
'Repo group'
),
rowKey: getProjectGroupHeaderKey(group.id),
order,
keywords: uniqueNormalized([group.name, ...PROJECT_GROUP_ALIASES])
})
})
const seenRowKeys = new Set<string>()
repos.forEach((repo, repoIndex) => {
if (renderableRepoIds && !renderableRepoIds.has(repo.id)) {
return
}
const target = getProjectHeaderRevealTarget(repo.id, repoMap, projectGrouping)
if (!target.repo || seenRowKeys.has(target.key)) {
return
}
seenRowKeys.add(target.key)
candidates.push({
id: `project:${target.key}`,
kind: 'project',
title: target.label,
description: translate('auto.components.cmd.j.palette.project.results.project', 'Project'),
rowKey: target.key,
repo: target.repo,
order: projectGroups.length + repoIndex,
keywords: uniqueNormalized([target.label, repo.displayName, ...PROJECT_ALIASES])
})
})
return candidates
}
export function hasCmdJProjectSearchCandidates({
projectGroups,
repos,
projects,
projectHostSetups,
renderableRepoIds
}: {
projectGroups: readonly ProjectGroup[]
repos: readonly Repo[]
projects: readonly Project[]
projectHostSetups: readonly ProjectHostSetup[]
renderableRepoIds?: ReadonlySet<string>
}): boolean {
return (
buildCmdJProjectSearchCandidates({
projectGroups,
repos,
projects,
projectHostSetups,
renderableRepoIds
}).length > 0
)
}
function projectRankingForCandidate(
query: string,
candidate: CmdJProjectSearchResult
): RankedProjectResult | null {
const title = normalizeQuery(candidate.title)
if (query === title) {
return { result: candidate, rule: 1, score: 0 }
}
if (title.startsWith(query)) {
return { result: candidate, rule: 2, score: 0 }
}
const aliasKeywords = candidate.kind === 'project-group' ? PROJECT_GROUP_ALIASES : PROJECT_ALIASES
if (aliasKeywords.map(normalizeQuery).includes(query)) {
return { result: candidate, rule: 3, score: 0 }
}
if (candidate.keywords.some((keyword) => keyword.startsWith(query))) {
return { result: candidate, rule: 4, score: 0 }
}
const score = tokenScore(query, [candidate.title, ...candidate.keywords])
return score > 0 ? { result: candidate, rule: 5, score } : null
}
function compareProjectRanked(a: RankedProjectResult, b: RankedProjectResult): number {
if (a.rule !== b.rule) {
return a.rule - b.rule
}
if (a.score !== b.score) {
return b.score - a.score
}
if (a.result.order !== b.result.order) {
return a.result.order - b.result.order
}
return a.result.id.localeCompare(b.result.id)
}
export function searchCmdJProjectResults({
query,
projectGroups,
repos,
projects,
projectHostSetups,
renderableRepoIds
}: {
query: string
projectGroups: readonly ProjectGroup[]
repos: readonly Repo[]
projects: readonly Project[]
projectHostSetups: readonly ProjectHostSetup[]
renderableRepoIds?: ReadonlySet<string>
}): CmdJProjectSearchResult[] {
// Why: oversized pasted input should not force the palette to scan project,
// repo, or group names that may include private workspace details.
if (isCmdJPaletteQueryTooLarge(query)) {
return []
}
const normalizedQuery = normalizeQuery(query)
// Why: project/group rows sit after worktree matches, so one-character
// searches would add broad noisy navigation targets before intent is clear.
if (normalizedQuery.length < 2) {
return []
}
return buildCmdJProjectSearchCandidates({
projectGroups,
repos,
projects,
projectHostSetups,
renderableRepoIds
})
.map((candidate) => projectRankingForCandidate(normalizedQuery, candidate))
.filter((entry): entry is RankedProjectResult => entry !== null)
.sort(compareProjectRanked)
.map((entry) => entry.result)
}

View File

@ -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([])
})
})

View File

@ -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<string, Repo>,
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<string>()
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<string>()
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<string, Repo>
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<string>()
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<string | null>(
null
)
const [highlightedRevealRowKey, setHighlightedRevealRowKey] = useState<string | null>(null)
const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId)
const assignWorktreeParent = useAppStore((s) => s.assignWorktreeParent)
const worktreeDragSessionRef = useRef<WorktreeSidebarDragSession | null>(null)
@ -1156,6 +1283,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
const worktreeNativeAutoscrollLastFrameTimeRef = useRef<number | null>(null)
const worktreeNativeLatestPointRef = useRef<WorktreeSidebarDragPoint | null>(null)
const pendingRevealRetryRef = useRef<{ worktreeId: string; count: number } | null>(null)
const pendingRowRevealRetryRef = useRef<{ rowKey: string; count: number } | null>(null)
const pendingRevealFrameIdsRef = useRef<Set<number>>(new Set())
const revealHighlightFrameIdRef = useRef<number | null>(null)
const revealHighlightTimeoutRef = useRef<number | null>(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
}
>
<div
id={getWorktreeOptionId(row.key)}
role="button"
tabIndex={0}
aria-expanded={showHeaderCollapseAffordance ? !isHeaderCollapsed : undefined}
@ -3754,6 +4010,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
className={cn(
'group relative flex h-7 w-full items-center gap-1.5 pr-2 text-left transition-all',
'cursor-pointer',
highlightedRevealRowKey === row.key &&
'rounded-md bg-worktree-sidebar-accent ring-1 ring-worktree-sidebar-ring/50',
isDraggingThis &&
'bg-accent/80 ring-1 ring-ring/40 shadow-md rounded-md scale-[1.01]',
headerWorkspaceStatus &&
@ -4308,7 +4566,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
'pointer-events-none opacity-0'
)}
data-scroll-reveal-highlight={
highlightedRevealWorktreeId === itemRow.worktree.id ? 'true' : undefined
highlightedRevealRowKey === itemRow.rowKey ? 'true' : undefined
}
// Why: nested child cards live inside the parent's clickable
// card body; bubbling would activate/edit the parent too.
@ -4337,7 +4595,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
isActiveWorktree && !forceActiveSurface ? activeSurfaceVariant : 'primary'
}
isMultiSelected={selectedWorktreeIds.has(itemRow.worktree.id)}
revealHighlight={highlightedRevealWorktreeId === itemRow.worktree.id}
revealHighlight={highlightedRevealRowKey === itemRow.rowKey}
revealHighlightTone={revealHighlightTone}
selectedWorktrees={selectedWorktrees}
nativeDragEnabled={false}
@ -4657,6 +4915,7 @@ const WorktreeList = React.memo(function WorktreeList({
const activeWorktreeId = useAppStore((s) => 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<string>()
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}

View File

@ -98,6 +98,7 @@ function filterState(overrides: Partial<FilterState> = {}): 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(

View File

@ -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)
}
}

View File

@ -145,14 +145,24 @@ type ProjectGroupingIndex = {
multiSetupProjectHostKeys: Set<string>
}
const projectGroupingIndexCache = new WeakMap<ProjectGroupingModel, ProjectGroupingIndex | null>()
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<string, number>()
@ -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<string, Repo>,
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<string, Repo>,
projectGrouping?: ProjectGroupingModel
): ProjectHeaderRevealTarget {
return getProjectGroupingForRepo(repoId, repoMap, buildProjectGroupingIndex(projectGrouping))
}
function addRepoIdToGroup(group: WorktreeGroupEntry, repoId: string): void {
group.repoIds.add(repoId)
}

View File

@ -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'

View File

@ -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"
}
}
}
}
},

View File

@ -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"
}
}
}
}
},

View File

@ -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"
}
}
}
}
},

View File

@ -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"
}
}
}
}
},

View File

@ -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"
}
}
}
}
},

View File

@ -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
})
}

View File

@ -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<AppState, [], [], UISlice> = (set, get)
}),
pendingRevealWorktree: null,
pendingRevealSidebarRow: null,
revealWorktreeInSidebar: (worktreeId, options) =>
set({
pendingRevealWorktree: {
@ -2154,7 +2170,16 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (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,